Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17dc350a6b | ||
|
|
64686e6ec5 | ||
|
|
129178bf09 | ||
|
|
8ff79323d8 | ||
|
|
3e163d29bd | ||
|
|
9859e1f7df | ||
|
|
7168845dc3 | ||
|
|
cc1765dce2 | ||
|
|
93f8da55cb | ||
|
|
d3d989c4c2 | ||
|
|
8cfc9e4e27 | ||
|
|
664b131c30 | ||
|
|
d010220588 | ||
|
|
59a735b8f3 | ||
|
|
69b97a97f7 | ||
|
|
b8a2b9b93e | ||
|
|
58e3d41582 | ||
|
|
a2172547a8 | ||
|
|
cdc123ec2f | ||
|
|
8b6d34ad85 | ||
|
|
12ad13ddca | ||
|
|
f957fe3760 | ||
|
|
de5ece9944 | ||
|
|
3d84689714 | ||
|
|
f7a6c7a6e5 | ||
|
|
8d4f7a0002 | ||
|
|
f43ff5b4bb | ||
|
|
8ceada6e30 | ||
|
|
be96f22026 | ||
|
|
2f9d88caee | ||
|
|
030822b68d | ||
|
|
a8e6c0f853 | ||
|
|
0721d2ea80 | ||
|
|
61e7db2820 | ||
|
|
26f1f6a76b | ||
|
|
c1b0f6f3c1 | ||
|
|
c3602f7f05 | ||
|
|
58ffc10c17 | ||
|
|
a01499136d | ||
|
|
7cbdcf1ba6 | ||
|
|
ca16a8e07d | ||
|
|
e59dcf46f1 | ||
|
|
e1367b44bc | ||
|
|
8e01309917 | ||
|
|
b0358cf3c8 | ||
|
|
527c68baa4 | ||
|
|
994a405f2d | ||
|
|
4ab4894f44 | ||
|
|
c83a196402 | ||
|
|
5bb933eedf | ||
|
|
f54e8706f7 | ||
|
|
40351d0923 | ||
|
|
57f8ba3b19 | ||
|
|
8df8f86784 | ||
|
|
5f4c952ab0 | ||
|
|
62bfba521d | ||
|
|
143942d497 | ||
|
|
6096f73ce8 | ||
|
|
5fccc9aae9 | ||
|
|
328e4f5a1e | ||
|
|
dc861964fc | ||
|
|
46fb10203b | ||
|
|
d21165c2f0 | ||
|
|
7d28e84e72 | ||
|
|
507d479c8c | ||
|
|
3dd9d5e692 | ||
|
|
936b407a72 | ||
|
|
390b03c455 | ||
|
|
b3ff5fc5b1 | ||
|
|
125fac7943 | ||
|
|
c428e725aa | ||
|
|
b0b7f15598 | ||
|
|
9881328651 | ||
|
|
a57bab1c84 | ||
|
|
f320f3e5d7 | ||
|
|
ff6d86ed0f | ||
|
|
f31c10a0da | ||
|
|
2c159f00e4 | ||
|
|
0bbf679e4c | ||
|
|
b416907538 | ||
|
|
38863322a4 | ||
|
|
094c883bc0 | ||
|
|
f50e1e9c51 | ||
|
|
286ec6afb3 | ||
|
|
7f9cab15d8 | ||
|
|
8ed766025b | ||
|
|
49497bcddb | ||
|
|
69e15d630e | ||
|
|
2168df37b8 | ||
|
|
3f08201bac | ||
|
|
406d7a67f0 | ||
|
|
2ebeede006 | ||
|
|
88a9557385 | ||
|
|
90d6296808 | ||
|
|
533e541237 | ||
|
|
d42b295792 | ||
|
|
91693f9d4c | ||
|
|
4fb1796331 | ||
|
|
d8cd7ac2e7 | ||
|
|
a4795da3cc | ||
|
|
c7b9dfa961 | ||
|
|
4f726ed467 | ||
|
|
f279f7fcf1 | ||
|
|
0c07a19752 | ||
|
|
b8939e8316 | ||
|
|
141d5db922 | ||
|
|
7337a9d997 | ||
|
|
700c8bfc5c | ||
|
|
79896788f3 | ||
|
|
1593bb82ac | ||
|
|
dcd3e917e8 | ||
|
|
2a8d4879f2 | ||
|
|
0a53663ef8 | ||
|
|
5243fcafa1 | ||
|
|
f8aef2e4e0 | ||
|
|
f36c748c85 | ||
|
|
02f5ced766 | ||
|
|
a83ec95c76 | ||
|
|
5f3f1948b7 | ||
|
|
ef8936d599 | ||
|
|
ad8c06047d | ||
|
|
702f5f10ad | ||
|
|
56b2b5a331 | ||
|
|
4db2f78ec7 | ||
|
|
66747f154c | ||
|
|
87aaf87748 | ||
|
|
3f9944bad9 | ||
|
|
7d96e602a9 | ||
|
|
9f5e568812 | ||
|
|
4baf2ed8ad | ||
|
|
dbf4f69b72 | ||
|
|
067cb9e033 | ||
|
|
5c4d358a7e | ||
|
|
6d76f5ca51 | ||
|
|
f91d5b2d13 | ||
|
|
e80e036086 | ||
|
|
305a3c7424 | ||
|
|
30f6fc81e2 | ||
|
|
bf21ba08e3 | ||
|
|
d63a1c4ccb | ||
|
|
b162371f50 | ||
|
|
efa002dd5d | ||
|
|
8b96fc57ed | ||
|
|
35cdc63ca0 | ||
|
|
a0a24ba215 | ||
|
|
957ea640de | ||
|
|
6326b30c93 | ||
|
|
8a21df18ac | ||
|
|
2c1d585002 | ||
|
|
da3c506db5 | ||
|
|
6d17b2a593 | ||
|
|
2a2474512b | ||
|
|
e5b83633ec | ||
|
|
43787dab14 | ||
|
|
e4b2b77852 | ||
|
|
066b53b282 | ||
|
|
c4acc4d2c5 | ||
|
|
dd3bd70f35 | ||
|
|
9c274db89f | ||
|
|
d0c4a82da9 | ||
|
|
960d339f86 | ||
|
|
f9a8ecc0ce |
@@ -5,24 +5,32 @@ description: >-
|
||||
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
|
||||
and are scoped to the App's installation permissions, not a user account.
|
||||
|
||||
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
|
||||
this happens on fork PRs where repo secrets are unavailable. The fallback
|
||||
ensures classification, timings, and review comments still work on
|
||||
forks (with the lower GITHUB_TOKEN rate limit).
|
||||
Callers must source App credentials from a protected, main-only environment.
|
||||
Never pass an App private key to a pull_request job, a local action, or a
|
||||
reusable workflow resolved from an untrusted PR ref. The fallback keeps a
|
||||
trusted caller functional when its protected environment is misconfigured.
|
||||
|
||||
Composite actions cannot access the secrets context directly, so the
|
||||
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
|
||||
as inputs. When both are empty (fork PRs), the fallback fires.
|
||||
Composite actions cannot access contexts directly, so callers pass the
|
||||
public vars.APP_CLIENT_ID and protected secrets.APP_PRIVATE_KEY as inputs.
|
||||
When the private key is empty, the fallback fires.
|
||||
|
||||
inputs:
|
||||
client-id:
|
||||
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
|
||||
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
private-key:
|
||||
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
owner:
|
||||
description: GitHub App installation owner. Empty scopes the token to the current repository.
|
||||
required: false
|
||||
default: ''
|
||||
repositories:
|
||||
description: Comma- or newline-separated repositories to scope within the installation owner.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
token:
|
||||
@@ -51,6 +59,8 @@ runs:
|
||||
with:
|
||||
client-id: ${{ inputs.client-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
owner: ${{ inputs.owner }}
|
||||
repositories: ${{ inputs.repositories }}
|
||||
|
||||
- name: Fall back to GITHUB_TOKEN
|
||||
id: fallback
|
||||
|
||||
+46
-50
@@ -9,6 +9,10 @@ name: CI
|
||||
# definitions, matrices, and concurrency settings. They no longer have
|
||||
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
|
||||
# through this file.
|
||||
#
|
||||
# SECURITY: this workflow runs PR-controlled actions, workflows, and code.
|
||||
# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted
|
||||
# main-only automation uses protected environments in its own workflows.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -50,19 +54,11 @@ jobs:
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
@@ -75,7 +71,6 @@ jobs:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
slice_count: 8
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Python lints
|
||||
@@ -84,14 +79,12 @@ jobs:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
secrets: inherit
|
||||
|
||||
js-tests:
|
||||
name: JS & TS checks
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/js-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-desktop:
|
||||
name: Desktop E2E
|
||||
@@ -104,48 +97,44 @@ jobs:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.site == 'true'
|
||||
uses: ./.github/workflows/docs-site-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
history-check:
|
||||
name: Deny unrelated histories
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/history-check.yml
|
||||
secrets: inherit
|
||||
|
||||
contributor-check:
|
||||
name: Check contributors
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/contributor-check.yml
|
||||
secrets: inherit
|
||||
|
||||
uv-lockfile:
|
||||
name: Check uv.lock
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
secrets: inherit
|
||||
|
||||
lockfile-diff:
|
||||
name: package-lock.json diff
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
|
||||
uses: ./.github/workflows/lockfile-diff.yml
|
||||
secrets: inherit
|
||||
|
||||
docker-lint:
|
||||
name: Lint Docker scripts
|
||||
needs: detect
|
||||
if: needs.detect.outputs.docker_meta == 'true'
|
||||
uses: ./.github/workflows/docker-lint.yml
|
||||
secrets: inherit
|
||||
|
||||
docker:
|
||||
name: Build&Test Docker image
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
|
||||
# Trusted main pushes run docker.yml directly so its container-publish
|
||||
# environment secrets never cross this reusable-workflow call. PR runs
|
||||
# remain build/test-only and secret-free.
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true')
|
||||
uses: ./.github/workflows/docker.yml
|
||||
secrets: inherit
|
||||
|
||||
supply-chain:
|
||||
name: Supply-chain scan
|
||||
@@ -167,12 +156,10 @@ jobs:
|
||||
ci_review_files: ${{ needs.detect.outputs.ci_review_files }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
osv-scanner:
|
||||
name: OSV scan
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
secrets: inherit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Live-updating PR review comment.
|
||||
@@ -182,9 +169,8 @@ jobs:
|
||||
# whatever results are available, and upserts it via the
|
||||
# ``<!-- hermes-ci-review-bot -->`` marker.
|
||||
#
|
||||
# The poller exits when all non-infra jobs are completed (or on
|
||||
# timeout). ci-timings' review_status is picked up automatically when
|
||||
# its artifact becomes available — the poller downloads and merges it.
|
||||
# When the visible job set goes quiet, the poller waits 10 seconds and polls
|
||||
# once more so downstream jobs created by an aggregate gate get included.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
comment-live:
|
||||
name: CI review comment (live)
|
||||
@@ -195,6 +181,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run live comment poller
|
||||
env:
|
||||
@@ -315,8 +304,8 @@ jobs:
|
||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||
#
|
||||
# The live comment poller picks up ci-timings' completion automatically —
|
||||
# it reads review-status.json from the artifact when the job finishes.
|
||||
# The live comment poller can read the standalone review-status artifact
|
||||
# after the HTML report is uploaded, so its link points straight at that report.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-timings:
|
||||
name: CI timing report
|
||||
@@ -328,13 +317,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Restore baseline cache (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -348,38 +330,52 @@ jobs:
|
||||
|
||||
- name: Collect timings and generate report
|
||||
env:
|
||||
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
|
||||
# the built-in read-only token so the timings API read still works
|
||||
# there instead of hard-failing this advisory job on every fork PR.
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--output ci-timings-report.html \
|
||||
--json-out ci-timings.json \
|
||||
--summary-out ci-timings-summary.md \
|
||||
--review-status-out review-status.json
|
||||
--summary-out ci-timings-summary.md
|
||||
|
||||
- name: Upload HTML report + review status
|
||||
- name: Upload HTML report
|
||||
# Advisory report — artifact-service blips must not fail the job.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
id: ci-timings-artifact
|
||||
id: ci-timings-html
|
||||
with:
|
||||
name: ci-timings-report
|
||||
path: |
|
||||
ci-timings-report.html
|
||||
review-status.json
|
||||
path: ci-timings-report.html
|
||||
retention-days: 14
|
||||
|
||||
- name: Build linked review status
|
||||
if: hashFiles('ci-timings.json') != ''
|
||||
env:
|
||||
CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--from-json ci-timings.json \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--review-status-out review-status.json \
|
||||
--review-status-only
|
||||
|
||||
- name: Upload review status
|
||||
if: hashFiles('review-status.json') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-timings-review-status
|
||||
path: review-status.json
|
||||
retention-days: 14
|
||||
|
||||
- name: Output summary
|
||||
env:
|
||||
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
|
||||
REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}}
|
||||
run: |
|
||||
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo "# CI Timing report"
|
||||
echo "[View the full interactive report]($REPORT_URL)"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save baseline cache (main only)
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
name: Docker Build, Test, and Publish
|
||||
|
||||
on:
|
||||
# Trusted main pushes run this workflow directly so environment-scoped
|
||||
# Docker Hub secrets are resolved by the top-level workflow, never across
|
||||
# a reusable-workflow boundary.
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
# CI calls this only for untrusted PR build/test coverage. Those runs never
|
||||
# reach the protected publish or merge jobs below.
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
@@ -20,7 +27,9 @@ env:
|
||||
IMAGE_NAME: nousresearch/hermes-agent
|
||||
|
||||
jobs:
|
||||
# Build, test, and optionally push the image for each architecture.
|
||||
# Build and test the image for each architecture. This job runs PR code,
|
||||
# so it must remain secret-free. Publishing happens in the separate,
|
||||
# protected publish job after these tests pass.
|
||||
build:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
strategy:
|
||||
@@ -62,49 +71,6 @@ jobs:
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
|
||||
|
||||
- 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
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
- name: Push ${{ matrix.arch }} 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
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
# Write the digest to a file and upload it as an artifact so the
|
||||
# merge job can stitch both per-arch digests into a manifest list.
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Run the docker-integration test suite against the freshly-built
|
||||
# image already loaded into the local daemon (`:test`).
|
||||
@@ -147,6 +113,74 @@ jobs:
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebuild and push each architecture only after the unprivileged build/test
|
||||
# matrix passes. This job is the sole Docker Hub credential boundary.
|
||||
# ---------------------------------------------------------------------------
|
||||
publish:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
needs: [build]
|
||||
environment: container-publish
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout trusted source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push by digest only (no tag). The merge job assembles the tagged
|
||||
# manifest list after both architecture publishers complete.
|
||||
- name: Push ${{ matrix.arch }} by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
@@ -158,8 +192,9 @@ jobs:
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
needs: [publish]
|
||||
timeout-minutes: 10
|
||||
environment: container-publish
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
|
||||
@@ -171,6 +171,19 @@ jobs:
|
||||
echo '__E2E_REVIEW_STATUS__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The trusted workflow_run publisher consumes only this flat, bounded
|
||||
# artifact. It turns selected images into GitHub attachment URLs; it
|
||||
# never checks out or runs this PR's code.
|
||||
- name: Upload inline E2E evidence
|
||||
if: always() && github.ref_name != 'main'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-evidence-${{ github.sha }}
|
||||
path: /tmp/e2e-evidence
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
|
||||
# ── Generate step summary with visual diff info ───────────────────
|
||||
# Parse the JSON report + scan for diff images, then post a summary
|
||||
# to the GitHub Actions step output so reviewers can see what changed
|
||||
|
||||
@@ -122,6 +122,7 @@ jobs:
|
||||
if: needs.generate-patch.outputs.has-fixes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: trusted-automation
|
||||
permissions:
|
||||
contents: write # needed to push to bot/js-autofix
|
||||
pull-requests: write # needed for PR creation + auto-merge
|
||||
@@ -132,7 +133,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Download patch
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Publish E2E evidence
|
||||
|
||||
# This runs only from the default branch after CI completes. It intentionally
|
||||
# checks out main, never the PR ref, and treats the downloaded artifact as
|
||||
# untrusted input before uploading validated GitHub attachments.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: publish-e2e-evidence-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish inline E2E evidence
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: gh-image
|
||||
steps:
|
||||
- name: Check out trusted publisher
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
# v1.2.0 resolves to 44f4b93ecbbe22de6c45fa2f62f519aee564ca8c.
|
||||
- name: Install gh-image
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh extension install drogers0/gh-image --pin v1.2.0
|
||||
|
||||
- name: Download and attach evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GH_SESSION_TOKEN: ${{ secrets.GH_IMAGE_SESSION_TOKEN }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PR_NUMBER=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID" --jq '.pull_requests[0].number // empty')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No pull request is associated with CI run $SOURCE_RUN_ID."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ARTIFACT_NAME=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID/artifacts" \
|
||||
--jq '.artifacts[] | select(.expired == false and (.name | startswith("e2e-evidence-"))) | .name' \
|
||||
| python3 -c 'import sys; print(next(iter(sys.stdin), "").strip())')
|
||||
if [ -z "$ARTIFACT_NAME" ]; then
|
||||
echo "No E2E evidence artifact was produced for CI run $SOURCE_RUN_ID."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EVIDENCE_DIR="$RUNNER_TEMP/e2e-evidence"
|
||||
mkdir -p "$EVIDENCE_DIR"
|
||||
gh run download "$SOURCE_RUN_ID" --repo "$SOURCE_REPO" --name "$ARTIFACT_NAME" --dir "$EVIDENCE_DIR"
|
||||
|
||||
python3 scripts/ci/publish_e2e_evidence.py \
|
||||
--evidence-dir "$EVIDENCE_DIR" \
|
||||
--source-repo "$SOURCE_REPO" \
|
||||
--pr-number "$PR_NUMBER"
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- name: Probe live index
|
||||
id: probe
|
||||
@@ -113,7 +114,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open issue on degraded / failed probe
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -28,7 +29,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -60,12 +61,13 @@ jobs:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
|
||||
@@ -65,17 +65,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Scan diff for critical patterns
|
||||
id: scan
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -922,6 +922,18 @@ def recover_with_credential_pool(
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
# Capture the current API key before any rotation — needed to
|
||||
# identify which credential actually failed when
|
||||
# mark_exhausted_and_rotate is called. Without this hint the
|
||||
# pool falls back to current() or _select_unlocked(), which may
|
||||
# return the NEXT (healthy) entry after a prior rotation, marking
|
||||
# the wrong credential as exhausted (#43747).
|
||||
_api_key_hint = getattr(agent, "api_key", None) or None
|
||||
if not _api_key_hint:
|
||||
_cur = pool.current()
|
||||
if _cur:
|
||||
_api_key_hint = getattr(_cur, "runtime_api_key", None)
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
if status_code == 402:
|
||||
@@ -952,13 +964,13 @@ def recover_with_credential_pool(
|
||||
|
||||
if effective_reason == FailoverReason.billing:
|
||||
rotate_status = status_code if status_code is not None else 402
|
||||
# Runtime credentials can be resolved by a separate pool instance,
|
||||
# leaving this recovery pool without ``current_id``. Match the key
|
||||
# that actually failed instead of quarantining a different account.
|
||||
next_entry = pool.mark_exhausted_and_rotate(
|
||||
status_code=rotate_status,
|
||||
error_context=error_context,
|
||||
# Runtime credentials can be resolved by a separate pool instance,
|
||||
# leaving this recovery pool without ``current_id``. Match the key
|
||||
# that actually failed instead of quarantining a different account.
|
||||
api_key_hint=getattr(agent, "api_key", None),
|
||||
api_key_hint=_api_key_hint,
|
||||
)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
@@ -983,7 +995,7 @@ def recover_with_credential_pool(
|
||||
current_last_status,
|
||||
)
|
||||
rotate_status = status_code if status_code is not None else 429
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
|
||||
@@ -1007,7 +1019,7 @@ def recover_with_credential_pool(
|
||||
if not has_retried_429 and not usage_limit_reached:
|
||||
return False, True
|
||||
rotate_status = status_code if status_code is not None else 429
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
"Credential %s (rate limit) — rotated to pool entry %s",
|
||||
@@ -1107,7 +1119,7 @@ def recover_with_credential_pool(
|
||||
# Refresh failed — rotate to next credential instead of giving up.
|
||||
# The failed entry is already marked exhausted by try_refresh_current().
|
||||
rotate_status = status_code if status_code is not None else 401
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
"Credential %s (auth refresh failed) — rotated to pool entry %s",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Provider-agnostic billing/credit recovery links.
|
||||
|
||||
Maps a billing-classified failure onto a recovery link + label. *Detection*
|
||||
is not done here — that is :mod:`agent.error_classifier`
|
||||
(``FailoverReason.billing``), the single source of truth for "credit wall vs.
|
||||
rate limit / auth / transport". The resulting :class:`BillingBlock` rides the
|
||||
turn result and the gateway ``message.complete`` event so every surface (CLI,
|
||||
TUI, desktop) renders one structured signal instead of re-parsing error text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Optional
|
||||
|
||||
from utils import base_url_host_matches
|
||||
|
||||
|
||||
@dataclass
|
||||
class BillingBlock:
|
||||
"""Structured billing-wall descriptor shared across every surface.
|
||||
|
||||
``is_nous`` is the routing bit: Nous has a first-class in-app billing surface
|
||||
(desktop Settings → Billing, TUI/CLI ``/topup``), so surfaces prefer that over
|
||||
``billing_url``; third-party providers have no in-app flow, so ``billing_url``
|
||||
is the deep link the user actually needs.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
provider_label: str
|
||||
model: str
|
||||
billing_url: Optional[str]
|
||||
is_nous: bool
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Provider:
|
||||
label: str
|
||||
url: str
|
||||
slugs: tuple[str, ...]
|
||||
hosts: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Single source of truth: internal slug(s) + base_url host(s) → billing page.
|
||||
# Curated "add credits / manage billing" landing pages, not marketing homes.
|
||||
# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket
|
||||
# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown
|
||||
# provider degrades to a readable label with no invented URL.
|
||||
_PROVIDERS: tuple[_Provider, ...] = (
|
||||
_Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)),
|
||||
_Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)),
|
||||
_Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)),
|
||||
_Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)),
|
||||
_Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)),
|
||||
_Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)),
|
||||
_Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)),
|
||||
_Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")),
|
||||
_Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)),
|
||||
_Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)),
|
||||
_Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)),
|
||||
_Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)),
|
||||
_Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)),
|
||||
_Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)),
|
||||
)
|
||||
|
||||
_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs}
|
||||
|
||||
|
||||
def is_nous_inference_route(provider: str, base_url: str) -> bool:
|
||||
"""True when the failing route is the Nous-managed inference gateway."""
|
||||
if (provider or "").strip().lower() == "nous":
|
||||
return True
|
||||
return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com")
|
||||
|
||||
|
||||
def _nous_billing_url() -> Optional[str]:
|
||||
"""Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow)."""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
|
||||
return nous_portal_billing_url(None)
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com/billing"
|
||||
|
||||
|
||||
def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]:
|
||||
"""Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback."""
|
||||
hit = _BY_SLUG.get(slug)
|
||||
if hit:
|
||||
return hit.label, hit.url
|
||||
|
||||
base = str(base_url or "")
|
||||
for p in _PROVIDERS:
|
||||
if any(base_url_host_matches(base, host) for host in p.hosts):
|
||||
return p.label, p.url
|
||||
|
||||
return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None
|
||||
|
||||
|
||||
def build_billing_block(
|
||||
*,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
message: str = "",
|
||||
) -> BillingBlock:
|
||||
"""Build the billing descriptor for a billing-classified failure.
|
||||
|
||||
``message`` is the guidance already assembled by the agent loop
|
||||
(:func:`agent.conversation_loop._billing_or_entitlement_message`), carried
|
||||
through unchanged so every surface shows identical copy.
|
||||
"""
|
||||
slug = (provider or "").strip().lower()
|
||||
model = (model or "").strip()
|
||||
|
||||
if is_nous_inference_route(slug, base_url):
|
||||
return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "")
|
||||
|
||||
label, url = _resolve_provider_link(slug, base_url)
|
||||
return BillingBlock(slug, label, model, url, False, message or "")
|
||||
@@ -307,6 +307,19 @@ def _billing_or_entitlement_message(
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
# Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq,
|
||||
# OpenRouter, …) so every text surface — CLI, gateway messaging, TUI
|
||||
# transcript — shows the same actionable link, not just OpenRouter.
|
||||
try:
|
||||
from agent.billing_links import build_billing_block
|
||||
|
||||
_link = build_billing_block(provider=provider, base_url=base_url, model=model)
|
||||
if _link.provider_label:
|
||||
provider_label = _link.provider_label
|
||||
billing_url = _link.billing_url
|
||||
except Exception:
|
||||
billing_url = None
|
||||
|
||||
lines = [
|
||||
(
|
||||
f"{provider_label} reported that billing, credits, or account "
|
||||
@@ -314,12 +327,24 @@ def _billing_or_entitlement_message(
|
||||
),
|
||||
"Add credits or update billing with that provider, then retry.",
|
||||
]
|
||||
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
|
||||
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
|
||||
if billing_url:
|
||||
lines.append(f"{provider_label} billing: {billing_url}")
|
||||
lines.append("You can switch providers temporarily with /model <model> --provider <provider>.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _billing_block_dict(provider, base_url, model, message="") -> Optional[dict]:
|
||||
"""Best-effort structured billing descriptor (None if billing_links is unavailable)."""
|
||||
try:
|
||||
from agent.billing_links import build_billing_block
|
||||
|
||||
return build_billing_block(
|
||||
provider=provider, base_url=str(base_url), model=model, message=message
|
||||
).to_dict()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _print_billing_or_entitlement_guidance(
|
||||
agent,
|
||||
*,
|
||||
@@ -4284,6 +4309,31 @@ def run_conversation(
|
||||
final_response=_policy_response,
|
||||
error_detail=_nonretryable_summary,
|
||||
)
|
||||
# Billing walls are the common non-retryable abort: enrich
|
||||
# the result with the same structured recovery descriptor as
|
||||
# the max-retries path so every surface (CLI, TUI, desktop)
|
||||
# renders one consistent billing signal.
|
||||
if classified.reason == FailoverReason.billing:
|
||||
_ce_guidance = _billing_or_entitlement_message(
|
||||
capability="model access",
|
||||
provider=_provider,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
)
|
||||
_ce_final = f"Billing or credits exhausted: {_nonretryable_summary}"
|
||||
if _ce_guidance:
|
||||
_ce_final += f"\n\n{_ce_guidance}"
|
||||
_ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance)
|
||||
return {
|
||||
"final_response": _ce_final,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": _nonretryable_summary,
|
||||
"failure_reason": classified.reason.value,
|
||||
"billing_block": _ce_block,
|
||||
}
|
||||
return {
|
||||
"final_response": _nonretryable_summary,
|
||||
"messages": messages,
|
||||
@@ -4444,10 +4494,14 @@ def run_conversation(
|
||||
api_kwargs, reason="max_retries_exhausted", error=api_error,
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
_billing_block = None
|
||||
if classified.reason == FailoverReason.billing:
|
||||
_final_response = f"Billing or credits exhausted: {_final_summary}"
|
||||
if _billing_guidance:
|
||||
_final_response += f"\n\n{_billing_guidance}"
|
||||
# Structured recovery descriptor so every surface renders
|
||||
# the same link + label from one signal (see helper).
|
||||
_billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance)
|
||||
else:
|
||||
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
|
||||
if _is_thinking_timeout:
|
||||
@@ -4487,6 +4541,9 @@ def run_conversation(
|
||||
# different exit code. ``rate_limit`` / ``billing`` here
|
||||
# mean "quota wall, not a task error".
|
||||
"failure_reason": classified.reason.value,
|
||||
# Present only for billing walls: structured recovery
|
||||
# descriptor (provider, billing_url, is_nous, message).
|
||||
"billing_block": _billing_block,
|
||||
}
|
||||
|
||||
# For rate limits, respect the Retry-After header if present
|
||||
|
||||
@@ -316,12 +316,21 @@ def evaluate_credits_notices(
|
||||
active.discard(CREDITS_USAGE_KEY)
|
||||
if target_band is not None:
|
||||
# Belt-and-suspenders: a producer could set subscription_limit_micros
|
||||
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
|
||||
# without subscription_limit_usd. Render "$?" rather than "$None".
|
||||
_cap_usd = state.subscription_limit_usd or "?"
|
||||
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
|
||||
# Report absolute dollars used, not a bare "N% used": the percentage is
|
||||
# only meaningful against a Nous subscription cap (no cap → never fires),
|
||||
# so dollars are clearer and don't imply a universal %. Used = cap −
|
||||
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
|
||||
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
|
||||
_lim = state.subscription_limit_micros or 0
|
||||
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
|
||||
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
|
||||
_glyph = "⚠" if _level == "warn" else "•"
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
|
||||
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
|
||||
level=_level,
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key=CREDITS_USAGE_KEY,
|
||||
|
||||
+12
-7
@@ -210,16 +210,21 @@ def _supports_vision_override(
|
||||
# get rewritten to provider="custom" at runtime
|
||||
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
|
||||
# config still holds the user-declared name under model.provider. Try
|
||||
# both as candidate provider keys, plus the stripped suffix from
|
||||
# "custom:<name>" (where <name> is the key under providers:).
|
||||
# both as candidate provider keys. Either identity may use the
|
||||
# "custom:<name>" form while providers: is keyed by bare <name>.
|
||||
config_provider = str(model_cfg.get("provider") or "").strip()
|
||||
# Extract the stripped name from "custom:<name>" if present
|
||||
stripped_suffix = ""
|
||||
if config_provider.startswith("custom:"):
|
||||
stripped_suffix = config_provider[len("custom:"):]
|
||||
provider_candidates: List[str] = []
|
||||
for candidate in (provider, config_provider):
|
||||
if not candidate:
|
||||
continue
|
||||
provider_candidates.append(candidate)
|
||||
if candidate.startswith("custom:"):
|
||||
stripped_candidate = candidate[len("custom:"):]
|
||||
if stripped_candidate:
|
||||
provider_candidates.append(stripped_candidate)
|
||||
providers_raw = cfg.get("providers")
|
||||
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
|
||||
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
|
||||
for p in dict.fromkeys(provider_candidates):
|
||||
entry_raw = providers_cfg.get(p)
|
||||
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
|
||||
models_raw = entry.get("models")
|
||||
|
||||
@@ -17,6 +17,9 @@ const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must rema
|
||||
const CORRECTION = 'E2E correction must stay after the original prompt.'
|
||||
const TOOL_STARTED = 'Checking the long-running task before I continue.'
|
||||
const CORRECTED_REPLY = 'The corrected task finished.'
|
||||
const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
|
||||
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
|
||||
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
@@ -26,6 +29,17 @@ async function send(page: Page, text: string): Promise<void> {
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await expect(primary).toHaveAttribute('aria-label', /Steer/)
|
||||
await primary.click()
|
||||
}
|
||||
|
||||
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
@@ -61,6 +75,17 @@ async function transcriptTextOrder(page: Page): Promise<string[]> {
|
||||
})
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await page.waitForFunction(
|
||||
@@ -83,15 +108,34 @@ async function reopenOriginalSession(page: Page): Promise<void> {
|
||||
await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
|
||||
}
|
||||
|
||||
async function reopenInferenceSession(page: Page): Promise<void> {
|
||||
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first()
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await waitForTranscriptText(page, INFERENCE_PROMPT)
|
||||
}
|
||||
|
||||
function relevantOrder(messages: string[]): string[] {
|
||||
return messages.filter(message => message.includes(ORIGINAL_PROMPT) || message.includes(CORRECTION))
|
||||
}
|
||||
|
||||
function steerTurnOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
|
||||
if (message.includes(CORRECTION)) return [CORRECTION]
|
||||
if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('correction session switch', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
@@ -113,9 +157,9 @@ test.describe('correction session switch', () => {
|
||||
await waitForTranscriptText(page, TOOL_STARTED)
|
||||
await waitForTranscriptText(page, ORIGINAL_PROMPT)
|
||||
|
||||
// The historical session redirected while a foreground terminal task was
|
||||
// running. Enter records the accepted correction at the next tool boundary.
|
||||
await send(page, CORRECTION)
|
||||
// The historical session redirects while a foreground terminal task is
|
||||
// running. Use the visible Steer action to cover the real composer path.
|
||||
await steer(page, CORRECTION)
|
||||
await waitForTranscriptText(page, CORRECTION)
|
||||
|
||||
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
|
||||
@@ -136,5 +180,31 @@ test.describe('correction session switch', () => {
|
||||
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
|
||||
|
||||
await waitForTranscriptText(page, CORRECTED_REPLY)
|
||||
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
|
||||
})
|
||||
|
||||
test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, OTHER_SESSION_PROMPT)
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
await openFreshDraft(page, OTHER_SESSION_PROMPT)
|
||||
|
||||
await send(page, INFERENCE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await waitForTranscriptText(page, INFERENCE_PROMPT)
|
||||
|
||||
await send(page, INFERENCE_CORRECTION)
|
||||
await waitForTranscriptText(page, INFERENCE_CORRECTION)
|
||||
|
||||
await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT)
|
||||
await reopenInferenceSession(page)
|
||||
|
||||
expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1)
|
||||
await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') })
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
})
|
||||
})
|
||||
@@ -141,22 +141,29 @@ export function createSandbox(prefix: string): Sandbox {
|
||||
* mock inference server. The provider is set as the active model provider so
|
||||
* the desktop app skips onboarding and boots straight to the chat UI.
|
||||
*
|
||||
* @param extraConfig optional YAML lines appended to the `display:` section,
|
||||
* used by the interim-message e2e test to toggle
|
||||
* `display.interim_assistant_messages`.
|
||||
* @param extraDisplayConfig optional YAML lines appended to the `display:`
|
||||
* section, used by the interim-message e2e test.
|
||||
* @param extraConfig optional top-level YAML sections for a test scenario.
|
||||
* @param modelContextLength optional primary-model context limit.
|
||||
*/
|
||||
export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void {
|
||||
export function writeMockProviderConfig(
|
||||
hermesHome: string,
|
||||
mockUrl: string,
|
||||
extraDisplayConfig?: string,
|
||||
extraConfig?: string,
|
||||
modelContextLength?: number,
|
||||
): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const displaySection = extraConfig
|
||||
? `\ndisplay:\n${extraConfig}\n`
|
||||
const displaySection = extraDisplayConfig
|
||||
? `\ndisplay:\n${extraDisplayConfig}\n`
|
||||
: ''
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
@@ -165,7 +172,7 @@ providers:
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
${displaySection}`
|
||||
${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
|
||||
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
@@ -339,6 +346,10 @@ export interface MockBackendOptions {
|
||||
* `display.interim_assistant_messages`.
|
||||
*/
|
||||
extraDisplayConfig?: string
|
||||
/** Additional top-level config.yaml sections for an E2E scenario. */
|
||||
extraConfig?: string
|
||||
/** Override the mock model's context window for compression scenarios. */
|
||||
modelContextLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +369,13 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig)
|
||||
writeMockProviderConfig(
|
||||
sandbox.hermesHome,
|
||||
mock.url,
|
||||
options.extraDisplayConfig,
|
||||
options.extraConfig,
|
||||
options.modelContextLength,
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
|
||||
@@ -23,6 +23,8 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c
|
||||
export interface MockServerOptions {
|
||||
/** Pause the matching stream after its first token for session-switch E2E coverage. */
|
||||
holdFirstStreamForPrompt?: string
|
||||
/** Pause the first completion whose request JSON contains this text. */
|
||||
holdFirstCompletionContaining?: string
|
||||
}
|
||||
|
||||
export interface MockServer {
|
||||
@@ -30,7 +32,9 @@ export interface MockServer {
|
||||
url: string
|
||||
receivedPrompts: string[]
|
||||
waitForHeldStream: () => Promise<void>
|
||||
waitForHeldCompletion: () => Promise<void>
|
||||
releaseHeldStream: () => void
|
||||
heldCompletionCount: () => number
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -248,6 +252,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
const receivedPrompts: string[] = []
|
||||
let resolveHeldStreamStarted: (() => void) | null = null
|
||||
let releaseHeldStream: (() => void) | null = null
|
||||
let heldCompletionCount = 0
|
||||
const heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
@@ -313,6 +318,11 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
const holdThisCompletion = Boolean(
|
||||
options.holdFirstCompletionContaining &&
|
||||
heldCompletionCount === 0 &&
|
||||
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
|
||||
)
|
||||
|
||||
// Detect the interim-message test trigger: the user's message
|
||||
// contains a specific keyword. The mock walks through the
|
||||
@@ -405,12 +415,21 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
|
||||
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
|
||||
)
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => {
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
}
|
||||
resolveHeldStreamStarted?.()
|
||||
return heldStreamReleased
|
||||
} : undefined)
|
||||
} else {
|
||||
nonStreamingTextResponse(res, model, MOCK_REPLY)
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
resolveHeldStreamStarted?.()
|
||||
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
|
||||
} else {
|
||||
nonStreamingTextResponse(res, model, MOCK_REPLY)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -443,7 +462,9 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
url,
|
||||
receivedPrompts,
|
||||
waitForHeldStream: () => heldStreamStarted,
|
||||
waitForHeldCompletion: () => heldStreamStarted,
|
||||
releaseHeldStream: () => releaseHeldStream?.(),
|
||||
heldCompletionCount: () => heldCompletionCount,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A queued prompt must remain local until the current inference turn settles.
|
||||
*
|
||||
* Hold the first streamed reply open after its first token. This gives the
|
||||
* composer a live, busy turn while the user queues a follow-up, then lets us
|
||||
* assert against the mock provider's real request log before and after the
|
||||
* held turn completes.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY } from './mock-server'
|
||||
|
||||
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
|
||||
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
|
||||
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function queue(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Control+Enter')
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
function steerTurnOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
|
||||
if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
|
||||
if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('queued prompt turn boundary', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('submits a queued prompt only after the active turn completes', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await queue(page, QUEUED_PROMPT)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
// The mock keeps the active SSE stream open, so a queued prompt has no
|
||||
// completed-turn boundary that could legitimately drain it. Wait past the
|
||||
// queue retry interval and assert the provider saw only the active turn.
|
||||
await page.waitForTimeout(1_000)
|
||||
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
|
||||
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('places a steer prompt before the reply it redirects', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await steer(page, STEER_PROMPT)
|
||||
mock.releaseHeldStream()
|
||||
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
|
||||
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
|
||||
})
|
||||
})
|
||||
@@ -9,15 +9,23 @@ import {
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { receivedUserTexts, restartMockServer } from './mock-server'
|
||||
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
async function send(page: Page, text: string, delay = 15): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 15 })
|
||||
await composer.type(text, { delay })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function pasteAndSend(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await page.keyboard.insertText(text)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
|
||||
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
|
||||
@@ -81,3 +89,58 @@ test.describe('session compression', () => {
|
||||
await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('session compression in progress', () => {
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
modelContextLength: 64_000,
|
||||
extraConfig: `compression:
|
||||
threshold_tokens: 22000
|
||||
protect_first_n: 0
|
||||
protect_last_n: 1
|
||||
auxiliary:
|
||||
compression:
|
||||
provider: custom
|
||||
model: mock-model`,
|
||||
mockServer: {
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
|
||||
}
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
|
||||
const { page } = fixture
|
||||
const queued = 'E2E_QUEUED_DURING_COMPACTION'
|
||||
|
||||
// A normal message crosses the tiny configured context budget. The mock
|
||||
// blocks only the resulting summary request, so these assertions run
|
||||
// during automatic compaction rather than a slash-command path.
|
||||
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500))
|
||||
await fixture.mock.waitForHeldCompletion()
|
||||
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
|
||||
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
|
||||
|
||||
await send(page, queued)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
expect(receivedUserTexts()).not.toContain(queued)
|
||||
await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
|
||||
|
||||
fixture.mock.releaseHeldStream()
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,7 +73,6 @@ test('creating a branch with ctrl-shift-b updates the composer git-status branch
|
||||
const codingRow = page.locator('.coding-status-bar')
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await expect(codingRow).toContainText('main')
|
||||
await composer.click()
|
||||
await composer.type('create a repo-backed e2e session', { delay: 2 })
|
||||
await page.keyboard.press('Enter')
|
||||
@@ -82,6 +81,7 @@ test('creating a branch with ctrl-shift-b updates the composer git-status branch
|
||||
'create a repo-backed e2e session',
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await expect(codingRow).toContainText('main')
|
||||
await page.keyboard.press('Control+Shift+B')
|
||||
|
||||
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
|
||||
|
||||
@@ -12,7 +12,12 @@ import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import {
|
||||
canImportHermesCli,
|
||||
hermesRuntimeImportProbe,
|
||||
shouldTrustHermesOverride,
|
||||
verifyHermesCli
|
||||
} from './backend-probes'
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
|
||||
@@ -2,17 +2,25 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. right-rail tabs (live preview and/or file peeks),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
|
||||
* the workspace can't close, so ⌘W shifts the NEXT session tab into main
|
||||
* (loads it as the primary + drops its now-redundant tile).
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
* and the macOS menu-accelerator IPC.
|
||||
*
|
||||
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
|
||||
* into main" (the two call sites have router access); omitting it disables the
|
||||
* step-4 promotion (⌘W stays the pre-existing no-op on the main tab).
|
||||
*/
|
||||
export function closeActiveTab(): boolean {
|
||||
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
closeActiveTerminal()
|
||||
|
||||
@@ -28,5 +36,27 @@ export function closeActiveTab(): boolean {
|
||||
return closeActiveRightRailTab()
|
||||
}
|
||||
|
||||
return closeWorkspaceTab()
|
||||
// A closeable main-zone tab (a session tile that's the active tab) closes
|
||||
// outright; the uncloseable workspace tab returns false and falls through.
|
||||
if (closeWorkspaceTab()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// The main (workspace) tab is active and can't be closed — but if session
|
||||
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
|
||||
// its tile (the session stays alive, no busy-close prompt) and load it into
|
||||
// main. Order matters — close the tile FIRST so the selection homes to the
|
||||
// workspace instead of re-fronting the tile.
|
||||
if (loadSessionIntoWorkspace) {
|
||||
const next = nextSessionTileForWorkspace()
|
||||
|
||||
if (next) {
|
||||
closeSessionTile(next)
|
||||
loadSessionIntoWorkspace(next)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -65,15 +65,15 @@ describe('ComposerControls shortcut tooltips', () => {
|
||||
await expectShortcutTooltip('Send', '↵')
|
||||
})
|
||||
|
||||
it('shows Ctrl+Enter for Steer', async () => {
|
||||
it('shows Enter for Steer', async () => {
|
||||
renderControls({ busy: true, busyAction: 'steer' })
|
||||
|
||||
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
|
||||
await expectShortcutTooltip('Steer the current run', '↵')
|
||||
})
|
||||
|
||||
it('shows Enter for Queue', async () => {
|
||||
it('shows Ctrl+Enter for Queue', async () => {
|
||||
renderControls({ busy: true, busyAction: 'queue' })
|
||||
|
||||
await expectShortcutTooltip('Queue message', '↵')
|
||||
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ export function ComposerControls({
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
className={GHOST_ICON_BTN}
|
||||
@@ -115,7 +115,16 @@ export function ComposerControls({
|
||||
<Tip
|
||||
label={
|
||||
busy ? (
|
||||
<TipKeybindLabel actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'} text={busyLabel} />
|
||||
<TipKeybindLabel
|
||||
actionId={
|
||||
busyAction === 'steer'
|
||||
? 'composer.steer'
|
||||
: busyAction === 'queue'
|
||||
? 'composer.queue'
|
||||
: 'composer.send'
|
||||
}
|
||||
text={busyLabel}
|
||||
/>
|
||||
) : (
|
||||
<TipKeybindLabel actionId="composer.send" text={c.send} />
|
||||
)
|
||||
|
||||
@@ -8,10 +8,16 @@ import { useComposerSubmit } from './use-composer-submit'
|
||||
interface SubmitHarnessOptions {
|
||||
attachments?: ComposerAttachment[]
|
||||
busy?: boolean
|
||||
compacting?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitHarnessOptions = {}) {
|
||||
function renderSubmitHook({
|
||||
attachments = [],
|
||||
busy = false,
|
||||
compacting = false,
|
||||
text = ''
|
||||
}: SubmitHarnessOptions = {}) {
|
||||
const draftRef = { current: text }
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = 'composer-rich-input'
|
||||
@@ -33,6 +39,7 @@ function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitH
|
||||
activeQueueSessionKeyRef: { current: 'stored-session' },
|
||||
attachments,
|
||||
busy,
|
||||
compacting,
|
||||
clearDraft,
|
||||
disabled: false,
|
||||
draftRef,
|
||||
@@ -79,6 +86,23 @@ describe('useComposerSubmit busy-turn routing', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues a plain-text follow-up while the active turn is compacting', () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
compacting: true,
|
||||
text: 'wait for the summary'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs slash commands immediately while busy', async () => {
|
||||
const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ interface UseComposerSubmitArgs {
|
||||
activeQueueSessionKeyRef: RefObject<string | null>
|
||||
attachments: ComposerAttachment[]
|
||||
busy: boolean
|
||||
compacting: boolean
|
||||
clearDraft: () => void
|
||||
disabled: boolean
|
||||
draftRef: RefObject<string>
|
||||
@@ -51,6 +52,7 @@ export function useComposerSubmit({
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
compacting,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
@@ -149,7 +151,7 @@ export function useComposerSubmit({
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
dispatchSubmit(text)
|
||||
} else if (!attachments.length && text.trim()) {
|
||||
} else if (!compacting && !attachments.length && text.trim()) {
|
||||
// Cursor-style stop-and-correct: interrupt the live turn and redirect
|
||||
// it with this text. redirect() preserves the shown reasoning/work; if
|
||||
// the turn already ended, steerDraft re-queues so nothing is lost.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
@@ -60,6 +63,7 @@ export function useComposerVoice({
|
||||
onTranscribeAudio
|
||||
})
|
||||
|
||||
/** Auto-speak selector: the latest unspoken reply only — a backlog collapses to the newest. */
|
||||
const pendingResponse = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
||||
@@ -81,6 +85,13 @@ export function useComposerVoice({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice-conversation selector: every unspoken assistant bubble of the turn,
|
||||
* in order — narration interims AND the final answer, not just whichever
|
||||
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
|
||||
*/
|
||||
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
|
||||
|
||||
const consumePendingResponse = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
||||
@@ -108,7 +119,7 @@ export function useComposerVoice({
|
||||
onFatalError: () => setVoiceConversationActive(false),
|
||||
onSubmit: submitVoiceTurn,
|
||||
onTranscribeAudio,
|
||||
pendingResponse
|
||||
pendingResponse: pendingTurnResponse
|
||||
})
|
||||
|
||||
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
|
||||
@@ -132,6 +143,57 @@ export function useComposerVoice({
|
||||
[target, toggleVoiceConversation]
|
||||
)
|
||||
|
||||
// "Hey Hermes" wake word: a latched start request (nanostore) the composer
|
||||
// claims once it's mounted and the gateway is ready. Survives the fresh-session
|
||||
// remount the wake handler triggers, and waits out a transient `disabled`.
|
||||
const voiceStartReq = useStore($voiceConversationStartRequest)
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
return // not ready — re-runs when `disabled` flips false
|
||||
}
|
||||
|
||||
if (!takeVoiceConversationStart(voiceStartReq)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!voiceConversationActive) {
|
||||
setVoiceConversationActive(true)
|
||||
}
|
||||
}, [voiceStartReq, disabled, voiceConversationActive])
|
||||
|
||||
// Hand the mic between the server-side wake detector and the browser's voice
|
||||
// loop: pause the detector while a conversation is live, resume it after
|
||||
// (no-ops server-side when the wake word isn't armed). wakePausedRef tracks
|
||||
// whether WE paused, so resume always runs once — including on unmount, where
|
||||
// ending voice can tear the composer down before the `false` render lands and
|
||||
// would otherwise leave the detector paused forever.
|
||||
const wakePausedRef = useRef(false)
|
||||
|
||||
const wakeRpc = useCallback(
|
||||
(method: string) => void $gateway.get()?.request(method, {}).catch(() => undefined),
|
||||
[]
|
||||
)
|
||||
|
||||
const resumeWakeIfPaused = useCallback(() => {
|
||||
if (!wakePausedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
wakePausedRef.current = false
|
||||
wakeRpc('wake.resume')
|
||||
}, [wakeRpc])
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceConversationActive) {
|
||||
wakePausedRef.current = true
|
||||
wakeRpc('wake.pause')
|
||||
} else {
|
||||
resumeWakeIfPaused()
|
||||
}
|
||||
}, [voiceConversationActive, wakeRpc, resumeWakeIfPaused])
|
||||
|
||||
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
|
||||
|
||||
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
||||
// the gated toggle above).
|
||||
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
|
||||
|
||||
@@ -11,6 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
|
||||
@@ -104,6 +105,7 @@ export function ChatBar({
|
||||
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
|
||||
const scope = useComposerScope()
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const compacting = useStore($compactionActive)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
@@ -230,11 +232,15 @@ export function ChatBar({
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
|
||||
// attachments queue for the next turn, an empty composer stops.
|
||||
const busyAction: 'steer' | 'queue' | 'stop' = canSteer ? 'steer' : hasComposerPayload ? 'queue' : 'stop'
|
||||
const busyAction: 'steer' | 'queue' | 'stop' = canSteer
|
||||
? 'steer'
|
||||
: compacting || hasComposerPayload
|
||||
? 'queue'
|
||||
: 'stop'
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
@@ -243,6 +249,7 @@ export function ChatBar({
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
compacting,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
@@ -580,14 +587,22 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
|
||||
// Steer when there's a steerable draft, otherwise swallow it so it can't
|
||||
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
|
||||
// Cmd/Ctrl+Enter queues a follow-up while a turn runs. Plain Enter steers
|
||||
// a text-only draft, so both live-turn actions stay reachable by keyboard.
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
if (canSteer) {
|
||||
steerDraft()
|
||||
if (busy && !disabled) {
|
||||
// As with plain Enter, source the just-typed content from the DOM so a
|
||||
// fast keypress cannot queue a stale draft.
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
|
||||
if (editorText !== draftRef.current) {
|
||||
draftRef.current = editorText
|
||||
setComposerText(editorText)
|
||||
}
|
||||
|
||||
queueDraft()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $billingBlock } from '@/store/billing-block'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
@@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const previewsBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
@@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
const sections: { key: string; node: ReactNode }[] = []
|
||||
|
||||
// Billing wall sits at the very top of the stack — it's the most important
|
||||
// thing above the composer when the account is out of credits. Rendered here
|
||||
// (not as a composer-disable) so slash commands stay usable.
|
||||
if (billing && sessionId && billing.sessionId === sessionId) {
|
||||
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
sections.push({
|
||||
key: group.type,
|
||||
|
||||
@@ -31,9 +31,10 @@ export interface PaneMirror<T> {
|
||||
before?: (tile: T) => null | string | undefined
|
||||
minWidth: string
|
||||
title: (key: string) => string
|
||||
/** Lead-dot color for the tile's tab (e.g. a session's project color). Re-read
|
||||
* on every `also` change, so pass the color source in `also` to keep it live. */
|
||||
accent?: (key: string) => string | undefined
|
||||
/** Custom lead NODE for the tile's tab (rendered before the label). A live,
|
||||
* self-subscribing component (e.g. a session's status dot) so the strip needn't
|
||||
* re-sync on status/color change — only `title` drives re-registration. */
|
||||
tabLead?: (key: string) => ReactNode
|
||||
render: (key: string) => ReactNode
|
||||
/** Wrap the tile's TAB (domain context menu — session verbs). */
|
||||
tabWrap?: (key: string, tab: ReactElement) => ReactNode
|
||||
@@ -52,7 +53,7 @@ export interface PaneMirror<T> {
|
||||
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
|
||||
* Module-level state lives in the returned closure, so call it once per app. */
|
||||
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
const registered = new Map<string, { dispose: () => void; title: string; accent?: string }>()
|
||||
const registered = new Map<string, { dispose: () => void; title: string }>()
|
||||
const paneId = (key: string) => `${cfg.prefix}:${key}`
|
||||
|
||||
const sync = () => {
|
||||
@@ -62,11 +63,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
for (const tile of tiles) {
|
||||
const key = cfg.key(tile)
|
||||
const title = cfg.title(key)
|
||||
const accent = cfg.accent?.(key)
|
||||
const current = registered.get(key)
|
||||
|
||||
// register() replaces same-id in place — safe for live title/accent refreshes.
|
||||
if (current && current.title === title && current.accent === accent) {
|
||||
// register() replaces same-id in place — safe for live title refreshes.
|
||||
if (current && current.title === title) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
area: 'panes',
|
||||
title,
|
||||
data: {
|
||||
accent,
|
||||
tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined,
|
||||
dock: {
|
||||
before: cfg.before?.(tile),
|
||||
pane: cfg.anchor?.(tile) ?? 'workspace',
|
||||
@@ -92,7 +92,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
render: () => cfg.render(key)
|
||||
})
|
||||
|
||||
registered.set(key, { dispose, title, accent })
|
||||
registered.set(key, { dispose, title })
|
||||
|
||||
if (!current) {
|
||||
registerPaneCloser(paneId(key), () => cfg.close(key))
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
||||
import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { type SessionDotState, sessionDotState } from './sidebar/session-row-state'
|
||||
|
||||
// A pure lookup table: each state maps to its className, aria-label, and title.
|
||||
// No priority resolution here — sessionDotState already picked one. Label/title
|
||||
// resolve from sidebar.row translations, keyed by name.
|
||||
type DotVariant = {
|
||||
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||
className: string
|
||||
role?: 'status'
|
||||
title?: (r: Translations['sidebar']['row']) => string
|
||||
}
|
||||
|
||||
// Shared base for every active dot; idle is smaller and uses its own class.
|
||||
const DOT_BASE = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
'needs-input': {
|
||||
ariaLabel: r => r.needsInput,
|
||||
className: `${DOT_BASE} quest-glow bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
stalled: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
|
||||
role: 'status',
|
||||
title: r => r.sessionRunning
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along". Brighter
|
||||
// than muted-foreground so it's visible against the surface.
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
// Steady green — a background session's turn completed and the user hasn't
|
||||
// opened it since. "Something new here, go look."
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionStatusDotProps {
|
||||
/** The STORED session id — the key every live-state atom (working /
|
||||
* attention / stalled / unread / background) is keyed by, on BOTH surfaces:
|
||||
* the sidebar row's `session.id` and a pane tile's `storedSessionId` are the
|
||||
* same stored id (`$workingSessionIds` et al. map `storedSessionId`). */
|
||||
storedSessionId: string
|
||||
/** The session row for color resolution — recents OR the project tree. Both
|
||||
* call sites already hold it; passing it lets the idle dot inherit the
|
||||
* project color even for a session older than the paginated recents page
|
||||
* (which has no `$sessionColorById` entry). */
|
||||
session?: null | SessionInfo
|
||||
/** TUI-style tree stem for a branched session (`└─ ` / `├─ `). */
|
||||
branchStem?: string
|
||||
/** Applied to the OUTER wrapper (stem + dot) — e.g. hover-fade on the
|
||||
* reorder handle. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SESSION STATUS DOT — the ONE primitive both the sidebar row and the pane tab
|
||||
* render, so a session's status/color can never disagree between the two
|
||||
* surfaces. It reads every signal itself from the shared stores keyed by the
|
||||
* stored session id: live state (working / needs-input / stalled / unread /
|
||||
* background, mutually exclusive via `sessionDotState`) and the resolved color
|
||||
* (override → project color, via `sessionColorFor`). An idle session shows its
|
||||
* project color; the active states own the dot with their semantic color so an
|
||||
* attention cue is never masked by the inherited tint.
|
||||
*/
|
||||
export function SessionStatusDot({ storedSessionId, session, branchStem, className }: SessionStatusDotProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// Subscribe to the shared color map for reactivity; sessionColorFor falls
|
||||
// back to the resolver for a session outside the recents page.
|
||||
useStore($sessionColorById)
|
||||
const color = sessionColorFor(session) ?? null
|
||||
|
||||
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
|
||||
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
|
||||
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
|
||||
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
{branchStem ? (
|
||||
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
|
||||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
{dotState === 'idle' && color ? (
|
||||
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
|
||||
) : (
|
||||
<span
|
||||
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
|
||||
className={DOT_VARIANTS[dotState].className}
|
||||
role={DOT_VARIANTS[dotState].role}
|
||||
title={DOT_VARIANTS[dotState].title?.(r)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useModelControls } from '@/app/session/hooks/use-model-controls'
|
||||
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
|
||||
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
@@ -36,6 +37,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $projectTree } from '@/store/projects'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import {
|
||||
$gatewayState,
|
||||
@@ -44,7 +46,6 @@ import {
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
||||
import {
|
||||
$sessionStates,
|
||||
$sessionTiles,
|
||||
@@ -54,12 +55,14 @@ import {
|
||||
type SessionTile,
|
||||
sessionTileDelegate
|
||||
} from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import type { SessionDragPayload } from './composer/inline-refs'
|
||||
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
||||
import { useComposerActions } from './hooks/use-composer-actions'
|
||||
import { paneMirror } from './pane-mirror'
|
||||
import { startSessionDrag } from './session-drag'
|
||||
import { SessionStatusDot } from './session-status-dot'
|
||||
import { useSessionTileActions } from './session-tile-actions'
|
||||
import { type SessionView, SessionViewProvider } from './session-view'
|
||||
import { SessionContextMenu } from './sidebar/session-actions-menu'
|
||||
@@ -199,6 +202,53 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
|
||||
const resumingRef = useRef(false)
|
||||
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
|
||||
|
||||
// A tab-strip "+"/⌘T tab is created UNLISTED — its session stays out of
|
||||
// $sessions (no sidebar clutter) until it's actually used, so the tab shows
|
||||
// "New session". The moment this tile has a message, pull its row into
|
||||
// $sessions via the lightweight by-id lookup so the tab (and a sidebar row)
|
||||
// resolve the real title. `resolveStoredSession` no-ops when it's already
|
||||
// listed, and 404s harmlessly for an in-memory draft that hasn't persisted a
|
||||
// turn yet — so we retry across that brief persist lag and stop as soon as it
|
||||
// lands (a global turn-complete refresh may beat us to it).
|
||||
const hasMessages = useStore(view.$messagesEmpty) === false
|
||||
|
||||
useEffect(() => {
|
||||
const alreadyListed = () => $sessions.get().some(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
if (!runtimeId || !hasMessages || alreadyListed()) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timer: number | undefined
|
||||
|
||||
const attempt = (remaining: number) => {
|
||||
if (cancelled || alreadyListed()) {
|
||||
return
|
||||
}
|
||||
|
||||
void resolveStoredSession(storedSessionId)
|
||||
.then(resolved => {
|
||||
if (cancelled || resolved || remaining <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
timer = window.setTimeout(() => attempt(remaining - 1), 500)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
attempt(6)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [hasMessages, runtimeId, storedSessionId])
|
||||
|
||||
// Same gating as the primary's route resume (use-route-resume): never fire
|
||||
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
|
||||
// while it's still connecting — an ungated resume rejected there and
|
||||
@@ -278,21 +328,34 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
|
||||
// Tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
/** Resolve a tile's stored row: the recents list first, then the project
|
||||
* tree. A session opened as a tab from a project group is often older than
|
||||
* the paginated recents page, so it has no `$sessions` row at all until new
|
||||
* activity lands it there — resolving through the tree keeps its tab titled
|
||||
* and tinted instead of a grey "Session" placeholder. */
|
||||
export function tileStoredRow(storedSessionId: string): SessionInfo | undefined {
|
||||
const match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId)
|
||||
|
||||
return stored ? sessionTitle(stored) : 'Session'
|
||||
return (
|
||||
$sessions.get().find(match) ??
|
||||
$projectTree
|
||||
.get()
|
||||
.flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])])
|
||||
.find(match)
|
||||
)
|
||||
}
|
||||
|
||||
/** The tab's lead-dot color — the tile's session resolved through the SAME
|
||||
* shared map the sidebar reads, so a row and its tab always agree. */
|
||||
function tileAccent(storedSessionId: string): string | undefined {
|
||||
return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
// A tab-strip "+" tab is unlisted until its first turn persists, so it isn't
|
||||
// in $sessions yet — label it "New session" rather than a bare "Session".
|
||||
return stored ? sessionTitle(stored) : 'New session'
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
}
|
||||
@@ -381,9 +444,12 @@ export function SessionTabMenu({
|
||||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
const sessions = useStore($sessions)
|
||||
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
|
||||
// (which spans both sources), so the values themselves are unused here.
|
||||
useStore($sessions)
|
||||
useStore($projectTree)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
@@ -440,7 +506,10 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
||||
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
|
||||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
also: [$sessions, $sessionColorById],
|
||||
// $projectTree: a tile whose session is older than the recents page resolves
|
||||
// its title through the tree, which loads after the tiles register. (The tab's
|
||||
// status dot subscribes to color/state itself, so it needs no `also` entry.)
|
||||
also: [$sessions, $projectTree],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
@@ -448,7 +517,13 @@ export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
before: t => t.before,
|
||||
minWidth: '20rem',
|
||||
title: tileTitle,
|
||||
accent: tileAccent,
|
||||
// The tab's status dot — the SAME primitive the sidebar row renders, keyed by
|
||||
// the stored id, so a session's status/color can never disagree between the
|
||||
// two surfaces. Self-subscribing (live state + resolved color), so the strip
|
||||
// needn't re-sync when it changes.
|
||||
tabLead: storedSessionId => (
|
||||
<SessionStatusDot session={tileStoredRow(storedSessionId)} storedSessionId={storedSessionId} />
|
||||
),
|
||||
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
|
||||
tabWrap: (storedSessionId, tab) => (
|
||||
<SessionTabMenu
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
|
||||
import { $dismissedWorktreeIds, dismissWorktree, setWorkspaceNodeOpen } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { removeWorktreePath } from '@/store/projects'
|
||||
|
||||
@@ -257,7 +257,15 @@ function RepoFlatSection({
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
onNewSession && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
|
||||
<WorkspaceAddButton
|
||||
label={s.newSessionIn(repo.label)}
|
||||
onClick={() => {
|
||||
// Reveal the repo the new session targets if the user had it
|
||||
// collapsed — the session lands in one of its lanes.
|
||||
setWorkspaceNodeOpen(repo.id, true)
|
||||
onNewSession(repo.path)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
count={repoCount}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { desktopGit } from '@/lib/desktop-git'
|
||||
import { mapPool } from '@/lib/pool'
|
||||
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { $sidebarWorkspaceNodeOpen, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { $worktreeRefreshToken } from '@/store/projects'
|
||||
|
||||
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
|
||||
@@ -123,14 +123,13 @@ export function useRepoWorktreeMap(
|
||||
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
|
||||
// layout auto-restore when you enter it, and survive reloads.
|
||||
//
|
||||
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
|
||||
// list: XOR lets one store serve both polarities. A default-open node (repo,
|
||||
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
|
||||
// sessions yet) instead records an explicit expand. So empty worktree/branch
|
||||
// lanes start collapsed and only open when the user clicks in.
|
||||
// State is stored as the RESOLVED boolean per node (see `$sidebarWorkspaceNodeOpen`),
|
||||
// so a node whose `defaultOpen` flips — an empty worktree/branch lane defaults
|
||||
// collapsed, then defaults open once it holds a session — keeps whatever the
|
||||
// user explicitly chose instead of having it silently reinterpreted. An absent
|
||||
// id follows `defaultOpen`, so empty lanes still start collapsed until opened.
|
||||
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
|
||||
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
|
||||
const overridden = collapsed.includes(id)
|
||||
const state = useStore($sidebarWorkspaceNodeOpen)
|
||||
|
||||
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
|
||||
return [state[id] ?? defaultOpen, () => toggleWorkspaceNodeCollapsed(id, defaultOpen)]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { setWorkspaceNodeOpen } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { newSessionInProfile } from '@/store/profile'
|
||||
import { switchBranchInRepo } from '@/store/projects'
|
||||
@@ -68,6 +69,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
|
||||
}
|
||||
|
||||
const handleNewSession = async () => {
|
||||
// Reveal the lane the new session targets — an empty worktree/branch lane
|
||||
// starts collapsed, so without this the session lands in a folder the user
|
||||
// can't see. Stable across the lane's default flipping open once populated.
|
||||
setWorkspaceNodeOpen(group.id, true)
|
||||
|
||||
if (isProfileGroup) {
|
||||
newSessionInProfile(group.id)
|
||||
|
||||
|
||||
@@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => {
|
||||
expect(id).toBe('p_app')
|
||||
})
|
||||
|
||||
it('places a cwd-outside-root session under an explicit project matching either path', () => {
|
||||
// A mid-session relocation (or a sibling worktree) leaves cwd outside the
|
||||
// recorded repo root. An explicit folder match is still authoritative —
|
||||
// only the auto-project (repo root) fallback needs cwd-under-root
|
||||
// confidence. Match via the repo root...
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
|
||||
makeProject('p_proj', ['/home/u/proj'])
|
||||
])
|
||||
).toBe('p_proj')
|
||||
// ...and via the cwd.
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
|
||||
makeProject('p_www', ['/www/elsewhere'])
|
||||
])
|
||||
).toBe('p_www')
|
||||
})
|
||||
|
||||
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
|
||||
// The bug: a fresh Windows session drops into the overlay before the next
|
||||
// backend refresh; case-sensitive matching missed its project until then.
|
||||
@@ -553,6 +571,14 @@ describe('sessionProjectColor', () => {
|
||||
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('colors a cwd-outside-root session when an explicit project folder matches', () => {
|
||||
// The backend tree groups such a row under the project; the client color
|
||||
// derivation must agree instead of leaving the row (and its tab) grey.
|
||||
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
|
||||
|
||||
expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
|
||||
// liveSessionProjectId falls back to the repo root id, which is not a
|
||||
// project row and therefore carries no color.
|
||||
|
||||
@@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean {
|
||||
* the overview at once instead of waiting for the next backend refresh. Returns
|
||||
* null only for sessions we genuinely can't place from the row alone: cwd-less,
|
||||
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
|
||||
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
|
||||
* lives OUTSIDE the repo root (a sibling dir) AND under no explicit project
|
||||
* folder. An explicit-project folder match always places the row — even when
|
||||
* the row's cwd sits outside its recorded repo root (a mid-session relocation,
|
||||
* or a sibling worktree of a project repo), the folder match is authoritative;
|
||||
* only the repo-root AUTO-project fallback needs cwd-under-root confidence.
|
||||
*/
|
||||
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
|
||||
const cwd = (session.cwd || '').trim()
|
||||
@@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
||||
return null
|
||||
}
|
||||
|
||||
// With a cwd present it must sit under the repo root (a sibling worktree
|
||||
// outside the root can't be placed from the row alone); a root-only session
|
||||
// skips this — the root IS the anchor.
|
||||
if (cwd && !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let projectId = ''
|
||||
let bestLen = -1
|
||||
|
||||
@@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
||||
}
|
||||
}
|
||||
|
||||
return projectId || repoRoot
|
||||
if (projectId) {
|
||||
return projectId
|
||||
}
|
||||
|
||||
// AUTO-project fallback (the repo root itself): with a cwd present it must
|
||||
// sit under the repo root (a sibling worktree outside the root can't be
|
||||
// placed from the row alone); a root-only session skips this — the root IS
|
||||
// the anchor.
|
||||
if (cwd && !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return repoRoot
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -273,7 +273,10 @@ function useSessionActions({
|
||||
const workItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
// Fork glyph to match the inline message action's GitFork icon
|
||||
// (assistant-message.tsx). NB: this codicon font has no `git-fork`
|
||||
// glyph (only `git-fork-private`); `repo-forked` is the fork icon.
|
||||
icon: 'repo-forked',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
|
||||
@@ -14,15 +14,14 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionColorById } from '@/store/session-color'
|
||||
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SessionStatusDot } from '../session-status-dot'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
|
||||
import { sessionShowsRunningArc } from './session-row-state'
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
@@ -88,22 +87,6 @@ export function SidebarSessionRow({
|
||||
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
|
||||
// True when a clarify prompt in this session is waiting on the user.
|
||||
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
||||
// True when the session's most recent turn finished in the background (while
|
||||
// the user was viewing a different session) and hasn't been opened since.
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when the turn is still running but the stream has been quiet long
|
||||
// enough to soften the animation. This must never look like an idle row.
|
||||
const isStalled = useStore($stalledSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
// The session's resolved color (idle dot tint), read from the ONE shared map
|
||||
// the pane tabs also read — an O(1) lookup, never re-derived per render.
|
||||
const projectColor = useStore($sessionColorById)[session.id] ?? null
|
||||
|
||||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
// to collapse them at the leaf is backwards.
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
@@ -237,16 +220,16 @@ export function SidebarSessionRow({
|
||||
dragHandleProps={dragHandleProps}
|
||||
leadClassName={needsInput ? 'overflow-visible' : undefined}
|
||||
>
|
||||
<SessionRowLeadDot
|
||||
<SessionStatusDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
dotState={dotState}
|
||||
projectColor={projectColor}
|
||||
session={session}
|
||||
storedSessionId={session.id}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
|
||||
<SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
@@ -267,128 +250,3 @@ export function SidebarSessionRow({
|
||||
</SessionContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
dotState = 'idle',
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
branchStem?: string
|
||||
dotState?: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
{branchStem ? (
|
||||
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
|
||||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// A pure lookup table: each state maps to its className, aria-label, and
|
||||
// title. No priority resolution here — the call site already picked one.
|
||||
// Label/title are resolved from sidebar.row translations, keyed by name.
|
||||
type DotVariant = {
|
||||
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||
className: string
|
||||
role?: 'status'
|
||||
title?: (r: Translations['sidebar']['row']) => string
|
||||
}
|
||||
|
||||
// Shared base for every active dot; idle is smaller and uses its own class.
|
||||
const DOT_BASE = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
'needs-input': {
|
||||
ariaLabel: r => r.needsInput,
|
||||
className: `${DOT_BASE} quest-glow bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
stalled: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
|
||||
role: 'status',
|
||||
title: r => r.sessionRunning
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along". Brighter
|
||||
// than muted-foreground so it's visible against the sidebar surface.
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
// Steady green — a background session's turn completed and the user hasn't
|
||||
// opened it since. "Something new here, go look."
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarRowDot({
|
||||
dotState,
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
dotState: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// An idle session inherits its project's color (a quiet marker matching the
|
||||
// project row's own color dot). The active states (working / needs-input /
|
||||
// background / unread) own the dot and keep their semantic color, so the
|
||||
// inherited tint never competes with an attention cue.
|
||||
if (dotState === 'idle' && projectColor) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1 rounded-full', className)}
|
||||
style={{ backgroundColor: projectColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={variant.ariaLabel?.(r)}
|
||||
className={cn(variant.className, className)}
|
||||
role={variant.role}
|
||||
title={variant.title?.(r)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'nanostores'
|
||||
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
|
||||
import { SessionStatusDot } from '@/app/chat/session-status-dot'
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { IdleMount } from '@/components/idle-mount'
|
||||
@@ -53,7 +54,6 @@ import {
|
||||
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
||||
|
||||
import type { SessionDragPayload } from '../chat/composer/inline-refs'
|
||||
import { watchRouteTiles } from '../chat/route-tile'
|
||||
@@ -410,9 +410,10 @@ const syncWorkspaceTitle = () => {
|
||||
area: 'panes',
|
||||
title: stored ? storedSessionTitle(stored) : 'New session',
|
||||
data: {
|
||||
// The tab's lead dot — same shared map the sidebar row reads, so the
|
||||
// main tab and its sidebar row always show the same color.
|
||||
accent: sessionColorFor(stored),
|
||||
// The tab's status dot — the SAME primitive the sidebar row and session
|
||||
// tiles render, so the main tab never disagrees with its sidebar row. No
|
||||
// dot on a fresh draft (no session yet).
|
||||
tabLead: selected ? () => <SessionStatusDot session={stored} storedSessionId={selected} /> : undefined,
|
||||
// Pages aren't tab-able: the main zone's bar stands down while one shows.
|
||||
headerVeto: $workspaceIsPage.get(),
|
||||
placement: 'main',
|
||||
@@ -427,7 +428,6 @@ const syncWorkspaceTitle = () => {
|
||||
|
||||
$selectedStoredSessionId.listen(syncWorkspaceTitle)
|
||||
$sessions.listen(syncWorkspaceTitle)
|
||||
$sessionColorById.listen(syncWorkspaceTitle)
|
||||
$workspaceIsPage.listen(syncWorkspaceTitle)
|
||||
|
||||
// Layout reset collapses every session tile into main as a tab (after the
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Dev-only: drive the credit-notice UX without a backend that can hit real
|
||||
// usage bands. Each trigger emits ONE synthetic `notification.show` /
|
||||
// `notification.clear` gateway event through the real fan-out
|
||||
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
|
||||
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
|
||||
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
|
||||
//
|
||||
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
|
||||
// of this ships in a production build.
|
||||
|
||||
import type { GatewayEvent } from '@hermes/shared'
|
||||
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { CreditCard } from '@/lib/icons'
|
||||
import { emitLocalGatewayEvent } from '@/store/gateway'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
interface NoticeStep {
|
||||
key: string
|
||||
level: string
|
||||
kind: 'sticky' | 'ttl'
|
||||
text: string
|
||||
ttl_ms?: number
|
||||
}
|
||||
|
||||
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
|
||||
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
|
||||
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
|
||||
// previous notice when the key changes, so the demo shows one toast at a time
|
||||
// (real usage CAN stack these, but that's noise when you're eyeballing a single
|
||||
// transition). The same-key usage steps still demonstrate in-place escalation.
|
||||
const STEPS: readonly NoticeStep[] = [
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
|
||||
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
|
||||
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
|
||||
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
|
||||
]
|
||||
|
||||
let cursor = 0
|
||||
let lastShownKey: null | string = null
|
||||
|
||||
function clearNotice(key: string): void {
|
||||
emitLocalGatewayEvent({
|
||||
payload: { key },
|
||||
session_id: $activeSessionId.get() ?? '',
|
||||
type: 'notification.clear'
|
||||
} as GatewayEvent)
|
||||
}
|
||||
|
||||
function showNotice(step: NoticeStep): void {
|
||||
emitLocalGatewayEvent({
|
||||
payload: {
|
||||
id: `${step.key}:${Date.now()}`,
|
||||
key: step.key,
|
||||
kind: step.kind,
|
||||
level: step.level,
|
||||
text: step.text,
|
||||
ttl_ms: step.ttl_ms ?? null
|
||||
},
|
||||
session_id: $activeSessionId.get() ?? '',
|
||||
type: 'notification.show'
|
||||
} as GatewayEvent)
|
||||
}
|
||||
|
||||
/** Fire the next notice in the scripted sequence, wrapping at the end. */
|
||||
export function stepCreditsNoticeDemo(): void {
|
||||
const step = STEPS[cursor % STEPS.length]
|
||||
|
||||
// One toast at a time: retire the previous notice when we move to a new key.
|
||||
// (Same-key steps skip this, so the usage line still escalates in place.)
|
||||
if (lastShownKey && lastShownKey !== step.key) {
|
||||
clearNotice(lastShownKey)
|
||||
}
|
||||
|
||||
showNotice(step)
|
||||
lastShownKey = step.key
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
|
||||
// with a system Cmd chord and works the same on Windows/Linux). Matched on
|
||||
// `code` so it's keyboard-layout independent, and captured before the composer
|
||||
// so a focused input can't swallow it.
|
||||
function isTriggerChord(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
|
||||
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
|
||||
*/
|
||||
export function installCreditsNoticeDemo(): () => void {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isTriggerChord(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
stepCreditsNoticeDemo()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
|
||||
|
||||
const disposePalette = registry.register({
|
||||
id: 'dev.creditsNotice',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'dev.creditsNotice',
|
||||
icon: CreditCard,
|
||||
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
|
||||
label: 'Dev: cycle credit notices',
|
||||
run: stepCreditsNoticeDemo
|
||||
} satisfies PaletteContribution
|
||||
})
|
||||
|
||||
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
disposePalette()
|
||||
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,15 @@ import { useEffect, useRef } from 'react'
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import {
|
||||
$sessions,
|
||||
getRememberedRoute,
|
||||
getRememberedSessionId,
|
||||
rememberedSessionProfile,
|
||||
setRememberedRoute,
|
||||
setRememberedSessionId
|
||||
} from '@/store/session'
|
||||
import { onSessionsChanged } from '@/store/session-sync'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
@@ -63,7 +71,10 @@ export function useDesktopIntegrations({
|
||||
// you don't want to boot into a modal.
|
||||
useEffect(() => {
|
||||
if (routedSessionId) {
|
||||
setRememberedSessionId(routedSessionId)
|
||||
setRememberedSessionId(
|
||||
routedSessionId,
|
||||
rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get())
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOverlayView(appViewForPath(locationPathname))) {
|
||||
@@ -92,7 +103,7 @@ export function useDesktopIntegrations({
|
||||
return
|
||||
}
|
||||
|
||||
const last = getRememberedSessionId()
|
||||
const last = getRememberedSessionId($activeGatewayProfile.get())
|
||||
|
||||
if (last) {
|
||||
navigate(sessionRoute(last), { replace: true })
|
||||
@@ -100,8 +111,14 @@ export function useDesktopIntegrations({
|
||||
}, [locationPathname, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null)
|
||||
if (!resumeExhaustedSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const owner = rememberedSessionProfile($sessions.get(), resumeExhaustedSessionId, $activeGatewayProfile.get())
|
||||
|
||||
if (getRememberedSessionId(owner) === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null, owner)
|
||||
}
|
||||
}, [resumeExhaustedSessionId])
|
||||
|
||||
@@ -155,10 +172,12 @@ export function useDesktopIntegrations({
|
||||
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
|
||||
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
|
||||
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(
|
||||
() => void closeActiveTab(id => navigate(sessionRoute(id)))
|
||||
)
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
}, [navigate])
|
||||
|
||||
// Another window mutated the shared session list -> re-pull the sidebar.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesModule from '@/hermes'
|
||||
import { setSessions } from '@/store/session'
|
||||
import { sessionTileDelegate } from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { useSessionTileDelegate } from './use-session-tile-delegate'
|
||||
|
||||
vi.mock('@/hermes', async importActual => ({
|
||||
...(await importActual<typeof HermesModule>()),
|
||||
getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
|
||||
}))
|
||||
|
||||
const { getSessionMessages } = await import('@/hermes')
|
||||
|
||||
const row = (over: Partial<SessionInfo>): SessionInfo =>
|
||||
({
|
||||
ended_at: null,
|
||||
id: 'live',
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 0,
|
||||
message_count: 1,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
profile: 'default',
|
||||
source: null,
|
||||
started_at: 0,
|
||||
title: null,
|
||||
...over
|
||||
}) as SessionInfo
|
||||
|
||||
function renderTile(requestGateway: ReturnType<typeof vi.fn>) {
|
||||
renderHook(() =>
|
||||
useSessionTileDelegate({
|
||||
archiveSession: vi.fn(async () => undefined),
|
||||
branchStoredSession: vi.fn(async () => undefined),
|
||||
executeSlashCommand: vi.fn(async () => undefined) as never,
|
||||
removeSession: vi.fn(async () => undefined),
|
||||
requestGateway: requestGateway as never,
|
||||
runtimeIdByStoredSessionIdRef: { current: new Map() },
|
||||
sessionStateByRuntimeIdRef: { current: new Map() },
|
||||
updateSessionState: vi.fn()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSessionTileDelegate resumeTile', () => {
|
||||
beforeEach(() => {
|
||||
setSessions([])
|
||||
vi.mocked(getSessionMessages).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setSessions([])
|
||||
})
|
||||
|
||||
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
|
||||
// A tile opens a session owned by another profile. Resuming without the
|
||||
// profile lets the gateway fall back to the launch-profile DB and clone the
|
||||
// conversation into the wrong profile (#67603). The owning profile must ride
|
||||
// both the transcript prefetch and the resume RPC.
|
||||
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
|
||||
)
|
||||
|
||||
renderTile(requestGateway)
|
||||
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
|
||||
|
||||
expect(runtimeId).toBe('runtime-1')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-x',
|
||||
cols: 96,
|
||||
profile: 'ai-engineer'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves and carries a default-profile session explicitly', async () => {
|
||||
setSessions([row({ id: 'stored-y', profile: 'default' })])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
method === 'session.resume' ? ({ session_id: 'runtime-2' } as never) : ({} as never)
|
||||
)
|
||||
|
||||
renderTile(requestGateway)
|
||||
await sessionTileDelegate()!.resumeTile('stored-y')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-y',
|
||||
cols: 96,
|
||||
profile: 'default'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { publishSessionState, setSessionTileDelegate } from '@/store/session-sta
|
||||
import type { SessionResumeResponse } from '@/types/hermes'
|
||||
|
||||
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
|
||||
import { resolveSessionProfile } from '../../session/hooks/use-session-actions/utils'
|
||||
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
@@ -66,9 +67,20 @@ export function useSessionTileDelegate({
|
||||
return existing
|
||||
}
|
||||
|
||||
// Resolve the owning profile before binding a runtime. A tile can open a
|
||||
// session from any profile, not just the active one; resuming (or
|
||||
// reading messages) without a profile lets the gateway fall back to the
|
||||
// launch-profile DB and fork the conversation into the wrong profile —
|
||||
// the same cross-profile bleed the recovery resumes had (#67603).
|
||||
const profile = await resolveSessionProfile(storedSessionId)
|
||||
|
||||
const [prefetch, resumed] = await Promise.all([
|
||||
getSessionMessages(storedSessionId).catch(() => null),
|
||||
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
|
||||
getSessionMessages(storedSessionId, profile).catch(() => null),
|
||||
requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
...(profile ? { profile } : {})
|
||||
})
|
||||
])
|
||||
|
||||
const runtimeId = resumed?.session_id
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { emitGatewayEvent } from '@/contrib/events'
|
||||
@@ -27,6 +28,8 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
||||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { $billingSettingsRequest } from '@/store/billing-block'
|
||||
import { requestVoiceConversationStart } from '@/store/composer'
|
||||
import { setCronFocusJobId } from '@/store/cron'
|
||||
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
@@ -125,6 +128,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
|
||||
const busyRef = useRef(false)
|
||||
const creatingSessionRef = useRef(false)
|
||||
// Billing recovery routes to Settings → Billing from surfaces without router
|
||||
// context (the sticky toast). The shell owns `navigate`, so it consumes the
|
||||
// intent counter here; the ref skips the initial mount value.
|
||||
const billingSettingsSeenRef = useRef(0)
|
||||
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
|
||||
// Stable identity for the whole callback surface (see WiringActions). Mutated
|
||||
// in place each render so memoized surfaces never re-render on churn.
|
||||
@@ -132,7 +139,20 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const billingSettingsRequest = useStore($billingSettingsRequest)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
|
||||
useEffect(() => {
|
||||
if (billingSettingsRequest === billingSettingsSeenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
billingSettingsSeenRef.current = billingSettingsRequest
|
||||
|
||||
if (billingSettingsRequest > 0) {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=billing`)
|
||||
}
|
||||
}, [billingSettingsRequest, navigate])
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
@@ -235,10 +255,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
|
||||
activeSessionIdRef,
|
||||
refreshProjectBranch
|
||||
})
|
||||
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef })
|
||||
|
||||
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
|
||||
queryClient,
|
||||
@@ -256,6 +273,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
|
||||
}, [navigate])
|
||||
|
||||
// Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
|
||||
// / window.__creditsDemo). Dynamic import inside the DEV guard so the module
|
||||
// is dropped from production builds.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
return
|
||||
}
|
||||
|
||||
let dispose: (() => void) | undefined
|
||||
|
||||
void import('./dev/credits-notice-demo').then(m => {
|
||||
dispose = m.installCreditsNoticeDemo()
|
||||
})
|
||||
|
||||
return () => dispose?.()
|
||||
}, [])
|
||||
|
||||
// Post-turn rehydrate from stored history (same behavior as DesktopController,
|
||||
// including finished-todos restoration).
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
@@ -612,9 +646,25 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
const handleGatewayEventWithPlugins = useCallback(
|
||||
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
|
||||
emitGatewayEvent(event)
|
||||
|
||||
// "Hey Hermes": handle the wake event on the canonical onEvent pipeline —
|
||||
// open a fresh session (unless the config keeps the current one) and begin
|
||||
// back-and-forth voice via the composer's latched start request.
|
||||
if (event.type === 'wake.detected') {
|
||||
const payload = event.payload as { start_new_session?: boolean } | undefined
|
||||
|
||||
if (payload?.start_new_session !== false) {
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
requestVoiceConversationStart()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
handleDesktopGatewayEvent(event)
|
||||
},
|
||||
[handleDesktopGatewayEvent]
|
||||
[handleDesktopGatewayEvent, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
useGatewayBoot({
|
||||
@@ -635,6 +685,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
refreshSessions
|
||||
})
|
||||
|
||||
// "Hey Hermes" wake word: arm the server-side detector for this surface
|
||||
// (gated on config server-side). Detection arrives as a wake.detected event
|
||||
// handled in handleGatewayEventWithPlugins. Idempotent, so reconnects are safe.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined)
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
const activeIsMessaging =
|
||||
@@ -694,15 +755,33 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The tab-strip "+" and ⌘T share one action: open a new session as its own
|
||||
// tab (stacked into the workspace zone) WITHOUT polluting the session list.
|
||||
// Created `listed: false`, so each new tab's in-memory session stays out of
|
||||
// the sidebar until its first message persists a turn and a refresh surfaces
|
||||
// it — Cursor-style. Every click opens a fresh "New session" tab (multiple
|
||||
// empty tabs are fine since none touch the session list).
|
||||
const openNewSessionTab = useCallback(() => {
|
||||
void openNewSessionTile('center', { listed: false })
|
||||
}, [openNewSessionTile])
|
||||
|
||||
// Single global listener for every rebindable hotkey plus the on-screen
|
||||
// keybind editor's capture mode (same as DesktopController).
|
||||
useKeybinds({
|
||||
openNewSessionTab: () => void openNewSessionTile('center'),
|
||||
openNewSessionTab,
|
||||
startFreshSession: startFreshSessionDraft,
|
||||
toggleCommandCenter,
|
||||
toggleSelectedPin
|
||||
})
|
||||
|
||||
// Register the tab-strip "+" action (the generic renderer stays
|
||||
// session-agnostic; null until wired hides the glyph).
|
||||
useEffect(() => {
|
||||
$newSessionTabAction.set(openNewSessionTab)
|
||||
|
||||
return () => $newSessionTabAction.set(null)
|
||||
}, [openNewSessionTab])
|
||||
|
||||
// The controller's entire callback surface, gathered into the stable
|
||||
// `actions` bag. `nextActions` is TS-checked against WiringActions each
|
||||
// render; its fields are copied into the ref object so `actions` keeps one
|
||||
|
||||
@@ -181,10 +181,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
|
||||
// On macOS the menu accelerator owns ⌘W and routes through the same
|
||||
// On the main tab with session tabs stacked, it shifts the next one in —
|
||||
// the loader navigates to that session's route (loads it into main). On
|
||||
// macOS the menu accelerator owns ⌘W and routes through the same
|
||||
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
|
||||
// the Win/Linux path where ⌘W reaches the renderer directly.
|
||||
'view.closeTab': () => void closeActiveTab(),
|
||||
'view.closeTab': () => void closeActiveTab(id => navigate(sessionRoute(id))),
|
||||
'view.reopenTab': reopenLastClosedTile,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
@@ -66,6 +66,13 @@ export function OverlayView({
|
||||
'p-[calc(var(--titlebar-height)+0.625rem)]',
|
||||
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
|
||||
)}
|
||||
// Every OverlayView-based overlay (settings, command-center, agents, cron,
|
||||
// profiles, star map, …) covers the chat while the composer stays mounted
|
||||
// beneath it. This marker tells `composerFocusBlockedBySurface` to stand
|
||||
// the global type-to-focus / soft `/` / Enter down, so keystrokes don't
|
||||
// leak into the hidden composer (and the overlay's own bare-key shortcuts,
|
||||
// e.g. star map's Space, keep working).
|
||||
data-overlay-surface=""
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlay()
|
||||
|
||||
@@ -19,7 +19,7 @@ import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
|
||||
import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { $sidebarWorkspaceNodeOpen, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import {
|
||||
@@ -198,9 +198,9 @@ function ReviewDirRow({
|
||||
motion: boolean
|
||||
node: ReviewTreeNode
|
||||
}) {
|
||||
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
|
||||
const nodeOpen = useStore($sidebarWorkspaceNodeOpen)
|
||||
const id = `review:${node.id}`
|
||||
const open = !collapsed.includes(id)
|
||||
const open = nodeOpen[id] ?? true
|
||||
const toggle = () => toggleWorkspaceNodeCollapsed(id)
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,133 +47,37 @@ describe('useHermesConfig refreshHermesConfig', () => {
|
||||
persistString(WORKSPACE_CWD_KEY, null)
|
||||
})
|
||||
|
||||
it('applies terminal.cwd from config even when localStorage has a stale value', async () => {
|
||||
// Simulate a stale remembered workspace cwd
|
||||
persistString(WORKSPACE_CWD_KEY, '/Users/old/stale-project')
|
||||
setCurrentCwd('/Users/old/stale-project')
|
||||
it('does not let terminal.cwd replace an inactive selected workspace', async () => {
|
||||
setCurrentCwd('/Users/example/repo/.worktrees/feature')
|
||||
|
||||
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: null },
|
||||
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshHermesConfig()
|
||||
})
|
||||
|
||||
// The configured terminal.cwd must override the stale localStorage value
|
||||
expect($currentCwd.get()).toBe('/Users/example/new-workspace')
|
||||
expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/feature')
|
||||
})
|
||||
|
||||
it('keeps the active session workspace when a session is running', async () => {
|
||||
setCurrentCwd('/workspace/attached-project')
|
||||
it('does not let terminal.cwd replace an active session workspace', async () => {
|
||||
setCurrentCwd('/Users/example/repo/.worktrees/attached')
|
||||
|
||||
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: 'session-1' },
|
||||
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: 'session-1' } }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshHermesConfig()
|
||||
})
|
||||
|
||||
// Config refreshes mid-session must not yank the workspace out from
|
||||
// under the attached session.
|
||||
expect($currentCwd.get()).toBe('/workspace/attached-project')
|
||||
})
|
||||
|
||||
it('uses empty string when terminal.cwd is not set and localStorage is empty', async () => {
|
||||
mockConfig({})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: null },
|
||||
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshHermesConfig()
|
||||
})
|
||||
|
||||
expect($currentCwd.get()).toBe('')
|
||||
})
|
||||
|
||||
it('ignores terminal.cwd when it is "."', async () => {
|
||||
mockConfig({ terminal: { cwd: '.' } })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: null },
|
||||
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshHermesConfig()
|
||||
})
|
||||
|
||||
expect($currentCwd.get()).toBe('')
|
||||
})
|
||||
|
||||
it('calls refreshProjectBranch with the configured cwd', async () => {
|
||||
const refreshProjectBranch = vi.fn().mockResolvedValue(undefined)
|
||||
setCurrentCwd('')
|
||||
|
||||
mockConfig({ terminal: { cwd: '/workspace/project-a' } })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: null },
|
||||
refreshProjectBranch
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshHermesConfig()
|
||||
})
|
||||
|
||||
expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/project-a')
|
||||
})
|
||||
|
||||
it('refreshes the branch for the session cwd (not config) when a session is active', async () => {
|
||||
const refreshProjectBranch = vi.fn().mockResolvedValue(undefined)
|
||||
setCurrentCwd('/workspace/attached-project')
|
||||
|
||||
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: 'session-1' },
|
||||
refreshProjectBranch
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshHermesConfig()
|
||||
})
|
||||
|
||||
expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/attached-project')
|
||||
expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/attached')
|
||||
})
|
||||
|
||||
it('does not let a stale forced config refresh overwrite newer draft selector intent', async () => {
|
||||
const profileConfig = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
|
||||
vi.mocked(getHermesConfig).mockReturnValueOnce(profileConfig.promise)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: null },
|
||||
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
|
||||
|
||||
let pendingRefresh!: Promise<void>
|
||||
act(() => {
|
||||
@@ -203,12 +107,7 @@ describe('useHermesConfig refreshHermesConfig', () => {
|
||||
const profileC = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
|
||||
vi.mocked(getHermesConfig).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useHermesConfig({
|
||||
activeSessionIdRef: { current: null },
|
||||
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
|
||||
|
||||
let refreshB!: Promise<void>
|
||||
let refreshC!: Promise<void>
|
||||
|
||||
@@ -4,11 +4,9 @@ import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import { normalize } from '@/lib/text'
|
||||
import {
|
||||
$currentCwd,
|
||||
getComposerSelectionGeneration,
|
||||
getCurrentModelSource,
|
||||
setAvailablePersonalities,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentPersonality,
|
||||
setCurrentReasoningEffort,
|
||||
@@ -43,10 +41,9 @@ function normalizeConfigEffort(value: unknown): string {
|
||||
|
||||
interface HermesConfigOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
refreshProjectBranch: (cwd: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
|
||||
export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
const profileRefreshEpochRef = useRef(0)
|
||||
@@ -83,16 +80,6 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
|
||||
])
|
||||
])
|
||||
|
||||
const cwd = (config.terminal?.cwd ?? '').trim()
|
||||
|
||||
if (cwd && cwd !== '.') {
|
||||
// Configured terminal.cwd beats a stale remembered workspace cwd
|
||||
// (#38855) — but never yank the workspace out from under an active
|
||||
// session; those keep their own cwd until the user detaches.
|
||||
setCurrentCwd(prev => (activeSessionIdRef.current ? prev : cwd))
|
||||
void refreshProjectBranch($currentCwd.get() || cwd)
|
||||
}
|
||||
|
||||
const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort)
|
||||
const tier = (config.agent?.service_tier ?? '').trim()
|
||||
|
||||
@@ -115,7 +102,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, refreshProjectBranch]
|
||||
[activeSessionIdRef]
|
||||
)
|
||||
|
||||
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
// A `clarify.request` must leave an answerable inline row even when the
|
||||
// `tool.start` that normally mounts it was missed (stream reconnect /
|
||||
// hydration race). Without it the sidebar says "needs input" but the
|
||||
// transcript has nowhere to render the choices, so the agent blocks forever.
|
||||
|
||||
const SID = 'session-1'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
stateRef = sessionStateByRuntimeIdRef
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
|
||||
const clarifyRequest = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' }))
|
||||
|
||||
const toolStart = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' }))
|
||||
|
||||
function clarifyParts() {
|
||||
const messages = stateRef?.current.get(SID)?.messages ?? []
|
||||
|
||||
return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify')
|
||||
}
|
||||
|
||||
describe('clarify.request stream hydration', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
stateRef = null
|
||||
clearClarifyRequest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearClarifyRequest()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('mounts an answerable clarify row when the tool.start row was missed', async () => {
|
||||
await mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' })
|
||||
|
||||
const parts = clarifyParts()
|
||||
expect(parts).toHaveLength(1)
|
||||
expect(parts[0].type === 'tool-call' && parts[0].toolCallId).toBe('req-1')
|
||||
expect(parts[0].type === 'tool-call' && parts[0].args).toMatchObject({
|
||||
choices: ['yes', 'no'],
|
||||
question: 'Ship it?'
|
||||
})
|
||||
})
|
||||
|
||||
it('merges with the real tool.start row even though its id differs from the request id', async () => {
|
||||
await mountStream()
|
||||
|
||||
// Reality: tool.start carries the model's tool_call_id, clarify.request a
|
||||
// separately-generated request_id. They must still collapse to ONE card
|
||||
// (correlated by question), not two.
|
||||
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' })
|
||||
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-2' })
|
||||
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate when clarify.request arrives before the tool.start row', async () => {
|
||||
await mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' })
|
||||
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' })
|
||||
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BillingBlock } from '@hermes/shared'
|
||||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
@@ -14,14 +15,16 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { revealDesktopPane } from '@/store/pane-focus'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -57,6 +60,50 @@ import type { ClientSessionState } from '../../../types'
|
||||
|
||||
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
|
||||
|
||||
function firstBillingLine(text: string): string {
|
||||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn failed on a billing wall (out of credits / payment required). The
|
||||
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
|
||||
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
|
||||
* billing-specific toast — never the generic "Hermes error" — with a smart CTA
|
||||
* (Nous → in-app Settings → Billing, other providers → their billing page).
|
||||
*/
|
||||
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const block = raw as BillingBlock
|
||||
|
||||
if (typeof block.provider !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
setBillingBlock(sessionId, block)
|
||||
|
||||
const ctaCopy = {
|
||||
addCredits: translateNow('billingBlock.addCredits'),
|
||||
openBilling: translateNow('billingBlock.openBilling')
|
||||
}
|
||||
|
||||
notify({
|
||||
// Collapse repeat walls from the same provider into one toast.
|
||||
id: `billing-block:${block.provider}`,
|
||||
kind: 'warning',
|
||||
icon: 'credit-card',
|
||||
title: block.is_nous
|
||||
? translateNow('billingBlock.titleNous')
|
||||
: translateNow('billingBlock.titleProvider', block.provider_label),
|
||||
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
|
||||
// Sticky: a credit wall blocks every turn until resolved.
|
||||
durationMs: 0,
|
||||
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
|
||||
})
|
||||
}
|
||||
|
||||
const COMPACTION_RESUME_EVENT_TYPES = new Set([
|
||||
'message.delta',
|
||||
'message.interim',
|
||||
@@ -361,9 +408,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
|
||||
if (typeof payload?.credential_warning === 'string' && payload.credential_warning) {
|
||||
requestDesktopOnboarding(payload.credential_warning)
|
||||
}
|
||||
requestDesktopOnboardingForCredentialWarning(payload?.credential_warning)
|
||||
|
||||
if (apply) {
|
||||
reportInstallMethodWarning(payload?.install_warning)
|
||||
@@ -390,6 +435,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
// A fresh turn on this session optimistically clears its billing wall;
|
||||
// if credits are still exhausted the next failure re-raises it.
|
||||
clearBillingBlock(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamStart')
|
||||
@@ -513,6 +561,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
|
||||
|
||||
// Structured billing wall forwarded by the gateway (out of credits /
|
||||
// payment required) — cache it + raise a billing-specific toast.
|
||||
if (payload?.billing) {
|
||||
surfaceBillingBlock(sessionId, payload.billing)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
@@ -622,21 +676,36 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
// over; the inline ClarifyTool reads the active session's entry.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const question = typeof payload?.question === 'string' ? payload.question : ''
|
||||
const rawChoices = payload?.choices
|
||||
const choices = normalizeChoices(rawChoices)
|
||||
|
||||
if (requestId && question) {
|
||||
if (rawChoices != null && choices.length === 0) {
|
||||
warnDroppedChoices('gateway', question, rawChoices)
|
||||
}
|
||||
|
||||
setClarifyRequest({
|
||||
requestId,
|
||||
question,
|
||||
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
|
||||
choices: choices.length > 0 ? choices : null,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
if (sessionId) {
|
||||
// `clarify.request` is the blocking event the Python side waits on,
|
||||
// while the inline UI normally mounts from the earlier `tool.start`
|
||||
// row. If that row was missed (stream reconnect / hydration race) the
|
||||
// sidebar still says "needs input" but there is nowhere to render the
|
||||
// choices. Upsert a stable pending clarify tool row from the request
|
||||
// itself so the prompt stays answerable; a real tool.start/complete
|
||||
// with the same request id merges rather than duplicates.
|
||||
upsertToolCall(sessionId, { args: { choices, question }, name: 'clarify', tool_id: requestId }, 'running')
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
@@ -797,6 +866,37 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
]
|
||||
}))
|
||||
}
|
||||
} else if (event.type === 'notification.show') {
|
||||
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
|
||||
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
|
||||
// status bar; the desktop renders them as toasts. The notice key doubles
|
||||
// as the toast id, so the escalating 50→75→90 credits line replaces in
|
||||
// place instead of stacking. Account-wide signal — shown regardless of
|
||||
// which session is focused.
|
||||
const notice = event.payload as AgentNoticePayload | undefined
|
||||
|
||||
showAgentNotice(notice)
|
||||
|
||||
// The urgent pair (access paused / restored) also breaks through as a
|
||||
// native OS notification when Hermes is backgrounded; dispatch is gated
|
||||
// by the user's notification prefs + backgrounded check.
|
||||
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
|
||||
|
||||
if (native) {
|
||||
dispatchNativeNotification(native)
|
||||
}
|
||||
|
||||
// A credits crossing moves the account balance. Settings → Billing polls
|
||||
// `billing.state` every 30s; nudge it so the page reflects the crossing
|
||||
// immediately instead of up to 30s late.
|
||||
if (notice?.key?.startsWith('credits.')) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
}
|
||||
} else if (event.type === 'notification.clear') {
|
||||
// Key-matched dismissal (e.g. credits restored clears the depleted
|
||||
// notice). notify() keys the toast by the notice key, so this maps
|
||||
// straight to dismissNotification(key).
|
||||
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSession } from '@/hermes'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
@@ -25,6 +26,7 @@ import { uploadComposerAttachment, usePromptActions } from '.'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
getSession: vi.fn(),
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
|
||||
setApiRequestProfile: vi.fn(),
|
||||
transcribeAudio: vi.fn()
|
||||
@@ -1062,11 +1064,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
markVoicePlaybackInterrupted()
|
||||
@@ -1838,6 +1836,99 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
// #67603 (second symptom): a recovery resume must re-register on the session's
|
||||
// OWNING profile. Resuming on whichever profile is live forks the conversation
|
||||
// into the wrong profile's DB — the session then appears under both profiles.
|
||||
it('carries the owning profile from the cache into the recovery resume', async () => {
|
||||
setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'work' })])
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
|
||||
|
||||
setSessions(() => [])
|
||||
})
|
||||
|
||||
// The session lives on another profile and is outside the paginated sidebar
|
||||
// cache: resolve it by id across profiles rather than resuming profile-blind.
|
||||
it('resolves the owning profile across profiles when the session is not cached', async () => {
|
||||
// module-factory vi.fn is not reset by restoreAllMocks — reset explicitly in
|
||||
// the finally below so this resolved value never leaks into sibling tests.
|
||||
setSessions(() => [])
|
||||
vi.mocked(getSession).mockResolvedValue(sessionInfo({ id: STORED_SESSION_ID, profile: 'work' }))
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
setSessions(() => [])
|
||||
})
|
||||
|
||||
it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
ImageAttachResponse,
|
||||
SessionRedirectResponse
|
||||
} from '../../../types'
|
||||
import { resolveSessionProfile } from '../use-session-actions/utils'
|
||||
|
||||
import {
|
||||
applyBranchVisibility,
|
||||
@@ -220,7 +221,13 @@ export function usePromptActions({
|
||||
const copy = t.desktop
|
||||
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(sessionId: string, role: ChatMessage['role'], text: string, storedSessionId?: string | null) => {
|
||||
(
|
||||
sessionId: string,
|
||||
role: ChatMessage['role'],
|
||||
text: string,
|
||||
storedSessionId?: string | null,
|
||||
options: { insertBeforeActiveReply?: boolean } = {}
|
||||
) => {
|
||||
// Strip ANSI: slash-command output from the backend worker carries SGR
|
||||
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
|
||||
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
|
||||
@@ -231,21 +238,39 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role,
|
||||
parts: [textPart(body)]
|
||||
}
|
||||
]
|
||||
}),
|
||||
state => {
|
||||
const message: ChatMessage = {
|
||||
id: messageId,
|
||||
role,
|
||||
parts: [textPart(body)]
|
||||
}
|
||||
|
||||
const streamIndex =
|
||||
options.insertBeforeActiveReply && state.streamId
|
||||
? state.messages.findIndex(candidate => candidate.id === state.streamId)
|
||||
: -1
|
||||
|
||||
const lastAssistantIndex = options.insertBeforeActiveReply
|
||||
? state.messages.map(candidate => candidate.role).lastIndexOf('assistant')
|
||||
: -1
|
||||
|
||||
const insertionIndex = streamIndex >= 0 ? streamIndex : lastAssistantIndex
|
||||
|
||||
const messages =
|
||||
insertionIndex >= 0
|
||||
? [...state.messages.slice(0, insertionIndex), message, ...state.messages.slice(insertionIndex)]
|
||||
: [...state.messages, message]
|
||||
|
||||
return { ...state, messages }
|
||||
},
|
||||
storedSessionId ?? selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
return messageId
|
||||
},
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
@@ -577,9 +602,12 @@ export function usePromptActions({
|
||||
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
@@ -620,15 +648,51 @@ export function usePromptActions({
|
||||
// message after the interrupted checkpoint, matching the durable core
|
||||
// transcript rather than a system note that changes role after reload.
|
||||
const send = async (id: string): Promise<boolean> => {
|
||||
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
|
||||
// Redirect aborts the model request, so the completion event can race
|
||||
// its RPC response. Insert before the live reply *before* awaiting the
|
||||
// gateway; appending after the response leaves the correction below a
|
||||
// reply that the redirect has already replaced.
|
||||
const messageId = appendSessionTextMessage(id, 'user', text, undefined, { insertBeforeActiveReply: true })
|
||||
|
||||
if (result?.status === 'redirected' || result?.status === 'queued') {
|
||||
triggerHaptic('submit')
|
||||
appendSessionTextMessage(id, 'user', text)
|
||||
const discardOptimisticMessage = () =>
|
||||
updateSessionState(id, state => ({
|
||||
...state,
|
||||
messages: state.messages.filter(message => message.id !== messageId)
|
||||
}))
|
||||
|
||||
return true
|
||||
const moveOptimisticMessageToEnd = () =>
|
||||
updateSessionState(id, state => {
|
||||
const message = state.messages.find(candidate => candidate.id === messageId)
|
||||
|
||||
return message
|
||||
? { ...state, messages: [...state.messages.filter(candidate => candidate.id !== messageId), message] }
|
||||
: state
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
|
||||
|
||||
if (result?.status === 'redirected') {
|
||||
triggerHaptic('submit')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
// Build-window redirects become the next turn, not part of the
|
||||
// active reply, so retain the optimistic row at the tail.
|
||||
moveOptimisticMessageToEnd()
|
||||
triggerHaptic('submit')
|
||||
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
discardOptimisticMessage()
|
||||
throw err
|
||||
}
|
||||
|
||||
discardOptimisticMessage()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -640,9 +704,12 @@ export function usePromptActions({
|
||||
// correction right after a reconnect isn't lost to the race.
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
@@ -661,7 +728,14 @@ export function usePromptActions({
|
||||
|
||||
return false
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway, selectedStoredSessionIdRef]
|
||||
[
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
appendSessionTextMessage,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
|
||||
@@ -24,6 +24,7 @@ import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
import { sessionContextDrift } from '../session-context-drift'
|
||||
import { resolveSessionProfile } from '../use-session-actions/utils'
|
||||
|
||||
import {
|
||||
_submitInFlight,
|
||||
@@ -384,9 +385,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
// background queue drain only has the durable id). Continue that target
|
||||
// conversation; only a genuine new-chat draft may create a new session.
|
||||
try {
|
||||
// Re-register on the session's OWNING profile — resuming on whichever
|
||||
// profile is live would fork the conversation into the wrong DB (#67603).
|
||||
const resumeProfile = await resolveSessionProfile(targetStoredSessionId)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: targetStoredSessionId,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const resumeDrift = sessionDriftReason()
|
||||
@@ -528,9 +534,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
// backend loop (#55578 symptom d) rejects the submit even though
|
||||
// the stored session is fine — resume + retry instead of erroring
|
||||
// out and losing the session binding.
|
||||
const resumeProfile = await resolveSessionProfile(recoverStoredSessionId)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: recoverStoredSessionId,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const resumeRetryDrift = sessionDriftReason()
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSessionMessages, type SessionInfo } from '@/hermes'
|
||||
import { getSession, getSessionMessages, type SessionInfo } from '@/hermes'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
setSelectedStoredSessionId,
|
||||
setSessions
|
||||
} from '@/store/session'
|
||||
import { $sessionTiles } from '@/store/session-states'
|
||||
|
||||
import { sessionRoute } from '../../routes'
|
||||
import type { ClientSessionState } from '../../types'
|
||||
@@ -42,6 +43,7 @@ import { useSessionActions } from './use-session-actions'
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
deleteSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
listAllProfileSessions: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
@@ -975,9 +977,11 @@ describe('resumeSession failure recovery', () => {
|
||||
})
|
||||
|
||||
function BranchHarness({
|
||||
navigate = vi.fn(),
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
navigate?: ReturnType<typeof vi.fn>
|
||||
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
@@ -991,7 +995,7 @@ function BranchHarness({
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
getRouteToken: () => 'token',
|
||||
getRoutedStoredSessionId: () => null,
|
||||
navigate: vi.fn() as never,
|
||||
navigate: navigate as never,
|
||||
requestGateway,
|
||||
resetViewSync: vi.fn(),
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
@@ -1013,9 +1017,48 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setSessions([])
|
||||
$sessionTiles.set([])
|
||||
setSelectedStoredSessionId(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens the branch as a new tab and leaves the parent chat selected', async () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
// Parent is the currently-open (primary) chat.
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
|
||||
setSelectedStoredSessionId('stored-parent')
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
const navigate = vi.fn()
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(
|
||||
<BranchHarness
|
||||
navigate={navigate}
|
||||
onReady={branch => (branchStoredSession = branch)}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
// The branch opened as its own tab...
|
||||
expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true)
|
||||
// ...without stealing the primary selection or navigating away from the parent.
|
||||
expect($selectedStoredSessionId.get()).toBe('stored-parent')
|
||||
expect(navigate).not.toHaveBeenCalledWith(sessionRoute('branch-stored'))
|
||||
})
|
||||
|
||||
it('tags desktop branch sessions as desktop sessions', async () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
@@ -1046,6 +1089,40 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
source: 'desktop'
|
||||
})
|
||||
})
|
||||
|
||||
// #67603: right-clicking a session outside the paginated sidebar window is a
|
||||
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
|
||||
// swap to it before reading the transcript / creating the branch, so the fork
|
||||
// is not created on whichever profile happens to be live.
|
||||
it('resolves and swaps to the parent profile when the branched session is not cached', async () => {
|
||||
setSessions([])
|
||||
vi.mocked(getSession).mockResolvedValue(storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' }))
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) | null =
|
||||
null
|
||||
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
|
||||
|
||||
@@ -13,7 +13,13 @@ import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
|
||||
import {
|
||||
beginSessionMutation,
|
||||
endSessionMutation,
|
||||
resolveNewSessionCwd,
|
||||
tombstoneSessions,
|
||||
untombstoneSessions
|
||||
} from '@/store/projects'
|
||||
import {
|
||||
$activeSessionStoredIdRotation,
|
||||
$currentCwd,
|
||||
@@ -457,10 +463,19 @@ export function useSessionActions({
|
||||
)
|
||||
|
||||
/** Create a fresh session and open it as a tile — leaves the primary chat alone.
|
||||
* Used by the New session row's "Open in split" menu (and any future
|
||||
* "new chat beside" affordance). */
|
||||
* Used by the New session row's "Open in split" menu and the tab-strip "+".
|
||||
*
|
||||
* `listed` (default true) controls sidebar visibility. A brand-new backend
|
||||
* session is IN-MEMORY only until its first turn persists a row, so
|
||||
* `listSessions(min_messages=1)` already hides an unused one — the sidebar
|
||||
* pollution comes solely from the optimistic upsert here. The tab-strip "+"
|
||||
* passes `listed: false` so an unused new tab never clutters the session
|
||||
* list (Cursor-style draft tab); it surfaces on the next refresh once the
|
||||
* first message persists a turn. "Open in split" keeps the listed behavior. */
|
||||
const openNewSessionTile = useCallback(
|
||||
async (dir: TileDock = 'right') => {
|
||||
async (dir: TileDock = 'right', options?: { listed?: boolean }) => {
|
||||
const listed = options?.listed ?? true
|
||||
|
||||
try {
|
||||
// Fresh tile → the resolved new-session cwd (project/default), not the
|
||||
// primary composer's live cwd.
|
||||
@@ -476,17 +491,25 @@ export function useSessionActions({
|
||||
}
|
||||
|
||||
createdThisRun.add(stored)
|
||||
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
|
||||
// selection — this session lives in the tile. Prime it with the create
|
||||
// runtime so the tile skips a redundant resume.
|
||||
upsertOptimisticSession(created, stored, null, null)
|
||||
|
||||
// Seed the per-runtime cache so the tile renders immediately without a
|
||||
// redundant resume. Only add the row to the SIDEBAR when `listed` — an
|
||||
// unlisted (draft) tab stays out of the session list until its first
|
||||
// turn persists and a refresh surfaces it.
|
||||
if (listed) {
|
||||
upsertOptimisticSession(created, stored, null, null)
|
||||
}
|
||||
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
|
||||
|
||||
openSessionTile(stored, dir)
|
||||
patchSessionTile(stored, { runtimeId: created.session_id })
|
||||
revealTreePane(`session-tile:${stored}`)
|
||||
broadcastSessionsChanged()
|
||||
|
||||
if (listed) {
|
||||
broadcastSessionsChanged()
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, copy.createSessionFailed)
|
||||
}
|
||||
@@ -1030,7 +1053,8 @@ export function useSessionActions({
|
||||
)
|
||||
|
||||
// Shared fork: create a child session seeded with `branchMessages`, linked to
|
||||
// `parentStoredId` so it nests under its parent, then make it the active chat.
|
||||
// `parentStoredId` so it nests under its parent, then open it as its own tab
|
||||
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
|
||||
const forkBranch = useCallback(
|
||||
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||
creatingSessionRef.current = true
|
||||
@@ -1067,8 +1091,6 @@ export function useSessionActions({
|
||||
parent ? parent.last_active || parent.started_at : undefined
|
||||
)
|
||||
ensureSessionState(branched.session_id, routedSessionId)
|
||||
setActiveSessionId(branched.session_id)
|
||||
activeSessionIdRef.current = branched.session_id
|
||||
updateSessionState(
|
||||
branched.session_id,
|
||||
state => ({
|
||||
@@ -1079,9 +1101,6 @@ export function useSessionActions({
|
||||
}),
|
||||
routedSessionId
|
||||
)
|
||||
setSelectedStoredSessionId(routedSessionId)
|
||||
selectedStoredSessionIdRef.current = routedSessionId
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
@@ -1090,6 +1109,15 @@ export function useSessionActions({
|
||||
updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId)
|
||||
}
|
||||
|
||||
// Open the branch as its own tab and switch to it, leaving the parent
|
||||
// chat exactly where it is. Prime the tile with the create runtime so it
|
||||
// skips a redundant resume. Do NOT select it as the primary session
|
||||
// first — openSessionTile no-ops when the id is already primary.
|
||||
openSessionTile(routedSessionId, 'center')
|
||||
patchSessionTile(routedSessionId, { runtimeId: branched.session_id })
|
||||
revealTreePane(`session-tile:${routedSessionId}`)
|
||||
broadcastSessionsChanged()
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, copy.branchFailed)
|
||||
@@ -1101,16 +1129,7 @@ export function useSessionActions({
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
copy,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
[copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
// Branch the open chat — optionally from a specific message — off its live transcript.
|
||||
@@ -1158,7 +1177,13 @@ export function useSessionActions({
|
||||
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
|
||||
clearNotifications()
|
||||
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
// Right-clicking a session outside the paginated sidebar window is a cache
|
||||
// miss: resolve it (cache → active backend → cross-profile) so the branch
|
||||
// is created on the parent's OWNING profile, not whichever is live (#67603).
|
||||
const stored =
|
||||
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ??
|
||||
(sessionProfile ? undefined : await resolveStoredSession(storedSessionId))
|
||||
|
||||
const profile = sessionProfile ?? stored?.profile
|
||||
|
||||
try {
|
||||
@@ -1194,12 +1219,15 @@ export function useSessionActions({
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
const removedIds = [storedSessionId, removed?.id, removed?._lineage_root_id]
|
||||
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
// Evict from the project tree's optimistic layer too (the backend snapshot
|
||||
// still lists it until its next refresh), so grouped + flat views drop the
|
||||
// row in lockstep.
|
||||
tombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||
// row in lockstep. Pin the tombstone against the projects.tree prune while
|
||||
// the delete RPC is in flight, so a racing refresh can't flash it back.
|
||||
tombstoneSessions(removedIds)
|
||||
beginSessionMutation(removedIds)
|
||||
// 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))
|
||||
@@ -1240,7 +1268,7 @@ export function useSessionActions({
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||
untombstoneSessions(removedIds)
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
if (wasSelected) {
|
||||
@@ -1263,6 +1291,11 @@ export function useSessionActions({
|
||||
}
|
||||
|
||||
notifyError(err, copy.deleteFailed)
|
||||
} finally {
|
||||
// Release the tombstone to the normal projects.tree prune now the RPC has
|
||||
// settled (kept on success — the backend has deleted it; cleared on the
|
||||
// rollback above on failure).
|
||||
endSessionMutation(removedIds)
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1289,10 +1322,12 @@ export function useSessionActions({
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
|
||||
const archivedIds = [storedSessionId, archived?.id, archived?._lineage_root_id]
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
tombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||
tombstoneSessions(archivedIds)
|
||||
beginSessionMutation(archivedIds)
|
||||
// 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.
|
||||
@@ -1305,12 +1340,6 @@ export function useSessionActions({
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
// A sidebar refresh can race the optimistic removal while the PATCH is
|
||||
// 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(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
// An archived session is hidden from the sidebar; its tile must go too.
|
||||
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
closeSessionTile(storedSessionId)
|
||||
@@ -1328,9 +1357,11 @@ export function useSessionActions({
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||
untombstoneSessions(archivedIds)
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, copy.archiveFailed)
|
||||
} finally {
|
||||
endSessionMutation(archivedIds)
|
||||
}
|
||||
},
|
||||
[copy, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, sessionStateByRuntimeIdRef, startFreshSessionDraft]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
@@ -38,6 +39,32 @@ describe('applyRuntimeInfo approval mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const initialOnboardingState = $desktopOnboarding.get()
|
||||
|
||||
describe('applyRuntimeInfo credential warnings', () => {
|
||||
beforeEach(() => {
|
||||
$desktopOnboarding.set({ ...initialOnboardingState, reason: null, requested: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$desktopOnboarding.set(initialOnboardingState)
|
||||
})
|
||||
|
||||
it('requests setup for the exact empty-key warning returned by the server', () => {
|
||||
const warning = "No API key configured for provider 'openrouter'. First message will fail."
|
||||
|
||||
applyRuntimeInfo({ credential_warning: warning })
|
||||
|
||||
expect($desktopOnboarding.get()).toMatchObject({ reason: warning, requested: true })
|
||||
})
|
||||
|
||||
it('ignores an auxiliary-provider warning', () => {
|
||||
applyRuntimeInfo({ credential_warning: 'OPENROUTER_API_KEY not set' })
|
||||
|
||||
expect($desktopOnboarding.get()).toMatchObject({ reason: null, requested: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionGoneError', () => {
|
||||
it('is true for 404 / session-not-found, false otherwise', () => {
|
||||
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
|
||||
@@ -342,11 +369,9 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
msg('user-inflight', 'user', 'the one genuinely in-flight prompt')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual([
|
||||
'stored-user',
|
||||
'stored-assistant',
|
||||
'user-inflight'
|
||||
])
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual(
|
||||
['stored-user', 'stored-assistant', 'user-inflight']
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the live tail once the latest authoritative user has persisted it after compression', () => {
|
||||
@@ -363,6 +388,58 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
|
||||
})
|
||||
|
||||
// #67603: the gateway persists model-switch / personality notices as role=user
|
||||
// ([System: …], tui_gateway/server.py). A single trailing marker is already
|
||||
// handled by the latestAuthoritativeUser guard above, but TWO switches around
|
||||
// one turn put a marker BEFORE the committed prompt (shifting its ordinal) and
|
||||
// another AFTER it (so the prompt is no longer the last user row, so the text
|
||||
// guard can't rescue it). Naive ordinal pairing then pairs the optimistic row
|
||||
// against a marker, treats it as uncommitted, and re-appends it — the
|
||||
// duplicated user bubble stacked at the bottom of the chat.
|
||||
it('does not duplicate the optimistic prompt when markers bracket it (two model switches)', () => {
|
||||
const marker = (name: string) => `[System: The active model for this chat has changed to ${name}.]`
|
||||
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'second question')
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('s1-user', 'user', 'first'),
|
||||
msg('s2-assistant', 'assistant', 'first answer'),
|
||||
msg('s3-marker', 'user', marker('k2')),
|
||||
msg('s4-user', 'user', 'second question'),
|
||||
msg('s5-assistant', 'assistant', 'second answer'),
|
||||
msg('s6-marker', 'user', marker('k3'))
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it('still keeps a genuinely uncommitted optimistic turn when a marker is present', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'new question')
|
||||
]
|
||||
|
||||
// The marker is persisted but the new prompt has not committed yet — the
|
||||
// optimistic row must survive (marker exclusion must not over-correct).
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first'),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer'),
|
||||
msg('3-marker-stored', 'user', '[System: The active model for this chat has changed to k3.]')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
|
||||
'1-user-stored',
|
||||
'2-assistant-stored',
|
||||
'3-marker-stored',
|
||||
'user-optimistic'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendLiveSessionProjection', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
@@ -240,7 +240,17 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
* history, not in-flight work. The latest authoritative user confirms whether
|
||||
* that tail has persisted; any authoritative assistant at the same ordinal
|
||||
* supersedes the local stream.
|
||||
*
|
||||
* Gateway bookkeeping markers (the model-switch / personality notices written
|
||||
* by tui_gateway/server.py) are persisted as role=user but are not user turns.
|
||||
* They must not take part in ordinal pairing on either side: a stored marker
|
||||
* between two real user turns shifts every later user ordinal, so the optimistic
|
||||
* row misses its committed copy and is appended a second time at the end of the
|
||||
* transcript — the duplicated user bubble of #67603.
|
||||
*/
|
||||
const isGatewaySystemMarker = (message: ChatMessage): boolean =>
|
||||
message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:')
|
||||
|
||||
export function preserveLocalPendingTurnMessages(
|
||||
nextMessages: ChatMessage[],
|
||||
previousMessages: ChatMessage[]
|
||||
@@ -253,6 +263,10 @@ export function preserveLocalPendingTurnMessages(
|
||||
const nextRoleCounts = new Map<ChatMessage['role'], number>()
|
||||
|
||||
for (const message of nextMessages) {
|
||||
if (isGatewaySystemMarker(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ordinal = nextRoleCounts.get(message.role) ?? 0
|
||||
nextRoleCounts.set(message.role, ordinal + 1)
|
||||
nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
|
||||
@@ -260,13 +274,19 @@ export function preserveLocalPendingTurnMessages(
|
||||
|
||||
const nextIds = new Set(nextMessages.map(message => message.id))
|
||||
const previousRoleCounts = new Map<ChatMessage['role'], number>()
|
||||
|
||||
const newestOptimisticUser = [...previousMessages]
|
||||
.reverse()
|
||||
.find(message => message.role === 'user' && message.id.startsWith('user-'))
|
||||
|
||||
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
|
||||
const preserved: ChatMessage[] = []
|
||||
|
||||
for (const message of previousMessages) {
|
||||
if (isGatewaySystemMarker(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ordinal = previousRoleCounts.get(message.role) ?? 0
|
||||
previousRoleCounts.set(message.role, ordinal + 1)
|
||||
|
||||
@@ -283,7 +303,11 @@ export function preserveLocalPendingTurnMessages(
|
||||
continue
|
||||
}
|
||||
|
||||
if (isOptimisticUser && latestAuthoritativeUser && chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()) {
|
||||
if (
|
||||
isOptimisticUser &&
|
||||
latestAuthoritativeUser &&
|
||||
chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -491,6 +515,28 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile that owns a stored session, resolved through the same
|
||||
* cache → active-backend → cross-profile ladder as `resolveStoredSession`.
|
||||
*
|
||||
* Recovery `session.resume` calls (stale runtime id, session-not-found, wedged
|
||||
* loop) must re-register the conversation on ITS backend, not on whichever
|
||||
* profile happens to be live. Omitting the profile lets the gateway fall back to
|
||||
* the launch-profile DB (tui_gateway/server.py), which is how a session bleeds
|
||||
* from one profile into another (#67603, second symptom). A cache-only lookup
|
||||
* misses any session outside the paginated sidebar window, so route through the
|
||||
* resolver, which probes uncached ids across profiles.
|
||||
*/
|
||||
export async function resolveSessionProfile(storedSessionId: null | string): Promise<string | undefined> {
|
||||
if (!storedSessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const profile = (await resolveStoredSession(storedSessionId))?.profile?.trim()
|
||||
|
||||
return profile || undefined
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
@@ -511,9 +557,7 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
|
||||
reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode)
|
||||
}
|
||||
|
||||
if (info.credential_warning) {
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
requestDesktopOnboardingForCredentialWarning(info.credential_warning)
|
||||
|
||||
reportInstallMethodWarning(info.install_warning)
|
||||
|
||||
|
||||
@@ -62,9 +62,18 @@ vi.mock('@/hermes', async importOriginal => ({
|
||||
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
|
||||
}))
|
||||
|
||||
// The refresh only reads the optimistic tombstone set; stub it so we don't pull
|
||||
// the whole projects store (gateway / fs / git) into this hook's test.
|
||||
const removed = vi.hoisted(() => ({ ids: new Set<string>() }))
|
||||
|
||||
vi.mock('@/store/projects', () => ({
|
||||
$removedSessionIds: { get: () => removed.ids }
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
listSidebarSessions.mockReset()
|
||||
listAllProfileSessions.mockReset()
|
||||
removed.ids = new Set()
|
||||
setSessions([])
|
||||
setCronSessions([])
|
||||
setMessagingSessions([])
|
||||
@@ -146,6 +155,28 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
expect(loadingStates).toEqual([false])
|
||||
})
|
||||
|
||||
it('drops rows the user just deleted, even when the backend page still lists them', async () => {
|
||||
// A delete RPC is in flight: the row is tombstoned optimistically but the
|
||||
// batched refresh still carries it (and a lineage-tip variant). Both must be
|
||||
// filtered so the optimistic removal never flashes back.
|
||||
removed.ids = new Set(['b', 'root-c'])
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({
|
||||
sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial<SessionInfo>)],
|
||||
total: 3,
|
||||
profile_totals: {}
|
||||
})
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
expect($sessions.get().map(s => s.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('still shows loading for the initial (empty-list) fetch', async () => {
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { setCronJobs } from '@/store/cron'
|
||||
import { $pinnedSessionIds, $sessionsLimit, bumpSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout'
|
||||
import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
|
||||
import { $removedSessionIds } from '@/store/projects'
|
||||
import {
|
||||
$messagingSessions,
|
||||
$selectedStoredSessionId,
|
||||
@@ -180,12 +181,24 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
const recents = result.recents
|
||||
|
||||
// Drop rows the user just deleted/archived: a refresh can race an
|
||||
// in-flight mutation and the backend page still carries the doomed row.
|
||||
// Honoring the optimistic tombstone keeps the removal from flashing back
|
||||
// (the tombstone self-clears once projects.tree confirms the delete).
|
||||
const tombstones = $removedSessionIds.get()
|
||||
|
||||
const incoming = tombstones.size
|
||||
? recents.sessions.filter(
|
||||
s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id))
|
||||
)
|
||||
: recents.sessions
|
||||
|
||||
// Signature-gate the swap (same pattern as cron/messaging): a refresh
|
||||
// that returns content-identical rows must keep the previous array
|
||||
// identity, or every sidebar memo keyed on $sessions recomputes and the
|
||||
// whole list re-renders once per turn/broadcast for nothing.
|
||||
setSessions(prev => {
|
||||
const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())
|
||||
const next = mergeSessionPage(prev, incoming, sessionsToKeep())
|
||||
|
||||
return sameCronSignature(prev, next) ? prev : next
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
todayBillingState,
|
||||
todaySubscriptionState
|
||||
} from './fixtures.test-util'
|
||||
import { formatUsageUpdatedAgo } from './use-billing-state'
|
||||
|
||||
import { BillingSettings } from './index'
|
||||
|
||||
@@ -121,7 +120,9 @@ describe('BillingSettings', () => {
|
||||
|
||||
renderBilling()
|
||||
|
||||
expect(await screen.findByText('No card on file')).toBeTruthy()
|
||||
// No card → the payment row collapses to a single "Add payment method" link.
|
||||
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
|
||||
expect(screen.queryByText('No card on file')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
|
||||
@@ -265,7 +266,7 @@ describe('BillingSettings', () => {
|
||||
// plans capability, so the URL must not surface a grid of Choose buttons.
|
||||
renderBilling(['/settings?tab=billing&bview=plans'])
|
||||
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
|
||||
})
|
||||
@@ -277,7 +278,7 @@ describe('BillingSettings', () => {
|
||||
|
||||
renderBilling(['/settings?tab=billing&bview=plans'])
|
||||
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
|
||||
})
|
||||
@@ -314,7 +315,7 @@ describe('BillingSettings', () => {
|
||||
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
|
||||
// Scheduled → back on the overview.
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -617,9 +618,11 @@ describe('BillingSettings', () => {
|
||||
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
|
||||
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
|
||||
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
|
||||
expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
|
||||
expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
|
||||
// Plain shared primitive track (no bespoke dither/tinted chrome); the
|
||||
// over-limit signal rides the destructive fill instead.
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
|
||||
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
|
||||
expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders an empty neutral usage track when a row has no bar data', async () => {
|
||||
@@ -644,74 +647,47 @@ describe('BillingSettings', () => {
|
||||
|
||||
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
|
||||
// Empty tracks are the plain shared primitive now — no hatched placeholder.
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
|
||||
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
|
||||
|
||||
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
|
||||
|
||||
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
|
||||
expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
|
||||
expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
|
||||
expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes both billing queries from the usage refresh button', async () => {
|
||||
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
|
||||
const fixture = billingDevFixtures['no-card']
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
|
||||
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
|
||||
|
||||
renderBilling()
|
||||
|
||||
expect(await screen.findByText('No payment method on file')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.'
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show the no-card notice when a card is on file', async () => {
|
||||
renderBilling()
|
||||
|
||||
await screen.findByText('$996.47')
|
||||
expect(screen.queryByText('No payment method on file')).toBeNull()
|
||||
})
|
||||
|
||||
it('polls billing on an interval without a manual refresh control', async () => {
|
||||
renderBilling()
|
||||
|
||||
await screen.findByText('$120 of $220 left')
|
||||
expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
|
||||
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
|
||||
await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
|
||||
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('disables the usage refresh button while either query is fetching', async () => {
|
||||
let settleBilling: (value: unknown) => void = () => {}
|
||||
|
||||
let settleSubscription: (value: unknown) => void = () => {}
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
settleBilling = resolve
|
||||
})
|
||||
)
|
||||
apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
settleSubscription = resolve
|
||||
})
|
||||
)
|
||||
|
||||
renderBilling()
|
||||
|
||||
const refresh = await screen.findByRole('button', { name: 'Refresh' })
|
||||
|
||||
fireEvent.click(refresh)
|
||||
|
||||
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
|
||||
|
||||
settleBilling(okBilling(todayBillingState))
|
||||
settleSubscription(okSubscription(todaySubscriptionState))
|
||||
|
||||
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatUsageUpdatedAgo', () => {
|
||||
it('formats sub-second and current timestamps as just now', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
|
||||
expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
|
||||
})
|
||||
|
||||
it('formats seconds below a minute', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
|
||||
})
|
||||
|
||||
it('rounds elapsed time to whole minutes from 61 seconds', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
|
||||
})
|
||||
|
||||
it('formats one hour and later as hours', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
|
||||
// The manual refresh affordance is gone — the queries poll on their own.
|
||||
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
|
||||
expect(screen.queryByText(/Updated/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,22 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
|
||||
import { ListRow, SectionHeading, SettingsContent } from '../primitives'
|
||||
import {
|
||||
ListRow,
|
||||
ListRowSkeleton,
|
||||
SectionHeading,
|
||||
SectionHeadingSkeleton,
|
||||
SettingsContent,
|
||||
SettingsSection
|
||||
} from '../primitives'
|
||||
|
||||
import { RowValue } from './account-row-value'
|
||||
import { BillingApiProvider } from './api'
|
||||
@@ -27,7 +36,6 @@ import {
|
||||
type BillingNoticeView,
|
||||
type BillingUsageRowView,
|
||||
deriveBillingView,
|
||||
formatUsageUpdatedAgo,
|
||||
useBillingState,
|
||||
useSubscriptionState
|
||||
} from './use-billing-state'
|
||||
@@ -64,9 +72,18 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | '
|
||||
}
|
||||
|
||||
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
||||
const warn = notice.tone === 'warn'
|
||||
|
||||
return (
|
||||
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
|
||||
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-text-font-size)] font-medium',
|
||||
warn ? 'text-(--ui-yellow)' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{notice.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{notice.message}
|
||||
</div>
|
||||
@@ -86,6 +103,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The payment method as it rides in the "Payment & credits" heading: the current
|
||||
// card (muted) plus a single underline text action (Update / Add payment method).
|
||||
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{row.value && (
|
||||
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
{row.action && (
|
||||
<Button
|
||||
disabled={row.action.disabled}
|
||||
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
{row.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
|
||||
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
|
||||
return <BuyCreditsRow billing={billing} row={row} />
|
||||
@@ -143,22 +185,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
|
||||
{presets.map(preset => (
|
||||
<Button
|
||||
aria-pressed={amount === preset.amount}
|
||||
disabled={controlsDisabled}
|
||||
key={preset.amount}
|
||||
onClick={() => setAmount(preset.amount)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={amount === preset.amount ? 'default' : 'outline'}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<SegmentedControl
|
||||
disabled={controlsDisabled}
|
||||
onChange={value => setAmount(value)}
|
||||
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
|
||||
value={amount}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Custom credit amount"
|
||||
className="w-24 py-[3px]"
|
||||
containerClassName="w-16"
|
||||
disabled={controlsDisabled}
|
||||
inputMode="decimal"
|
||||
max={billing.max_usd ?? undefined}
|
||||
@@ -168,13 +203,14 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
||||
flow.reset()
|
||||
setAmount(event.target.value)
|
||||
}}
|
||||
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
|
||||
size="sm"
|
||||
placeholder={billing.min_usd ?? ''}
|
||||
prefix="$"
|
||||
size="xs"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={amount}
|
||||
/>
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
|
||||
Buy
|
||||
</Button>
|
||||
</div>
|
||||
@@ -283,43 +319,20 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
|
||||
value: 0
|
||||
}
|
||||
|
||||
const width = Math.round(resolvedBar.value * 100)
|
||||
const isEmpty = resolvedBar.value === 0
|
||||
const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
|
||||
// Plain shared primitive — no bespoke track chrome. Only the fill tone carries
|
||||
// billing meaning: destructive when over-limit, green for healthy remaining
|
||||
// credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
|
||||
const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
|
||||
|
||||
return (
|
||||
<div
|
||||
<Progress
|
||||
aria-label={resolvedBar.label}
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={width}
|
||||
className={cn(
|
||||
// Radius follows the app-wide rounded-full progress-bar idiom.
|
||||
'relative h-2 w-full overflow-hidden rounded-full',
|
||||
resolvedBar.track === 'danger'
|
||||
? 'dither text-destructive/60 bg-destructive/10'
|
||||
: isEmpty
|
||||
? 'dither bg-(--ui-bg-elevated)'
|
||||
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
|
||||
)}
|
||||
role="progressbar"
|
||||
>
|
||||
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full rounded-full transition-[width] duration-300 ease-out',
|
||||
resolvedBar.state === 'danger'
|
||||
? 'bg-destructive'
|
||||
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
|
||||
? 'bg-(--ui-green)'
|
||||
: 'bg-muted-foreground/45'
|
||||
)}
|
||||
style={{
|
||||
minWidth: resolvedBar.value > 0 ? 4 : undefined,
|
||||
width: `${width}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
destructive={resolvedBar.state === 'danger'}
|
||||
fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
|
||||
fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
|
||||
size="lg"
|
||||
value={resolvedBar.value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -351,53 +364,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) {
|
||||
)
|
||||
}
|
||||
|
||||
function UsageRefreshRow({
|
||||
fixtureName,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
updatedAt
|
||||
}: {
|
||||
fixtureName?: BillingFixtureSelection
|
||||
isFetching: boolean
|
||||
onRefresh: () => void
|
||||
updatedAt: number
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
|
||||
return () => window.clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (fixtureName && fixtureName !== 'live') {
|
||||
return (
|
||||
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
fixture: {fixtureName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
|
||||
<Tip label="Refresh">
|
||||
<Button
|
||||
aria-label="Refresh"
|
||||
className="size-7 p-0 text-(--ui-text-tertiary)"
|
||||
disabled={isFetching}
|
||||
onClick={onRefresh}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DEV-only preview switcher: swaps the whole page onto a canned fixture so every
|
||||
// billing state can be reviewed without a matching live account. Marked with a
|
||||
// wrench + "preview" so it never reads as a shipping control (it's compiled out of
|
||||
// production builds entirely).
|
||||
function BillingFixtureSelect({
|
||||
onValueChange,
|
||||
value
|
||||
@@ -406,23 +376,27 @@ function BillingFixtureSelect({
|
||||
value: BillingFixtureSelection
|
||||
}) {
|
||||
return (
|
||||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing fixture"
|
||||
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="live">live</SelectItem>
|
||||
{BILLING_DEV_FIXTURE_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
|
||||
<Wrench className="size-3.5 shrink-0" />
|
||||
<span className="text-xs font-normal">preview</span>
|
||||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing preview fixture (dev only)"
|
||||
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="live">live</SelectItem>
|
||||
{BILLING_DEV_FIXTURE_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -446,6 +420,34 @@ function BillingHeader({
|
||||
)
|
||||
}
|
||||
|
||||
// Loading shape for the billing overview: three summary cards over the Plan /
|
||||
// Payment & credits / Usage sections. Rendered under the real header.
|
||||
function BillingSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="@container mb-6">
|
||||
<div className="grid gap-3 @2xl:grid-cols-3">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div className="min-w-0 space-y-2" key={i}>
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{[0, 1, 2].map(section => (
|
||||
<section className="mb-6" key={section}>
|
||||
<SectionHeadingSkeleton />
|
||||
<div className="grid gap-1">
|
||||
<ListRowSkeleton />
|
||||
<ListRowSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingSettingsContent({
|
||||
fixtureName,
|
||||
onFixtureChange
|
||||
@@ -460,19 +462,30 @@ function BillingSettingsContent({
|
||||
// fixture short-circuit here.
|
||||
const billingState = useBillingState()
|
||||
const subscriptionState = useSubscriptionState()
|
||||
|
||||
// First load keeps the page's shape via a skeleton instead of flashing "—"
|
||||
// summary cards (background refetches leave `isPending` false, so no flicker).
|
||||
if (billingState.isPending) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
|
||||
<BillingSkeleton />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
const billingResult = billingState.data
|
||||
const subscriptionResult = subscriptionState.data
|
||||
const view = deriveBillingView(billingResult, subscriptionResult)
|
||||
const billing = billingResult?.ok ? billingResult.data : undefined
|
||||
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
|
||||
const usageIsFetching = billingState.isFetching || subscriptionState.isFetching
|
||||
|
||||
const refreshUsage = () => {
|
||||
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
|
||||
}
|
||||
|
||||
const { paymentRow, refillRow, topupRow } = view
|
||||
|
||||
// The payment method rides in the section header (right-aligned) — the
|
||||
// "Payment & credits" title already names it, so a full labelled row would just
|
||||
// repeat "Payment method". The stacked rows are the remaining money controls.
|
||||
const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined)
|
||||
|
||||
// Gate the plans sub-view on the SAME capability that renders the in-app button
|
||||
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
|
||||
// reach a grid of live Choose buttons — it falls back to the overview.
|
||||
@@ -491,59 +504,42 @@ function BillingSettingsContent({
|
||||
<SettingsContent>
|
||||
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
|
||||
|
||||
<div className="@container mb-5">
|
||||
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
|
||||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
<div className="@container mb-6">
|
||||
<div className="grid gap-3 @2xl:grid-cols-3">
|
||||
{view.summary.map(item => (
|
||||
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
{view.plan && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Package} title="Plan" />
|
||||
<SettingsSection icon={Package} title="Plan">
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{paymentRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Lock} title="Payment" />
|
||||
<AccountRow billing={billing} row={paymentRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topupRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Plus} title="One-time top-up" />
|
||||
<AccountRow billing={billing} row={topupRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refillRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={RefreshCw} title="Automatic refill" />
|
||||
<AccountRow billing={billing} row={refillRow} />
|
||||
</div>
|
||||
{(paymentRow || accountRows.length > 0) && (
|
||||
<SettingsSection
|
||||
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
||||
icon={CreditCard}
|
||||
title="Payment & credits"
|
||||
>
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
))}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{view.usageRows.length > 0 && (
|
||||
<>
|
||||
<SectionHeading icon={BarChart3} title="Usage" />
|
||||
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
|
||||
<SettingsSection icon={BarChart3} title="Usage">
|
||||
<div className="@container">
|
||||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
))}
|
||||
<UsageRefreshRow
|
||||
fixtureName={fixtureName}
|
||||
isFetching={usageIsFetching}
|
||||
onRefresh={refreshUsage}
|
||||
updatedAt={usageUpdatedAt}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{
|
||||
@@ -586,9 +582,3 @@ export function BillingSettings() {
|
||||
|
||||
return <BillingSettingsContent />
|
||||
}
|
||||
|
||||
function oldestUpdatedAt(...timestamps: number[]): number {
|
||||
const populated = timestamps.filter(timestamp => timestamp > 0)
|
||||
|
||||
return populated.length > 0 ? Math.min(...populated) : Date.now()
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
|
||||
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
|
||||
ref={panelRef}
|
||||
role="status"
|
||||
tabIndex={-1}
|
||||
@@ -129,8 +129,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
|
||||
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
|
||||
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
|
||||
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
|
||||
)}
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
@@ -219,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No plans are available to change to right now.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,11 +147,14 @@ describe('deriveBillingView', () => {
|
||||
|
||||
expect(buyCredits).toMatchObject({
|
||||
action: { disabled: true, label: 'Buy' },
|
||||
description:
|
||||
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
|
||||
"(one-time credit buys don't save a reusable card)."
|
||||
// The no-card blocker is explained once by the page-level notice, not
|
||||
// duplicated (emoji and all) into the row description.
|
||||
description: 'A single charge on your card, added to your balance today.'
|
||||
})
|
||||
expect(buyCredits?.description).not.toContain('💳')
|
||||
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
|
||||
// The page still leads with the warn banner naming the blocker + fix.
|
||||
expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
|
||||
})
|
||||
|
||||
it('derives a calm logged-out card with no account or usage rows', () => {
|
||||
|
||||
@@ -11,10 +11,22 @@ export const EMPTY_BILLING_VALUE = '—'
|
||||
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
|
||||
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
|
||||
|
||||
// The billing endpoint is the authoritative source of truth for balance / cap /
|
||||
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
|
||||
// out of sync (notably in team/org accounts where another member's spend moves
|
||||
// the shared balance without ever touching THIS client's headers). So the page
|
||||
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
|
||||
// fresh fetch every time it opens or regains focus, and it keeps polling every
|
||||
// 30s while mounted (react-query only ticks an active observer; it pauses when
|
||||
// the window is backgrounded — refetchIntervalInBackground defaults to false).
|
||||
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
|
||||
// pull the change in immediately rather than waiting for the next poll tick.
|
||||
const BILLING_QUERY_OPTIONS = {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
staleTime: 30_000
|
||||
staleTime: 0
|
||||
} as const
|
||||
|
||||
export interface BillingSummaryItemView {
|
||||
@@ -30,6 +42,8 @@ export interface BillingNoticeView {
|
||||
}
|
||||
message: string
|
||||
title: string
|
||||
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
|
||||
tone?: 'info' | 'warn'
|
||||
}
|
||||
|
||||
export interface BillingRowActionView {
|
||||
@@ -207,7 +221,7 @@ export function deriveBillingView(
|
||||
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
|
||||
|
||||
return {
|
||||
notice: undefined,
|
||||
notice: noCardNotice(billing),
|
||||
paymentRow: paymentMethodRow(billing),
|
||||
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
|
||||
refillRow: autoReloadRow(billing),
|
||||
@@ -276,26 +290,6 @@ export function formatBillingDate(value?: null | string): string {
|
||||
return fmtDate.format(date)
|
||||
}
|
||||
|
||||
export function formatUsageUpdatedAgo(updatedAt: number, now: number): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000))
|
||||
|
||||
if (elapsedSeconds < 1) {
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
if (elapsedSeconds < 60) {
|
||||
return `${elapsedSeconds}s ago`
|
||||
}
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60)
|
||||
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes}m ago`
|
||||
}
|
||||
|
||||
return `${Math.floor(elapsedMinutes / 60)}h ago`
|
||||
}
|
||||
|
||||
function emptySummary(): BillingSummaryItemView[] {
|
||||
return [
|
||||
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
|
||||
@@ -311,7 +305,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
||||
return {
|
||||
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
|
||||
message: resolved.message,
|
||||
title: resolved.title
|
||||
title: resolved.title,
|
||||
tone: 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
// A logged-in account with no card can't buy credits or manage auto-refill, and
|
||||
// every one of those controls disables silently — so lead the page with a single
|
||||
// warn banner that names the blocker and links straight to the fix.
|
||||
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
|
||||
if (billing.card) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
|
||||
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
|
||||
title: 'No payment method on file',
|
||||
tone: 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,17 +541,19 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
|
||||
const card = billing.card
|
||||
|
||||
if (!card) {
|
||||
// No card → a single "Add payment method" link, the way every other app does
|
||||
// it. The reason (buys/auto-refill are blocked) already leads the page as a
|
||||
// notice, so the row stays a bare call-to-action with no redundant status text.
|
||||
return {
|
||||
action: { label: 'Update ↗', url: portalUrl },
|
||||
description: 'Add a payment method on the portal before buying top-up credits.',
|
||||
action: { label: 'Add payment method', url: portalUrl },
|
||||
description: '',
|
||||
id: 'payment_method',
|
||||
title: 'Payment method',
|
||||
value: 'No card on file'
|
||||
title: 'Payment method'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: { label: 'Update ↗', url: portalUrl },
|
||||
action: { label: 'Update', url: portalUrl },
|
||||
description: 'Manage the card used for top-ups and subscription renewals.',
|
||||
id: 'payment_method',
|
||||
title: 'Payment method',
|
||||
@@ -550,14 +563,13 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
|
||||
|
||||
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
|
||||
if (!billing.card) {
|
||||
// The no-card blocker is already spelled out by the page-level warn banner
|
||||
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
|
||||
// so keep the plain "what buying does" line and let the controls sit disabled.
|
||||
return {
|
||||
action: { disabled: true, label: 'Buy' },
|
||||
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
||||
description: resolveRefusal({
|
||||
kind: 'no_payment_method',
|
||||
message: '',
|
||||
portalUrl: billing.portal_url ?? undefined
|
||||
}).message,
|
||||
description: 'A single charge on your card, added to your balance today.',
|
||||
id: 'buy_credits',
|
||||
title: 'Buy credits now'
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
|
||||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
|
||||
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
@@ -264,8 +264,8 @@ export function ConfigSettings({
|
||||
)
|
||||
}
|
||||
|
||||
// Model keeps its shape via a skeleton (its catalog fetch is the slow part);
|
||||
// other sections are quick config/schema reads, so a light loader is fine.
|
||||
// Every section keeps its shape via a skeleton; model gets its bespoke one
|
||||
// (its catalog fetch is the slow part), the rest the shared field rhythm.
|
||||
if (activeSectionId === 'model') {
|
||||
return (
|
||||
<SettingsContent>
|
||||
@@ -276,7 +276,7 @@ export function ConfigSettings({
|
||||
)
|
||||
}
|
||||
|
||||
return <LoadingState label={c.loading} />
|
||||
return <SettingsSkeleton sections={[{ rows: 6 }]} />
|
||||
}
|
||||
|
||||
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
|
||||
|
||||
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
|
||||
interface CustomEndpointsSettingsProps {
|
||||
onConfigSaved?: () => void
|
||||
@@ -218,7 +218,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading custom endpoints..." />
|
||||
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
|
||||
}
|
||||
|
||||
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
|
||||
|
||||
@@ -27,7 +27,7 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
|
||||
|
||||
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
|
||||
@@ -985,7 +985,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label={g.loading} />
|
||||
return (
|
||||
<SettingsSkeleton
|
||||
sections={[
|
||||
{ heading: true, rows: 3 },
|
||||
{ heading: true, rows: 3 }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.getConnectionConfig) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes'
|
||||
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
||||
import { useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import { SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
|
||||
@@ -64,7 +64,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
|
||||
}, [vars])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.keys.loading} />
|
||||
return <SettingsSkeleton sections={[{ rows: 5 }]} />
|
||||
}
|
||||
|
||||
const visible = groups.filter(g => g.category === view)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -139,7 +140,21 @@ export function PetSettings() {
|
||||
{/* Fixed-height scroll area so filtering never grows/shrinks the
|
||||
page (no layout thrash); the grid scrolls inside it. */}
|
||||
<div className="mt-3 h-72 overflow-y-auto pr-1">
|
||||
{pets.length === 0 ? (
|
||||
{status === 'loading' && pets.length === 0 ? (
|
||||
// First load keeps the grid's shape rather than flashing the
|
||||
// "unreachable" copy before the gallery has even arrived.
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-2" key={i}>
|
||||
<Skeleton className="size-10 shrink-0 rounded-md" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24 max-w-full" />
|
||||
<Skeleton className="h-3 w-16 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pets.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.unreachable}
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
@@ -28,16 +28,53 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
|
||||
return <Badge variant={PILL_VARIANT[tone]}>{children}</Badge>
|
||||
}
|
||||
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
|
||||
export function SectionHeading({
|
||||
aside,
|
||||
icon: Icon,
|
||||
meta,
|
||||
title
|
||||
}: {
|
||||
// Right-aligned trailing content on the heading row (e.g. a compact status +
|
||||
// action), so a single-item section needn't repeat its own label as a row.
|
||||
aside?: ReactNode
|
||||
icon: IconComponent
|
||||
meta?: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{meta && <Pill>{meta}</Pill>}
|
||||
{aside && <div className="ml-auto flex min-w-0 items-center">{aside}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A titled section: heading + body with the shared vertical rhythm. Keeps the
|
||||
// heading and its content welded together so pages stop hand-rolling
|
||||
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
|
||||
export function SettingsSection({
|
||||
aside,
|
||||
children,
|
||||
icon,
|
||||
meta,
|
||||
title
|
||||
}: {
|
||||
aside?: ReactNode
|
||||
children: ReactNode
|
||||
icon: IconComponent
|
||||
meta?: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<SectionHeading aside={aside} icon={icon} meta={meta} title={title} />
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavLink({
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -143,16 +180,60 @@ export function ToggleRow({
|
||||
)
|
||||
}
|
||||
|
||||
// The settings panels render this as the sole child of the top-padded
|
||||
// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx).
|
||||
// Cancel that top pad so the loader centers in the whole card, not just the
|
||||
// band beneath it. Inline loaders (mid-panel) should use <PageLoader> directly.
|
||||
export function LoadingState({ label }: { label: string }) {
|
||||
// Skeleton primitives mirroring the settings layout rhythm — a loading page keeps
|
||||
// its shape (like ModelSettings) instead of collapsing to a centered spinner.
|
||||
export function SectionHeadingSkeleton() {
|
||||
return (
|
||||
<PageLoader
|
||||
className="-mt-[calc(var(--titlebar-height)+1rem)] h-[calc(100%+var(--titlebar-height)+1rem)]"
|
||||
label={label}
|
||||
/>
|
||||
<div className="mb-2.5 flex items-center gap-2 pt-2">
|
||||
<Skeleton className="size-4" />
|
||||
<Skeleton className="h-4 w-36 max-w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListRowSkeleton({ wide = false }: { wide?: boolean }) {
|
||||
return (
|
||||
<div className="@container">
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3',
|
||||
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-40 max-w-full" />
|
||||
<Skeleton className="h-3 w-64 max-w-full" />
|
||||
</div>
|
||||
{!wide && <Skeleton className="h-8 w-full @2xl:w-72 @2xl:justify-self-end" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A full settings page in its loading shape: an optional leading search field
|
||||
// over one or more sections, each an optional heading above a run of rows.
|
||||
// `<SettingsSkeleton search sections={[{ heading, rows }]} />`.
|
||||
export function SettingsSkeleton({
|
||||
search = false,
|
||||
sections = [{ rows: 4 }]
|
||||
}: {
|
||||
search?: boolean
|
||||
sections?: { heading?: boolean; rows: number }[]
|
||||
}) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
{search && <Skeleton className="mb-3 h-8 w-full" />}
|
||||
{sections.map((section, i) => (
|
||||
<section className={cn(i > 0 && 'mt-6')} key={i}>
|
||||
{section.heading && <SectionHeadingSkeleton />}
|
||||
<div className="grid gap-1">
|
||||
{Array.from({ length: section.rows }, (_, r) => (
|
||||
<ListRowSkeleton key={r} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
||||
import { CustomEndpointsSettings } from './custom-endpoints-settings'
|
||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import { SettingsContent, SettingsSkeleton } from './primitives'
|
||||
|
||||
// The embedded terminal (and thus the "run disconnect command" path) only
|
||||
// exists in the Electron desktop shell, not the web dashboard.
|
||||
@@ -431,7 +431,7 @@ export function ProvidersSettings({
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.providers.loading} />
|
||||
return <SettingsSkeleton search sections={[{ rows: 6 }]} />
|
||||
}
|
||||
|
||||
const hasOauth = oauthProviders.length > 0
|
||||
|
||||
@@ -12,7 +12,7 @@ import { untombstoneSessions } from '@/store/projects'
|
||||
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
@@ -107,7 +107,7 @@ export function SessionsSettings() {
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label={s.loading} />
|
||||
return <SettingsSkeleton sections={[{ rows: 1 }, { heading: true, rows: 4 }]} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,12 @@ interface ContextUsagePanelProps {
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
export function ContextUsagePanel({ currentUsage, onUsageSnapshot, requestGateway, sessionId }: ContextUsagePanelProps) {
|
||||
export function ContextUsagePanel({
|
||||
currentUsage,
|
||||
onUsageSnapshot,
|
||||
requestGateway,
|
||||
sessionId
|
||||
}: ContextUsagePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar.contextUsagePanel
|
||||
const [breakdown, setBreakdown] = useState<ContextBreakdown | null>(null)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
@@ -396,15 +397,12 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
||||
percent === null && 'w-1/3 animate-pulse'
|
||||
)}
|
||||
style={percent !== null ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={label}
|
||||
indeterminate={percent === null}
|
||||
size="lg"
|
||||
value={percent === null ? 0 : percent / 100}
|
||||
/>
|
||||
|
||||
{recentLog.length > 1 ? (
|
||||
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onComposerInsertRequest } from '@/app/chat/composer/focus'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
import { ClarifyTool, readClarifyResult } from './clarify-tool'
|
||||
|
||||
// The live pending card only renders while its message is running. Force that so
|
||||
// keyboard-navigation tests can exercise ClarifyToolPending directly.
|
||||
vi.mock('@assistant-ui/react', () => ({
|
||||
useAuiState: () => true
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearClarifyRequest()
|
||||
$activeSessionId.set(null)
|
||||
$gateway.set(null)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderClarify(ui: ReactNode) {
|
||||
@@ -39,6 +53,40 @@ function settledClarifyProps(
|
||||
}
|
||||
}
|
||||
|
||||
function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps {
|
||||
const args = { choices, question: 'Which deployment target?' }
|
||||
|
||||
return {
|
||||
addResult: vi.fn(),
|
||||
args,
|
||||
argsText: JSON.stringify(args),
|
||||
isError: false,
|
||||
respondToApproval: vi.fn(),
|
||||
result: undefined,
|
||||
resume: vi.fn(),
|
||||
status: { type: 'running' },
|
||||
toolCallId: 'clarify-live',
|
||||
toolName: 'clarify',
|
||||
type: 'tool-call'
|
||||
}
|
||||
}
|
||||
|
||||
function renderLiveClarify() {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
$activeSessionId.set('session-1')
|
||||
$gateway.set({ request } as never)
|
||||
setClarifyRequest({
|
||||
choices: ['staging', 'production'],
|
||||
question: 'Which deployment target?',
|
||||
requestId: 'request-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
renderClarify(<ClarifyTool {...liveClarifyProps()} />)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
describe('readClarifyResult', () => {
|
||||
it('reads question + user_response from the tool JSON payload', () => {
|
||||
expect(
|
||||
@@ -114,4 +162,179 @@ describe('ClarifyTool settled view', () => {
|
||||
expect(screen.getByText('Anything else?')).toBeTruthy()
|
||||
expect(screen.getByText('Skipped')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the original choices visible and clickable after a skip', async () => {
|
||||
const inserts: string[] = []
|
||||
|
||||
const stop = onComposerInsertRequest(detail => {
|
||||
inserts.push(detail.text)
|
||||
})
|
||||
|
||||
try {
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
{...settledClarifyProps(
|
||||
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
|
||||
{ question: 'Which deployment target?', user_response: '' },
|
||||
'clarify-3'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
// The skip label renders AND the original options are still on screen.
|
||||
expect(screen.getByText('Skipped')).toBeTruthy()
|
||||
const group = document.querySelector('[data-clarify-late-choices]')
|
||||
expect(group).toBeTruthy()
|
||||
expect(screen.getByText('staging')).toBeTruthy()
|
||||
expect(screen.getByText('prod')).toBeTruthy()
|
||||
|
||||
// Picking one drafts a quoted follow-up into the composer. The insert
|
||||
// bus defers dispatch by a macrotask, so flush one tick.
|
||||
fireEvent.click(screen.getByText('prod'))
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
|
||||
expect(inserts).toHaveLength(1)
|
||||
expect(inserts[0]).toContain('Which deployment target?')
|
||||
expect(inserts[0]).toContain('prod')
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not render late choices on an answered clarify', () => {
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
{...settledClarifyProps(
|
||||
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
|
||||
{ question: 'Which deployment target?', user_response: 'staging' },
|
||||
'clarify-4'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render late choices for a free-text (no-choice) skip', () => {
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
{...settledClarifyProps(
|
||||
{ question: 'Anything else?' },
|
||||
{ question: 'Anything else?', user_response: '' },
|
||||
'clarify-5'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool keyboard navigation', () => {
|
||||
it('cycles through choices and Other with the arrow keys', () => {
|
||||
renderLiveClarify()
|
||||
|
||||
const staging = screen.getByRole('button', { name: /staging/ })
|
||||
const production = screen.getByRole('button', { name: /production/ })
|
||||
const other = screen.getByPlaceholderText(/Other/)
|
||||
|
||||
expect(staging.getAttribute('data-highlighted')).toBe('true')
|
||||
expect(staging.getAttribute('aria-current')).toBe('true')
|
||||
expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
expect(production.getAttribute('data-highlighted')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
|
||||
expect(other.getAttribute('aria-current')).toBe('true')
|
||||
expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
expect(staging.getAttribute('data-highlighted')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp' })
|
||||
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
|
||||
})
|
||||
|
||||
it('selects by number and confirms the answer with Enter', async () => {
|
||||
const request = renderLiveClarify()
|
||||
|
||||
fireEvent.keyDown(window, { key: '2' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith('clarify.respond', {
|
||||
answer: 'production',
|
||||
request_id: 'request-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('focuses Other when its number is pressed and leaves typing keys alone', () => {
|
||||
renderLiveClarify()
|
||||
|
||||
const other = screen.getByPlaceholderText(/Other/)
|
||||
|
||||
fireEvent.keyDown(window, { key: '3' })
|
||||
expect(document.activeElement).toBe(other)
|
||||
|
||||
fireEvent.change(other, { target: { value: 'canary' } })
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(other)
|
||||
expect((other as HTMLTextAreaElement).value).toBe('canary')
|
||||
})
|
||||
|
||||
it('does not intercept keyboard events while an action button has focus', () => {
|
||||
const request = renderLiveClarify()
|
||||
const skip = screen.getByRole('button', { name: 'Skip' })
|
||||
|
||||
skip.focus()
|
||||
|
||||
expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)
|
||||
expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool pending marker', () => {
|
||||
it('marks a live choices card so type-to-focus yields its shortcut keys', () => {
|
||||
renderLiveClarify()
|
||||
|
||||
// The marker is what `composerFocusBlockedBySurface` keys off of, so the
|
||||
// global type-to-focus listener stands down and A/B/C… + 1-9 + Enter reach
|
||||
// the card instead of the composer.
|
||||
expect(document.querySelector('[data-clarify-choices]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not mark a free-text (no-choice) pending card', () => {
|
||||
$activeSessionId.set('session-1')
|
||||
$gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never)
|
||||
setClarifyRequest({
|
||||
choices: null,
|
||||
question: 'Anything else?',
|
||||
requestId: 'request-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
|
||||
const args = { question: 'Anything else?' }
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
addResult={vi.fn()}
|
||||
args={args}
|
||||
argsText={JSON.stringify(args)}
|
||||
isError={false}
|
||||
respondToApproval={vi.fn()}
|
||||
result={undefined}
|
||||
resume={vi.fn()}
|
||||
status={{ type: 'running' }}
|
||||
toolCallId="clarify-free"
|
||||
toolName="clarify"
|
||||
type="tool-call"
|
||||
/>
|
||||
)
|
||||
|
||||
// No shortcuts → nothing to protect → composer type-to-focus stays live.
|
||||
expect(document.querySelector('[data-clarify-choices]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,16 +13,18 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
|
||||
import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -52,11 +54,18 @@ function stringField(row: Record<string, unknown>, ...keys: string[]): string |
|
||||
|
||||
function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
const row = parseMaybeObject(args)
|
||||
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
|
||||
const rawChoices = row.choices
|
||||
const choices = normalizeChoices(rawChoices)
|
||||
|
||||
const question = stringField(row, 'question')
|
||||
|
||||
if (rawChoices != null && choices.length === 0 && question) {
|
||||
warnDroppedChoices('tool_args', question, rawChoices)
|
||||
}
|
||||
|
||||
return {
|
||||
question: stringField(row, 'question'),
|
||||
choices: choices && choices.length > 0 ? choices : null
|
||||
question,
|
||||
choices: choices.length > 0 ? choices : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +134,60 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
|
||||
)
|
||||
}
|
||||
|
||||
/** A letter-badged option row. Shared by the live pending card (where a click
|
||||
* selects an answer) and the settled skip card (where a click drafts a
|
||||
* follow-up), so both stay visually identical. */
|
||||
function ChoiceButton({
|
||||
active = false,
|
||||
char,
|
||||
choice,
|
||||
disabled,
|
||||
keyShortcuts,
|
||||
onClick,
|
||||
selected = false,
|
||||
title
|
||||
}: {
|
||||
active?: boolean
|
||||
char: string
|
||||
choice: string
|
||||
disabled?: boolean
|
||||
keyShortcuts?: string
|
||||
onClick: () => void
|
||||
selected?: boolean
|
||||
title?: string
|
||||
}) {
|
||||
// `Tip` is the repo's themed replacement for native `title=` (a native
|
||||
// tooltip on a <button> is banned by the no-native-title guard). It renders
|
||||
// the child untouched when `label` is falsy, so the live card (no tip) is
|
||||
// unaffected and only the settled skip card gets the hover hint.
|
||||
//
|
||||
// `active` is the keyboard cursor on the live card (arrow-key navigation);
|
||||
// it highlights the row and previews its key badge. The settled skip card
|
||||
// never passes it, so its rows stay plain.
|
||||
return (
|
||||
<Tip label={title}>
|
||||
<button
|
||||
aria-current={active || undefined}
|
||||
aria-keyshortcuts={keyShortcuts}
|
||||
className={cn(
|
||||
OPTION_ROW_CLASS,
|
||||
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
active && 'bg-(--chrome-action-hover) text-(--ui-text-primary)',
|
||||
selected && 'text-(--ui-text-primary)'
|
||||
)}
|
||||
data-choice
|
||||
data-highlighted={active || undefined}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<KeyBadge char={char} preview={active} selected={selected} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
// Answered → settled Q&A (ToolFallback collapsed the answer away).
|
||||
if (props.result !== undefined) {
|
||||
@@ -156,6 +219,22 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
|
||||
const error = fromResult.error
|
||||
const skipped = !error && answer !== undefined && !answer.trim()
|
||||
const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim())
|
||||
const choices = fromArgs.choices ?? []
|
||||
|
||||
// A skipped (timed-out) clarify keeps its choices on screen and actionable.
|
||||
// The blocking request is long gone — the tool already returned empty — so a
|
||||
// pick can't resolve it retroactively. Instead it drafts a quoted follow-up
|
||||
// into the composer (Enter sends; if the agent is mid-turn it queues like
|
||||
// any other prompt). Without this the card collapsed to just "Skipped" and
|
||||
// the options were unrecoverable.
|
||||
const followUp = useCallback(
|
||||
(choice: string) => {
|
||||
requestComposerInsert(copy.lateAnswer(question, choice), { mode: 'block' })
|
||||
requestComposerFocus()
|
||||
triggerHaptic('selection')
|
||||
},
|
||||
[copy, question]
|
||||
)
|
||||
|
||||
return (
|
||||
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
|
||||
@@ -178,6 +257,20 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
|
||||
</p>
|
||||
</ClarifyLine>
|
||||
) : null}
|
||||
{skipped && choices.length > 0 ? (
|
||||
<div className="grid gap-px" data-clarify-late-choices="" role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<ChoiceButton
|
||||
char={letterFor(index)}
|
||||
choice={choice}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => followUp(choice)}
|
||||
title={copy.lateAnswerTip}
|
||||
/>
|
||||
))}
|
||||
<p className="px-1.5 pt-0.5 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">{copy.lateAnswerHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
@@ -217,6 +310,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
|
||||
// The keyboard cursor. Indices 0..choices.length-1 are the options; the
|
||||
// trailing index (=== choices.length) is the "Other" free-text row.
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [otherFocused, setOtherFocused] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
@@ -265,12 +361,31 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
// confirms with Continue (or Enter from the field).
|
||||
const pendingAnswer = selectedChoice ?? (trimmedDraft || null)
|
||||
|
||||
const selectChoice = useCallback((choice: string) => {
|
||||
const selectChoice = useCallback((choice: string, index: number) => {
|
||||
// Picking a choice and typing are mutually exclusive answers.
|
||||
setDraft('')
|
||||
setSelectedChoice(choice)
|
||||
setActiveIndex(index)
|
||||
}, [])
|
||||
|
||||
// Keep the cursor in range when the choice set changes (never past "Other").
|
||||
useEffect(() => {
|
||||
setActiveIndex(index => Math.min(index, choices.length))
|
||||
}, [choices.length])
|
||||
|
||||
const moveActive = useCallback(
|
||||
(delta: number) => {
|
||||
const itemCount = choices.length + 1
|
||||
|
||||
// Arrow navigation is a move, not a pick — clear any staged answer so the
|
||||
// cursor and the selection can't disagree.
|
||||
setDraft('')
|
||||
setSelectedChoice(null)
|
||||
setActiveIndex(index => (index + delta + itemCount) % itemCount)
|
||||
},
|
||||
[choices.length]
|
||||
)
|
||||
|
||||
const submitAnswer = useCallback(() => {
|
||||
if (selectedChoice !== null) {
|
||||
void respond(selectedChoice)
|
||||
@@ -283,6 +398,27 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
}
|
||||
}, [respond, selectedChoice, trimmedDraft])
|
||||
|
||||
const activateActive = useCallback(() => {
|
||||
// A staged answer (picked choice or typed text) wins — confirm it.
|
||||
if (pendingAnswer) {
|
||||
submitAnswer()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise act on the highlighted row: a choice responds immediately, and
|
||||
// the trailing "Other" row focuses the free-text field.
|
||||
const choice = choices[activeIndex]
|
||||
|
||||
if (choice) {
|
||||
void respond(choice)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
textareaRef.current?.focus()
|
||||
}, [activeIndex, choices, pendingAnswer, respond, submitAnswer])
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
@@ -305,10 +441,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[submitAnswer]
|
||||
)
|
||||
|
||||
// Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps
|
||||
// into "Other", and Enter confirms the current pick. Stands down whenever a
|
||||
// field is focused (you're typing, not navigating) so it never eats keystrokes
|
||||
// meant for the composer or the Other box.
|
||||
// Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter
|
||||
// confirms the current answer (or acts on the highlighted row). Stands down
|
||||
// whenever a focusable control (a field, a choice button, the action bar) is
|
||||
// focused, so it never eats keystrokes meant for the composer, the Other box,
|
||||
// or a button the user tabbed to.
|
||||
useEffect(() => {
|
||||
if (!ready || !hasChoices || submitting) {
|
||||
return
|
||||
@@ -321,7 +458,32 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
if (
|
||||
active &&
|
||||
(active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveActive(event.key === 'ArrowDown' ? 1 : -1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (/^[1-9]$/.test(event.key)) {
|
||||
const index = Number(event.key) - 1
|
||||
|
||||
if (index < choices.length) {
|
||||
event.preventDefault()
|
||||
selectChoice(choices[index], index)
|
||||
} else if (index === choices.length) {
|
||||
event.preventDefault()
|
||||
setActiveIndex(index)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -332,25 +494,26 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
if (index < choices.length) {
|
||||
event.preventDefault()
|
||||
selectChoice(choices[index])
|
||||
selectChoice(choices[index], index)
|
||||
} else if (index === choices.length) {
|
||||
event.preventDefault()
|
||||
setActiveIndex(index)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && pendingAnswer) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitAnswer()
|
||||
activateActive()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting])
|
||||
}, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -375,7 +538,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ClarifyShell className="grid gap-2 px-2.5 py-2">
|
||||
// `data-clarify-choices` marks the panel as owning printable/Enter keys
|
||||
// while its A/B/C… shortcuts are live, so the global type-to-focus listener
|
||||
// (`composerFocusBlockedBySurface`) stands down and the letters reach this
|
||||
// card instead of being redirected into the composer.
|
||||
<ClarifyShell className="grid gap-2 px-2.5 py-2" data-clarify-choices={hasChoices ? '' : undefined}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
|
||||
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
@@ -385,31 +552,40 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{hasChoices ? (
|
||||
<div className="grid gap-px" role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
OPTION_ROW_CLASS,
|
||||
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
selectedChoice === choice && 'text-(--ui-text-primary)'
|
||||
)}
|
||||
data-choice
|
||||
<ChoiceButton
|
||||
active={activeIndex === index}
|
||||
char={letterFor(index)}
|
||||
choice={choice}
|
||||
disabled={submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => selectChoice(choice)}
|
||||
type="button"
|
||||
>
|
||||
<KeyBadge char={letterFor(index)} selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
keyShortcuts={`${letterFor(index)} ${index + 1}`}
|
||||
onClick={() => selectChoice(choice, index)}
|
||||
selected={selectedChoice === choice}
|
||||
/>
|
||||
))}
|
||||
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
|
||||
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
|
||||
<label
|
||||
className={cn(
|
||||
OPTION_ROW_CLASS,
|
||||
'items-center',
|
||||
activeIndex === choices.length && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
data-highlighted={activeIndex === choices.length || undefined}
|
||||
>
|
||||
<KeyBadge
|
||||
char={letterFor(choices.length)}
|
||||
preview={otherFocused || activeIndex === choices.length}
|
||||
selected={Boolean(trimmedDraft)}
|
||||
/>
|
||||
<Textarea
|
||||
aria-current={activeIndex === choices.length || undefined}
|
||||
aria-keyshortcuts={`${letterFor(choices.length)} ${choices.length + 1}`}
|
||||
className={CLARIFY_TEXTAREA_CLASS}
|
||||
disabled={submitting}
|
||||
onBlur={() => setOtherFocused(false)}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
onFocus={() => {
|
||||
setSelectedChoice(null)
|
||||
setActiveIndex(choices.length)
|
||||
setOtherFocused(true)
|
||||
}}
|
||||
onKeyDown={handleTextareaKey}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
|
||||
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
|
||||
|
||||
describe('MarkdownTextContent remote images', () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/read-data-url?')) {
|
||||
return { dataUrl: REMOTE_IMAGE_DATA_URL }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
let originalDesktop: typeof window.hermesDesktop
|
||||
|
||||
beforeEach(() => {
|
||||
api.mockClear()
|
||||
originalDesktop = window.hermesDesktop
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { api }
|
||||
})
|
||||
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: originalDesktop
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the gateway bridge data URL through Streamdown to the zoomable image', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text={``} />)
|
||||
|
||||
const image = await screen.findByRole('img', { name: 'Remote preview' })
|
||||
|
||||
expect(image.getAttribute('src')).toBe(REMOTE_IMAGE_DATA_URL)
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fimages%2Fremote-preview.png',
|
||||
profile: 'remote-work'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,14 +20,14 @@ import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
import {
|
||||
downloadGatewayMediaFile,
|
||||
filePathFromMediaPath,
|
||||
gatewayMediaDataUrl,
|
||||
isInlineMediaSrc,
|
||||
isRemoteGateway,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref,
|
||||
mediaStreamUrl
|
||||
mediaStreamUrl,
|
||||
resolveMediaDisplaySrc
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -60,27 +60,13 @@ function preprocessWithTailRepair(text: string): string {
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Stream audio/video through the custom protocol: data URLs are capped and
|
||||
// load the whole file into memory, which broke playback for larger videos.
|
||||
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
// Remote gateway: the image lives on the gateway machine, so read it over the
|
||||
// authenticated API rather than this machine's disk.
|
||||
if (window.hermesDesktop && isRemoteGateway()) {
|
||||
return gatewayMediaDataUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
return resolveMediaDisplaySrc(path)
|
||||
}
|
||||
|
||||
function useOpenMediaFile(path: string) {
|
||||
@@ -286,6 +272,65 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
||||
}
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
const rawSrc = typeof src === 'string' ? src : ''
|
||||
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
|
||||
const [failed, setFailed] = useState(false)
|
||||
const { open, openFailed } = useOpenMediaFile(rawSrc)
|
||||
const name = mediaName(rawSrc || String(alt || 'image'))
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
setFailed(false)
|
||||
setResolvedSrc(rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : '')
|
||||
|
||||
if (!rawSrc || isInlineMediaSrc(rawSrc)) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
void resolveMediaDisplaySrc(rawSrc)
|
||||
.then(value => {
|
||||
if (!cancelled) {
|
||||
setResolvedSrc(value)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [rawSrc])
|
||||
|
||||
if (!rawSrc) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="my-2 block text-sm text-muted-foreground">
|
||||
Couldn't load {name}.{' '}
|
||||
<button
|
||||
className="bg-transparent font-medium text-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
Open image
|
||||
</button>
|
||||
{openFailed && <OpenMediaFailedNote name={name} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (!resolvedSrc) {
|
||||
return <span className="my-2 block text-sm text-muted-foreground">Loading {name}...</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
@@ -295,7 +340,7 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
||||
)}
|
||||
containerClassName="my-2 block w-fit max-w-full"
|
||||
slot="aui_markdown-image"
|
||||
src={src}
|
||||
src={resolvedSrc}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -14,12 +14,21 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
|
||||
const { args, result } = props
|
||||
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
|
||||
|
||||
// The image card owns successful generations. Failed or malformed results
|
||||
// still need the normal tool row: it extracts the error text and gives the
|
||||
// user an honest, expandable failure rather than silently dropping the call.
|
||||
if (result !== undefined && !generatedImageFromResult(result)) {
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<GeneratedImage aspectRatio={aspectRatio} result={result} />
|
||||
|
||||
@@ -217,7 +217,10 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantImageMessage(running = false): ThreadMessage {
|
||||
function assistantImageMessage(
|
||||
running = false,
|
||||
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: `assistant-image-${running ? 'running' : 'done'}`,
|
||||
role: 'assistant',
|
||||
@@ -228,7 +231,7 @@ function assistantImageMessage(running = false): ThreadMessage {
|
||||
toolName: 'image_generate',
|
||||
args: { prompt: 'draw a cat' },
|
||||
argsText: JSON.stringify({ prompt: 'draw a cat' }),
|
||||
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
|
||||
...(running ? {} : { result })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
@@ -243,6 +246,32 @@ function assistantImageMessage(running = false): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantTerminalMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-terminal-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'terminal-1',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'npm run check --workspace=apps/desktop' },
|
||||
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
|
||||
result: { exit_code: 0, stdout: 'all checks passed' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
interface StreamingControls {
|
||||
emitSecond: () => void
|
||||
complete: () => void
|
||||
@@ -634,4 +663,32 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
|
||||
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
|
||||
const { container } = render(
|
||||
<MessageHarness
|
||||
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-tool-row] button')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('FAL rejected the prompt')
|
||||
})
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
|
||||
expect(container.textContent).not.toContain('"success":false')
|
||||
})
|
||||
|
||||
it('shows the command prompt and exit code for terminal calls', async () => {
|
||||
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-tool-row] button')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
|
||||
expect(container.textContent).toContain('exit 0')
|
||||
expect(container.textContent).toContain('all checks passed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,38 @@ describe('buildToolView terminal exit-code status', () => {
|
||||
'error'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the command and exit code for the terminal transcript', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { command: 'npm run check --workspace=apps/desktop' },
|
||||
result: { exit_code: 0, output: 'done' },
|
||||
toolName: 'terminal'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.terminalCommand).toBe('npm run check --workspace=apps/desktop')
|
||||
expect(view.terminalExitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView web-search query', () => {
|
||||
it('keeps the query separate from structured search results', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { query: 'Hermes Agent Desktop tool calls' },
|
||||
result: { web: [{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }] },
|
||||
toolName: 'web_search'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.searchQuery).toBe('Hermes Agent Desktop tool calls')
|
||||
expect(view.searchHits).toEqual([
|
||||
{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView browser_navigate title', () => {
|
||||
|
||||
@@ -128,6 +128,14 @@ function readFileDisplayTarget(args: Record<string, unknown>, result: Record<str
|
||||
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function shellCommand(args: Record<string, unknown>): string {
|
||||
return (
|
||||
firstStringField(args, ['context', 'preview']) ||
|
||||
firstStringField(args, ['command', 'code']) ||
|
||||
contextValue(args)
|
||||
)
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
|
||||
browser_click: {
|
||||
icon: 'globe',
|
||||
@@ -1315,10 +1323,7 @@ function dynamicTitle(
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
const command =
|
||||
firstStringField(args, ['context', 'preview']) ||
|
||||
firstStringField(args, ['command', 'code']) ||
|
||||
contextValue(args)
|
||||
const command = shellCommand(args)
|
||||
|
||||
if (command) {
|
||||
const action =
|
||||
@@ -1384,6 +1389,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
|
||||
const searchHits =
|
||||
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
|
||||
const searchQuery =
|
||||
part.toolName === 'web_search' ? firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) : ''
|
||||
|
||||
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
|
||||
|
||||
@@ -1398,6 +1405,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
// field — otherwise the merged `detail` already covers it and double-
|
||||
// rendering would duplicate output.
|
||||
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
|
||||
const terminalCommand = part.toolName === 'terminal' ? shellCommand(argsRecord) : undefined
|
||||
const terminalExitCode = part.toolName === 'terminal' ? numericField(resultRecord, 'exit_code') : undefined
|
||||
|
||||
return {
|
||||
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
|
||||
@@ -1409,8 +1418,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
inlineDiff,
|
||||
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
|
||||
rendersAnsi: rendersAnsi || undefined,
|
||||
searchQuery: searchQuery || undefined,
|
||||
searchHits: searchHits?.length ? searchHits : undefined,
|
||||
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
|
||||
terminalCommand,
|
||||
terminalExitCode,
|
||||
stdout: hasSplitStreams ? stdout || undefined : undefined,
|
||||
status,
|
||||
subtitle,
|
||||
|
||||
@@ -40,11 +40,17 @@ export interface ToolView {
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
/** Original query, shown above structured web-search results. */
|
||||
searchQuery?: string
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** Terminal-only command shown as the prompt in the expanded transcript. */
|
||||
terminalCommand?: string
|
||||
/** Terminal-only process exit code, when the backend reported one. */
|
||||
terminalExitCode?: number
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldBoundToolGroup, UNBOUNDABLE_TOOLS } from './fallback'
|
||||
import { shouldBoundToolGroup, technicalTrace, UNBOUNDABLE_TOOLS } from './fallback'
|
||||
|
||||
describe('shouldBoundToolGroup', () => {
|
||||
it('bounds long runs of ordinary tool calls', () => {
|
||||
@@ -22,3 +22,16 @@ describe('UNBOUNDABLE_TOOLS', () => {
|
||||
expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('technicalTrace', () => {
|
||||
it('indents object payloads and persisted JSON strings', () => {
|
||||
expect(technicalTrace({ offset: 2, path: '/tmp/demo.txt' }, '{"success":true,"lines":["a","b"]}')).toBe(
|
||||
'Arguments:\n{\n "offset": 2,\n "path": "/tmp/demo.txt"\n}\n\nResult:\n{\n "success": true,\n "lines": [\n "a",\n "b"\n ]\n}'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves scalar strings untouched', () => {
|
||||
expect(technicalTrace(undefined, 'plain text')).toBe('Result:\nplain text')
|
||||
expect(technicalTrace(undefined, '"already quoted"')).toBe('Result:\n"already quoted"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
type ToolStatus,
|
||||
type ToolTitleAction
|
||||
} from './fallback-model'
|
||||
import { prettyJson } from './fallback-model/format'
|
||||
|
||||
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
|
||||
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
|
||||
@@ -104,23 +103,39 @@ interface ToolStatusCopy {
|
||||
statusRunning: string
|
||||
}
|
||||
|
||||
function rawTechnicalTrace(args: unknown, result: unknown): string {
|
||||
const parts = [args, result]
|
||||
.filter(value => value !== undefined && value !== null)
|
||||
.map(value => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
function prettyTechnicalValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return value
|
||||
}
|
||||
|
||||
return clampForDisplay(parts.join('\n'))
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function technicalTrace(args: unknown, result: unknown): string {
|
||||
const parts = [
|
||||
['Arguments', args],
|
||||
['Result', result]
|
||||
]
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([label, value]) => `${label}:\n${prettyTechnicalValue(value)}`)
|
||||
|
||||
return clampForDisplay(parts.join('\n\n'))
|
||||
}
|
||||
|
||||
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
@@ -363,7 +378,8 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
|
||||
const showDetail =
|
||||
!view.inlineDiff &&
|
||||
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
||||
(Boolean(view.stdout || view.stderr) ||
|
||||
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
||||
(view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle))
|
||||
|
||||
const renderDetailAsCode =
|
||||
@@ -373,21 +389,16 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
const hasSearchHits = Boolean(view.searchHits?.length)
|
||||
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
|
||||
|
||||
// Only web_search renders the raw JSON drilldown, so serialize the result
|
||||
// lazily here instead of prettyJson-ing every tool's result in buildToolView.
|
||||
const rawResult = useMemo(
|
||||
() => (part.toolName === 'web_search' && toolViewMode !== 'technical' ? prettyJson(part.result) : ''),
|
||||
[part.toolName, part.result, toolViewMode]
|
||||
)
|
||||
|
||||
const showRawSearchDrilldown =
|
||||
part.toolName === 'web_search' &&
|
||||
part.result !== undefined &&
|
||||
toolViewMode !== 'technical' &&
|
||||
Boolean(rawResult.trim())
|
||||
|
||||
const hasExpandableContent = Boolean(
|
||||
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
|
||||
view.imageUrl ||
|
||||
view.inlineDiff ||
|
||||
showDetail ||
|
||||
hasSearchHits ||
|
||||
view.stdout ||
|
||||
view.stderr ||
|
||||
view.terminalCommand ||
|
||||
view.terminalExitCode !== undefined ||
|
||||
toolViewMode === 'technical'
|
||||
)
|
||||
|
||||
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
|
||||
@@ -511,6 +522,9 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
text={copyAction.text}
|
||||
/>
|
||||
)}
|
||||
{part.toolName === 'terminal' && toolViewMode !== 'technical' && (
|
||||
<TerminalTranscript command={view.terminalCommand} exitCode={view.terminalExitCode} />
|
||||
)}
|
||||
{view.imageUrl && (
|
||||
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
||||
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
|
||||
@@ -518,6 +532,12 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
{hasSearchHits && view.searchHits && (
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.searchQuery && (
|
||||
<p className="mb-1 flex min-w-0 gap-1.5 wrap-anywhere">
|
||||
<span className="shrink-0 font-medium text-(--ui-text-tertiary)">Search</span>
|
||||
<span>{view.searchQuery}</span>
|
||||
</p>
|
||||
)}
|
||||
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
|
||||
<SearchResultsList hits={view.searchHits} />
|
||||
</div>
|
||||
@@ -596,22 +616,16 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showRawSearchDrilldown && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>{rawResult}</pre>
|
||||
</details>
|
||||
)}
|
||||
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
{technicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
)}
|
||||
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
{technicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
@@ -621,6 +635,38 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface TerminalTranscriptProps {
|
||||
command?: string
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
|
||||
if (!command && exitCode === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-[0.25rem] border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed">
|
||||
{command && (
|
||||
<code className="min-w-0 flex-1 whitespace-pre-wrap wrap-anywhere text-(--ui-text-secondary)">
|
||||
<span aria-hidden className="select-none text-(--ui-accent-secondary)">$ </span>
|
||||
{command}
|
||||
</code>
|
||||
)}
|
||||
{exitCode !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded bg-(--ui-bg-tertiary) px-1 py-px text-[0.6rem] tabular-nums',
|
||||
exitCode === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
exit {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A back-to-back run of this many tool calls collapses into the bounded,
|
||||
// auto-scrolling window; fewer than this stays a plain inline stack.
|
||||
const TOOL_GROUP_SCROLL_THRESHOLD = 3
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block'
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent, in-stack billing wall for THIS session. Rendered as a shared
|
||||
* {@link StatusRow} — same chrome as its status-stack siblings, so it reads as
|
||||
* one piece with the composer card (no bordered alert-in-a-card). It never
|
||||
* disables the composer — slash commands (`/topup`, `/model`, `/login`) stay
|
||||
* usable — it only offers recovery: Nous opens Settings → Billing in-app, other
|
||||
* providers deep-link out. The sticky toast is the loud surface; this is the calm
|
||||
* reminder that outlives it.
|
||||
*/
|
||||
export function BillingBanner({ sessionId }: { sessionId: null | string }) {
|
||||
const active = useStore($billingBlock)
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!active || !sessionId || active.sessionId !== sessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { block } = active
|
||||
const copy = t.billingBlock
|
||||
const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label)
|
||||
const message = firstLine(block.message) || copy.fallbackMessage
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
leading={<Codicon aria-hidden className="text-destructive/85" name="credit-card" size="0.8rem" />}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
className="text-foreground/90 hover:text-foreground"
|
||||
onClick={() => runBillingRecovery(block)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{billingCtaLabel(block, copy)}
|
||||
</Button>
|
||||
<Tip label={copy.dismiss}>
|
||||
<Button
|
||||
aria-label={copy.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={() => clearBillingBlock(sessionId)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
|
||||
<span className="font-medium">{title}</span>
|
||||
{message && <span className="text-muted-foreground/80"> · {message}</span>}
|
||||
</span>
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExpandableBlock } from './expandable-block'
|
||||
|
||||
// jsdom has no ResizeObserver and reports scrollHeight === 0, so the block
|
||||
// never flips to `overflowing` on its own. Stub RO to fire immediately and
|
||||
// force a tall scrollHeight on the observed node so the toggle mounts.
|
||||
class TestResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
observe(target: Element) {
|
||||
Object.defineProperty(target, 'scrollHeight', { configurable: true, value: 400 })
|
||||
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ExpandableBlock', () => {
|
||||
it('lets horizontal scroll through and keeps the last line selectable', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
|
||||
const { container } = render(
|
||||
<ExpandableBlock>
|
||||
<pre data-testid="content">{'const x = 1\n'.repeat(20)}</pre>
|
||||
</ExpandableBlock>
|
||||
)
|
||||
|
||||
const inner = container.querySelector('[data-testid="content"]')!.parentElement!
|
||||
const toggle = screen.getByRole('button', { name: /expand|collapse/i })
|
||||
const fade = toggle.parentElement!
|
||||
|
||||
// Inner container allows horizontal scroll so wide code gets a scrollbar.
|
||||
expect(inner.className).toContain('overflow-x-auto')
|
||||
|
||||
// The full-width fade is a pure cue: it spans the bottom edge but must not
|
||||
// intercept pointer events, so the scrollbar drag and text selection on the
|
||||
// last line pass through to the content underneath.
|
||||
expect(fade.className).toContain('pointer-events-none')
|
||||
expect(fade.className).toContain('inset-x-0')
|
||||
|
||||
// Only the compact toggle is clickable, and it is pinned to the right edge
|
||||
// rather than spanning the full width (the old bug).
|
||||
expect(toggle.className).toContain('pointer-events-auto')
|
||||
expect(toggle.className).toContain('w-9')
|
||||
expect(toggle.className).not.toContain('inset-x-0')
|
||||
})
|
||||
|
||||
it('still toggles expanded state when the compact control is clicked', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
|
||||
render(
|
||||
<ExpandableBlock>
|
||||
<pre data-testid="content">{'line\n'.repeat(20)}</pre>
|
||||
</ExpandableBlock>
|
||||
)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Expand' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Collapse' }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -31,19 +31,30 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)} ref={innerRef}>
|
||||
<div
|
||||
className={cn('overflow-y-auto overflow-x-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
|
||||
ref={innerRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{overflowing && (
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
</button>
|
||||
// The fade is a pure overflow cue and must not intercept pointer events:
|
||||
// it spans the full bottom edge (over the horizontal scrollbar of a wide
|
||||
// code block AND the block's last line), so making it clickable killed
|
||||
// both sideways scrolling and text selection. Keep the fade
|
||||
// `pointer-events-none` and pin the only clickable target — a compact
|
||||
// toggle — to the right edge, clear of the draggable scrollbar track.
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-7 justify-end bg-linear-to-t from-(--ui-chat-surface-background) to-transparent">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
className="pointer-events-auto flex h-7 w-9 cursor-pointer items-end justify-center pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorIcon } from '@/components/ui/error-state'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import type {
|
||||
DesktopBootstrapEvent,
|
||||
DesktopBootstrapStageDescriptor,
|
||||
@@ -435,12 +436,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</span>
|
||||
<span className="tabular-nums">{progressPct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={copy.progress(completedCount, totalCount)}
|
||||
className="bg-(--ui-bg-tertiary)"
|
||||
destructive={failed}
|
||||
value={progressPct / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
@@ -162,6 +162,30 @@ function BottomRightStack({
|
||||
)
|
||||
}
|
||||
|
||||
// Emphasize only the leading money figure ("$16.00" — the amount used) with the
|
||||
// accent color (semibold), leaving the rest of the line in its default muted
|
||||
// tone. No accent, or no figure in the message → render the text untouched.
|
||||
function renderMessage(message: string, accent?: string): ReactNode {
|
||||
const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null
|
||||
|
||||
if (!match) {
|
||||
return message
|
||||
}
|
||||
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, start)}
|
||||
<span className="font-semibold" style={{ color: accent }}>
|
||||
{match[0]}
|
||||
</span>
|
||||
{message.slice(end)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
@@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
// Nudge the icon down to sit on the first text line, in `ch` so it tracks the
|
||||
// toast's font size instead of a fixed rem. `accentColor` (when set) tints the
|
||||
// icon + message as a severity ramp, overriding the kind's default color.
|
||||
const accent = notification.accentColor
|
||||
const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) }
|
||||
|
||||
return (
|
||||
<Alert
|
||||
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
||||
@@ -177,14 +207,17 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
variant={styles.variant}
|
||||
>
|
||||
{notification.icon ? (
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" style={iconStyle} />
|
||||
) : (
|
||||
<Icon className={styles.iconClass} />
|
||||
<Icon className={styles.iconClass} style={iconStyle} />
|
||||
)}
|
||||
<div className="col-start-2 min-w-0">
|
||||
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
||||
<AlertDescription className="col-start-auto">
|
||||
<p className="m-0">{notification.message}</p>
|
||||
<p className="m-0">{renderMessage(notification.message, accent)}</p>
|
||||
{notification.meta && (
|
||||
<p className="m-0 text-xs text-muted-foreground tabular-nums">{notification.meta}</p>
|
||||
)}
|
||||
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
||||
{notification.action && (
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons'
|
||||
@@ -372,15 +373,12 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
||||
hasError && 'bg-destructive'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={installing ? t.onboarding.preparingInstall : t.onboarding.starting}
|
||||
destructive={hasError}
|
||||
size="lg"
|
||||
value={progress / 100}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">{boot.message}</span>
|
||||
<span>{progress}%</span>
|
||||
|
||||
@@ -58,11 +58,11 @@ interface PaneChrome {
|
||||
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
|
||||
* live: the workspace contribution re-registers it on route changes. */
|
||||
headerVeto?: boolean
|
||||
/** A lead-dot color for this pane's TAB (a session tab inheriting its
|
||||
* project color). Generic — any pane may contribute one; the strip just
|
||||
* renders a tinted dot before the label. Live: the owning contribution
|
||||
* re-registers it when the resolved color changes. */
|
||||
accent?: string
|
||||
/** A lead NODE for this pane's TAB, rendered before the label. A session
|
||||
* pane (main workspace + tiles) passes its live `SessionStatusDot` here so
|
||||
* the tab and the sidebar row render status/color from the ONE primitive
|
||||
* (self-subscribing — it updates without the strip re-registering). */
|
||||
tabLead?: () => React.ReactNode
|
||||
}
|
||||
|
||||
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
$hiddenTreePanes,
|
||||
$layoutTree,
|
||||
$narrowViewport,
|
||||
$newSessionTabAction,
|
||||
$treeDragging,
|
||||
activateTreePane,
|
||||
closeTreePane,
|
||||
@@ -162,6 +163,7 @@ export function TreeGroup({
|
||||
|
||||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
const narrow = useStore($narrowViewport)
|
||||
const newSessionTabAction = useStore($newSessionTabAction)
|
||||
|
||||
const paneFor = (id: string) => panes.find(p => p.id === id)
|
||||
|
||||
@@ -178,6 +180,30 @@ export function TreeGroup({
|
||||
const active = paneFor(activeId)
|
||||
const isEmpty = node.panes.length === 0
|
||||
|
||||
// KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted —
|
||||
// an inactive tab merely hides (visibility), it does not unmount. Remounting
|
||||
// on every tab switch re-measured and re-scrolled the content from scratch
|
||||
// (the thread visibly layout-shifted each time a session tab was revisited).
|
||||
// Lazy on purpose: a pane first mounts when first activated, so a
|
||||
// boot-restored tab stack doesn't resume every session up front.
|
||||
const everActivePanesRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (!node.minimized && !isEmpty) {
|
||||
everActivePanesRef.current.add(activeId)
|
||||
}
|
||||
|
||||
// Prune panes that left the zone (closed / moved to another group), so a
|
||||
// long-lived zone doesn't pin stale ids forever.
|
||||
for (const id of everActivePanesRef.current) {
|
||||
if (!node.panes.includes(id)) {
|
||||
everActivePanesRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id))
|
||||
|
||||
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
|
||||
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
|
||||
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
|
||||
@@ -446,12 +472,8 @@ export function TreeGroup({
|
||||
role="tab"
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
{chrome.accent ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-2 -mr-1 size-1 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: chrome.accent }}
|
||||
/>
|
||||
{chrome.tabLead ? (
|
||||
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
|
||||
) : null}
|
||||
<PaneTabLabel>{title}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
@@ -461,6 +483,23 @@ export function TreeGroup({
|
||||
// tile tab); the wrapper needs the key since it's the root.
|
||||
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
|
||||
})}
|
||||
|
||||
{/* Plain "+" after the last tab of the MAIN strip (the workspace
|
||||
zone) — always shown, no tab/button chrome, just the glyph.
|
||||
Creates a new session tab (mirrors ⌘T) via the app-registered
|
||||
action; hidden when unwired or the zone is minimized. */}
|
||||
{node.panes.includes('workspace') && newSessionTabAction && !node.minimized && (
|
||||
<button
|
||||
aria-label={t.zones.newSessionTab}
|
||||
className="grid size-7 shrink-0 place-items-center self-center bg-transparent text-(--ui-text-quaternary) transition-colors hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={() => newSessionTabAction()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
title={t.zones.newSessionTab}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.8125rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{minimizable && (
|
||||
<button
|
||||
@@ -478,18 +517,40 @@ export function TreeGroup({
|
||||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* Body: the active pane's contributed content, or the empty zone. */}
|
||||
{/* Body: the zone's pane content — every kept (ever-active) pane stays
|
||||
mounted in an absolute layer; only the active one is visible.
|
||||
`visibility` (not display) keeps the hidden pane's layout box, so
|
||||
scroll positions and measurements survive the round-trip. */}
|
||||
{!node.minimized && (
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-auto">
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{isEmpty ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
{/* Same decode primitive as the CONNECTING boot overlay. */}
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
|
||||
</div>
|
||||
) : active?.render ? (
|
||||
<ContribBoundary id={active.id}>{active.render()}</ContribBoundary>
|
||||
) : (
|
||||
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div>
|
||||
keptPanes.map(paneId => {
|
||||
const pane = paneFor(paneId)
|
||||
const isActive = paneId === activeId
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isActive || undefined}
|
||||
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
|
||||
key={paneId}
|
||||
>
|
||||
{pane?.render ? (
|
||||
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
|
||||
) : (
|
||||
isActive && (
|
||||
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
|
||||
{t.zones.missingPane(paneId)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -343,6 +343,13 @@ export function treePanesWithPrefix(prefix: string): string[] {
|
||||
return tree ? allPaneIds(tree).filter(id => id.startsWith(prefix)) : []
|
||||
}
|
||||
|
||||
/** The main tab strip's "+": open a new session as its own tab (reusing an
|
||||
* already-open unused tab when one exists, so repeated clicks don't pile up
|
||||
* empty sessions). The app wiring registers the concrete action so this
|
||||
* generic renderer stays session-agnostic; null until wired (the "+" hides).
|
||||
* An atom so the strip re-renders when the action becomes available. */
|
||||
export const $newSessionTabAction = atom<(() => void) | null>(null)
|
||||
|
||||
/** ⌘1…⌘9: activate the Nth tab of the FOCUSED zone (the interaction tracker's
|
||||
* group), but only when it's a real tab strip (≥2 panes). Returns false so the
|
||||
* caller falls back to its default (profile switch) — the number keys mean
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
interface PetEggHatchProps {
|
||||
subtitle?: string
|
||||
@@ -23,22 +24,17 @@ interface PetEggHatchProps {
|
||||
*/
|
||||
export function PetProgress({ done, total }: { done?: number; total?: number }) {
|
||||
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
|
||||
const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={determinate ? pct : undefined}
|
||||
className="pet-progress"
|
||||
role="progressbar"
|
||||
>
|
||||
{determinate ? (
|
||||
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
|
||||
) : (
|
||||
<div className="pet-progress__indeterminate" />
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
animated
|
||||
aria-label="Hatching progress"
|
||||
className="bg-[color-mix(in_srgb,var(--ui-accent)_15%,transparent)]"
|
||||
fillClassName="bg-(--ui-accent)"
|
||||
indeterminate={!determinate}
|
||||
size="sm"
|
||||
value={determinate ? done / total : 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Input } from './input'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders a bare input carrying the control chrome when unadorned', () => {
|
||||
const { getByRole } = render(<Input aria-label="plain" />)
|
||||
const el = getByRole('textbox')
|
||||
|
||||
expect(el.tagName).toBe('INPUT')
|
||||
expect(el.className).toContain('desktop-input-chrome')
|
||||
// No group wrapper — the input is the top-level node.
|
||||
expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group')
|
||||
})
|
||||
|
||||
it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => {
|
||||
const { getByRole, getByText } = render(<Input aria-label="amount" prefix="$" />)
|
||||
const el = getByRole('textbox')
|
||||
const group = el.closest('[data-slot="input-group"]')
|
||||
|
||||
expect(group).not.toBeNull()
|
||||
// Chrome moves to the wrapper; the input itself goes transparent/borderless.
|
||||
expect(group?.className).toContain('desktop-input-chrome')
|
||||
expect(el.className).not.toContain('desktop-input-chrome')
|
||||
expect(el.className).toContain('bg-transparent')
|
||||
|
||||
const prefix = getByText('$')
|
||||
expect(group?.contains(prefix)).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a trailing suffix inside the group', () => {
|
||||
const { getByText } = render(<Input aria-label="rate" suffix="%" />)
|
||||
const suffix = getByText('%')
|
||||
|
||||
expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('forwards value/onChange through the adorned field', () => {
|
||||
const onChange = vi.fn()
|
||||
const { getByRole } = render(<Input aria-label="amount" onChange={onChange} prefix="$" value="" />)
|
||||
|
||||
fireEvent.change(getByRole('textbox'), { target: { value: '100' } })
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dims the group and disables the field when disabled', () => {
|
||||
const { getByRole } = render(<Input aria-label="amount" disabled prefix="$" />)
|
||||
const el = getByRole('textbox') as HTMLInputElement
|
||||
const group = el.closest('[data-slot="input-group"]')
|
||||
|
||||
expect(el.disabled).toBe(true)
|
||||
expect(group?.className).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('lets containerClassName override the wrapper width', () => {
|
||||
const { getByRole } = render(<Input aria-label="amount" containerClassName="w-20" prefix="$" />)
|
||||
const group = getByRole('textbox').closest('[data-slot="input-group"]')
|
||||
|
||||
// twMerge resolves the control's default w-full down to the caller's width.
|
||||
expect(group?.className).toContain('w-20')
|
||||
expect(group?.className).not.toContain('w-full')
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,22 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
|
||||
return (
|
||||
// `prefix`/`suffix` are DOM string attributes on the native element; we shadow
|
||||
// them with ReactNode adornments, so they're omitted from the base props.
|
||||
type InputProps = Omit<React.ComponentProps<'input'>, 'size' | 'prefix' | 'suffix'> &
|
||||
ControlVariantProps & {
|
||||
/** Leading adornment rendered inside the field (e.g. a `$` for money). */
|
||||
prefix?: React.ReactNode
|
||||
/** Trailing adornment rendered inside the field (e.g. a unit label). */
|
||||
suffix?: React.ReactNode
|
||||
/** Applied to the wrapper when an adornment promotes the field to a group. */
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) {
|
||||
const grouped = prefix != null || suffix != null
|
||||
|
||||
const field = (
|
||||
<input
|
||||
// Off by default for every consumer — these are code/config/search fields,
|
||||
// not prose. Callers can re-enable per-instance by passing the prop.
|
||||
@@ -13,7 +27,12 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
controlVariants({ size }),
|
||||
// When adorned, the wrapper owns the chrome (border/background/focus
|
||||
// glow) and the input goes transparent so the whole thing reads as one
|
||||
// box; otherwise the input carries the chrome itself.
|
||||
grouped
|
||||
? 'min-w-0 flex-1 border-0 bg-transparent p-0 text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground'
|
||||
: controlVariants({ size }),
|
||||
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
|
||||
className
|
||||
)}
|
||||
@@ -23,6 +42,32 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!grouped) {
|
||||
return field
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Same control chrome/sizing as a bare input; `.desktop-input-chrome`
|
||||
// lights on `:focus-within` (styles.css) since the div never focuses.
|
||||
controlVariants({ size }),
|
||||
'inline-flex items-center gap-1',
|
||||
props.disabled && 'cursor-not-allowed opacity-50',
|
||||
containerClassName
|
||||
)}
|
||||
data-slot="input-group"
|
||||
>
|
||||
{prefix != null && (
|
||||
<span className="pointer-events-none shrink-0 select-none text-muted-foreground">{prefix}</span>
|
||||
)}
|
||||
{field}
|
||||
{suffix != null && (
|
||||
<span className="pointer-events-none shrink-0 select-none text-muted-foreground">{suffix}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user