Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 6212b5e4af fix(ci): route root npm manifest changes into the Python lane
The change classifier treated package.json / package-lock.json as
python-irrelevant, so a lockfile-only PR skipped the Python lane entirely.
But several Python invariant tests read these files (assistant-ui tap cluster,
electron pin, lazy-deps, lockfile churn) — so a lockfile change can break the
Python suite. #63970 merged green (Python skipped on the PR) then reddened main
(push fails open and runs everything).

Drop root npm from the _py_irrelevant denylist so lockfile changes run Python,
honoring the classifier's own contract: never skip a lane a change could break.
Root npm still triggers the frontend lane as before.
2026-07-15 03:17:29 -04:00
626 changed files with 8303 additions and 46202 deletions
@@ -24,15 +24,9 @@ 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
+1 -11
View File
@@ -41,10 +41,8 @@ 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
@@ -67,11 +65,10 @@ jobs:
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
if: needs.detect.outputs.python == '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
@@ -102,12 +99,6 @@ 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
@@ -153,7 +144,6 @@ jobs:
- history-check
- contributor-check
- uv-lockfile
- lockfile-diff
- docker-lint
- supply-chain
- osv-scanner
-240
View File
@@ -1,240 +0,0 @@
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."
+3 -3
View File
@@ -30,8 +30,8 @@ jobs:
check:
name: Typecheck & Test
needs: workspaces
runs-on: ubuntu-latest
needs: workspaces
strategy:
matrix:
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
@@ -44,6 +44,6 @@ jobs:
cache: npm
- uses: ./.github/actions/retry
with:
command: npm ci
# --ignore-scripts: TS & tests don't need native deps
command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} check
- run: npm run --prefix ${{ matrix.package }} fix
-112
View File
@@ -15,10 +15,6 @@ 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
@@ -162,111 +158,3 @@ 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
-98
View File
@@ -1,98 +0,0 @@
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
View File
@@ -68,17 +68,6 @@ 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
-4
View File
@@ -1,4 +0,0 @@
# 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
+8 -22
View File
@@ -1094,16 +1094,14 @@ kanban task.
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
`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`.
`unlink`, `comment`, `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`, `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.
`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.
- **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
@@ -1280,7 +1278,6 @@ 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`
@@ -1294,12 +1291,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 |
| ------------------- | ------------------------------------------- | ----------------------------------------- |
@@ -1308,17 +1305,6 @@ 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
-1
View File
@@ -109,7 +109,6 @@ 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)
+1 -12
View File
@@ -1903,18 +1903,7 @@ class HermesACPAgent(acp.Agent):
def _cmd_reset(self, args: str, state: SessionState) -> str:
state.history.clear()
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."
self.session_manager.save_session(state.session_id)
return "Conversation history cleared."
def _cmd_compact(self, args: str, state: SessionState) -> str:
-18
View File
@@ -387,24 +387,6 @@ 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:
-21
View File
@@ -1354,27 +1354,6 @@ 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(
-42
View File
@@ -357,48 +357,6 @@ 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.
+2 -8
View File
@@ -534,9 +534,8 @@ 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, Azure AI
Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy
follow this pattern.
MiniMax's global and China Anthropic-compatible endpoints, and Azure AI
Foundry's Anthropic-style endpoint follow this pattern.
"""
normalized = _normalize_base_url_text(base_url)
if not normalized:
@@ -545,11 +544,6 @@ 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")
)
-138
View File
@@ -1,138 +0,0 @@
"""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)
+11 -164
View File
@@ -44,7 +44,6 @@ import contextlib
import json
import logging
import os
import re
import threading
import time
from pathlib import Path # noqa: F401 — used by test mocks
@@ -103,6 +102,7 @@ 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,47 +156,22 @@ 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)."""
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
client = build_keepalive_http_client(
str(base_url or ""),
async_mode=async_mode,
verify=_resolve_aux_verify(base_url),
)
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
@@ -1362,31 +1337,6 @@ 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(
@@ -3694,40 +3644,6 @@ 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],
@@ -3756,20 +3672,7 @@ 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,
@@ -3828,14 +3731,6 @@ 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,
@@ -4384,41 +4279,10 @@ 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":
# Anonymous custom endpoint (OPENAI_BASE_URL / config.model.base_url)
# — pass through with explicit base_url + api_key.
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
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
@@ -6657,12 +6521,7 @@ def _build_call_kwargs(
return kwargs
def _validate_llm_response(
response: Any,
task: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
) -> Any:
def _validate_llm_response(response: Any, task: 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
@@ -6670,21 +6529,11 @@ def _validate_llm_response(
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:
@@ -6951,8 +6800,7 @@ def call_llm(
# for the transient retry every auxiliary task shares. (PR #16587)
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task,
provider=resolved_provider, base_url=_base_info)
client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
@@ -7531,8 +7379,7 @@ async def async_call_llm(
# for the rationale. (PR #16587)
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task,
provider=resolved_provider, base_url=_client_base)
await client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
+1 -10
View File
@@ -528,19 +528,10 @@ 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": raw_bytes},
"source": {"bytes": data},
}
})
else:
-23
View File
@@ -504,29 +504,6 @@ 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:
+6 -27
View File
@@ -1118,22 +1118,6 @@ 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
@@ -1150,18 +1134,15 @@ 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)
@@ -1430,8 +1411,6 @@ 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:
+35 -144
View File
@@ -26,7 +26,6 @@ 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,
@@ -36,47 +35,6 @@ 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"
@@ -628,27 +586,6 @@ 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.
@@ -749,16 +686,7 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
if tool_name == "web_extract":
urls = args.get("urls", [])
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
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
if isinstance(urls, list) and len(urls) > 1:
url_desc += f" (+{len(urls) - 1} more)"
return f"[web_extract] {url_desc} ({content_len:,} chars)"
@@ -837,7 +765,6 @@ 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
@@ -876,7 +803,6 @@ 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
@@ -901,7 +827,6 @@ 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()
@@ -1050,7 +975,6 @@ 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", "")
@@ -1731,24 +1655,7 @@ class ContextCompressor(ContextEngine):
parts = []
for msg in turns:
role = msg.get("role", "unknown")
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 = redact_sensitive_text(msg.get("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
@@ -2334,7 +2241,6 @@ 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
@@ -2356,18 +2262,25 @@ 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, 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
# 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
# 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; 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.
# 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:
self._last_summary_auth_failure = True
if _is_json_decode and not _is_model_not_found and not _is_timeout:
logger.error(
@@ -2417,30 +2330,7 @@ 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.
# 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
_transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60
err_text = str(e).strip() or e.__class__.__name__
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
@@ -3253,14 +3143,16 @@ This compaction should PRIORITISE preserving all information related to the focu
# surface a warning.
# Default is False (historical behavior).
#
# 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.
# 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.
if not summary and (
self.abort_on_summary_failure
or self._last_summary_auth_failure
@@ -3273,12 +3165,11 @@ 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 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.",
"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.",
n_skipped,
)
elif self._last_summary_network_failure:
+15 -47
View File
@@ -1642,16 +1642,12 @@ 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
@@ -1661,8 +1657,6 @@ 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":
@@ -2625,31 +2619,6 @@ 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)
@@ -4490,28 +4459,27 @@ def run_conversation(
or interim_has_codex_message_items
):
last_msg = messages[-1] if messages else None
# 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 = (
# 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 = (
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 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:
if not duplicate_interim:
messages.append(interim_msg)
agent._emit_interim_assistant_message(interim_msg)
+8 -6
View File
@@ -543,12 +543,14 @@ def _write_through_provider_state_to_global_root(
except Exception:
return
try:
auth_mod._persist_provider_state_to_store(
provider_id,
state,
global_path,
set_active=False,
)
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)
except Exception as exc: # pragma: no cover - best effort
logger.debug(
"%s pool refresh: write-through to global root failed: %s",
+2 -24
View File
@@ -840,34 +840,12 @@ def classify_api_error(
)
return _result(FailoverReason.timeout, retryable=True)
# ── 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 ───────────────────────────
# ── 7. Transport / timeout heuristics ───────────────────────────
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
return _result(FailoverReason.timeout, retryable=True)
# ── 9. Fallback: unknown ────────────────────────────────────────
# ── 8. Fallback: unknown ────────────────────────────────────────
return _result(FailoverReason.unknown, retryable=True)
-12
View File
@@ -439,18 +439,6 @@ 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 = []
-12
View File
@@ -20,17 +20,6 @@ _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],
@@ -50,7 +39,6 @@ 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:
+11 -52
View File
@@ -4,86 +4,45 @@ 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 aborted:
headline = f"Compression aborted: {before_count} messages preserved"
elif fallback_used:
headline = (
f"Compressed with fallback: {before_count} → {after_count} messages"
)
elif noop:
if 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 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:
if 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,
+1 -7
View File
@@ -404,12 +404,6 @@ 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":
@@ -421,7 +415,7 @@ def _run_references_parallel(
continue
futures[
executor.submit(
propagate_context_to_thread(_run_reference),
_run_reference,
slot,
ref_messages,
temperature=temperature,
+3 -83
View File
@@ -31,55 +31,7 @@ version can change at runtime (editable installs, hot-reload tooling), and
from __future__ import annotations
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()
from typing import List
def _hermes_version() -> str:
@@ -103,42 +55,10 @@ def hermes_client_tag() -> str:
return f"client=hermes-client-v{_hermes_version()}"
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]:
def nous_portal_tags() -> 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.
"""
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
return ["product=hermes-agent", hermes_client_tag()]
+7 -38
View File
@@ -257,10 +257,6 @@ 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"
@@ -1961,7 +1957,6 @@ 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.
@@ -1983,43 +1978,17 @@ 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 = []
# 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)
)
# 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)
+5 -43
View File
@@ -10,36 +10,15 @@ 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."""
@@ -63,38 +42,21 @@ 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 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).
# 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.
override = _session_cwd_override()
if override:
p = Path(override).expanduser()
if not p.is_dir():
logger.warning("configured working directory does not exist: %s", override)
else:
return p
return None
return Path(override).expanduser()
raw = os.environ.get("TERMINAL_CWD", "").strip()
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
return Path(raw).expanduser() if raw else None
+1 -8
View File
@@ -463,16 +463,9 @@ 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,
allow_install_tree_fallback=agent.platform in ("cli", "tui"))
context_length=_ctx_len)
if context_files_prompt:
context_parts.append(context_files_prompt)
+4 -14
View File
@@ -208,29 +208,19 @@ 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 ""
return self._strip_orphan_close_tags(tail)
tail = self._strip_orphan_close_tags(tail)
if tail:
self._last_emitted_ended_newline = tail.endswith("\n")
return tail
# ── internal helpers ───────────────────────────────────────────────
-19
View File
@@ -145,25 +145,6 @@ 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
)
+11 -38
View File
@@ -102,7 +102,7 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool:
return False
def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = None) -> List[tuple]:
def _plan_tool_batch_segments(tool_calls) -> 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, *, execution_cwd: Optional[Path] = Non
continue
if tool_name in _PATH_SCOPED_TOOLS:
scoped_path = _extract_parallel_scope_path(tool_name, function_args, execution_cwd=execution_cwd)
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
if scoped_path is None:
_add_sequential(tool_call)
continue
@@ -217,34 +217,8 @@ def _should_parallelize_tool_batch(tool_calls) -> bool:
return len(segments) == 1 and segments[0][0] == "parallel"
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).
"""
def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]:
"""Return the normalized file target for path-scoped tools."""
if tool_name not in _PATH_SCOPED_TOOLS:
return None
@@ -252,16 +226,16 @@ def _extract_parallel_scope_path(
if not isinstance(raw_path, str) or not raw_path.strip():
return None
return _canonical_path(raw_path, execution_cwd)
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)))
def _paths_overlap(left: Path, right: Path) -> bool:
"""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.
"""
"""Return True when two paths may refer to the same subtree."""
left_parts = left.parts
right_parts = right.parts
if not left_parts or not right_parts:
@@ -639,7 +613,6 @@ __all__ = [
"_is_destructive_command",
"_plan_tool_batch_segments",
"_should_parallelize_tool_batch",
"_canonical_path",
"_extract_parallel_scope_path",
"_paths_overlap",
"_is_multimodal_tool_result",
+1 -4
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import concurrent.futures
import json
from pathlib import Path
import logging
import os
import random
@@ -1765,9 +1764,7 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec
from types import SimpleNamespace
if segments is None:
_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)
segments = _plan_tool_batch_segments(assistant_message.tool_calls)
for kind, calls in segments:
segment_message = SimpleNamespace(tool_calls=list(calls))
+5 -8
View File
@@ -776,18 +776,15 @@ class ChatCompletionsTransport(ProviderTransport):
return True
def extract_cache_stats(self, response: Any) -> dict[str, int] | None:
"""Extract cache stats from prompt_tokens_details (OpenRouter/OpenAI)
or DeepSeek's native top-level prompt_cache_hit_tokens field."""
"""Extract OpenRouter/OpenAI cache stats from prompt_tokens_details."""
usage = getattr(response, "usage", None)
if usage is None:
return None
details = getattr(usage, "prompt_tokens_details", None)
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 details is None:
return None
cached = getattr(details, "cached_tokens", 0) or 0
written = getattr(details, "cache_write_tokens", 0) or 0
if cached or written:
return {"cached_tokens": cached, "creation_tokens": written}
return None
+4 -16
View File
@@ -435,27 +435,15 @@ 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. 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.
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.
"""
if response is None:
return False
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
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 False
return True
def preflight_kwargs(
-5
View File
@@ -218,11 +218,6 @@ 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
+8 -220
View File
@@ -446,52 +446,36 @@ _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-07",
pricing_version="deepseek-pricing-2026-03-16",
),
(
"deepseek",
"deepseek-reasoner",
): PricingEntry(
input_cost_per_million=Decimal("0.14"),
output_cost_per_million=Decimal("0.28"),
cache_read_cost_per_million=Decimal("0.0028"),
input_cost_per_million=Decimal("0.55"),
output_cost_per_million=Decimal("2.19"),
source="official_docs_snapshot",
source_url="https://api-docs.deepseek.com/quick_start/pricing",
pricing_version="deepseek-pricing-2026-07",
pricing_version="deepseek-pricing-2026-03-16",
),
(
"deepseek",
"deepseek-v4-pro",
): PricingEntry(
input_cost_per_million=Decimal("0.435"),
output_cost_per_million=Decimal("0.87"),
cache_read_cost_per_million=Decimal("0.003625"),
input_cost_per_million=Decimal("1.74"),
output_cost_per_million=Decimal("3.48"),
cache_read_cost_per_million=Decimal("0.0145"),
source="official_docs_snapshot",
source_url="https://api-docs.deepseek.com/quick_start/pricing",
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",
pricing_version="deepseek-pricing-2026-05-12",
),
# Google Gemini
(
@@ -625,189 +609,6 @@ _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
@@ -871,10 +672,6 @@ 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")
@@ -1074,15 +871,6 @@ 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
)
@@ -1,5 +0,0 @@
import shared from '../../eslint.config.shared.mjs'
export default [
...shared
]
+1 -12
View File
@@ -13,10 +13,7 @@
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit",
"check": "npm run typecheck",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix"
"check": "npm run typecheck"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@@ -41,19 +38,11 @@
"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"
}
}
+3 -4
View File
@@ -1,11 +1,10 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import Failure from './routes/failure'
import { $route, $bootstrap, initialize } from './store'
import Welcome from './routes/welcome'
import Progress from './routes/progress'
import Success from './routes/success'
import Welcome from './routes/welcome'
import { $bootstrap, $route, initialize } from './store'
import Failure from './routes/failure'
/*
* App shell — Hermes Setup.
+1 -3
View File
@@ -1,9 +1,7 @@
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,16 +1,15 @@
import { useStore } from '@nanostores/react'
import { FileText, RefreshCw } from 'lucide-react'
import { type CSSProperties } from 'react'
import { useStore } from '@nanostores/react'
import { Button } from '../components/button'
import {
$logPath,
$mode,
type BootstrapStateModel,
openLogDir,
startInstall,
startUpdate
startUpdate,
type BootstrapStateModel
} from '../store'
import { RefreshCw, FileText } from 'lucide-react'
interface FailureProps {
bootstrap: BootstrapStateModel
@@ -56,11 +55,11 @@ export default function Failure({ bootstrap }: FailureProps) {
</div>
<div className="flex items-center gap-3">
<Button className="gap-1.5" onClick={() => void (isUpdate ? startUpdate() : startInstall())}>
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
<RefreshCw />
{isUpdate ? 'Retry update' : 'Retry install'}
</Button>
<Button className="gap-1.5" onClick={() => void openLogDir()} variant="text">
<Button variant="text" onClick={() => void openLogDir()} className="gap-1.5">
<FileText />
Open logs
</Button>
@@ -1,18 +1,17 @@
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 { useStore } from '@nanostores/react'
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
@@ -43,19 +42,15 @@ 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 (
@@ -95,25 +90,22 @@ 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>
@@ -134,11 +126,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>
@@ -151,17 +143,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
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"
onClick={() => setShowLogs((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<FileText size={14} />
{showLogs ? 'Hide details' : 'Show details'}
<ChevronRight className={clsx('transition-transform', showLogs && 'rotate-90')} size={12} />
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
</button>
{bootstrap.status === 'running' && (
<Button onClick={() => void cancelInstall()} size="sm" variant="outline">
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
Cancel
</Button>
)}
@@ -175,36 +167,29 @@ 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 className="shrink-0 text-muted-foreground" size={13} />
return <Check size={13} className="shrink-0 text-muted-foreground" />
}
if (state === 'skipped') {
return <Check className="shrink-0 text-muted-foreground/50" size={13} />
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
}
if (state === 'failed') {
return <X className="shrink-0 text-destructive" size={13} />
return <X size={13} className="shrink-0 text-destructive" />
}
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,9 +1,8 @@
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
@@ -23,7 +22,6 @@ export default function Success() {
async function handleLaunch() {
setError(null)
setLaunching(true)
try {
await launchHermesDesktop()
// On success the installer exits — control never returns here.
@@ -67,8 +65,8 @@ export default function Success() {
/>
{error && (
<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 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="min-w-0">
<div className="font-medium text-destructive">Couldn&rsquo;t launch the desktop app</div>
<div className="mt-0.5 text-muted-foreground">{error}</div>
@@ -1,5 +1,4 @@
import { type CSSProperties } from 'react'
import { HackeryButton } from '../components/hackery-button'
import { startInstall } from '../store'
+16 -58
View File
@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { atom, computed } from 'nanostores'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
/*
* Bootstrap state store — single source of truth for installer screens.
@@ -79,16 +79,12 @@ 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 }
})
@@ -103,9 +99,7 @@ function withStageState(
error?: string
): BootstrapStateModel {
const existing = cur.stages[name]
if (!existing) {return cur}
if (!existing) return cur
return {
...cur,
stages: {
@@ -169,21 +163,18 @@ 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
}
@@ -194,7 +185,6 @@ export async function initialize(): Promise<void> {
invoke<string>('get_hermes_home'),
invoke<AppMode>('get_mode')
])
$logPath.set(logPath)
$hermesHome.set(hermesHome)
$mode.set(mode)
@@ -205,17 +195,14 @@ 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',
@@ -228,34 +215,26 @@ 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,
@@ -263,7 +242,6 @@ 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
@@ -271,9 +249,7 @@ export async function initialize(): Promise<void> {
if ($mode.get() !== 'update') {
$route.set('success')
}
break
case 'failed':
$bootstrap.set({
...cur,
@@ -282,7 +258,6 @@ export async function initialize(): Promise<void> {
currentStage: null
})
$route.set('failure')
break
}
})
@@ -301,13 +276,10 @@ 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)
@@ -325,10 +297,8 @@ 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.
@@ -340,23 +310,20 @@ 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')
}
@@ -374,9 +341,8 @@ 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
}
@@ -417,18 +383,15 @@ 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
}
@@ -449,16 +412,14 @@ 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}…`)
}
@@ -466,18 +427,15 @@ 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
}
+10 -1
View File
@@ -87,7 +87,16 @@ 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
]
)
})
+9 -1
View File
@@ -573,7 +573,15 @@ 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,7 +110,6 @@ 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',
@@ -1,108 +0,0 @@
/**
* 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.'
)
}
})
+2 -61
View File
@@ -227,10 +227,7 @@ 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)
@@ -257,11 +254,7 @@ 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)
@@ -269,55 +262,3 @@ 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 })
}
})
+3 -20
View File
@@ -265,15 +265,8 @@ 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
// If it doesn't exist locally, we'll get an error anyways
}
// 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)
@@ -378,21 +371,11 @@ 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
+19 -90
View File
@@ -1,3 +1,4 @@
import { execFile, execFileSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
@@ -64,6 +65,7 @@ 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 {
@@ -82,14 +84,7 @@ 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,
@@ -132,16 +127,10 @@ 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
@@ -3608,35 +3597,9 @@ 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, contentType } = options.upload
? multipartBody(options.upload)
: {
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
contentType: 'application/json'
}
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
@@ -3652,7 +3615,7 @@ function fetchJson(url, token, options: any = {}) {
{
method: options.method || 'GET',
headers: {
'Content-Type': contentType,
'Content-Type': 'application/json',
'X-Hermes-Session-Token': token,
...(body ? { 'Content-Length': String(body.length) } : {})
}
@@ -4613,13 +4576,14 @@ function buildApplicationMenu() {
submenu: [
IS_MAC
? {
// 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(),
accelerator: 'CommandOrControl+W',
click: () => {
if (previewShortcutActive) {
sendClosePreviewRequested()
} else {
mainWindow?.close()
}
},
label: 'Close'
}
: { role: 'quit' }
@@ -4725,12 +4689,9 @@ function installDevToolsShortcut(window) {
function installPreviewShortcut(window) {
window.webContents.on('before-input-event', (event, input) => {
const key = String(input.key || '').toLowerCase()
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
// 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) {
if (!isPreviewCloseShortcut || !previewShortcutActive) {
return
}
@@ -5496,7 +5457,6 @@ function openPortalLoginWindow() {
if (settled) {
return
}
settled = true
if (pollTimer) {
@@ -5583,7 +5543,6 @@ 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
}
@@ -5952,7 +5911,6 @@ function buildRemoteBlock(remoteUrl, authMode, token, org?: string) {
authMode,
token
}
const orgValue = typeof org === 'string' ? org.trim() : ''
if (orgValue) {
@@ -6948,7 +6906,6 @@ 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
@@ -6956,7 +6913,6 @@ 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)
@@ -7914,12 +7870,6 @@ 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,
@@ -7930,7 +7880,6 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return fetchJson(url, connection.token, {
method: request?.method,
body: request?.body,
upload: request?.upload,
timeoutMs
})
})
@@ -8431,28 +8380,6 @@ 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.
@@ -8539,7 +8466,9 @@ 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
-1
View File
@@ -107,7 +107,6 @@ 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,12 +17,7 @@ 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)
+14 -18
View File
@@ -111,25 +111,21 @@ 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,5 +1,4 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
-1
View File
@@ -53,7 +53,6 @@ export function resolveDefaultWslDistro(): string {
timeout: 2000,
windowsHide: true
})
cachedDistro = parseDefaultDistro(out) || 'Ubuntu'
} catch {
cachedDistro = 'Ubuntu'
+100 -21
View File
@@ -1,37 +1,116 @@
import shared from '../../eslint.config.shared.mjs'
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 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,
{
// 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.
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
},
js.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
},
parser: typescriptParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': typescriptEslint,
'custom-rules': customRules,
perfectionist,
react: reactPlugin,
'react-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' }
}
},
{
// 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.'
}
]
}
]
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
ignores: ['**/node_modules/**', '**/dist/**'],
languageOptions: {
ecmaVersion: 'latest',
globals: { ...globals.node },
sourceType: 'module'
}
},
{
+4 -35
View File
@@ -19,8 +19,7 @@ import {
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
rmSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isMain } from './utils.mjs'
@@ -29,30 +28,6 @@ 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.
@@ -100,11 +75,7 @@ function copyBuildRelease(srcDir, destDir) {
continue
}
if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) {
const destFile = join(destDir, entry.name)
cpSync(join(srcDir, entry.name), destFile)
if (entry.name === 'spawn-helper') {
makeExecutable(destFile)
}
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
}
}
}
@@ -249,7 +220,6 @@ 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.
@@ -269,9 +239,8 @@ export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platfor
continue
}
if (entry.name === 'spawn-helper') {
const destFile = join(destPrebuild, entry.name)
cpSync(join(prebuildDir, entry.name), destFile)
makeExecutable(destFile)
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
chmodSync(join(destPrebuild, entry.name), 0o775)
}
}
}
@@ -2,7 +2,6 @@ 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 {
@@ -44,19 +43,6 @@ 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', () => {
@@ -276,66 +262,6 @@ 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 {
+18 -18
View File
@@ -1,48 +1,48 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import { Codicon } from '@/components/ui/codicon'
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.
* The label names the outcome (attach files / link this chat); the last kind is
* held through the fade-out so it doesn't blank.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const { t } = useI18n()
const lastKind = useRef<DragKind>(kind)
const lastKind = useRef<'files' | 'session'>('files')
if (kind) {
lastKind.current = kind
}
const shown = kind ?? lastKind.current
const resolvedKind = kind ?? lastKind.current
const icon = ICONS[resolvedKind]
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<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 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>
)
}
-29
View File
@@ -1,29 +0,0 @@
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,12 +6,6 @@ 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.
@@ -85,5 +79,7 @@ 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,7 +18,6 @@ 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'
@@ -40,9 +39,6 @@ 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 (
<>
@@ -94,18 +90,6 @@ 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">
@@ -1,54 +0,0 @@
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' })
})
})
@@ -1,94 +0,0 @@
/**
* 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))
}
+6 -14
View File
@@ -13,9 +13,7 @@
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
/** 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 ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
@@ -78,10 +76,6 @@ 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) })
@@ -135,14 +129,12 @@ export const requestComposerSubmit = (
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** 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) })
/** 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() })
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
export const onComposerVoiceToggleRequest = (handler: () => void) =>
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
/**
* Focus a composer input across React commit + browser focus restore.
@@ -1,9 +1,8 @@
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
@@ -18,8 +17,6 @@ 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.
@@ -27,7 +24,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran
(path: string) => {
const text = draftRef.current
clearDraft()
scope.attachments.clear()
clearComposerAttachments()
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 { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
import {
@@ -21,7 +21,6 @@ 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 {
@@ -51,8 +50,6 @@ 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.
@@ -101,8 +98,8 @@ export function useComposerDraft({
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
markActiveComposer(target)
}, [target])
markActiveComposer('main')
}, [])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
@@ -158,14 +155,14 @@ export function useComposerDraft({
return undefined
}
const offFocus = onComposerFocusRequest(requested => {
if (requested === target) {
const offFocus = onComposerFocusRequest(target => {
if (target === 'main') {
setFocusRequestId(id => id + 1)
}
})
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
if (requested === target) {
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
if (target === 'main') {
appendExternalText(text, mode)
}
})
@@ -174,13 +171,13 @@ export function useComposerDraft({
offFocus()
offInsert()
}
}, [appendExternalText, inputDisabled, target])
}, [appendExternalText, inputDisabled])
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
stashSessionDraft(scope, text, attachments)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
attachmentScope.$attachments.set(cloneAttachments(attachments))
$composerAttachments.set(cloneAttachments(attachments))
paintDraft(text, false)
}
@@ -299,12 +296,12 @@ export function useComposerDraft({
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target: requested }) => {
if (requested === target) {
return onComposerInsertRefsRequest(({ refs, target }) => {
if (target === 'main') {
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,15 +2,10 @@ 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
}
/**
@@ -20,7 +15,7 @@ interface UseComposerEscCancelOptions {
* the window listener registered exactly once while still reading fresh
* busy/awaitingInput/onCancel each press.
*/
export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
export function useComposerEscCancel({ awaitingInput, busy, onCancel }: 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
@@ -35,12 +30,6 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }:
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_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
interface UseComposerMetricsArgs {
composerRef: RefObject<HTMLFormElement | null>
@@ -24,13 +24,10 @@ 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
@@ -75,7 +72,6 @@ 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
@@ -109,13 +105,6 @@ 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
@@ -167,7 +156,5 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
}
}, [])
// 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 }
return { stacked: expanded || narrow || tight }
}
@@ -11,8 +11,6 @@ import {
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerScope } from '../scope'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@@ -27,10 +25,7 @@ interface UseComposerPopoutOptions {
* window's composer out via the shared atom.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
// 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 popoutAllowed = !isSecondaryWindow()
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 { type ComposerAttachment } from '@/store/composer'
import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
@@ -19,7 +19,6 @@ import {
import { notify } from '@/store/notifications'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerQueueArgs {
@@ -61,7 +60,6 @@ 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
@@ -175,11 +173,11 @@ export function useComposerQueue({
}
clearDraft()
scope.attachments.clear()
clearComposerAttachments()
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
}, [activeQueueSessionKey, attachments, clearDraft, draftRef])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
@@ -2,14 +2,13 @@ import { type RefObject, useEffect, useRef } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { clearComposerAttachments, 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 {
@@ -72,8 +71,6 @@ 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[]) => {
@@ -166,7 +163,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
scope.attachments.clear()
clearComposerAttachments()
dispatchSubmit(text, submittedAttachments)
}
@@ -8,7 +8,6 @@ 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'
@@ -26,9 +25,6 @@ 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
}
/**
@@ -46,8 +42,7 @@ export function useComposerVoice({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
sessionId,
target
sessionId
}: UseComposerVoiceArgs) {
const { t } = useI18n()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
@@ -127,10 +122,7 @@ export function useComposerVoice({
}
}, [conversation, disabled, voiceConversationActive])
useEffect(
() => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
[target, toggleVoiceConversation]
)
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
+23 -54
View File
@@ -1,21 +1,21 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, 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 } from '@/store/session'
import { $gatewayState, $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
@@ -23,7 +23,6 @@ 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'
@@ -52,7 +51,6 @@ 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'
@@ -81,36 +79,17 @@ export function ChatBar({
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit: onSubmitProp,
onSubmit,
onTranscribeAudio
}: ChatBarProps) {
// 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 attachments = useStore($composerAttachments)
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(scope.$awaitingInput)
const awaitingInput = useStore($activeSessionAwaitingInput)
const activeQueueSessionKey = queueSessionKey || sessionId || null
// Status items (subagents, background processes) are keyed by the RUNTIME
@@ -209,7 +188,7 @@ export function ChatBar({
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
@@ -218,6 +197,8 @@ 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({
@@ -286,7 +267,7 @@ export function ChatBar({
normalizeComposerEditorDom(editor)
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
const nextDraft = composerPlainText(editor)
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
@@ -351,7 +332,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 = sanitizeComposerInput(event.clipboardData.getData('text').trim())
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
@@ -525,11 +506,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(scope.readMessages(), chatMessageText)
const history = deriveUserHistory($messages.get(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
loadIntoComposer(entry, scope.attachments.$attachments.get())
loadIntoComposer(entry, $composerAttachments.get())
}
return
@@ -550,11 +531,11 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const history = deriveUserHistory($messages.get(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
loadIntoComposer(result.text, scope.attachments.$attachments.get())
loadIntoComposer(result.text, $composerAttachments.get())
}
}
@@ -662,7 +643,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, target: scope.target })
useComposerEscCancel({ awaitingInput, busy, onCancel })
const {
conversation,
@@ -682,8 +663,7 @@ export function ChatBar({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
sessionId,
target: scope.target
sessionId
})
const contextMenu = (
@@ -705,7 +685,7 @@ export function ChatBar({
busyAction={busyAction}
canSteer={canSteer}
canSubmit={canSubmit}
compactModelPill={poppedOut || compactPill}
compactModelPill={poppedOut}
conversation={{
active: voiceConversationActive,
level: conversation.level,
@@ -761,7 +741,7 @@ export function ChatBar({
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer(scope.target)}
onFocus={() => markActiveComposer('main')}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
@@ -865,7 +845,7 @@ export function ChatBar({
: undefined
}
>
{isHelpHint && <HelpHint />}
{showHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
@@ -955,10 +935,6 @@ 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 && (
@@ -994,17 +970,10 @@ 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 gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
</div>
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
</div>
</div>
@@ -1,5 +1,4 @@
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'
@@ -9,22 +8,44 @@ 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 }
/** A dragged sidebar session — carried in-memory by the pointer drag session
* (session-drag.ts); sessions never ride native DnD. */
/** MIME for an in-app session drag (sidebar row → composer). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
export interface SessionDragPayload {
id: string
profile: string
title: string
}
/** 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))
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
}
}
/** 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(payload: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
@@ -1,47 +0,0 @@
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,46 +255,22 @@ 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
}
export function useComposerActions({
activeSessionId,
currentCwd,
requestGateway,
scope = MAIN_ACTIONS_SCOPE
}: ComposerActionsOptions) {
/** 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) {
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' })
}, [])
@@ -312,24 +288,21 @@ export function useComposerActions({
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]
)
attachToMain({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
detail,
refText
})
}, [])
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
@@ -356,7 +329,7 @@ export function useComposerActions({
})
}
},
[attachToMain, currentCwd]
[currentCwd]
)
const insertContextPathInlineRef = useCallback(
@@ -371,12 +344,12 @@ export function useComposerActions({
return false
}
requestComposerInsertRefs([ref], { target: scope.target })
requestComposerFocus(scope.target)
requestComposerInsertRefs([ref])
requestComposerFocus('main')
return true
},
[currentCwd, scope.target]
[currentCwd]
)
const attachContextFilePath = useCallback(
@@ -398,7 +371,7 @@ export function useComposerActions({
return true
},
[attachToMain, currentCwd]
[currentCwd]
)
const attachImagePath = useCallback(
@@ -421,7 +394,7 @@ export function useComposerActions({
const previewUrl = await attachmentPreviewDataUrl(filePath)
if (previewUrl) {
scope.add({ ...baseAttachment, previewUrl })
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
@@ -431,7 +404,7 @@ export function useComposerActions({
return true
}
},
[attachToMain, copy.imagePreviewFailed, scope]
[copy.imagePreviewFailed]
)
const attachImageBlob = useCallback(
@@ -536,7 +509,7 @@ export function useComposerActions({
return true
},
[attachToMain, currentCwd]
[currentCwd]
)
const attachDroppedItems = useCallback(
@@ -626,7 +599,7 @@ export function useComposerActions({
const removeAttachment = useCallback(
async (id: string) => {
const removed = scope.remove(id)
const removed = removeComposerAttachment(id)
if (
removed?.kind === 'image' &&
@@ -641,7 +614,7 @@ export function useComposerActions({
}).catch(() => undefined)
}
},
[activeSessionId, requestGateway, scope]
[activeSessionId, requestGateway]
)
return {
@@ -1,75 +1,53 @@
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
import {
dragHasAttachments,
dragHasSession,
readSessionDrag,
type SessionDragPayload
} from '@/app/chat/composer/inline-refs'
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 =>
dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null
const dragKindOf = (event: ReactDragEvent): DragKind => {
if (dragHasSession(event.dataTransfer)) {
return 'session'
}
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return 'files'
}
return 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 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
* "Drop anywhere in this region" affordance for files *and* in-app session
* links. 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 }: FileDropZoneOptions) {
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: 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
@@ -79,12 +57,6 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
}
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)
},
@@ -117,17 +89,16 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
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 (claimed) {
if (kind === 'session') {
const session = readSessionDrag(event.dataTransfer)
if (session) {
onDropSession?.(session)
}
return
}
@@ -137,7 +108,7 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
onDropFiles(files)
}
},
[enabled, onDropFiles, reset]
[enabled, onDropFiles, onDropSession, reset]
)
return {
+108 -84
View File
@@ -1,22 +1,32 @@
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
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 } from 'react'
import { Suspense, useCallback, useMemo, useRef } 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 { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import {
coalesceToolOnlyAssistants,
createToolMergeCache,
quickModelOptions,
sessionTitle,
toRuntimeMessage
} from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
@@ -25,14 +35,23 @@ 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'
@@ -44,15 +63,12 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitle
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { useComposerScope } from './composer/scope'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { useRuntimeMessageRepository } from './runtime-repository'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'
@@ -106,7 +122,7 @@ function ChatHeader({
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession =
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
@@ -144,7 +160,14 @@ function ChatHeader({
sideOffset={8}
title={title}
>
<TitleMenuTrigger>{title}</TitleMenuTrigger>
<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>
</SessionActionsMenu>
</div>
</header>
@@ -184,9 +207,45 @@ function ChatRuntimeBoundary({
onThreadMessagesChange,
suppressMessages
}: ChatRuntimeBoundaryProps) {
const storeMessages = useStore(useSessionView().$messages)
const storeMessages = useStore($messages)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
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 runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
@@ -234,24 +293,13 @@ export function ChatView({
}: ChatViewProps) {
const location = useLocation()
const { t } = useI18n()
// 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 activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
// 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)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
const petActive = useStore($petActive)
const petOverlayActive = useStore($petOverlayActive)
@@ -262,18 +310,16 @@ export function ChatView({
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
// 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)
// 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)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
// A tile IS its session — no route involved, never "mismatched".
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
@@ -285,7 +331,6 @@ 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 &&
@@ -304,7 +349,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 = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
@@ -375,33 +420,23 @@ export function ChatView({
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: composerScope.target })
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[composerScope.target, currentCwd, onAttachDroppedItems]
[currentCwd, onAttachDroppedItems]
)
// 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 })
// 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' })
}, [])
// 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
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
return (
<div
@@ -409,26 +444,17 @@ 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 />
{/* 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}
/>
)}
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
{/* 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} />
<PromptOverlays />
<ChatRuntimeBoundary
busy={busy}
@@ -484,9 +510,7 @@ export function ChatView({
}}
/>
)}
{/* 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} />
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:
-121
View File
@@ -1,121 +0,0 @@
/**
* 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 } from './preview'
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
@@ -498,10 +498,8 @@ 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('active')
requestComposerFocus('main')
}
window.addEventListener('keydown', onKeyDown, { capture: true })
@@ -10,7 +10,6 @@ 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'
@@ -39,6 +38,14 @@ 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
@@ -103,12 +110,7 @@ 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={cn(
'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)',
PANE_TAB_STRIP_LINE
)}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<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"
@@ -122,20 +124,67 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
return (
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
<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)" />
)}
<Tip label={tab.target.path || tab.target.url || tab.label}>
<PaneTabLabel
<button
aria-selected={active}
as="button"
className="normal-case tracking-normal"
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
{tab.label}
</PaneTabLabel>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
</PaneTab>
<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>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
-90
View File
@@ -1,90 +0,0 @@
/**
* 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
})
@@ -1,51 +0,0 @@
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])
}
-193
View File
@@ -1,193 +0,0 @@
/**
* 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 })
}
}
})
}
@@ -1,372 +0,0 @@
/**
* 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
]
)
}
-436
View File
@@ -1,436 +0,0 @@
/**
* 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
})
@@ -1,61 +0,0 @@
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)
+70 -127
View File
@@ -3,12 +3,10 @@ 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'
@@ -21,7 +19,6 @@ 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'
@@ -37,6 +34,8 @@ import {
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
$sidebarProjectOrderIds,
$sidebarRecentsOpen,
@@ -79,7 +78,6 @@ import {
refreshWorktrees,
scanAndRecordRepos
} from '@/store/projects'
import { openRouteTile } from '@/store/route-tiles'
import {
$cronSessions,
$currentCwd,
@@ -87,6 +85,7 @@ import {
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
@@ -95,16 +94,8 @@ import {
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import {
type AppView,
ARTIFACTS_ROUTE,
MESSAGING_ROUTE,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
SKILLS_ROUTE
} from '../../routes'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import type { SidebarNavItem } from '../../types'
import { countLabel } from './chrome'
@@ -130,7 +121,6 @@ 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
@@ -219,8 +209,6 @@ 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
}
@@ -236,49 +224,22 @@ export function ChatSidebar({
onArchiveSession,
onBranchSession,
onNewSessionInWorkspace,
onNewSessionSplit,
onManageCronJob,
onTriggerCronJob
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
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 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 panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const cronOpen = useStore($sidebarCronOpen)
// 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 selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
@@ -1070,12 +1031,15 @@ 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',
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
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'
)}
collapsible="none"
>
@@ -1083,85 +1047,62 @@ 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, ...contributedNav].map(item => {
{SIDEBAR_NAV.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') ||
// Contributed rows light up at their own route.
(Boolean(item.route) && pathname === item.route)
(item.id === 'artifacts' && currentView === 'artifacts')
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}>
{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
)}
<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>
</SidebarMenuItem>
)
})}
@@ -1169,7 +1110,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{showSessionSections && (
{contentVisible && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@@ -1181,7 +1122,7 @@ export function ChatSidebar({
</div>
)}
{showSessionSections && (
{contentVisible && showSessionSections && (
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
{trimmedQuery && (
<SidebarSessionsSection
@@ -1451,11 +1392,13 @@ export function ChatSidebar({
</div>
)}
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
</SidebarContent>
<ProjectDialog />
</Sidebar>
@@ -35,12 +35,6 @@ 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 {
@@ -73,11 +67,12 @@ 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
// 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
// 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}`
// 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
@@ -179,7 +174,7 @@ export function ProfileRail() {
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
reorderStepHaptic()
triggerHaptic('selection')
}
}
@@ -196,7 +191,7 @@ export function ProfileRail() {
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
reorderCommitHaptic()
triggerHaptic('success')
}
}
@@ -16,9 +16,6 @@ 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,22 +1,9 @@
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,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
@@ -26,13 +13,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
@@ -41,7 +22,6 @@ 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'
@@ -100,31 +80,10 @@ 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
@@ -142,45 +101,26 @@ function useSessionActions({
onPin,
onBranch,
onArchive,
onDelete,
onClose,
onHideTabBar,
surface = 'row',
tabPaneId
onDelete
}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const tiles = useStore($sessionTiles)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
// 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 pinItem: ItemSpec = {
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
}
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')
}
})
]
: []),
const items: ItemSpec[] = [
...(canOpenSessionWindow()
? [
spec({
{
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
@@ -188,45 +128,10 @@ function useSessionActions({
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
})
}
]
: [])
]
// IDENTITY — name/mark/reference the session.
const identityItems: ItemSpec[] = [
spec({
disabled: !sessionId,
icon: 'edit',
label: r.rename,
onSelect: () => {
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,
@@ -234,65 +139,26 @@ function useSessionActions({
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: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
disabled: !sessionId,
icon: 'edit',
label: r.rename,
onSelect: () => {
triggerHaptic('selection')
setRenameOpen(true)
}
},
{
disabled: !onArchive,
icon: 'archive',
label: r.archive,
@@ -300,7 +166,7 @@ function useSessionActions({
triggerHaptic('selection')
onArchive?.()
}
}),
},
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@@ -321,13 +187,11 @@ function useSessionActions({
</Item>
)
const renderItems = (kit: MenuKit) => (
const renderItems = (Item: MenuItem) => (
<>
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
{renderMenuItem(Item, pinItem)}
<CopyButton
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
@@ -336,30 +200,7 @@ function useSessionActions({
onCopyError={err => notifyError(err, r.copyIdFailed)}
text={sessionId}
/>
<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()
}
})}
</>
)}
{items.map(spec => renderMenuItem(Item, spec))}
</>
)
@@ -384,11 +225,10 @@ 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 onOpenChange={setOpen} open={open}>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
@@ -396,7 +236,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DROPDOWN_KIT)}
{renderItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
{renameDialog}
@@ -417,7 +257,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(CONTEXT_KIT)}
{renderItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
{renameDialog}

Some files were not shown because too many files have changed in this diff Show More