Compare commits
303
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e704e9220 | ||
|
|
bd37ff9138 | ||
|
|
659d1123c4 | ||
|
|
6cd5a2c5f7 | ||
|
|
bfca45bda0 | ||
|
|
23526148d1 | ||
|
|
fdbfae825e | ||
|
|
d0dcb9a5fd | ||
|
|
8462764367 | ||
|
|
b099652d9f | ||
|
|
cf73b3d411 | ||
|
|
633fc7ab88 | ||
|
|
0678f8f019 | ||
|
|
007cd15132 | ||
|
|
9298099689 | ||
|
|
5d9a72b7c2 | ||
|
|
4759362188 | ||
|
|
9078a838c7 | ||
|
|
d79f75e1c6 | ||
|
|
8091c44054 | ||
|
|
58010c8b3d | ||
|
|
b5bd0ef38b | ||
|
|
6cc4691c86 | ||
|
|
c2e11bf418 | ||
|
|
f3cbe45605 | ||
|
|
3fccd698fd | ||
|
|
14f023cd00 | ||
|
|
a7ec1b6e39 | ||
|
|
1305a690e0 | ||
|
|
03c0b00f45 | ||
|
|
7edaaf4682 | ||
|
|
558fcb6146 | ||
|
|
01bab394cd | ||
|
|
72562be961 | ||
|
|
2fba721ab0 | ||
|
|
adb647269a | ||
|
|
367d3758d5 | ||
|
|
a6d9d1d2cf | ||
|
|
4ccb232af9 | ||
|
|
1b69c47e97 | ||
|
|
efb6c21498 | ||
|
|
27b31bb7ff | ||
|
|
fbf5005a7e | ||
|
|
9fc8fe2176 | ||
|
|
3990bdf551 | ||
|
|
998e35313a | ||
|
|
c3b2af95e3 | ||
|
|
bb853b2e95 | ||
|
|
6ff65c4d20 | ||
|
|
fef0b2d600 | ||
|
|
0cc9426c6d | ||
|
|
9cb3569e97 | ||
|
|
6b1267c2e4 | ||
|
|
f7d6f099db | ||
|
|
8191f621c3 | ||
|
|
dd9e75335c | ||
|
|
fe2d847aca | ||
|
|
e984a61306 | ||
|
|
3366204474 | ||
|
|
d34cc4093a | ||
|
|
454d553d34 | ||
|
|
bda8bd76a8 | ||
|
|
75c878217d | ||
|
|
4ad5036a44 | ||
|
|
a7a05024c1 | ||
|
|
6ef13af4be | ||
|
|
679487b807 | ||
|
|
f68fd80f41 | ||
|
|
261b0f8240 | ||
|
|
56e06d7ee9 | ||
|
|
f01f0f75fe | ||
|
|
dc419d6e80 | ||
|
|
d0afcb125c | ||
|
|
6297634d22 | ||
|
|
7a78342ab3 | ||
|
|
a61e29e879 | ||
|
|
97397a1ccc | ||
|
|
53adb3fd97 | ||
|
|
5604d1852e | ||
|
|
f61169861a | ||
|
|
702473edbd | ||
|
|
01d3268e02 | ||
|
|
64746b4bd3 | ||
|
|
bd44ef8645 | ||
|
|
ea2c9bc10f | ||
|
|
6160a80253 | ||
|
|
8fc989b416 | ||
|
|
c82a196ea9 | ||
|
|
49d3fee0bd | ||
|
|
95a0f9c836 | ||
|
|
f4c7caa70c | ||
|
|
13e19a9092 | ||
|
|
164bca658e | ||
|
|
21dedb8586 | ||
|
|
07e537d8ea | ||
|
|
bfb51fec81 | ||
|
|
caf5f27e30 | ||
|
|
7d8c499893 | ||
|
|
59787b9ada | ||
|
|
b60c940d9e | ||
|
|
13906cd4de | ||
|
|
a79b818360 | ||
|
|
681852a5b9 | ||
|
|
176a98c39a | ||
|
|
a710becd6c | ||
|
|
0fe18b8650 | ||
|
|
4851f894be | ||
|
|
ee659d1d8f | ||
|
|
d73a6f5ac2 | ||
|
|
4a79305930 | ||
|
|
27364b24fe | ||
|
|
eb48e22106 | ||
|
|
4fb6c297ee | ||
|
|
ea028ca311 | ||
|
|
244f70aae5 | ||
|
|
33513991be | ||
|
|
9a21d0e3f2 | ||
|
|
b4e4b5a43e | ||
|
|
86e7917ba7 | ||
|
|
a04fcbf779 | ||
|
|
6e96b745d8 | ||
|
|
e2db5ebad5 | ||
|
|
193871f1a6 | ||
|
|
fce298f700 | ||
|
|
09505a393e | ||
|
|
6803519aa5 | ||
|
|
b9858acb0c | ||
|
|
94136efcca | ||
|
|
5342f8613d | ||
|
|
fe5c0cb6c3 | ||
|
|
365620ab28 | ||
|
|
5a39f0501c | ||
|
|
a3c0de1a36 | ||
|
|
4d6686c18a | ||
|
|
298a94926f | ||
|
|
2fd36b17c5 | ||
|
|
eb6aa03609 | ||
|
|
c9c9bb33fc | ||
|
|
7aa21e3362 | ||
|
|
e0240d7bf7 | ||
|
|
1a323d608e | ||
|
|
e844ea9f0b | ||
|
|
1e895f4c17 | ||
|
|
577beeb9b9 | ||
|
|
202ad1b8c9 | ||
|
|
c72f4576b9 | ||
|
|
5f171e36ab | ||
|
|
03885e0aa1 | ||
|
|
49a8c3f836 | ||
|
|
0ccd05b78a | ||
|
|
9420ad946a | ||
|
|
6ee40b65ba | ||
|
|
5330b2cfad | ||
|
|
5458c76566 | ||
|
|
0f239f49c6 | ||
|
|
f0e6daddce | ||
|
|
9ce0e67f27 | ||
|
|
156ea4ad89 | ||
|
|
c98de70c2b | ||
|
|
479d1aff6c | ||
|
|
f8bf40b18b | ||
|
|
d68ac9092a | ||
|
|
d8f7b608c9 | ||
|
|
4a69a6620e | ||
|
|
d1be769b45 | ||
|
|
3ffd8b3da0 | ||
|
|
c80b244b52 | ||
|
|
4d30b05d6d | ||
|
|
fd3d9c63d0 | ||
|
|
5461e0e098 | ||
|
|
be2a1290de | ||
|
|
92876effe2 | ||
|
|
d542894adf | ||
|
|
b27d8b6ac8 | ||
|
|
094f2b55d3 | ||
|
|
ca803523ff | ||
|
|
54d0948d38 | ||
|
|
81fc24862c | ||
|
|
2ea39daeb1 | ||
|
|
6a35f9e667 | ||
|
|
f8ddf4fd86 | ||
|
|
f8b6d381e2 | ||
|
|
a16ac37dd9 | ||
|
|
3f2a389c7e | ||
|
|
5fc2d9e64d | ||
|
|
0c1adb4877 | ||
|
|
75f45a0692 | ||
|
|
5222d24f35 | ||
|
|
dbf86b9234 | ||
|
|
09f8a8268c | ||
|
|
2f3007ff51 | ||
|
|
f8abc521f3 | ||
|
|
64389a2ce2 | ||
|
|
00a36831d2 | ||
|
|
56ab9951b1 | ||
|
|
3bfa6001f7 | ||
|
|
93808ca6a7 | ||
|
|
3102fc9a66 | ||
|
|
02613a4d50 | ||
|
|
894e62759b | ||
|
|
f32a1f6078 | ||
|
|
ef7aabd3d1 | ||
|
|
2179d5e8af | ||
|
|
214cbf77f0 | ||
|
|
b80b52aa46 | ||
|
|
8b209e0dd7 | ||
|
|
a569226f88 | ||
|
|
4e7b0389ea | ||
|
|
c1945d410b | ||
|
|
b6c11a35ac | ||
|
|
5601f24449 | ||
|
|
bd7e480236 | ||
|
|
c81afd6e50 | ||
|
|
5f1991bf6b | ||
|
|
2b6897f982 | ||
|
|
58033baba2 | ||
|
|
c346f018d4 | ||
|
|
a4c9b069dc | ||
|
|
e0e7cfa673 | ||
|
|
170959d80a | ||
|
|
91f87137b6 | ||
|
|
7a5a6ef99b | ||
|
|
da4a28ec6d | ||
|
|
587e76fbc2 | ||
|
|
1f89f3102f | ||
|
|
5c03e27ce2 | ||
|
|
6020b9f4fe | ||
|
|
3b15afafa2 | ||
|
|
b6f749af94 | ||
|
|
5e6a0d9eea | ||
|
|
2106f63723 | ||
|
|
fcdc10a0f3 | ||
|
|
a61a0bc019 | ||
|
|
f8630a1456 | ||
|
|
647520f83e | ||
|
|
c29ab7b9fe | ||
|
|
f58e4622cf | ||
|
|
e8b7ce8c19 | ||
|
|
d7993ab178 | ||
|
|
a1d6654264 | ||
|
|
a55523fd6d | ||
|
|
9166d727b8 | ||
|
|
5e65f6d79f | ||
|
|
6efec39ecf | ||
|
|
6a8e7069b4 | ||
|
|
09b6d22dfe | ||
|
|
08015c3a8f | ||
|
|
fe1ab949fd | ||
|
|
704bbcca80 | ||
|
|
868a2f7d17 | ||
|
|
1600008ab0 | ||
|
|
1d48863b85 | ||
|
|
678b86df1c | ||
|
|
07be37d996 | ||
|
|
f3ec79964e | ||
|
|
2388e0687b | ||
|
|
1011cd24e2 | ||
|
|
7c954969b7 | ||
|
|
306e2d2318 | ||
|
|
8662254ab2 | ||
|
|
a7ef17da7a | ||
|
|
31dcd68bfa | ||
|
|
771571aee4 | ||
|
|
9df5f879b4 | ||
|
|
a52393a3b6 | ||
|
|
9be941dac1 | ||
|
|
7af59f474d | ||
|
|
bcd7e2ce89 | ||
|
|
c2a3b9ce58 | ||
|
|
779c0dd80d | ||
|
|
8fa8aabbbb | ||
|
|
1b059d1ae7 | ||
|
|
0ab90040ad | ||
|
|
b5aef05e2c | ||
|
|
c18edf4ac0 | ||
|
|
305e26558e | ||
|
|
092a97ef75 | ||
|
|
db488df1c3 | ||
|
|
798e602a8e | ||
|
|
ec926ce89e | ||
|
|
7dc21f08a1 | ||
|
|
5f37c9e85c | ||
|
|
8bd4a419de | ||
|
|
d14bf23fa3 | ||
|
|
a57b33782e | ||
|
|
aff205dcc2 | ||
|
|
369d0eeeff | ||
|
|
10fbade64b | ||
|
|
7f74b324c3 | ||
|
|
0f922002eb | ||
|
|
0f398f8e9c | ||
|
|
2afbe77763 | ||
|
|
860a3f67bb | ||
|
|
eae1d7d147 | ||
|
|
ac4f596ca2 | ||
|
|
e6fea77d14 | ||
|
|
f1379bd6c2 | ||
|
|
aae35c5ee4 | ||
|
|
63a9bde77b | ||
|
|
c388daa665 | ||
|
|
7ed7170960 | ||
|
|
aefb36299a | ||
|
|
99ff67eb03 |
@@ -24,9 +24,15 @@ outputs:
|
||||
deps:
|
||||
description: Check pyproject.toml dependency upper bounds.
|
||||
value: ${{ steps.classify.outputs.deps }}
|
||||
npm_lock:
|
||||
description: Post/update the semantic package-lock.json diff PR comment.
|
||||
value: ${{ steps.classify.outputs.npm_lock }}
|
||||
mcp_catalog:
|
||||
description: Require MCP catalog security review label.
|
||||
value: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
ci_review:
|
||||
description: Require CI-sensitive file review label.
|
||||
value: ${{ steps.classify.outputs.ci_review }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
@@ -41,8 +41,10 @@ jobs:
|
||||
site: ${{ steps.classify.outputs.site }}
|
||||
scan: ${{ steps.classify.outputs.scan }}
|
||||
deps: ${{ steps.classify.outputs.deps }}
|
||||
npm_lock: ${{ steps.classify.outputs.npm_lock }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
ci_review: ${{ steps.classify.outputs.ci_review }}
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -65,10 +67,11 @@ jobs:
|
||||
lint:
|
||||
name: Python lints
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
|
||||
js-tests:
|
||||
name: JS & TS checks
|
||||
@@ -99,6 +102,12 @@ jobs:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
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
|
||||
|
||||
docker-lint:
|
||||
name: Lint Docker scripts
|
||||
needs: detect
|
||||
@@ -144,6 +153,7 @@ jobs:
|
||||
- history-check
|
||||
- contributor-check
|
||||
- uv-lockfile
|
||||
- lockfile-diff
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- osv-scanner
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
name: auto-fix lint issues & formatting
|
||||
|
||||
# On push to main (or manual trigger), run `npm run fix` on each workspace
|
||||
# package and apply any changes via a PR.
|
||||
#
|
||||
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
|
||||
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
|
||||
# check in typecheck.yml fails only when un-fixable errors remain.
|
||||
#
|
||||
# Pushes made by GITHUB_TOKEN don't trigger further workflow runs, so there's
|
||||
# no infinite loop — the push to bot/js-autofix and the resulting squash merge
|
||||
# to main are both made by GITHUB_TOKEN.
|
||||
#
|
||||
# ── Security model: two-job split ───────────────────────────────────────────
|
||||
#
|
||||
# The eslint process executes repo code (eslint.config.mjs, package.json
|
||||
# scripts, installed plugins). To prevent a malicious PR from getting arbitrary
|
||||
# code execution on a runner with push access, the work is split:
|
||||
#
|
||||
# 1. generate-patch (unprivileged, contents: read only)
|
||||
# Checks out, installs deps, runs eslint --fix, produces a .patch artifact.
|
||||
# Worst case: malicious code runs here on an ephemeral runner with zero
|
||||
# push permissions.
|
||||
#
|
||||
# 2. apply-patch (privileged, contents: write + pull-requests: write)
|
||||
# Checks out, downloads the patch artifact, applies it, pushes to the
|
||||
# bot/js-autofix branch, creates/updates a PR, and enables auto-merge.
|
||||
# This job never runs npm, never installs anything, never executes any
|
||||
# repo code. The only input it trusts is the patch artifact.
|
||||
# The PR auto-merges (squash) once CI passes. If CI fails or main moves,
|
||||
# the PR is auto-closed and the branch deleted — the next run re-applies
|
||||
# on the current state.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.js'
|
||||
- '**/*.cjs'
|
||||
- '**/*.mjs'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read # default; apply-patch job overrides to write
|
||||
|
||||
concurrency:
|
||||
group: ts-autofix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate-patch:
|
||||
name: Generate eslint --fix patch
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# No permissions override → inherits workflow-level contents: read.
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# --ignore-scripts: eslint only needs TS sources + eslint packages.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
|
||||
- name: npm run fix in all workspaces
|
||||
# continue-on-error: if un-fixable errors exist on main, we still want
|
||||
# to commit whatever fixes were applied. The PR-time check in
|
||||
# typecheck.yml is what blocks un-fixable errors from landing.
|
||||
continue-on-error: true
|
||||
run: npm run fix
|
||||
|
||||
- name: Produce patch
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "No fixes needed."
|
||||
# Empty patch signals "nothing to do" to apply-patch.
|
||||
: > js-fix.patch
|
||||
else
|
||||
git diff > js-fix.patch
|
||||
echo "Patch size: $(wc -c < js-fix.patch) bytes"
|
||||
|
||||
# Reject patches that touch anything outside JS/TS/JSON sources.
|
||||
# `npm run fix` should only ever modify those; anything else means
|
||||
# eslint/prettier or a plugin went rogue and we refuse to ship it.
|
||||
BAD=$(git diff --name-only | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "::error::Refusing to upload patch — touches disallowed files:"
|
||||
echo "$BAD"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Upload patch artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: js-fix-patch
|
||||
path: js-fix.patch
|
||||
retention-days: 1
|
||||
include-hidden-files: true
|
||||
|
||||
apply-patch:
|
||||
name: Apply patch
|
||||
needs: generate-patch
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write # needed to push to bot/js-autofix
|
||||
pull-requests: write # needed for PR creation + auto-merge
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download patch
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: js-fix-patch
|
||||
# ${{ runner.temp }} expands in with: params (shell-style $VAR does not).
|
||||
# download-artifact's path is a *directory* — the artifact's js-fix.patch
|
||||
# file lands inside it, so $RUNNER_TEMP/js-fix.patch resolves correctly
|
||||
# in the run step below.
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Apply patch and push to bot branch
|
||||
env:
|
||||
BOT_BRANCH: bot/js-autofix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Empty patch = nothing to do.
|
||||
if [ ! -s "$RUNNER_TEMP/js-fix.patch" ]; then
|
||||
echo "Patch is empty. No fixes to apply."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Apply the patch produced by the unprivileged job.
|
||||
git apply --check "$RUNNER_TEMP/js-fix.patch" || {
|
||||
echo "::error::Patch does not apply cleanly. Branch may have moved."
|
||||
exit 1
|
||||
}
|
||||
git apply "$RUNNER_TEMP/js-fix.patch"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "fmt(js): \`npm run fix\` on merge"
|
||||
|
||||
# Push to the dedicated bot branch. Force-push is safe here:
|
||||
# bot/js-autofix is a bot-only branch that gets rewritten each run.
|
||||
# If the branch was deleted after a previous PR merge, this
|
||||
# recreates it.
|
||||
git push --force origin HEAD:"$BOT_BRANCH"
|
||||
|
||||
- name: Create/update PR and enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BOT_BRANCH: bot/js-autofix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Create PR if one doesn't exist. If it already exists, the
|
||||
# force-push above already updated it with the latest fixes.
|
||||
PR_NUM=$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true)
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
# gh pr create prints the PR URL. Extract the number from it
|
||||
# (https://github.com/<org>/<repo>/pull/<number>).
|
||||
PR_URL=$(gh pr create \
|
||||
--head "$BOT_BRANCH" --base main \
|
||||
--title 'fmt(js): `npm run fix` auto-fix' \
|
||||
--body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.')
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
fi
|
||||
|
||||
# Enable auto-merge (squash). If already enabled, this is a no-op.
|
||||
gh pr merge "$PR_NUM" --auto --squash || true
|
||||
|
||||
- name: Wait for merge, auto-close on failure or stale
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
START_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PR_NUM=$(gh pr list --head bot/js-autofix --state open --json number --jq '.[0].number' 2>/dev/null || true)
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
echo "No open PR. Nothing to wait for."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for PR #$PR_NUM to merge..."
|
||||
|
||||
# Poll every 15s for up to ~10 minutes. Auto-merge will handle the
|
||||
# PR even if this job times out — the polling is for cleanup only
|
||||
# (auto-close on CI failure, conflicts, or main moving).
|
||||
for i in $(seq 1 40); do
|
||||
sleep 15
|
||||
|
||||
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
|
||||
if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then
|
||||
echo "PR #$PR_NUM is $STATE."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If main moved, the PR may have already merged (which moves
|
||||
# main) or another commit landed. Re-check state first.
|
||||
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha')
|
||||
if [ "$CURRENT_SHA" != "$START_SHA" ]; then
|
||||
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
|
||||
if [ "$STATE" = "MERGED" ]; then
|
||||
echo "PR #$PR_NUM merged (main moved to $CURRENT_SHA)."
|
||||
exit 0
|
||||
fi
|
||||
echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR."
|
||||
gh pr close "$PR_NUM" --delete-branch || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If CI checks failed, close + delete the branch.
|
||||
if gh pr checks "$PR_NUM" 2>/dev/null | grep -qi "fail"; then
|
||||
echo "CI failed on PR #$PR_NUM. Closing + deleting branch."
|
||||
gh pr close "$PR_NUM" --delete-branch
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If PR is conflicted, close + delete the branch.
|
||||
MERGEABLE=$(gh pr view "$PR_NUM" --json mergeable --jq '.mergeable')
|
||||
if [ "$MERGEABLE" = "CONFLICTING" ]; then
|
||||
echo "PR #$PR_NUM is conflicted. Closing + deleting branch."
|
||||
gh pr close "$PR_NUM" --delete-branch
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Timeout reached. Auto-merge will handle PR #$PR_NUM if CI passes."
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
|
||||
check:
|
||||
name: Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: workspaces
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
|
||||
@@ -44,6 +44,6 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
# --ignore-scripts: TS & tests don't need native deps
|
||||
command: npm ci --ignore-scripts
|
||||
command: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} check
|
||||
- run: npm run --prefix ${{ matrix.package }} fix
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -158,3 +162,111 @@ jobs:
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
ci-review:
|
||||
# Require explicit maintainer review when CI-sensitive files change:
|
||||
# eslint config, workflow YAMLs, or composite actions. These files
|
||||
# influence what code the js-autofix job executes and pushes to
|
||||
# main, so a malicious PR could inject arbitrary code via a custom eslint
|
||||
# rule's `fix` function. The label gate ensures a human reviews before
|
||||
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
|
||||
name: CI-sensitive file review
|
||||
if: inputs.event_name == 'pull_request' && inputs.ci_review
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Require ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ci-reviewed label present."
|
||||
exit 0
|
||||
fi
|
||||
echo "reviewed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# On failure: find the bot's previous comment and edit it, or create
|
||||
# a new one if none exists. Using an HTML comment marker so we can
|
||||
# locate it reliably across runs without parsing the body text.
|
||||
# Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API
|
||||
# call would fail. The label gate still holds via the step below.
|
||||
- name: Post or update review warning
|
||||
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
MARKER="<!-- ci-review-bot -->"
|
||||
BODY="${MARKER}
|
||||
## ⚠️ CI-sensitive file review required
|
||||
|
||||
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
|
||||
or composite actions). These files influence what code the
|
||||
js-autofix job executes and pushes to main.
|
||||
|
||||
A maintainer should verify:
|
||||
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
|
||||
- no workflow changes that widen permissions or remove guards,
|
||||
- no composite action changes that alter what gets executed.
|
||||
|
||||
After review, add the \`ci-reviewed\` label and re-run this check."
|
||||
|
||||
# Find an existing comment with our marker.
|
||||
COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api --method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-f body="$BODY"
|
||||
else
|
||||
gh pr comment "$PR" --body "$BODY"
|
||||
fi
|
||||
|
||||
# Fail the job when the label is missing — always runs (including
|
||||
# fork PRs) so the security gate holds even when the comment step
|
||||
# was skipped above.
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.reviewed != 'true'
|
||||
run: |
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label."
|
||||
exit 1
|
||||
|
||||
# On success: if a previous warning comment exists, edit it to show
|
||||
# the review passed so the PR doesn't have a stale ⚠️ sitting around.
|
||||
# Skipped on fork PRs — no comment was ever posted to update.
|
||||
- name: Update previous warning to passed
|
||||
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
MARKER="<!-- ci-review-bot -->"
|
||||
|
||||
# Find an existing comment with our marker.
|
||||
COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
BODY="${MARKER}
|
||||
## ✅ CI-sensitive file review passed
|
||||
|
||||
The \`ci-reviewed\` label is present on this PR."
|
||||
|
||||
gh api --method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-f body="$BODY"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Lockfile diff
|
||||
|
||||
# Advisory PR comment showing the *semantic* diff of package-lock.json
|
||||
# changes — which packages were added/removed/updated and their versions.
|
||||
# The raw textual diff of a lockfile is unreadable (npm reorders entries
|
||||
# and rewrites integrity hashes), so scripts/ci/lockfile_diff.py parses
|
||||
# the ``packages`` map at the merge base and at HEAD and set-diffs the
|
||||
# {install path: version} maps instead.
|
||||
#
|
||||
# The comment is upserted: the script embeds a hidden HTML marker and the
|
||||
# workflow PATCHes the existing comment when one is found, so a PR gets
|
||||
# exactly one lockfile-diff comment that tracks the latest push instead
|
||||
# of a stack of stale ones. When a later push reverts all lockfile
|
||||
# changes, the comment is updated to say so (deleting it would be more
|
||||
# surprising than telling the reviewer it's resolved).
|
||||
#
|
||||
# Never blocking — this is review signal, not enforcement. Exit is 0 even
|
||||
# when commenting fails (fork PRs get a read-only GITHUB_TOKEN).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post/update the diff comment
|
||||
|
||||
concurrency:
|
||||
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
diff:
|
||||
name: package-lock.json semantic diff
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need history for the merge base
|
||||
|
||||
- name: Generate semantic lockfile diff
|
||||
id: diff
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Three-dot semantics by hand: diff from the merge base with the
|
||||
# target branch to the PR head, so changes that landed on main
|
||||
# after the branch point don't show up as this PR's doing.
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
echo "Merge base: ${BASE_SHA}"
|
||||
python3 scripts/ci/lockfile_diff.py \
|
||||
--base "$BASE_SHA" \
|
||||
--head HEAD \
|
||||
--output /tmp/lockfile-diff.md
|
||||
if [ -s /tmp/lockfile-diff.md ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
CHANGED: ${{ steps.diff.outputs.changed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MARKER='<!-- hermes-lockfile-diff -->'
|
||||
|
||||
# Find our previous comment (paginated — busy PRs exceed one page).
|
||||
EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \
|
||||
--jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ "$CHANGED" != "true" ]; then
|
||||
if [ -n "$EXISTING" ]; then
|
||||
# A previous push changed the lockfile but the latest one
|
||||
# doesn't — update the comment rather than leave stale info.
|
||||
printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md
|
||||
else
|
||||
echo "No lockfile changes and no existing comment — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Updating existing comment ${EXISTING}"
|
||||
gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \
|
||||
-F body=@/tmp/lockfile-diff.md > /dev/null \
|
||||
|| echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
else
|
||||
echo "Creating new comment"
|
||||
gh api "repos/${REPO}/issues/${PR}/comments" \
|
||||
-F body=@/tmp/lockfile-diff.md > /dev/null \
|
||||
|| echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
fi
|
||||
+11
@@ -68,6 +68,17 @@ environments/benchmarks/evals/
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
|
||||
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
|
||||
# resolves the stale .js OVER the .tsx — never track these)
|
||||
apps/desktop/src/**/*.js
|
||||
apps/desktop/src/**/*.js.map
|
||||
apps/desktop/src/**/*.d.ts
|
||||
!apps/desktop/src/global.d.ts
|
||||
!apps/desktop/src/vite-env.d.ts
|
||||
apps/shared/src/**/*.js
|
||||
apps/shared/src/**/*.js.map
|
||||
apps/shared/src/**/*.d.ts
|
||||
apps/desktop/release/
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Lockfiles must never be reformatted — main has a repo rule requiring
|
||||
# team approval when lockfiles change, so an autofix PR touching one
|
||||
# would hang waiting for review.
|
||||
package-lock.json
|
||||
@@ -1094,14 +1094,16 @@ kanban task.
|
||||
|
||||
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
|
||||
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
|
||||
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
|
||||
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
|
||||
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
|
||||
`unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
|
||||
`block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
|
||||
`stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
|
||||
`dispatch`, `daemon`, `gc`.
|
||||
- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
|
||||
`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
|
||||
`kanban_comment`, `kanban_create`, `kanban_link`; profiles that
|
||||
explicitly enable the `kanban` toolset outside a dispatcher-spawned
|
||||
task also get `kanban_list` and `kanban_unblock` for board routing.
|
||||
`kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
|
||||
`kanban_attach_url`, `kanban_attachments`; profiles that explicitly
|
||||
enable the `kanban` toolset outside a dispatcher-spawned task also get
|
||||
`kanban_list` and `kanban_unblock` for board routing.
|
||||
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
|
||||
stale claims, promotes ready tasks, atomically claims, and spawns
|
||||
assigned profiles. Runs **inside the gateway** by default via
|
||||
@@ -1278,6 +1280,7 @@ def profile_env(tmp_path, monkeypatch):
|
||||
|
||||
## Testing
|
||||
|
||||
### Python
|
||||
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
|
||||
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
|
||||
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
|
||||
@@ -1291,12 +1294,12 @@ scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
|
||||
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
|
||||
```
|
||||
|
||||
### Subprocess-per-test-file isolation
|
||||
#### Subprocess-per-test-file isolation
|
||||
|
||||
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
|
||||
ContextVars from one test file cannot leak into the next.
|
||||
|
||||
### Why the wrapper
|
||||
#### Why the wrapper
|
||||
|
||||
| | Without wrapper | With wrapper |
|
||||
| ------------------- | ------------------------------------------- | ----------------------------------------- |
|
||||
@@ -1305,6 +1308,17 @@ ContextVars from one test file cannot leak into the next.
|
||||
| Timezone | Local TZ (PDT etc.) | UTC |
|
||||
| Locale | Whatever is set | C.UTF-8 |
|
||||
|
||||
### Where to place what tests
|
||||
|
||||
The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
|
||||
about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
|
||||
source, or any other JS-side artifact will not run on a PR that only touches
|
||||
those files. This means a regression can go green on a PR and red on `main` (where the
|
||||
classifier fails open and runs everything).
|
||||
|
||||
Any test that reads or asserts about `package.json`,
|
||||
`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
|
||||
source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
|
||||
|
||||
### Don't write change-detector tests
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ hermes # Interactive CLI — start a conversation
|
||||
hermes model # Choose your LLM provider and model
|
||||
hermes tools # Configure which tools are enabled
|
||||
hermes config set # Set individual config values
|
||||
hermes config get # Print individual config values
|
||||
hermes gateway # Start the messaging gateway (Telegram, Discord, etc.)
|
||||
hermes setup # Run the full setup wizard (configures everything at once)
|
||||
hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw)
|
||||
|
||||
+12
-1
@@ -1903,7 +1903,18 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
def _cmd_reset(self, args: str, state: SessionState) -> str:
|
||||
state.history.clear()
|
||||
self.session_manager.save_session(state.session_id)
|
||||
reset_failed = False
|
||||
try:
|
||||
reset_session_state = getattr(state.agent, "reset_session_state", None)
|
||||
if callable(reset_session_state):
|
||||
reset_session_state()
|
||||
except Exception:
|
||||
reset_failed = True
|
||||
logger.warning("ACP session state reset failed for %s", state.session_id, exc_info=True)
|
||||
finally:
|
||||
self.session_manager.save_session(state.session_id)
|
||||
if reset_failed:
|
||||
return "Conversation history cleared. Agent session state reset failed; see logs."
|
||||
return "Conversation history cleared."
|
||||
|
||||
def _cmd_compact(self, args: str, state: SessionState) -> str:
|
||||
|
||||
@@ -387,6 +387,24 @@ def _format_execute_code_result(result: Optional[str]) -> Optional[str]:
|
||||
error = str(data.get("error") or "")
|
||||
exit_code = data.get("exit_code")
|
||||
parts = [f"Exit code: {exit_code}" if exit_code is not None else "Execution complete"]
|
||||
if data.get("stdout_truncated"):
|
||||
total = data.get("stdout_bytes_total")
|
||||
captured = data.get("stdout_bytes_captured")
|
||||
omitted = data.get("stdout_bytes_omitted")
|
||||
if all(isinstance(v, int) for v in (captured, total, omitted)):
|
||||
parts.extend([
|
||||
"",
|
||||
(
|
||||
"Output truncated: "
|
||||
f"captured {captured:,} of {total:,} bytes "
|
||||
f"({omitted:,} omitted)."
|
||||
),
|
||||
])
|
||||
else:
|
||||
parts.extend(["", "Output truncated."])
|
||||
warning = str(data.get("warning") or "").strip()
|
||||
if warning:
|
||||
parts.extend(["", "Warning:", warning])
|
||||
if output:
|
||||
parts.extend(["", "Output:", output])
|
||||
if error:
|
||||
|
||||
@@ -1354,6 +1354,27 @@ def init_agent(
|
||||
_agent_cfg = _load_agent_config()
|
||||
except Exception:
|
||||
_agent_cfg = {}
|
||||
|
||||
# LM Studio can either be explicitly preloaded through LM Studio's
|
||||
# management API (the historical Hermes behavior) or left to LM Studio's
|
||||
# just-in-time / Auto-Evict chat-completions path. Keep the default
|
||||
# explicit for backward compatibility; users with LM Studio Auto-Evict can
|
||||
# opt into JIT via ``model.lmstudio_load_mode: jit``.
|
||||
agent.lmstudio_load_mode = "explicit"
|
||||
try:
|
||||
_model_section = _agent_cfg.get("model", {})
|
||||
if isinstance(_model_section, dict):
|
||||
_load_mode = str(_model_section.get("lmstudio_load_mode", "explicit") or "explicit").strip().lower()
|
||||
if _load_mode in {"explicit", "jit"}:
|
||||
agent.lmstudio_load_mode = _load_mode
|
||||
else:
|
||||
logger.warning(
|
||||
"Invalid model.lmstudio_load_mode=%r; expected 'explicit' or 'jit'. Using explicit.",
|
||||
_model_section.get("lmstudio_load_mode"),
|
||||
)
|
||||
except Exception:
|
||||
agent.lmstudio_load_mode = "explicit"
|
||||
|
||||
try:
|
||||
agent._tool_guardrails = ToolCallGuardrailController(
|
||||
ToolCallGuardrailConfig.from_mapping(
|
||||
|
||||
@@ -357,6 +357,48 @@ def sanitize_tool_call_arguments(
|
||||
return repaired
|
||||
|
||||
|
||||
def note_turn_start(agent, turn_id: str):
|
||||
"""Tripwire: detect a turn starting while the previous turn of the SAME
|
||||
agent/session has not completed its turn-end persist.
|
||||
|
||||
Two turns interleaving on one session corrupt the durable transcript:
|
||||
their flushes race (user rows can persist out of arrival order), a row
|
||||
can be swallowed by the identity-marker dedup over shared history dicts,
|
||||
and the second turn runs on a history base that never saw the first
|
||||
turn's exchange. This helper does NOT prevent any of that — it names the
|
||||
occurrence, with both turn ids, so the dispatch route that let the
|
||||
second turn through the busy guard can be identified from logs.
|
||||
|
||||
Returns the previous in-flight turn_id when an overlap is detected,
|
||||
else None. Takes ownership of the in-flight slot either way, so a turn
|
||||
that crashed before its persist produces at most one warning."""
|
||||
prev = getattr(agent, "_inflight_turn_id", None)
|
||||
prev_started = getattr(agent, "_inflight_turn_started", 0.0)
|
||||
agent._inflight_turn_id = turn_id
|
||||
agent._inflight_turn_started = time.time()
|
||||
if prev and prev != turn_id:
|
||||
logger.warning(
|
||||
"turn %s starting while turn %s (started %.0fs ago) has not "
|
||||
"completed its turn-end persist (session=%s) — concurrent turns "
|
||||
"on one session; transcript writes may interleave",
|
||||
turn_id,
|
||||
prev,
|
||||
time.time() - prev_started if prev_started else -1.0,
|
||||
getattr(agent, "session_id", None) or "-",
|
||||
)
|
||||
return prev
|
||||
return None
|
||||
|
||||
|
||||
def note_turn_persisted(agent):
|
||||
"""Clear the in-flight marker at turn-end persist (see note_turn_start).
|
||||
|
||||
Called from the single persist funnel; unconditional by design — when two
|
||||
turns genuinely overlap, the first persist clears the second turn's slot
|
||||
and the tripwire under-reports instead of double-reporting. A diagnostic
|
||||
must never be noisier than the defect it hunts."""
|
||||
agent._inflight_turn_id = None
|
||||
|
||||
|
||||
def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
"""Collapse malformed role-alternation left in the live history.
|
||||
|
||||
@@ -534,8 +534,9 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
|
||||
Some third-party /anthropic endpoints implement Anthropic's Messages API but
|
||||
require Authorization: Bearer instead of Anthropic's native x-api-key header.
|
||||
MiniMax's global and China Anthropic-compatible endpoints, and Azure AI
|
||||
Foundry's Anthropic-style endpoint follow this pattern.
|
||||
MiniMax's global and China Anthropic-compatible endpoints, Azure AI
|
||||
Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy
|
||||
follow this pattern.
|
||||
"""
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
@@ -544,6 +545,11 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
return (
|
||||
normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
|
||||
or "azure.com" in normalized
|
||||
# Palantir Foundry LLM proxy (<org>.palantirfoundry.com/api/v2/llm/proxy/anthropic)
|
||||
# rejects x-api-key with 401 and requires Authorization: Bearer.
|
||||
# Hostname match (not substring) so e.g. evil.com/palantirfoundry
|
||||
# paths don't trigger Bearer auth.
|
||||
or base_url_host_matches(normalized, "palantirfoundry.com")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Ambient session-accounting context for auxiliary LLM calls.
|
||||
|
||||
Auxiliary calls (vision, compression, title generation, web_extract,
|
||||
session_search, ...) funnel through ``agent.auxiliary_client`` which has no
|
||||
session handle — so their token usage was historically discarded, leaving
|
||||
dashboard analytics blind to aux model spend (issue #23270).
|
||||
|
||||
Instead of threading ``session_db``/``session_id`` parameters through every
|
||||
aux call site, the agent loop publishes them here (mirroring the Nous Portal
|
||||
conversation context in ``agent.portal_tags``) and the auxiliary client
|
||||
records usage at its single response-validation chokepoint.
|
||||
|
||||
ContextVar semantics give us the right isolation for free:
|
||||
|
||||
* concurrent agents in one process (gateway sessions, delegate subagents)
|
||||
never see each other's accounting context;
|
||||
* worker threads spawned via ``tools.thread_context.propagate_context_to_thread``
|
||||
(MoA fan-out, background review) inherit the parent turn's context;
|
||||
* asyncio tasks inherit the context of the code that created them.
|
||||
|
||||
MoA reference/aggregator slots are explicitly EXCLUDED from recording:
|
||||
``agent/conversation_loop.py`` already folds MoA advisor usage and cost into
|
||||
the main loop's ``update_token_counts`` delta, so recording them here would
|
||||
double-count (see ``_EXCLUDED_TASKS``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (session_db, session_id) for the active agent turn, or None outside one.
|
||||
_accounting: ContextVar[Optional[tuple]] = ContextVar(
|
||||
"aux_accounting_context", default=None
|
||||
)
|
||||
|
||||
# Aux tasks whose usage is already accounted by the main loop — recording
|
||||
# them here would double-count. MoA advisor/aggregator usage is folded into
|
||||
# conversation_loop's update_token_counts delta (tokens AND cost).
|
||||
_EXCLUDED_TASKS = frozenset({"moa_reference", "moa_aggregator"})
|
||||
|
||||
|
||||
def set_accounting_context(session_db: Any, session_id: Optional[str]):
|
||||
"""Publish the active session's accounting handles for aux usage recording.
|
||||
|
||||
Called by the agent loop at turn entry. Returns the ContextVar token so
|
||||
callers can ``reset_accounting_context(token)`` on turn exit. Publishing
|
||||
``None`` handles (no DB / no session id) clears the context.
|
||||
"""
|
||||
if session_db is None or not session_id:
|
||||
return _accounting.set(None)
|
||||
return _accounting.set((session_db, session_id))
|
||||
|
||||
|
||||
def reset_accounting_context(token) -> None:
|
||||
"""Restore the previous accounting context (pair with ``set_...``)."""
|
||||
try:
|
||||
_accounting.reset(token)
|
||||
except Exception:
|
||||
_accounting.set(None)
|
||||
|
||||
|
||||
def get_accounting_context() -> Optional[tuple]:
|
||||
"""Return ``(session_db, session_id)`` for the active turn, or ``None``."""
|
||||
return _accounting.get()
|
||||
|
||||
|
||||
def record_aux_usage(
|
||||
response: Any,
|
||||
task: Optional[str],
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record an auxiliary response's token usage against the ambient session.
|
||||
|
||||
Called from the auxiliary client's response-validation chokepoint. Strictly
|
||||
best-effort: any failure is swallowed (accounting must never break an aux
|
||||
call). No-ops when:
|
||||
|
||||
* no accounting context is published (call is outside any agent turn),
|
||||
* the task is main-loop-accounted (MoA slots — see ``_EXCLUDED_TASKS``),
|
||||
* the response carries no usage object.
|
||||
|
||||
The model is read from ``response.model`` (accurate even after the aux
|
||||
client's provider-fallback chains); *provider*/*base_url* reflect the
|
||||
originally-resolved route and are best-effort.
|
||||
"""
|
||||
try:
|
||||
if not task or task in _EXCLUDED_TASKS:
|
||||
return
|
||||
ctx = _accounting.get()
|
||||
if ctx is None:
|
||||
return
|
||||
session_db, session_id = ctx
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if raw_usage is None:
|
||||
return
|
||||
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
|
||||
usage = normalize_usage(raw_usage, provider=provider)
|
||||
if not (
|
||||
usage.input_tokens or usage.output_tokens
|
||||
or usage.cache_read_tokens or usage.cache_write_tokens
|
||||
or usage.reasoning_tokens
|
||||
):
|
||||
return
|
||||
|
||||
model = str(getattr(response, "model", "") or "") or "unknown"
|
||||
estimated_cost = None
|
||||
try:
|
||||
cost = estimate_usage_cost(
|
||||
model, usage, provider=provider, base_url=base_url
|
||||
)
|
||||
if cost.amount_usd is not None:
|
||||
estimated_cost = float(cost.amount_usd)
|
||||
except Exception:
|
||||
logger.debug("Aux usage cost estimation failed", exc_info=True)
|
||||
|
||||
session_db.record_auxiliary_usage(
|
||||
session_id,
|
||||
task,
|
||||
model=model,
|
||||
billing_provider=provider,
|
||||
billing_base_url=base_url,
|
||||
input_tokens=usage.input_tokens,
|
||||
output_tokens=usage.output_tokens,
|
||||
cache_read_tokens=usage.cache_read_tokens,
|
||||
cache_write_tokens=usage.cache_write_tokens,
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Aux usage recording failed (non-fatal)", exc_info=True)
|
||||
+164
-11
@@ -44,6 +44,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
@@ -102,7 +103,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
|
||||
from agent.process_bootstrap import build_keepalive_http_client
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
@@ -156,22 +156,47 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any:
|
||||
return True
|
||||
|
||||
|
||||
_WARNED_KEEPALIVE_IMPORT_SKEW = False
|
||||
|
||||
|
||||
def _openai_http_client_kwargs(
|
||||
base_url: Optional[str],
|
||||
*,
|
||||
async_mode: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
|
||||
client = build_keepalive_http_client(
|
||||
str(base_url or ""),
|
||||
async_mode=async_mode,
|
||||
verify=_resolve_aux_verify(base_url),
|
||||
)
|
||||
try:
|
||||
from agent.process_bootstrap import build_keepalive_http_client
|
||||
client = build_keepalive_http_client(
|
||||
str(base_url or ""),
|
||||
async_mode=async_mode,
|
||||
verify=_resolve_aux_verify(base_url),
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
# Version-skewed installs (#64333): a process whose sys.path resolves
|
||||
# an older agent/process_bootstrap.py without this helper — seen when
|
||||
# the Desktop app's bundled runtime lags a git-installed source tree
|
||||
# that newer callers (cron scheduler) were written against. Every cron
|
||||
# job died on this ImportError before any agent logic ran. Degrade
|
||||
# gracefully to the OpenAI SDK's default httpx client (respects macOS
|
||||
# system proxy, no pool-level keepalive expiry) instead of failing the
|
||||
# whole job, and say so once — silent version skew is how this bug
|
||||
# went unnoticed until jobs were already dead on arrival.
|
||||
global _WARNED_KEEPALIVE_IMPORT_SKEW
|
||||
if not _WARNED_KEEPALIVE_IMPORT_SKEW:
|
||||
_WARNED_KEEPALIVE_IMPORT_SKEW = True
|
||||
logger.warning(
|
||||
"agent.process_bootstrap.build_keepalive_http_client is "
|
||||
"unavailable — mixed/stale install detected (#64333). Falling "
|
||||
"back to the SDK default HTTP client. Run `hermes update` (or "
|
||||
"reinstall the Desktop app) to resync the runtime."
|
||||
)
|
||||
client = None
|
||||
|
||||
if client is None:
|
||||
return {}
|
||||
return {"http_client": client}
|
||||
|
||||
|
||||
def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
|
||||
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
|
||||
# Hermes owns auxiliary retry + provider/model fallback policy (the
|
||||
@@ -1337,6 +1362,31 @@ class _AnthropicCompletionsAdapter:
|
||||
if not _forbids_sampling_params(model):
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
# Pass through caller-supplied extra_body so providers behind
|
||||
# Anthropic-compatible gateways receive their per-vendor request
|
||||
# fields (thinking control, metadata, portal tags, ...). The dict
|
||||
# form is the documented Anthropic SDK passthrough for non-standard
|
||||
# request body keys; merge on top of whatever build_anthropic_kwargs
|
||||
# already produced (e.g. fast-mode ``speed``) so call-time settings
|
||||
# survive. Two exclusions:
|
||||
# - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into
|
||||
# the native ``thinking`` field above (build_anthropic_kwargs);
|
||||
# forwarding the raw field alongside would double-specify
|
||||
# reasoning and 400 on strict gateways.
|
||||
# - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config
|
||||
# et al.), never wire fields.
|
||||
caller_extra_body = kwargs.get("extra_body")
|
||||
if caller_extra_body and isinstance(caller_extra_body, dict):
|
||||
passthrough = {
|
||||
k: v for k, v in caller_extra_body.items()
|
||||
if k != "reasoning" and not str(k).startswith("_")
|
||||
}
|
||||
if passthrough:
|
||||
existing = anthropic_kwargs.get("extra_body") or {}
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
anthropic_kwargs["extra_body"] = {**existing, **passthrough}
|
||||
|
||||
response = create_anthropic_message(self._client, anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
@@ -3644,6 +3694,40 @@ def _auth_refresh_provider_for_route(
|
||||
return normalized
|
||||
|
||||
|
||||
def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]:
|
||||
"""Resolve a per-entry ``timeout`` for a configured fallback candidate.
|
||||
|
||||
A fallback candidate previously inherited the exact timeout the primary
|
||||
provider was called with. When that deadline was tuned for the primary
|
||||
(or the primary simply consumed its whole budget before failing over),
|
||||
the fallback aborted on the same clock even when independently healthy —
|
||||
a 163k-token compression that needs ~90s on the fallback died at the
|
||||
primary's 30s deadline every turn (#62452).
|
||||
|
||||
Entries in ``auxiliary.<task>.fallback_chain`` may declare their own
|
||||
``timeout`` (seconds). This helper reads it by parsing the entry index
|
||||
out of the label minted by :func:`_try_configured_fallback_chain`
|
||||
(``fallback_chain[<i>](<provider>)`` — our own stable format). Returns
|
||||
``None`` when the label is not a configured-chain candidate, the entry
|
||||
has no ``timeout``, or the value is invalid — callers then keep the
|
||||
task-level timeout, preserving existing behavior.
|
||||
"""
|
||||
if not task or not fb_label:
|
||||
return None
|
||||
m = re.match(r"fallback_chain\[(\d+)\]", fb_label)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
chain = _get_auxiliary_task_config(task).get("fallback_chain")
|
||||
entry = chain[int(m.group(1))] if isinstance(chain, list) else None
|
||||
raw = entry.get("timeout") if isinstance(entry, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0:
|
||||
return float(raw)
|
||||
return None
|
||||
|
||||
|
||||
def _call_fallback_candidate_sync(
|
||||
fb_client: Any,
|
||||
fb_model: Optional[str],
|
||||
@@ -3672,7 +3756,20 @@ def _call_fallback_candidate_sync(
|
||||
once with a rebuilt client; if the retry also auth-fails (non-refreshable
|
||||
expired token), mark the provider unhealthy and return ``None`` so the
|
||||
caller can continue to the next fallback layer. Non-auth errors raise.
|
||||
|
||||
``effective_timeout`` is the task-level deadline; a configured-chain
|
||||
candidate with its own ``timeout`` entry gets that instead, so a
|
||||
fallback tuned differently from the primary is allowed its own budget
|
||||
(#62452).
|
||||
"""
|
||||
fb_timeout = _fallback_entry_timeout(task, fb_label)
|
||||
if fb_timeout is not None and fb_timeout != effective_timeout:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s using its configured timeout %.0fs "
|
||||
"(task-level was %.0fs)",
|
||||
task or "call", fb_label, fb_timeout, effective_timeout,
|
||||
)
|
||||
effective_timeout = fb_timeout
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
@@ -3731,6 +3828,14 @@ async def _call_fallback_candidate_async(
|
||||
reasoning_config: Optional[dict],
|
||||
) -> Optional[Any]:
|
||||
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
|
||||
fb_timeout = _fallback_entry_timeout(task, fb_label)
|
||||
if fb_timeout is not None and fb_timeout != effective_timeout:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s using its configured timeout %.0fs "
|
||||
"(task-level was %.0fs)",
|
||||
task or "call", fb_label, fb_timeout, effective_timeout,
|
||||
)
|
||||
effective_timeout = fb_timeout
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
@@ -4279,10 +4384,41 @@ def _resolve_auto(
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
if runtime_base_url and main_provider == "custom":
|
||||
# Anonymous custom endpoint (OPENAI_BASE_URL / config.model.base_url)
|
||||
# — pass through with explicit base_url + api_key.
|
||||
resolved_provider = "custom"
|
||||
explicit_base_url = runtime_base_url
|
||||
explicit_api_key = runtime_api_key or None
|
||||
elif main_provider.startswith("custom:"):
|
||||
# Named custom provider (custom_providers / providers dict entry).
|
||||
_has_named_entry = False
|
||||
try:
|
||||
from hermes_cli.runtime_provider import _get_named_custom_provider
|
||||
_has_named_entry = _get_named_custom_provider(main_provider) is not None
|
||||
except ImportError:
|
||||
pass
|
||||
if _has_named_entry:
|
||||
# KEEP the full ``custom:<name>`` so resolve_provider_client
|
||||
# lands in the named-custom-provider arm — that arm honours the
|
||||
# entry's api_mode (e.g. anthropic_messages →
|
||||
# AnthropicAuxiliaryClient, avoiding the /anthropic→/v1 rewrite
|
||||
# that 404s against proxies like Palantir Foundry's Anthropic
|
||||
# surface). Do NOT collapse to plain "custom"; that path
|
||||
# strips /anthropic and routes through OpenAI chat.completions.
|
||||
# base_url and api_key come from the named entry itself, so
|
||||
# leave the explicit_* overrides unset.
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = None
|
||||
elif runtime_base_url:
|
||||
# Config-less named custom provider (#34777): the entry only
|
||||
# exists in the live runtime, so collapse to the anonymous
|
||||
# custom arm with the runtime endpoint + key.
|
||||
resolved_provider = "custom"
|
||||
explicit_base_url = runtime_base_url
|
||||
explicit_api_key = runtime_api_key or None
|
||||
elif runtime_api_key:
|
||||
explicit_api_key = runtime_api_key
|
||||
elif runtime_api_key:
|
||||
# Pin auxiliary to the same api_key as the active main chat session
|
||||
# so that a working key is reused instead of re-selecting from the pool
|
||||
@@ -6521,7 +6657,12 @@ def _build_call_kwargs(
|
||||
return kwargs
|
||||
|
||||
|
||||
def _validate_llm_response(response: Any, task: str = None) -> Any:
|
||||
def _validate_llm_response(
|
||||
response: Any,
|
||||
task: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Validate that an LLM response has the expected .choices[0].message shape.
|
||||
|
||||
Fails fast with a clear error instead of letting malformed payloads
|
||||
@@ -6529,11 +6670,21 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
|
||||
AttributeError (e.g. "'str' object has no attribute 'choices'").
|
||||
|
||||
See #7264.
|
||||
|
||||
Also the single accounting chokepoint for auxiliary usage: every
|
||||
successful non-streaming aux response passes through here exactly once,
|
||||
so token usage is recorded against the ambient session context published
|
||||
by the agent loop (``agent.aux_accounting``, issue #23270). Recording is
|
||||
best-effort and never affects validation. *provider*/*base_url* are
|
||||
optional accounting hints — fallback-path calls omit them and the row
|
||||
keeps the model (read from the response itself) with an empty route.
|
||||
"""
|
||||
if response is None:
|
||||
raise RuntimeError(
|
||||
f"Auxiliary {task or 'call'}: LLM returned None response"
|
||||
)
|
||||
from agent.aux_accounting import record_aux_usage
|
||||
record_aux_usage(response, task, provider=provider, base_url=base_url)
|
||||
# Allow SimpleNamespace responses from adapters (CodexAuxiliaryClient,
|
||||
# AnthropicAuxiliaryClient) — they have .choices[0].message.
|
||||
try:
|
||||
@@ -6800,7 +6951,8 @@ def call_llm(
|
||||
# for the transient retry every auxiliary task shares. (PR #16587)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
client.chat.completions.create(**kwargs), task,
|
||||
provider=resolved_provider, base_url=_base_info)
|
||||
except Exception as transient_err:
|
||||
if not _is_transient_transport_error(transient_err):
|
||||
raise
|
||||
@@ -7379,7 +7531,8 @@ async def async_call_llm(
|
||||
# for the rationale. (PR #16587)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await client.chat.completions.create(**kwargs), task,
|
||||
provider=resolved_provider, base_url=_client_base)
|
||||
except Exception as transient_err:
|
||||
if not _is_transient_transport_error(transient_err):
|
||||
raise
|
||||
|
||||
@@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]:
|
||||
mime_part = header[5:].split(";")[0]
|
||||
if mime_part:
|
||||
media_type = mime_part
|
||||
# Decode base64 to raw bytes — boto3 re-encodes at the
|
||||
# wire layer, so passing the base64 string directly
|
||||
# results in double-encoding and Bedrock rejects it with
|
||||
# "Failed to sanitize image". Ref: #33317.
|
||||
import base64
|
||||
try:
|
||||
raw_bytes = base64.b64decode(data)
|
||||
except Exception:
|
||||
raw_bytes = data.encode("utf-8")
|
||||
blocks.append({
|
||||
"image": {
|
||||
"format": media_type.split("/")[-1] if "/" in media_type else "jpeg",
|
||||
"source": {"bytes": data},
|
||||
"source": {"bytes": raw_bytes},
|
||||
}
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -504,6 +504,29 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
if _codex_floor:
|
||||
_stale_timeout = max(_stale_timeout, _codex_floor)
|
||||
|
||||
# ── Codex absolute hard ceiling (#64507) ──────────────────────────
|
||||
# ``openai_codex_stale_timeout_floor`` *raises* the stale timeout (up to
|
||||
# 1200s at >100k tokens) so healthy gateway-scale payloads aren't aborted.
|
||||
# The scaled no-byte TTFB watchdog catches dead streams that never emit a
|
||||
# first byte, but a request that emits SOME bytes and then wedges (the
|
||||
# issue-64507 symptom: vision-inflated request, worker idle, no ended_at)
|
||||
# is only reclaimed at the (high) stale floor. Add a flat, finite hard
|
||||
# ceiling on total request time that ALWAYS applies to openai-codex
|
||||
# requests regardless of the TTFB/stale interaction, so a stalled request
|
||||
# is recovered (retry loop / visible failure) instead of hanging
|
||||
# indefinitely. The default sits ABOVE the maximum stale floor (1200s) so
|
||||
# it never clamps an intentionally-raised timeout for healthy large
|
||||
# requests — it is a backstop against unbounded growth, not a tighter
|
||||
# limit. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (set to 0 to
|
||||
# disable the ceiling entirely; that restores the pre-fix behavior).
|
||||
_codex_hard_timeout = _env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0)
|
||||
if (
|
||||
_codex_watchdog_enabled
|
||||
and _openai_codex_backend
|
||||
and _codex_hard_timeout > 0
|
||||
):
|
||||
_stale_timeout = min(_stale_timeout, _codex_hard_timeout)
|
||||
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_codex_idle_timeout_default = 180.0
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
|
||||
@@ -1118,6 +1118,22 @@ def _normalize_codex_response(
|
||||
differs from the one that minted the encrypted_content blob and drop
|
||||
the item instead of triggering HTTP 400 invalid_encrypted_content.
|
||||
"""
|
||||
response_status = getattr(response, "status", None)
|
||||
if isinstance(response_status, str):
|
||||
response_status = response_status.strip().lower()
|
||||
else:
|
||||
response_status = None
|
||||
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
incomplete_reason = ""
|
||||
if isinstance(incomplete_details, dict):
|
||||
incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower()
|
||||
elif incomplete_details is not None:
|
||||
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
|
||||
response_incomplete_content_filter = (
|
||||
response_status == "incomplete" and incomplete_reason == "content_filter"
|
||||
)
|
||||
|
||||
output = getattr(response, "output", None)
|
||||
if not isinstance(output, list) or not output:
|
||||
# The Codex backend can return empty output when the answer was
|
||||
@@ -1134,15 +1150,18 @@ def _normalize_codex_response(
|
||||
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
|
||||
)]
|
||||
response.output = output
|
||||
elif response_incomplete_content_filter:
|
||||
# This is a deterministic provider safety block, not a partial
|
||||
# answer. Synthesize an empty message so finish_reason below becomes
|
||||
# content_filter and the conversation loop can fallback/surface it
|
||||
# instead of burning three continuation attempts.
|
||||
output = [SimpleNamespace(
|
||||
type="message", role="assistant", status="completed", content=[]
|
||||
)]
|
||||
response.output = output
|
||||
else:
|
||||
raise RuntimeError("Responses API returned no output items")
|
||||
|
||||
response_status = getattr(response, "status", None)
|
||||
if isinstance(response_status, str):
|
||||
response_status = response_status.strip().lower()
|
||||
else:
|
||||
response_status = None
|
||||
|
||||
if response_status in {"failed", "cancelled"}:
|
||||
error_obj = getattr(response, "error", None)
|
||||
error_msg = _format_responses_error(error_obj, response_status)
|
||||
@@ -1411,6 +1430,8 @@ def _normalize_codex_response(
|
||||
|
||||
if tool_calls:
|
||||
finish_reason = "tool_calls"
|
||||
elif response_incomplete_content_filter:
|
||||
finish_reason = "content_filter"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
|
||||
+144
-35
@@ -26,6 +26,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm, _is_connection_error, aux_interrupt_protection
|
||||
from agent.context_engine import ContextEngine
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
get_model_context_length,
|
||||
@@ -35,6 +36,47 @@ from agent.redact import redact_sensitive_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SUMMARY_PERMANENT_QUOTA_MARKERS: tuple[str, ...] = (
|
||||
"insufficient_quota",
|
||||
"quota exceeded",
|
||||
"quota_exceeded",
|
||||
"out of funds",
|
||||
"out of credits",
|
||||
"out of credit",
|
||||
"out of extra usage",
|
||||
)
|
||||
|
||||
_SUMMARY_MISSING_CREDENTIAL_MARKERS: tuple[str, ...] = (
|
||||
"no api key was found",
|
||||
"no api key found",
|
||||
)
|
||||
|
||||
|
||||
def _is_summary_access_or_quota_error(exc: Exception) -> bool:
|
||||
"""Return True for non-retryable summary auth, permission, or quota errors."""
|
||||
|
||||
classified = classify_api_error(exc)
|
||||
if classified.reason is FailoverReason.rate_limit:
|
||||
return False
|
||||
if classified.reason in {FailoverReason.auth, FailoverReason.auth_permanent}:
|
||||
return True
|
||||
|
||||
err_text = str(exc).lower()
|
||||
if any(marker in err_text for marker in _SUMMARY_MISSING_CREDENTIAL_MARKERS):
|
||||
return True
|
||||
|
||||
status = getattr(exc, "status_code", None) or getattr(
|
||||
getattr(exc, "response", None), "status_code", None
|
||||
)
|
||||
if status in {401, 402, 403}:
|
||||
return True
|
||||
|
||||
if classified.reason is FailoverReason.billing:
|
||||
return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS)
|
||||
return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS)
|
||||
|
||||
|
||||
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
|
||||
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
|
||||
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
|
||||
@@ -586,6 +628,27 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
|
||||
return result if changed else messages
|
||||
|
||||
|
||||
def _image_part_label(part: Dict[str, Any]) -> str:
|
||||
"""Render a multimodal image part as a short text label for the summarizer.
|
||||
|
||||
Keeps a real, referenceable URL when the image lives at an http(s)
|
||||
address — the summary can then preserve the handle so the agent (or a
|
||||
later vision_analyze call) can still reach the image after compaction.
|
||||
Base64 ``data:`` URLs carry no reusable reference and would flood the
|
||||
summarizer input, so they collapse to ``[image]``.
|
||||
"""
|
||||
url = ""
|
||||
if isinstance(part.get("image_url"), dict):
|
||||
url = str(part["image_url"].get("url") or "")
|
||||
elif isinstance(part.get("image_url"), str):
|
||||
url = part["image_url"]
|
||||
elif isinstance(part.get("url"), str):
|
||||
url = part["url"]
|
||||
if url.startswith(("http://", "https://")):
|
||||
return f"[image: {url}]"
|
||||
return "[image]"
|
||||
|
||||
|
||||
def _str_arg(args: dict, key: str, default: str = "") -> str:
|
||||
"""Safely get a string argument from parsed tool args.
|
||||
|
||||
@@ -686,7 +749,16 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
|
||||
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
first = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
# web_search results are dicts ({"url"/"href": ...}) and models often
|
||||
# forward them straight into web_extract. Unwrap to the URL string so
|
||||
# the summary stays readable and the ``+=`` below never hits the
|
||||
# ``dict + str`` TypeError that would abort pre-compression pruning.
|
||||
if isinstance(first, dict):
|
||||
first = first.get("url") or first.get("href") or "?"
|
||||
elif not isinstance(first, str):
|
||||
first = "?"
|
||||
url_desc = first
|
||||
if isinstance(urls, list) and len(urls) > 1:
|
||||
url_desc += f" (+{len(urls) - 1} more)"
|
||||
return f"[web_extract] {url_desc} ({content_len:,} chars)"
|
||||
@@ -765,6 +837,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._context_probe_persistable = False
|
||||
self._previous_summary = None
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
@@ -803,6 +876,7 @@ class ContextCompressor(ContextEngine):
|
||||
"""
|
||||
self._previous_summary = None
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
@@ -827,6 +901,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._session_id = session_id or ""
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._fallback_compression_streak = 0
|
||||
self.get_active_compression_failure_cooldown()
|
||||
self._load_fallback_compression_streak()
|
||||
@@ -975,6 +1050,7 @@ class ContextCompressor(ContextEngine):
|
||||
def _clear_compression_failure_cooldown(self) -> None:
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
session_id = getattr(self, "_session_id", "")
|
||||
@@ -1655,7 +1731,24 @@ class ContextCompressor(ContextEngine):
|
||||
parts = []
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
text_parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
elif ptype in {"image", "image_url", "input_image"}:
|
||||
text_parts.append(_image_part_label(part))
|
||||
else:
|
||||
# Unknown part type — keep a marker so the
|
||||
# summarizer knows content existed here.
|
||||
text_parts.append(f"[{ptype or 'attachment'}]")
|
||||
elif isinstance(part, str):
|
||||
text_parts.append(part)
|
||||
content = "\n".join(text_parts)
|
||||
content = redact_sensitive_text(content or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
|
||||
# assistant content before it reaches the summarizer. Reasoning
|
||||
@@ -2241,6 +2334,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
_is_timeout = (
|
||||
_status in {408, 429, 502, 504}
|
||||
or "timeout" in _err_str
|
||||
or "timed out" in _err_str
|
||||
)
|
||||
# Non-JSON / malformed-body responses from misconfigured providers
|
||||
# or proxies (e.g. an HTML 502 page returned with
|
||||
@@ -2262,25 +2356,18 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# back to the main model instead of entering a 60-second cooldown.
|
||||
# See issue #18458.
|
||||
_is_streaming_closed = _is_connection_error(e)
|
||||
# Authentication / permission failures (401/403) are NOT transient
|
||||
# and NOT fixable by retrying the same request: the credential is
|
||||
# invalid/blocked/expired or the endpoint is wrong (e.g. a prod
|
||||
# token sent to a staging inference URL). Flag them so compress()
|
||||
# aborts and preserves the session instead of rotating into a
|
||||
# Authentication, permission, and exhausted-quota failures are NOT
|
||||
# transient or fixable by retrying the same request. Flag them so
|
||||
# compress() preserves the session instead of rotating into a
|
||||
# degraded child with a placeholder summary. We still allow the
|
||||
# one-shot fallback to the MAIN model below when the failure came
|
||||
# from a distinct auxiliary summary_model (its dedicated creds may
|
||||
# be the only broken thing); only a failure on the main model — or
|
||||
# a fallback that also auth-fails — makes the abort stick.
|
||||
_is_auth_error = (
|
||||
_status in {401, 403}
|
||||
or "invalid api key" in _err_str
|
||||
or "invalid x-api-key" in _err_str
|
||||
or ("api key" in _err_str and ("invalid" in _err_str or "blocked" in _err_str))
|
||||
or "unauthorized" in _err_str
|
||||
or "authentication" in _err_str
|
||||
)
|
||||
if _is_auth_error:
|
||||
# from a distinct auxiliary summary_model; only a failure on the
|
||||
# main model — or a fallback that also access/quota-fails — makes
|
||||
# the abort stick.
|
||||
_is_access_or_quota_error = _is_summary_access_or_quota_error(e)
|
||||
if _is_access_or_quota_error:
|
||||
# Keep the established field name for caller compatibility;
|
||||
# it now represents the broader terminal access/quota class.
|
||||
self._last_summary_auth_failure = True
|
||||
if _is_json_decode and not _is_model_not_found and not _is_timeout:
|
||||
logger.error(
|
||||
@@ -2330,7 +2417,30 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# Transient errors (timeout, rate limit, network, JSON decode,
|
||||
# streaming premature-close) — shorter cooldown for JSON decode and
|
||||
# streaming-closed since those conditions can self-resolve quickly.
|
||||
_transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60
|
||||
# Timeout-class failures escalate with consecutive occurrences:
|
||||
# a session whose transcript structurally exceeds what the
|
||||
# summary route can produce within its deadline will fail the
|
||||
# same way every time, and re-burning the full timeout every
|
||||
# 60s turns each subsequent turn into a multi-minute stall
|
||||
# (#62452). 60s → 300s → 900s (capped); any successful summary
|
||||
# resets the streak via _clear_compression_failure_cooldown().
|
||||
# Timeout takes precedence over the streaming-closed short rung:
|
||||
# a "timed out" error also matches _is_connection_error, but a
|
||||
# deadline exhaustion is the structural repeat-offender class,
|
||||
# not a transient mid-stream drop.
|
||||
if _is_timeout:
|
||||
self._consecutive_timeout_failures = (
|
||||
getattr(self, "_consecutive_timeout_failures", 0) + 1
|
||||
)
|
||||
_TIMEOUT_COOLDOWN_LADDER = (60, 300, 900)
|
||||
_transient_cooldown = _TIMEOUT_COOLDOWN_LADDER[
|
||||
min(self._consecutive_timeout_failures,
|
||||
len(_TIMEOUT_COOLDOWN_LADDER)) - 1
|
||||
]
|
||||
elif _is_json_decode or _is_streaming_closed:
|
||||
_transient_cooldown = 30
|
||||
else:
|
||||
_transient_cooldown = 60
|
||||
err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(err_text) > 220:
|
||||
err_text = err_text[:217].rstrip() + "..."
|
||||
@@ -3143,16 +3253,14 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
#
|
||||
# EXCEPTION — auth AND transient network failures always abort. A
|
||||
# 401/403 from the summary call means the credential or endpoint is
|
||||
# broken (invalid/blocked key, or a token pointed at the wrong
|
||||
# inference host). A connection/stream-close error means the network
|
||||
# blipped at the compaction moment (#29559). In BOTH cases rotating into
|
||||
# a child session with a placeholder summary on a broken credential
|
||||
# strands the user on a degraded session for zero benefit — every
|
||||
# subsequent call fails the same way. So when the failure was an auth
|
||||
# error we abort regardless of abort_on_summary_failure, preserving
|
||||
# the conversation unchanged until the credential is fixed.
|
||||
# EXCEPTION — terminal access/quota AND transient network failures
|
||||
# always abort. Missing credentials, 401/402/403 access failures, and
|
||||
# confirmed non-resetting quota exhaustion cannot be repaired by
|
||||
# retrying the same summary request. A connection/stream-close error
|
||||
# means the network blipped at the compaction moment (#29559). In all
|
||||
# of these cases, rotating into a child session with a placeholder
|
||||
# summary degrades the conversation for zero benefit. Preserve it
|
||||
# unchanged until access is restored or connectivity recovers.
|
||||
if not summary and (
|
||||
self.abort_on_summary_failure
|
||||
or self._last_summary_auth_failure
|
||||
@@ -3165,11 +3273,12 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
if not self.quiet_mode:
|
||||
if self._last_summary_auth_failure:
|
||||
logger.warning(
|
||||
"Summary generation failed with an authentication "
|
||||
"error — aborting compression. %d message(s) preserved "
|
||||
"unchanged; the session was NOT rotated. Check your "
|
||||
"provider credential / inference endpoint, then retry "
|
||||
"with /compress or start fresh with /new.",
|
||||
"Summary generation failed with a terminal access or "
|
||||
"quota error — aborting compression. %d message(s) "
|
||||
"preserved unchanged; the session was NOT rotated. "
|
||||
"Check the provider credential, permission, quota, or "
|
||||
"inference endpoint, then retry with /compress or "
|
||||
"start fresh with /new.",
|
||||
n_skipped,
|
||||
)
|
||||
elif self._last_summary_network_failure:
|
||||
|
||||
+47
-15
@@ -1642,12 +1642,16 @@ def run_conversation(
|
||||
# Check finish_reason before proceeding
|
||||
if agent.api_mode == "codex_responses":
|
||||
status = getattr(response, "status", None)
|
||||
if isinstance(status, str):
|
||||
status = status.strip().lower()
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
incomplete_reason = None
|
||||
if isinstance(incomplete_details, dict):
|
||||
incomplete_reason = incomplete_details.get("reason")
|
||||
else:
|
||||
incomplete_reason = getattr(incomplete_details, "reason", None)
|
||||
if incomplete_reason is not None:
|
||||
incomplete_reason = str(incomplete_reason).strip().lower()
|
||||
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
|
||||
# Responses API max-output exhaustion is a normal
|
||||
# Codex incomplete turn. Let the Codex-specific
|
||||
@@ -1657,6 +1661,8 @@ def run_conversation(
|
||||
# emits "Response truncated due to output length
|
||||
# limit" and stops gateway turns.
|
||||
finish_reason = "incomplete"
|
||||
elif status == "incomplete" and incomplete_reason == "content_filter":
|
||||
finish_reason = "content_filter"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
elif agent.api_mode == "anthropic_messages":
|
||||
@@ -2619,6 +2625,31 @@ def run_conversation(
|
||||
)
|
||||
continue
|
||||
|
||||
# ── Bedrock AnthropicBedrock SDK streaming failure ──
|
||||
# The Anthropic SDK's stream accumulator raises RuntimeError
|
||||
# "Unexpected event order" when Bedrock returns an error event
|
||||
# before message_start (throttling, overload, validation).
|
||||
# Fall back to the native Converse API path for the rest of
|
||||
# this session — it handles these errors gracefully. Ref: #28156.
|
||||
if (
|
||||
isinstance(api_error, RuntimeError)
|
||||
and "unexpected event order" in str(api_error).lower()
|
||||
and getattr(agent, "provider", "") == "bedrock"
|
||||
and agent.api_mode == "anthropic_messages"
|
||||
and not getattr(agent, "_bedrock_converse_fallback_attempted", False)
|
||||
):
|
||||
agent._bedrock_converse_fallback_attempted = True
|
||||
agent.api_mode = "bedrock_converse"
|
||||
agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1"
|
||||
agent.client = None # Drop the AnthropicBedrock client
|
||||
agent._client_kwargs = {}
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — "
|
||||
f"falling back to native Converse API for this session.",
|
||||
force=True,
|
||||
)
|
||||
continue
|
||||
|
||||
status_code = getattr(api_error, "status_code", None)
|
||||
error_context = agent._extract_api_error_context(api_error)
|
||||
|
||||
@@ -4459,27 +4490,28 @@ def run_conversation(
|
||||
or interim_has_codex_message_items
|
||||
):
|
||||
last_msg = messages[-1] if messages else None
|
||||
# Duplicate detection: two consecutive incomplete assistant
|
||||
# messages with identical content AND reasoning are collapsed.
|
||||
# For provider-state-only changes (encrypted reasoning
|
||||
# items or replayable message ids/phases/statuses differ
|
||||
# while visible content/reasoning are unchanged), compare
|
||||
# those opaque payloads too so we don't silently drop the
|
||||
# newer continuation state.
|
||||
last_codex_items = last_msg.get("codex_reasoning_items") if isinstance(last_msg, dict) else None
|
||||
interim_codex_items = interim_msg.get("codex_reasoning_items")
|
||||
last_codex_message_items = last_msg.get("codex_message_items") if isinstance(last_msg, dict) else None
|
||||
interim_codex_message_items = interim_msg.get("codex_message_items")
|
||||
duplicate_interim = (
|
||||
# Duplicate detection: compare only visible content
|
||||
# (content + reasoning). Opaque provider state
|
||||
# (encrypted reasoning items, message item ids/phases)
|
||||
# drifts per continuation even when the visible output
|
||||
# is identical, so including it in the comparison defeats
|
||||
# dedup and causes message storms (#52711).
|
||||
visible_duplicate = (
|
||||
isinstance(last_msg, dict)
|
||||
and last_msg.get("role") == "assistant"
|
||||
and last_msg.get("finish_reason") == "incomplete"
|
||||
and (last_msg.get("content") or "") == (interim_msg.get("content") or "")
|
||||
and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "")
|
||||
and last_codex_items == interim_codex_items
|
||||
and last_codex_message_items == interim_codex_message_items
|
||||
)
|
||||
if not duplicate_interim:
|
||||
if visible_duplicate:
|
||||
# Update opaque state in-place so the latest
|
||||
# provider payload is preserved without emitting
|
||||
# a duplicate visible message.
|
||||
for _key in ("codex_reasoning_items", "codex_message_items"):
|
||||
_new_val = interim_msg.get(_key)
|
||||
if _new_val is not None:
|
||||
last_msg[_key] = _new_val
|
||||
else:
|
||||
messages.append(interim_msg)
|
||||
agent._emit_interim_assistant_message(interim_msg)
|
||||
|
||||
|
||||
@@ -543,14 +543,12 @@ def _write_through_provider_state_to_global_root(
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
|
||||
auth_mod._save_auth_store(global_store, global_path)
|
||||
auth_mod._persist_provider_state_to_store(
|
||||
provider_id,
|
||||
state,
|
||||
global_path,
|
||||
set_active=False,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug(
|
||||
"%s pool refresh: write-through to global root failed: %s",
|
||||
|
||||
@@ -840,12 +840,34 @@ def classify_api_error(
|
||||
)
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 7. Transport / timeout heuristics ───────────────────────────
|
||||
# ── 7b. Stale-call circuit breaker → failover immediately ──────
|
||||
# _check_stale_giveup() in agent/chat_completion_helpers.py raises a
|
||||
# RuntimeError when the provider has been unresponsive for N
|
||||
# consecutive stale attempts (default 5). The error is NOT a transport
|
||||
# timeout — the circuit breaker fires *before* any network call to avoid
|
||||
# an indefinite stall. Without this classification the RuntimeError
|
||||
# falls through to FailoverReason.unknown (retryable=True), which burns
|
||||
# all max_retries against the same dead provider (each retry hitting the
|
||||
# circuit breaker instantly with zero network overhead) before fallback
|
||||
# is attempted. Classify as non-retryable + should_fallback so the
|
||||
# retry loop activates the next fallback provider on the first hit.
|
||||
if (
|
||||
error_type == "RuntimeError"
|
||||
and "consecutive stale attempts" in error_msg
|
||||
and "aborting this call" in error_msg
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.timeout,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# ── 8. Transport / timeout heuristics ───────────────────────────
|
||||
|
||||
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 8. Fallback: unknown ────────────────────────────────────────
|
||||
# ── 9. Fallback: unknown ────────────────────────────────────────
|
||||
|
||||
return _result(FailoverReason.unknown, retryable=True)
|
||||
|
||||
|
||||
@@ -439,6 +439,18 @@ class InsightsEngine:
|
||||
|
||||
if models:
|
||||
total_cost = sum(float(m.get("cost") or 0.0) for m in models)
|
||||
# Token totals likewise: the per-model breakdown includes
|
||||
# auxiliary usage rows (vision/compression/titles — task
|
||||
# dimension in session_model_usage, #23270) plus reconciled
|
||||
# residuals, while the sessions counters carry main-loop usage
|
||||
# only. Summing the breakdown keeps overview totals consistent
|
||||
# with the per-model table and stops `hermes insights`
|
||||
# undercounting aux spend (#58592, #9979).
|
||||
total_input = sum(int(m.get("input_tokens") or 0) for m in models)
|
||||
total_output = sum(int(m.get("output_tokens") or 0) for m in models)
|
||||
total_cache_read = sum(int(m.get("cache_read_tokens") or 0) for m in models)
|
||||
total_cache_write = sum(int(m.get("cache_write_tokens") or 0) for m in models)
|
||||
total_tokens = total_input + total_output + total_cache_read + total_cache_write
|
||||
|
||||
# Session duration stats (guard against negative durations from clock drift)
|
||||
durations = []
|
||||
|
||||
@@ -20,6 +20,17 @@ _LM_VALID_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
# Map them onto the OpenAI-compatible request vocabulary.
|
||||
_LM_EFFORT_ALIASES = {"off": "none", "on": "medium"}
|
||||
|
||||
# Hermes' generic effort ladder grew past LM Studio's vocabulary ("max",
|
||||
# "ultra"). Clamp the stronger generic levels onto LM Studio's ceiling: left
|
||||
# alone they miss _LM_VALID_EFFORTS, keep the initialized "medium" default and
|
||||
# are thereby conflated with unparseable input, so asking for more reasoning
|
||||
# yields less than "xhigh". Mirrors the ceiling clamp every other provider
|
||||
# applies (see agent/transports/codex.py).
|
||||
#
|
||||
# Deliberately separate from _LM_EFFORT_ALIASES: that mapping is also applied
|
||||
# to the model's published allowed_options, which must not be rewritten.
|
||||
_LM_EFFORT_CLAMP = {"max": "xhigh", "ultra": "xhigh"}
|
||||
|
||||
|
||||
def resolve_lmstudio_effort(
|
||||
reasoning_config: Optional[dict],
|
||||
@@ -39,6 +50,7 @@ def resolve_lmstudio_effort(
|
||||
else:
|
||||
raw = (reasoning_config.get("effort") or "").strip().lower()
|
||||
raw = _LM_EFFORT_ALIASES.get(raw, raw)
|
||||
raw = _LM_EFFORT_CLAMP.get(raw, raw)
|
||||
if raw in _LM_VALID_EFFORTS:
|
||||
effort = raw
|
||||
if allowed_options:
|
||||
|
||||
@@ -4,45 +4,86 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
|
||||
def summarize_manual_compression(
|
||||
before_messages: Sequence[dict[str, Any]],
|
||||
after_messages: Sequence[dict[str, Any]],
|
||||
before_tokens: int,
|
||||
after_tokens: int,
|
||||
*,
|
||||
compression_state: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return consistent user-facing feedback for manual compression."""
|
||||
before_count = len(before_messages)
|
||||
after_count = len(after_messages)
|
||||
noop = list(after_messages) == list(before_messages)
|
||||
aborted = (
|
||||
compression_state is not None
|
||||
and getattr(compression_state, "_last_compress_aborted", False) is True
|
||||
)
|
||||
fallback_used = (
|
||||
compression_state is not None
|
||||
and getattr(compression_state, "_last_summary_fallback_used", False) is True
|
||||
)
|
||||
failure_reason = (
|
||||
getattr(compression_state, "_last_summary_error", None)
|
||||
if compression_state is not None
|
||||
else None
|
||||
)
|
||||
if not isinstance(failure_reason, str) or not failure_reason.strip():
|
||||
failure_reason = None
|
||||
|
||||
if noop:
|
||||
if aborted:
|
||||
headline = f"Compression aborted: {before_count} messages preserved"
|
||||
elif fallback_used:
|
||||
headline = (
|
||||
f"Compressed with fallback: {before_count} → {after_count} messages"
|
||||
)
|
||||
elif noop:
|
||||
headline = f"No changes from compression: {before_count} messages"
|
||||
if after_tokens == before_tokens:
|
||||
token_line = (
|
||||
f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
|
||||
)
|
||||
else:
|
||||
token_line = (
|
||||
f"Approx request size: ~{before_tokens:,} → "
|
||||
f"~{after_tokens:,} tokens"
|
||||
)
|
||||
else:
|
||||
headline = f"Compressed: {before_count} → {after_count} messages"
|
||||
|
||||
if noop and after_tokens == before_tokens:
|
||||
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
|
||||
else:
|
||||
token_line = (
|
||||
f"Approx request size: ~{before_tokens:,} → "
|
||||
f"~{after_tokens:,} tokens"
|
||||
)
|
||||
|
||||
note = None
|
||||
if not noop and after_count < before_count and after_tokens > before_tokens:
|
||||
if aborted:
|
||||
note = "Summary generation failed; no messages were removed."
|
||||
elif fallback_used:
|
||||
dropped_count = getattr(
|
||||
compression_state, "_last_summary_dropped_count", None
|
||||
)
|
||||
if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
|
||||
dropped_count = max(before_count - after_count, 0)
|
||||
note = (
|
||||
"Summary generation failed; Hermes used limited fallback context "
|
||||
f"and removed {dropped_count} message(s)."
|
||||
)
|
||||
elif not noop and after_count < before_count and after_tokens > before_tokens:
|
||||
note = (
|
||||
"Note: fewer messages can still raise this estimate when "
|
||||
"compression rewrites the transcript into denser summaries."
|
||||
)
|
||||
|
||||
if failure_reason and (aborted or fallback_used):
|
||||
# This text crosses a user-facing UI boundary. Never let a disabled
|
||||
# global redaction preference expose credentials embedded in provider
|
||||
# exception text.
|
||||
safe_reason = redact_sensitive_text(failure_reason.strip(), force=True)
|
||||
note = f"{note} Reason: {safe_reason}"
|
||||
|
||||
return {
|
||||
"noop": noop,
|
||||
"aborted": aborted,
|
||||
"fallback_used": fallback_used,
|
||||
"headline": headline,
|
||||
"token_line": token_line,
|
||||
"note": note,
|
||||
|
||||
+7
-1
@@ -404,6 +404,12 @@ def _run_references_parallel(
|
||||
results: list[tuple[str, str, Any] | None] = [None] * len(reference_models)
|
||||
futures = {}
|
||||
workers = min(_MAX_REFERENCE_WORKERS, len(reference_models))
|
||||
# Reference slots run on bare executor threads, which start with an empty
|
||||
# contextvars.Context — propagate the parent turn's context (approval
|
||||
# callbacks + the Nous Portal conversation tag) into each worker so
|
||||
# advisor calls attribute to the same conversation as the acting turn.
|
||||
from tools.thread_context import propagate_context_to_thread
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
for idx, slot in enumerate(reference_models):
|
||||
if slot.get("provider") == "moa":
|
||||
@@ -415,7 +421,7 @@ def _run_references_parallel(
|
||||
continue
|
||||
futures[
|
||||
executor.submit(
|
||||
_run_reference,
|
||||
propagate_context_to_thread(_run_reference),
|
||||
slot,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
|
||||
+83
-3
@@ -31,7 +31,55 @@ version can change at runtime (editable installs, hot-reload tooling), and
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from contextvars import ContextVar
|
||||
from typing import List, Optional
|
||||
|
||||
# ── Ambient conversation context ─────────────────────────────────────────────
|
||||
#
|
||||
# The main agent loop knows its ``session_id``; the dozens of auxiliary call
|
||||
# sites (compression, title generation, vision, web_extract, session_search,
|
||||
# MoA reference/aggregator slots, curator, kanban helpers, ...) do not — they
|
||||
# funnel through ``agent.auxiliary_client.call_llm`` which has no session
|
||||
# handle. Rather than threading a ``session_id`` parameter through every one
|
||||
# of those call sites (and every future one), the agent loop publishes the
|
||||
# active conversation id here and ``nous_portal_tags()`` picks it up as a
|
||||
# fallback whenever no explicit ``session_id`` is passed.
|
||||
#
|
||||
# ContextVar (not a module global) so concurrent agents in one process —
|
||||
# gateway sessions, delegate_task subagents, batch runners — never see each
|
||||
# other's conversation id. Worker threads spawned via
|
||||
# ``tools.thread_context.propagate_context_to_thread`` (background review,
|
||||
# MoA fan-out, tool executor) inherit it through the copied Context; bare
|
||||
# threads (title generator) capture it explicitly at spawn time.
|
||||
_conversation_id: ContextVar[Optional[str]] = ContextVar(
|
||||
"nous_portal_conversation_id", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_conversation_context(conversation_id: Optional[str]):
|
||||
"""Publish the active conversation id for ambient Portal tagging.
|
||||
|
||||
Called by the agent loop at turn entry with the conversation's stable
|
||||
id (the session-lineage ROOT id, so the tag survives context-compression
|
||||
session rotation). Pass ``None`` to clear. Returns the ContextVar token
|
||||
so callers can ``reset_conversation_context(token)`` on turn exit.
|
||||
"""
|
||||
return _conversation_id.set(conversation_id or None)
|
||||
|
||||
|
||||
def reset_conversation_context(token) -> None:
|
||||
"""Restore the previous conversation context (pair with ``set_...``)."""
|
||||
try:
|
||||
_conversation_id.reset(token)
|
||||
except Exception:
|
||||
# Token from another Context (e.g. reset on a different thread) —
|
||||
# fall back to clearing rather than raising in cleanup paths.
|
||||
_conversation_id.set(None)
|
||||
|
||||
|
||||
def get_conversation_context() -> Optional[str]:
|
||||
"""Return the ambient conversation id, or ``None`` when unset."""
|
||||
return _conversation_id.get()
|
||||
|
||||
|
||||
def _hermes_version() -> str:
|
||||
@@ -55,10 +103,42 @@ def hermes_client_tag() -> str:
|
||||
return f"client=hermes-client-v{_hermes_version()}"
|
||||
|
||||
|
||||
def nous_portal_tags() -> List[str]:
|
||||
def conversation_tag(session_id: str) -> str:
|
||||
"""Return the ``conversation=...`` tag for a Hermes session/conversation.
|
||||
|
||||
Format: ``conversation=<session_id>``. ``session_id`` is the canonical
|
||||
Hermes conversation identifier (``AIAgent.session_id``) — the same value
|
||||
used for ``~/.hermes/sessions/`` storage, session logs, and lineage.
|
||||
|
||||
Unlike the product/client tags this is high-cardinality (one value per
|
||||
conversation), so it is only appended when a session id is actually
|
||||
available — never as part of the always-on base tag set.
|
||||
"""
|
||||
return f"conversation={session_id}"
|
||||
|
||||
|
||||
def nous_portal_tags(session_id: str | None = None) -> List[str]:
|
||||
"""Return the canonical list of Nous Portal product tags.
|
||||
|
||||
Always returns a fresh list so callers can mutate it freely
|
||||
(e.g. ``merged_extra.setdefault("tags", []).extend(nous_portal_tags())``).
|
||||
|
||||
When ``session_id`` is provided, a ``conversation=<session_id>`` tag is
|
||||
appended so Portal usage can be attributed to a specific Hermes
|
||||
conversation. When it is omitted, the ambient conversation context
|
||||
(``set_conversation_context``, published by the agent loop at turn
|
||||
entry) is used instead — this is how auxiliary calls (compression,
|
||||
titles, vision, MoA slots, ...) inherit the conversation tag without
|
||||
per-call-site plumbing. Callers outside any conversation (e.g. the
|
||||
auxiliary client's import-time base tags) get the canonical two-tag set.
|
||||
"""
|
||||
return ["product=hermes-agent", hermes_client_tag()]
|
||||
tags = ["product=hermes-agent", hermes_client_tag()]
|
||||
# Ambient context first: the agent loop publishes the lineage ROOT id
|
||||
# (stable across context-compression rotation and delegate subagent
|
||||
# trees), which is the better conversation key than a per-segment
|
||||
# session_id passed explicitly. The explicit argument remains as a
|
||||
# fallback for callers running outside any agent turn.
|
||||
effective = get_conversation_context() or session_id
|
||||
if effective:
|
||||
tags.append(conversation_tag(effective))
|
||||
return tags
|
||||
|
||||
+38
-7
@@ -257,6 +257,10 @@ KANBAN_GUIDANCE = (
|
||||
"- **Deliverables.** Files a human wants go in "
|
||||
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
|
||||
"`metadata` are NOT uploaded). Files must exist at completion.\n"
|
||||
"- **Attachments.** Attach real downloadable artifacts instead of pasting "
|
||||
"links in comments: `kanban_attach` (base64) or `kanban_attach_url` "
|
||||
"(server-side public http(s) fetch); 25 MB cap, `kanban_attachments` "
|
||||
"lists them. Workers may only attach to their own task.\n"
|
||||
"- **Created cards.** List ids in `kanban_complete(created_cards=[...])` "
|
||||
"ONLY when captured from a successful `kanban_create` return — never invent "
|
||||
"or paste ids; the kernel rejects the completion on any phantom id.\n"
|
||||
@@ -1957,6 +1961,7 @@ def build_context_files_prompt(
|
||||
cwd: Optional[str] = None,
|
||||
skip_soul: bool = False,
|
||||
context_length: Optional[int] = None,
|
||||
allow_install_tree_fallback: bool = False,
|
||||
) -> str:
|
||||
"""Discover and load context files for the system prompt.
|
||||
|
||||
@@ -1978,17 +1983,43 @@ def build_context_files_prompt(
|
||||
"""
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
cwd_is_fallback = True
|
||||
else:
|
||||
cwd_is_fallback = False
|
||||
|
||||
cwd_path = Path(cwd).resolve()
|
||||
sections = []
|
||||
|
||||
# Priority-based project context: first match wins
|
||||
project_context = (
|
||||
_load_hermes_md(cwd_path, context_length)
|
||||
or _load_agents_md(cwd_path, context_length)
|
||||
or _load_claude_md(cwd_path, context_length)
|
||||
or _load_cursorrules(cwd_path, context_length)
|
||||
)
|
||||
# Never let a FALLBACK-picked directory inside the Hermes install/source
|
||||
# tree gain system-prompt authority. A backend that self-spawns into that
|
||||
# tree (the desktop app default) would otherwise load this repo's
|
||||
# contributor AGENTS.md as authoritative project context (#64590). An
|
||||
# explicitly configured cwd is honored verbatim — the Hermes tree is a
|
||||
# legitimate workspace when the user deliberately points a session at it —
|
||||
# and CLI-style surfaces pass allow_install_tree_fallback=True because
|
||||
# their launch dir IS the user's shell cwd (developing Hermes in-tree).
|
||||
from agent.runtime_cwd import _is_install_tree
|
||||
|
||||
if (
|
||||
cwd_is_fallback
|
||||
and not allow_install_tree_fallback
|
||||
and _is_install_tree(cwd_path)
|
||||
):
|
||||
logger.warning(
|
||||
"skipping project-context discovery: working-directory resolution "
|
||||
"fell back to the Hermes install tree (%s) — set terminal.cwd to "
|
||||
"your project directory",
|
||||
cwd_path,
|
||||
)
|
||||
project_context = ""
|
||||
else:
|
||||
# Priority-based project context: first match wins
|
||||
project_context = (
|
||||
_load_hermes_md(cwd_path, context_length)
|
||||
or _load_agents_md(cwd_path, context_length)
|
||||
or _load_claude_md(cwd_path, context_length)
|
||||
or _load_cursorrules(cwd_path, context_length)
|
||||
)
|
||||
if project_context:
|
||||
sections.append(project_context)
|
||||
|
||||
|
||||
+43
-5
@@ -10,15 +10,36 @@ Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
|
||||
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
|
||||
|
||||
# The Python package/source root (this file lives at <root>/agent/runtime_cwd.py).
|
||||
# When a backend is launched from, or self-spawns into, this tree (the desktop
|
||||
# app default), an os.getcwd() fallback would inject this repo's contributor
|
||||
# AGENTS.md as authoritative project context. Context discovery must never
|
||||
# resolve here.
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _is_install_tree(p: Path) -> bool:
|
||||
# True only when p IS the package root or sits inside it. Ancestors of the
|
||||
# package root (a user home that happens to contain the checkout, a --user
|
||||
# site-packages parent) are legitimate workspaces and must not be blocked.
|
||||
try:
|
||||
p = p.resolve()
|
||||
except Exception:
|
||||
return False
|
||||
return p == _PACKAGE_ROOT or _PACKAGE_ROOT in p.parents
|
||||
|
||||
|
||||
def set_session_cwd(cwd: str | None) -> Token:
|
||||
"""Pin the logical cwd for the current context."""
|
||||
@@ -42,21 +63,38 @@ def resolve_agent_cwd() -> Path:
|
||||
p = Path(override).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
logger.warning("configured working directory does not exist: %s", override)
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
logger.warning("TERMINAL_CWD does not exist: %s", raw)
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
|
||||
# or, per session, the _SESSION_CWD contextvar above.
|
||||
# to the launch dir (os.getcwd()), correct for a local CLI launched inside a
|
||||
# real project. A configured path is validated here (previously it was passed
|
||||
# through unchecked, diverging from resolve_agent_cwd). An explicitly
|
||||
# configured path is otherwise honored verbatim — including the Hermes
|
||||
# source tree itself, which is a legitimate workspace when the user is
|
||||
# developing Hermes (per-surface policy for fallback-picked directories
|
||||
# lives in build_context_files_prompt; see #64590).
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
p = Path(override).expanduser()
|
||||
if not p.is_dir():
|
||||
logger.warning("configured working directory does not exist: %s", override)
|
||||
else:
|
||||
return p
|
||||
return None
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if not p.is_dir():
|
||||
logger.warning("TERMINAL_CWD does not exist: %s", raw)
|
||||
else:
|
||||
return p
|
||||
return None
|
||||
|
||||
@@ -463,9 +463,16 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# CLI), None lets build_context_files_prompt fall back to the launch
|
||||
# dir — the user's real cwd there, but the install dir for the gateway
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
#
|
||||
# allow_install_tree_fallback: for cli/tui the launch dir IS the
|
||||
# user's shell cwd, so an in-tree fallback is a deliberate choice
|
||||
# (developing Hermes). Every other surface (desktop chat panel,
|
||||
# gateway daemons) self-spawns into the install tree, where the
|
||||
# fallback would inject this repo's contributor AGENTS.md (#64590).
|
||||
context_files_prompt = _r.build_context_files_prompt(
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
|
||||
context_length=_ctx_len)
|
||||
context_length=_ctx_len,
|
||||
allow_install_tree_fallback=agent.platform in ("cli", "tui"))
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
|
||||
+14
-4
@@ -208,19 +208,29 @@ class StreamingThinkScrubber:
|
||||
discarded — leaking partial reasoning is worse than a
|
||||
truncated answer. Otherwise the held-back partial-tag tail is
|
||||
emitted verbatim (it turned out not to be a real tag prefix).
|
||||
|
||||
Always treats the next ``feed()`` as a fresh stream boundary.
|
||||
Intra-turn retries (thinking-only prefill, empty-response
|
||||
retry) flush then stream again without calling ``reset()``;
|
||||
leaving ``_last_emitted_ended_newline`` False made a new
|
||||
stream's opening ``<think>`` look mid-line and leak into the
|
||||
visible reply.
|
||||
"""
|
||||
if self._in_block:
|
||||
self._buf = ""
|
||||
self._in_block = False
|
||||
# Next feed() is a new stream — start-of-stream is a boundary.
|
||||
self._last_emitted_ended_newline = True
|
||||
return ""
|
||||
tail = self._buf
|
||||
self._buf = ""
|
||||
# Same for the non-block path: do NOT derive the boundary flag
|
||||
# from the flushed tail (e.g. a held-back '<'). End-of-stream
|
||||
# means the next feed() starts a new model response.
|
||||
self._last_emitted_ended_newline = True
|
||||
if not tail:
|
||||
return ""
|
||||
tail = self._strip_orphan_close_tags(tail)
|
||||
if tail:
|
||||
self._last_emitted_ended_newline = tail.endswith("\n")
|
||||
return tail
|
||||
return self._strip_orphan_close_tags(tail)
|
||||
|
||||
# ── internal helpers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -145,6 +145,25 @@ def auto_title_session(
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# This runs on a bare daemon thread spawned AFTER the turn's ambient
|
||||
# conversation context was reset, so publish it here from the session id
|
||||
# we already hold — the title-generation LLM call then carries the same
|
||||
# ``conversation=`` Portal tag as the turn it titles. Root-of-lineage for
|
||||
# consistency with the agent loop (a no-op on first exchange, where
|
||||
# titling happens, but correct if this ever runs on a continuation).
|
||||
from agent.aux_accounting import set_accounting_context
|
||||
from agent.portal_tags import set_conversation_context
|
||||
|
||||
conversation_id = session_id
|
||||
try:
|
||||
conversation_id = session_db.get_conversation_root(session_id) or session_id
|
||||
except Exception:
|
||||
pass
|
||||
set_conversation_context(conversation_id)
|
||||
# Same for the accounting context, so the title call's token usage is
|
||||
# recorded against this session (task='title_generation', #23270).
|
||||
set_accounting_context(session_db, session_id)
|
||||
|
||||
title = generate_title(
|
||||
user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime
|
||||
)
|
||||
|
||||
@@ -102,7 +102,7 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
|
||||
def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = None) -> List[tuple]:
|
||||
"""Split a tool-call batch into ordered ``(kind, calls)`` segments.
|
||||
|
||||
``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe
|
||||
@@ -173,7 +173,7 @@ def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
|
||||
continue
|
||||
|
||||
if tool_name in _PATH_SCOPED_TOOLS:
|
||||
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
|
||||
scoped_path = _extract_parallel_scope_path(tool_name, function_args, execution_cwd=execution_cwd)
|
||||
if scoped_path is None:
|
||||
_add_sequential(tool_call)
|
||||
continue
|
||||
@@ -217,8 +217,34 @@ def _should_parallelize_tool_batch(tool_calls) -> bool:
|
||||
return len(segments) == 1 and segments[0][0] == "parallel"
|
||||
|
||||
|
||||
def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]:
|
||||
"""Return the normalized file target for path-scoped tools."""
|
||||
def _canonical_path(raw_path: str, execution_cwd: Optional[Path] = None) -> Path:
|
||||
"""Return a canonical, OS-aware path for overlap detection.
|
||||
|
||||
Uses ``os.path.realpath`` to resolve symlinks on existing path components
|
||||
and ``os.path.normcase`` for case-insensitive platforms (Windows).
|
||||
Falls back to ``Path.cwd()`` when *execution_cwd* is not supplied.
|
||||
"""
|
||||
expanded = Path(raw_path).expanduser()
|
||||
base = execution_cwd if execution_cwd is not None else Path.cwd()
|
||||
candidate = expanded if expanded.is_absolute() else base / expanded
|
||||
# realpath resolves symlinks on path components that exist; for
|
||||
# not-yet-created files it canonicalises as far as possible.
|
||||
resolved = os.path.normcase(os.path.realpath(os.path.abspath(str(candidate))))
|
||||
return Path(resolved)
|
||||
|
||||
|
||||
def _extract_parallel_scope_path(
|
||||
tool_name: str,
|
||||
function_args: dict,
|
||||
execution_cwd: Optional[Path] = None,
|
||||
) -> Optional[Path]:
|
||||
"""Return the canonical file target for path-scoped tools.
|
||||
|
||||
*execution_cwd* should be the working directory that the tool will
|
||||
actually use at runtime. When omitted the process cwd is used,
|
||||
which may differ from the tool execution environment on some
|
||||
platforms (e.g. WSL, sandboxed sub-processes).
|
||||
"""
|
||||
if tool_name not in _PATH_SCOPED_TOOLS:
|
||||
return None
|
||||
|
||||
@@ -226,16 +252,16 @@ def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optiona
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
return None
|
||||
|
||||
expanded = Path(raw_path).expanduser()
|
||||
if expanded.is_absolute():
|
||||
return Path(os.path.abspath(str(expanded)))
|
||||
|
||||
# Avoid resolve(); the file may not exist yet.
|
||||
return Path(os.path.abspath(str(Path.cwd() / expanded)))
|
||||
return _canonical_path(raw_path, execution_cwd)
|
||||
|
||||
|
||||
def _paths_overlap(left: Path, right: Path) -> bool:
|
||||
"""Return True when two paths may refer to the same subtree."""
|
||||
"""Return True when two paths may refer to the same subtree.
|
||||
|
||||
Both *left* and *right* must already be canonical (as returned by
|
||||
``_extract_parallel_scope_path`` / ``_canonical_path``) so that
|
||||
symlink aliases and case differences are already normalised.
|
||||
"""
|
||||
left_parts = left.parts
|
||||
right_parts = right.parts
|
||||
if not left_parts or not right_parts:
|
||||
@@ -613,6 +639,7 @@ __all__ = [
|
||||
"_is_destructive_command",
|
||||
"_plan_tool_batch_segments",
|
||||
"_should_parallelize_tool_batch",
|
||||
"_canonical_path",
|
||||
"_extract_parallel_scope_path",
|
||||
"_paths_overlap",
|
||||
"_is_multimodal_tool_result",
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
@@ -1764,7 +1765,9 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec
|
||||
from types import SimpleNamespace
|
||||
|
||||
if segments is None:
|
||||
segments = _plan_tool_batch_segments(assistant_message.tool_calls)
|
||||
_active_env = get_active_env(effective_task_id)
|
||||
_exec_cwd = Path(_active_env.cwd) if _active_env is not None and _active_env.cwd else None
|
||||
segments = _plan_tool_batch_segments(assistant_message.tool_calls, execution_cwd=_exec_cwd)
|
||||
|
||||
for kind, calls in segments:
|
||||
segment_message = SimpleNamespace(tool_calls=list(calls))
|
||||
|
||||
@@ -776,15 +776,18 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> dict[str, int] | None:
|
||||
"""Extract OpenRouter/OpenAI cache stats from prompt_tokens_details."""
|
||||
"""Extract cache stats from prompt_tokens_details (OpenRouter/OpenAI)
|
||||
or DeepSeek's native top-level prompt_cache_hit_tokens field."""
|
||||
usage = getattr(response, "usage", None)
|
||||
if usage is None:
|
||||
return None
|
||||
details = getattr(usage, "prompt_tokens_details", None)
|
||||
if details is None:
|
||||
return None
|
||||
cached = getattr(details, "cached_tokens", 0) or 0
|
||||
written = getattr(details, "cache_write_tokens", 0) or 0
|
||||
cached = getattr(details, "cached_tokens", 0) or 0 if details else 0
|
||||
written = getattr(details, "cache_write_tokens", 0) or 0 if details else 0
|
||||
if not cached:
|
||||
# DeepSeek native API shape (api.deepseek.com): top-level
|
||||
# prompt_cache_hit_tokens / prompt_cache_miss_tokens (#61871).
|
||||
cached = getattr(usage, "prompt_cache_hit_tokens", 0) or 0
|
||||
if cached or written:
|
||||
return {"cached_tokens": cached, "creation_tokens": written}
|
||||
return None
|
||||
|
||||
@@ -435,15 +435,27 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Codex Responses API response has valid output structure.
|
||||
|
||||
Returns True only if response.output is a non-empty list.
|
||||
Does NOT check output_text fallback — the caller handles that
|
||||
with diagnostic logging for stream backfill recovery.
|
||||
Returns True only if response.output is a non-empty list. Also treats
|
||||
terminal content-filter incomplete responses as valid: the Responses API
|
||||
may return status=incomplete with incomplete_details.reason='content_filter'
|
||||
and no output items. That is a provider refusal signal, not a malformed
|
||||
response, and must reach normalization so the agent loop can use the
|
||||
content-policy / fallback path instead of invalid-response retries.
|
||||
|
||||
Does NOT check output_text fallback — the caller handles that with
|
||||
diagnostic logging for stream backfill recovery.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
output = getattr(response, "output", None)
|
||||
if not isinstance(output, list) or not output:
|
||||
return False
|
||||
status = str(getattr(response, "status", "") or "").strip().lower()
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
if isinstance(incomplete_details, dict):
|
||||
reason = str(incomplete_details.get("reason") or "").strip().lower()
|
||||
else:
|
||||
reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
|
||||
return status == "incomplete" and reason == "content_filter"
|
||||
return True
|
||||
|
||||
def preflight_kwargs(
|
||||
|
||||
@@ -218,6 +218,11 @@ def build_turn_context(
|
||||
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
|
||||
agent._current_turn_id = turn_id
|
||||
agent._current_api_request_id = ""
|
||||
# Tripwire: warn (with both turn ids) when this turn starts before the
|
||||
# previous turn's turn-end persist — concurrent turns on one session
|
||||
# interleave transcript writes. Cleared in _persist_session.
|
||||
from agent.agent_runtime_helpers import note_turn_start
|
||||
note_turn_start(agent, turn_id)
|
||||
|
||||
# Reset retry counters and iteration budget at the start of each turn.
|
||||
agent._invalid_tool_retries = 0
|
||||
|
||||
+220
-8
@@ -446,36 +446,52 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# DeepSeek
|
||||
# Snapshot of https://api-docs.deepseek.com/quick_start/pricing (2026-07).
|
||||
# deepseek-chat / deepseek-reasoner are deprecated 2026-07-24 and now alias
|
||||
# deepseek-v4-flash's non-thinking / thinking modes — same rates.
|
||||
(
|
||||
"deepseek",
|
||||
"deepseek-chat",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.14"),
|
||||
output_cost_per_million=Decimal("0.28"),
|
||||
cache_read_cost_per_million=Decimal("0.0028"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
||||
pricing_version="deepseek-pricing-2026-03-16",
|
||||
pricing_version="deepseek-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"deepseek",
|
||||
"deepseek-reasoner",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.55"),
|
||||
output_cost_per_million=Decimal("2.19"),
|
||||
input_cost_per_million=Decimal("0.14"),
|
||||
output_cost_per_million=Decimal("0.28"),
|
||||
cache_read_cost_per_million=Decimal("0.0028"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
||||
pricing_version="deepseek-pricing-2026-03-16",
|
||||
pricing_version="deepseek-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"deepseek",
|
||||
"deepseek-v4-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.74"),
|
||||
output_cost_per_million=Decimal("3.48"),
|
||||
cache_read_cost_per_million=Decimal("0.0145"),
|
||||
input_cost_per_million=Decimal("0.435"),
|
||||
output_cost_per_million=Decimal("0.87"),
|
||||
cache_read_cost_per_million=Decimal("0.003625"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
||||
pricing_version="deepseek-pricing-2026-05-12",
|
||||
pricing_version="deepseek-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"deepseek",
|
||||
"deepseek-v4-flash",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.14"),
|
||||
output_cost_per_million=Decimal("0.28"),
|
||||
cache_read_cost_per_million=Decimal("0.0028"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
||||
pricing_version="deepseek-pricing-2026-07",
|
||||
),
|
||||
# Google Gemini
|
||||
(
|
||||
@@ -609,6 +625,189 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source="official_docs_snapshot",
|
||||
pricing_version="minimax-pricing-2026-04",
|
||||
),
|
||||
# Fireworks AI — serverless pricing for the models hermes typically routes
|
||||
# through when configured with provider="fireworks". Fireworks publishes a
|
||||
# cached_input rate per model alongside input/output, which maps to
|
||||
# cache_read_cost_per_million. No separately published cache_write rate.
|
||||
# Snapshot of https://docs.fireworks.ai/serverless/pricing (Standard tier).
|
||||
(
|
||||
"fireworks",
|
||||
"kimi-k2p6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.95"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.16"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"kimi-k2p7-code",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.95"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
cache_read_cost_per_million=Decimal("0.19"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"glm-5p2",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.40"),
|
||||
output_cost_per_million=Decimal("4.40"),
|
||||
cache_read_cost_per_million=Decimal("0.14"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"deepseek-v4-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.74"),
|
||||
output_cost_per_million=Decimal("3.48"),
|
||||
cache_read_cost_per_million=Decimal("0.145"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"deepseek-v4-flash",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.14"),
|
||||
output_cost_per_million=Decimal("0.28"),
|
||||
cache_read_cost_per_million=Decimal("0.028"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"qwen3p7-plus",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.40"),
|
||||
output_cost_per_million=Decimal("1.60"),
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"minimax-m3",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.30"),
|
||||
output_cost_per_million=Decimal("1.20"),
|
||||
cache_read_cost_per_million=Decimal("0.06"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"gpt-oss-120b",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.15"),
|
||||
output_cost_per_million=Decimal("0.60"),
|
||||
cache_read_cost_per_million=Decimal("0.015"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"gpt-oss-20b",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.07"),
|
||||
output_cost_per_million=Decimal("0.30"),
|
||||
cache_read_cost_per_million=Decimal("0.035"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"glm-5p1",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.40"),
|
||||
output_cost_per_million=Decimal("4.40"),
|
||||
cache_read_cost_per_million=Decimal("0.26"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"minimax-m2p7",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.30"),
|
||||
output_cost_per_million=Decimal("1.20"),
|
||||
cache_read_cost_per_million=Decimal("0.06"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
# Fast/turbo serving tiers — exposed as accounts/fireworks/routers/<name>,
|
||||
# so rsplit("/", 1) yields these distinct ids with their own (higher) rates.
|
||||
(
|
||||
"fireworks",
|
||||
"kimi-k2p6-fast",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.00"),
|
||||
output_cost_per_million=Decimal("8.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"kimi-k2p6-turbo",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.00"),
|
||||
output_cost_per_million=Decimal("8.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"kimi-k2p7-code-fast",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.90"),
|
||||
output_cost_per_million=Decimal("8.00"),
|
||||
cache_read_cost_per_million=Decimal("0.38"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"glm-5p2-fast",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.10"),
|
||||
output_cost_per_million=Decimal("6.60"),
|
||||
cache_read_cost_per_million=Decimal("0.21"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"glm-5p1-fast",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.80"),
|
||||
output_cost_per_million=Decimal("8.80"),
|
||||
cache_read_cost_per_million=Decimal("0.52"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.fireworks.ai/serverless/pricing",
|
||||
pricing_version="fireworks-pricing-2026-07",
|
||||
),
|
||||
}
|
||||
|
||||
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
|
||||
@@ -672,6 +871,10 @@ def resolve_billing_route(
|
||||
# the OpenAI-compat endpoint requires so the pricing key matches.
|
||||
if provider_name == "vertex" or base_url_host_matches(base_url or "", "aiplatform.googleapis.com"):
|
||||
return BillingRoute(provider="gemini", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name == "fireworks" or base_url_host_matches(base_url or "", "api.fireworks.ai"):
|
||||
# Fireworks model ids look like accounts/fireworks/models/<name>;
|
||||
# rsplit("/", 1)[-1] yields just <name> which is what the dict keys on.
|
||||
return BillingRoute(provider="fireworks", model=model.rsplit("/", 1)[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name in {"custom", "local"} or (base and "localhost" in base):
|
||||
return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown")
|
||||
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
|
||||
@@ -871,6 +1074,15 @@ def normalize_usage(
|
||||
cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0)
|
||||
if not cache_read_tokens:
|
||||
cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0))
|
||||
if not cache_read_tokens:
|
||||
# DeepSeek's native API (api.deepseek.com) reports context-cache
|
||||
# hits as top-level prompt_cache_hit_tokens (+ the complementary
|
||||
# prompt_cache_miss_tokens; prompt_tokens = hit + miss), not the
|
||||
# OpenAI nested shape. Without this, direct DeepSeek sessions
|
||||
# always showed 0 cache-hit tokens (#61871).
|
||||
cache_read_tokens = _to_int(
|
||||
getattr(response_usage, "prompt_cache_hit_tokens", 0)
|
||||
)
|
||||
cache_write_tokens = _to_int(
|
||||
getattr(details, "cache_write_tokens", 0) if details else 0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import shared from '../../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared
|
||||
]
|
||||
@@ -13,7 +13,10 @@
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"check": "npm run typecheck"
|
||||
"check": "npm run typecheck",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"fix": "npm run lint:fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
@@ -38,11 +41,19 @@
|
||||
"tw-shimmer": "^0.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $route, $bootstrap, initialize } from './store'
|
||||
import Welcome from './routes/welcome'
|
||||
|
||||
import Failure from './routes/failure'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Failure from './routes/failure'
|
||||
import Welcome from './routes/welcome'
|
||||
import { $bootstrap, $route, initialize } from './store'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './styles.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import App from './app.tsx'
|
||||
import './styles.css'
|
||||
import { watchTheme } from './theme'
|
||||
|
||||
// Follow the OS light/dark appearance. theme.ts paints the first frame on
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { FileText, RefreshCw } from 'lucide-react'
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
$mode,
|
||||
type BootstrapStateModel,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
startUpdate,
|
||||
type BootstrapStateModel
|
||||
startUpdate
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
@@ -55,11 +56,11 @@ export default function Failure({ bootstrap }: FailureProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
|
||||
<Button className="gap-1.5" onClick={() => void (isUpdate ? startUpdate() : startInstall())}>
|
||||
<RefreshCw />
|
||||
{isUpdate ? 'Retry update' : 'Retry install'}
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => void openLogDir()} className="gap-1.5">
|
||||
<Button className="gap-1.5" onClick={() => void openLogDir()} variant="text">
|
||||
<FileText />
|
||||
Open logs
|
||||
</Button>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import clsx from 'clsx'
|
||||
import { Check, ChevronRight, FileText, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '../components/brand-mark'
|
||||
import { Button } from '../components/button'
|
||||
import { Loader } from '../components/loader'
|
||||
import {
|
||||
cancelInstall,
|
||||
$mode,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
cancelInstall,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { BrandMark } from '../components/brand-mark'
|
||||
import { Loader } from '../components/loader'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
@@ -42,15 +43,19 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
if (bootstrap.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [bootstrap.status])
|
||||
|
||||
const isUpdate = mode === 'update'
|
||||
const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent'
|
||||
|
||||
const description = isUpdate
|
||||
? 'Hermes is updating to the latest version — this only takes a moment.'
|
||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.'
|
||||
|
||||
const pct = Math.round(progress.fraction * 100)
|
||||
|
||||
return (
|
||||
@@ -90,22 +95,25 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
<ol className="space-y-0.5">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
|
||||
if (!rec) {return null}
|
||||
|
||||
const meta =
|
||||
rec.state === 'running' && rec.startedAt != null
|
||||
? formatElapsed(now - rec.startedAt)
|
||||
: rec.durationMs != null && rec.state !== 'failed'
|
||||
? formatDuration(rec.durationMs)
|
||||
: null
|
||||
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
|
||||
rec.state === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
key={name}
|
||||
>
|
||||
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
@@ -126,11 +134,11 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
|
||||
)}
|
||||
key={idx}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
@@ -143,17 +151,17 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
|
||||
<ChevronRight className={clsx('transition-transform', showLogs && 'rotate-90')} size={12} />
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
|
||||
<Button onClick={() => void cancelInstall()} size="sm" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
@@ -167,29 +175,36 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
// spinner on the left; pending stays icon-less.
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground" />
|
||||
return <Check className="shrink-0 text-muted-foreground" size={13} />
|
||||
}
|
||||
|
||||
if (state === 'skipped') {
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
|
||||
return <Check className="shrink-0 text-muted-foreground/50" size={13} />
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
return <X size={13} className="shrink-0 text-destructive" />
|
||||
return <X className="shrink-0 text-destructive" size={13} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
if (ms < 1000) {return `${ms}ms`}
|
||||
|
||||
if (ms < 60000) {return `${(ms / 1000).toFixed(1)}s`}
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
// Live elapsed for a running stage: bare seconds under a minute, then m:ss.
|
||||
function formatElapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
|
||||
if (s < 60) {return `${s}s`}
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
return `${m}:${String(s - m * 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
@@ -22,6 +23,7 @@ export default function Success() {
|
||||
async function handleLaunch() {
|
||||
setError(null)
|
||||
setLaunching(true)
|
||||
|
||||
try {
|
||||
await launchHermesDesktop()
|
||||
// On success the installer exits — control never returns here.
|
||||
@@ -65,8 +67,8 @@ export default function Success() {
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="flex max-w-2xl items-start gap-2 text-sm">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0 text-destructive" />
|
||||
<div className="flex max-w-2xl items-start gap-2 text-sm" role="alert">
|
||||
<AlertCircle className="mt-0.5 shrink-0 text-destructive" size={16} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-destructive">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error}</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { startInstall } from '../store'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
@@ -79,12 +79,16 @@ export const $hermesHome = atom<string | null>(null)
|
||||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
||||
|
||||
if (total === 0) {return { done: 0, total: 0, fraction: 0 }}
|
||||
let done = 0
|
||||
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
||||
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') {done += 1}
|
||||
}
|
||||
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
@@ -99,7 +103,9 @@ function withStageState(
|
||||
error?: string
|
||||
): BootstrapStateModel {
|
||||
const existing = cur.stages[name]
|
||||
if (!existing) return cur
|
||||
|
||||
if (!existing) {return cur}
|
||||
|
||||
return {
|
||||
...cur,
|
||||
stages: {
|
||||
@@ -163,18 +169,21 @@ type BootstrapEvent =
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) return
|
||||
if (unlisten) {return}
|
||||
|
||||
// Dev-only isolated preview (see runFakeBoot): drive the screens in a plain
|
||||
// browser, no Tauri backend, no real install.
|
||||
const fake = fakeMode()
|
||||
|
||||
if (fake) {
|
||||
unlisten = () => {}
|
||||
$logPath.set('~/.hermes/logs/bootstrap-installer.log')
|
||||
$hermesHome.set('~/.hermes')
|
||||
$mode.set(fake === 'update' ? 'update' : 'install')
|
||||
|
||||
// Update auto-runs (it's a hand-off); install/failure wait for the welcome click.
|
||||
if (fake === 'update') void runFakeBoot('update')
|
||||
if (fake === 'update') {void runFakeBoot('update')}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,6 +194,7 @@ export async function initialize(): Promise<void> {
|
||||
invoke<string>('get_hermes_home'),
|
||||
invoke<AppMode>('get_mode')
|
||||
])
|
||||
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
$mode.set(mode)
|
||||
@@ -195,14 +205,17 @@ export async function initialize(): Promise<void> {
|
||||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
@@ -215,26 +228,34 @@ export async function initialize(): Promise<void> {
|
||||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'stage': {
|
||||
if (!cur.stages[payload.name]) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
$bootstrap.set(
|
||||
withStageState(cur, payload.name, payload.state, payload.durationMs, payload.error)
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
@@ -242,6 +263,7 @@ export async function initialize(): Promise<void> {
|
||||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
|
||||
// Install: show the "launch Hermes" success screen. Update: this is a
|
||||
// hand-off — the installer relaunches the desktop and exits within a
|
||||
// few hundred ms, so routing to success just flashes that screen
|
||||
@@ -249,7 +271,9 @@ export async function initialize(): Promise<void> {
|
||||
if ($mode.get() !== 'update') {
|
||||
$route.set('success')
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
@@ -258,6 +282,7 @@ export async function initialize(): Promise<void> {
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
|
||||
break
|
||||
}
|
||||
})
|
||||
@@ -276,10 +301,13 @@ export async function initialize(): Promise<void> {
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
const fake = fakeMode()
|
||||
|
||||
if (fake) {
|
||||
void runFakeBoot(fake === 'failure' ? 'failure' : 'install')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
@@ -297,8 +325,10 @@ export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
export async function startUpdate(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
void runFakeBoot('update')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
|
||||
// there's no welcome click. Reset + jump straight to progress, then let the
|
||||
// Rust side stream the synthetic update manifest.
|
||||
@@ -310,20 +340,23 @@ export async function startUpdate(): Promise<void> {
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
fakeCancelled = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
if (fakeMode()) throw new Error('Preview mode — launching is disabled.')
|
||||
if (fakeMode()) {throw new Error('Preview mode — launching is disabled.')}
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
if (!installRoot) throw new Error('no install root')
|
||||
|
||||
if (!installRoot) {throw new Error('no install root')}
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
if (fakeMode()) return
|
||||
if (fakeMode()) {return}
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
|
||||
@@ -341,8 +374,9 @@ export async function openLogDir(): Promise<void> {
|
||||
type FakeMode = 'install' | 'update' | 'failure'
|
||||
|
||||
function fakeMode(): FakeMode | null {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') return null
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {return null}
|
||||
const v = new URLSearchParams(window.location.search).get('fake')
|
||||
|
||||
return v === 'install' || v === 'update' || v === 'failure' ? v : null
|
||||
}
|
||||
|
||||
@@ -383,15 +417,18 @@ const fakeFail = (error: string) =>
|
||||
$bootstrap.set({ ...$bootstrap.get(), status: 'failed', error, currentStage: null })
|
||||
|
||||
async function runFakeBoot(kind: FakeMode): Promise<void> {
|
||||
if (fakeRunning) return
|
||||
if (fakeRunning) {return}
|
||||
fakeRunning = true
|
||||
fakeCancelled = false
|
||||
|
||||
try {
|
||||
const stages = kind === 'update' ? FAKE_UPDATE_STAGES : FAKE_INSTALL_STAGES
|
||||
|
||||
const cancelled = () => {
|
||||
if (!fakeCancelled) return false
|
||||
if (!fakeCancelled) {return false}
|
||||
fakeFail(kind === 'update' ? 'Update cancelled.' : 'Install cancelled.')
|
||||
$route.set('failure')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -412,14 +449,16 @@ async function runFakeBoot(kind: FakeMode): Promise<void> {
|
||||
const failAt = kind === 'failure' ? stages[Math.floor(stages.length / 2)]?.name : null
|
||||
|
||||
for (const s of stages) {
|
||||
if (cancelled()) return
|
||||
if (cancelled()) {return}
|
||||
fakeStage(s.name, 'running')
|
||||
|
||||
const durationMs = 700 + Math.floor(Math.random() * 2200)
|
||||
const lines = Math.max(2, Math.round(durationMs / 450))
|
||||
|
||||
for (let l = 0; l < lines; l++) {
|
||||
await sleep(durationMs / lines)
|
||||
if (cancelled()) return
|
||||
|
||||
if (cancelled()) {return}
|
||||
fakeLog(s.name, `[${s.name}] ${s.title.toLowerCase()} — step ${l + 1}/${lines}…`)
|
||||
}
|
||||
|
||||
@@ -427,15 +466,18 @@ async function runFakeBoot(kind: FakeMode): Promise<void> {
|
||||
fakeStage(s.name, 'failed', durationMs, 'Simulated failure for preview.')
|
||||
fakeFail('Simulated failure for preview (fake boot).')
|
||||
$route.set('failure')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fakeStage(s.name, 'succeeded', durationMs)
|
||||
}
|
||||
|
||||
$bootstrap.set({ ...$bootstrap.get(), status: 'completed', currentStage: null })
|
||||
|
||||
// Install lands on success; update stays on progress (the real updater
|
||||
// relaunches the desktop and exits from there).
|
||||
if (kind !== 'update') $route.set('success')
|
||||
if (kind !== 'update') {$route.set('success')}
|
||||
} finally {
|
||||
fakeRunning = false
|
||||
}
|
||||
|
||||
@@ -87,16 +87,7 @@ test('fresh bootstrap args include the packaged commit pin', () => {
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes'
|
||||
}),
|
||||
[
|
||||
'--dir',
|
||||
'/tmp/hermes-agent',
|
||||
'--hermes-home',
|
||||
'/tmp/hermes',
|
||||
'--branch',
|
||||
'main',
|
||||
'--commit',
|
||||
installStamp.commit
|
||||
]
|
||||
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main', '--commit', installStamp.commit]
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -573,15 +573,7 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = t
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({
|
||||
scriptPath,
|
||||
installerKind,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp,
|
||||
pinCommit
|
||||
}) {
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp, pinCommit }) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
|
||||
@@ -110,6 +110,7 @@ test('profileRemoteOverride treats a cloud entry as a remote override', () => {
|
||||
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://agent-1.agents.nousresearch.com',
|
||||
authMode: 'oauth',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Regression: the desktop Electron dependency must be an exact, consistent pin.
|
||||
*
|
||||
* The Windows desktop install failed at "Building desktop app" because Electron
|
||||
* changed its install mechanism mid patch-series:
|
||||
*
|
||||
* electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS)
|
||||
* electron 40.10.3 / 40.10.4 -> @electron/get@^5 +
|
||||
* @electron-internal/extract-zip@^1 (native napi)
|
||||
*
|
||||
* ``apps/desktop/package.json`` declared ``electronVersion: 40.9.3`` (the tested,
|
||||
* JS-extract build) but pinned the dependency loosely as ``electron: ^40.9.3``.
|
||||
* ``npm ci`` then resolved 40.10.3/40.10.4 — the new *native* extract-zip whose
|
||||
* win32-x64 binding fails to ``dlopen`` on some Windows hosts
|
||||
* (``ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node``).
|
||||
*
|
||||
* These tests lock the contract that prevents that drift, without hard-coding the
|
||||
* specific version (which is allowed to move):
|
||||
*
|
||||
* 1. the Electron dependency is an *exact* version (Electron Builder needs the
|
||||
* installed binary to match ``electronVersion`` / ``electronDist``), and
|
||||
* 2. the dependency, ``build.electronVersion``, and the resolved lockfile entry
|
||||
* all agree — so ``npm ci`` installs exactly what the build packages.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..')
|
||||
const DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json')
|
||||
const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json')
|
||||
|
||||
// An exact semver: digits.digits.digits with an optional prerelease/build tag,
|
||||
// but NO range operators (^ ~ > < = * x || spaces || -range).
|
||||
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
||||
|
||||
function desktopPkg(): Record<string, unknown> {
|
||||
assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`)
|
||||
|
||||
return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8'))
|
||||
}
|
||||
|
||||
function electronSpec(pkg: Record<string, unknown>): string {
|
||||
for (const section of ['dependencies', 'devDependencies'] as const) {
|
||||
const deps = (pkg[section] ?? {}) as Record<string, string>
|
||||
const spec = deps['electron']
|
||||
|
||||
if (spec) {
|
||||
return spec
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail('electron is not listed in apps/desktop dependencies')
|
||||
}
|
||||
|
||||
test('electron dependency is exactly pinned', () => {
|
||||
const spec = electronSpec(desktopPkg())
|
||||
assert.match(
|
||||
spec,
|
||||
EXACT_SEMVER,
|
||||
`electron must be pinned to an exact version, got "${spec}". ` +
|
||||
'A range (^/~) lets npm ci resolve a newer Electron whose postinstall ' +
|
||||
'may differ from the one the build was validated against.'
|
||||
)
|
||||
})
|
||||
|
||||
test('electron dependency matches build.electronVersion', () => {
|
||||
const pkg = desktopPkg()
|
||||
const spec = electronSpec(pkg)
|
||||
const build = (pkg.build ?? {}) as Record<string, unknown>
|
||||
const builderVersion = build.electronVersion as string | undefined
|
||||
assert.ok(builderVersion, 'build.electronVersion is missing')
|
||||
assert.equal(
|
||||
spec,
|
||||
builderVersion,
|
||||
`electron dependency ("${spec}") must equal build.electronVersion ` +
|
||||
`("${builderVersion}"); otherwise electron-builder packages a different ` +
|
||||
'version than npm installs into electronDist.'
|
||||
)
|
||||
})
|
||||
|
||||
test('lockfile resolves the pinned electron', () => {
|
||||
if (!fs.existsSync(ROOT_LOCK)) {
|
||||
return
|
||||
} // skip if lockfile not present
|
||||
const spec = electronSpec(desktopPkg())
|
||||
const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8'))
|
||||
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>
|
||||
|
||||
const resolved = Object.entries(packages)
|
||||
.filter(([key]) => key.endsWith('node_modules/electron'))
|
||||
.map(([, meta]) => meta.version)
|
||||
.filter((v): v is string => !!v)
|
||||
|
||||
assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json')
|
||||
|
||||
for (const v of resolved) {
|
||||
assert.equal(
|
||||
v,
|
||||
spec,
|
||||
`package-lock.json resolves electron to ${v}, but the pin is "${spec}"; ` +
|
||||
'run `npm install --package-lock-only` so `npm ci` stays consistent.'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -227,7 +227,10 @@ test('listBaseBranches: lists local branches and flags the default', async () =>
|
||||
|
||||
assert.deepEqual(names, [trunk, 'feature'].sort())
|
||||
// No remote → all local.
|
||||
assert.equal(branches.every(b => !b.isRemote), true)
|
||||
assert.equal(
|
||||
branches.every(b => !b.isRemote),
|
||||
true
|
||||
)
|
||||
// The trunk is flagged as the default.
|
||||
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
@@ -254,7 +257,11 @@ test('addWorktree: base param branches off a specified local branch', async () =
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'staging'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
|
||||
const result = await addWorktree(
|
||||
dir,
|
||||
{ base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' },
|
||||
'git'
|
||||
)
|
||||
|
||||
assert.equal(result.branch, 'new-from-staging')
|
||||
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
|
||||
@@ -262,3 +269,55 @@ test('addWorktree: base param branches off a specified local branch', async () =
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: base origin/main does not set up upstream tracking', async () => {
|
||||
// Two repos: a bare "remote" and a clone, so origin/main resolves as a
|
||||
// remote-tracking ref — the condition that triggers auto-tracking.
|
||||
const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-'))
|
||||
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-clone-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: cloneDir }).toString().trim()
|
||||
|
||||
try {
|
||||
// Seed the remote with a commit on main. Inline identity so it works
|
||||
// on CI runners with no global git config.
|
||||
execFileSync('git', ['init', '-b', 'main', remoteDir])
|
||||
execFileSync('git', [
|
||||
'-C',
|
||||
remoteDir,
|
||||
'-c',
|
||||
'user.email=hermes@localhost',
|
||||
'-c',
|
||||
'user.name=Hermes',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'root'
|
||||
])
|
||||
|
||||
// Clone so origin/main exists as a remote-tracking ref.
|
||||
execFileSync('git', ['clone', remoteDir, cloneDir])
|
||||
|
||||
const result = await addWorktree(
|
||||
cloneDir,
|
||||
{ base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' },
|
||||
'git'
|
||||
)
|
||||
|
||||
assert.equal(result.branch, 'feature-branch')
|
||||
|
||||
// The new branch must NOT have an upstream — like `git checkout origin/main
|
||||
// && git checkout -b feature-branch`, not `git worktree add -b … origin/main`.
|
||||
let hasUpstream = true
|
||||
|
||||
try {
|
||||
execFileSync('git', ['-C', result.path, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
|
||||
} catch {
|
||||
hasUpstream = false
|
||||
}
|
||||
|
||||
assert.equal(hasUpstream, false)
|
||||
} finally {
|
||||
fs.rmSync(remoteDir, { recursive: true, force: true })
|
||||
fs.rmSync(cloneDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -265,8 +265,15 @@ async function addWorktree(repoPath, options, gitBin) {
|
||||
} catch {
|
||||
// The fetch isn't mandatory, but it would be nice to do if possible.
|
||||
// If it's not possible, just use the local ref of the remote branch.
|
||||
// If it doesn't exist locally, we'll get an error anyways
|
||||
// If it doesn't exist locally, we'll get an error
|
||||
}
|
||||
|
||||
// When branching off a remote-tracking ref, git auto-sets up tracking
|
||||
// (e.g. `new-branch` → tracks `origin/main`). The user almost certainly
|
||||
// wants a standalone local branch — like `git checkout origin/main &&
|
||||
// git checkout -b new-branch` — not a branch silently wired to the
|
||||
// remote's upstream. `--no-track` prevents that.
|
||||
args.push('--no-track')
|
||||
}
|
||||
|
||||
args.push(base)
|
||||
@@ -371,11 +378,21 @@ async function listBaseBranches(repoPath, gitBin) {
|
||||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
|
||||
[
|
||||
'for-each-ref',
|
||||
'--format=%(refname:short)\t%(committerdate:iso)',
|
||||
'--sort=-committerdate',
|
||||
'refs/heads',
|
||||
'refs/remotes'
|
||||
],
|
||||
resolved
|
||||
)
|
||||
|
||||
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
|
||||
const remoteDefault = await gitLine(
|
||||
gitBin,
|
||||
['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
|
||||
resolved
|
||||
)
|
||||
const localDefault = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
@@ -65,7 +64,6 @@ import {
|
||||
} from './desktop-uninstall'
|
||||
import { installEmbedReferer } from './embed-referer'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { resolvePickerDefaultPath } from './wsl-path-bridge'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
import {
|
||||
@@ -84,7 +82,14 @@ import {
|
||||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import {
|
||||
addWorktree,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
switchBranch
|
||||
} from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
@@ -127,10 +132,16 @@ import {
|
||||
MIN_WIDTH as WINDOW_MIN_WIDTH
|
||||
} from './window-state'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
import {
|
||||
buildPathExtCandidates,
|
||||
chooseUpdaterArgs,
|
||||
getVenvSitePackagesEntries,
|
||||
resolveVenvHermesCommand
|
||||
} from './windows-hermes-path'
|
||||
import { readWindowsUserEnvVar } from './windows-user-env'
|
||||
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
|
||||
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
|
||||
import { resolvePickerDefaultPath } from './wsl-path-bridge'
|
||||
|
||||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||
|
||||
@@ -3597,9 +3608,35 @@ async function ensureRuntime(backend) {
|
||||
return backend
|
||||
}
|
||||
|
||||
// Assemble a single-file multipart/form-data body (FastAPI `UploadFile`
|
||||
// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no
|
||||
// FormData and the payload is one file — a dependency would be overkill.
|
||||
function multipartBody(upload) {
|
||||
const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}`
|
||||
const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_')
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n`
|
||||
),
|
||||
Buffer.from(upload.bytes),
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
])
|
||||
|
||||
return { body, contentType: `multipart/form-data; boundary=${boundary}` }
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const { body, contentType } = options.upload
|
||||
? multipartBody(options.upload)
|
||||
: {
|
||||
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
const parsed = new URL(url)
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
@@ -3615,7 +3652,7 @@ function fetchJson(url, token, options: any = {}) {
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type': contentType,
|
||||
'X-Hermes-Session-Token': token,
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
@@ -4576,14 +4613,13 @@ function buildApplicationMenu() {
|
||||
submenu: [
|
||||
IS_MAC
|
||||
? {
|
||||
accelerator: 'CommandOrControl+W',
|
||||
click: () => {
|
||||
if (previewShortcutActive) {
|
||||
sendClosePreviewRequested()
|
||||
} else {
|
||||
mainWindow?.close()
|
||||
}
|
||||
},
|
||||
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
|
||||
// menu before the web contents ever sees it (and registerAccelerator
|
||||
// false is a no-op on mac — electron#18295). Leaving it off lets the
|
||||
// `before-input-event` handler below intercept ⌘W and route it to the
|
||||
// renderer's close-active-tab. Clicking the item still closes the tab
|
||||
// (or window) via the same request.
|
||||
click: () => sendClosePreviewRequested(),
|
||||
label: 'Close'
|
||||
}
|
||||
: { role: 'quit' }
|
||||
@@ -4689,9 +4725,12 @@ function installDevToolsShortcut(window) {
|
||||
function installPreviewShortcut(window) {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = String(input.key || '').toLowerCase()
|
||||
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
|
||||
if (!isPreviewCloseShortcut || !previewShortcutActive) {
|
||||
// Always claim ⌘W here (the File>Close item deliberately has no
|
||||
// accelerator, so nothing else does). The renderer decides tab-vs-window
|
||||
// — no `previewShortcutActive` gate, so it works for every closeable tab.
|
||||
if (!isCloseTabShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5457,6 +5496,7 @@ function openPortalLoginWindow() {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (pollTimer) {
|
||||
@@ -5543,6 +5583,7 @@ async function discoverCloudAgents(org?: string) {
|
||||
const err = new Error(
|
||||
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
|
||||
) as any
|
||||
|
||||
err.needsCloudLogin = true
|
||||
throw err
|
||||
}
|
||||
@@ -5911,6 +5952,7 @@ function buildRemoteBlock(remoteUrl, authMode, token, org?: string) {
|
||||
authMode,
|
||||
token
|
||||
}
|
||||
|
||||
const orgValue = typeof org === 'string' ? org.trim() : ''
|
||||
|
||||
if (orgValue) {
|
||||
@@ -6906,6 +6948,7 @@ async function startHermes() {
|
||||
function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) {
|
||||
installPreviewShortcut(win)
|
||||
installDevToolsShortcut(win)
|
||||
|
||||
if (zoom) {
|
||||
installZoomShortcuts(win)
|
||||
// Re-apply persisted zoom on show/restore (Windows drops webContents zoom on
|
||||
@@ -6913,6 +6956,7 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {})
|
||||
installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))
|
||||
win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))
|
||||
}
|
||||
|
||||
installContextMenu(win)
|
||||
win.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
@@ -7870,6 +7914,12 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// session so the cookie attaches automatically. Token/local modes keep using
|
||||
// the static session-token header.
|
||||
if (connection.authMode === 'oauth') {
|
||||
// The OAuth path rides electron.net with JSON headers; multipart isn't
|
||||
// wired there. Fail loudly rather than corrupting the upload.
|
||||
if (request?.upload) {
|
||||
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
|
||||
}
|
||||
|
||||
return fetchJsonViaOauthSession(url, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
@@ -7880,6 +7930,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
return fetchJson(url, connection.token, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
upload: request?.upload,
|
||||
timeoutMs
|
||||
})
|
||||
})
|
||||
@@ -8380,6 +8431,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
|
||||
// `reveal` (which selects an existing item and silently no-ops on a missing
|
||||
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
|
||||
// which often doesn't exist on first use. `shell.openPath` returns '' on
|
||||
// success or an error string; both mkdir + openPath failures are surfaced.
|
||||
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
||||
const dir = String(dirPath || '').trim()
|
||||
|
||||
if (!dir) {
|
||||
return { ok: false, error: 'no path' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const error = await shell.openPath(path.normalize(dir))
|
||||
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
@@ -8466,9 +8539,7 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
|
||||
listBaseBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
|
||||
@@ -107,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
||||
|
||||
@@ -17,7 +17,12 @@ import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
import {
|
||||
buildPathExtCandidates,
|
||||
chooseUpdaterArgs,
|
||||
getVenvSitePackagesEntries,
|
||||
resolveVenvHermesCommand
|
||||
} from './windows-hermes-path'
|
||||
|
||||
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
|
||||
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
|
||||
|
||||
@@ -111,21 +111,25 @@ export function getVenvSitePackagesEntries(
|
||||
|
||||
const isWindows = opts.isWindows ?? process.platform === 'win32'
|
||||
|
||||
const directoryExists = opts.directoryExists ?? ((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const directoryExists =
|
||||
opts.directoryExists ??
|
||||
((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const readFile = opts.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
const readFile =
|
||||
opts.readFile ??
|
||||
((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
|
||||
|
||||
@@ -53,6 +53,7 @@ export function resolveDefaultWslDistro(): string {
|
||||
timeout: 2000,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
cachedDistro = parseDefaultDistro(out) || 'Ubuntu'
|
||||
} catch {
|
||||
cachedDistro = 'Ubuntu'
|
||||
|
||||
+21
-100
@@ -1,116 +1,37 @@
|
||||
import js from '@eslint/js'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import shared from '../../eslint.config.shared.mjs'
|
||||
import globals from 'globals'
|
||||
|
||||
const noopRule = {
|
||||
meta: { schema: [], type: 'problem' },
|
||||
create: () => ({})
|
||||
}
|
||||
|
||||
const customRules = {
|
||||
rules: {
|
||||
'no-process-cwd': noopRule,
|
||||
'no-process-env-top-level': noopRule,
|
||||
'no-sync-fs': noopRule,
|
||||
'no-top-level-dynamic-import': noopRule,
|
||||
'no-top-level-side-effects': noopRule
|
||||
}
|
||||
}
|
||||
|
||||
export default [
|
||||
...shared,
|
||||
{
|
||||
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
// Desktop is an Electron renderer — it legitimately uses browser globals
|
||||
// (window, document, etc). Re-add them here; the shared config omits
|
||||
// globals.browser so terminal-only workspaces (ui-tui) don't get them.
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
},
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
curly: ['error', 'all'],
|
||||
'no-fallthrough': ['error', { allowEmptyCase: true }],
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'padding-line-between-statements': [
|
||||
1,
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: [
|
||||
'block-like',
|
||||
'block',
|
||||
'return',
|
||||
'if',
|
||||
'class',
|
||||
'continue',
|
||||
'debugger',
|
||||
'break',
|
||||
'multiline-const',
|
||||
'multiline-let'
|
||||
],
|
||||
prev: '*'
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: '*',
|
||||
prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like']
|
||||
},
|
||||
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
|
||||
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
|
||||
{ blankLine: 'always', next: ['empty'], prev: 'export' },
|
||||
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
|
||||
],
|
||||
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
order: 'asc',
|
||||
type: 'natural'
|
||||
}
|
||||
],
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: { ...globals.node },
|
||||
sourceType: 'module'
|
||||
// THE PLUGIN FENCE: plugins speak @hermes/plugin-sdk (+ react), never `@/…`
|
||||
// internals — the same isolation a runtime-fetched published plugin gets,
|
||||
// enforced on bundled ones so the SDK surface stays honest and sufficient.
|
||||
files: ['src/plugins/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/*', '../*', '@hermes/shared'],
|
||||
message: 'Plugins import only @hermes/plugin-sdk (and react). Missing something? Add it to the SDK.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isMain } from './utils.mjs'
|
||||
@@ -28,6 +29,30 @@ const here = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = resolve(here, '..')
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
function makeExecutable(filePath) {
|
||||
chmodSync(filePath, 0o755)
|
||||
}
|
||||
|
||||
function patchUnixTerminalAsarPaths(destRoot) {
|
||||
const filePath = join(destRoot, 'lib', 'unixTerminal.js')
|
||||
if (!existsSync(filePath)) return
|
||||
|
||||
const source = readFileSync(filePath, 'utf8')
|
||||
const patched = source
|
||||
.replace(
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace(/app\\.asar(?!\\.unpacked)/, 'app.asar.unpacked');"
|
||||
)
|
||||
.replace(
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"helperPath = helperPath.replace(/node_modules\\.asar(?!\\.unpacked)/, 'node_modules.asar.unpacked');"
|
||||
)
|
||||
|
||||
if (patched !== source) {
|
||||
writeFileSync(filePath, patched)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate node-pty's package root via real module resolution, so this
|
||||
* works whether it's hoisted to a workspace root or local to this app.
|
||||
@@ -75,7 +100,11 @@ function copyBuildRelease(srcDir, destDir) {
|
||||
continue
|
||||
}
|
||||
if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) {
|
||||
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
|
||||
const destFile = join(destDir, entry.name)
|
||||
cpSync(join(srcDir, entry.name), destFile)
|
||||
if (entry.name === 'spawn-helper') {
|
||||
makeExecutable(destFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +249,7 @@ export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platfor
|
||||
|
||||
// lib/**/*.js — the JS surface node-pty's `main` points into.
|
||||
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
|
||||
patchUnixTerminalAsarPaths(destRoot)
|
||||
|
||||
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
|
||||
// *target* we're packaging, not necessarily the host running this script.
|
||||
@@ -239,8 +269,9 @@ export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platfor
|
||||
continue
|
||||
}
|
||||
if (entry.name === 'spawn-helper') {
|
||||
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
|
||||
chmodSync(join(destPrebuild, entry.name), 0o775)
|
||||
const destFile = join(destPrebuild, entry.name)
|
||||
cpSync(join(prebuildDir, entry.name), destFile)
|
||||
makeExecutable(destFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import fs, { existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -43,6 +44,19 @@ function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeFakeUnixTerminal(srcRoot) {
|
||||
fs.writeFileSync(
|
||||
join(srcRoot, 'lib', 'unixTerminal.js'),
|
||||
[
|
||||
"exports.resolveHelper = function (helperPath) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
' return helperPath;',
|
||||
'};'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
// ─── classifyNativeBinary tests ─────────────────────────────────────
|
||||
|
||||
test('classifyNativeBinary detects ELF as linux', () => {
|
||||
@@ -262,6 +276,66 @@ test('host-target: host build/Release IS staged for a matching target', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'host-target: staged node-pty resolves an already-unpacked helper and preserves executable helpers',
|
||||
async () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
const prebuildDir = join(srcRoot, 'prebuilds', `${process.platform}-${process.arch}`)
|
||||
const buildReleaseDir = join(srcRoot, 'build', 'Release')
|
||||
|
||||
makeFakeNodePty(srcRoot, {
|
||||
prebuildPlatform: process.platform,
|
||||
prebuildArch: process.arch
|
||||
})
|
||||
makeFakeUnixTerminal(srcRoot)
|
||||
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
|
||||
fs.writeFileSync(join(prebuildDir, 'spawn-helper'), 'prebuild helper')
|
||||
fs.writeFileSync(join(buildReleaseDir, 'spawn-helper'), 'build helper')
|
||||
fs.chmodSync(join(prebuildDir, 'spawn-helper'), 0o644)
|
||||
fs.chmodSync(join(buildReleaseDir, 'spawn-helper'), 0o644)
|
||||
|
||||
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
|
||||
|
||||
const stagedUnixTerminalUrl = pathToFileURL(join(destRoot, 'lib', 'unixTerminal.js'))
|
||||
stagedUnixTerminalUrl.searchParams.set('t', String(Date.now()))
|
||||
const stagedUnixTerminal = await import(stagedUnixTerminalUrl.href)
|
||||
const unpackedHelper = join(
|
||||
tmp,
|
||||
'Hermes.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'spawn-helper'
|
||||
)
|
||||
const nodeModulesUnpackedHelper = unpackedHelper.replace(
|
||||
`${path.sep}node_modules${path.sep}`,
|
||||
`${path.sep}node_modules.asar.unpacked${path.sep}`
|
||||
)
|
||||
|
||||
assert.equal(stagedUnixTerminal.resolveHelper(unpackedHelper), unpackedHelper)
|
||||
assert.equal(
|
||||
stagedUnixTerminal.resolveHelper(nodeModulesUnpackedHelper),
|
||||
nodeModulesUnpackedHelper
|
||||
)
|
||||
assert.equal(
|
||||
fs.statSync(join(destRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')).mode & 0o777,
|
||||
0o755
|
||||
)
|
||||
assert.equal(fs.statSync(join(destRoot, 'build', 'Release', 'spawn-helper')).mode & 0o777, 0o755)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test('validation rejects a staged binary with the wrong platform magic', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ICONS: Record<'files' | 'session', string> = {
|
||||
files: 'cloud-upload',
|
||||
session: 'comment-discussion'
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed affordance shown while files or a session are dragged over the chat
|
||||
* area. Always `pointer-events-none` so the drop lands on the real element
|
||||
* underneath and the drop-zone handler claims it — the overlay is purely visual.
|
||||
* Copy adapts to whatever is being dragged; the last kind is held through the
|
||||
* fade-out so the label doesn't blank.
|
||||
* The label names the outcome (attach files / link this chat); the last kind is
|
||||
* held through the fade-out so it doesn't blank.
|
||||
*/
|
||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||
const { t } = useI18n()
|
||||
const lastKind = useRef<'files' | 'session'>('files')
|
||||
const lastKind = useRef<DragKind>(kind)
|
||||
|
||||
if (kind) {
|
||||
lastKind.current = kind
|
||||
}
|
||||
|
||||
const resolvedKind = kind ?? lastKind.current
|
||||
const icon = ICONS[resolvedKind]
|
||||
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
|
||||
const shown = kind ?? lastKind.current
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
|
||||
kind ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
DROP_SHEET_CLASS,
|
||||
DROP_SHEET_BLUR_CLASS,
|
||||
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
{shown && (
|
||||
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
|
||||
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* 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.
|
||||
*/
|
||||
export function closeActiveTab(): boolean {
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
closeActiveTerminal()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if ($filePreviewTarget.get() || $previewTarget.get()) {
|
||||
closeActiveRightRailTab()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return closeWorkspaceTab()
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// Above the stack breakpoint but still cramped: the model pill sheds its label
|
||||
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
|
||||
// before the whole row has to stack. Progressive collapse: full pill → icon
|
||||
// pill → stacked.
|
||||
export const COMPOSER_COMPACT_PILL_PX = 440
|
||||
|
||||
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
||||
// vertical padding). Anything taller means the text wrapped to a second line,
|
||||
// which is when the composer should expand to the stacked layout.
|
||||
@@ -79,7 +85,5 @@ export function isPendingDraftPersistCurrent(
|
||||
pending: PendingDraftPersist | null,
|
||||
expected: PendingDraftPersist | null
|
||||
): boolean {
|
||||
return (
|
||||
pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
)
|
||||
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useComposerAttachmentProviders } from './contrib'
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
@@ -39,6 +40,9 @@ export function ContextMenu({
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
// easier to grow with search / descriptions, and no positioning math.
|
||||
const [snippetsOpen, setSnippetsOpen] = useState(false)
|
||||
// `composer.attachments` contributions — plugin/core-registered rows that
|
||||
// extend this menu through the same registry as every other surface.
|
||||
const attachmentProviders = useComposerAttachmentProviders()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,6 +94,18 @@ export function ContextMenu({
|
||||
{c.promptSnippets}
|
||||
</ContextMenuItem>
|
||||
|
||||
{attachmentProviders.length > 0 && <DropdownMenuSeparator />}
|
||||
{attachmentProviders.map(provider => (
|
||||
<DropdownMenuItem
|
||||
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
|
||||
key={provider.key}
|
||||
onSelect={() => void provider.run({ insertText: onInsertText })}
|
||||
>
|
||||
<Codicon name={provider.icon ?? 'plug'} size="0.875rem" />
|
||||
<span>{provider.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib'
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
|
||||
function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) {
|
||||
disposers.push(
|
||||
registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware })
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
disposers.splice(0).forEach(d => d())
|
||||
})
|
||||
|
||||
describe('runComposerMiddleware', () => {
|
||||
it('passes the draft through untouched when nothing is registered', async () => {
|
||||
const draft = { text: 'hello' }
|
||||
|
||||
expect(await runComposerMiddleware(draft)).toBe(draft)
|
||||
})
|
||||
|
||||
it('chains rewrites in registry order', async () => {
|
||||
addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20)
|
||||
addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10)
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' })
|
||||
})
|
||||
|
||||
it('cancels the send when a handler returns null', async () => {
|
||||
addMiddleware('gate', () => null)
|
||||
addMiddleware('later', d => ({ ...d, text: 'never' }), 99)
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'x' })).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a throwing handler as pass-through', async () => {
|
||||
addMiddleware('boom', () => {
|
||||
throw new Error('broken plugin')
|
||||
})
|
||||
addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99)
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' })
|
||||
})
|
||||
|
||||
it('supports async handlers', async () => {
|
||||
addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() }))
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Composer contribution surface — every seam of the composer is hook-into-able
|
||||
* through the SAME registry schema as every other surface (statusbar, titlebar,
|
||||
* panes, layouts):
|
||||
*
|
||||
* render areas (`render`): composer.top — banner strip above the input
|
||||
* composer.bottom — row below the input grid
|
||||
* composer.leading — inline after the "+" menu
|
||||
* composer.actions — inline before the model pill
|
||||
*
|
||||
* data kinds (`data`): composer.middleware (ComposerMiddleware)
|
||||
* composer.attachments (ComposerAttachmentProvider)
|
||||
*
|
||||
* Core keeps ownership of the transcript, input, and submit engine — these
|
||||
* seams AUGMENT the composer, they never replace it. Middleware runs as an
|
||||
* ordered async chain around the app's onSubmit: each handler may rewrite the
|
||||
* draft, pass it through, or cancel the send by returning null.
|
||||
*/
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
export const COMPOSER_AREAS = {
|
||||
top: 'composer.top',
|
||||
bottom: 'composer.bottom',
|
||||
leading: 'composer.leading',
|
||||
actions: 'composer.actions',
|
||||
middleware: 'composer.middleware',
|
||||
attachments: 'composer.attachments'
|
||||
} as const
|
||||
|
||||
export interface ComposerDraft {
|
||||
text: string
|
||||
attachments?: ComposerAttachment[]
|
||||
}
|
||||
|
||||
/** Payload of a `composer.middleware` data contribution. */
|
||||
export interface ComposerMiddleware {
|
||||
/** Rewrite (return a draft), pass through (same draft), or cancel (null). */
|
||||
handler: (draft: ComposerDraft) => ComposerDraft | null | Promise<ComposerDraft | null>
|
||||
}
|
||||
|
||||
export interface ComposerAttachmentContext {
|
||||
insertText: (text: string) => void
|
||||
}
|
||||
|
||||
/** Payload of a `composer.attachments` data contribution — an entry in the
|
||||
* composer's "+" attach menu. */
|
||||
export interface ComposerAttachmentProvider {
|
||||
label: string
|
||||
/** Codicon name for the menu row. Defaults to `plug`. */
|
||||
icon?: string
|
||||
run: (ctx: ComposerAttachmentContext) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the ordered middleware chain over a draft. Contributions execute in
|
||||
* registry order (`order`, then registration order); the first `null` wins
|
||||
* and cancels the send. A throwing handler is treated as pass-through so a
|
||||
* broken plugin can't eat messages.
|
||||
*/
|
||||
export async function runComposerMiddleware(draft: ComposerDraft): Promise<ComposerDraft | null> {
|
||||
let current = draft
|
||||
|
||||
for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) {
|
||||
const middleware = contribution.data as ComposerMiddleware | undefined
|
||||
|
||||
if (!middleware?.handler) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await middleware.handler(current)
|
||||
|
||||
if (next === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
current = next
|
||||
} catch {
|
||||
// Pass-through: a faulty middleware must never swallow the message.
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/** Attach-menu entries contributed by plugins/core, with stable render keys. */
|
||||
export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvider & { key: string }> {
|
||||
return useContributions(COMPOSER_AREAS.attachments)
|
||||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
|
||||
.filter(p => Boolean(p.label && p.run))
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
/** Composer routing key. The main chat is `'main'`, the edit composer
|
||||
* `'edit'`; scoped composers (session tiles) use `'tile:<id>'`. */
|
||||
export type ComposerTarget = 'edit' | 'main' | (string & {})
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
interface FocusDetail {
|
||||
@@ -76,6 +78,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
|
||||
activeTarget = target
|
||||
}
|
||||
|
||||
/** The composer that last held focus — the target `'active'` resolves to.
|
||||
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
|
||||
export const getActiveComposer = (): ComposerTarget => activeTarget
|
||||
|
||||
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
|
||||
|
||||
@@ -129,12 +135,14 @@ export const requestComposerSubmit = (
|
||||
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
|
||||
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
|
||||
|
||||
/** Toggle the active composer's voice conversation — the `composer.voice`
|
||||
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
|
||||
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
|
||||
/** Toggle ONE composer's voice conversation — the `composer.voice` hotkey
|
||||
* (Ctrl+B) reaches the composer that owns voice. Defaults to the active
|
||||
* composer so N tiles don't all flip together. */
|
||||
export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) })
|
||||
|
||||
export const onComposerVoiceToggleRequest = (handler: () => void) =>
|
||||
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
|
||||
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { clearComposerAttachments } from '@/store/composer'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
interface UseComposerBranchOptions {
|
||||
clearDraft: () => void
|
||||
cwd: null | string | undefined
|
||||
@@ -17,6 +18,8 @@ interface UseComposerBranchOptions {
|
||||
* projects store) is the only dependency; nothing about ChatBar's render.
|
||||
*/
|
||||
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Hand a worktree off to the controller: open a fresh session anchored there,
|
||||
// carrying the composer draft as its first turn. Clearing here means the draft
|
||||
// travels to the new session instead of getting stashed under this one.
|
||||
@@ -24,7 +27,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran
|
||||
(path: string) => {
|
||||
const text = draftRef.current
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
scope.attachments.clear()
|
||||
requestStartWorkSession(path, text)
|
||||
},
|
||||
[clearDraft, draftRef]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { isBrowsingHistory } from '@/store/composer-input-history'
|
||||
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../focus'
|
||||
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
|
||||
import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
interface UseComposerDraftArgs {
|
||||
@@ -50,6 +51,8 @@ export function useComposerDraft({
|
||||
}: UseComposerDraftArgs) {
|
||||
const aui = useAui()
|
||||
const composerRuntime = useComposerRuntime()
|
||||
// Which composer this is on the focus bus + which attachment set it owns.
|
||||
const { attachments: attachmentScope, target } = useComposerScope()
|
||||
|
||||
// Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
|
||||
// steerable-vs-slash), so typing within a line costs no render.
|
||||
@@ -98,8 +101,8 @@ export function useComposerDraft({
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
focusComposerInput(editorRef.current)
|
||||
markActiveComposer('main')
|
||||
}, [])
|
||||
markActiveComposer(target)
|
||||
}, [target])
|
||||
|
||||
const requestMainFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
@@ -155,14 +158,14 @@ export function useComposerDraft({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const offFocus = onComposerFocusRequest(target => {
|
||||
if (target === 'main') {
|
||||
const offFocus = onComposerFocusRequest(requested => {
|
||||
if (requested === target) {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'main') {
|
||||
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
|
||||
if (requested === target) {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
@@ -171,13 +174,13 @@ export function useComposerDraft({
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, inputDisabled])
|
||||
}, [appendExternalText, inputDisabled, target])
|
||||
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
|
||||
stashSessionDraft(scope, text, attachments)
|
||||
|
||||
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
||||
$composerAttachments.set(cloneAttachments(attachments))
|
||||
attachmentScope.$attachments.set(cloneAttachments(attachments))
|
||||
paintDraft(text, false)
|
||||
}
|
||||
|
||||
@@ -296,12 +299,12 @@ export function useComposerDraft({
|
||||
insertInlineRefsRef.current = insertInlineRefs
|
||||
|
||||
useEffect(() => {
|
||||
return onComposerInsertRefsRequest(({ refs, target }) => {
|
||||
if (target === 'main') {
|
||||
return onComposerInsertRefsRequest(({ refs, target: requested }) => {
|
||||
if (requested === target) {
|
||||
insertInlineRefsRef.current(refs)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [target])
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
import { type ComposerTarget, getActiveComposer } from '../focus'
|
||||
|
||||
interface UseComposerEscCancelOptions {
|
||||
awaitingInput: boolean
|
||||
busy: boolean
|
||||
onCancel: () => unknown
|
||||
/** This composer's focus-bus key. With N composers mounted (main + tiles),
|
||||
* only the active one's Esc cancels — otherwise every busy tile stops. */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +20,7 @@ interface UseComposerEscCancelOptions {
|
||||
* the window listener registered exactly once while still reading fresh
|
||||
* busy/awaitingInput/onCancel each press.
|
||||
*/
|
||||
export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) {
|
||||
export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
|
||||
// Intentional only: we bail if (a) the composer/another field already handled
|
||||
// Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable
|
||||
// (you're typing, not stopping), or (c) a dialog/popover is open — Esc must
|
||||
@@ -30,6 +35,12 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseCompo
|
||||
return
|
||||
}
|
||||
|
||||
// Only the focused composer cancels — otherwise every mounted busy tile
|
||||
// stops at once (and the winner would be mount-order arbitrary).
|
||||
if (getActiveComposer() !== target) {
|
||||
return
|
||||
}
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { $composerPoppedOut } from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
|
||||
interface UseComposerMetricsArgs {
|
||||
composerRef: RefObject<HTMLFormElement | null>
|
||||
@@ -24,10 +24,13 @@ interface UseComposerMetricsArgs {
|
||||
* Returns `stacked` (the only value the render needs).
|
||||
*/
|
||||
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
|
||||
compactPill: boolean
|
||||
stacked: boolean
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [tight, setTight] = useState(false)
|
||||
// Wider than `tight`: the pill goes icon-only before the row has to stack.
|
||||
const [compactPill, setCompactPill] = useState(false)
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
// Edge signals, not the live text: these only re-render when emptiness / the
|
||||
@@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
||||
const lastBucketedHeightRef = useRef(0)
|
||||
const lastBucketedSurfaceHeightRef = useRef(0)
|
||||
const lastTightRef = useRef<boolean | null>(null)
|
||||
const lastCompactPillRef = useRef<boolean | null>(null)
|
||||
|
||||
const syncComposerMetrics = useCallback(() => {
|
||||
const composer = composerRef.current
|
||||
@@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
||||
lastTightRef.current = nextTight
|
||||
setTight(nextTight)
|
||||
}
|
||||
|
||||
const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX
|
||||
|
||||
if (nextCompactPill !== lastCompactPillRef.current) {
|
||||
lastCompactPillRef.current = nextCompactPill
|
||||
setCompactPill(nextCompactPill)
|
||||
}
|
||||
}
|
||||
|
||||
// Expand once the input has actually wrapped past a single line. The
|
||||
@@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { stacked: expanded || narrow || tight }
|
||||
// Pill compacts on real width (tile/pane), OR when stacked for any reason
|
||||
// (viewport-narrow / wrapped) so the controls row never over-runs.
|
||||
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
import { useComposerPopoutGestures } from './use-popout-drag'
|
||||
|
||||
interface UseComposerPopoutOptions {
|
||||
@@ -25,7 +27,10 @@ interface UseComposerPopoutOptions {
|
||||
* window's composer out via the shared atom.
|
||||
*/
|
||||
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
|
||||
const popoutAllowed = !isSecondaryWindow()
|
||||
// The floating composer is a window-level singleton: only the main scope
|
||||
// (not tiles) in a primary window may pop out.
|
||||
const scope = useComposerScope()
|
||||
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
|
||||
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
|
||||
const popoutPosition = useStore($composerPopoutPosition)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
interface UseComposerQueueArgs {
|
||||
@@ -60,6 +61,7 @@ export function useComposerQueue({
|
||||
sessionId
|
||||
}: UseComposerQueueArgs) {
|
||||
const { t } = useI18n()
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Per-session slice (edge): re-renders only when THIS session's queue changes,
|
||||
// not on cross-session queue churn (the plain atom's map ref changes on every
|
||||
@@ -173,11 +175,11 @@ export function useComposerQueue({
|
||||
}
|
||||
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
scope.attachments.clear()
|
||||
triggerHaptic('selection')
|
||||
|
||||
return true
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draftRef])
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
|
||||
|
||||
// All queue drain paths share one lock + send-then-remove sequence.
|
||||
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
|
||||
|
||||
@@ -2,13 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
|
||||
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
||||
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { onComposerSubmitRequest } from '../focus'
|
||||
import { composerPlainText } from '../rich-editor'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
interface UseComposerSubmitArgs {
|
||||
@@ -71,6 +72,8 @@ export function useComposerSubmit({
|
||||
setComposerText,
|
||||
stashAt
|
||||
}: UseComposerSubmitArgs) {
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
|
||||
// === false) or throws, re-load + re-stash the draft so the words survive.
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
@@ -163,7 +166,7 @@ export function useComposerSubmit({
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
scope.attachments.clear()
|
||||
dispatchSubmit(text, submittedAttachments)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
|
||||
import type { ComposerTarget } from '../focus'
|
||||
import { onComposerVoiceToggleRequest } from '../focus'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
@@ -25,6 +26,9 @@ interface UseComposerVoiceArgs {
|
||||
onSubmit: ChatBarProps['onSubmit']
|
||||
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
|
||||
sessionId: string | null | undefined
|
||||
/** This composer's focus-bus key — voice toggles targeting another
|
||||
* composer (or the active one, when not us) are ignored. */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +46,8 @@ export function useComposerVoice({
|
||||
maxRecordingSeconds,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
sessionId
|
||||
sessionId,
|
||||
target
|
||||
}: UseComposerVoiceArgs) {
|
||||
const { t } = useI18n()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
@@ -122,7 +127,10 @@ export function useComposerVoice({
|
||||
}
|
||||
}, [conversation, disabled, voiceConversationActive])
|
||||
|
||||
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
|
||||
useEffect(
|
||||
() => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
|
||||
[target, toggleVoiceConversation]
|
||||
)
|
||||
|
||||
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
||||
// the gated toggle above).
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slot as ContribSlot } from '@/contrib/react/slot'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
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 { $composerAttachments } from '@/store/composer'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { removeQueuedPrompt } from '@/store/composer-queue'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { useTheme } from '@/themes'
|
||||
@@ -23,6 +23,7 @@ import { useTheme } from '@/themes'
|
||||
import { AttachmentList } from './attachments'
|
||||
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
|
||||
import { ComposerControls } from './controls'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
||||
import { markActiveComposer } from './focus'
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
normalizeComposerEditorDom,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { useComposerScope } from './scope'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
@@ -79,17 +81,36 @@ export function ChatBar({
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onSubmit: onSubmitProp,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
const attachments = useStore($composerAttachments)
|
||||
// Every send (typed, queued, voice) passes through the contributed
|
||||
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
|
||||
// exact pass-through, so surfaces without contributions are byte-identical.
|
||||
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
|
||||
async (value, options) => {
|
||||
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
|
||||
|
||||
if (!draft) {
|
||||
return false
|
||||
}
|
||||
|
||||
return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
|
||||
},
|
||||
[onSubmitProp]
|
||||
)
|
||||
|
||||
// Which live composer this instance IS (main | tile) — its attachment set,
|
||||
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
|
||||
const scope = useComposerScope()
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
// would discard a question the user may want to come back to. The blocking
|
||||
// prompt owns its own dismissal (Skip, Reject, dialog close).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
const awaitingInput = useStore(scope.$awaitingInput)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
// Status items (subagents, background processes) are keyed by the RUNTIME
|
||||
@@ -188,7 +209,7 @@ export function ChatBar({
|
||||
|
||||
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
|
||||
|
||||
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
@@ -197,8 +218,6 @@ export function ChatBar({
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
const showHelpHint = isHelpHint
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
const { steerDraft, submitDraft } = useComposerSubmit({
|
||||
@@ -267,7 +286,7 @@ export function ChatBar({
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
@@ -332,7 +351,7 @@ export function ChatBar({
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
@@ -506,11 +525,11 @@ export function ChatBar({
|
||||
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
loadIntoComposer(entry, $composerAttachments.get())
|
||||
loadIntoComposer(entry, scope.attachments.$attachments.get())
|
||||
}
|
||||
|
||||
return
|
||||
@@ -531,11 +550,11 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
loadIntoComposer(result.text, $composerAttachments.get())
|
||||
loadIntoComposer(result.text, scope.attachments.$attachments.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +662,7 @@ export function ChatBar({
|
||||
useComposerBranch({ clearDraft, cwd, draftRef })
|
||||
|
||||
// Global Esc-to-cancel when the chat (not the composer input) has focus.
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel })
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
|
||||
|
||||
const {
|
||||
conversation,
|
||||
@@ -663,7 +682,8 @@ export function ChatBar({
|
||||
maxRecordingSeconds,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
sessionId
|
||||
sessionId,
|
||||
target: scope.target
|
||||
})
|
||||
|
||||
const contextMenu = (
|
||||
@@ -685,7 +705,7 @@ export function ChatBar({
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut}
|
||||
compactModelPill={poppedOut || compactPill}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
level: conversation.level,
|
||||
@@ -741,7 +761,7 @@ export function ChatBar({
|
||||
}}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onFocus={() => markActiveComposer('main')}
|
||||
onFocus={() => markActiveComposer(scope.target)}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onKeyUp={handleEditorKeyUp}
|
||||
@@ -845,7 +865,7 @@ export function ChatBar({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{isHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
@@ -935,6 +955,10 @@ export function ChatBar({
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
{/* Contribution seams: banners above, a row below, inline
|
||||
additions beside the "+" menu and before the controls.
|
||||
All four render nothing until something contributes. */}
|
||||
<ContribSlot area={COMPOSER_AREAS.top} />
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
@@ -970,10 +994,17 @@ export function ChatBar({
|
||||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
)}
|
||||
>
|
||||
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
|
||||
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
||||
{contextMenu}
|
||||
<ContribSlot area={COMPOSER_AREAS.leading} />
|
||||
</div>
|
||||
<div className="min-w-0 [grid-area:input]">{input}</div>
|
||||
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
|
||||
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
|
||||
<ContribSlot area={COMPOSER_AREAS.actions} />
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
<ContribSlot area={COMPOSER_AREAS.bottom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
@@ -8,44 +9,22 @@ import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipEl
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
||||
/** MIME for an in-app session drag (sidebar row → composer). */
|
||||
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
|
||||
|
||||
/** A dragged sidebar session — carried in-memory by the pointer drag session
|
||||
* (session-drag.ts); sessions never ride native DnD. */
|
||||
export interface SessionDragPayload {
|
||||
id: string
|
||||
profile: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
|
||||
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
|
||||
transfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
export function dragHasSession(transfer: DataTransfer | null) {
|
||||
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
|
||||
}
|
||||
|
||||
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
|
||||
const raw = transfer?.getData(HERMES_SESSION_MIME)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
|
||||
|
||||
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
/** A session's friendly display label — its title, or a localized fallback. */
|
||||
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
|
||||
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
|
||||
|
||||
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
|
||||
* needs to resolve the link (session_search); label shows the friendly title. */
|
||||
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
|
||||
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
|
||||
}
|
||||
|
||||
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { type ComposerAttachmentScope, mainComposerScope } from '@/store/composer'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { $messages } from '@/store/session'
|
||||
|
||||
import type { ComposerTarget } from './focus'
|
||||
|
||||
/**
|
||||
* COMPOSER SCOPE — which live composer a ChatBar instance IS. The main chat's
|
||||
* ChatBar runs in the default scope (module-level attachment atom, focus-bus
|
||||
* target 'main', the active session's awaiting-input edge). A session tile
|
||||
* mounts its ChatBar under its own scope, so N composers coexist: separate
|
||||
* attachment chips, separate focus/insert routing, separate Esc semantics.
|
||||
*
|
||||
* Draft TEXT needs no scoping — it lives in each ChatBar's contentEditable +
|
||||
* draftRef and stashes per session key (`stashSessionDraft`), which already
|
||||
* differs per surface.
|
||||
*/
|
||||
export interface ComposerScope {
|
||||
/** This scope's "turn parked on user input" edge — gates Esc-to-stop. */
|
||||
$awaitingInput: ReadableAtom<boolean>
|
||||
attachments: ComposerAttachmentScope
|
||||
/** Only the main scope may pop out (the floating composer is a singleton). */
|
||||
popoutAllowed: boolean
|
||||
/** Imperative read of this scope's transcript (input-history browse) —
|
||||
* never subscribed, so streaming stays out of the composer's renders. */
|
||||
readMessages: () => ChatMessage[]
|
||||
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
|
||||
$awaitingInput: $activeSessionAwaitingInput,
|
||||
attachments: mainComposerScope,
|
||||
popoutAllowed: true,
|
||||
readMessages: () => $messages.get(),
|
||||
target: 'main'
|
||||
}
|
||||
|
||||
const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
|
||||
|
||||
export const ComposerScopeProvider = ComposerScopeContext.Provider
|
||||
|
||||
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)
|
||||
@@ -255,22 +255,46 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): {
|
||||
return { osDrops, inAppRefs }
|
||||
}
|
||||
|
||||
/** The composer these actions feed. Defaults to the main chat's scope;
|
||||
* session tiles pass their own so picks/drops/pastes land in THEIR chips. */
|
||||
interface ComposerActionsScope {
|
||||
add: (attachment: ComposerAttachment) => void
|
||||
remove: (id: string) => ComposerAttachment | null
|
||||
target: string
|
||||
}
|
||||
|
||||
const MAIN_ACTIONS_SCOPE: ComposerActionsScope = {
|
||||
add: addComposerAttachment,
|
||||
remove: removeComposerAttachment,
|
||||
target: 'main'
|
||||
}
|
||||
|
||||
interface ComposerActionsOptions {
|
||||
activeSessionId: string | null
|
||||
currentCwd: string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
scope?: ComposerActionsScope
|
||||
}
|
||||
|
||||
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
|
||||
const attachToMain = (attachment: ComposerAttachment) => {
|
||||
addComposerAttachment(attachment)
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
export function useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway,
|
||||
scope = MAIN_ACTIONS_SCOPE
|
||||
}: ComposerActionsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
|
||||
/** Add to this scope's composer and focus it. All sidebar/picker/drop
|
||||
* attach paths funnel through here. */
|
||||
const attachToMain = useCallback(
|
||||
(attachment: ComposerAttachment) => {
|
||||
scope.add(attachment)
|
||||
requestComposerFocus(scope.target)
|
||||
},
|
||||
[scope]
|
||||
)
|
||||
|
||||
const addTextToDraft = useCallback((text: string) => {
|
||||
requestComposerInsert(text, { mode: 'block' })
|
||||
}, [])
|
||||
@@ -288,21 +312,24 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
requestComposerInsert(refText, { mode: 'inline' })
|
||||
}, [])
|
||||
|
||||
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
|
||||
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
|
||||
? 'folder'
|
||||
: refText.startsWith('@url:')
|
||||
? 'url'
|
||||
: 'file'
|
||||
const addContextRefAttachment = useCallback(
|
||||
(refText: string, label?: string, detail?: string) => {
|
||||
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
|
||||
? 'folder'
|
||||
: refText.startsWith('@url:')
|
||||
? 'url'
|
||||
: 'file'
|
||||
|
||||
attachToMain({
|
||||
id: attachmentId(kind, refText),
|
||||
kind,
|
||||
label: label || refText.replace(/^@(file|folder|url):/, ''),
|
||||
detail,
|
||||
refText
|
||||
})
|
||||
}, [])
|
||||
attachToMain({
|
||||
id: attachmentId(kind, refText),
|
||||
kind,
|
||||
label: label || refText.replace(/^@(file|folder|url):/, ''),
|
||||
detail,
|
||||
refText
|
||||
})
|
||||
},
|
||||
[attachToMain]
|
||||
)
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
@@ -329,7 +356,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
})
|
||||
}
|
||||
},
|
||||
[currentCwd]
|
||||
[attachToMain, currentCwd]
|
||||
)
|
||||
|
||||
const insertContextPathInlineRef = useCallback(
|
||||
@@ -344,12 +371,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
return false
|
||||
}
|
||||
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
requestComposerInsertRefs([ref], { target: scope.target })
|
||||
requestComposerFocus(scope.target)
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
[currentCwd, scope.target]
|
||||
)
|
||||
|
||||
const attachContextFilePath = useCallback(
|
||||
@@ -371,7 +398,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
[attachToMain, currentCwd]
|
||||
)
|
||||
|
||||
const attachImagePath = useCallback(
|
||||
@@ -394,7 +421,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
const previewUrl = await attachmentPreviewDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
scope.add({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -404,7 +431,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
return true
|
||||
}
|
||||
},
|
||||
[copy.imagePreviewFailed]
|
||||
[attachToMain, copy.imagePreviewFailed, scope]
|
||||
)
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
@@ -509,7 +536,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
[attachToMain, currentCwd]
|
||||
)
|
||||
|
||||
const attachDroppedItems = useCallback(
|
||||
@@ -599,7 +626,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
async (id: string) => {
|
||||
const removed = removeComposerAttachment(id)
|
||||
const removed = scope.remove(id)
|
||||
|
||||
if (
|
||||
removed?.kind === 'image' &&
|
||||
@@ -614,7 +641,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
[activeSessionId, requestGateway, scope]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,53 +1,75 @@
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
dragHasAttachments,
|
||||
dragHasSession,
|
||||
readSessionDrag,
|
||||
type SessionDragPayload
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
|
||||
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
|
||||
|
||||
/** `'session'` is set by callers from the pointer drag session's store —
|
||||
* native drags only ever resolve to `'files'` here (sessions left native
|
||||
* DnD; see session-drag.ts). */
|
||||
export type DragKind = 'files' | 'session' | null
|
||||
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind => {
|
||||
if (dragHasSession(event.dataTransfer)) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return 'files'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind =>
|
||||
dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null
|
||||
|
||||
interface FileDropZoneOptions {
|
||||
/** When false the zone ignores drags entirely. */
|
||||
enabled?: boolean
|
||||
onDropFiles: (files: DroppedFile[]) => void
|
||||
onDropSession?: (session: SessionDragPayload) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Drop anywhere in this region" affordance for files *and* in-app session
|
||||
* links. An enter/leave depth counter keeps nested children from flickering the
|
||||
* "Drop anywhere in this region" affordance for FILE drags — the one drag
|
||||
* kind still on native DnD (Finder/OS drops and the project tree must be).
|
||||
* An enter/leave depth counter keeps nested children from flickering the
|
||||
* active state; `onDropCapture` clears it even when a nested target (the
|
||||
* composer) handles the drop and stops propagation before our bubble-phase
|
||||
* `onDrop` would fire.
|
||||
*
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
|
||||
* Esc aborts an in-flight drag, matching the sidebar session drag.
|
||||
*/
|
||||
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
|
||||
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
|
||||
const [dragKind, setDragKind] = useState<DragKind>(null)
|
||||
const depth = useRef(0)
|
||||
const aborted = useRef(false)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0
|
||||
setDragKind(null)
|
||||
}, [])
|
||||
|
||||
// Esc aborts a file drag — the same "never mind" a session drag gets. Native
|
||||
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
|
||||
// guard that swallows the trailing drop instead. Top escape layer + capture
|
||||
// stop so it doesn't also fire a handler behind the drag (see drag-session).
|
||||
useEffect(() => {
|
||||
if (dragKind === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
aborted.current = true
|
||||
reset()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [dragKind, reset])
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
@@ -57,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
// A genuinely new drag (not a nested-child re-enter) re-arms after abort.
|
||||
if (depth.current === 0) {
|
||||
aborted.current = false
|
||||
}
|
||||
|
||||
depth.current += 1
|
||||
setDragKind(kind)
|
||||
},
|
||||
@@ -89,16 +117,17 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
||||
return
|
||||
}
|
||||
|
||||
// Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The
|
||||
// file tree's app-wide react-dnd HTML5Backend preventDefaults every native
|
||||
// file drop in the capture phase, so that flag is always set here (every
|
||||
// Finder drop would no-op). Genuine nested targets claim via stopPropagation
|
||||
// and never reach this bubble handler anyway.
|
||||
const claimed = aborted.current
|
||||
|
||||
event.preventDefault()
|
||||
reset()
|
||||
|
||||
if (kind === 'session') {
|
||||
const session = readSessionDrag(event.dataTransfer)
|
||||
|
||||
if (session) {
|
||||
onDropSession?.(session)
|
||||
}
|
||||
|
||||
if (claimed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,7 +137,7 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
||||
onDropFiles(files)
|
||||
}
|
||||
},
|
||||
[enabled, onDropFiles, onDropSession, reset]
|
||||
[enabled, onDropFiles, reset]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import {
|
||||
type AppendMessage,
|
||||
AssistantRuntimeProvider,
|
||||
ExportedMessageRepository,
|
||||
type ThreadMessage
|
||||
} from '@assistant-ui/react'
|
||||
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo, useRef } from 'react'
|
||||
import { Suspense, useCallback, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||
import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
coalesceToolOnlyAssistants,
|
||||
createToolMergeCache,
|
||||
quickModelOptions,
|
||||
sessionTitle,
|
||||
toRuntimeMessage
|
||||
} from '@/lib/chat-runtime'
|
||||
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
@@ -35,23 +25,14 @@ import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$contextSuggestions,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$introPersonality,
|
||||
$introSeed,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$resumeExhaustedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
|
||||
@@ -63,12 +44,15 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitle
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||
import { requestComposerInsert } from './composer/focus'
|
||||
import { droppedFileInlineRefs } from './composer/inline-refs'
|
||||
import { useComposerScope } from './composer/scope'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { useRuntimeMessageRepository } from './runtime-repository'
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { useSessionView } from './session-view'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { threadLoadingState } from './thread-loading'
|
||||
|
||||
@@ -122,7 +106,7 @@ function ChatHeader({
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
const activeStoredSession =
|
||||
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
|
||||
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
|
||||
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
|
||||
@@ -160,14 +144,7 @@ function ChatHeader({
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
|
||||
</Button>
|
||||
<TitleMenuTrigger>{title}</TitleMenuTrigger>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</header>
|
||||
@@ -207,45 +184,9 @@ function ChatRuntimeBoundary({
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore($messages)
|
||||
const storeMessages = useStore(useSessionView().$messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const toolMergeCacheRef = useRef(createToolMergeCache())
|
||||
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
const branchParentByGroup = new Map<string, string | null>()
|
||||
let visibleParentId: string | null = null
|
||||
let headId: string | null = null
|
||||
|
||||
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
|
||||
let parentId = visibleParentId
|
||||
|
||||
if (message.role === 'assistant' && message.branchGroupId) {
|
||||
if (!branchParentByGroup.has(message.branchGroupId)) {
|
||||
branchParentByGroup.set(message.branchGroupId, visibleParentId)
|
||||
}
|
||||
|
||||
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
|
||||
}
|
||||
|
||||
const cachedMessage = runtimeMessageCacheRef.current.get(message)
|
||||
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
|
||||
|
||||
if (!cachedMessage) {
|
||||
runtimeMessageCacheRef.current.set(message, runtimeMessage)
|
||||
}
|
||||
|
||||
items.push({ message: runtimeMessage, parentId })
|
||||
|
||||
if (!message.hidden) {
|
||||
visibleParentId = message.id
|
||||
headId = message.id
|
||||
}
|
||||
}
|
||||
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
@@ -293,13 +234,24 @@ export function ChatView({
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
// The view this surface renders: the primary route-driven session (global
|
||||
// atoms) or a tile's session slice — same component either way.
|
||||
const view = useSessionView()
|
||||
const composerScope = useComposerScope()
|
||||
const isPrimary = view.kind === 'primary'
|
||||
const activeSessionId = useStore(view.$runtimeId)
|
||||
const storedId = useStore(view.$storedId)
|
||||
// Dock anchor for a session drop onto this surface: the workspace pane for the
|
||||
// primary, this tile's pane id for a tile. Read by the session-drop bridge.
|
||||
const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}`
|
||||
const awaitingResponse = useStore(view.$awaitingResponse)
|
||||
const busy = useStore(view.$busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
// Per-session (SessionView) reads — a tile IS its session, so these come
|
||||
// from the view slice, not the global atoms (which track the primary only).
|
||||
const currentCwd = useStore(view.$cwd)
|
||||
const currentModel = useStore(view.$model)
|
||||
const currentProvider = useStore(view.$provider)
|
||||
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
|
||||
const petActive = useStore($petActive)
|
||||
const petOverlayActive = useStore($petOverlayActive)
|
||||
@@ -310,16 +262,18 @@ export function ChatView({
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
|
||||
// every streaming delta flush (~30×/s) and a subscription here re-renders
|
||||
// the entire chat shell (header, chat bar, thread wrapper) per token. The
|
||||
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
|
||||
// this component only needs streaming-stable derivations.
|
||||
const messagesEmpty = useStore($messagesEmpty)
|
||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
// PERF: ChatView must not subscribe to the view's $messages — the atom is
|
||||
// replaced on every streaming delta flush (~30×/s) and a subscription here
|
||||
// re-renders the entire chat shell (header, chat bar, thread wrapper) per
|
||||
// token. The runtime that DOES need the messages lives in
|
||||
// ChatRuntimeBoundary below; this component only needs streaming-stable
|
||||
// derivations.
|
||||
const messagesEmpty = useStore(view.$messagesEmpty)
|
||||
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
|
||||
const selectedSessionId = useStore(view.$storedId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
// A tile IS its session — no route involved, never "mismatched".
|
||||
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
||||
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
|
||||
@@ -331,6 +285,7 @@ export function ChatView({
|
||||
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
|
||||
// scratch window, not the full-height empty state.
|
||||
const showIntro =
|
||||
isPrimary &&
|
||||
!isSecondaryWindow() &&
|
||||
freshDraftReady &&
|
||||
!isRoutedSessionView &&
|
||||
@@ -349,7 +304,7 @@ export function ChatView({
|
||||
// Suppress the loader and show an explicit error + manual Retry instead of
|
||||
// spinning forever. Gated on the route matching so a stale latch from another
|
||||
// session can't blank the current one.
|
||||
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
|
||||
const resumeExhausted = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
|
||||
|
||||
const loadingSession =
|
||||
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||
@@ -420,23 +375,33 @@ export function ChatView({
|
||||
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
|
||||
|
||||
if (refs.length) {
|
||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: composerScope.target })
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
void onAttachDroppedItems(osDrops)
|
||||
}
|
||||
},
|
||||
[currentCwd, onAttachDroppedItems]
|
||||
[composerScope.target, currentCwd, onAttachDroppedItems]
|
||||
)
|
||||
|
||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||
// via session_search (carries the source profile, so cross-profile works).
|
||||
const onDropSession = useCallback((session: SessionDragPayload) => {
|
||||
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
|
||||
}, [])
|
||||
// Session drags are POINTER drags (session-drag.ts) — never native DnD.
|
||||
// The drop zone below only handles files; session drops commit through the
|
||||
// drag session itself, which routes a center/link drop to this surface's
|
||||
// composer via `data-composer-target`.
|
||||
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
|
||||
|
||||
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
|
||||
// While a session drag targets one of this surface's EDGES or a tab strip,
|
||||
// the zone overlay/caret owns the visual — the link overlay stands down.
|
||||
// It shows for the whole drag on every chat surface otherwise (the drag
|
||||
// session's global sentinel, not a per-surface hover chain).
|
||||
// COMPUTED booleans, never the raw `$dropHint`: the hint churns on every
|
||||
// pointer-crossing of every drag (pane drags included), and a re-render
|
||||
// here is the WHOLE surface — thread, composer, header — per mounted tile.
|
||||
const sessionDragging = useStore($sessionTileDragging)
|
||||
const sessionEdgeHover = useStore($sessionTileEdgeHover)
|
||||
|
||||
const overlayKind: DragKind = dragKind === 'files' ? 'files' : sessionDragging && !sessionEdgeHover ? 'session' : null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -444,17 +409,26 @@ export function ChatView({
|
||||
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
|
||||
className
|
||||
)}
|
||||
data-composer-target={composerScope.target}
|
||||
data-session-anchor={sessionAnchor}
|
||||
>
|
||||
<Backdrop />
|
||||
<ChatHeader
|
||||
activeSessionId={activeSessionId}
|
||||
isRoutedSessionView={isRoutedSessionView}
|
||||
onDeleteSelectedSession={onDeleteSelectedSession}
|
||||
onToggleSelectedPin={onToggleSelectedPin}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
{/* Tiles get their chrome from the layout zone (chip strip); the modal
|
||||
prompt overlays stay active-session-scoped in the primary surface. */}
|
||||
{isPrimary && (
|
||||
<ChatHeader
|
||||
activeSessionId={activeSessionId}
|
||||
isRoutedSessionView={isRoutedSessionView}
|
||||
onDeleteSelectedSession={onDeleteSelectedSession}
|
||||
onToggleSelectedPin={onToggleSelectedPin}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PromptOverlays />
|
||||
{/* Mounted for the primary AND every tile, each scoped to its own session
|
||||
so a tiled/background session's blocking prompt surfaces instead of
|
||||
stalling to timeout. */}
|
||||
<PromptOverlays sessionId={activeSessionId} />
|
||||
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
@@ -510,7 +484,9 @@ export function ChatView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
{/* A session drag hovering an EDGE hands the visual to the zone
|
||||
target; the link overlay shows only for the center region. */}
|
||||
<ChatDropOverlay kind={overlayKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Mirror a reactive list of "tiles" into layout-tree pane contributions:
|
||||
* register a pane per tile, refresh its title in place, and dispose panes whose
|
||||
* tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set
|
||||
* diff, a one-time pane closer — behind BOTH session tiles and route (page)
|
||||
* tiles; each supplies only what differs (key, title, render, close, edge).
|
||||
*/
|
||||
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { TileDock } from '@/store/session-states'
|
||||
|
||||
export interface PaneMirror<T> {
|
||||
/** Reactive source list. */
|
||||
source: ReadableAtom<T[]>
|
||||
/** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */
|
||||
also?: ReadableAtom<unknown>[]
|
||||
/** Stable key + pane-id seed for a tile. */
|
||||
key: (tile: T) => string
|
||||
/** Pane-id namespace — the id is `${prefix}:${key}`. */
|
||||
prefix: string
|
||||
/** Dock on adoption (default right; `center` = stack into anchor's zone). */
|
||||
dir?: (tile: T) => TileDock | undefined
|
||||
/** Pane to dock against (default `workspace`) — a drop's target zone. */
|
||||
anchor?: (tile: T) => string | undefined
|
||||
/** Center docks: the strip slot (stack before this pane id). */
|
||||
before?: (tile: T) => null | string | undefined
|
||||
minWidth: string
|
||||
title: (key: string) => string
|
||||
render: (key: string) => ReactNode
|
||||
/** Wrap the tile's TAB (domain context menu — session verbs). */
|
||||
tabWrap?: (key: string, tab: ReactElement) => ReactNode
|
||||
/** Override the tile's TAB drag (session drop language: stack/split/link).
|
||||
* Returns whether it took the drag (see PaneChrome.tabDrag). */
|
||||
tabDrag?: (
|
||||
key: string,
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
onTap: () => void,
|
||||
double?: DoubleTapContext
|
||||
) => boolean
|
||||
/** Wired as the pane's closer (tab Close). */
|
||||
close: (key: string) => void
|
||||
}
|
||||
|
||||
/** 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 }>()
|
||||
const paneId = (key: string) => `${cfg.prefix}:${key}`
|
||||
|
||||
const sync = () => {
|
||||
const tiles = cfg.source.get()
|
||||
const wanted = new Set(tiles.map(cfg.key))
|
||||
|
||||
for (const tile of tiles) {
|
||||
const key = cfg.key(tile)
|
||||
const title = cfg.title(key)
|
||||
const current = registered.get(key)
|
||||
|
||||
// register() replaces same-id in place — safe for live title refreshes.
|
||||
if (current && current.title === title) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dispose = registry.register({
|
||||
id: paneId(key),
|
||||
area: 'panes',
|
||||
title,
|
||||
data: {
|
||||
dock: {
|
||||
before: cfg.before?.(tile),
|
||||
pane: cfg.anchor?.(tile) ?? 'workspace',
|
||||
pos: cfg.dir?.(tile) ?? 'right'
|
||||
},
|
||||
minWidth: cfg.minWidth,
|
||||
placement: 'main',
|
||||
tabDrag: cfg.tabDrag
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
|
||||
cfg.tabDrag!(key, event, onTap, double)
|
||||
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
|
||||
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
|
||||
},
|
||||
render: () => cfg.render(key)
|
||||
})
|
||||
|
||||
registered.set(key, { dispose, title })
|
||||
|
||||
if (!current) {
|
||||
registerPaneCloser(paneId(key), () => cfg.close(key))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of registered) {
|
||||
if (!wanted.has(key)) {
|
||||
entry.dispose()
|
||||
registered.delete(key)
|
||||
removeTreePane(paneId(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Prune tree panes the SHARED tree persisted for a tile we never registered
|
||||
// this session and that isn't wanted now — a profile switch reloads with the
|
||||
// other profile's tile panes still stacked in. (`registered` is empty after a
|
||||
// reload, so the loop above can't catch these.)
|
||||
for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) {
|
||||
if (!wanted.has(id.slice(cfg.prefix.length + 1))) {
|
||||
removeTreePane(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
sync()
|
||||
cfg.source.listen(sync)
|
||||
cfg.also?.forEach(atom => atom.listen(sync))
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
|
||||
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from './preview'
|
||||
|
||||
@@ -498,8 +498,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Insert into and focus the SAME composer — 'active' — so a tile that owns
|
||||
// focus keeps it instead of the ref landing in a tile but main stealing focus.
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
requestComposerFocus('active')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { PANE_TAB_STRIP_LINE, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
@@ -38,14 +39,6 @@ import { PreviewPane } from './preview-pane'
|
||||
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
|
||||
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
|
||||
|
||||
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
|
||||
|
||||
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
|
||||
// against --chat-min-width so the chat surface never gets squeezed below it.
|
||||
// Subtracts the project browser width so preview yields rather than crushing
|
||||
// the chat when both right-side panes are open.
|
||||
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
|
||||
|
||||
interface ChatPreviewRailProps {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
@@ -110,7 +103,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
|
||||
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
|
||||
>
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
className={cn(
|
||||
'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)',
|
||||
PANE_TAB_STRIP_LINE
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
@@ -124,67 +122,20 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
return (
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<button
|
||||
<PaneTabLabel
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
as="button"
|
||||
className="normal-case tracking-normal"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
{tab.label}
|
||||
</PaneTabLabel>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
{dirty && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
|
||||
>
|
||||
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
|
||||
legible where it overlaps the filename. */}
|
||||
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
</PaneTab>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* ROUTE (PAGE) TILES — a full-page view rendered as a layout-tree pane BESIDE
|
||||
* the main thread, the page analog of session tiles. Built-in pages
|
||||
* (Capabilities / Messaging / Artifacts) render their view; plugin pages render
|
||||
* their `ROUTES_AREA` contribution. Lifecycle mirrors session tiles:
|
||||
* `openRouteTile(path)` -> `watchRouteTiles` registers a pane docked beside
|
||||
* main -> tree adoption lands it on the chosen edge; closing removes it.
|
||||
*/
|
||||
|
||||
import { lazy, type ReactNode, Suspense } from 'react'
|
||||
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
|
||||
|
||||
import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
|
||||
|
||||
import { paneMirror } from './pane-mirror'
|
||||
|
||||
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
|
||||
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
|
||||
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
|
||||
|
||||
// Built-in page views + their pane titles, keyed by route.
|
||||
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
|
||||
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
|
||||
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
|
||||
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
|
||||
}
|
||||
|
||||
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
|
||||
const humanizePath = (path: string): string =>
|
||||
path
|
||||
.replace(/^\/+/, '')
|
||||
.split(/[/-]/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ') || path
|
||||
|
||||
/** Title for a route tile: the built-in name, the contribution's own `title`,
|
||||
* else a humanized path — never the internal `${source}:${id}` key. */
|
||||
function routeTitle(path: string): string {
|
||||
if (BUILTIN_PAGES[path]) {
|
||||
return BUILTIN_PAGES[path].title
|
||||
}
|
||||
|
||||
return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path)
|
||||
}
|
||||
|
||||
function RouteTilePane({ path }: { path: string }) {
|
||||
const builtin = BUILTIN_PAGES[path]
|
||||
|
||||
// Subscribe so a plugin page tile appears the moment its route registers.
|
||||
useContributions(ROUTES_AREA)
|
||||
const contrib = builtin ? null : contributedRoutes().find(r => r.path === path)
|
||||
|
||||
if (builtin) {
|
||||
return (
|
||||
<ContribBoundary id={path}>
|
||||
<Suspense fallback={null}>{builtin.render()}</Suspense>
|
||||
</ContribBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
if (contrib) {
|
||||
return <ContribBoundary id={path}>{contrib.render()}</ContribBoundary>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)">
|
||||
no page at {path}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */
|
||||
export const watchRouteTiles = paneMirror<RouteTile>({
|
||||
source: $routeTiles,
|
||||
key: t => t.path,
|
||||
prefix: 'route-tile',
|
||||
dir: t => t.dir,
|
||||
minWidth: '22rem',
|
||||
title: routeTitle,
|
||||
render: path => <RouteTilePane path={path} />,
|
||||
close: closeRouteTile
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { coalesceToolOnlyAssistants, createToolMergeCache, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
|
||||
/**
|
||||
* ChatMessage[] -> assistant-ui message repository, with a WeakMap identity
|
||||
* cache so unchanged messages convert once (and a tool-merge cache that folds
|
||||
* tool-only assistant turns into their neighbour). Shared by the main chat's
|
||||
* runtime boundary and session tiles — one transcript pipeline, N surfaces.
|
||||
*/
|
||||
export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMessageRepository {
|
||||
const cacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const toolMergeCacheRef = useRef(createToolMergeCache())
|
||||
|
||||
return useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
const branchParentByGroup = new Map<string, string | null>()
|
||||
let visibleParentId: string | null = null
|
||||
let headId: string | null = null
|
||||
|
||||
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
|
||||
let parentId = visibleParentId
|
||||
|
||||
if (message.role === 'assistant' && message.branchGroupId) {
|
||||
if (!branchParentByGroup.has(message.branchGroupId)) {
|
||||
branchParentByGroup.set(message.branchGroupId, visibleParentId)
|
||||
}
|
||||
|
||||
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
|
||||
}
|
||||
|
||||
const cachedMessage = cacheRef.current.get(message)
|
||||
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
|
||||
|
||||
if (!cachedMessage) {
|
||||
cacheRef.current.set(message, runtimeMessage)
|
||||
}
|
||||
|
||||
items.push({ message: runtimeMessage, parentId })
|
||||
|
||||
if (!message.hidden) {
|
||||
visibleParentId = message.id
|
||||
headId = message.id
|
||||
}
|
||||
}
|
||||
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Sidebar session drag — the session RESOLVER over the shared pointer drag
|
||||
* session (pane-shell drag-session.ts). Same machinery as a pane drag
|
||||
* (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous
|
||||
* teardown), session-specific targeting:
|
||||
*
|
||||
* - a chat zone's TAB STRIP → stack: open the session as a tab at the
|
||||
* divider's slot (the strip caret shows it);
|
||||
* - a chat zone's EDGE band → split: open the session as a tile docked on
|
||||
* that edge (the zone sheet morphs to the half);
|
||||
* - a chat zone's CENTER / the composer → link: insert an `@session` chip
|
||||
* into that surface's composer (ChatDropOverlay owns the visual);
|
||||
* - anything else (sidebar, terminal, gutters) → deny.
|
||||
*
|
||||
* Zones that don't host a chat surface are NOT targets — the overlay never
|
||||
* lights them, so a release there must not commit either (one truth).
|
||||
*
|
||||
* This replaced the native-HTML5 drag + SessionTileDropBridge: riding the
|
||||
* native DnD layer meant macOS's cancel snap-back animation, a `dragend`
|
||||
* held hostage until that animation finished, an Esc the page never even
|
||||
* saw, and window-level armor against react-dnd/dnd-kit. A pointer session
|
||||
* has none of those failure modes. Native DnD remains only at the true OS
|
||||
* boundary (Finder file drops). Known trade: a session can no longer be
|
||||
* dragged into a separate BrowserWindow (native DnD was the only transport
|
||||
* that crossed windows).
|
||||
*/
|
||||
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { findGroup } from '@/components/pane-shell/tree/model'
|
||||
import {
|
||||
type DoubleTapContext,
|
||||
rectContains,
|
||||
slotBefore,
|
||||
snapshotStrips,
|
||||
snapshotZones,
|
||||
startDragSession,
|
||||
type StripSnapshot,
|
||||
subZonePosition
|
||||
} from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
$treeDragging,
|
||||
type DropHint,
|
||||
revealTreePane,
|
||||
SESSION_TILE_DRAG
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine'
|
||||
import { openSessionTile, type TileDock } from '@/store/session-states'
|
||||
|
||||
import { requestComposerInsertRefs } from './composer/focus'
|
||||
import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs'
|
||||
|
||||
/** A chat surface's drag-start geometry: the anchor pane id it advertises
|
||||
* (`data-session-anchor`) and the composer a link drop routes to
|
||||
* (`data-composer-target`). */
|
||||
interface SurfaceSnapshot {
|
||||
anchor: string
|
||||
composerTarget: string
|
||||
rect: ZoneRect
|
||||
}
|
||||
|
||||
const snapRect = (el: HTMLElement): ZoneRect => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
|
||||
}
|
||||
|
||||
function snapshotSurfaces(): SurfaceSnapshot[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-session-anchor]')].map(el => ({
|
||||
anchor: el.dataset.sessionAnchor || 'workspace',
|
||||
composerTarget: el.dataset.composerTarget || 'main',
|
||||
rect: snapRect(el)
|
||||
}))
|
||||
}
|
||||
|
||||
/** A session may land in a zone only if it hosts a chat surface — never the
|
||||
* sidebar/terminal zones. Returns the pane a stack anchors to. */
|
||||
function chatZonePane(groupId: string): null | string {
|
||||
const tree = $layoutTree.get()
|
||||
const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : []
|
||||
|
||||
return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin dragging a session — a sidebar row OR a tile's own tab (same drop
|
||||
* language either way: stack, split, or composer link). Sub-threshold releases
|
||||
* stay ordinary clicks, so `opts.onTap` (activate the tile) and `opts.double`
|
||||
* (hide the tab bar) ride the tab's gestures; Esc aborts instantly. A stack/
|
||||
* split commits through `openSessionTile`, which OPENS a new tile from a sidebar
|
||||
* row and MOVES the existing one when its tab is the drag source.
|
||||
*/
|
||||
export function startSessionDrag(
|
||||
payload: SessionDragPayload,
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
opts?: { double?: DoubleTapContext; onTap?: () => void }
|
||||
) {
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
let surfaces: SurfaceSnapshot[] = []
|
||||
let composers: ZoneRect[] = []
|
||||
let zoneHost = new Map<string, null | string>()
|
||||
|
||||
// Commit intent, updated per resolved move (the machinery flushes the final
|
||||
// move before commit, so these always match the released-at position).
|
||||
let split: { anchor: string; before?: null | string; pos: TileDock } | null = null
|
||||
let link: null | string = null
|
||||
|
||||
// The drag SOURCE (sidebar row or tile tab). Captured synchronously — React
|
||||
// clears `currentTarget` after the pointerdown handler returns, but this runs
|
||||
// inside it. Dimmed while lifted so the source reads as "picked up" — the
|
||||
// same in-place feedback pane-tab drags use, replacing the old cursor chip.
|
||||
const source = e.currentTarget
|
||||
const restoreOpacity = source?.style.opacity ?? ''
|
||||
|
||||
startDragSession(e, {
|
||||
double: opts?.double,
|
||||
ghost: { label: sessionLabel(payload) },
|
||||
onTap: opts?.onTap,
|
||||
|
||||
onEngage() {
|
||||
zones = snapshotZones()
|
||||
strips = snapshotStrips()
|
||||
surfaces = snapshotSurfaces()
|
||||
composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect)
|
||||
zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)]))
|
||||
source?.style.setProperty('opacity', '0.45')
|
||||
// The same sentinel the zone overlay + chat surfaces key off — the
|
||||
// whole drop language (sheets, pills, caret, link overlay) lights up.
|
||||
$treeDragging.set(SESSION_TILE_DRAG)
|
||||
},
|
||||
|
||||
onEnd() {
|
||||
if (source) {
|
||||
source.style.opacity = restoreOpacity
|
||||
}
|
||||
},
|
||||
|
||||
resolveMove(x, y): DropHint | null {
|
||||
const zone = zones.find(z => rectContains(z.rect, x, y))
|
||||
const host = zone ? zoneHost.get(zone.id) : null
|
||||
|
||||
if (!zone || !host) {
|
||||
split = null
|
||||
link = null
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// The zone's TAB STRIP stacks the session at the divider's slot.
|
||||
const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y))
|
||||
|
||||
if (strip) {
|
||||
// Exclude the tile's OWN tab from the slots so re-dropping it in its
|
||||
// home strip reorders cleanly (a no-op for a sidebar-row drag).
|
||||
const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`)
|
||||
split = { anchor: host, before: stack.before, pos: 'center' }
|
||||
link = null
|
||||
|
||||
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack }
|
||||
}
|
||||
|
||||
// The composer (and everything in it) is always the link/attach drop;
|
||||
// elsewhere the shared radial targeting decides center vs edge.
|
||||
const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y)
|
||||
const surface = surfaces.find(s => rectContains(s.rect, x, y))
|
||||
|
||||
if (pos === 'center') {
|
||||
split = null
|
||||
link = surface?.composerTarget ?? 'main'
|
||||
} else {
|
||||
split = { anchor: surface?.anchor ?? 'workspace', pos }
|
||||
link = null
|
||||
}
|
||||
|
||||
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos }
|
||||
},
|
||||
|
||||
onCommit() {
|
||||
if (split) {
|
||||
openSessionTile(payload.id, split.pos, split.anchor, split.before)
|
||||
// A tile for this session may already exist (openSessionTile is
|
||||
// idempotent — e.g. persisted from an earlier run): a drop must never
|
||||
// feel dead, so front/unhide/un-dismiss it either way.
|
||||
revealTreePane(`session-tile:${payload.id}`)
|
||||
} else if (link) {
|
||||
// The "link to chat" drop: an @session chip in that surface's composer.
|
||||
requestComposerInsertRefs([sessionInlineRef(payload)], { target: link })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Prompt actions for a SESSION TILE — the same verbs the primary chat wires
|
||||
* (submit incl. slash, cancel, steer, edit, reload, restore, branch-hide
|
||||
* sync), targeted at the tile's session instead of the active one. State
|
||||
* writes go through the delegate's `updateSession` (the wiring cache), so
|
||||
* the cache, the primary view, and every tile mirror stay one truth; view
|
||||
* concerns (busy pill, transcript) reach the tile via its `$sessionStates`
|
||||
* slice — never the global `$busy`/`$messages`.
|
||||
*/
|
||||
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { clearAllPrompts } from '@/store/prompts'
|
||||
import { $connection } from '@/store/session'
|
||||
import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
|
||||
import {
|
||||
applyBranchVisibility,
|
||||
applyReloadOptimistic,
|
||||
applyRewindOptimistic,
|
||||
finalizeInterruptedMessages,
|
||||
planEdit,
|
||||
planReload,
|
||||
planRestore,
|
||||
runRewindSubmit
|
||||
} from '../session/hooks/use-prompt-actions/rewind'
|
||||
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
|
||||
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
|
||||
|
||||
import type { ComposerScope } from './composer/scope'
|
||||
|
||||
interface SessionTileActionsArgs {
|
||||
runtimeId: string
|
||||
scope: ComposerScope
|
||||
storedSessionId: string
|
||||
}
|
||||
|
||||
export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const runtimeIdRef = useRef(runtimeId)
|
||||
runtimeIdRef.current = runtimeId
|
||||
const storedIdRef = useRef(storedSessionId)
|
||||
storedIdRef.current = storedSessionId
|
||||
|
||||
// Tile busy tracks the SESSION state, never the global $busy — and it must
|
||||
// read LIVE. A render-time snapshot goes stale (this hook's host doesn't
|
||||
// re-render on busy edges), and a stale `true` silently blocks every
|
||||
// subsequent submit ("tile only sends one message"). The setter is a no-op:
|
||||
// session state owns busy; submit's optimistic writes flow through
|
||||
// updateSession.
|
||||
const busyRef = useMemo(
|
||||
() =>
|
||||
({
|
||||
get current() {
|
||||
return $sessionStates.get()[runtimeIdRef.current]?.busy ?? false
|
||||
},
|
||||
set current(_value: boolean) {
|
||||
// Owned by session state.
|
||||
}
|
||||
}) as { current: boolean },
|
||||
[]
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
(updater: (state: ClientSessionState) => ClientSessionState) =>
|
||||
sessionTileDelegate()?.updateSession(runtimeIdRef.current, updater),
|
||||
[]
|
||||
)
|
||||
|
||||
const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], [])
|
||||
const readMessages = useCallback(() => readState()?.messages ?? [], [readState])
|
||||
|
||||
// Tile-side attachment staging: same upload rules as the primary submit
|
||||
// (skip synced/pathless, byte-upload files+images), against the tile scope.
|
||||
const syncAttachmentsForSubmit = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
attachments: ComposerAttachment[],
|
||||
options: { updateComposerAttachments?: boolean } = {}
|
||||
): Promise<ComposerAttachment[]> => {
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
const synced: ComposerAttachment[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||
synced.push(attachment)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||
|
||||
if (options.updateComposerAttachments ?? true) {
|
||||
scope.attachments.update(next)
|
||||
}
|
||||
|
||||
synced.push(next)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
synced.push(attachment)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
[requestGateway, scope.attachments]
|
||||
)
|
||||
|
||||
// The REAL submit pipeline with tile seams: session always exists, and the
|
||||
// scope's writers replace the global view/attachment writes.
|
||||
const submitPromptText = useSubmitPrompt({
|
||||
activeSessionId: runtimeId,
|
||||
activeSessionIdRef: runtimeIdRef,
|
||||
busyRef,
|
||||
copy,
|
||||
createBackendSessionForSend: async () => runtimeIdRef.current,
|
||||
// A tile IS its session — no route to abandon, so the create-abort guard's
|
||||
// token is a stable constant (the guard never trips for a tile).
|
||||
getRouteToken: () => runtimeId,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef: storedIdRef,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater),
|
||||
scope: {
|
||||
clearAttachments: scope.attachments.clear,
|
||||
readAttachments: () => scope.attachments.$attachments.get(),
|
||||
// Busy/messages flow through updateSession -> the tile's state slice;
|
||||
// the primary view atoms must never see a tile turn.
|
||||
setAwaitingResponse: () => undefined,
|
||||
setBusy: () => undefined,
|
||||
setMessages: () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
const submitText = useCallback(
|
||||
async (rawText: string, options?: SubmitTextOptions) => {
|
||||
const visibleText = rawText.trim()
|
||||
const attachments = options?.attachments ?? scope.attachments.$attachments.get()
|
||||
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
|
||||
triggerHaptic('selection')
|
||||
await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return await submitPromptText(rawText, options)
|
||||
},
|
||||
[scope.attachments.$attachments, submitPromptText]
|
||||
)
|
||||
|
||||
const appendSystemNote = useCallback(
|
||||
(text: string) => {
|
||||
update(state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: 'system',
|
||||
parts: [textPart(text)]
|
||||
}
|
||||
]
|
||||
}))
|
||||
},
|
||||
[update]
|
||||
)
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = runtimeIdRef.current
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
messages: finalizeInterruptedMessages(state.messages, state.streamId),
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
needsInput: false,
|
||||
interrupted: true
|
||||
}))
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch (err) {
|
||||
notifyError(err, copy.stopFailed)
|
||||
}
|
||||
}, [copy.stopFailed, requestGateway, update])
|
||||
|
||||
const steerPrompt = useCallback(
|
||||
async (rawText: string): Promise<boolean> => {
|
||||
const text = rawText.trim()
|
||||
|
||||
if (!text) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ status?: string }>('session.steer', {
|
||||
session_id: runtimeIdRef.current,
|
||||
text
|
||||
})
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
triggerHaptic('submit')
|
||||
appendSystemNote(`steer:${text}`)
|
||||
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Swallow — the caller queues the text so nothing is lost.
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[appendSystemNote, requestGateway]
|
||||
)
|
||||
|
||||
// Rewind primitive (interrupt-first for live turns, busy-retry) — shared with
|
||||
// the primary chat so the two can't diverge.
|
||||
const submitRewind = useCallback(
|
||||
(text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
|
||||
runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst),
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const state = readState()
|
||||
|
||||
if (!state || state.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
const plan = planReload(state.messages, parentId)
|
||||
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
update(current => applyReloadOptimistic(current, plan))
|
||||
|
||||
try {
|
||||
await requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
} catch (err) {
|
||||
update(current => ({ ...current, busy: false, awaitingResponse: false }))
|
||||
notifyError(err, copy.regenerateFailed)
|
||||
}
|
||||
},
|
||||
[copy.regenerateFailed, readState, requestGateway, update]
|
||||
)
|
||||
|
||||
const restoreToMessage = useCallback(
|
||||
async (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => {
|
||||
const sessionId = runtimeIdRef.current
|
||||
const messages = readMessages()
|
||||
const plan = planRestore(messages, messageId, target)
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
const wasBusy = readState()?.busy ?? false
|
||||
|
||||
update(state => applyRewindOptimistic(state, plan.sourceIndex))
|
||||
|
||||
try {
|
||||
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
|
||||
} catch (err) {
|
||||
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[readMessages, readState, submitRewind, update]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const messages = readMessages()
|
||||
const plan = planEdit(messages, edited)
|
||||
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = runtimeIdRef.current
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
const wasBusy = readState()?.busy ?? false
|
||||
|
||||
update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
|
||||
|
||||
try {
|
||||
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
|
||||
} catch (err) {
|
||||
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||
notifyError(err, copy.editFailed)
|
||||
}
|
||||
},
|
||||
[copy.editFailed, readMessages, readState, submitRewind, update]
|
||||
)
|
||||
|
||||
// Branch-visibility sync (assistant-ui hides non-active branches).
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
(nextMessages: readonly ThreadMessage[]) => update(state => applyBranchVisibility(state, nextMessages)),
|
||||
[update]
|
||||
)
|
||||
|
||||
const dismissError = useCallback(
|
||||
(messageId: string) => {
|
||||
update(state => ({ ...state, messages: state.messages.filter(m => m.id !== messageId) }))
|
||||
},
|
||||
[update]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
cancelRun,
|
||||
dismissError,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText
|
||||
}),
|
||||
[
|
||||
cancelRun,
|
||||
dismissError,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* SESSION TILES — a stored session rendered as a layout-tree pane BESIDE the
|
||||
* main thread (multi-session tiling). A tile IS the real chat surface: the
|
||||
* same ChatView/ChatBar/Thread tree the primary session renders, mounted
|
||||
* under a tile `SessionView` (its session's slice of `$sessionStates`) and a
|
||||
* tile `ComposerScope` (own attachment chips, own focus-bus key). Actions
|
||||
* (submit/slash/steer/edit/reload/restore/stop) come from
|
||||
* `useSessionTileActions`, all writing through the wiring cache.
|
||||
*
|
||||
* Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a
|
||||
* pane contribution docked right of the main zone -> tree adoption lands it
|
||||
* -> the pane mounts and asks the delegate for a live runtime id. Closing
|
||||
* the pane (tab Close) removes the tile + its zone; tiles persist across
|
||||
* restarts and re-resume on boot.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import {
|
||||
$sessionStates,
|
||||
$sessionTiles,
|
||||
closeSessionTile,
|
||||
discardSessionTile,
|
||||
patchSessionTile,
|
||||
type SessionTile,
|
||||
sessionTileDelegate
|
||||
} from '@/store/session-states'
|
||||
|
||||
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 { useSessionTileActions } from './session-tile-actions'
|
||||
import { type SessionView, SessionViewProvider } from './session-view'
|
||||
import { SessionContextMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser } from './thread-loading'
|
||||
|
||||
import { ChatView } from '.'
|
||||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/** The tile's SessionView: the same atom shape the primary chat renders
|
||||
* from, computed from this session's slice of `$sessionStates`. */
|
||||
function buildTileView(storedSessionId: string): SessionView {
|
||||
const $runtimeId = computed(
|
||||
$sessionTiles,
|
||||
tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null
|
||||
)
|
||||
|
||||
const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) =>
|
||||
runtimeId ? states[runtimeId] : undefined
|
||||
)
|
||||
|
||||
const $messages = computed($state, state => state?.messages ?? NO_MESSAGES)
|
||||
|
||||
return {
|
||||
kind: 'tile',
|
||||
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
|
||||
$busy: computed($state, state => Boolean(state?.busy)),
|
||||
$cwd: computed($state, state => state?.cwd ?? ''),
|
||||
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
|
||||
$messages,
|
||||
$messagesEmpty: computed($messages, messages => messages.length === 0),
|
||||
$model: computed($state, state => state?.model ?? ''),
|
||||
$provider: computed($state, state => state?.provider ?? ''),
|
||||
$runtimeId,
|
||||
// Constant for the tile's lifetime — a plain atom, not a computed.
|
||||
$storedId: atom(storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function TileChat({
|
||||
runtimeId,
|
||||
storedSessionId,
|
||||
view
|
||||
}: {
|
||||
runtimeId: string
|
||||
storedSessionId: string
|
||||
view: SessionView
|
||||
}) {
|
||||
const { gatewayRef, requestGateway } = useGatewayRequest()
|
||||
const cwd = useStore(view.$cwd)
|
||||
|
||||
// One attachment set + focus key per tile, stable for the tile's lifetime.
|
||||
const attachments = useRef(createComposerAttachmentScope()).current
|
||||
|
||||
const scope = useMemo<ComposerScope>(
|
||||
() => ({
|
||||
$awaitingInput: sessionAwaitingInput(runtimeId),
|
||||
attachments,
|
||||
popoutAllowed: false,
|
||||
readMessages: () => view.$messages.get(),
|
||||
target: `tile:${storedSessionId}`
|
||||
}),
|
||||
[attachments, runtimeId, storedSessionId, view.$messages]
|
||||
)
|
||||
|
||||
const actions = useSessionTileActions({ runtimeId, scope, storedSessionId })
|
||||
|
||||
// The same attach/pick/paste/drop pipeline the primary composer uses,
|
||||
// pointed at this tile's chips + session.
|
||||
const composer = useComposerActions({
|
||||
activeSessionId: runtimeId,
|
||||
currentCwd: cwd,
|
||||
requestGateway,
|
||||
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionViewProvider value={view}>
|
||||
<ComposerScopeProvider value={scope}>
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
onAttachImageBlob={composer.attachImageBlob}
|
||||
onBranchInNewChat={() => undefined}
|
||||
onCancel={actions.cancelRun}
|
||||
onDeleteSelectedSession={() => undefined}
|
||||
onDismissError={actions.dismissError}
|
||||
onEdit={actions.editMessage}
|
||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
onReload={actions.reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRestoreToMessage={actions.restoreToMessage}
|
||||
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
|
||||
onSteer={actions.steerPrompt}
|
||||
onSubmit={actions.submitText}
|
||||
onThreadMessagesChange={actions.handleThreadMessagesChange}
|
||||
onToggleSelectedPin={() => undefined}
|
||||
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
|
||||
/>
|
||||
</ComposerScopeProvider>
|
||||
</SessionViewProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
|
||||
const tiles = useStore($sessionTiles)
|
||||
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
|
||||
const runtimeId = tile?.runtimeId ?? null
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const resumingRef = useRef(false)
|
||||
const view = useMemo(() => buildTileView(storedSessionId), [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
|
||||
// latched every restored tile into the error card.
|
||||
useEffect(() => {
|
||||
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const delegate = sessionTileDelegate()
|
||||
|
||||
if (!delegate) {
|
||||
return
|
||||
}
|
||||
|
||||
resumingRef.current = true
|
||||
|
||||
delegate
|
||||
.resumeTile(storedSessionId)
|
||||
.then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id }))
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// A gone session (404 / "Session not found") is terminal — a stale or
|
||||
// cross-profile persisted tile. Discard it instead of latching an error
|
||||
// that re-retries on every reconnect (the "Session not found" spam).
|
||||
if (/session not found|\b404\b/i.test(message)) {
|
||||
discardSessionTile(storedSessionId)
|
||||
} else {
|
||||
patchSessionTile(storedSessionId, { error: message })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
resumingRef.current = false
|
||||
})
|
||||
}, [gatewayOpen, runtimeId, storedSessionId, tile?.error])
|
||||
|
||||
// The gateway (re)opening invalidates any latched error — it likely came
|
||||
// from a not-yet-open gateway or the previous connection. Clearing it
|
||||
// retriggers the resume effect: one bounded auto-retry per (re)connect,
|
||||
// mirroring the primary path's became-open resync.
|
||||
useEffect(() => {
|
||||
if (gatewayOpen && tile?.error) {
|
||||
patchSessionTile(storedSessionId, { error: undefined })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewayOpen, storedSessionId])
|
||||
|
||||
if (tile?.error) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
|
||||
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
|
||||
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
|
||||
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!runtimeId) {
|
||||
// The SAME session loader the primary thread shows (Thread's
|
||||
// loading === 'session' branch) — one loading language everywhere.
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<CenteredThreadSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
return stored ? sessionTitle(stored) : '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))
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
|
||||
// input) doesn't close silently.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stored id awaiting close confirmation (null = no dialog). */
|
||||
const $confirmCloseTile = atom<null | string>(null)
|
||||
|
||||
/** The tile closer, gated: a quiet session closes immediately; a busy or
|
||||
* input-blocked one asks first. One state read — the tile's runtime slice. */
|
||||
export function requestCloseSessionTile(storedSessionId: string): void {
|
||||
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
|
||||
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
|
||||
|
||||
if (state?.busy || state?.awaitingResponse || state?.needsInput) {
|
||||
$confirmCloseTile.set(storedSessionId)
|
||||
} else {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mounted once at the shell root: the "Close running tab?" confirmation. */
|
||||
export function SessionTileCloseConfirm() {
|
||||
const { t } = useI18n()
|
||||
const storedSessionId = useStore($confirmCloseTile)
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel={t.zones.closeRunningConfirm}
|
||||
description={t.zones.closeRunningBody}
|
||||
destructive
|
||||
onClose={() => $confirmCloseTile.set(null)}
|
||||
onConfirm={() => {
|
||||
if (storedSessionId) {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
}}
|
||||
open={storedSessionId !== null}
|
||||
title={t.zones.closeRunningTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Layout reset → every session tile collapses into the MAIN zone as a tab
|
||||
* after the workspace (the primary session stays the first tab), the "smart"
|
||||
* reset: N scattered tiles become one tab bar over the chat instead of
|
||||
* re-docking to their old edges.
|
||||
*
|
||||
* Runs BEFORE generic adoption (see registerLayoutResetHandler) — the tiles
|
||||
* aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the
|
||||
* workspace group as a tab (append). The main group id is re-read each pass
|
||||
* because appending returns a new tree. */
|
||||
export function stackSessionTilesIntoMain(): void {
|
||||
for (const tile of $sessionTiles.get()) {
|
||||
const tree = $layoutTree.get()
|
||||
const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null
|
||||
|
||||
if (mainGroup) {
|
||||
moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A session TAB's context menu: the full session verb set (pin, copy id, new
|
||||
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
|
||||
* gets, targeted through the tile delegate (whose verbs are generic over
|
||||
* stored ids, primary included). The wrapper stops the contextmenu from also
|
||||
* opening the zone strip's menu. Shared by tile tabs AND the main tab. */
|
||||
export function SessionTabMenu({
|
||||
children,
|
||||
onClose,
|
||||
onHideTabBar,
|
||||
storedSessionId,
|
||||
tabPaneId
|
||||
}: {
|
||||
children: React.ReactElement
|
||||
/** Close this tab (tiles; the main tab passes nothing). */
|
||||
onClose?: () => void
|
||||
/** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */
|
||||
onHideTabBar?: () => void
|
||||
storedSessionId: string
|
||||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
return (
|
||||
<span className="contents" onContextMenu={event => event.stopPropagation()}>
|
||||
<SessionContextMenu
|
||||
onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)}
|
||||
onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)}
|
||||
onClose={onClose}
|
||||
onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)}
|
||||
onHideTabBar={onHideTabBar}
|
||||
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
||||
pinned={pinned}
|
||||
profile={stored?.profile}
|
||||
sessionId={storedSessionId}
|
||||
surface="tab"
|
||||
tabPaneId={tabPaneId}
|
||||
title={tileTitle(storedSessionId)}
|
||||
>
|
||||
{children}
|
||||
</SessionContextMenu>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
|
||||
* session, plus the bar's off switch (the bar sticky-shows once a tab is
|
||||
* ever gained; this is the explicit way back). A fresh draft has no session —
|
||||
* no menu. */
|
||||
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
|
||||
const selected = useStore($selectedStoredSessionId)
|
||||
|
||||
const hideTabBar = () => {
|
||||
const tree = $layoutTree.get()
|
||||
const group = tree ? findGroupOfPane(tree, 'workspace') : null
|
||||
|
||||
if (group) {
|
||||
setTreeGroupHeaderHidden(group.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
|
||||
{children}
|
||||
</SessionTabMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep pane contributions mirroring `$sessionTiles` (+ titles from
|
||||
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
|
||||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
also: [$sessions],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
anchor: t => t.anchor,
|
||||
before: t => t.before,
|
||||
minWidth: '20rem',
|
||||
title: tileTitle,
|
||||
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
|
||||
tabWrap: (storedSessionId, tab) => (
|
||||
<SessionTabMenu
|
||||
onClose={() => requestCloseSessionTile(storedSessionId)}
|
||||
storedSessionId={storedSessionId}
|
||||
tabPaneId={`session-tile:${storedSessionId}`}
|
||||
>
|
||||
{tab}
|
||||
</SessionTabMenu>
|
||||
),
|
||||
// A tile's tab drags like a sidebar row — stack / split / drop-to-link — with
|
||||
// its tap (activate) + double-tap (hide bar) preserved. Always takes the drag.
|
||||
tabDrag: (storedSessionId, event, onTap, double) => {
|
||||
startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap })
|
||||
|
||||
return true
|
||||
},
|
||||
close: requestCloseSessionTile
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$selectedStoredSessionId
|
||||
} from '@/store/session'
|
||||
|
||||
/**
|
||||
* SESSION VIEW — the store surface a ChatView renders from. The PRIMARY view
|
||||
* is the app's classic global atoms (route-driven active session, untouched
|
||||
* fast path). A session TILE provides the same shape computed from its
|
||||
* session's slice of `$sessionStates`, so the identical ChatView tree renders
|
||||
* either — one chat surface, N sessions on screen.
|
||||
*
|
||||
* Everything is atoms (not values) so subscription granularity survives:
|
||||
* ChatView subscribes only to the coarse edges; `$messages` stays boundary-
|
||||
* only exactly like the primary view's perf contract.
|
||||
*/
|
||||
export interface SessionView {
|
||||
kind: 'primary' | 'tile'
|
||||
$runtimeId: ReadableAtom<string | null>
|
||||
$storedId: ReadableAtom<string | null>
|
||||
$messages: ReadableAtom<ChatMessage[]>
|
||||
$busy: ReadableAtom<boolean>
|
||||
$awaitingResponse: ReadableAtom<boolean>
|
||||
$messagesEmpty: ReadableAtom<boolean>
|
||||
$lastVisibleIsUser: ReadableAtom<boolean>
|
||||
$cwd: ReadableAtom<string>
|
||||
$model: ReadableAtom<string>
|
||||
$provider: ReadableAtom<string>
|
||||
}
|
||||
|
||||
export const PRIMARY_SESSION_VIEW: SessionView = {
|
||||
kind: 'primary',
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$cwd: $currentCwd,
|
||||
$lastVisibleIsUser: $lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$model: $currentModel,
|
||||
$provider: $currentProvider,
|
||||
$runtimeId: $activeSessionId,
|
||||
$storedId: $selectedStoredSessionId
|
||||
}
|
||||
|
||||
const SessionViewContext = createContext<SessionView>(PRIMARY_SESSION_VIEW)
|
||||
|
||||
export const SessionViewProvider = SessionViewContext.Provider
|
||||
|
||||
export const useSessionView = (): SessionView => useContext(SessionViewContext)
|
||||
@@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
@@ -34,8 +37,6 @@ import {
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarCronOpen,
|
||||
$sidebarMessagingOpenIds,
|
||||
$sidebarOpen,
|
||||
$sidebarOverlayMounted,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarProjectOrderIds,
|
||||
$sidebarRecentsOpen,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
refreshWorktrees,
|
||||
scanAndRecordRepos
|
||||
} from '@/store/projects'
|
||||
import { openRouteTile } from '@/store/route-tiles'
|
||||
import {
|
||||
$cronSessions,
|
||||
$currentCwd,
|
||||
@@ -85,7 +87,6 @@ import {
|
||||
$messagingPlatformTotals,
|
||||
$messagingSessions,
|
||||
$messagingTruncated,
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
@@ -94,8 +95,16 @@ import {
|
||||
sessionPinId,
|
||||
setCurrentCwd
|
||||
} from '@/store/session'
|
||||
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import {
|
||||
type AppView,
|
||||
ARTIFACTS_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
SIDEBAR_NAV_AREA,
|
||||
type SidebarNavContribution,
|
||||
SKILLS_ROUTE
|
||||
} from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { countLabel } from './chrome'
|
||||
@@ -121,6 +130,7 @@ import {
|
||||
} from './projects'
|
||||
import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states'
|
||||
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
|
||||
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
|
||||
|
||||
// Non-session groups (messaging platforms) stay compact: show a few rows up
|
||||
// front, reveal more in larger steps on demand. Keeps a busy platform from
|
||||
@@ -209,6 +219,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onBranchSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
/** Create a brand-new session and open it as a tile on `dir`. */
|
||||
onNewSessionSplit: (dir: SplitDir) => void
|
||||
onManageCronJob: (jobId: string) => void
|
||||
onTriggerCronJob: (jobId: string) => void
|
||||
}
|
||||
@@ -224,22 +236,49 @@ export function ChatSidebar({
|
||||
onArchiveSession,
|
||||
onBranchSession,
|
||||
onNewSessionInWorkspace,
|
||||
onNewSessionSplit,
|
||||
onManageCronJob,
|
||||
onTriggerCronJob
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
|
||||
const overlayMounted = useStore($sidebarOverlayMounted)
|
||||
const contentVisible = sidebarOpen || overlayMounted
|
||||
const { pathname } = useLocation()
|
||||
// Contributed nav rows (plugins pairing a page with a sidebar entry) render
|
||||
// below the built-ins with the same chrome; active = at their route.
|
||||
const navContributions = useContributions(SIDEBAR_NAV_AREA)
|
||||
|
||||
const contributedNav = useMemo<SidebarNavItem[]>(
|
||||
() =>
|
||||
navContributions.flatMap(c => {
|
||||
const data = c.data as Partial<SidebarNavContribution> | undefined
|
||||
|
||||
if (!data?.path?.startsWith('/') || !data.label) {
|
||||
return []
|
||||
}
|
||||
|
||||
const codicon = data.codicon || 'plug'
|
||||
|
||||
return [
|
||||
{
|
||||
id: c.id,
|
||||
label: data.label,
|
||||
icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />,
|
||||
route: data.path
|
||||
}
|
||||
]
|
||||
}),
|
||||
[navContributions]
|
||||
)
|
||||
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const cronOpen = useStore($sidebarCronOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
// The sidebar highlight tracks the FOCUSED session — the interacted tile's
|
||||
// tab, else the main selection — so it stays 1:1 with whatever tab is active.
|
||||
const selectedSessionId = useStore($focusedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const cronSessions = useStore($cronSessions)
|
||||
const cronJobs = useStore($cronJobs)
|
||||
@@ -1031,15 +1070,12 @@ export function ChatSidebar({
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
// Visibility is the layout tree's job (a hidden zone is display:none;
|
||||
// the narrow overlay renders the live instance) — the sidebar always
|
||||
// paints itself fully.
|
||||
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
|
||||
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0',
|
||||
// While floated by PaneShell's hover-reveal, force visible + interactive
|
||||
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
|
||||
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
|
||||
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
@@ -1047,62 +1083,85 @@ export function ChatSidebar({
|
||||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
(item.id === 'artifacts' && currentView === 'artifacts') ||
|
||||
// Contributed rows light up at their own route.
|
||||
(Boolean(item.route) && pathname === item.route)
|
||||
|
||||
const isNewSession = item.id === 'new-session'
|
||||
|
||||
const button = (
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
)
|
||||
|
||||
// New session + route-backed pages can open in a split —
|
||||
// right-click for the directional "Open in split" submenu.
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{contentVisible && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{isNewSession || item.route ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={s.nav[item.id] ?? item.label}>
|
||||
<SplitSubmenu
|
||||
kit={CONTEXT_SPLIT_KIT}
|
||||
label={s.row.openInSplit}
|
||||
onSplit={dir => {
|
||||
if (isNewSession) {
|
||||
onNewSessionSplit(dir)
|
||||
} else if (item.route) {
|
||||
openRouteTile(item.route, dir)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
@@ -1110,7 +1169,7 @@ export function ChatSidebar({
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
{showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label={s.searchAria}
|
||||
@@ -1122,7 +1181,7 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
{showSessionSections && (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
|
||||
{trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
@@ -1392,13 +1451,11 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
|
||||
{contentVisible && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<ProjectDialog />
|
||||
</Sidebar>
|
||||
|
||||
@@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import {
|
||||
REORDER_DRAG_TRANSITION_CSS,
|
||||
REORDER_RAIL_TRANSITION,
|
||||
reorderCommitHaptic,
|
||||
reorderStepHaptic
|
||||
} from '@/lib/reorder'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
// select. Drag-reorder and long-press-recolor live only on the squares path.
|
||||
const PROFILE_DROPDOWN_THRESHOLD = 13
|
||||
|
||||
// easeOutBack — a little overshoot so squares spring into their new slot rather
|
||||
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
|
||||
// glides between snapped cells on the snappier DRAG_TRANSITION.
|
||||
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
|
||||
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
|
||||
// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between
|
||||
// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED
|
||||
// reorder primitive (lib/reorder.ts) so every reorder strip feels identical.
|
||||
const RAIL_TRANSITION = REORDER_RAIL_TRANSITION
|
||||
const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS
|
||||
|
||||
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
|
||||
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
|
||||
@@ -174,7 +179,7 @@ export function ProfileRail() {
|
||||
|
||||
if (id && id !== lastOverRef.current) {
|
||||
lastOverRef.current = id
|
||||
triggerHaptic('selection')
|
||||
reorderStepHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +196,7 @@ export function ProfileRail() {
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
setProfileOrder(arrayMove(ids, from, to))
|
||||
triggerHaptic('success')
|
||||
reorderCommitHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
|
||||
// profile.ts calls this at import (its $activeGatewayProfile subscribe fires
|
||||
// immediately), pulled in transitively via session-states.
|
||||
setApiRequestProfile: () => {},
|
||||
HermesGateway: class {}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
closeAllTreeTabs,
|
||||
closeOtherTreeTabs,
|
||||
closeTreeTabsToRight,
|
||||
treeTabCloseTargets
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,7 +26,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -22,6 +41,7 @@ import { exportSession } from '@/lib/session-export'
|
||||
import { activeGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
|
||||
import { $sessionTiles, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import type { SessionTitleResponse } from '../../types'
|
||||
@@ -80,10 +100,31 @@ interface SessionActions {
|
||||
onBranch?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
/** Close this surface (a tile tab) — omitted where nothing closes (sidebar
|
||||
* rows, the main tab). */
|
||||
onClose?: () => void
|
||||
/** TAB surfaces: the session is already a tab, so "Open in new tab" is
|
||||
* nonsense there — sidebar rows/dropdowns keep it. */
|
||||
surface?: 'row' | 'tab'
|
||||
/** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) — enables
|
||||
* the Close-others / to-the-right / all tab verbs. Tab surfaces only. */
|
||||
tabPaneId?: string
|
||||
/** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows
|
||||
* once a tab is ever gained; this is the explicit off switch). */
|
||||
onHideTabBar?: () => void
|
||||
}
|
||||
|
||||
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
|
||||
/** A menu flavour (dropdown / context) — item + separator components. */
|
||||
interface MenuKit {
|
||||
Item: MenuItem
|
||||
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
|
||||
}
|
||||
|
||||
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
|
||||
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
|
||||
|
||||
interface ItemSpec {
|
||||
className?: string
|
||||
disabled: boolean
|
||||
@@ -101,26 +142,45 @@ function useSessionActions({
|
||||
onPin,
|
||||
onBranch,
|
||||
onArchive,
|
||||
onDelete
|
||||
onDelete,
|
||||
onClose,
|
||||
onHideTabBar,
|
||||
surface = 'row',
|
||||
tabPaneId
|
||||
}: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const tiles = useStore($sessionTiles)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
const pinItem: ItemSpec = {
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
}
|
||||
// Already showing as a tab somewhere (a tile, or loaded in main — main IS
|
||||
// a tab): offering "Open in new tab" again is noise.
|
||||
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
|
||||
|
||||
// OPEN — where else this session can go. A tab surface IS a tab already,
|
||||
// so it only offers the window hop (and its own Close, below).
|
||||
const openItems: ItemSpec[] = [
|
||||
...(surface === 'row' && !alreadyTabbed
|
||||
? [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'browser',
|
||||
label: r.openInNewTab,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
// Stack into the MAIN zone as a tab (center dock; the strip
|
||||
// sticky-shows on gain) — the door to the tab bar.
|
||||
openSessionTile(sessionId, 'center')
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'link-external',
|
||||
label: r.newWindow,
|
||||
@@ -128,28 +188,14 @@ function useSessionActions({
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(sessionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
: [])
|
||||
]
|
||||
|
||||
// IDENTITY — name/mark/reference the session.
|
||||
const identityItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: r.rename,
|
||||
@@ -157,8 +203,96 @@ function useSessionActions({
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
}),
|
||||
spec({
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// WORK — derive/extract from the session.
|
||||
const workItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
|
||||
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
|
||||
|
||||
const tabCloseItems: ItemSpec[] =
|
||||
surface === 'tab'
|
||||
? [
|
||||
...(onClose
|
||||
? [
|
||||
spec({
|
||||
disabled: false,
|
||||
icon: 'close',
|
||||
label: t.common.close,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(tabPaneId
|
||||
? [
|
||||
spec({
|
||||
disabled: !closeTargets?.others,
|
||||
icon: 'close-all',
|
||||
label: t.zones.closeOthers,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeOtherTreeTabs(tabPaneId)
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !closeTargets?.right,
|
||||
icon: 'arrow-right',
|
||||
label: t.zones.closeToRight,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeTreeTabsToRight(tabPaneId)
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !closeTargets?.all,
|
||||
icon: 'clear-all',
|
||||
label: t.zones.closeAll,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeAllTreeTabs(tabPaneId)
|
||||
}
|
||||
})
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
|
||||
// DANGER — put it away / destroy it (delete stays last, destructive-red).
|
||||
const dangerItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: r.archive,
|
||||
@@ -166,7 +300,7 @@ function useSessionActions({
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
@@ -187,11 +321,13 @@ function useSessionActions({
|
||||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (Item: MenuItem) => (
|
||||
const renderItems = (kit: MenuKit) => (
|
||||
<>
|
||||
{renderMenuItem(Item, pinItem)}
|
||||
{openItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{openItems.length > 0 && <kit.Separator />}
|
||||
{identityItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
<CopyButton
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
iconClassName="size-3.5 text-current"
|
||||
@@ -200,7 +336,30 @@ function useSessionActions({
|
||||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
text={sessionId}
|
||||
/>
|
||||
{items.map(spec => renderMenuItem(Item, spec))}
|
||||
<kit.Separator />
|
||||
{workItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{tabCloseItems.length > 0 && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
</>
|
||||
)}
|
||||
<kit.Separator />
|
||||
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{onHideTabBar && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{renderMenuItem(kit.Item, {
|
||||
disabled: false,
|
||||
icon: 'eye-closed',
|
||||
label: r.hideTabBar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onHideTabBar()
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -225,10 +384,11 @@ interface SessionActionsMenuProps
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
@@ -236,7 +396,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{renderItems(DropdownMenuItem)}
|
||||
{renderItems(DROPDOWN_KIT)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{renameDialog}
|
||||
@@ -257,7 +417,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
{renderItems(ContextMenuItem)}
|
||||
{renderItems(CONTEXT_KIT)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{renameDialog}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user