Merge remote-tracking branch 'origin/main' into agent/81234-merge-20260821
# Conflicts: # tests/agent/test_reference_handoff_active_turn.py
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# actionlint knows only GitHub-hosted runner labels. An org admin names the
|
||||
# larger runners. Each one therefore reads as "unknown runner label" and hides
|
||||
# the real findings, unless this file declares it.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- ubuntu-latest-96-core
|
||||
- ubuntu-latest-32-core
|
||||
- ubuntu-latest-32-arm-core
|
||||
- windows-latest-32-core
|
||||
@@ -0,0 +1,141 @@
|
||||
// Run every workspace check at the same time and report all failures.
|
||||
//
|
||||
// The unit of work is a CHECK, and not a workspace. A package that declares
|
||||
// `check:*` sub-scripts gives one unit for each sub-script. A package with a
|
||||
// plain `check` gives that. This is the same selection rule the old CI matrix
|
||||
// used, so the set of commands is unchanged. Only the schedule is different.
|
||||
//
|
||||
// This is not `npm run --ws check`, because that command is serial and stops
|
||||
// at the first workspace that fails. This runs every unit and fails at the
|
||||
// end with the full list.
|
||||
//
|
||||
// The output of each unit goes to a buffer and prints on completion inside a
|
||||
// group that collapses. Children that write to one stdout together interleave
|
||||
// their lines, and a failure is then hard to read.
|
||||
//
|
||||
// This also runs on a laptop: `node .github/scripts/run-workspace-checks.mjs`.
|
||||
// `--concurrency N` sets the limit. `--list` prints the units and exits.
|
||||
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { availableParallelism } from 'node:os'
|
||||
|
||||
const IS_CI = Boolean(process.env.GITHUB_ACTIONS)
|
||||
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
|
||||
/** @returns {{pkg: string, script: string}[]} */
|
||||
function discoverUnits() {
|
||||
const raw = execFileSync(NPM, ['query', '.workspace'], {
|
||||
encoding: 'utf-8',
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
/** @type {{location: string, scripts?: Record<string,string>}[]} */
|
||||
const pkgs = JSON.parse(raw)
|
||||
|
||||
/** @type {{pkg: string, script: string}[]} */
|
||||
const units = []
|
||||
for (const pkg of pkgs) {
|
||||
const scripts = pkg.scripts || {}
|
||||
const subs = Object.keys(scripts).filter((s) => /^check:.+$/.test(s))
|
||||
if (subs.length > 0) {
|
||||
for (const script of subs) units.push({ pkg: pkg.location, script })
|
||||
} else if (scripts.check) {
|
||||
units.push({ pkg: pkg.location, script: 'check' })
|
||||
}
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
/** @param {{pkg: string, script: string}} unit */
|
||||
function runUnit(unit) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now()
|
||||
const child = spawn(NPM, ['run', '--prefix', unit.pkg, unit.script], {
|
||||
// Buffer, and do not inherit. Children that share one stdout
|
||||
// interleave their lines, and a failure is then hard to read.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
/** @type {Buffer[]} */
|
||||
const chunks = []
|
||||
child.stdout.on('data', (c) => chunks.push(c))
|
||||
child.stderr.on('data', (c) => chunks.push(c))
|
||||
child.on('error', (err) => {
|
||||
chunks.push(Buffer.from(`failed to spawn: ${err.message}\n`))
|
||||
resolve({ unit, code: 1, output: Buffer.concat(chunks).toString('utf-8'), ms: Date.now() - started })
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
unit,
|
||||
code: code ?? 1,
|
||||
output: Buffer.concat(chunks).toString('utf-8'),
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
const units = discoverUnits()
|
||||
|
||||
if (units.length === 0) {
|
||||
console.error(
|
||||
'::error::No workspace package declares a check script — refusing to report green having run nothing.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (argv.includes('--list')) {
|
||||
for (const u of units) console.log(`${u.pkg} :: ${u.script}`)
|
||||
return
|
||||
}
|
||||
|
||||
const flagIdx = argv.indexOf('--concurrency')
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
flagIdx !== -1 ? Number(argv[flagIdx + 1]) : Math.min(units.length, availableParallelism()),
|
||||
)
|
||||
|
||||
console.log(`running ${units.length} checks, up to ${concurrency} at a time:`)
|
||||
for (const u of units) console.log(` ${u.pkg} :: ${u.script}`)
|
||||
console.log('')
|
||||
|
||||
const queue = [...units]
|
||||
/** @type {{unit: {pkg: string, script: string}, code: number, output: string, ms: number}[]} */
|
||||
const results = []
|
||||
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const unit = queue.shift()
|
||||
if (!unit) return
|
||||
const res = await runUnit(unit)
|
||||
results.push(res)
|
||||
const label = `${res.unit.pkg} :: ${res.unit.script}`
|
||||
const secs = (res.ms / 1000).toFixed(1)
|
||||
const status = res.code === 0 ? 'PASS' : 'FAIL'
|
||||
if (IS_CI) console.log(`::group::${status} ${label} (${secs}s)`)
|
||||
else console.log(`----- ${status} ${label} (${secs}s) -----`)
|
||||
process.stdout.write(res.output.endsWith('\n') ? res.output : res.output + '\n')
|
||||
if (IS_CI) console.log('::endgroup::')
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, units.length) }, worker))
|
||||
|
||||
const failed = results.filter((r) => r.code !== 0)
|
||||
console.log('\n=== summary ===')
|
||||
for (const r of [...results].sort((a, b) => b.ms - a.ms)) {
|
||||
console.log(
|
||||
` ${r.code === 0 ? 'pass' : 'FAIL'} ${(r.ms / 1000).toFixed(1).padStart(6)}s ${r.unit.pkg} :: ${r.unit.script}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
for (const r of failed) console.error(`::error::${r.unit.pkg} :: ${r.unit.script} failed`)
|
||||
console.error(`::error::${failed.length} of ${results.length} checks failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nall ${results.length} checks passed`)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
detect:
|
||||
name: Detect affected areas
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 1
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
python_prod: ${{ steps.classify.outputs.python_prod }}
|
||||
@@ -61,6 +61,8 @@ jobs:
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
sparse-checkout: scripts/ci/classify_changes.py
|
||||
sparse-checkout-cone-mode: false
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -72,8 +74,6 @@ jobs:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
slice_count: 12
|
||||
|
||||
# macOS + Windows lanes. The main `tests` lane above is Linux-only, and
|
||||
# the OS-marked tests it collects are skipped there by design (see the
|
||||
|
||||
@@ -76,12 +76,14 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
runner: ubuntu-latest-32-core
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
# arm64 builds on the native arm64 larger runner. A build of
|
||||
# linux/arm64 on an x64 host uses emulation.
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runner: ubuntu-latest-32-arm-core
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
@@ -169,7 +171,11 @@ jobs:
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
# Each of these tests drives a container, so the docker daemon sets
|
||||
# the limit and not the processor. This caps the workers. The
|
||||
# default from run_tests.sh is cpu_count*2, which starts 64
|
||||
# containers together on the 32-core amd64 runner.
|
||||
HERMES_TEST_WORKERS=$(nproc) scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebuild and push each architecture only after the unprivileged build/test
|
||||
@@ -184,12 +190,13 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
runner: ubuntu-latest-32-core
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
# Native arm64 for the same reason as the build matrix above.
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runner: ubuntu-latest-32-arm-core
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
@@ -17,7 +17,10 @@ concurrency:
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
# This job builds the renderer and the electron bundle, then drives a real
|
||||
# Electron app under xvfb. vite, tsc and the Playwright workers all scale
|
||||
# with the core count.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
review_status: ${{ steps.review-status.outputs.review_status }}
|
||||
|
||||
@@ -5,87 +5,17 @@ on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
checks: ${{ steps.set-matrix.outputs.checks }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
- name: grab npm 12
|
||||
run: |
|
||||
# No-op once the bundled npm is already 12.x — saves ~5-15s/job and
|
||||
# keeps the installed major aligned with the npm12 cache-key tag.
|
||||
npm --version | grep -q '^12\.' || npm i -g npm@12
|
||||
|
||||
# ``setup-node``'s ``cache: npm`` only caches the ~/.npm tarball cache;
|
||||
# every job still re-extracts the full workspace node_modules and reruns
|
||||
# postinstalls (including the Electron binary fetch). Cache the installed
|
||||
# tree itself, keyed on the lockfile, and skip ``npm ci`` on an exact
|
||||
# hit. No restore-keys: a partial hit would leave a stale tree, so
|
||||
# anything but an exact lockfile match reinstalls from scratch.
|
||||
# The discovery job installs with --ignore-scripts, so its tree differs
|
||||
# from the check jobs' — hence the distinct ``-noscripts`` key.
|
||||
- name: Restore node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
apps/*/node_modules
|
||||
ui-tui/node_modules
|
||||
ui-tui/packages/*/node_modules
|
||||
tests-js/node_modules
|
||||
web/node_modules
|
||||
key: node-modules-noscripts-${{ runner.os }}-node26-npm12-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- uses: ./.github/actions/retry
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- id: set-matrix
|
||||
run: |
|
||||
node -e '
|
||||
const { execSync } = require("child_process");
|
||||
const pkgs = JSON.parse(execSync("npm query .workspace", { encoding: "utf-8" }));
|
||||
if (pkgs.length === 0) {
|
||||
console.error("::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently).");
|
||||
process.exit(1);
|
||||
}
|
||||
const checks = [];
|
||||
for (const pkg of pkgs) {
|
||||
const scripts = pkg.scripts || {};
|
||||
const subs = Object.keys(scripts).filter(s => /^check:.+$/.test(s));
|
||||
if (subs.length > 0) {
|
||||
for (const script of subs) {
|
||||
checks.push({ package: pkg.location, script });
|
||||
}
|
||||
} else if (scripts.check) {
|
||||
checks.push({ package: pkg.location, script: "check" });
|
||||
}
|
||||
}
|
||||
if (checks.length === 0) {
|
||||
console.error("::error::No check scripts found in any workspace package.");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("checks=" + JSON.stringify(checks) + "\n");
|
||||
' >> "$GITHUB_OUTPUT"
|
||||
|
||||
check:
|
||||
name: ${{ matrix.package }} / ${{ matrix.script }}
|
||||
needs: workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.workspaces.outputs.checks) }}
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
name: JS & TS checks
|
||||
# One 32-core job replaces a 14-leg matrix. The matrix spread about 612s
|
||||
# of check payload over 4-core runners. It paid about 371s of repeated
|
||||
# setup to do it: 14 checkouts, 14 node installs, 14 node_modules
|
||||
# restores.
|
||||
#
|
||||
# One larger runner installs one time. vitest, tsc and eslint each size
|
||||
# their own worker pool from the core count.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
@@ -99,12 +29,21 @@ jobs:
|
||||
# keeps the installed major aligned with the npm12 cache-key tag.
|
||||
npm --version | grep -q '^12\.' || npm i -g npm@12
|
||||
|
||||
# Same rationale as the discovery job's cache above, but this ``npm ci``
|
||||
# runs WITH install scripts, so the tree includes postinstall artifacts
|
||||
# (electron's postinstall unpacks its binary into node_modules/electron/
|
||||
# dist, which lives inside the cached tree — the ~/.cache/electron
|
||||
# download cache is deliberately NOT cached: with npm ci skipped on hit
|
||||
# it would never be read, only inflate the archive).
|
||||
# The ``cache: npm`` option of ``setup-node`` caches only the ~/.npm
|
||||
# tarball cache. The job then extracts the full workspace node_modules
|
||||
# again and runs the postinstalls again, which includes the Electron
|
||||
# binary fetch. This caches the installed tree itself, keyed on the
|
||||
# lockfile, and skips ``npm ci`` on an exact hit. There are no
|
||||
# restore-keys: a partial hit leaves a stale tree, so anything other
|
||||
# than an exact lockfile match reinstalls from the start.
|
||||
#
|
||||
# This install runs WITH scripts, so the tree holds the postinstall
|
||||
# artifacts. The postinstall of electron unpacks its binary into
|
||||
# node_modules/electron/dist, which is inside the cached tree.
|
||||
#
|
||||
# The ~/.cache/electron download cache stays out of the key on purpose.
|
||||
# ``npm ci`` is skipped on a hit, so nothing reads that cache. It only
|
||||
# makes the archive larger.
|
||||
- name: Restore node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
@@ -122,4 +61,23 @@ jobs:
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}
|
||||
|
||||
# Every check runs at the same time. The step fails only after all of
|
||||
# them finish. There are two reasons this is not ``npm run --ws check``.
|
||||
#
|
||||
# * ``--ws`` is serial and stops at the first workspace that fails. A
|
||||
# run then reports one failure, where the matrix this replaced
|
||||
# reported every failure together.
|
||||
# * The unit of work is a CHECK, and not a workspace. apps/desktop is
|
||||
# most of the payload, and its own ``check`` is a serial && chain.
|
||||
# A spread across workspaces alone leaves that chain as the long
|
||||
# pole. This expands the ``check:*`` sub-scripts of a package, so
|
||||
# its lint, ui, electron and plugin suites all run together. That
|
||||
# is the same selection rule the old matrix job used.
|
||||
#
|
||||
# Discovery is ``npm query .workspace``. A new package or a new
|
||||
# ``check:*`` script needs no change here. An empty list is an error and
|
||||
# not an empty run, because an empty run reports green after it checks
|
||||
# nothing.
|
||||
- name: Run all workspace checks
|
||||
run: node .github/scripts/run-workspace-checks.mjs
|
||||
|
||||
@@ -51,8 +51,10 @@ jobs:
|
||||
needs: [detect]
|
||||
if: needs.detect.outputs.nix == 'true'
|
||||
# The build compiles the package and its whole dependency closure, so this
|
||||
# is minutes and not seconds when the cache misses.
|
||||
runs-on: ubuntu-latest
|
||||
# takes minutes and not seconds when the cache misses. `nix flake check`
|
||||
# builds 21 checks, and --max-jobs defaults to the core count. It uses the
|
||||
# wider runner with no more configuration.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -27,7 +27,10 @@ concurrency:
|
||||
jobs:
|
||||
bootstrap-installer:
|
||||
name: cargo test (bootstrap installer)
|
||||
runs-on: ubuntu-latest
|
||||
# cargo builds codegen units and test binaries in parallel across the
|
||||
# cores. This lane also builds the crate from the start when Cargo.toml
|
||||
# changes.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -16,9 +16,9 @@ name: OS-specific tests
|
||||
#
|
||||
# Deliberately NOT sliced. The marked set is small (tens of tests, not
|
||||
# thousands), so one plain ``pytest`` process per OS is both faster and far
|
||||
# less machinery than the LPT-sliced per-file runner the Linux lane needs.
|
||||
# less machinery than the per-file parallel runner the Linux lane uses.
|
||||
# If either lane grows past its timeout, that is the signal to reach for
|
||||
# scripts/run_tests.sh --slice here too.
|
||||
# scripts/run_tests.sh here too.
|
||||
#
|
||||
# Each lane FAILS when it selects zero tests (pytest exit code 5). Without
|
||||
# that guard, a renamed marker or a bad selector would report a green job
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runner: macos-latest
|
||||
marker: macos_only
|
||||
- name: Windows-only tests
|
||||
runner: windows-latest
|
||||
runner: windows-latest-32-core
|
||||
marker: windows_only
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
+34
-93
@@ -2,11 +2,6 @@ name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slice_count:
|
||||
description: Number of parallel test slices
|
||||
type: number
|
||||
default: 8
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,42 +12,17 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: "Generate slices"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
# Saves use test-durations-${run_id}, so the exact key above never
|
||||
# matches — without this prefix fallback the cache ALWAYS missed,
|
||||
# LPT slicing ran on no data, and unbalanced slices pushed heavy
|
||||
# files toward the per-file timeout under load.
|
||||
restore-keys: |
|
||||
test-durations-
|
||||
|
||||
- name: Generate test slices
|
||||
id: matrix
|
||||
run: |
|
||||
MATRIX=$(python3 scripts/run_tests_parallel.py --generate-slices ${{ inputs.slice_count }})
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test:
|
||||
name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
|
||||
needs: generate
|
||||
runs-on: ubuntu-latest
|
||||
name: Run tests
|
||||
# One 96-core runner for the whole suite. There is no slicing. Slicing
|
||||
# existed to spread the suite over 4-core runners. It cost a matrix job, a
|
||||
# duration cache, a per-slice artifact and a merge job to do it.
|
||||
#
|
||||
# 96 cores clear the floor that the slowest single test file sets (about
|
||||
# 82s). A second slice divides work that is already at that floor, and
|
||||
# adds a second setup.
|
||||
runs-on: ubuntu-latest-96-core
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -117,73 +87,44 @@ jobs:
|
||||
# re-download, keeping the persisted cache small and fast to restore.
|
||||
run: uv cache prune --ci
|
||||
|
||||
- name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
|
||||
- name: Run tests
|
||||
# Per-file isolation via scripts/run_tests.sh: each test file runs
|
||||
# in its own freshly-spawned `python -m pytest <file>` subprocess
|
||||
# with bounded parallelism. No xdist, no shared workers, no
|
||||
# module-level state leakage between files.
|
||||
#
|
||||
# File list is pre-computed by the generate job (--generate-slices)
|
||||
# which runs LPT distribution once and passes the file list to each
|
||||
# matrix job via --files. Previously each job re-discovered files and
|
||||
# re-ran LPT independently — redundant N times.
|
||||
# No --files: the runner discovers the suite itself. The discovered
|
||||
# set is identical to the list the removed matrix job used to pass in.
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
|
||||
scripts/run_tests.sh
|
||||
env:
|
||||
# This is the maximum number of test FILES that run together.
|
||||
# run_tests_parallel.py starts one pytest subprocess for each file
|
||||
# from a single ThreadPoolExecutor, so this value IS the limit. The
|
||||
# default is cpu_count*2, which is 192 here.
|
||||
#
|
||||
# Measured on this runner (96-core EPYC 7763, 377GB). Whole suite,
|
||||
# two repetitions for each value. See run 32549672063:
|
||||
#
|
||||
# workers x cores mean
|
||||
# 48 0.5x 138s
|
||||
# 96 1.0x 126s <- fastest
|
||||
# 144 1.5x 132s
|
||||
# 192 2.0x 132s
|
||||
# 240 2.5x 140s
|
||||
# 288 3.0x 142s
|
||||
#
|
||||
# One worker for each core wins. The curve is shallow: 126s to 142s
|
||||
# across a 6x range. The suite has sufficient concurrency at this
|
||||
# size. The remaining time is the slowest files plus the setup.
|
||||
# Workers above the core count only add contention.
|
||||
HERMES_TEST_WORKERS: 96
|
||||
# Ensure tests don't accidentally call real APIs
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
# Advisory artifact (feeds slice balancing) — a transient artifact-
|
||||
# service blip must not fail an otherwise-green test slice.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice.index }}
|
||||
path: test_durations.json
|
||||
retention-days: 1
|
||||
|
||||
# Merge per-slice duration data into a single cache, so future runs
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
# Each slice uploads the same file name (test_durations.json).
|
||||
# With merge-multiple, the parallel downloads write to one path.
|
||||
# This causes two problems: a race can write two JSON documents
|
||||
# into one file, and the last write erases the other slices.
|
||||
# Without merge-multiple, each artifact gets its own directory.
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
|
||||
- name: Merge into single durations file
|
||||
run: |
|
||||
python3 -c "
|
||||
import json, glob, os
|
||||
merged = {}
|
||||
for f in glob.glob('durations/*/test_durations.json'):
|
||||
with open(f) as fh:
|
||||
merged.update(json.load(fh))
|
||||
with open('test_durations.json', 'w') as fh:
|
||||
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||
print(f'Merged {len(merged)} file durations')
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations-${{ github.run_id }}
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Windows venv-holder live E2E
|
||||
|
||||
# ON-DEMAND ONLY (fleet-update #91277, venv-holder consolidation work).
|
||||
#
|
||||
# Runs the live venv-holder E2E suite on a real windows-latest runner:
|
||||
# spawns actual processes with realistic Hermes argv shapes and drives the
|
||||
# REAL detection/classification/exemption code against the live process
|
||||
# table — the coverage that cannot exist on the Linux lanes and that the
|
||||
# maintainer cannot exercise locally before the work reaches main.
|
||||
#
|
||||
# Deliberately NOT wired to pull_request/main: it fires only on pushes to
|
||||
# wine2e/** working branches, so it costs nothing on normal PRs. Delete or
|
||||
# keep dormant after the venv-holder work lands.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "wine2e/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-venv-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
venv-holder-e2e:
|
||||
name: venv-holder live E2E (windows-latest)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
version: "0.9.28"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra dev
|
||||
|
||||
- name: Run venv-holder live E2E
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
uv run --no-sync python -m pytest \
|
||||
tests/hermes_cli/test_venv_holder_windows_live.py \
|
||||
-o addopts= -v -p no:cacheprovider
|
||||
@@ -920,6 +920,72 @@ plug into `agent/context_engine.py`; image-gen providers into
|
||||
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
|
||||
companion repo, not in this tree.
|
||||
|
||||
### Bot Mode (`apps/desktop/src/plugins/hermes-bots/`)
|
||||
|
||||
The desktop "Bots" experience ships bundled in-tree. Each bot is a Hermes
|
||||
agent **profile** with a persistent identity. Its design rests on one settled
|
||||
invariant that has been regressed repeatedly, cost users real conversation
|
||||
history each time, and is not open for re-litigation in a routine PR:
|
||||
|
||||
**One bot = ONE canonical forever-chat, identified by NAME.** The chat's one
|
||||
and only identity is **(profile, session titled exactly "Bot Chat")** — the
|
||||
state DB's UNIQUE(title) index makes that pair an exact registry of at most
|
||||
one row. The full lifecycle when a bot row is clicked:
|
||||
|
||||
1. **Resolve the registry, every time.** Look up the profile's `Bot Chat`
|
||||
session by exact title via `session.list {title, include_hidden: true}`
|
||||
(indexed, window-free; hidden rows resolve because canonical chats are
|
||||
always hidden; compression lineages resolve to the live tip). Row exists →
|
||||
open it. That is the entire happy path.
|
||||
2. **No row → create it,** titled `Bot Chat`, born hidden, kicked off with
|
||||
the bot's intro. Creation adopts-before-minting: it re-runs the registry
|
||||
lookup first, so a concurrent or pre-existing row is opened, never forked.
|
||||
(`set_session_title` silently drops conflicting titles — returns 0 rows —
|
||||
which is how the 2026-08 infinite fork loop started; adopt-before-mint is
|
||||
what kills it.)
|
||||
|
||||
**There is NO session-id pin.** The previous design stored a pointer in
|
||||
`ui_meta['hermes-bots'].chat` and verified it per click; five hardening
|
||||
waves (#88690, #90732, #90751, the #91791 revert, #92042) each guarded a new
|
||||
way that pointer dangled or got stolen — rows[0] steals, `last_session`
|
||||
adoptions, transient clears, drifted-title welds (a pin re-anchored onto a
|
||||
cron session passed every guard). Name-as-identity removes the failure class:
|
||||
a name cannot dangle, and a corrupted historical pointer simply never gets
|
||||
read. Legacy `chat` keys in ui_meta are ignored and dropped from merges.
|
||||
|
||||
Why recency must never win (the #91791 → #92042 lesson): canonical Bot
|
||||
Chats are **unconditionally hidden** from the Sessions sidebar, so the bot
|
||||
row is the ONLY door to the forever-chat. A "newest visible session wins"
|
||||
preference doesn't re-order two equivalent entry points — it walls the
|
||||
entire relationship off behind a row that previews one session and opens
|
||||
another, and any stray draft that catches a prompt captures the row.
|
||||
Side-chats started via "New chat with this agent" are not plumbing-titled,
|
||||
stay visible in the Sessions sidebar, and are reachable there; they are
|
||||
never the bot row's target.
|
||||
|
||||
Corollaries for reviewers:
|
||||
|
||||
- There is no per-bot session browser, by explicit design (removed in
|
||||
#90732). Do not add one back.
|
||||
- Reject any PR that reintroduces a stored session-id pointer as canonical
|
||||
identity — including "as a fallback tier" or "for verification". The
|
||||
registry lookup is the whole contract; pointers are how every prior
|
||||
incident started.
|
||||
- Reject any PR that consults recency, visibility, or "where the user left
|
||||
off" for the bot row's target — reports that motivate such a change are
|
||||
almost always about side-chats, and the fix belongs in the Sessions
|
||||
sidebar (hide-sweep false positives), not in the bot row's target.
|
||||
- The gateway reports the registry row per profile as `canonical_session`
|
||||
on `profiles.list` (resolved server-side by title); roster preview,
|
||||
activity signals, and the `/new`→`/compact` guard all read it, so preview
|
||||
identity and click identity are the same row by construction.
|
||||
|
||||
Regression tests encoding this contract:
|
||||
`tests/canonical-chat-registry.test.mjs` (includes a tripwire asserting the
|
||||
open path never reads or writes a stored pointer),
|
||||
`tests/canonical-chat-creation.test.mjs`, `tests/hide-bot-chats.test.mjs`,
|
||||
and `tests/tui_gateway/test_profiles_list_canonical_session.py`.
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
@@ -1201,6 +1267,79 @@ Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
|
||||
|
||||
---
|
||||
|
||||
## Update Pipeline (`hermes update`)
|
||||
|
||||
The updater is transactional in shape (fleet-update campaign, #91277 —
|
||||
Aug 2026). Every stage exists because its absence was a real field
|
||||
failure; PRs that weaken a stage need to answer for the failure class it
|
||||
guards:
|
||||
|
||||
```
|
||||
plan → snapshot → apply → restart-per-kind → verify → report
|
||||
```
|
||||
|
||||
- **Plan** (`hermes_cli/update_inventory.py`, `hermes update --plan`):
|
||||
read-only inventory — install kind, all profiles, every live gateway
|
||||
with supervisor + running code version. Deployment kinds are
|
||||
first-class: `git` updates in place; `docker`/`nix`/`apt` are NOT
|
||||
in-place-updatable and the updater reports the correct external
|
||||
command instead of fighting the deployment model.
|
||||
- **Snapshot** (`hermes_cli/backup.py`): pre-update quick snapshot for
|
||||
EVERY profile (the code swap + fleet restart touch all of them), each
|
||||
into its own `state-snapshots/`, identical file set + 1 GiB per-file
|
||||
cap + keep=1. **Never add a partial/tiered snapshot set** — mixed
|
||||
coverage creates torn-restore states across schema generations. Quick
|
||||
snapshots are FILE-LOSS RECOVERY (the per-profile cron-jobs safety
|
||||
net restores from them), NOT code-rollback insurance; `--backup` full
|
||||
mode owns rollback.
|
||||
- **Apply**: git pull, or the Windows ZIP fallback — which fires ONLY
|
||||
when git itself failed (`_should_zip_fallback_on_update_error`,
|
||||
argv-classified; a dependency-install failure must never trigger a
|
||||
tree-clobbering re-download), REFUSES a dirty working tree
|
||||
(`-uall`, plus a pre-swap TOCTOU re-check), and grafts the live
|
||||
`apps/desktop/release/` into the staged swap (the GitHub source ZIP
|
||||
has no built desktop app; without the graft the swap deletes it).
|
||||
- **Restart-per-kind**: systemd and launchd restarts are FLEET-WIDE
|
||||
(every `hermes-gateway*` unit / `ai.hermes.gateway*` LaunchAgent),
|
||||
drain-first (SIGUSR1) with per-unit/per-label failure isolation.
|
||||
Restarting only the invoking profile's service leaves siblings on
|
||||
stale `sys.modules` until they crash — the largest dupe-PR cluster in
|
||||
the repo's history came from that bug.
|
||||
- **Verify**: gateways stamp their running `code_sha`/`code_version`
|
||||
into `gateway_state.json` on every runtime-status write
|
||||
(`gateway/status.py`); after the restart phase the updater compares
|
||||
each live gateway against the fresh checkout and prints a fleet
|
||||
version matrix. A provably-stale gateway fails the update (exit 1) —
|
||||
automation must never treat a mixed-version fleet as healthy.
|
||||
- **Report**: every run writes a machine-readable receipt to
|
||||
`~/.hermes/logs/update_receipts/` (`latest.json` pointer; steps,
|
||||
skips WITH reasons, restart outcome, plan, fleet snapshot).
|
||||
Finalization is owned by the `cmd_update` command boundary — early
|
||||
`sys.exit` paths (preflight refusals, fetch failures) still persist
|
||||
a receipt with the real exit code. A begun-but-unwritten receipt is
|
||||
a bug: the refused/failed runs are the ones receipts exist for.
|
||||
|
||||
Architecture direction: process-scan-based coordination between the
|
||||
updater, serve/dashboard, and the gateway is being replaced by a
|
||||
gateway-owned control socket (#92091). Do not add new scan heuristics
|
||||
without checking that design; scans are the fallback layer.
|
||||
|
||||
### Gateway lifecycle vs. the Desktop app
|
||||
|
||||
`hermes serve` (control plane, desktop-spawned child) dies with the app
|
||||
— by design. The messaging gateway (`gateway run`) SURVIVES the app: the
|
||||
serve backend's `/api/gateway/*` endpoints spawn it detached
|
||||
(`_spawn_hermes_action` — `start_new_session` / `DETACHED_PROCESS`), so
|
||||
`before-quit`'s backend SIGTERM never reaches it. Bots keep running
|
||||
when the user closes the app. The known breach of this contract is the
|
||||
Windows shim-unlock teardown (`taskkill /T /F` on venv-shim holders,
|
||||
#85265) — it exists to let updates proceed, and its replacement is
|
||||
#92091's `pause-for-update`. Do not "fix" gateway-dies-with-app reports
|
||||
by re-parenting the gateway under the backend, and do not "fix" update
|
||||
locks by widening the tree-kill.
|
||||
|
||||
---
|
||||
|
||||
## Important Policies
|
||||
|
||||
### Prompt Caching Must Not Break
|
||||
@@ -1314,6 +1453,27 @@ automatically scope to the active profile.
|
||||
|
||||
## Known Pitfalls
|
||||
|
||||
### DO NOT infer process identity from argv substrings
|
||||
The bug class behind ~10 fleet-update issues (#90778, #87594, #78089,
|
||||
#76129, #91964, ...): classifying a process by `"serve" in cmdline` or
|
||||
similar. `kanban --preserve-cache` contains "serve"; a flag VALUE can
|
||||
equal a subcommand (`-m dashboard serve`); truncated cmdlines hide the
|
||||
real subcommand. Rules:
|
||||
- Use the canonical matchers: `gateway.status.looks_like_gateway_command_line`
|
||||
(gateway run), `hermes_cli.update_cmd._hermes_holder_subcommand`
|
||||
(top-level subcommand of any Hermes argv). Never hand-roll token scans.
|
||||
- Flag sets must be DERIVED from the parser
|
||||
(`_holder_value_flags()` introspects `build_top_level_parser()`), never
|
||||
hand-written lists — they drift.
|
||||
- Never blanket-exclude ancestors from process scans: when `/update` runs
|
||||
as the gateway's child, a gateway ancestor must stay visible to the
|
||||
pause machinery (#87594). Exclude interactive ancestry, carve out
|
||||
gateway-shaped ancestors.
|
||||
- Match on FULL cmdlines; truncate only at display time (#78089).
|
||||
- Before adding any new scan heuristic, read #92091 — the gateway control
|
||||
socket replaces scans as the primary coordination mechanism; scans are
|
||||
the fallback layer for old/crashed processes.
|
||||
|
||||
### DO NOT hardcode `~/.hermes` paths
|
||||
Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
|
||||
for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
|
||||
@@ -1491,6 +1651,22 @@ in order to pass, it belongs on that OS.**
|
||||
When one test body walks several platforms in sequence, split it.
|
||||
Keep the host-native arm on the Linux lane and move the other arm into its own marked test.
|
||||
|
||||
**Live Windows process-topology E2E: the `wine2e` lane.** For claims about
|
||||
real Windows process behavior that mocks cannot reproduce (venv-holder
|
||||
scans, process-tree parentage, launcher/worker chains, detach semantics),
|
||||
there is an on-demand workflow `windows-venv-e2e.yml` that runs
|
||||
`tests/hermes_cli/test_venv_holder_windows_live.py` on a real
|
||||
`windows-latest` runner — spawning actual processes and driving the real
|
||||
detection code, no mocked psutil. It fires ONLY on pushes to `wine2e/**`
|
||||
branches (inert on PRs and main; costs nothing on normal work). The proven
|
||||
workflow: write probes that pin CORRECT behavior, push to a `wine2e/`
|
||||
branch to reproduce the bugs live on unfixed code, build the fix, iterate
|
||||
until the lane is green, then open the PR — the live receipt on the exact
|
||||
head is the Windows proof reviewers ask for. Extend the live suite when
|
||||
touching that subsystem; assert against the gateway ANCESTOR found by
|
||||
argv, not the direct parent (the venv shim makes every spawn a
|
||||
launcher/worker chain).
|
||||
|
||||
**Use the marker, never a bare `skipif`.** `scripts/ci/list_os_marked_tests.py`
|
||||
decides which files the macOS/Windows lanes import by grepping for the marker
|
||||
*name*, and the lane then filters with `-m <marker>`. A test gated with
|
||||
|
||||
@@ -1259,6 +1259,7 @@ def init_agent(
|
||||
_gr_label = " + Guardrails" if agent._bedrock_guardrail_config else ""
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})")
|
||||
else:
|
||||
client_kwargs = {}
|
||||
if api_key and base_url:
|
||||
# Explicit credentials from CLI/gateway — construct directly.
|
||||
# The runtime provider resolver already handled auth for us.
|
||||
@@ -1430,6 +1431,19 @@ def init_agent(
|
||||
"select a provider, or run `hermes setup` for first-time "
|
||||
"configuration."
|
||||
)
|
||||
# Bedrock GPT-5.5/5.6 use Bedrock Mantle's OpenAI Responses endpoint.
|
||||
# Runtime resolution uses api_key="aws-sdk" as the IAM-auth sentinel;
|
||||
# attach an httpx client that SigV4-signs every OpenAI SDK request.
|
||||
# No-op for non-Mantle base URLs.
|
||||
try:
|
||||
from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs
|
||||
configure_bedrock_openai_client_kwargs(
|
||||
client_kwargs,
|
||||
timeout=_provider_timeout,
|
||||
)
|
||||
except Exception:
|
||||
if agent.provider == "bedrock" and "bedrock-mantle." in str(client_kwargs.get("base_url", "")):
|
||||
raise
|
||||
|
||||
agent._client_kwargs = client_kwargs # stored for rebuilding after interrupt
|
||||
|
||||
|
||||
+59
-12
@@ -671,7 +671,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non
|
||||
via prefix so the override tracks every 272K-capped family (5.4, 5.5,
|
||||
5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every
|
||||
variant. (Name kept for backward compatibility with the
|
||||
``compression.codex_gpt55_autoraise`` config key.)
|
||||
``compression.codex_gpt55_autoraise`` config key.) The exact
|
||||
``gpt-daybreak-blue-latest`` Codex slug is also a verified Sol-family
|
||||
alias and receives the same autoraise.
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
@@ -687,6 +689,7 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non
|
||||
or bare == "gpt-5.6"
|
||||
or bare.startswith("gpt-5.6-")
|
||||
or bare.startswith("gpt-5.6.")
|
||||
or bare == "gpt-daybreak-blue-latest"
|
||||
)
|
||||
|
||||
|
||||
@@ -741,7 +744,8 @@ def _compression_threshold_for_model(
|
||||
|
||||
Per-model/route overrides:
|
||||
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
|
||||
- gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because
|
||||
- gpt-5.4 / gpt-5.5 / gpt-5.6 and the exact Daybreak Sol alias on the
|
||||
Codex OAuth route → 0.85, because
|
||||
Codex caps all three families at 272K and the default 50% trigger
|
||||
would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise``
|
||||
(historical config-key name kept for backward compatibility) so the
|
||||
@@ -2826,8 +2830,15 @@ _paid_lane_warned: set = set()
|
||||
|
||||
|
||||
def _is_free_model(model: Optional[str]) -> bool:
|
||||
"""True when ``model`` is an OpenRouter free SKU (``:free`` suffix)."""
|
||||
return bool(model) and str(model).strip().endswith(":free")
|
||||
"""True when ``model`` is a free SKU (``:free`` suffix or ``stealth/`` prefix).
|
||||
|
||||
Naming-convention trust: a paid model shipped under ``stealth/`` would
|
||||
silently bypass both the free_only gate and the paid-lane warning.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
normalized = str(model).strip()
|
||||
return normalized.endswith(":free") or normalized.startswith("stealth/")
|
||||
|
||||
|
||||
def _aux_openrouter_settings() -> Tuple[bool, str]:
|
||||
@@ -2849,7 +2860,8 @@ def _aux_openrouter_settings() -> Tuple[bool, str]:
|
||||
|
||||
|
||||
def _warn_paid_lane_once(model: str) -> None:
|
||||
"""Log a WARNING the first time a non-:free OpenRouter model is engaged."""
|
||||
"""Log a WARNING the first time a non-free (neither ``:free`` nor
|
||||
``stealth/``) OpenRouter model is engaged."""
|
||||
if model in _paid_lane_warned:
|
||||
return
|
||||
_paid_lane_warned.add(model)
|
||||
@@ -6979,8 +6991,12 @@ def resolve_provider_client(
|
||||
default_model = "google/gemini-3-flash-preview"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=token, base_url=base_url)
|
||||
# Alias the import: a bare `from openai import OpenAI` here would
|
||||
# make `OpenAI` function-local and shadow the module-level lazy
|
||||
# proxy for every other branch of this function (breaking both the
|
||||
# Bedrock Mantle branch below and patch("agent.auxiliary_client.OpenAI")).
|
||||
from openai import OpenAI as _VertexOpenAI
|
||||
client = _VertexOpenAI(api_key=token, base_url=base_url)
|
||||
except Exception as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Vertex "
|
||||
"client: %s", exc)
|
||||
@@ -6991,17 +7007,23 @@ def resolve_provider_client(
|
||||
|
||||
elif pconfig.auth_type == "aws_sdk":
|
||||
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
|
||||
# SDK (prompt caching, thinking); non-Claude models use Converse API.
|
||||
# SDK (prompt caching, thinking); OpenAI models (GPT-5.5/5.6) use
|
||||
# Bedrock Mantle's OpenAI Responses endpoint; all other models use the
|
||||
# Converse API.
|
||||
try:
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
is_anthropic_bedrock_model,
|
||||
resolve_bedrock_region,
|
||||
resolve_bedrock_runtime_region,
|
||||
is_openai_bedrock_model,
|
||||
bedrock_openai_base_url,
|
||||
resolve_bedrock_bearer_token,
|
||||
configure_bedrock_openai_client_kwargs,
|
||||
)
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
except ImportError:
|
||||
logger.warning("resolve_provider_client: bedrock requested but "
|
||||
"boto3 or anthropic SDK not installed")
|
||||
"boto3, httpx/openai, or anthropic SDK not installed")
|
||||
return None, None
|
||||
|
||||
if not has_aws_credentials():
|
||||
@@ -7009,9 +7031,34 @@ def resolve_provider_client(
|
||||
"no AWS credentials found")
|
||||
return None, None
|
||||
|
||||
region = resolve_bedrock_region()
|
||||
# Region must match the main runtime's resolution (bedrock.region in
|
||||
# config.yaml first, then env/profile) — see review on #53880/#65076:
|
||||
# a bare resolve_bedrock_region() here let auxiliary calls (compression,
|
||||
# memory, vision) leave the primary runtime's configured region.
|
||||
region = resolve_bedrock_runtime_region()
|
||||
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
final_model = _normalize_resolved_model(model or default_model, provider) or default_model
|
||||
|
||||
if is_openai_bedrock_model(final_model):
|
||||
# NOTE: no local `from openai import OpenAI` here — the module-level
|
||||
# lazy proxy (see top of file) must stay visible so tests can
|
||||
# patch("agent.auxiliary_client.OpenAI", ...).
|
||||
bearer = resolve_bedrock_bearer_token()
|
||||
mantle_base_url = bedrock_openai_base_url(region)
|
||||
client_kwargs: Dict[str, Any] = {
|
||||
"api_key": bearer or "aws-sdk",
|
||||
"base_url": mantle_base_url,
|
||||
}
|
||||
configure_bedrock_openai_client_kwargs(client_kwargs)
|
||||
client = OpenAI(**client_kwargs)
|
||||
logger.debug("resolve_provider_client: bedrock-openai (%s, %s)", final_model, region)
|
||||
if raw_codex:
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
wrapped = CodexAuxiliaryClient(client, final_model)
|
||||
return (_to_async_client(wrapped, final_model, is_vision=is_vision) if async_mode
|
||||
else (wrapped, final_model))
|
||||
|
||||
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
if is_anthropic_bedrock_model(final_model):
|
||||
|
||||
+197
-2
@@ -33,6 +33,9 @@ import os
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +60,25 @@ except Exception:
|
||||
_bedrock_runtime_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||
|
||||
# Bedrock-hosted OpenAI GPT-5.5 is not exposed through the native Converse
|
||||
# runtime. AWS serves it from the Bedrock Mantle OpenAI-compatible Responses
|
||||
# endpoint instead (https://bedrock-mantle.<region>.api.aws/openai/v1).
|
||||
# Keep the allowlist intentionally narrow so OpenAI GPT-OSS models that are
|
||||
# Converse-capable continue to use the native Bedrock path.
|
||||
BEDROCK_OPENAI_RESPONSES_MODEL_IDS: Tuple[str, ...] = (
|
||||
"openai.gpt-5.5",
|
||||
# GPT-5.6 family (GA on Bedrock 2026-07-13): Sol (frontier), Terra
|
||||
# (balanced), Luna (fast/affordable). All are Mantle-only — the model
|
||||
# cards list bedrock-runtime/Converse as unsupported.
|
||||
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards-openai.html
|
||||
"openai.gpt-5.6-sol",
|
||||
"openai.gpt-5.6-terra",
|
||||
"openai.gpt-5.6-luna",
|
||||
)
|
||||
_BEDROCK_OPENAI_HOST_RE = re.compile(
|
||||
r"^bedrock-mantle\.([a-z0-9-]+)\.api\.aws$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
_MIN_BOTO3_VERSION = (1, 34, 59)
|
||||
|
||||
@@ -133,6 +155,143 @@ def invalidate_runtime_client(region: str) -> bool:
|
||||
return existed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bedrock Mantle / OpenAI Responses support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_openai_bedrock_model(model_id: str) -> bool:
|
||||
"""Return True for Bedrock-hosted OpenAI models that require Mantle.
|
||||
|
||||
Bedrock's GPT-OSS models are Converse-capable and intentionally do not
|
||||
match this helper. The allowlist tracks models served by the OpenAI
|
||||
Responses-compatible ``bedrock-mantle`` route.
|
||||
"""
|
||||
normalized = str(model_id or "").strip().lower()
|
||||
return normalized in {m.lower() for m in BEDROCK_OPENAI_RESPONSES_MODEL_IDS}
|
||||
|
||||
|
||||
def merge_bedrock_openai_model_ids(model_ids: List[str]) -> List[str]:
|
||||
"""Append Bedrock OpenAI Responses models to a discovered Bedrock list.
|
||||
|
||||
The Bedrock control plane's ListFoundationModels/ListInferenceProfiles
|
||||
discovery covers Converse models but does not enumerate Mantle-only
|
||||
OpenAI Responses models. The picker needs both surfaces under AWS Bedrock.
|
||||
"""
|
||||
merged = list(model_ids or [])
|
||||
seen = {str(m).lower() for m in merged}
|
||||
for model_id in BEDROCK_OPENAI_RESPONSES_MODEL_IDS:
|
||||
if model_id.lower() not in seen:
|
||||
merged.append(model_id)
|
||||
seen.add(model_id.lower())
|
||||
return merged
|
||||
|
||||
|
||||
def bedrock_openai_base_url(region: str) -> str:
|
||||
"""Return Bedrock Mantle's OpenAI-compatible base URL for *region*."""
|
||||
resolved = (region or "").strip() or resolve_bedrock_runtime_region()
|
||||
return f"https://bedrock-mantle.{resolved}.api.aws/openai/v1"
|
||||
|
||||
|
||||
def bedrock_openai_region_from_base_url(base_url: str) -> Optional[str]:
|
||||
"""Extract the AWS region from a Bedrock Mantle OpenAI base URL."""
|
||||
host = urlparse(str(base_url or "")).hostname or ""
|
||||
match = _BEDROCK_OPENAI_HOST_RE.match(host)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def is_bedrock_openai_base_url(base_url: str) -> bool:
|
||||
"""Return True for Bedrock Mantle OpenAI-compatible endpoints."""
|
||||
parsed = urlparse(str(base_url or ""))
|
||||
host = parsed.hostname or ""
|
||||
if not _BEDROCK_OPENAI_HOST_RE.match(host):
|
||||
return False
|
||||
# The OpenAI GPT-5.5 Bedrock route lives under /openai/v1. Accept a bare
|
||||
# host too so callers can normalize before appending the path.
|
||||
path = (parsed.path or "").rstrip("/").lower()
|
||||
return path in {"", "/openai", "/openai/v1"}
|
||||
|
||||
|
||||
def resolve_bedrock_bearer_token(env: Optional[Dict[str, str]] = None) -> str:
|
||||
"""Return AWS_BEARER_TOKEN_BEDROCK when Bedrock API-key auth is configured."""
|
||||
env = env if env is not None else os.environ
|
||||
return (env.get("AWS_BEARER_TOKEN_BEDROCK", "") or "").strip()
|
||||
|
||||
|
||||
class BedrockOpenAISigV4Auth(httpx.Auth):
|
||||
"""httpx auth hook that SigV4-signs Bedrock Mantle OpenAI requests."""
|
||||
|
||||
requires_request_body = True
|
||||
|
||||
def __init__(self, region: str, service: str = "bedrock"):
|
||||
self.region = (region or "").strip() or resolve_bedrock_runtime_region()
|
||||
self.service = service
|
||||
|
||||
def auth_flow(self, request): # pragma: no cover - exercised by live call
|
||||
import botocore.session
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.awsrequest import AWSRequest
|
||||
|
||||
credentials = botocore.session.get_session().get_credentials()
|
||||
if credentials is None:
|
||||
raise RuntimeError(
|
||||
"No AWS credentials available for Bedrock OpenAI Responses. "
|
||||
"Configure AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, "
|
||||
"SSO, or an instance/task role."
|
||||
)
|
||||
frozen = credentials.get_frozen_credentials()
|
||||
# Drop the OpenAI SDK's placeholder bearer header before signing; SigV4
|
||||
# must own Authorization. Keep all other SDK headers so AWS receives
|
||||
# content-type, accept, request IDs, etc.
|
||||
headers = {
|
||||
str(k): str(v)
|
||||
for k, v in request.headers.items()
|
||||
if str(k).lower() not in {"authorization", "x-amz-date", "x-amz-security-token"}
|
||||
}
|
||||
aws_request = AWSRequest(
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
data=request.content or b"",
|
||||
headers=headers,
|
||||
)
|
||||
SigV4Auth(frozen, self.service, self.region).add_auth(aws_request)
|
||||
request.headers.update(dict(aws_request.headers.items()))
|
||||
yield request
|
||||
|
||||
|
||||
def build_bedrock_openai_http_client(region: str, *, timeout: Optional[float] = None):
|
||||
"""Build an httpx client that SigV4-signs Bedrock OpenAI requests."""
|
||||
import httpx
|
||||
|
||||
kwargs: Dict[str, Any] = {"auth": BedrockOpenAISigV4Auth(region)}
|
||||
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and timeout > 0:
|
||||
kwargs["timeout"] = timeout
|
||||
return httpx.Client(**kwargs)
|
||||
|
||||
|
||||
def configure_bedrock_openai_client_kwargs(
|
||||
client_kwargs: Dict[str, Any],
|
||||
*,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Install SigV4 auth on OpenAI SDK kwargs for Bedrock Mantle.
|
||||
|
||||
``AWS_BEARER_TOKEN_BEDROCK``/explicit Bedrock API keys continue to use the
|
||||
SDK's normal bearer auth. The special ``aws-sdk`` placeholder means IAM
|
||||
credential-chain auth, so we attach a per-request SigV4 httpx client.
|
||||
"""
|
||||
base_url = str(client_kwargs.get("base_url") or "")
|
||||
if not is_bedrock_openai_base_url(base_url):
|
||||
return client_kwargs
|
||||
api_key = client_kwargs.get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip() and api_key not in {"aws-sdk", "no-key-required"}:
|
||||
return client_kwargs
|
||||
region = bedrock_openai_region_from_base_url(base_url) or resolve_bedrock_runtime_region()
|
||||
client_kwargs["api_key"] = "aws-sdk"
|
||||
client_kwargs["http_client"] = build_bedrock_openai_http_client(region, timeout=timeout)
|
||||
return client_kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale-connection detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -384,6 +543,36 @@ def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str:
|
||||
return "us-east-1"
|
||||
|
||||
|
||||
def resolve_bedrock_runtime_region(config: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Resolve the Bedrock region with the same priority as the main runtime.
|
||||
|
||||
Priority (matches the runtime provider resolver in
|
||||
``hermes_cli/runtime_provider.py``):
|
||||
1. ``bedrock.region`` in config.yaml
|
||||
2. ``resolve_bedrock_region()`` (AWS_REGION / AWS_DEFAULT_REGION /
|
||||
botocore profile / us-east-1)
|
||||
|
||||
Callers that already hold a loaded config dict should pass it to avoid a
|
||||
disk read; when *config* is None the config is loaded read-only. Every
|
||||
non-runtime call site that constructs a Bedrock endpoint (auxiliary
|
||||
client resolution, model discovery for the picker) must use this helper —
|
||||
using bare ``resolve_bedrock_region()`` there lets auxiliary calls leave
|
||||
the primary runtime's configured region when ``bedrock.region`` and the
|
||||
ambient AWS env/profile disagree.
|
||||
"""
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
config = load_config_readonly()
|
||||
except Exception:
|
||||
config = {}
|
||||
bedrock_cfg = (config or {}).get("bedrock") or {}
|
||||
cfg_region = str(bedrock_cfg.get("region") or "").strip()
|
||||
if cfg_region:
|
||||
return cfg_region
|
||||
return resolve_bedrock_region()
|
||||
|
||||
|
||||
def bedrock_model_ids_or_none() -> Optional[List[str]]:
|
||||
"""Live-discover Bedrock model IDs for the active region.
|
||||
|
||||
@@ -396,9 +585,9 @@ def bedrock_model_ids_or_none() -> Optional[List[str]]:
|
||||
``list_authenticated_providers`` section 2, and section 3.
|
||||
"""
|
||||
try:
|
||||
discovered = discover_bedrock_models(resolve_bedrock_region())
|
||||
discovered = discover_bedrock_models(resolve_bedrock_runtime_region())
|
||||
if discovered:
|
||||
return [m["id"] for m in discovered]
|
||||
return merge_bedrock_openai_model_ids([m["id"] for m in discovered])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -1450,6 +1639,12 @@ BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
|
||||
"mistral.mistral-large": 128_000,
|
||||
# DeepSeek
|
||||
"deepseek.v3": 128_000,
|
||||
# OpenAI on Bedrock (Mantle/Responses route)
|
||||
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards-openai.html
|
||||
"openai.gpt-5.5": 272_000,
|
||||
"openai.gpt-5.6-sol": 272_000,
|
||||
"openai.gpt-5.6-terra": 272_000,
|
||||
"openai.gpt-5.6-luna": 272_000,
|
||||
}
|
||||
|
||||
# Default for unknown Bedrock models
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Client-facing projection helpers for model-only compaction carriers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from agent.context_compressor import (
|
||||
ContextCompressor,
|
||||
is_compaction_summary_message,
|
||||
)
|
||||
|
||||
|
||||
_COMPACTION_INTERNAL_FIELDS = (
|
||||
"tool_calls",
|
||||
"finish_reason",
|
||||
"reasoning",
|
||||
"reasoning_content",
|
||||
"reasoning_details",
|
||||
"codex_reasoning_items",
|
||||
"codex_message_items",
|
||||
)
|
||||
|
||||
|
||||
def project_compaction_message_for_display(
|
||||
message: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return authentic transcript content, or ``None`` for a pure handoff.
|
||||
|
||||
Model-facing recovery history retains the complete carrier. Display
|
||||
projections instead remove the handoff, inherited tool state, and internal
|
||||
reasoning while preserving any real prior-tail content or live user ask
|
||||
embedded in the carrier.
|
||||
"""
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
if not is_compaction_summary_message(message):
|
||||
return message.copy()
|
||||
|
||||
projected = ContextCompressor._strip_context_summary_handoff_message(message)
|
||||
if projected is None:
|
||||
return None
|
||||
|
||||
projected = projected.copy()
|
||||
for key in _COMPACTION_INTERNAL_FIELDS:
|
||||
projected.pop(key, None)
|
||||
projected.pop("display_kind", None)
|
||||
return projected
|
||||
@@ -8186,8 +8186,24 @@ def reference_handoff_would_drive_next_model_call(
|
||||
for index, message in enumerate(messages):
|
||||
if not is_compaction_summary_message(message):
|
||||
continue
|
||||
if _handoff_carries_live_user_content(message):
|
||||
# Embedded live ask — this row is not a sole-handoff driver.
|
||||
merged_completed_assistant = (
|
||||
isinstance(message, dict)
|
||||
and message.get("role") == "assistant"
|
||||
and ContextCompressor.classify_summary_content(
|
||||
message.get("content")
|
||||
)
|
||||
== "merged"
|
||||
and message.get("finish_reason") == "stop"
|
||||
and not message.get("tool_calls")
|
||||
)
|
||||
if (
|
||||
_handoff_carries_live_user_content(message)
|
||||
and not merged_completed_assistant
|
||||
):
|
||||
# Embedded live ask — this row is not a sole-handoff driver. A
|
||||
# completed merged assistant carrier preserves the assistant's own
|
||||
# prose, not a fresh user request. A carrier with pending tool_calls
|
||||
# remains live regardless of an earlier completed assistant turn.
|
||||
continue
|
||||
last_driving_handoff = index
|
||||
|
||||
|
||||
@@ -3460,6 +3460,24 @@ def compress_context(
|
||||
"could not record rejected-compaction strike",
|
||||
exc_info=True,
|
||||
)
|
||||
# Restore ONLY the prune runway (same rationale as the
|
||||
# rotation-failure rollback below): compress()'s successful
|
||||
# tail already zeroed _proactive_prune_rearm_tokens in
|
||||
# memory, but this refusal keeps the ORIGINAL transcript —
|
||||
# whose cached prefix is intact. Leaving the runway at 0
|
||||
# disarms the #79640 throttle, so the very next iteration's
|
||||
# proactive prune rewrites history and breaks the prompt
|
||||
# cache without the required regrowth interval (#91830).
|
||||
# The durable copy was never cleared (that clear only rides
|
||||
# the archive_and_compact / child-row commit that never
|
||||
# ran), so restoring the snapshot re-aligns memory with
|
||||
# disk.
|
||||
if "_proactive_prune_rearm_tokens" in _compressor_attempt_snapshot:
|
||||
agent.context_compressor._proactive_prune_rearm_tokens = (
|
||||
_compressor_attempt_snapshot[
|
||||
"_proactive_prune_rearm_tokens"
|
||||
]
|
||||
)
|
||||
_release_lock()
|
||||
return messages, _existing_sp
|
||||
|
||||
|
||||
@@ -755,6 +755,10 @@ def _billing_failure_result(
|
||||
"failed": True,
|
||||
"error": summary,
|
||||
"failure_reason": classified.reason.value,
|
||||
# The classifier's own retry verdict — carried so UI surfaces
|
||||
# (agent/error_surface.py) show Retry only when a re-run can differ,
|
||||
# instead of re-deriving retryability from a second taxonomy.
|
||||
"failure_retryable": bool(classified.retryable),
|
||||
# The billing verdict may rest on an ambiguous body (#82154) — carry
|
||||
# that through the structured result, not just the prose.
|
||||
"billing_unverified": unverified,
|
||||
@@ -6439,6 +6443,9 @@ def run_conversation(
|
||||
# different exit code. ``rate_limit`` / ``billing`` here
|
||||
# mean "quota wall, not a task error".
|
||||
"failure_reason": classified.reason.value,
|
||||
# The classifier's own retry verdict — UI surfaces use
|
||||
# this instead of re-deriving from the reason string.
|
||||
"failure_retryable": bool(classified.retryable),
|
||||
# True when the billing verdict rests on an ambiguous
|
||||
# body (#82154) — may be a content-filter rejection.
|
||||
"billing_unverified": _billing_unverified,
|
||||
|
||||
@@ -226,12 +226,15 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool:
|
||||
1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g.
|
||||
``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
|
||||
(spend is forced to 0 for ``:free`` ids).
|
||||
2. A peek into the in-process pricing cache in ``hermes_cli.models``
|
||||
2. The ``stealth/`` prefix — Nous stealth-preview SKUs (e.g.
|
||||
``stealth/ox-alpha``) are free-tier but carry no ``:free`` suffix. Spend
|
||||
is forced to zero server-side, so these are also free by construction.
|
||||
3. A peek into the in-process pricing cache in ``hermes_cli.models``
|
||||
(populated when the model picker fetched ``/v1/models`` pricing for
|
||||
*base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is
|
||||
CLI/TUI-session best-effort: gateway sessions never run the picker's
|
||||
pricing fetch, so suppression there rests entirely on the ``:free``
|
||||
suffix (which all Nous free SKUs carry).
|
||||
suffix and ``stealth/`` prefix.
|
||||
|
||||
Fail-open to False (the depleted notice still shows) on any error: wrongly
|
||||
showing the warning is recoverable noise; wrongly hiding it on a paid model
|
||||
@@ -241,6 +244,11 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool:
|
||||
return False
|
||||
if model.endswith(":free"):
|
||||
return True
|
||||
# Stealth-preview SKUs are free-tier but carry no ``:free`` suffix (see
|
||||
# docstring point 2). Naming-convention trust: if a PAID model ever shipped
|
||||
# under ``stealth/`` this would wrongly suppress the banner on it.
|
||||
if model.startswith("stealth/"):
|
||||
return True
|
||||
if not base_url:
|
||||
return False
|
||||
try:
|
||||
|
||||
@@ -459,6 +459,59 @@ _REQUEST_VALIDATION_PATTERNS = [
|
||||
"unsupported_parameter",
|
||||
]
|
||||
|
||||
# Request parameters that Hermes sends on SOME routes only, paired with the
|
||||
# providers/hosts where sending them is deliberate.
|
||||
#
|
||||
# When a host that is NOT in the allowed set rejects one of these fields, the
|
||||
# client never put it in the body — the provider's own gateway injected it —
|
||||
# so the 400 is a server-side flake rather than a deterministic request-shape
|
||||
# error. See ``_is_server_injected_param_rejection`` and the branch in
|
||||
# ``_classify_400``.
|
||||
#
|
||||
# ``prompt_cache_retention`` is only sent for api.meta.ai and bedrock-mantle
|
||||
# hosts (agent/transports/codex.py::_default_prompt_cache_retention_for_request).
|
||||
# The Codex OAuth backend rejects it spontaneously on requests that provably
|
||||
# never carried it.
|
||||
_SERVER_INJECTED_PARAM_SENDERS: Dict[str, tuple] = {
|
||||
"prompt_cache_retention": ("meta", "muse", "msl", "model-api", "bedrock", "mantle"),
|
||||
}
|
||||
|
||||
|
||||
def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool:
|
||||
"""True when a 400 blames a parameter this route never sends.
|
||||
|
||||
``error_msg`` is the lowercased, concatenated message text; ``provider`` is
|
||||
the lowercased provider slug. A match means the rejection cannot be
|
||||
attributed to our own request shape, so the error is transient and retrying
|
||||
the identical request is the correct recovery.
|
||||
|
||||
Deliberately conservative: it fires only for known one-route-only
|
||||
parameters AND only when the current provider is not one of the routes that
|
||||
actually sends them, so a genuine client-side bad parameter (``max_tokens``
|
||||
on a GPT-5 model) still fails fast as a ``format_error``.
|
||||
"""
|
||||
if not error_msg:
|
||||
return False
|
||||
provider_slug = (provider or "").strip().lower()
|
||||
for param, senders in _SERVER_INJECTED_PARAM_SENDERS.items():
|
||||
if param not in error_msg:
|
||||
continue
|
||||
# Require the message to actually be a rejection of that parameter,
|
||||
# not an incidental mention.
|
||||
if not (
|
||||
"not supported" in error_msg
|
||||
or "unsupported" in error_msg
|
||||
or "unknown" in error_msg
|
||||
or "unrecognized" in error_msg
|
||||
):
|
||||
continue
|
||||
if any(sender in provider_slug for sender in senders):
|
||||
# This route sends the field on purpose — a real request error.
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# OpenRouter aggregator policy-block patterns.
|
||||
#
|
||||
# When a user's OpenRouter account privacy setting (or a per-request
|
||||
@@ -1310,11 +1363,16 @@ def _classify_by_status(
|
||||
# server_error" rule turns one bad request into a retry flood.
|
||||
# Detect the unambiguous request-validation signals (in either the
|
||||
# message text or the structured error code) and fail fast.
|
||||
#
|
||||
# Exception: a parameter WE never sent on this route was injected by
|
||||
# the provider/proxy itself, so the rejection is not deterministic and
|
||||
# the generic retryable-5xx handling is correct. Mirrors the guard in
|
||||
# _classify_400 — see _is_server_injected_param_rejection.
|
||||
if (
|
||||
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS)
|
||||
or error_code.lower() in {"invalid_request_error", "unknown_parameter",
|
||||
"unsupported_parameter"}
|
||||
):
|
||||
) and not _is_server_injected_param_rejection(error_msg, provider):
|
||||
return result_fn(
|
||||
FailoverReason.format_error,
|
||||
retryable=False,
|
||||
@@ -1473,6 +1531,25 @@ def _classify_400(
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# Server-injected parameter rejection: a 400 blaming a request field the
|
||||
# client never sent. MUST be checked BEFORE the request-validation branch
|
||||
# below, which would otherwise class it as a deterministic format_error and
|
||||
# abort the turn.
|
||||
#
|
||||
# Observed live on the Codex OAuth backend (chatgpt.com/backend-api/codex):
|
||||
# it intermittently adds ``prompt_cache_retention`` to its own upstream
|
||||
# call and then rejects it, so a byte-identical request succeeds on retry
|
||||
# (measured ~20% failure over n=20 on a minimal 1-message request that
|
||||
# provably carried no cache parameters). Retrying is the correct and only
|
||||
# recovery; failing fast burnt an entire large-context request per attempt.
|
||||
if _is_server_injected_param_rejection(error_msg, provider):
|
||||
return result_fn(
|
||||
FailoverReason.server_error,
|
||||
retryable=True,
|
||||
# The request shape was fine — never route this into compression.
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# Request-validation errors (unsupported / unknown parameter) MUST be
|
||||
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
|
||||
# returns:
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Structured error-surface descriptors for UI clients (Desktop/TUI).
|
||||
|
||||
Maps the internal failure taxonomy (``agent.error_classifier.FailoverReason``
|
||||
values carried in turn results as ``failure_reason``, or raw exceptions from
|
||||
the turn dispatcher) onto a small, stable wire descriptor:
|
||||
|
||||
{"layer": <ui layer>, "code": <specific code>, "retryable": <bool>}
|
||||
|
||||
The *layer* names which part of the stack failed, so clients can say
|
||||
"Provider error" / "Gateway error" instead of toasting an opaque string and
|
||||
leaving the user to guess whether the model, the gateway, or the app froze:
|
||||
|
||||
provider — the model/provider API rejected or failed the call
|
||||
endpoint — a user-configured custom/local endpoint failed (transport)
|
||||
streaming — the provider's SSE/stream connection dropped mid-turn
|
||||
auth — authentication/authorization failed
|
||||
billing — credits/quota wall (clients usually have a richer
|
||||
billing_block descriptor; this is the fallback signal)
|
||||
gateway — the local gateway/agent runtime itself errored
|
||||
runtime — agent initialization / local environment failure
|
||||
disk — local disk full / persistence failure
|
||||
|
||||
This module is intentionally dependency-light and NEVER raises: surfacing
|
||||
diagnostics must not be able to break the error path it describes. Clients
|
||||
treat the descriptor as advisory — an absent or partial descriptor falls
|
||||
back to today's string-sniffing behavior (older backends keep working).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# UI layers (wire values — stable contract with desktop/TUI clients).
|
||||
LAYER_PROVIDER = "provider"
|
||||
LAYER_ENDPOINT = "endpoint"
|
||||
LAYER_STREAMING = "streaming"
|
||||
LAYER_AUTH = "auth"
|
||||
LAYER_BILLING = "billing"
|
||||
LAYER_GATEWAY = "gateway"
|
||||
LAYER_RUNTIME = "runtime"
|
||||
LAYER_DISK = "disk"
|
||||
|
||||
# failure_reason (FailoverReason.value) → UI layer. Reasons not listed fall
|
||||
# back to LAYER_PROVIDER: every FailoverReason is produced by classifying a
|
||||
# provider API call, so "the provider call failed" is the honest default.
|
||||
_REASON_TO_LAYER = {
|
||||
"auth": LAYER_AUTH,
|
||||
"auth_permanent": LAYER_AUTH,
|
||||
"billing": LAYER_BILLING,
|
||||
"billing_unverified": LAYER_BILLING,
|
||||
}
|
||||
|
||||
# Transport-ish reasons: the failure is between us and the base_url, not a
|
||||
# verdict the provider returned. On a custom/local endpoint these point at
|
||||
# the user's endpoint config, so they surface as LAYER_ENDPOINT there.
|
||||
_TRANSPORT_REASONS = {
|
||||
"timeout",
|
||||
"ssl_cert_verification",
|
||||
}
|
||||
|
||||
# Reasons that are deterministic for the request — a bare "Retry" repeats the
|
||||
# same failure, so clients shouldn't lead with it. Fallback only: results
|
||||
# from current backends carry the classifier's own verdict in
|
||||
# ``failure_retryable`` and never consult this set. Kept in sync with
|
||||
# ``classify_api_error``'s retryable=False verdicts.
|
||||
_NON_RETRYABLE_REASONS = {
|
||||
"auth",
|
||||
"auth_permanent",
|
||||
"billing",
|
||||
"billing_unverified",
|
||||
"content_policy_blocked",
|
||||
"provider_policy_blocked",
|
||||
"model_not_found",
|
||||
"format_error",
|
||||
"ssl_cert_verification",
|
||||
}
|
||||
|
||||
# Providers whose base_url is user-supplied rather than a known vendor —
|
||||
# transport failures against these are endpoint-config problems.
|
||||
_CUSTOM_ENDPOINT_PROVIDERS = {
|
||||
"custom",
|
||||
"local",
|
||||
"llama.cpp",
|
||||
"llamacpp",
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"vllm",
|
||||
}
|
||||
|
||||
# Message fragments that mark a mid-stream connection drop. Deliberately
|
||||
# narrow: these strings come from our own retry-exhaustion summaries and the
|
||||
# OpenAI SDK's stream-abort errors.
|
||||
_STREAM_DROP_FRAGMENTS = (
|
||||
"stream connection",
|
||||
"peer closed connection",
|
||||
"incomplete chunked read",
|
||||
"connection broken",
|
||||
"stream ended prematurely",
|
||||
"sse",
|
||||
"mid-stream",
|
||||
)
|
||||
|
||||
# Exception modules that indicate the failure came from an API/transport call
|
||||
# (vs. a bug in our own dispatcher code, which is a gateway-layer failure).
|
||||
# Covers every SDK family our provider adapters raise from: OpenAI-compatible
|
||||
# (openai/httpx/httpcore), Anthropic, Bedrock (botocore/boto3), Google
|
||||
# (google.*/grpc), plus raw transports (requests/aiohttp/ssl/socket/urllib).
|
||||
_API_EXC_MODULE_PREFIXES = (
|
||||
"openai",
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"anthropic",
|
||||
"botocore",
|
||||
"boto3",
|
||||
"google",
|
||||
"grpc",
|
||||
"requests",
|
||||
"aiohttp",
|
||||
"ssl",
|
||||
"socket",
|
||||
"urllib",
|
||||
)
|
||||
|
||||
|
||||
def _is_custom_endpoint(provider: Optional[str]) -> bool:
|
||||
p = (provider or "").strip().lower()
|
||||
return p in _CUSTOM_ENDPOINT_PROVIDERS or p.startswith("custom:")
|
||||
|
||||
|
||||
def _looks_like_stream_drop(message: str) -> bool:
|
||||
msg = message.lower()
|
||||
return any(fragment in msg for fragment in _STREAM_DROP_FRAGMENTS)
|
||||
|
||||
|
||||
def _surface(
|
||||
layer: str,
|
||||
code: str,
|
||||
retryable: bool,
|
||||
provider: str = "",
|
||||
model: str = "",
|
||||
) -> dict:
|
||||
out = {"layer": layer, "code": code, "retryable": bool(retryable)}
|
||||
# The failing session's identity, captured at classification time so
|
||||
# clients report the model/provider that actually failed — not whatever
|
||||
# the foreground composer points at when a button is clicked later.
|
||||
if provider:
|
||||
out["provider"] = provider
|
||||
if model:
|
||||
out["model"] = model
|
||||
return out
|
||||
|
||||
|
||||
def build_error_surface_from_result(
|
||||
result: Any, provider: str = "", model: str = ""
|
||||
) -> Optional[dict]:
|
||||
"""Descriptor for a returned-error turn result (``failed=True`` dicts).
|
||||
|
||||
Reads the ``failure_reason`` the conversation loop already stamps
|
||||
(a ``FailoverReason.value``) plus the error text, and maps them onto a
|
||||
UI layer. Returns None when the result carries no failure signal.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
error_text = str(result.get("error") or "")
|
||||
reason = str(result.get("failure_reason") or "").strip()
|
||||
if not error_text and not reason:
|
||||
return None
|
||||
|
||||
# Disk-full wins outright: the fix (free space) is unrelated to the
|
||||
# provider stack, and hermes_state owns the pattern list.
|
||||
try:
|
||||
from hermes_state import is_disk_full_error
|
||||
|
||||
if error_text and is_disk_full_error(error_text):
|
||||
return _surface(LAYER_DISK, "disk_full", False, provider, model)
|
||||
except Exception: # pragma: no cover - defensive import guard
|
||||
pass
|
||||
|
||||
if result.get("billing_block") or reason in ("billing", "billing_unverified"):
|
||||
return _surface(LAYER_BILLING, reason or "billing", False, provider, model)
|
||||
|
||||
if not reason:
|
||||
# Failed result without a classified reason (legacy paths).
|
||||
if _looks_like_stream_drop(error_text):
|
||||
return _surface(LAYER_STREAMING, "stream_drop", True, provider, model)
|
||||
return _surface(LAYER_PROVIDER, "unknown", True, provider, model)
|
||||
|
||||
layer = _REASON_TO_LAYER.get(reason)
|
||||
if layer is None:
|
||||
if reason in _TRANSPORT_REASONS and _is_custom_endpoint(provider):
|
||||
layer = LAYER_ENDPOINT
|
||||
elif _looks_like_stream_drop(error_text):
|
||||
layer = LAYER_STREAMING
|
||||
else:
|
||||
layer = LAYER_PROVIDER
|
||||
# Prefer the classifier's own retry verdict when the result carries it
|
||||
# (conversation_loop stamps ``failure_retryable`` next to
|
||||
# ``failure_reason``); the reason-set fallback covers older results.
|
||||
retryable = result.get("failure_retryable")
|
||||
if not isinstance(retryable, bool):
|
||||
retryable = reason not in _NON_RETRYABLE_REASONS
|
||||
return _surface(layer, reason, retryable, provider, model)
|
||||
except Exception: # pragma: no cover — never break the error path
|
||||
logger.debug("error_surface: result classification failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def build_error_surface_from_exception(
|
||||
exc: BaseException, provider: str = "", model: str = ""
|
||||
) -> Optional[dict]:
|
||||
"""Descriptor for an exception that escaped the turn dispatcher.
|
||||
|
||||
API/transport exceptions are classified through the real
|
||||
``classify_api_error`` pipeline (same taxonomy as the retry loop);
|
||||
anything else is a gateway-layer failure — a bug or environment problem
|
||||
in our own dispatcher, not a provider verdict.
|
||||
"""
|
||||
try:
|
||||
message = str(exc) or type(exc).__name__
|
||||
|
||||
try:
|
||||
from hermes_state import is_disk_full_error
|
||||
|
||||
if is_disk_full_error(exc):
|
||||
return _surface(LAYER_DISK, "disk_full", False, provider, model)
|
||||
except Exception: # pragma: no cover - defensive import guard
|
||||
pass
|
||||
|
||||
exc_module = type(exc).__module__ or ""
|
||||
api_like = exc_module.split(".")[0] in _API_EXC_MODULE_PREFIXES or hasattr(
|
||||
exc, "status_code"
|
||||
)
|
||||
|
||||
if not api_like or not isinstance(exc, Exception):
|
||||
return _surface(LAYER_GATEWAY, type(exc).__name__, True, provider, model)
|
||||
|
||||
from agent.error_classifier import classify_api_error
|
||||
|
||||
classified = classify_api_error(exc, provider=provider, model=model)
|
||||
reason = classified.reason.value
|
||||
|
||||
synthetic = {
|
||||
"error": classified.message or message,
|
||||
"failure_reason": reason,
|
||||
}
|
||||
surface = build_error_surface_from_result(
|
||||
synthetic, provider=provider, model=model
|
||||
)
|
||||
if surface is not None:
|
||||
surface["retryable"] = bool(classified.retryable)
|
||||
return surface
|
||||
except Exception: # pragma: no cover — never break the error path
|
||||
logger.debug("error_surface: exception classification failed", exc_info=True)
|
||||
return None
|
||||
+11
-6
@@ -501,16 +501,19 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
"minimax": 204800,
|
||||
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
|
||||
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
|
||||
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
|
||||
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
|
||||
# ensures "glm-5.2" resolves to 1M while older variants still hit the
|
||||
# generic 202K fallback.
|
||||
# GLM — GLM-5.2 and GLM-5.3 ship with a 1M context window. GLM-5.2 was
|
||||
# verified empirically (needle-in-a-haystack retrieval at 789K prompt
|
||||
# tokens succeeded with zero errors on api.z.ai/api/coding/paas/v4).
|
||||
# GLM-5.3 uses the same base model (all gains are post-training) with
|
||||
# 1M context / 128K max output per docs.z.ai/guides/llm/glm-5.3
|
||||
# (verified 2026-08-14). Older GLM models (5, 5.1, 5-turbo) are ~202K.
|
||||
# Longest-key-first substring matching ensures "glm-5.2"/"glm-5.3"
|
||||
# resolve to 1M while older variants still hit the generic 202K fallback.
|
||||
"glm-5.2": 1_048_576,
|
||||
# OpenRouter's free GLM-5.2 variant is capped at 256K (live metadata,
|
||||
# 2026-08-21) — longer key wins over the 1M paid entry above.
|
||||
"glm-5.2:free": 256_000,
|
||||
"glm-5.3": 1_048_576,
|
||||
"glm": 202752,
|
||||
# xAI Grok — xAI /v1/models does not return context_length metadata,
|
||||
# so these hardcoded fallbacks prevent Hermes from probing-down to
|
||||
@@ -2395,6 +2398,7 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
||||
"gpt-5.6-sol": 272_000,
|
||||
"gpt-5.6-terra": 272_000,
|
||||
"gpt-5.6-luna": 272_000,
|
||||
"gpt-daybreak-blue-latest": 272_000,
|
||||
"gpt-5.5": 272_000,
|
||||
"gpt-5.4": 272_000,
|
||||
"gpt-5.2": 272_000,
|
||||
@@ -2428,6 +2432,7 @@ _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: Dict[str, int] = {
|
||||
}
|
||||
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: Dict[str, int] = {
|
||||
"gpt-5.4": 900_000, # verified live at 900K; gpt-5.4-mini rejected 500K — excluded
|
||||
"gpt-daybreak-blue-latest": 900_000, # exact Daybreak/Sol alias verified at 911,276
|
||||
}
|
||||
|
||||
# The advertised value the verified-above table is allowed to override.
|
||||
|
||||
@@ -117,6 +117,13 @@ KIMI_K3_OVERRIDES: dict[str, str] = {"medium": "high", "xhigh": "max"}
|
||||
GLM52_EFFORTS: tuple[str, ...] = ("high", "max")
|
||||
GLM52_OVERRIDES: dict[str, str] = {"xhigh": "max"}
|
||||
|
||||
#: GLM-5.3 widens the knob to a graded low/medium/high/max scale — verified
|
||||
#: live on api.z.ai/api/coding/paas/v4 (issue #91789, 2026-08-21): every
|
||||
#: level accepted with monotonic reasoning-token scaling (low=4, medium=11,
|
||||
#: high=98, max=125 on the probe prompt). ``xhigh`` requests the top tier.
|
||||
GLM53_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "max")
|
||||
GLM53_OVERRIDES: dict[str, str] = {"xhigh": "max"}
|
||||
|
||||
#: DeepSeek V4 OpenAI-compat endpoint: low/medium/high/max; ``xhigh``
|
||||
#: requests the top tier (matches the shipped profile mapping).
|
||||
DEEPSEEK_V4_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "max")
|
||||
|
||||
@@ -2066,6 +2066,31 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
agent._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "message_agent":
|
||||
# Bot Mode teammate DM (tools/bot_mode_dm.py) — injected, not
|
||||
# registered: only a canonical Bot Chat session carries the
|
||||
# schema, and the tool re-gates on the session title itself.
|
||||
def _execute(next_args: dict) -> Any:
|
||||
from tools.bot_mode_dm import message_agent_tool as _message_agent_tool
|
||||
return _message_agent_tool(
|
||||
target=next_args.get("target", ""),
|
||||
message=next_args.get("message", ""),
|
||||
task_id=effective_task_id,
|
||||
agent=agent,
|
||||
)
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
scope_block=_ts_scope_block,
|
||||
display_index=i,
|
||||
))
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
agent._vprint(f" {_get_cute_tool_message_impl('message_agent', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "session_search":
|
||||
def _execute(next_args: dict) -> Any:
|
||||
session_db = agent._get_session_db_for_recall()
|
||||
|
||||
@@ -754,6 +754,19 @@ def build_turn_context(
|
||||
|
||||
active_system_prompt = agent._cached_system_prompt
|
||||
|
||||
# Bot Mode DM tool — injected ONLY into a bot's canonical "Bot Chat"
|
||||
# session on Bot-Mode-managed installs (same gate as the protocol
|
||||
# section above). The gate is stable for a session's lifetime, so the
|
||||
# tool list is byte-identical every turn: prompt-cache safe. Every
|
||||
# other session (CLI, gateway chats, group-room member sessions, cron,
|
||||
# subagents) fails the gate and never sees the schema.
|
||||
try:
|
||||
from tools.bot_mode_dm import ensure_message_agent_tool
|
||||
|
||||
ensure_message_agent_tool(agent)
|
||||
except Exception:
|
||||
logger.debug("message_agent injection skipped", exc_info=True)
|
||||
|
||||
# Create the DB session row now that _cached_system_prompt is populated, so
|
||||
# the persisted snapshot is written non-NULL on the first turn (Issue
|
||||
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
isAuthRejectionError,
|
||||
isGatedMissingHealthError,
|
||||
isMissingHealthEndpointError,
|
||||
isNousCloudAgentUrl,
|
||||
isReauthRequiredError,
|
||||
isServerSideHttpError,
|
||||
makeNousCloudBackendDownError,
|
||||
waitForHermesReady
|
||||
} from './backend-health'
|
||||
|
||||
@@ -338,3 +341,186 @@ test('error-shape predicates', () => {
|
||||
// A gated 401 must NOT be conflated with a missing route by the 404 predicate.
|
||||
assert.equal(isMissingHealthEndpointError(new Error(GATE_401)), false)
|
||||
})
|
||||
|
||||
test('isServerSideHttpError detects 502/503/504', () => {
|
||||
// 503 — server-side fault
|
||||
const result503 = isServerSideHttpError(new Error('503: Service Unavailable'))
|
||||
assert.ok(result503, 'should detect 503')
|
||||
assert.equal(result503?.statusCode, 503)
|
||||
assert.equal(result503?.detail, '503: Service Unavailable')
|
||||
|
||||
// 502
|
||||
const result502 = isServerSideHttpError(new Error('502: Bad Gateway'))
|
||||
assert.ok(result502, 'should detect 502')
|
||||
assert.equal(result502?.statusCode, 502)
|
||||
|
||||
// 504
|
||||
const result504 = isServerSideHttpError(new Error('504: Gateway Timeout'))
|
||||
assert.ok(result504, 'should detect 504')
|
||||
assert.equal(result504?.statusCode, 504)
|
||||
|
||||
// 500 is NOT a server-side HTTP error per our definition (keeps polling)
|
||||
const result500 = isServerSideHttpError(new Error('500: Internal Server Error'))
|
||||
assert.equal(result500, null)
|
||||
|
||||
// 401/403/404/429 are not server-side faults
|
||||
assert.equal(isServerSideHttpError(new Error('401: Unauthorized')), null)
|
||||
assert.equal(isServerSideHttpError(new Error('403: Forbidden')), null)
|
||||
assert.equal(isServerSideHttpError(new Error('404: Not Found')), null)
|
||||
assert.equal(isServerSideHttpError(new Error('429: Too Many Requests')), null)
|
||||
|
||||
// Non-HTTP errors (timeouts, network failures) don't match the pattern
|
||||
assert.equal(isServerSideHttpError(new Error('connect ECONNREFUSED')), null)
|
||||
assert.equal(isServerSideHttpError(null), null)
|
||||
assert.equal(isServerSideHttpError('503: something'), null) // not an Error
|
||||
})
|
||||
|
||||
test('isNousCloudAgentUrl detects cloud agent hosts', () => {
|
||||
// Positive cases
|
||||
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com'), true)
|
||||
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com/api/health'), true)
|
||||
assert.equal(isNousCloudAgentUrl('http://test.agents.nousresearch.com'), true)
|
||||
|
||||
// Negative cases
|
||||
assert.equal(isNousCloudAgentUrl('http://127.0.0.1:9000'), false)
|
||||
assert.equal(isNousCloudAgentUrl('https://gateway.example.com'), false)
|
||||
assert.equal(isNousCloudAgentUrl('https://nousresearch.com'), false)
|
||||
assert.equal(isNousCloudAgentUrl('not-a-url'), false)
|
||||
})
|
||||
|
||||
test('waitForHermesReady surfaces actionable error for cloud agent 503', async () => {
|
||||
let attempts = 0
|
||||
const currentTime = { value: 0 }
|
||||
|
||||
try {
|
||||
await waitForHermesReady('https://ares-3009.agents.nousresearch.com', {
|
||||
fetchPublicJson: async () => {
|
||||
attempts++
|
||||
// Always return 503
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
sleep: async () => {},
|
||||
// Advance the mock clock per poll — a frozen now() never crosses the
|
||||
// deadline and the readiness loop spins forever (hung the whole vitest
|
||||
// electron project for 20m in CI).
|
||||
now: () => {
|
||||
currentTime.value += 20
|
||||
|
||||
return currentTime.value
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
assert.fail('should have thrown')
|
||||
} catch (error: any) {
|
||||
assert.ok(error.message.includes('Nous Cloud agent'), `unexpected message: ${error.message}`)
|
||||
assert.ok(error.message.includes('503'), `should mention status code: ${error.message}`)
|
||||
assert.ok(error.message.includes('portal.nousresearch.com'), `should mention portal: ${error.message}`)
|
||||
assert.ok(error.message.includes('discord.gg/NousResearch'), `should mention Discord: ${error.message}`)
|
||||
assert.equal(error.isCloudBackendDown, true)
|
||||
assert.equal(error.statusCode, 503)
|
||||
assert.ok(attempts > 1, 'should have retried before failing')
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForHermesReady does not cloud-wrap non-cloud 503 errors', async () => {
|
||||
const currentTime = { value: 0 }
|
||||
|
||||
try {
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
sleep: async () => {},
|
||||
// Same advancing clock as above — frozen now() = infinite loop.
|
||||
now: () => {
|
||||
currentTime.value += 20
|
||||
|
||||
return currentTime.value
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
assert.fail('should have thrown')
|
||||
} catch (error: any) {
|
||||
// Non-cloud URLs get the generic message
|
||||
assert.ok(error.message.includes('did not become ready'), `unexpected message: ${error.message}`)
|
||||
assert.equal(error.isCloudBackendDown, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('isServerSideHttpError detects structured statusCode even when the message is opaque', () => {
|
||||
const err = new Error('upstream unavailable') as any
|
||||
err.statusCode = 503
|
||||
const result = isServerSideHttpError(err)
|
||||
assert.ok(result)
|
||||
assert.equal(result?.statusCode, 503)
|
||||
assert.equal(result?.detail, 'upstream unavailable')
|
||||
|
||||
const err502 = new Error('bad gateway') as any
|
||||
err502.statusCode = 502
|
||||
assert.equal(isServerSideHttpError(err502)?.statusCode, 502)
|
||||
|
||||
const err504 = new Error('gateway timeout') as any
|
||||
err504.statusCode = 504
|
||||
assert.equal(isServerSideHttpError(err504)?.statusCode, 504)
|
||||
})
|
||||
|
||||
test('isServerSideHttpError rejects non-Error inputs even with a 503-shaped value', () => {
|
||||
// The structured path requires an actual Error (the fetch layer attaches
|
||||
// statusCode to an Error instance); a bare string/null/number must not be
|
||||
// misclassified by the legacy prefix fallback.
|
||||
assert.equal(isServerSideHttpError('503: something'), null)
|
||||
assert.equal(isServerSideHttpError({ statusCode: 503 }), null)
|
||||
assert.equal(isServerSideHttpError(null), null)
|
||||
assert.equal(isServerSideHttpError(503), null)
|
||||
})
|
||||
|
||||
test('isServerSideHttpError structured path excludes 500/401/403/404/429 even when statusCode is attached', () => {
|
||||
for (const code of [500, 401, 403, 404, 429]) {
|
||||
const err = new Error(`HTTP ${code}`) as any
|
||||
err.statusCode = code
|
||||
assert.equal(isServerSideHttpError(err), null, `should reject statusCode ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError produces the Cloud shape and preserves cause', () => {
|
||||
const err = new Error('upstream unavailable') as any
|
||||
err.statusCode = 503
|
||||
const result = makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err)
|
||||
assert.ok(result)
|
||||
assert.equal((result as any).isCloudBackendDown, true)
|
||||
assert.equal((result as any).statusCode, 503)
|
||||
assert.equal((result as any).cause, err)
|
||||
assert.ok(result?.message.includes('Nous Cloud agent ares-3009.agents.nousresearch.com is down'))
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError returns null for a Cloud 401 (routes to reauth)', () => {
|
||||
const err = new Error('Unauthorized') as any
|
||||
err.statusCode = 401
|
||||
assert.equal(makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err), null)
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError returns null for a non-Cloud 503 (generic remote failure)', () => {
|
||||
const err = new Error('Service Unavailable') as any
|
||||
err.statusCode = 503
|
||||
assert.equal(makeNousCloudBackendDownError('https://gateway.example.com', err), null)
|
||||
assert.equal(makeNousCloudBackendDownError('http://127.0.0.1:9000', err), null)
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError preserves legacy string-prefix compatibility', () => {
|
||||
const result = makeNousCloudBackendDownError(
|
||||
'https://ares-3009.agents.nousresearch.com',
|
||||
new Error('503: Service Unavailable')
|
||||
)
|
||||
|
||||
assert.ok(result)
|
||||
assert.equal((result as any).isCloudBackendDown, true)
|
||||
assert.equal((result as any).statusCode, 503)
|
||||
})
|
||||
|
||||
@@ -38,6 +38,124 @@ export interface HermesReadyOptions {
|
||||
export const REMOTE_SESSION_EXPIRED_MESSAGE =
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.'
|
||||
|
||||
/**
|
||||
* True for HTTP 502/503/504 from the backend — a server-side fault, not a
|
||||
* connectivity or auth issue. These keep polling in the readiness loop but,
|
||||
* when they exhaust the budget, the user needs to know it is the remote
|
||||
* server that is down, not their local config.
|
||||
*/
|
||||
export function isServerSideHttpError(error: unknown): {
|
||||
statusCode: number
|
||||
detail: string
|
||||
} | null {
|
||||
// Reject non-Error inputs, as before. The fetch layer attaches statusCode to
|
||||
// an actual Error instance (err.statusCode = statusCode), so requiring an
|
||||
// Error is compatible with structured detection and keeps plain strings /
|
||||
// null / numbers from being misclassified by the legacy prefix.
|
||||
if (!(error instanceof Error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Structured-first: the real fetch layer attaches err.statusCode = statusCode
|
||||
// (see fetchJson). That is the strongest transport contract, so inspect it
|
||||
// before falling back to the legacy "503: ..." string prefix.
|
||||
if ('statusCode' in error) {
|
||||
const structured = Number((error as { statusCode?: unknown }).statusCode)
|
||||
|
||||
if (Number.isInteger(structured) && (structured === 502 || structured === 503 || structured === 504)) {
|
||||
const detail = error.message
|
||||
|
||||
return { statusCode: structured, detail }
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility fallback: the legacy leading "503: ..." prefix. Only reached
|
||||
// when no structured statusCode matched (or was absent).
|
||||
const message = error.message
|
||||
const match = /^(\d{3}):/.exec(message)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10)
|
||||
|
||||
if (code === 502 || code === 503 || code === 504) {
|
||||
return { statusCode: code, detail: message }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one factory for the actionable Nous Cloud agent-is-down error, shared by
|
||||
* both startup boundaries that can observe a server-side HTTP fault:
|
||||
*
|
||||
* - OAuth WS-ticket mint (buildRemoteConnection → mintGatewayWsTicket), which
|
||||
* runs BEFORE the readiness loop; and
|
||||
* - readiness-probe exhaustion in waitForHermesReady().
|
||||
*
|
||||
* Returns null unless the backend is a *.agents.nousresearch.com host AND the
|
||||
* error classifies as 502/503/504. When it matches, returns an error carrying:
|
||||
* isCloudBackendDown, statusCode, detail, and the original cause. The renderer
|
||||
* overlay keys on isCloudBackendDown/statusCode; main owns the classification.
|
||||
*/
|
||||
export function makeNousCloudBackendDownError(baseUrl: string, error: unknown): Error | null {
|
||||
if (!isNousCloudAgentUrl(baseUrl)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serverError = isServerSideHttpError(error)
|
||||
|
||||
if (serverError === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
let hostname = baseUrl
|
||||
|
||||
try {
|
||||
hostname = new URL(baseUrl).hostname
|
||||
} catch {
|
||||
// baseUrl is known to parse (isNousCloudAgentUrl already did); keep the raw
|
||||
// value as a last resort rather than throwing.
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
const err = new Error(
|
||||
`Nous Cloud agent ${hostname} is down ` +
|
||||
`(HTTP ${serverError.statusCode}: server-side fault). ` +
|
||||
'Check https://portal.nousresearch.com for backend status, ' +
|
||||
'or switch to Local mode in Settings → Gateway. ' +
|
||||
'You can also reach out on Discord at discord.gg/NousResearch ' +
|
||||
'for immediate assistance. ' +
|
||||
`Original detail: ${detail}`
|
||||
) as any
|
||||
|
||||
err.isCloudBackendDown = true
|
||||
err.statusCode = serverError.statusCode
|
||||
err.detail = detail
|
||||
err.cause = error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the backend URL points at a Nous-managed Hermes Cloud instance
|
||||
* (e.g. ares-3009.agents.nousresearch.com). These are Fly.io-hosted machines
|
||||
* the user cannot restart themselves — a 503 from one means the server is down
|
||||
* and the recovery path is Portal/Discord/wait.
|
||||
*/
|
||||
export function isNousCloudAgentUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname
|
||||
|
||||
return host.endsWith('.agents.nousresearch.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isMissingHealthEndpointError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
@@ -165,5 +283,18 @@ export async function waitForHermesReady(baseUrl: string, options: HermesReadyOp
|
||||
}
|
||||
|
||||
const detail = lastError instanceof Error ? lastError.message : 'timeout'
|
||||
|
||||
// When a Nous-managed cloud agent returns a server-side HTTP error
|
||||
// (502/503/504), the backend server itself is down — the user cannot
|
||||
// restart it and the generic "did not become ready" message is opaque.
|
||||
// Surface an actionable error instead (#85335). This is the SAME factory
|
||||
// buildRemoteConnection uses at the OAuth WS-ticket-mint boundary, so both
|
||||
// startup paths produce the identical Cloud-down shape.
|
||||
const cloudError = makeNousCloudBackendDownError(baseUrl, lastError)
|
||||
|
||||
if (cloudError !== null) {
|
||||
throw cloudError
|
||||
}
|
||||
|
||||
throw new Error(`Hermes backend did not become ready: ${detail}`)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { makeNousCloudBackendDownError } from './backend-health'
|
||||
import {
|
||||
apiRequestRegistryConnectionId,
|
||||
AT_COOKIE_VARIANTS,
|
||||
@@ -1167,3 +1168,83 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
||||
/mintTicket function is required/
|
||||
)
|
||||
})
|
||||
|
||||
test('gatewayTicketFailure preserves a structured 503 statusCode as a transport failure', () => {
|
||||
const source = new Error('upstream unavailable') as any
|
||||
source.statusCode = 503
|
||||
|
||||
const wrapped = gatewayTicketFailure(source, 'auth message', 'transport message')
|
||||
|
||||
assert.equal(wrapped.message, 'transport message')
|
||||
assert.equal((wrapped as any).statusCode, 503)
|
||||
assert.equal((wrapped as any).needsOauthLogin, undefined)
|
||||
assert.equal((wrapped as any).cause, source)
|
||||
})
|
||||
|
||||
test('gatewayTicketFailure keeps 401 and 403 as reauth with needsOauthLogin', () => {
|
||||
for (const code of [401, 403]) {
|
||||
const source = new Error(`HTTP ${code}`) as any
|
||||
source.statusCode = code
|
||||
|
||||
const wrapped = gatewayTicketFailure(source, 'auth message', 'transport message')
|
||||
|
||||
assert.equal(wrapped.message, 'auth message')
|
||||
assert.equal((wrapped as any).needsOauthLogin, true)
|
||||
assert.equal((wrapped as any).statusCode, code)
|
||||
assert.equal((wrapped as any).cause, source)
|
||||
}
|
||||
})
|
||||
|
||||
test('gatewayTicketFailure only copies an integer statusCode, not a message prefix', () => {
|
||||
// A legacy "503: ..." message carries no structured statusCode; the Cloud
|
||||
// classifier (makeNousCloudBackendDownError) handles the prefix at the mint
|
||||
// boundary. The wrapper must not invent an integer from the message.
|
||||
const source = new Error('503: Service Unavailable') as any
|
||||
|
||||
const wrapped = gatewayTicketFailure(source, 'auth message', 'transport message')
|
||||
|
||||
assert.equal((wrapped as any).statusCode, undefined)
|
||||
assert.equal((wrapped as any).needsOauthLogin, undefined)
|
||||
})
|
||||
|
||||
// OAuth integration regression (#85373): the WS-ticket mint boundary runs
|
||||
// BEFORE waitForHermesReady. This mirrors main.ts buildRemoteConnection's
|
||||
// catch — classify a Nous Cloud server fault via the shared factory, else
|
||||
// fall through to gatewayTicketFailure. Proves the production composition:
|
||||
// 1. Cloud + OAuth ticket mint + 503 -> actionable Cloud-down error
|
||||
// 2. Cloud + OAuth ticket mint + 401 -> reauth (never Cloud-down)
|
||||
test('OAuth ticket-mint 503 surfaces the Cloud-down error (startup boundary)', () => {
|
||||
const baseUrl = 'https://ares-3009.agents.nousresearch.com'
|
||||
const ticketErr = new Error('upstream unavailable') as any
|
||||
ticketErr.statusCode = 503
|
||||
|
||||
// The exact production sequence from main.ts.
|
||||
const cloudError = makeNousCloudBackendDownError(baseUrl, ticketErr)
|
||||
|
||||
if (cloudError !== null) {
|
||||
assert.equal((cloudError as any).isCloudBackendDown, true)
|
||||
assert.equal((cloudError as any).statusCode, 503)
|
||||
assert.ok(cloudError.message.includes('Nous Cloud agent ares-3009.agents.nousresearch.com is down'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const wrapped = gatewayTicketFailure(ticketErr, 'auth', 'transport')
|
||||
|
||||
assert.fail(`expected Cloud-down classification, got wrapper: ${wrapped.message}`)
|
||||
})
|
||||
|
||||
test('OAuth ticket-mint 401 stays on the reauth path (never Cloud-down)', () => {
|
||||
const baseUrl = 'https://ares-3009.agents.nousresearch.com'
|
||||
const ticketErr = new Error('Unauthorized') as any
|
||||
ticketErr.statusCode = 401
|
||||
|
||||
const cloudError = makeNousCloudBackendDownError(baseUrl, ticketErr)
|
||||
assert.equal(cloudError, null, 'a 401 must not become a Cloud-down error')
|
||||
|
||||
const wrapped = gatewayTicketFailure(ticketErr, 'auth message', 'transport message')
|
||||
|
||||
assert.equal(wrapped.message, 'auth message')
|
||||
assert.equal((wrapped as any).needsOauthLogin, true)
|
||||
assert.equal((wrapped as any).statusCode, 401)
|
||||
})
|
||||
|
||||
@@ -134,6 +134,18 @@ function gatewayTicketFailure(error, authMessage, transportMessage) {
|
||||
;(err as any).needsOauthLogin = true
|
||||
}
|
||||
|
||||
// Preserve structured HTTP context when the source error carried an integer
|
||||
// statusCode (the fetch layer attaches err.statusCode). Downstream Cloud
|
||||
// classification (isServerSideHttpError / makeNousCloudBackendDownError) and
|
||||
// the renderer overlay depend on it surviving the ticket-error wrapper. Auth
|
||||
// semantics are unchanged: 401/403 route to reauth, 5xx stays a transport
|
||||
// failure, everything else keeps current behavior.
|
||||
const sourceStatus = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN)
|
||||
|
||||
if (Number.isInteger(sourceStatus)) {
|
||||
;(err as any).statusCode = sourceStatus
|
||||
}
|
||||
|
||||
err.cause = error
|
||||
|
||||
return err
|
||||
|
||||
@@ -98,6 +98,12 @@ export function registerFsIpc({
|
||||
|
||||
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins'))
|
||||
|
||||
// The LOCAL logs root (`<HERMES_HOME>/logs`, profile-aware) — the error
|
||||
// card's "Open Logs" action reveals agent.log/gateway.log without the user
|
||||
// knowing where HERMES_HOME lives. Same Electron-local resolution as the
|
||||
// plugin roots: valid in every connection mode, created on demand.
|
||||
ipcMain.handle('hermes:fs:logsRoot', async () => localPluginsRoot('logs'))
|
||||
|
||||
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
|
||||
// resolution as above. This is the desktop half of a UNIFIED plugin package:
|
||||
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
|
||||
|
||||
@@ -35,7 +35,7 @@ import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } f
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { isReauthRequiredError, waitForHermesReady } from './backend-health'
|
||||
import { isReauthRequiredError, makeNousCloudBackendDownError, waitForHermesReady } from './backend-health'
|
||||
import { backendCommandMatches, createBackendOwnership, createBackendShutdownCoordinator } from './backend-ownership'
|
||||
import {
|
||||
canImportHermesCli,
|
||||
@@ -1371,11 +1371,13 @@ let nativeThemeListenerInstalled = false
|
||||
let bootProgressState = {
|
||||
error: null,
|
||||
fakeMode: BOOT_FAKE_MODE,
|
||||
isCloudBackendDown: false,
|
||||
message: 'Waiting to start Hermes backend',
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
retryable: false,
|
||||
running: false,
|
||||
statusCode: null,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
@@ -8979,6 +8981,19 @@ async function buildRemoteConnection(
|
||||
try {
|
||||
ticket = await mintGatewayWsTicket(baseUrl, remoteHeaders)
|
||||
} catch (error) {
|
||||
// For a Nous-managed Cloud agent, a 502/503/504 from the WS-ticket mint
|
||||
// means the backend server itself is down — the actionable Cloud-down
|
||||
// error. This boundary runs BEFORE the readiness loop, so without this
|
||||
// the ticket wrapper below would swallow the server-fault classification
|
||||
// and the renderer would never see isCloudBackendDown. Preserve the
|
||||
// existing 401/403 reauth and generic transport behavior for everything
|
||||
// else (#85335).
|
||||
const cloudError = makeNousCloudBackendDownError(baseUrl, error)
|
||||
|
||||
if (cloudError !== null) {
|
||||
throw cloudError
|
||||
}
|
||||
|
||||
throw gatewayTicketFailure(
|
||||
error,
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
|
||||
@@ -10892,6 +10907,18 @@ async function startHermes() {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const hostKeyChanged = isHostKeyChangedBootFailure(error)
|
||||
|
||||
// Carry structured Cloud-down metadata through the boot-progress / IPC
|
||||
// boundary when present, so the renderer overlay can key on it rather than
|
||||
// re-classifying the message string. main owns classification; the renderer
|
||||
// only consumes the structured result (#85335).
|
||||
const isCloudBackendDown = Boolean(error && typeof error === 'object' && (error as any).isCloudBackendDown === true)
|
||||
|
||||
const statusCode = Number(
|
||||
error && typeof error === 'object' && Number.isInteger((error as any).statusCode)
|
||||
? (error as any).statusCode
|
||||
: NaN
|
||||
)
|
||||
|
||||
// Only latch LOCAL boot failures. A remote failure (lapsed session / mint
|
||||
// timeout / host briefly unreachable across sleep) is transient and has no
|
||||
// child 'exit' handler to clear the cache — latching it would wedge the app
|
||||
@@ -10920,6 +10947,7 @@ async function startHermes() {
|
||||
updateBootProgress(
|
||||
{
|
||||
error: message,
|
||||
isCloudBackendDown: isCloudBackendDown || undefined,
|
||||
message: `Desktop boot failed: ${message}`,
|
||||
phase: 'backend.error',
|
||||
// Renderer contract for the self-heal loop (#82679): a transient
|
||||
@@ -10933,7 +10961,8 @@ async function startHermes() {
|
||||
isReauth: isReauthRequiredError(error),
|
||||
isHostKeyChanged: hostKeyChanged
|
||||
}),
|
||||
running: false
|
||||
running: false,
|
||||
statusCode: Number.isInteger(statusCode) ? statusCode : undefined
|
||||
},
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
|
||||
@@ -269,6 +269,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
|
||||
desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'),
|
||||
logsRoot: () => ipcRenderer.invoke('hermes:fs:logsRoot'),
|
||||
agentPluginsRoot: () => ipcRenderer.invoke('hermes:fs:agentPluginsRoot'),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
|
||||
@@ -66,14 +66,12 @@
|
||||
"test:find-in-page-native": "electron electron/find-in-page-native-fixture",
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check:test:ui": "npm run test:ui",
|
||||
"check:test:desktop:platforms": "npm run test:desktop:platforms",
|
||||
"check:test:plugins": "node --test src/plugins/*/tests/*.test.mjs",
|
||||
"check:test:ui:shard-1of3": "node scripts/run-ui-shard.mjs",
|
||||
"check:test:ui:shard-2of3": "node scripts/run-ui-shard.mjs",
|
||||
"check:test:ui:shard-3of3": "node scripts/run-ui-shard.mjs",
|
||||
"check:test:desktop:all": "npm run test:desktop:all",
|
||||
"check:test:plugins": "node --test src/plugins/*/tests/*.test.mjs",
|
||||
"check:lint": "npm run typecheck && npm run lint",
|
||||
"check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all",
|
||||
"check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all && npm run check:test:plugins",
|
||||
"test:e2e": "npm run build && playwright test e2e/",
|
||||
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Runs one shard of the UI vitest suite, deriving the shard index/count from
|
||||
// the npm script NAME (npm_lifecycle_event), so the name and the flag can
|
||||
// never disagree. A copy-paste slip like "check:test:ui:shard-2of3" running
|
||||
// --shard=1/3 would silently skip a third of the suite while CI stays green;
|
||||
// deriving from the name makes that impossible.
|
||||
//
|
||||
// It also validates that this package.json declares exactly the shard family
|
||||
// 1..M for a single M, so a partial 3→4 migration (adding shard-4of4 without
|
||||
// updating the siblings) fails loudly instead of dropping coverage.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SHARD_RE = /^check:test:ui:shard-(\d+)of(\d+)$/
|
||||
|
||||
const scriptName = process.env.npm_lifecycle_event ?? ''
|
||||
const match = scriptName.match(SHARD_RE)
|
||||
if (!match) {
|
||||
console.error(
|
||||
`run-ui-shard: must be invoked via an npm script named check:test:ui:shard-<N>of<M> (got ${JSON.stringify(scriptName)})`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const [, indexRaw, countRaw] = match
|
||||
const index = Number(indexRaw)
|
||||
const count = Number(countRaw)
|
||||
if (!(index >= 1 && index <= count)) {
|
||||
console.error(`run-ui-shard: shard index ${index} out of range 1..${count}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// The whole family must be exactly 1..M of one M — otherwise a rename or a
|
||||
// partial count bump leaves a silently untested slice of the suite.
|
||||
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
||||
const family = Object.keys(pkg.scripts ?? {})
|
||||
.map((name) => name.match(SHARD_RE))
|
||||
.filter(Boolean)
|
||||
const counts = new Set(family.map((m) => Number(m[2])))
|
||||
const indices = family.map((m) => Number(m[1])).sort((a, b) => a - b)
|
||||
const expected = Array.from({ length: count }, (_, i) => i + 1)
|
||||
if (counts.size !== 1 || indices.length !== count || indices.some((v, i) => v !== expected[i])) {
|
||||
console.error(
|
||||
`run-ui-shard: shard scripts must form exactly 1..M for a single M; found indices [${indices}] with counts {${[...counts]}}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Delegate through test:ui so the vitest command stays single-sourced.
|
||||
// npm resolves to npm.cmd on Windows, which needs a shell (same handling as
|
||||
// test-desktop.mjs and stage-native-deps.mjs).
|
||||
const result = spawnSync(
|
||||
'npm',
|
||||
['run', 'test:ui', '--', `--shard=${index}/${count}`, ...process.argv.slice(2)],
|
||||
{ stdio: 'inherit', cwd: pkgDir, shell: process.platform === 'win32' },
|
||||
)
|
||||
if (result.error) {
|
||||
console.error(`run-ui-shard: ${result.error.message}`)
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -9,7 +9,6 @@
|
||||
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'
|
||||
@@ -44,12 +43,7 @@ export interface PaneMirror<T> {
|
||||
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
|
||||
tabDrag?: (key: string, event: ReactPointerEvent<HTMLElement>, onTap: () => void) => boolean
|
||||
/** Wired as the pane's closer (tab Close). */
|
||||
close: (key: string) => void
|
||||
}
|
||||
@@ -89,11 +83,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
minWidth: cfg.minWidth,
|
||||
// Every mirrored tile is a full workspace surface docked beside main —
|
||||
// and closeable, which is what keeps its tab when it lands in a zone of
|
||||
// its own (see lone-header.ts).
|
||||
// its own (see strip-visibility.ts).
|
||||
placement: 'main',
|
||||
tabDrag: cfg.tabDrag
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
|
||||
cfg.tabDrag!(key, event, onTap, double)
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void) => cfg.tabDrag!(key, event, onTap)
|
||||
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
|
||||
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
|
||||
},
|
||||
|
||||
@@ -30,7 +30,6 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { queryAllVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { findGroup } from '@/components/pane-shell/tree/model'
|
||||
import {
|
||||
type DoubleTapContext,
|
||||
rectContains,
|
||||
slotBefore,
|
||||
snapshotStrips,
|
||||
@@ -95,15 +94,15 @@ function tileZoneHost(groupId: string): { chat: boolean; pane: string } | 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.
|
||||
* stay ordinary clicks, so `opts.onTap` (activate the tile) rides the tab's
|
||||
* gesture; 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 }
|
||||
opts?: { onTap?: () => void }
|
||||
) {
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
@@ -124,7 +123,6 @@ export function startSessionDrag(
|
||||
const restoreOpacity = source?.style.opacity ?? ''
|
||||
|
||||
startDragSession(e, {
|
||||
double: opts?.double,
|
||||
ghost: { label: sessionLabel(payload) },
|
||||
onTap: opts?.onTap,
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
|
||||
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, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupTabStrip } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
@@ -560,7 +560,7 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
||||
const group = tree ? findGroupOfPane(tree, 'workspace') : null
|
||||
|
||||
if (group) {
|
||||
setTreeGroupHeaderHidden(group.id, true)
|
||||
setTreeGroupTabStrip(group.id, 'never')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,9 +616,9 @@ export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
</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 })
|
||||
// its tap (activate) preserved. Always takes the drag.
|
||||
tabDrag: (storedSessionId, event, onTap) => {
|
||||
startSessionDrag(tileDragPayload(storedSessionId), event, { onTap })
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { terminalMenuHandleFor } from '@/app/right-sidebar/terminal/terminal-context-menu'
|
||||
import { toggleTargetZoneTabStrip } from '@/components/pane-shell/tree/store'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { HERMES_CONTEXT_MENU_TRIGGER_ATTR } from '@/components/ui/context-menu'
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
@@ -566,6 +567,15 @@ function shellSections({ navigate, t }: ShellVerbs): ReactNode[][] {
|
||||
label={t.keybinds.actions['view.toggleStatusbar']}
|
||||
onSelect={toggleStatusbarVisible}
|
||||
/>,
|
||||
// The pointer-only way back to a hidden tab strip: right-clicking the
|
||||
// shell reaches this menu from anywhere, including a zone that has no
|
||||
// chrome left to right-click.
|
||||
<Item
|
||||
icon="layout-menubar"
|
||||
key="shell-tabstrip"
|
||||
label={t.keybinds.actions['view.toggleTabStrip']}
|
||||
onSelect={() => void toggleTargetZoneTabStrip()}
|
||||
/>,
|
||||
<Item
|
||||
icon="settings-gear"
|
||||
key="shell-settings"
|
||||
|
||||
@@ -11,7 +11,6 @@ import { IdleMount } from '@/components/idle-mount'
|
||||
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
|
||||
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
bindPaneVisibility,
|
||||
@@ -30,7 +29,9 @@ import {
|
||||
resetLayoutTree,
|
||||
revealTreePane,
|
||||
setStripTabHidden,
|
||||
targetZoneTabStripVisible,
|
||||
togglePaneVisible,
|
||||
toggleTargetZoneTabStrip,
|
||||
watchContributedPanes
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
@@ -41,7 +42,7 @@ import { registry } from '@/contrib/registry'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||
import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons'
|
||||
import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Zap } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
|
||||
import { setYoloEnabled } from '@/lib/yolo-session'
|
||||
@@ -138,14 +139,14 @@ const workspaceDragPayload = (): SessionDragPayload | null => {
|
||||
// The main tab drags like a session tile — drop it on a composer to link the
|
||||
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
|
||||
// move when there's no loaded session to carry.
|
||||
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
|
||||
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void) => {
|
||||
const payload = workspaceDragPayload()
|
||||
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
startSessionDrag(payload, event, { double, onTap })
|
||||
startSessionDrag(payload, event, { onTap })
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -163,7 +164,6 @@ registry.registerMany([
|
||||
collapsible: true,
|
||||
dock: { pane: 'workspace', pos: 'left' },
|
||||
revealAliases: ['chat-sidebar'],
|
||||
showCloseButton: false,
|
||||
// Standing chrome: no close gestures at all — the tab is shown/hidden
|
||||
// (zone menu Show/Hide rows + the auto-registered ⌘K toggle below).
|
||||
hideOnly: true,
|
||||
@@ -324,6 +324,18 @@ registry.registerMany([
|
||||
get: () => $statusbarVisible.get(),
|
||||
set: enabled => $statusbarVisible.set(enabled)
|
||||
}),
|
||||
paletteToggle({
|
||||
id: 'view.toggleTabStrip',
|
||||
label: 'Toggle tabs',
|
||||
action: 'view.toggleTabStrip',
|
||||
icon: PanelTop,
|
||||
keywords: ['tab strip', 'tab bar', 'tabs', 'header', 'zone', 'hide', 'show', 'chrome'],
|
||||
// On-screen truth for the zone the verbs target, not a stored flag: a zone
|
||||
// on auto has no stored value, and the row must read as "what pressing
|
||||
// this does to what I can see".
|
||||
get: () => Boolean(targetZoneTabStripVisible()),
|
||||
set: () => void toggleTargetZoneTabStrip()
|
||||
}),
|
||||
// The keybind panel's non-titlebar door (the keyboard icon is gone).
|
||||
{
|
||||
id: 'keybinds.panel',
|
||||
|
||||
@@ -147,11 +147,13 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
|
||||
/>
|
||||
)
|
||||
|
||||
// FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a
|
||||
// page is not a tab-able surface, so the zone's double-click header toggle
|
||||
// stands down while one is showing (see onZoneDoubleClick).
|
||||
// FULL-PAGE views (not chat): a page is not a tab-able surface, so the zone's
|
||||
// tab strip stands down while one is showing. That is `paneChrome.headerVeto`
|
||||
// on the contribution, not a DOM marker — the `data-zone-no-header` attribute
|
||||
// that used to ride this wrapper gated a body double-click toggle that no
|
||||
// longer exists, and nothing has read it since.
|
||||
const page = (view: ReactNode) => (
|
||||
<div className="contents" data-zone-no-header>
|
||||
<div className="contents">
|
||||
<Suspense fallback={null}>{view}</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { SendDiagnosticsHost } from '@/components/send-diagnostics-dialog'
|
||||
import { emitGatewayEvent } from '@/contrib/events'
|
||||
import { getLatestSessionMessages } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
@@ -1181,6 +1182,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
{/* Backs confirm() from @/store/confirm — renders only while one is open. */}
|
||||
<ConfirmHost />
|
||||
|
||||
{/* Send Diagnostics consent/upload dialog — driven by $sendDiagnostics
|
||||
(error card action); renders nothing until requested. */}
|
||||
<SendDiagnosticsHost />
|
||||
|
||||
{/* Petdex floating mascot — renders nothing unless installed + enabled.
|
||||
Never in the HUD: that window is the chat bar and nothing else. */}
|
||||
{!isHudWindow() && <FloatingPet />}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
cycleTreeTabInFocusedZone,
|
||||
isPaneVisible,
|
||||
layoutHasRootSide,
|
||||
togglePaneVisible
|
||||
togglePaneVisible,
|
||||
toggleTargetZoneTabStrip
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { onReleaseTypingFocus } from '@/components/ui/keyboard-first'
|
||||
import { findBarClaimsCombo } from '@/lib/find-in-page'
|
||||
@@ -245,6 +246,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
layoutHasRootSide('right') ? toggleFileBrowserOpen() : togglePaneVisible('terminal'),
|
||||
'view.toggleReview': toggleReview,
|
||||
'view.toggleStatusbar': toggleStatusbarVisible,
|
||||
'view.toggleTabStrip': () => void toggleTargetZoneTabStrip(),
|
||||
'view.showFiles': showFiles,
|
||||
'view.showBrowser': openBrowserTab,
|
||||
'view.toggleHud': () => toggleHud(hudTargetSessionId()),
|
||||
|
||||
+6
-3
@@ -4,6 +4,7 @@ import { burstVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { coerceGatewayText, coerceThinkingText } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { parseErrorSurface } from '@/lib/error-surface'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
@@ -335,13 +336,15 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
|
||||
// Terminal error frames (status "error") carry the failure in
|
||||
// structured fields: `error` is the message, and `partial` marks
|
||||
// `text` as streamed output to keep rather than the error string.
|
||||
// structured fields: `error` is the message, `partial` marks
|
||||
// `text` as streamed output to keep rather than the error string, and
|
||||
// `error_surface` (newer gateways) names the failing layer for the card.
|
||||
const failure =
|
||||
payload?.status === 'error'
|
||||
? {
|
||||
error: coerceGatewayText(payload.error).trim() || finalText || 'Hermes reported an error',
|
||||
partial: Boolean(payload.partial)
|
||||
partial: Boolean(payload.partial),
|
||||
surface: parseErrorSurface(payload.error_surface)
|
||||
}
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
sealOpenToolParts,
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import type { ErrorSurface } from '@/lib/error-surface'
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
generatedImageEchoSources,
|
||||
@@ -561,7 +562,7 @@ export function useMessageStream({
|
||||
sessionId: string,
|
||||
text: string,
|
||||
responsePreviewed?: boolean,
|
||||
failure?: { error: string; partial: boolean },
|
||||
failure?: { error: string; partial: boolean; surface?: ErrorSurface | null },
|
||||
occurredAt = Date.now() / 1000
|
||||
) => {
|
||||
let shouldHydrate = false
|
||||
@@ -616,7 +617,8 @@ export function useMessageStream({
|
||||
parts: completeOpenTimelineParts(message.parts, occurredAt),
|
||||
pending: false,
|
||||
interim: false,
|
||||
...(durationS !== undefined ? { durationS } : {})
|
||||
...(durationS !== undefined ? { durationS } : {}),
|
||||
...(completionError && failure?.surface ? { errorSurface: failure.surface } : {})
|
||||
}
|
||||
|
||||
if (completionError && !keepFailedPartialText) {
|
||||
@@ -641,7 +643,8 @@ export function useMessageStream({
|
||||
completedAt: occurredAt,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined,
|
||||
...(durationS !== undefined ? { durationS } : {}),
|
||||
...(completionError && { error: completionError })
|
||||
...(completionError && { error: completionError }),
|
||||
...(completionError && failure?.surface ? { errorSurface: failure.surface } : {})
|
||||
})
|
||||
|
||||
const prev = state.messages
|
||||
|
||||
@@ -78,4 +78,38 @@ describe('terminal error message.complete frames', () => {
|
||||
const bubble = lastAssistant()
|
||||
expect(bubble?.error).toBe('Error: something broke')
|
||||
})
|
||||
|
||||
it('attaches the structured error_surface descriptor to the failed bubble', async () => {
|
||||
mountStream()
|
||||
await start()
|
||||
await delta('…')
|
||||
|
||||
await completeWithError({
|
||||
text: 'Error: rate limited',
|
||||
error: 'rate limited',
|
||||
error_surface: { layer: 'provider', code: 'rate_limit', retryable: true },
|
||||
recoverable: true
|
||||
})
|
||||
|
||||
const bubble = lastAssistant()
|
||||
expect(bubble?.error).toBe('rate limited')
|
||||
expect(bubble?.errorSurface).toEqual({ layer: 'provider', code: 'rate_limit', retryable: true })
|
||||
})
|
||||
|
||||
it('ignores a garbled error_surface payload (older/foreign backends)', async () => {
|
||||
mountStream()
|
||||
await start()
|
||||
await delta('…')
|
||||
|
||||
await completeWithError({
|
||||
text: 'Error: kaput',
|
||||
error: 'kaput',
|
||||
error_surface: { layer: 'not-a-layer', code: 42 },
|
||||
recoverable: true
|
||||
})
|
||||
|
||||
const bubble = lastAssistant()
|
||||
expect(bubble?.error).toBe('kaput')
|
||||
expect(bubble?.errorSurface).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getSession } from '@/hermes'
|
||||
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { parseErrorSurface } from '@/lib/error-surface'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -146,6 +147,9 @@ const COMPARED_FIELDS = [
|
||||
'role',
|
||||
'pending',
|
||||
'error',
|
||||
// Structured failure layer — drives the error card's title and action row,
|
||||
// so a change (e.g. resume replay attaching the descriptor) must repaint.
|
||||
'errorSurface',
|
||||
'hidden',
|
||||
'branchGroupId',
|
||||
'interim',
|
||||
@@ -254,6 +258,11 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
|
||||
a.role !== b.role ||
|
||||
a.pending !== b.pending ||
|
||||
a.error !== b.error ||
|
||||
// Structural compare — the descriptor arrives as a fresh object per
|
||||
// resume/replay, so identity comparison would repaint forever.
|
||||
(a.errorSurface?.layer ?? null) !== (b.errorSurface?.layer ?? null) ||
|
||||
(a.errorSurface?.code ?? null) !== (b.errorSurface?.code ?? null) ||
|
||||
(a.errorSurface?.retryable ?? null) !== (b.errorSurface?.retryable ?? null) ||
|
||||
a.hidden !== b.hidden ||
|
||||
a.branchGroupId !== b.branchGroupId ||
|
||||
a.timestamp !== b.timestamp ||
|
||||
@@ -742,6 +751,7 @@ export function appendLiveSessionProjection(messages: ChatMessage[], projection:
|
||||
// the terminal frame may have been lost to a disconnect) — surface the
|
||||
// failure on the projected row instead of rendering the partial as healthy.
|
||||
const inflightError = projection.inflight?.error?.trim() ?? ''
|
||||
const inflightErrorSurface = parseErrorSurface(projection.inflight?.error_surface)
|
||||
const queuedUser = projection.queued?.user?.trim() ?? ''
|
||||
|
||||
if (
|
||||
@@ -904,7 +914,8 @@ export function appendLiveSessionProjection(messages: ChatMessage[], projection:
|
||||
role: 'assistant',
|
||||
parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [],
|
||||
pending: inflightStreaming,
|
||||
...(inflightError ? { error: inflightError } : {})
|
||||
...(inflightError ? { error: inflightError } : {}),
|
||||
...(inflightError && inflightErrorSurface ? { errorSurface: inflightErrorSurface } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
|
||||
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
|
||||
import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
|
||||
import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density'
|
||||
import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import {
|
||||
$translucency,
|
||||
@@ -345,6 +346,7 @@ export function AppearanceSettings() {
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault)
|
||||
const sessionListDensity = useStore($sessionListDensity)
|
||||
const tabStripDefault = useStore($tabStripDefault)
|
||||
const zoomPercent = useStore($zoomPercent)
|
||||
const embedMode = useStore($embedMode)
|
||||
const embedAllowed = useStore($embedAllowed)
|
||||
@@ -423,6 +425,12 @@ export function AppearanceSettings() {
|
||||
{ id: 'detailed', label: a.sessionDensityDetailed }
|
||||
] as const satisfies readonly { id: SessionListDensity; label: string }[]
|
||||
|
||||
const tabStripOptions = [
|
||||
{ id: 'auto', label: a.tabStripAuto },
|
||||
{ id: 'always', label: a.tabStripAlways },
|
||||
{ id: 'never', label: a.tabStripNever }
|
||||
] as const satisfies readonly { id: TabStripDefault; label: string }[]
|
||||
|
||||
const embedOptions = [
|
||||
{ id: 'ask', label: a.embedsAsk },
|
||||
{ id: 'always', label: a.embedsAlways },
|
||||
@@ -583,6 +591,21 @@ export function AppearanceSettings() {
|
||||
title={a.sessionDensityTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setTabStripDefault(id)
|
||||
}}
|
||||
options={tabStripOptions}
|
||||
value={tabStripDefault}
|
||||
/>
|
||||
}
|
||||
description={a.tabStripDesc}
|
||||
title={a.tabStripTitle}
|
||||
/>
|
||||
|
||||
{/* Linux has neither half of this setting (see TRANSLUCENCY_SUPPORTED),
|
||||
so the row is absent there rather than offering a dead lever. */}
|
||||
{TRANSLUCENCY_SUPPORTED && (
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useMemo, useState } from 'react'
|
||||
import { useInRouterContext, useNavigate } from 'react-router'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { ChangedFilesCard } from '@/components/assistant-ui/thread/changed-files-card'
|
||||
import {
|
||||
contentHasVisibleText,
|
||||
@@ -28,14 +30,26 @@ import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ErrorSurface, formatErrorDiagnostics } from '@/lib/error-surface'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import {
|
||||
AudioLines,
|
||||
GitForkIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
SmilePlusIcon,
|
||||
Upload,
|
||||
VolumeXIcon,
|
||||
XIcon
|
||||
} from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { markAssistantIdSpoken } from '@/lib/spoken-reply'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { requestSendDiagnostics } from '@/store/send-diagnostics'
|
||||
import { $connection, $currentModel } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
// Stable empty identity for the settled-parts selector — a fresh [] per render
|
||||
@@ -224,20 +238,26 @@ const AssistantMessageBody: FC<AssistantMessageProps & { collapsedNotice?: null
|
||||
<AssistantPreviewEmbeds />
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root
|
||||
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
||||
className="mt-1.5 flex flex-col gap-1.5 rounded-lg border border-[color-mix(in_srgb,var(--dt-destructive)_35%,transparent)] bg-[color-mix(in_srgb,var(--dt-destructive)_7%,transparent)] px-3 py-2 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
||||
role="alert"
|
||||
>
|
||||
<ErrorPrimitive.Message className="min-w-0 flex-1" />
|
||||
{onDismissError && (
|
||||
<TooltipIconButton
|
||||
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
|
||||
onClick={() => onDismissError(messageId)}
|
||||
side="top"
|
||||
tooltip={t.assistant.thread.dismissError}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ErrorLayerLabel />
|
||||
<ErrorPrimitive.Message className="min-w-0" />
|
||||
</div>
|
||||
{onDismissError && (
|
||||
<TooltipIconButton
|
||||
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
|
||||
onClick={() => onDismissError(messageId)}
|
||||
side="top"
|
||||
tooltip={t.assistant.thread.dismissError}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
</div>
|
||||
<ErrorRecoveryActions />
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
@@ -433,6 +453,131 @@ const StreamingMarker: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Layered error card pieces ────────────────────────────────────────────
|
||||
//
|
||||
// The gateway stamps failed turns with a structured {layer, code, retryable}
|
||||
// descriptor (metadata.custom.errorSurface — see agent/error_surface.py).
|
||||
// These leaves render the layer label + recovery actions. Older backends
|
||||
// never send the descriptor: the label falls back to a generic title and the
|
||||
// action row still offers Retry / Open Logs / Copy error details, so nothing
|
||||
// regresses on version skew.
|
||||
|
||||
const ErrorLayerLabel: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
|
||||
|
||||
const labels = t.assistant.thread.errorLayers
|
||||
const label = (surface && labels[surface.layer]) || labels.generic
|
||||
|
||||
return <div className="font-medium">{label}</div>
|
||||
}
|
||||
|
||||
// Isolated because useNavigate() THROWS outside a <Router> (bare test
|
||||
// harnesses, embedded panes render threads router-free). The parent gates
|
||||
// this child's mount on useInRouterContext(), which is safe anywhere.
|
||||
const SwitchProviderAction: FC<{ label: string }> = ({ label }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<button className="aui-error-action" onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model`)} type="button">
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorRecoveryActions: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
|
||||
|
||||
const errorText = useAuiState(s => {
|
||||
const status = s.message.status as { error?: unknown; type?: string } | undefined
|
||||
|
||||
return status?.type === 'incomplete' && typeof status.error === 'string' ? status.error : ''
|
||||
})
|
||||
|
||||
// useNavigate() would throw here when no Router is above us; the deep-link
|
||||
// child mounts only when one is (see SwitchProviderAction).
|
||||
const inRouter = useInRouterContext()
|
||||
const model = useStore($currentModel)
|
||||
const connection = useStore($connection)
|
||||
|
||||
// Open Logs reveals the LOCAL Electron profile's HERMES_HOME/logs. On a
|
||||
// remote/cloud connection the failed turn's gateway+agent logs live on the
|
||||
// remote box — the local folder only holds Desktop-side transport logs, so
|
||||
// the label says "Open Desktop logs" there instead of implying it opens the
|
||||
// runtime's logs.
|
||||
const remoteConnection = connection?.mode === 'remote'
|
||||
|
||||
// Retry = assistant-ui reload (same wiring as the footer's refresh action):
|
||||
// re-runs the failed turn's prompt in place. Suppressed when the classifier
|
||||
// says the failure is deterministic (retrying reproduces it).
|
||||
const retryable = !surface || surface.retryable
|
||||
|
||||
// Switch Provider deep-links Settings → Models for the layers where the fix
|
||||
// is provider/endpoint/auth config, not a retry.
|
||||
const showSwitchProvider = surface != null && ['auth', 'billing', 'endpoint', 'provider'].includes(surface.layer)
|
||||
|
||||
const openLogs = useCallback(async () => {
|
||||
try {
|
||||
const root = await window.hermesDesktop?.logsRoot?.()
|
||||
|
||||
if (!root) {
|
||||
notifyError(new Error('logs root unavailable'), copy.errorOpenLogsFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop?.openDir?.(root)
|
||||
|
||||
if (result && !result.ok) {
|
||||
notifyError(new Error(result.error || 'open failed'), copy.errorOpenLogsFailed)
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, copy.errorOpenLogsFailed)
|
||||
}
|
||||
}, [copy.errorOpenLogsFailed])
|
||||
|
||||
const diagnosticsText = useCallback(
|
||||
() =>
|
||||
formatErrorDiagnostics({
|
||||
errorText,
|
||||
model: model || undefined,
|
||||
surface
|
||||
}),
|
||||
[errorText, model, surface]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{retryable && (
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<button className="aui-error-action" onClick={() => triggerHaptic('submit')} type="button">
|
||||
<RefreshCwIcon className="size-3" />
|
||||
{copy.errorRetry}
|
||||
</button>
|
||||
</ActionBarPrimitive.Reload>
|
||||
)}
|
||||
{showSwitchProvider && inRouter && <SwitchProviderAction label={copy.errorSwitchProvider} />}
|
||||
{window.hermesDesktop?.logsRoot && (
|
||||
<button className="aui-error-action" onClick={() => void openLogs()} type="button">
|
||||
{remoteConnection ? copy.errorOpenDesktopLogs : copy.errorOpenLogs}
|
||||
</button>
|
||||
)}
|
||||
<button className="aui-error-action" onClick={() => requestSendDiagnostics(diagnosticsText())} type="button">
|
||||
<Upload className="size-3" />
|
||||
{copy.errorSendDiagnostics}
|
||||
</button>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="aui-error-action"
|
||||
label={copy.errorCopyDiagnostics}
|
||||
text={diagnosticsText}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
|
||||
@@ -98,4 +98,41 @@ describe('BootFailureOverlay', () => {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the Nous Cloud down recovery when the backend flags isCloudBackendDown', async () => {
|
||||
const restore = stubDesktop(remoteToken)
|
||||
$desktopBoot.set({
|
||||
error: 'Nous Cloud agent ares-3009.agents.nousresearch.com is down (HTTP 503: server-side fault).',
|
||||
fakeMode: false,
|
||||
isCloudBackendDown: true,
|
||||
message: 'boot failed',
|
||||
phase: 'renderer.error',
|
||||
progress: 40,
|
||||
running: false,
|
||||
statusCode: 503,
|
||||
timestamp: Date.now(),
|
||||
visible: true
|
||||
})
|
||||
|
||||
try {
|
||||
render(<BootFailureOverlay />)
|
||||
// Cloud-specific title + actionable recovery instead of the generic
|
||||
// remote-failure copy.
|
||||
expect(await screen.findByText(/Nous Cloud agent is down/i)).toBeTruthy()
|
||||
// Portal and Discord are dedicated action buttons (localized labels
|
||||
// can't drift the URLs, which live in code).
|
||||
expect(screen.getByRole('button', { name: /check portal status/i })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /get help on discord/i })).toBeTruthy()
|
||||
// Cloud-down is a remote failure: local-only Repair is dropped; the
|
||||
// actionable paths are Gateway settings + Use local gateway.
|
||||
expect(screen.queryByRole('button', { name: /repair/i })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /use local gateway/i })).toBeTruthy()
|
||||
// The electron-built error message (portal / local mode / Discord) is
|
||||
// still surfaced in the error box.
|
||||
expect(screen.getByText(/ares-3009\.agents\.nousresearch\.com/i)).toBeTruthy()
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Loader } from '@/components/ui/loader'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronLeft, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { ChevronLeft, ExternalLink, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
@@ -247,6 +248,11 @@ export function BootFailureOverlay() {
|
||||
|
||||
let actions: RecoveryAction[]
|
||||
let hint: string
|
||||
// The electron boot path flags a Nous Cloud backend-down (502/503/504) with
|
||||
// the structured isCloudBackendDown/statusCode it carries through boot
|
||||
// progress. When set, the recovery screen leads with the cloud-specific
|
||||
// guidance instead of the generic remote-failure copy (#85335).
|
||||
const cloudDown = Boolean(boot.isCloudBackendDown)
|
||||
|
||||
if (remoteReauth) {
|
||||
actions = [
|
||||
@@ -261,6 +267,31 @@ export function BootFailureOverlay() {
|
||||
localAction
|
||||
]
|
||||
hint = copy.remoteSignInHint(label)
|
||||
} else if (cloudDown) {
|
||||
// A Nous Cloud agent is down — the user cannot restart the managed
|
||||
// instance and Repair is local-only. Lead with the paths that actually
|
||||
// resolve it: check the portal (status/instance controls), switch to the
|
||||
// local gateway, retry, or get support on Discord. Portal/Discord are
|
||||
// buttons (not URLs buried in the hint prose) so localized hints can't
|
||||
// drift the links.
|
||||
actions = [
|
||||
{
|
||||
key: 'portal',
|
||||
label: copy.cloudDownCheckPortal,
|
||||
onClick: () => openExternalLink('https://portal.nousresearch.com'),
|
||||
icon: <ExternalLink />
|
||||
},
|
||||
localAction,
|
||||
{ ...retryAction, variant: 'secondary' },
|
||||
{
|
||||
key: 'discord',
|
||||
label: copy.cloudDownDiscord,
|
||||
onClick: () => openExternalLink('https://discord.gg/NousResearch'),
|
||||
variant: 'ghost'
|
||||
},
|
||||
{ ...settingsAction, variant: 'ghost' }
|
||||
]
|
||||
hint = copy.cloudDownHint
|
||||
} else if (remoteFailure) {
|
||||
actions = [settingsAction, { ...retryAction, variant: 'secondary' }, localAction]
|
||||
hint = copy.remoteFailureHint
|
||||
@@ -284,7 +315,12 @@ export function BootFailureOverlay() {
|
||||
|
||||
if (view === 'connect') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div
|
||||
className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6"
|
||||
// Masks the whole app on boot failure — must stay filled under window
|
||||
// glass. Contract: `[data-glass-opaque]` in styles.css.
|
||||
data-glass-opaque=""
|
||||
>
|
||||
<div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
|
||||
{/* Subtle back affordance (projects/overlay idiom): muted → foreground
|
||||
on hover, no divider. */}
|
||||
@@ -307,16 +343,21 @@ export function BootFailureOverlay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div
|
||||
className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6"
|
||||
// Masks the whole app on boot failure — must stay filled under window
|
||||
// glass. Contract: `[data-glass-opaque]` in styles.css.
|
||||
data-glass-opaque=""
|
||||
>
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
|
||||
<div className="flex items-start gap-3 px-5 py-4">
|
||||
<ErrorIcon className="mt-0.5" size="1.25rem" />
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
|
||||
{remoteReauth ? copy.remoteTitle : copy.title}
|
||||
{remoteReauth ? copy.remoteTitle : cloudDown ? copy.cloudDownTitle : copy.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
{remoteReauth ? copy.remoteDescription : copy.description}
|
||||
{remoteReauth ? copy.remoteDescription : cloudDown ? copy.cloudDownDescription : copy.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,12 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-(--z-crash) grid place-items-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div
|
||||
className="fixed inset-0 z-(--z-crash) grid place-items-center bg-(--ui-chat-surface-background) p-6"
|
||||
// Masks a crashed app — must stay filled under window glass. Contract:
|
||||
// `[data-glass-opaque]` in styles.css.
|
||||
data-glass-opaque=""
|
||||
>
|
||||
<ErrorState
|
||||
className="w-full max-w-[28rem]"
|
||||
description={error.message || t.errors.boundaryDesc}
|
||||
|
||||
@@ -141,6 +141,10 @@ export function GatewayConnectingOverlay() {
|
||||
'fixed inset-0 z-(--z-connecting) grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
|
||||
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
// Masks the whole app while booting — must stay filled under window
|
||||
// glass or the shell shows through. Contract: `[data-glass-opaque]`
|
||||
// in styles.css.
|
||||
data-glass-opaque=""
|
||||
>
|
||||
<DecodeText
|
||||
active={phase === 'live' && (previewing || connecting)}
|
||||
|
||||
@@ -263,13 +263,25 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
|
||||
|
||||
if (price.free) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide',
|
||||
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{copy.free}
|
||||
<span className="shrink-0 inline-flex items-center gap-1.5">
|
||||
{typeof price.discount_percent === 'number' ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold',
|
||||
isCurrent ? 'bg-primary-foreground/20' : 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
-{price.discount_percent}%
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide',
|
||||
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{copy.free}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -308,6 +308,10 @@ export function DesktopOnboardingOverlay({
|
||||
bare && leaving ? '[transition-delay:660ms]' : '',
|
||||
leaving ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
// Masks the whole app until onboarding finishes — must stay filled under
|
||||
// window glass or the shell shows through. Contract:
|
||||
// `[data-glass-opaque]` in styles.css.
|
||||
data-glass-opaque=""
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -180,11 +180,12 @@ describe('enforced dock (stacked Bots pane → sessions-zone tab, every boot)',
|
||||
expect(group.panes).toEqual(['sessions', 'hermes-bots:pane'])
|
||||
})
|
||||
|
||||
it('forces the tab strip visible when already co-located but hidden with bots active (community "only Bots shows" regression)', async () => {
|
||||
it('shows the tab strip when already co-located but hidden with bots active (community "only Bots shows" regression)', async () => {
|
||||
// The Aug 2026 field reports: sessions+bots already share one group, the
|
||||
// strip is hidden (headerHidden), and bots holds the active tab — the
|
||||
// sessions pane exists but is unreachable. The re-home path never runs
|
||||
// (nothing to move), so the enforce must repair reachability directly.
|
||||
// legacy strip flag is set, and bots holds the active tab — the sessions
|
||||
// pane exists but is unreachable. The re-home path never runs (nothing to
|
||||
// move), so reachability has to come from somewhere else: the migration
|
||||
// drops the legacy flag, and a two-pane zone on auto shows its strip.
|
||||
const hiddenStackedTree = {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
@@ -208,10 +209,10 @@ describe('enforced dock (stacked Bots pane → sessions-zone tab, every boot)',
|
||||
|
||||
const group = model.findGroupOfPane(tree.$layoutTree.get()!, 'hermes-bots:pane')!
|
||||
|
||||
// Both panes stay put — but the strip is forced visible so SESSIONS is
|
||||
// reachable again. The active tab is NOT stolen mid-boot.
|
||||
// Both panes stay put — but the strip is visible so SESSIONS is reachable
|
||||
// again. The active tab is NOT stolen mid-boot.
|
||||
expect(group.panes).toEqual(['sessions', 'hermes-bots:pane'])
|
||||
expect(group.headerHidden).not.toBe(true)
|
||||
expect(tree.tabStripVisibleForGroup(group)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-homes an edge-enforced pane stranded in the sessions tab strip', async () => {
|
||||
|
||||
@@ -15,6 +15,20 @@
|
||||
|
||||
export type Orientation = 'row' | 'column'
|
||||
|
||||
/**
|
||||
* A zone's STANDING CHOICE about its tab strip. Absent is the third value and
|
||||
* the default: AUTO, where the strip's presence is a pure function of what the
|
||||
* zone currently holds (see `resolveTabStripVisible`).
|
||||
*
|
||||
* This replaced a `headerHidden?: boolean` that tried to carry both the user's
|
||||
* choice and the layout's own repairs in one field. `false` there meant either
|
||||
* "the user wants the strip" or "some code path pinned it visible to escape a
|
||||
* dead end" — insert, tab cycling, dock enforcement and pane adoption all wrote
|
||||
* it — so a repair permanently overwrote a choice and neither could be read
|
||||
* back. Only the user writes `tabStrip`; everything else asks AUTO.
|
||||
*/
|
||||
export type TabStripMode = 'always' | 'never'
|
||||
|
||||
export interface SplitNode {
|
||||
type: 'split'
|
||||
id: string
|
||||
@@ -33,12 +47,10 @@ export interface GroupNode {
|
||||
active: string
|
||||
/** Collapsed to header strip (chevron restores). */
|
||||
minimized?: boolean
|
||||
/**
|
||||
* Header hidden entirely (double-click the header to hide, double-click the
|
||||
* zone's top edge to bring it back). Minimize always shows the header —
|
||||
* a minimized group IS its header.
|
||||
*/
|
||||
headerHidden?: boolean
|
||||
/** The user's standing choice for this zone's strip; absent = auto. Written
|
||||
* only by the zone menu and the toggle command. Minimize ignores it — a
|
||||
* minimized group IS its strip. */
|
||||
tabStrip?: TabStripMode
|
||||
}
|
||||
|
||||
export type LayoutNode = SplitNode | GroupNode
|
||||
@@ -57,7 +69,7 @@ export const group = (panes: string[], options?: Partial<Omit<GroupNode, 'type'
|
||||
panes,
|
||||
active: options?.active ?? panes[0] ?? '',
|
||||
minimized: options?.minimized,
|
||||
headerHidden: options?.headerHidden
|
||||
tabStrip: options?.tabStrip
|
||||
})
|
||||
|
||||
export const split = (
|
||||
@@ -156,12 +168,10 @@ export function normalize(node: LayoutNode): LayoutNode | null {
|
||||
|
||||
const active = node.panes.includes(node.active) ? node.active : node.panes[0]
|
||||
|
||||
// NOTE: `headerHidden` is deliberately untouched here. A zone down to one
|
||||
// pane is headerless by default anyway, so a stored `true` is visually
|
||||
// redundant *while it's alone* — but normalize used to DROP it, which threw
|
||||
// away the user's standing choice: the bar came back the moment a pane
|
||||
// rejoined (close a stacked tool panel, toggle it back on). `false` is
|
||||
// sticky for the mirror reason — once a zone has had a tab bar, it keeps it.
|
||||
// `tabStrip` is deliberately untouched: it is the user's standing choice
|
||||
// about this zone, not a derived attribute, so no structural edit may
|
||||
// launder it. (Its predecessor `headerHidden` had to be reasoned about here
|
||||
// precisely because the layout wrote to it too.)
|
||||
if (active === node.active) {
|
||||
return node
|
||||
}
|
||||
@@ -262,15 +272,17 @@ export function insertAtGroup(
|
||||
const at = before ? n.panes.indexOf(before) : -1
|
||||
const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId]
|
||||
|
||||
// Gaining a pane pins the header EXPLICITLY shown (not just cleared):
|
||||
// a stack you can't see is a trap, and once a zone has ever stacked
|
||||
// the bar STAYS when it drops back to one tab — the auto-hide flicker
|
||||
// while dragging tabs around felt broken. Hiding is the user's call
|
||||
// (double-click / zone menu). Active moves only on a gesture; an empty
|
||||
// target has no prior tab, so the newcomer takes it regardless.
|
||||
// `tabStrip` is NOT touched. Gaining a pane used to pin the strip
|
||||
// visible so a surprise arrival always had a handle, which is how a
|
||||
// deliberate hide came undone by a background adoption. Reachability
|
||||
// is the resolver's job now, and it answers per-pane: a closeable tile
|
||||
// forces the strip open, a stack of tool panels doesn't need it
|
||||
// because tab cycling already reaches every member.
|
||||
// Active moves only on a gesture; an empty target has no prior tab, so
|
||||
// the newcomer takes it regardless.
|
||||
const active = activate || n.panes.length === 0 ? paneId : n.active
|
||||
|
||||
return { ...n, panes, active, headerHidden: false }
|
||||
return { ...n, panes, active }
|
||||
}
|
||||
|
||||
const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column'
|
||||
@@ -530,8 +542,9 @@ export function setGroupMinimized(root: LayoutNode, groupId: string, minimized:
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g))
|
||||
}
|
||||
|
||||
export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g))
|
||||
/** Write a zone's standing strip choice; `undefined` returns it to auto. */
|
||||
export function setGroupTabStrip(root: LayoutNode, groupId: string, tabStrip: TabStripMode | undefined): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, tabStrip } : g))
|
||||
}
|
||||
|
||||
function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode {
|
||||
@@ -577,6 +590,33 @@ export function setSplitWeights(root: LayoutNode, splitId: string, weights: numb
|
||||
// Validation (persisted trees are untrusted)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bring a persisted tree onto the current attribute schema.
|
||||
*
|
||||
* Retires `headerHidden` outright rather than translating it. A stored `true`
|
||||
* could have come from a deliberate "Hide header", or from a double-tap the
|
||||
* user never meant (that gesture rode every tab, so an ordinary double-click
|
||||
* on a title hid the strip), and nothing on disk distinguishes them. Since the
|
||||
* hide also unmounted the only surface offering "Show header", every wrongly
|
||||
* hidden zone stayed hidden across restarts — the state people actually
|
||||
* reported being stuck in. Carrying those forward as `tabStrip: 'never'` would
|
||||
* re-strand exactly them, so the flag is dropped and the zone returns to auto;
|
||||
* the strip is now hidden deliberately, from controls that say how to undo it.
|
||||
*
|
||||
* A stored `false` is dropped for the same reason in reverse: most were written
|
||||
* by the layout's own repair paths, not by anyone choosing to see a strip.
|
||||
*/
|
||||
export function migratePersistedTree(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'group') {
|
||||
const { headerHidden, ...rest } = node as GroupNode & { headerHidden?: unknown }
|
||||
const tabStrip = rest.tabStrip === 'always' || rest.tabStrip === 'never' ? rest.tabStrip : undefined
|
||||
|
||||
return headerHidden === undefined && rest.tabStrip === tabStrip ? node : { ...rest, tabStrip }
|
||||
}
|
||||
|
||||
return { ...node, children: node.children.map(migratePersistedTree) }
|
||||
}
|
||||
|
||||
export function isLayoutNode(value: unknown): value is LayoutNode {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
|
||||
@@ -155,17 +155,10 @@ const sameHint = (a: DropHint | null, b: DropHint | null) =>
|
||||
(a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) &&
|
||||
(a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id)
|
||||
|
||||
/** Double-tap detection for drag handles. Pane handles preventDefault
|
||||
* pointerdown, which suppresses native `dblclick` — so rapid same-handle
|
||||
* taps are detected here instead. */
|
||||
const DOUBLE_TAP_MS = 400
|
||||
let lastTap: { key: string; time: number } | null = null
|
||||
|
||||
export interface DoubleTapContext {
|
||||
/** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */
|
||||
key: string
|
||||
onDoubleTap: () => void
|
||||
}
|
||||
// Drag handles carry NO double-tap. Handles preventDefault pointerdown, so a
|
||||
// synthesized one is the only way to get it here — and a gesture this machinery
|
||||
// hands to every handle at once is the wrong home for anything destructive.
|
||||
// Trackpad double-tap is a separate concern: `@/lib/trackpad-gestures`.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The generic drag session (machinery) — resolvers plug in below / elsewhere.
|
||||
@@ -186,7 +179,6 @@ export interface DragSessionSpec {
|
||||
onEnd?(): void
|
||||
/** Sub-threshold release = a click on the handle. */
|
||||
onTap?(): void
|
||||
double?: DoubleTapContext
|
||||
/** Floating chip following the pointer — for drags whose source doesn't
|
||||
* stay visibly "held" (a sidebar row, unlike a dimmed tab). See
|
||||
* `@/lib/drag-ghost`. */
|
||||
@@ -218,10 +210,10 @@ function suppressDragClick(committed: boolean) {
|
||||
|
||||
/**
|
||||
* Begin a drag session from a handle's pointerdown. A sub-threshold release
|
||||
* is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's
|
||||
* resolver owns targeting and the machinery owns everything else. Esc aborts
|
||||
* instantly: the session registers as the TOP escape layer, tears down
|
||||
* synchronously, and nothing commits.
|
||||
* is a click (`onTap`); past the threshold the spec's resolver owns targeting
|
||||
* and the machinery owns everything else. Esc aborts instantly: the session
|
||||
* registers as the TOP escape layer, tears down synchronously, and nothing
|
||||
* commits.
|
||||
*/
|
||||
export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSessionSpec) {
|
||||
if (e.button !== 0) {
|
||||
@@ -365,15 +357,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
|
||||
spec.onCommit($dropHint.get())
|
||||
}
|
||||
} else if (commit) {
|
||||
const now = Date.now()
|
||||
|
||||
if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) {
|
||||
lastTap = null
|
||||
spec.double.onDoubleTap()
|
||||
} else {
|
||||
lastTap = spec.double ? { key: spec.double.key, time: now } : null
|
||||
spec.onTap?.()
|
||||
}
|
||||
spec.onTap?.()
|
||||
}
|
||||
|
||||
spec.onEnd?.()
|
||||
@@ -418,14 +402,13 @@ const TEAR_OFF_SLACK_PX = 18
|
||||
|
||||
/**
|
||||
* Begin a pane drag from any handle. A sub-threshold release is a click
|
||||
* (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap`
|
||||
* instead). With a `reorder` context (tab drags), movement inside the strip
|
||||
* targets an insertion slot — the strip renders a divider at it, NOTHING
|
||||
* moves until release (placement-on-release, like every other drop); tearing
|
||||
* away from the strip converts the drag into a zone move. Zone mode: zones
|
||||
* light up, the target's tab strip stacks at its divider slot, Shift extends
|
||||
* the highlight range, release drops into the ClosestCenter primary zone.
|
||||
* Esc aborts either mode.
|
||||
* (`onTap`, used to activate tabs). With a `reorder` context (tab drags),
|
||||
* movement inside the strip targets an insertion slot — the strip renders a
|
||||
* divider at it, NOTHING moves until release (placement-on-release, like every
|
||||
* other drop); tearing away from the strip converts the drag into a zone move.
|
||||
* Zone mode: zones light up, the target's tab strip stacks at its divider slot,
|
||||
* Shift extends the highlight range, release drops into the ClosestCenter
|
||||
* primary zone. Esc aborts either mode.
|
||||
*
|
||||
* `ghostLabel` opts into the pointer-following chip (`@/lib/drag-ghost`) — the
|
||||
* same "what am I holding" affordance sessions use. The in-strip dim only
|
||||
@@ -437,7 +420,6 @@ export function startPaneDrag(
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
onTap?: () => void,
|
||||
reorder?: ReorderContext,
|
||||
double?: DoubleTapContext,
|
||||
ghostLabel?: string,
|
||||
/** Multi-tab selection riding this drag (strip order, includes `paneId`).
|
||||
* The whole block moves/reorders together; `paneId` stays the pressed tab
|
||||
@@ -504,7 +486,6 @@ export function startPaneDrag(
|
||||
Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX)
|
||||
|
||||
startDragSession(e, {
|
||||
double,
|
||||
ghost: ghostLabel ? { label: ghostLabel } : undefined,
|
||||
onTap,
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
|
||||
describe('forceLoneHeaderForPanes', () => {
|
||||
const chrome =
|
||||
(placement?: string, uncloseable = false) =>
|
||||
() => ({ placement, uncloseable })
|
||||
|
||||
const noCollapse = () => false
|
||||
|
||||
// Every mirrored tile (session / page / preview) is a closeable `main` pane, so
|
||||
// dragging one into a zone of its own must keep its tab — it used to strand a
|
||||
// preview headerless, with nothing to grab and no ✕.
|
||||
it('forces a header for closeable placement:main panes', () => {
|
||||
expect(forceLoneHeaderForPanes(['preview-tile:url:x'], chrome('main'), noCollapse)).toBe(true)
|
||||
expect(forceLoneHeaderForPanes(['session-tile:abc'], chrome('main'), noCollapse)).toBe(true)
|
||||
})
|
||||
|
||||
it('forces a header for a lone collapse tool pane', () => {
|
||||
expect(
|
||||
forceLoneHeaderForPanes(
|
||||
['terminal'],
|
||||
() => ({}),
|
||||
id => id === 'terminal'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a lone uncloseable workspace headerless', () => {
|
||||
expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves standing side chrome (files / sessions) headerless', () => {
|
||||
expect(forceLoneHeaderForPanes(['files'], chrome('right'), noCollapse)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* When a lone pane must keep its tab strip (name card + close).
|
||||
*
|
||||
* Default: a single pane isn't a "tab", so the header auto-hides. Exceptions
|
||||
* force it on so a closeable surface never becomes an unclosable dead zone:
|
||||
* - a closeable `placement: 'main'` pane — every mirrored TILE (a session, a
|
||||
* page, a preview) is one, so dragging a tile into a zone of its own keeps
|
||||
* its tab and its ✕
|
||||
* - a collapse tool panel dragged into its own zone
|
||||
*/
|
||||
|
||||
export interface LoneHeaderChrome {
|
||||
placement?: string
|
||||
uncloseable?: boolean
|
||||
}
|
||||
|
||||
export function forceLoneHeaderForPanes(
|
||||
shown: readonly string[],
|
||||
chromeOf: (id: string) => LoneHeaderChrome,
|
||||
isCollapsePane: (id: string) => boolean
|
||||
): boolean {
|
||||
// "This pane can be closed, so it must expose the ✕." Only the uncloseable
|
||||
// workspace is exempt; standing side chrome (files / sessions) isn't 'main'.
|
||||
if (
|
||||
shown.some(id => {
|
||||
const chrome = chromeOf(id)
|
||||
|
||||
return !chrome.uncloseable && chrome.placement === 'main'
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return shown.length === 1 && isCollapsePane(shown[0])
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
import { setTabStripDefault } from '@/store/tabstrip-prefs'
|
||||
|
||||
import { resolveTabStripVisible, type StripPane, tabStripVisibleForZone } from './strip-visibility'
|
||||
|
||||
const tile = (): StripPane => ({ collapsePane: false, placement: 'main' })
|
||||
const workspace = (): StripPane => ({ collapsePane: false, placement: 'main', uncloseable: true })
|
||||
const toolPanel = (): StripPane => ({ collapsePane: true, placement: 'bottom' })
|
||||
const sideChrome = (): StripPane => ({ collapsePane: false, placement: 'right' })
|
||||
|
||||
describe('auto (no stored choice)', () => {
|
||||
it('gives a lone workspace no strip and a stack of two a strip', () => {
|
||||
expect(resolveTabStripVisible({ shown: [workspace()] })).toBe(false)
|
||||
expect(resolveTabStripVisible({ shown: [workspace(), sideChrome()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves standing side chrome alone in its own zone', () => {
|
||||
expect(resolveTabStripVisible({ shown: [sideChrome()] })).toBe(false)
|
||||
})
|
||||
|
||||
it('has nothing to draw for an empty zone', () => {
|
||||
expect(resolveTabStripVisible({ shown: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the stored choice', () => {
|
||||
it('overrides auto in both directions', () => {
|
||||
expect(resolveTabStripVisible({ mode: 'always', shown: [workspace()] })).toBe(true)
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace(), sideChrome()] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// THE invariant the old boolean could not hold. `never` used to sit above the
|
||||
// force-visible rule, so hiding a zone that held only a closeable tile left a
|
||||
// surface with no tab, no ✕ and no menu — the "how do I get it back" reports.
|
||||
describe('no dead zone', () => {
|
||||
it('keeps the strip for a closeable tile even when the zone says never', () => {
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [tile()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the strip for a lone tool panel even when the zone says never', () => {
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [toolPanel()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('still hides a zone that cannot strand anything', () => {
|
||||
// The workspace is uncloseable, and a stack is reachable by tab cycling —
|
||||
// the invariant protects handles, it does not veto hiding as such.
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(false)
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [toolPanel(), toolPanel()] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// A full-page view is not a tab-able surface, and it lifts itself the moment
|
||||
// the chat comes back — so it outranks even the stranding rule and, unlike
|
||||
// `mode`, is never written to the tree.
|
||||
describe('a full-page view', () => {
|
||||
it('suppresses the strip regardless of what the zone holds or says', () => {
|
||||
expect(resolveTabStripVisible({ headerVeto: true, mode: 'always', shown: [tile()] })).toBe(false)
|
||||
expect(resolveTabStripVisible({ headerVeto: true, shown: [workspace(), tile()] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The adapter both TreeGroup and the store call. Its job is to read the same
|
||||
// chrome flags and fold in the app-wide default on both paths, so the strip on
|
||||
// screen and the toggle command can never disagree.
|
||||
describe('tabStripVisibleForZone', () => {
|
||||
const contributions: Record<string, Contribution> = {
|
||||
terminal: { area: 'panes', data: { placement: 'bottom' }, id: 'terminal', render: () => null, title: 'terminal' },
|
||||
'tile:a': { area: 'panes', data: { placement: 'main' }, id: 'tile:a', render: () => null, title: 'tile' },
|
||||
workspace: {
|
||||
area: 'panes',
|
||||
data: { placement: 'main', uncloseable: true },
|
||||
id: 'workspace',
|
||||
render: () => null,
|
||||
title: 'chat'
|
||||
}
|
||||
}
|
||||
|
||||
const visible = (shown: string[], mode?: 'always' | 'never') =>
|
||||
tabStripVisibleForZone({
|
||||
active: shown[0],
|
||||
isCollapsePane: id => id === 'terminal',
|
||||
mode,
|
||||
paneFor: id => contributions[id],
|
||||
shown
|
||||
})
|
||||
|
||||
afterEach(() => setTabStripDefault('auto'))
|
||||
|
||||
it('reads placement, uncloseable and collapse off the contributions', () => {
|
||||
expect(visible(['workspace'])).toBe(false)
|
||||
expect(visible(['tile:a'], 'never')).toBe(true)
|
||||
expect(visible(['terminal'], 'never')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the app default when the zone has no choice', () => {
|
||||
setTabStripDefault('always')
|
||||
expect(visible(['workspace'])).toBe(true)
|
||||
|
||||
setTabStripDefault('never')
|
||||
expect(visible(['workspace', 'terminal'])).toBe(false)
|
||||
})
|
||||
|
||||
it("lets a zone's own choice beat the app default", () => {
|
||||
setTabStripDefault('never')
|
||||
expect(visible(['workspace'], 'always')).toBe(true)
|
||||
|
||||
setTabStripDefault('always')
|
||||
expect(visible(['workspace'], 'never')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Does this zone show its tab strip? One resolver, one precedence order, so
|
||||
* every caller gets the same answer and the rule can be read in one place.
|
||||
*
|
||||
* The decision used to be an inline expression in TreeGroup fed by a flag four
|
||||
* other code paths also wrote to, which is how a zone could end up with no
|
||||
* strip, no tab, no ✕ and no menu to get any of them back. The ladder below is
|
||||
* the whole policy; nothing outside `mode` is persisted, so a zone's chrome is
|
||||
* a function of what it currently holds plus one deliberate choice.
|
||||
*/
|
||||
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
import { effectiveTabStripMode } from '@/store/tabstrip-prefs'
|
||||
|
||||
import type { TabStripMode } from '../model'
|
||||
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
export interface StripPane {
|
||||
/** A tool panel (terminal / logs) that collapses rather than closes. */
|
||||
collapsePane: boolean
|
||||
/** Contribution placement — `'main'` marks a docked tile (session, page,
|
||||
* preview) as opposed to standing side chrome. */
|
||||
placement?: string
|
||||
/** Panes that never leave the tree (the workspace). */
|
||||
uncloseable?: boolean
|
||||
}
|
||||
|
||||
export interface StripZone {
|
||||
/** The ACTIVE pane declines to be tabbed (a full-page view). */
|
||||
headerVeto?: boolean
|
||||
/** The zone's standing choice; undefined = auto. */
|
||||
mode?: TabStripMode
|
||||
/** Panes currently rendered as chips — chrome-hidden and narrow-collapsed
|
||||
* panes are already filtered out. */
|
||||
shown: readonly StripPane[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane is STRANDED without a strip when the strip is the only thing carrying
|
||||
* its handle: a closeable tile needs its ✕, a lone tool panel needs a chip to
|
||||
* grab. The uncloseable workspace is not strandable — it cannot be closed or
|
||||
* lost, so a lone chat is free to be chromeless.
|
||||
*
|
||||
* This outranks an explicit `never` on purpose. "Hide the strip" is a request
|
||||
* about chrome, never a request to make a surface unreachable, and a zone that
|
||||
* answers no gesture at all is not a state any setting should be able to
|
||||
* produce. Hiding still works everywhere it cannot trap you.
|
||||
*/
|
||||
function stranded(shown: readonly StripPane[]): boolean {
|
||||
if (shown.some(pane => !pane.uncloseable && pane.placement === 'main')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return shown.length === 1 && shown[0].collapsePane
|
||||
}
|
||||
|
||||
export function resolveTabStripVisible(zone: StripZone): boolean {
|
||||
if (zone.shown.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// A page is not a tab-able surface. Contextual and self-lifting: the strip
|
||||
// returns with the chat, so it is resolved ahead of any stored choice and
|
||||
// never written down.
|
||||
if (zone.headerVeto) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stranded(zone.shown)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (zone.mode) {
|
||||
return zone.mode === 'always'
|
||||
}
|
||||
|
||||
// Auto: a lone pane is not a "tab", so it goes without a strip; two or more
|
||||
// need one to switch between them.
|
||||
return zone.shown.length > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a zone straight from what the layout knows about it. Both callers —
|
||||
* TreeGroup from its render inputs, the store from the registry — go through
|
||||
* here, so neither can drift on which chrome flags feed the answer or forget to
|
||||
* fold in the app-wide default.
|
||||
*/
|
||||
export function tabStripVisibleForZone(zone: {
|
||||
/** The zone's ACTIVE pane. */
|
||||
active: string
|
||||
isCollapsePane: (id: string) => boolean
|
||||
/** The zone's own choice, before the app default applies. */
|
||||
mode: TabStripMode | undefined
|
||||
paneFor: (id: string) => Contribution | undefined
|
||||
/** Panes currently rendered as chips. */
|
||||
shown: readonly string[]
|
||||
}): boolean {
|
||||
return resolveTabStripVisible({
|
||||
headerVeto: paneChrome(zone.paneFor(zone.active)).headerVeto,
|
||||
mode: effectiveTabStripMode(zone.mode),
|
||||
shown: zone.shown.map(id => ({
|
||||
collapsePane: zone.isCollapsePane(id),
|
||||
placement: paneChrome(zone.paneFor(id)).placement,
|
||||
uncloseable: paneChrome(zone.paneFor(id)).uncloseable
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { allPaneIds, group, split } from '../model'
|
||||
import { $layoutTree } from '../store'
|
||||
|
||||
import { TreeGroup } from './tree-group'
|
||||
|
||||
// The hover ✕ and middle-click are ONE affordance in two shapes: whichever tabs
|
||||
// the pointer gesture can close must advertise it. A tab that closes on
|
||||
// middle-click but hides its ✕ is a close verb the user cannot discover, and a
|
||||
// tab that shows a ✕ it will not honor is a dead control. This asserts the
|
||||
// equivalence over every tab kind the app registers, so the ✕ can never be
|
||||
// wired (or un-wired) on its own again.
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
// jsdom lacks CSS.escape, which tab-strip-scroll uses in a layout effect.
|
||||
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
HTMLElement.prototype.scrollIntoView ??= () => undefined
|
||||
})
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
|
||||
/** Every tab kind that shares a strip, paired with what its chrome declares. */
|
||||
const PANES: readonly (readonly [string, Record<string, unknown>])[] = [
|
||||
// Standing chrome: no close gesture of any kind.
|
||||
['sessions', { hideOnly: true, placement: 'left' }],
|
||||
// A plain side pane: closes, so it must say so.
|
||||
['files', { placement: 'right' }],
|
||||
// The one surface that cannot leave the tree.
|
||||
['workspace', { placement: 'main', uncloseable: true }],
|
||||
// A mirrored session tile — `placement: 'main'` but closeable.
|
||||
['session-tile:abc', { placement: 'main' }]
|
||||
]
|
||||
|
||||
beforeEach(async () => {
|
||||
window.localStorage.clear()
|
||||
|
||||
const { $dismissedPanes, $hiddenTreePanes } = await import('../store')
|
||||
$dismissedPanes.set(new Set())
|
||||
$hiddenTreePanes.set(new Set())
|
||||
|
||||
for (const [id, data] of PANES) {
|
||||
disposers.push(registry.register({ area: 'panes', data, id, render: () => null, title: id }))
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disposers.splice(0).forEach(dispose => dispose())
|
||||
})
|
||||
|
||||
/** All four panes in ONE zone, so every tab renders in the same strip. */
|
||||
function renderOneStrip() {
|
||||
$layoutTree.set(
|
||||
split('row', [
|
||||
group(
|
||||
PANES.map(([id]) => id),
|
||||
{ active: 'workspace', id: 'grp-all' }
|
||||
),
|
||||
group(['spacer'], { id: 'grp-spacer' })
|
||||
])
|
||||
)
|
||||
|
||||
const node = $layoutTree.get()!
|
||||
const zone = (node.type === 'split' ? node.children[0] : node) as never
|
||||
|
||||
render(<TreeGroup node={zone} parentAxis="row" />)
|
||||
}
|
||||
|
||||
const tabEl = (paneId: string) => document.querySelector<HTMLElement>(`[data-tree-tab="${paneId}"]`)
|
||||
|
||||
/** Does this tab advertise a ✕? */
|
||||
const hasCloseButton = (paneId: string) => Boolean(tabEl(paneId)?.querySelector('button[aria-label]'))
|
||||
|
||||
/** Does the middle-click gesture actually close this tab? Observed through the
|
||||
* tree, not through a spy — a pane that is gone stopped being a tab. */
|
||||
function middleClickCloses(paneId: string): boolean {
|
||||
const tab = tabEl(paneId)!
|
||||
fireEvent.pointerDown(tab, { button: 1 })
|
||||
fireEvent.pointerUp(tab, { button: 1 })
|
||||
|
||||
return !allPaneIds($layoutTree.get()!).includes(paneId)
|
||||
}
|
||||
|
||||
describe('a tab advertises exactly the close gesture it honors', () => {
|
||||
for (const [paneId] of PANES) {
|
||||
it(`${paneId}: ✕ presence matches middle-click`, () => {
|
||||
renderOneStrip()
|
||||
|
||||
expect(tabEl(paneId)).toBeTruthy()
|
||||
|
||||
const advertised = hasCloseButton(paneId)
|
||||
|
||||
expect(advertised).toBe(middleClickCloses(paneId))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { group, type GroupNode, split } from '../model'
|
||||
import {
|
||||
$layoutTree,
|
||||
markCollapsePane,
|
||||
registerPaneCloser,
|
||||
setTreeGroupTabStrip,
|
||||
tabStripVisibleForGroup,
|
||||
toggleTargetZoneTabStrip
|
||||
} from '../store'
|
||||
|
||||
import { TreeGroup } from './tree-group'
|
||||
|
||||
/** TreeGroup reads its node from props; subscribe so store writes re-render. */
|
||||
function LiveTreeGroup() {
|
||||
useStore($layoutTree)
|
||||
|
||||
return <TreeGroup node={zoneAt(0)} parentAxis="column" />
|
||||
}
|
||||
|
||||
// Pins the tab-strip hide grammar. Hiding is a COMMAND now, not a gesture: the
|
||||
// pointer can no longer take the strip away by accident, and whatever does take
|
||||
// it away leaves a way back that does not depend on the chrome it just removed.
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
// jsdom lacks CSS.escape, which tab-strip-scroll uses in a layout effect.
|
||||
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
HTMLElement.prototype.scrollIntoView ??= () => undefined
|
||||
})
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
window.localStorage.clear()
|
||||
|
||||
const { $dismissedPanes, $hiddenTreePanes } = await import('../store')
|
||||
$dismissedPanes.set(new Set())
|
||||
$hiddenTreePanes.set(new Set())
|
||||
|
||||
for (const [id, data] of [
|
||||
['workspace', { placement: 'main', uncloseable: true }],
|
||||
['terminal', { placement: 'bottom' }]
|
||||
] as const) {
|
||||
disposers.push(registry.register({ area: 'panes', data, id, render: () => null, title: id }))
|
||||
}
|
||||
|
||||
markCollapsePane('terminal')
|
||||
registerPaneCloser('terminal', () => undefined)
|
||||
|
||||
$layoutTree.set(split('column', [group(['workspace', 'terminal'], { active: 'terminal', id: 'grp-main' })]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disposers.splice(0).forEach(dispose => dispose())
|
||||
})
|
||||
|
||||
const zoneAt = (index: number) => {
|
||||
const node = $layoutTree.get()!
|
||||
|
||||
return (node.type === 'split' ? node.children[index] : node) as never
|
||||
}
|
||||
|
||||
const groupNode = () => {
|
||||
const node = $layoutTree.get()!
|
||||
|
||||
return (node.type === 'split' ? node.children[0] : node) as GroupNode
|
||||
}
|
||||
|
||||
const tablist = () => globalThis.document.querySelector('[role="tablist"]')
|
||||
|
||||
/** Two sub-threshold taps: pointerdown on the target, pointerup on window
|
||||
* (drag-session listens there), twice — the retired double-tap path. */
|
||||
const doubleTap = (target: Element) => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
fireEvent.pointerDown(target, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(window, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' })
|
||||
}
|
||||
}
|
||||
|
||||
describe('tab strip hide grammar', () => {
|
||||
it('no pointer gesture hides the strip', () => {
|
||||
render(<LiveTreeGroup />)
|
||||
|
||||
// Both halves of the strip: the tab, which was always activate-only, and
|
||||
// the background, which used to answer a double-tap nothing announced.
|
||||
doubleTap(globalThis.document.querySelector('[data-tree-tab="terminal"]')!)
|
||||
doubleTap(globalThis.document.querySelector('[data-zone-tabstrip="grp-main"]')!)
|
||||
|
||||
expect(tablist()).toBeTruthy()
|
||||
expect(groupNode().tabStrip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders no strip at all for a zone set to never', () => {
|
||||
setTreeGroupTabStrip('grp-main', 'never')
|
||||
render(<LiveTreeGroup />)
|
||||
|
||||
expect(tablist()).toBeNull()
|
||||
})
|
||||
|
||||
// The state that had no way out. The command targets the zone by
|
||||
// hover/focus/workspace fallback, so restoring the strip never depends on
|
||||
// the strip — or on any other chrome the hide took away.
|
||||
it('the toggle command reaches a zone that has no chrome left to click', () => {
|
||||
setTreeGroupTabStrip('grp-main', 'never')
|
||||
expect(tabStripVisibleForGroup(groupNode())).toBe(false)
|
||||
|
||||
expect(toggleTargetZoneTabStrip()).toBe('always')
|
||||
expect(tabStripVisibleForGroup(groupNode())).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@ import type { Contribution } from '@/contrib/types'
|
||||
import type { GroupNode, LayoutNode } from '../model'
|
||||
import { allPaneIds } from '../model'
|
||||
|
||||
import type { DoubleTapContext } from './drag-session'
|
||||
import type { FloatingAnchor } from './floating-rect'
|
||||
|
||||
export const MIN_PANE_PX = 80
|
||||
@@ -65,12 +64,12 @@ interface PaneChrome extends PaneSizing {
|
||||
/** No Close in the tab menu — the one surface the app can't lose (the
|
||||
* main workspace). Session tiles share `placement: 'main'` but close. */
|
||||
uncloseable?: boolean
|
||||
/** Hide the hover ✕ while retaining explicit close behavior for this pane. */
|
||||
showCloseButton?: boolean
|
||||
/** Standing chrome tab (sessions / Bots) whose tab shows NO ✕ and no Close
|
||||
* verbs — it is shown/hidden instead (the zone menu's Show/Hide rows and a
|
||||
* ⌘K toggle, via `setStripTabHidden`). Close was too destructive for these:
|
||||
* an accidental ✕ removed Bot Mode until the next launch. */
|
||||
/** Standing chrome tab (sessions / Bots) with NO close verb at all: no ✕,
|
||||
* no middle / ⌘-click, no Close menu rows. It is shown/hidden instead (the
|
||||
* zone menu's Show/Hide rows and a ⌘K toggle, via `setStripTabHidden`).
|
||||
* Close was too destructive for these: an accidental ✕ removed Bot Mode
|
||||
* until the next launch. The ✕ follows the verb (see `PaneTab.onClose`),
|
||||
* so dropping the verb here is what takes the chip off the tab. */
|
||||
hideOnly?: boolean
|
||||
/** Wrap this pane's TAB (e.g. in a domain context menu — a session tile's
|
||||
* pin/branch/rename/archive/delete). The wrapper must render `tab` as its
|
||||
@@ -78,10 +77,10 @@ interface PaneChrome extends PaneSizing {
|
||||
tabWrap?: (tab: React.ReactElement) => React.ReactNode
|
||||
/** Override this pane's TAB drag (a session tab drags like a sidebar row —
|
||||
* stack / split / composer-link — not the generic pane move). Given the
|
||||
* tab's tap (activate) + double-tap (hide header) so those gestures survive.
|
||||
* Returns whether it took the drag; `false` (or absent) defers to
|
||||
* `startPaneDrag` — e.g. the workspace tab on a fresh draft, nothing to link. */
|
||||
tabDrag?: (event: React.PointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => boolean
|
||||
* tab's tap (activate) so that gesture survives. Returns whether it took the
|
||||
* drag; `false` (or absent) defers to `startPaneDrag` — e.g. the workspace
|
||||
* tab on a fresh draft, nothing to link. */
|
||||
tabDrag?: (event: React.PointerEvent<HTMLElement>, onTap: () => void) => boolean
|
||||
/** Suppress the zone header while THIS pane is active — full-page views
|
||||
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
|
||||
* live: the workspace contribution re-registers it on route changes. */
|
||||
|
||||
@@ -27,10 +27,12 @@ function render(ui: ReactNode) {
|
||||
function terminalGroup(minimized: boolean): GroupNode {
|
||||
return {
|
||||
active: 'terminal',
|
||||
headerHidden: false,
|
||||
id: 'terminal-zone',
|
||||
minimized,
|
||||
panes: ['terminal'],
|
||||
// The chevron lives in the strip, so this zone has to be showing one. A
|
||||
// lone unregistered pane is on auto and would render none.
|
||||
tabStrip: 'always',
|
||||
type: 'group'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
@@ -58,8 +59,8 @@ import {
|
||||
restoreTreePane,
|
||||
SESSION_TILE_DRAG,
|
||||
setStripTabHidden,
|
||||
setTreeGroupHeaderHidden,
|
||||
setTreeGroupMinimized,
|
||||
setTreeGroupTabStrip,
|
||||
treeTabCloseTargets
|
||||
} from '../store'
|
||||
import {
|
||||
@@ -71,8 +72,8 @@ import {
|
||||
toggleTabSelected
|
||||
} from '../tab-selection'
|
||||
|
||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
import { startPaneDrag } from './drag-session'
|
||||
import { tabStripVisibleForZone } from './strip-visibility'
|
||||
import { useActiveTabVisible } from './tab-strip-scroll'
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
@@ -85,9 +86,9 @@ function ZoneMenu({
|
||||
children,
|
||||
closable,
|
||||
minimizable = true,
|
||||
headerHidden,
|
||||
minimized,
|
||||
nodeId,
|
||||
stripVisible,
|
||||
targetPane
|
||||
}: {
|
||||
children: ReactNode
|
||||
@@ -97,9 +98,11 @@ function ZoneMenu({
|
||||
/** False for the zone hosting the uncloseable workspace — collapsing the
|
||||
* MAIN pane strands the app behind a strip. */
|
||||
minimizable?: boolean
|
||||
headerHidden?: boolean
|
||||
minimized?: boolean
|
||||
nodeId: string
|
||||
/** Whether the strip is on screen — the Hide/Show row toggles against what
|
||||
* the user can see, not against the stored mode (a zone on auto has none). */
|
||||
stripVisible?: boolean
|
||||
/** The right-clicked chip (else the active pane) — what the close-others /
|
||||
* to-the-right / all verbs measure from. Called when the menu RENDERS, not
|
||||
* on every zone re-render: resolving the siblings reads the layout tree,
|
||||
@@ -108,6 +111,10 @@ function ZoneMenu({
|
||||
targetPane: () => string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
// Hiding the strip takes this menu with it, so the row that hides it is the
|
||||
// last place to say how to get it back — the status bar's hide row does the
|
||||
// same for the same reason.
|
||||
const toggleHint = useKeybindHint('view.toggleTabStrip')
|
||||
|
||||
// Resolved at render: the menu mounts on open, after the right-click set
|
||||
// menuPane — so an uncloseable target hides Close instead of offering a
|
||||
@@ -157,9 +164,17 @@ function ZoneMenu({
|
||||
})()}
|
||||
<kit.Separator />
|
||||
{renderActionItem(kit, {
|
||||
icon: headerHidden ? 'eye' : 'eye-closed',
|
||||
label: headerHidden ? t.zones.showHeader : t.zones.hideHeader,
|
||||
onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden)
|
||||
icon: stripVisible ? 'eye-closed' : 'eye',
|
||||
key: 'zone-tabstrip',
|
||||
label: (
|
||||
<>
|
||||
{/* The hint's `ml-auto` makes the label the row's flexible part,
|
||||
so without this it breaks mid-phrase before the menu widens. */}
|
||||
<span className="whitespace-nowrap">{stripVisible ? t.zones.hideTabStrip : t.zones.showTabStrip}</span>
|
||||
{toggleHint && <span className="ml-auto pl-2 text-(--ui-text-quaternary)">{toggleHint}</span>}
|
||||
</>
|
||||
),
|
||||
onSelect: () => setTreeGroupTabStrip(nodeId, stripVisible ? 'never' : 'always')
|
||||
})}
|
||||
{minimizable &&
|
||||
renderActionItem(kit, {
|
||||
@@ -256,23 +271,17 @@ export function TreeGroup({
|
||||
const paneLifecycle = lifecycleRef.current.entries
|
||||
const keptPanes = shown.filter(id => paneLifecycle[id] && paneLifecycle[id].lifecycle !== 'parked')
|
||||
|
||||
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
|
||||
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
|
||||
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
|
||||
// - a TILE (closeable, placement 'main' — a session/page split), else a
|
||||
// tile in its own zone is unclosable (the "3rd tile has no tab" trap);
|
||||
// - a TOOL PANEL (terminal/logs — a collapse pane) dragged out of the main
|
||||
// stack, else it's a dead zone with no tab to grab or ✕ to close.
|
||||
// The uncloseable workspace and side chrome (sessions/files) keep the clean
|
||||
// no-tab default. Double-click toggles it either way; a minimized group
|
||||
// always shows its header (it IS the header).
|
||||
// Session-tile ids force the header even before chrome registers — cycling
|
||||
// onto a freshly-split tile used to land headerless ("name card missing").
|
||||
const forceLoneHeader = forceLoneHeaderForPanes(shown, id => paneChrome(paneFor(id)), isCollapsePane)
|
||||
|
||||
// A full-page view (headerVeto) suppresses the strip while it's the active
|
||||
// pane — a page is not a tab-able surface; the bar returns with the chat.
|
||||
const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !forceLoneHeader))
|
||||
// ONE header style: the app's compact pane-header. Whether this zone shows
|
||||
// it is the resolver's call, not this component's — see strip-visibility.ts
|
||||
// for the precedence. The same resolver answers for the toggle command, so
|
||||
// the keystroke and the screen always agree about which way "toggle" points.
|
||||
const stripVisible = tabStripVisibleForZone({
|
||||
active: activeId,
|
||||
isCollapsePane,
|
||||
mode: node.tabStrip,
|
||||
paneFor,
|
||||
shown
|
||||
})
|
||||
|
||||
// A group collapses ALONG its parent split's axis. In a row that means the
|
||||
// WIDTH collapses — a full-width horizontal header would strand a tall
|
||||
@@ -280,7 +289,8 @@ export function TreeGroup({
|
||||
// (tabs reading top-to-bottom). In a column (stacked zones) the horizontal
|
||||
// header IS the collapsed form, exactly as before.
|
||||
const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty
|
||||
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden)
|
||||
// A minimized group IS its header, so it shows one regardless.
|
||||
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || stripVisible)
|
||||
|
||||
// Keep the activated tab — and, on the last one, the trailing "+" — inside
|
||||
// the strip's scroll window. Opening a tab past the right edge otherwise
|
||||
@@ -291,17 +301,6 @@ export function TreeGroup({
|
||||
tabCount: shown.length
|
||||
})
|
||||
|
||||
// Drag handles preventDefault pointerdown (no native dblclick), so the
|
||||
// header + chips share a synthesized double-tap: restore if collapsed
|
||||
// (undoing the first tap's minimize toggle) and hide the chrome.
|
||||
const hideHeaderDoubleTap: DoubleTapContext = {
|
||||
key: `hide-header-${node.id}`,
|
||||
onDoubleTap: () => {
|
||||
setTreeGroupMinimized(node.id, false)
|
||||
setTreeGroupHeaderHidden(node.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Zone-menu close targets read the layout tree, but this component must NOT
|
||||
// subscribe to it: `useStore($layoutTree)` here wires every zone — and
|
||||
// therefore every mounted pane and its whole transcript — to the entire
|
||||
@@ -348,19 +347,13 @@ export function TreeGroup({
|
||||
// Same menu on the header strip and the edit veil — one prop bag.
|
||||
const zoneMenu = {
|
||||
closable,
|
||||
headerHidden,
|
||||
minimizable,
|
||||
minimized: node.minimized,
|
||||
nodeId: node.id,
|
||||
stripVisible,
|
||||
targetPane
|
||||
}
|
||||
|
||||
// NO body double-click toggle: virtualized content (the thread) recreates
|
||||
// its nodes between clicks, so the gesture was hopelessly unreliable. The
|
||||
// bar's lifecycle is explicit instead — gaining a tab sticky-shows it
|
||||
// (insertAtGroup pins headerHidden false), the main tab's context menu
|
||||
// hides it, and full-page views veto it via paneChrome.headerVeto.
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-editor-surface-background)"
|
||||
@@ -442,16 +435,11 @@ export function TreeGroup({
|
||||
listRef={tabsRef}
|
||||
onPointerDown={e =>
|
||||
// Tap the header to collapse to it / expand back — the DetailPane
|
||||
// / sidebar-section gesture (never for the main zone). Double-tap
|
||||
// hides the header entirely. Drag still moves the pane.
|
||||
startPaneDrag(
|
||||
activeId,
|
||||
e,
|
||||
() => minimizable && toggleCollapse(),
|
||||
undefined,
|
||||
hideHeaderDoubleTap,
|
||||
active?.title ?? activeId
|
||||
)
|
||||
// / sidebar-section gesture (never for the main zone). Drag still
|
||||
// moves the pane. No double-tap hide belongs here: hiding the
|
||||
// strip unmounts every affordance the zone has, including the
|
||||
// menu offering "Show", so it stays a named command.
|
||||
startPaneDrag(activeId, e, () => minimizable && toggleCollapse(), undefined, active?.title ?? activeId)
|
||||
}
|
||||
ref={stripRef}
|
||||
style={{ cursor: 'grab' }}
|
||||
@@ -545,7 +533,6 @@ export function TreeGroup({
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
hideHeaderDoubleTap,
|
||||
t.zones.tabCount(dragSelection.length),
|
||||
dragSelection
|
||||
)
|
||||
@@ -557,20 +544,18 @@ export function TreeGroup({
|
||||
// session drop language — link/stack/split); `false` defers
|
||||
// to the generic pane move (the workspace tab on a fresh
|
||||
// draft has no session to link).
|
||||
if (!chrome.tabDrag?.(e, onTap, hideHeaderDoubleTap)) {
|
||||
if (!chrome.tabDrag?.(e, onTap)) {
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
hideHeaderDoubleTap,
|
||||
title
|
||||
)
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
selected={isSelected}
|
||||
showCloseButton={chrome.showCloseButton !== false}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
{chrome.tabLead ? (
|
||||
@@ -679,7 +664,7 @@ export function TreeGroup({
|
||||
// barely-tinted wash; the light blur reads as "edit mode" the same
|
||||
// way the zone editor's backdrop does.
|
||||
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
|
||||
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, undefined, active?.title ?? activeId)}
|
||||
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, active?.title ?? activeId)}
|
||||
style={{
|
||||
top: headerVisible ? 28 : 0,
|
||||
background:
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
isLayoutNode,
|
||||
type LayoutNode,
|
||||
mergeZonesWithPane as mergeZonesWithPaneOp,
|
||||
migratePersistedTree,
|
||||
mirrorTreeHorizontal,
|
||||
movePane as movePaneOp,
|
||||
movePanes as movePanesOp,
|
||||
@@ -34,12 +35,14 @@ import {
|
||||
removePane,
|
||||
reorderPanesInGroup as reorderPanesInGroupOp,
|
||||
setActivePane as setActivePaneOp,
|
||||
setGroupHeaderHidden as setGroupHeaderHiddenOp,
|
||||
setGroupMinimized,
|
||||
setGroupTabStrip as setGroupTabStripOp,
|
||||
setSplitWeights as setSplitWeightsOp,
|
||||
type SplitNode
|
||||
type SplitNode,
|
||||
type TabStripMode
|
||||
} from './model'
|
||||
import { FLOATING_PLACEMENT } from './renderer/floating-rect'
|
||||
import { tabStripVisibleForZone } from './renderer/strip-visibility'
|
||||
import { rootChildSide } from './renderer/track-model'
|
||||
|
||||
// v2: v1 trees were saved against placeholder panes with index-order zone
|
||||
@@ -53,9 +56,10 @@ let defaultTree: LayoutNode | null = null
|
||||
function loadPersisted(): LayoutNode | null {
|
||||
const parsed = readJson<unknown>(STORAGE_KEY)
|
||||
|
||||
// Canonicalize on load: strips stale attributes older code persisted
|
||||
// (e.g. explicit headerHidden on lone-pane zones) and re-flattens.
|
||||
return isLayoutNode(parsed) ? normalize(parsed) : null
|
||||
// Canonicalize on load: bring attributes onto the current schema (see
|
||||
// migratePersistedTree — the retired `headerHidden` is dropped here) and
|
||||
// re-flatten the structure.
|
||||
return isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : null
|
||||
}
|
||||
|
||||
function persist(tree: LayoutNode | null) {
|
||||
@@ -715,6 +719,22 @@ function shownPanesInGroup(group: { panes: readonly string[] }): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Is this zone showing a tab strip right now? The store's adapter over the
|
||||
* shared resolver — TreeGroup answers the same question from its own render
|
||||
* inputs, so the toggle command and the strip on screen cannot disagree about
|
||||
* which way "toggle" points. */
|
||||
export function tabStripVisibleForGroup(group: GroupNode): boolean {
|
||||
const registered = registry.getArea('panes')
|
||||
|
||||
return tabStripVisibleForZone({
|
||||
active: group.active,
|
||||
isCollapsePane,
|
||||
mode: group.tabStrip,
|
||||
paneFor: (id: string) => registered.find(c => c.id === id),
|
||||
shown: shownPanesInGroup(group)
|
||||
})
|
||||
}
|
||||
|
||||
/** ⌘1…⌘9: activate the Nth *visible* tab of the target zone — the first of
|
||||
* hovered / focused / workspace that is a real tab strip (≥2 shown panes).
|
||||
* Pointing at the sidebar (or nothing) therefore still switches main's tabs
|
||||
@@ -761,13 +781,11 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): null | string {
|
||||
const nextId = panes[(idx + direction + panes.length) % panes.length]
|
||||
activateTreePane(group.id, nextId)
|
||||
|
||||
// Cycling onto a session/main tab must surface the name card — a zone that
|
||||
// was double-tap-hidden stays headerless otherwise ("the one that cycles
|
||||
// never gets it").
|
||||
if (isMainStripPane(nextId)) {
|
||||
setTreeGroupHeaderHidden(group.id, false)
|
||||
}
|
||||
|
||||
// No strip repair here: cycling needs two shown tabs, which is exactly when
|
||||
// auto shows a strip anyway. The old force-show existed because a stray
|
||||
// double-tap could leave a multi-tab zone headerless; that gesture is gone,
|
||||
// and a zone the user deliberately set to `never` must not be argued with by
|
||||
// a keystroke that was only asked to change tabs.
|
||||
return nextId
|
||||
}
|
||||
|
||||
@@ -1262,16 +1280,11 @@ function enforceDockedPanes(
|
||||
}
|
||||
|
||||
if (dock.pos === 'center' && from.id === anchor.id) {
|
||||
// Already stacked with its anchor — but an enforced tab must be
|
||||
// REACHABLE, not just co-located. Community regression (Aug 2026):
|
||||
// persisted trees where the enforced pane was center-stacked with the
|
||||
// strip hidden and itself active left the ANCHOR invisible with no
|
||||
// strip to switch back ("my ui only shows bots now... cant find the
|
||||
// sessions"). An enforced zone always shows its strip.
|
||||
if (anchor.headerHidden === true) {
|
||||
next = setGroupHeaderHiddenOp(next, anchor.id, false) ?? next
|
||||
}
|
||||
|
||||
// Already stacked with its anchor, and nothing to repair: the trees that
|
||||
// produced the "my ui only shows bots now... cant find the sessions"
|
||||
// regression carried an accidental `headerHidden: true`, which the load
|
||||
// migration now drops outright. A surviving `never` here is deliberate
|
||||
// and recoverable from the toggle command, so boot does not overrule it.
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1354,14 +1367,14 @@ function adoptContributedPanes(): void {
|
||||
const target = findGroupOfPane(next, anchor ?? '')?.id
|
||||
|
||||
if (target) {
|
||||
// Whether the DESTINATION zone's header was explicitly hidden, read
|
||||
// BEFORE the insert — `insertAtGroup` pins `headerHidden: false` on a
|
||||
// center drop (a stack you can't see is a trap), which is right for a
|
||||
// drag but wrong for adoption into a zone whose bar the user hid.
|
||||
const hostHeaderHidden = findGroup(next, target)?.headerHidden === true
|
||||
|
||||
// Silent adoption: don't front over the zone's active tab — a reveal
|
||||
// does. An edge dock re-takes the share the pane held when it closed.
|
||||
//
|
||||
// Nothing writes the strip choice afterwards. This used to read the
|
||||
// host's hidden flag before the insert and stamp it back on after, purely
|
||||
// to undo the pin `insertAtGroup` applied; with the pin gone the zone's
|
||||
// own preference simply survives, and the adopted pane arrives with a
|
||||
// chip whenever auto says the zone has more than one.
|
||||
next =
|
||||
insertAtGroup(
|
||||
next,
|
||||
@@ -1372,20 +1385,6 @@ function adoptContributedPanes(): void {
|
||||
false,
|
||||
recalledEdgeWeights(pane.id)
|
||||
) ?? next
|
||||
|
||||
// An adopted pane ARRIVES with its chip showing — a surprise zone with
|
||||
// zero chrome has no obvious handle to drag or close. (Explicit reveal;
|
||||
// the next structural op returns lone panes to the auto-hide default.)
|
||||
//
|
||||
// EXCEPT into a zone whose header the user explicitly hid: that's a
|
||||
// standing preference about the zone, not a stale default. Without this
|
||||
// the bar came back every time a tool panel was closed and toggled on
|
||||
// again — Close dismisses the pane, the toggle re-adopts it through here.
|
||||
const landed = findGroupOfPane(next, pane.id)
|
||||
|
||||
if (landed) {
|
||||
next = setGroupHeaderHiddenOp(next, landed.id, hostHeaderHidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1846,15 +1845,50 @@ export function collapseTreePane(paneId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide/show a zone's header entirely (double-click gesture). */
|
||||
export function setTreeGroupHeaderHidden(groupId: string, headerHidden: boolean) {
|
||||
/** Write a zone's standing tab-strip choice; `undefined` returns it to auto. */
|
||||
export function setTreeGroupTabStrip(groupId: string, tabStrip: TabStripMode | undefined) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (tree) {
|
||||
commit(setGroupHeaderHiddenOp(tree, groupId, headerHidden))
|
||||
commit(setGroupTabStripOp(tree, groupId, tabStrip))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone `view.toggleTabStrip` and its ⌘K row act on: the first of hovered /
|
||||
* focused / workspace that renders panes at all. Deliberately the widest
|
||||
* eligibility of any tab verb — the whole point of the command is to reach a
|
||||
* zone showing no chrome, so it must not require the chrome it restores.
|
||||
*/
|
||||
const tabStripTargetGroup = () => tabTargetGroup(candidate => shownPanesInGroup(candidate).length > 0)
|
||||
|
||||
/** Is the toggle's target zone currently showing a strip? Null when no zone
|
||||
* qualifies — the ⌘K row reads this to describe what pressing it will do. */
|
||||
export function targetZoneTabStripVisible(): boolean | null {
|
||||
const group = tabStripTargetGroup()
|
||||
|
||||
return group ? tabStripVisibleForGroup(group) : null
|
||||
}
|
||||
|
||||
/** Flip the target zone's strip. Returns the mode written, or null when there
|
||||
* was no zone to act on. */
|
||||
export function toggleTargetZoneTabStrip(): TabStripMode | null {
|
||||
const group = tabStripTargetGroup()
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Toggle against what is ON SCREEN, not against the stored mode: a zone on
|
||||
// auto has no stored mode, and "toggle" means "do the other thing to what I
|
||||
// am looking at". Both outcomes are explicit, so the zone leaves auto either
|
||||
// way rather than drifting with its tab count afterwards.
|
||||
const next: TabStripMode = tabStripVisibleForGroup(group) ? 'never' : 'always'
|
||||
setTreeGroupTabStrip(group.id, next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function setTreeSplitWeights(splitId: string, weights: number[]) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type LayoutNode, migratePersistedTree } from './model'
|
||||
|
||||
// A stored `headerHidden: true` is ambiguous — a deliberate "Hide header" and
|
||||
// an accidental double-tap wrote the same byte — and it is the state people got
|
||||
// stuck in, because hiding removed the only control offering to unhide. The
|
||||
// migration therefore drops it rather than translating it to `tabStrip: 'never'`.
|
||||
|
||||
const persisted = (node: unknown) => migratePersistedTree(node as LayoutNode) as never as Record<string, unknown>
|
||||
|
||||
describe('migratePersistedTree', () => {
|
||||
it('returns a hidden zone to auto instead of re-stranding it', () => {
|
||||
const migrated = persisted({
|
||||
active: 'workspace',
|
||||
headerHidden: true,
|
||||
id: 'g',
|
||||
panes: ['workspace'],
|
||||
type: 'group'
|
||||
})
|
||||
|
||||
expect(migrated.headerHidden).toBeUndefined()
|
||||
expect(migrated.tabStrip).toBeUndefined()
|
||||
expect(migrated.panes).toEqual(['workspace'])
|
||||
})
|
||||
|
||||
it('drops a stored false too — the repair paths wrote most of them, not users', () => {
|
||||
expect(persisted({ headerHidden: false, id: 'g', panes: ['workspace'], type: 'group' }).tabStrip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a tabStrip choice, which only a user can have written', () => {
|
||||
expect(persisted({ id: 'g', panes: ['workspace'], tabStrip: 'never', type: 'group' }).tabStrip).toBe('never')
|
||||
expect(persisted({ id: 'g', panes: ['workspace'], tabStrip: 'always', type: 'group' }).tabStrip).toBe('always')
|
||||
})
|
||||
|
||||
it('discards a tabStrip value outside the schema', () => {
|
||||
expect(persisted({ id: 'g', panes: ['workspace'], tabStrip: 'sometimes', type: 'group' }).tabStrip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reaches groups nested in splits', () => {
|
||||
const migrated = persisted({
|
||||
children: [
|
||||
{ active: 'workspace', headerHidden: true, id: 'a', panes: ['workspace'], type: 'group' },
|
||||
{
|
||||
children: [{ headerHidden: true, id: 'b', panes: ['terminal'], type: 'group' }],
|
||||
id: 'inner',
|
||||
orientation: 'column',
|
||||
type: 'split',
|
||||
weights: [1]
|
||||
}
|
||||
],
|
||||
id: 'root',
|
||||
orientation: 'row',
|
||||
type: 'split',
|
||||
weights: [1, 1]
|
||||
})
|
||||
|
||||
expect(JSON.stringify(migrated)).not.toContain('headerHidden')
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
closeToolPane,
|
||||
isPaneVisible,
|
||||
revealTreePane,
|
||||
setTreeGroupHeaderHidden,
|
||||
setTreeGroupTabStrip,
|
||||
togglePaneVisible
|
||||
} from './store'
|
||||
|
||||
@@ -69,7 +69,7 @@ const toolZone = () => {
|
||||
? tree.children.find(c => c.type === 'group' && (c.panes.includes('terminal') || c.panes.includes('logs')))
|
||||
: null
|
||||
|
||||
return found as { active?: string; headerHidden?: boolean; minimized?: boolean; panes: string[] } | null
|
||||
return found as { active?: string; minimized?: boolean; panes: string[]; tabStrip?: string } | null
|
||||
}
|
||||
|
||||
/** Terminal dragged to the bottom; logs adopted into the same zone.
|
||||
@@ -77,13 +77,13 @@ const toolZone = () => {
|
||||
* Set via `$layoutTree.set`, NOT `declareDefaultTree` — that only adopts into
|
||||
* an existing tree, and `$layoutTree` is module state that survives between
|
||||
* tests, so the second case would silently assert against the first's shape. */
|
||||
const stackTree = (options?: { active?: string; headerHidden?: boolean }) => {
|
||||
const stackTree = (options?: { active?: string; tabStrip?: 'always' | 'never' }) => {
|
||||
$layoutTree.set(
|
||||
split('column', [
|
||||
group(['workspace'], { active: 'workspace', id: 'grp-main' }),
|
||||
group(['terminal', 'logs'], {
|
||||
active: options?.active ?? 'terminal',
|
||||
headerHidden: options?.headerHidden,
|
||||
tabStrip: options?.tabStrip,
|
||||
id: 'g-tools'
|
||||
})
|
||||
])
|
||||
@@ -363,23 +363,23 @@ describe('a terminal that owns its own zone (Default / Terminal deck / Quad)', (
|
||||
|
||||
describe('a zone whose header the user hid', () => {
|
||||
it('keeps it hidden after a stacked sibling is closed and toggled back', () => {
|
||||
stackTree({ headerHidden: true })
|
||||
stackTree({ tabStrip: 'never' })
|
||||
bindPaneCollapse('terminal', atom(true))
|
||||
const $logs = atom(true)
|
||||
bindPaneCollapse('logs', $logs)
|
||||
|
||||
setTreeGroupHeaderHidden('g-tools', true)
|
||||
setTreeGroupTabStrip('g-tools', 'never')
|
||||
|
||||
// Close logs: the zone drops to one pane. normalize used to DISCARD the
|
||||
// hidden flag here ("a lone zone is headerless anyway"), so the bar
|
||||
// reappeared the moment logs was toggled back in.
|
||||
closeToolPane('logs')
|
||||
expect(toolZone()?.headerHidden).toBe(true)
|
||||
expect(toolZone()?.tabStrip).toBe('never')
|
||||
|
||||
$logs.set(true)
|
||||
|
||||
expect(toolZone()?.panes).toContain('logs')
|
||||
expect(toolZone()?.headerHidden).toBe(true)
|
||||
expect(toolZone()?.tabStrip).toBe('never')
|
||||
})
|
||||
|
||||
it('keeps it hidden when a closed pane is re-adopted into it', () => {
|
||||
@@ -388,14 +388,14 @@ describe('a zone whose header the user hid', () => {
|
||||
bindPaneCollapse('terminal', $terminal)
|
||||
bindPaneCollapse('logs', atom(true))
|
||||
|
||||
setTreeGroupHeaderHidden('g-tools', true)
|
||||
setTreeGroupTabStrip('g-tools', 'never')
|
||||
|
||||
// Re-adoption pins headerHidden:false so a surprise pane always has a
|
||||
// handle — correct for a new pane, wrong for a zone the user hid.
|
||||
// Re-adoption used to pin the strip visible so a surprise pane always had
|
||||
// a handle — correct for a new pane, wrong for a zone the user hid.
|
||||
closeToolPane('terminal')
|
||||
$terminal.set(true)
|
||||
|
||||
expect(toolZone()?.panes).toContain('terminal')
|
||||
expect(toolZone()?.headerHidden).toBe(true)
|
||||
expect(toolZone()?.tabStrip).toBe('never')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Send Diagnostics — the consent-gated debug-bundle upload dialog.
|
||||
//
|
||||
// Rendered globally (wiring.tsx, beside ConfirmHost) and driven by the
|
||||
// $sendDiagnostics store: any surface (the failed-turn error card today)
|
||||
// opens it via requestSendDiagnostics(). Three faces:
|
||||
// consent — privacy notice (what's collected, who can see it, retention)
|
||||
// with an explicit Upload button; nothing is sent before it.
|
||||
// uploading — spinner while the backend collects, redacts and uploads.
|
||||
// done — the private view link (copyable) + where to pick up the
|
||||
// discussion: GitHub Issues · Nous Portal Support · Discord.
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { ExternalLink, Loader2Icon, Lock } from '@/lib/icons'
|
||||
import { $sendDiagnostics, confirmSendDiagnostics, dismissSendDiagnostics } from '@/store/send-diagnostics'
|
||||
|
||||
const SUPPORT_LINKS = [
|
||||
{ key: 'github', url: 'https://github.com/NousResearch/hermes-agent/issues' },
|
||||
{ key: 'portal', url: 'https://portal.nousresearch.com/help' },
|
||||
{ key: 'discord', url: 'https://discord.gg/NousResearch' }
|
||||
] as const
|
||||
|
||||
export function SendDiagnosticsHost() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.sendDiagnostics
|
||||
const state = useStore($sendDiagnostics)
|
||||
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const busy = state.phase === 'uploading'
|
||||
|
||||
return (
|
||||
// Dismissal is allowed in EVERY phase, including mid-upload: the store's
|
||||
// generation guard makes a dismissed upload's completion a no-op, so Esc/
|
||||
// backdrop/Cancel are always an immediate way out (cancellation of the
|
||||
// in-flight request itself stays best-effort).
|
||||
<Dialog onOpenChange={open => (!open ? dismissSendDiagnostics() : undefined)} open>
|
||||
<DialogContent className="max-w-[30rem]">
|
||||
{state.phase === 'consent' || state.phase === 'uploading' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-(--ui-text-tertiary)" />
|
||||
{copy.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="whitespace-pre-line text-left">{copy.privacyNotice}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={dismissSendDiagnostics} variant="ghost">
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void confirmSendDiagnostics()}>
|
||||
{busy ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
{copy.uploading}
|
||||
</span>
|
||||
) : (
|
||||
copy.upload
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : state.phase === 'error' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.failedTitle}</DialogTitle>
|
||||
<DialogDescription className="text-left">
|
||||
{state.error}
|
||||
{'\n'}
|
||||
{copy.failedHint}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={dismissSendDiagnostics} variant="ghost">
|
||||
{copy.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.doneTitle}</DialogTitle>
|
||||
<DialogDescription className="text-left">{copy.doneDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(state.result?.viewUrl || state.result?.uploadId) && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate text-[0.78rem] text-(--ui-text-secondary)">
|
||||
{state.result.viewUrl ?? copy.uploadIdFallback(state.result.uploadId ?? '')}
|
||||
</code>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
label={copy.copyLink}
|
||||
text={state.result.viewUrl ?? state.result.uploadId ?? ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[0.8rem] text-(--ui-text-secondary)">{copy.handoffLead}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUPPORT_LINKS.map(link => (
|
||||
<Button key={link.key} onClick={() => openExternalLink(link.url)} size="sm" variant="outline">
|
||||
<ExternalLink className="size-3" />
|
||||
{copy.links[link.key]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={dismissSendDiagnostics} variant="ghost">
|
||||
{copy.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
common: { cancel: 'Cancel', confirm: 'Confirm', delete: 'Delete', done: 'Done', loading: 'Working' },
|
||||
errors: { genericFailure: 'Something failed' }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// ConfirmDialog schedules window.setTimeout(onClose, 600) after a successful
|
||||
// confirm. The timer had no cleanup, so an unmount inside that window left it
|
||||
// pending. In CI it came due after the environment was gone. The setState
|
||||
// path of React then touched `window`:
|
||||
//
|
||||
// ReferenceError: window is not defined
|
||||
// at resolveUpdatePriority (react-dom-client.development.js:1308)
|
||||
// at dispatchSetState
|
||||
// at Timeout.t4 [as _onTimeout] session-actions-menu.tsx:574
|
||||
//
|
||||
// The frame at session-actions-menu.tsx:574 is the `onClose` prop of
|
||||
// DeleteSessionDialog. The owner of the timer is this component.
|
||||
//
|
||||
// This test confirms, unmounts inside the 600ms window, and then lets the
|
||||
// timer come due on the dead tree.
|
||||
test('the close timer does not fire after unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onClose = vi.fn()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(<ConfirmDialog confirmLabel="Delete" onClose={onClose} onConfirm={onConfirm} open title="Delete session" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
// Not waitFor: it polls on real timers, and the fake timers of this test
|
||||
// never let it advance. onConfirm runs synchronously inside the click, and
|
||||
// one microtask turn is enough for the await in run() to settle and reach
|
||||
// the setTimeout.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
|
||||
// Unmount while the close timer is still pending.
|
||||
cleanup()
|
||||
|
||||
// Let the timer come due on the unmounted tree.
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
@@ -58,6 +58,7 @@ export function ConfirmDialog({
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const closeTimerRef = useRef<null | number>(null)
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const busy = status === 'saving' || status === 'done'
|
||||
@@ -73,6 +74,24 @@ export function ConfirmDialog({
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Cancel the pending close timer on unmount. The timer below holds the
|
||||
// "done" beat visible for 600ms, and an unmount inside that window used to
|
||||
// leave it armed. It then called onClose on a tree that is gone, which
|
||||
// reaches setState in the parent. Under vitest the environment can be torn
|
||||
// down first, and React then reads `window` during the update and throws
|
||||
// ReferenceError.
|
||||
// The write below is a timer handle, and not a mirror of a reactive value.
|
||||
// It happens on unmount only, and it clears the handle this component owns.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function run() {
|
||||
if (busy) {
|
||||
return
|
||||
@@ -96,7 +115,10 @@ export function ConfirmDialog({
|
||||
try {
|
||||
await onConfirm()
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 600)
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null
|
||||
onClose()
|
||||
}, 600)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : t.errors.genericFailure)
|
||||
|
||||
@@ -124,15 +124,16 @@ describe('PaneTab hover close button', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
})
|
||||
|
||||
it('can hide the hover ✕ while retaining the close handler', () => {
|
||||
it('a closeable horizontal tab always shows its ✕ — the chip and the pointer gestures are one affordance', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PaneTab onClose={onClose} showCloseButton={false}>
|
||||
<PaneTab onClose={onClose}>
|
||||
<PaneTabLabel>tab</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy()
|
||||
|
||||
const tab = screen.getByText('tab')
|
||||
fireEvent.pointerDown(tab, { button: 1 })
|
||||
fireEvent.pointerUp(tab, { button: 1 })
|
||||
|
||||
@@ -53,13 +53,14 @@ interface PaneTabProps extends React.ComponentProps<'div'> {
|
||||
dirty?: boolean
|
||||
/** Close verb. Horizontal tabs reveal a hover ✕ on the right (a `--tab-face`
|
||||
* gradient fades it over the label); middle-click and ⌘-click always work,
|
||||
* and stay the only gestures on vertical rails (no room for a chip ✕). */
|
||||
* and stay the only gestures on vertical rails (no room for a chip ✕).
|
||||
* There is no way to take the ✕ off a tab that HAS this verb: the chip and
|
||||
* the pointer gestures are one affordance, so a closeable tab always says
|
||||
* so. Omit `onClose` to make a tab uncloseable. */
|
||||
onClose?: () => void
|
||||
/** Part of a multi-tab selection (⌥/Ctrl-click, Shift-click) — an accent
|
||||
* wash marks every tab that a drag would carry, Chrome-style. */
|
||||
selected?: boolean
|
||||
/** Whether a closeable horizontal tab reveals the hover ✕. */
|
||||
showCloseButton?: boolean
|
||||
/** Vertical rail form (collapsed sidebar zones). */
|
||||
vertical?: boolean
|
||||
/** Content-facing edge of a vertical rail — the strip line the active tab cuts. */
|
||||
@@ -83,7 +84,6 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
||||
onPointerUp,
|
||||
onClickCapture,
|
||||
selected = false,
|
||||
showCloseButton = true,
|
||||
vertical = false,
|
||||
side = 'left',
|
||||
children,
|
||||
@@ -162,7 +162,7 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
||||
<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>
|
||||
)}
|
||||
{onClose && showCloseButton && !vertical && (
|
||||
{onClose && !vertical && (
|
||||
// Hover ✕, painted OVER the label's right edge as an overlay (no
|
||||
// layout shift, tab width never jumps on hover). The runway is a tiny
|
||||
// transparent→`--tab-face` gradient, so the button melts into the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
@@ -117,6 +117,22 @@ function Toolbar({
|
||||
zoomOut: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const resetRef = useRef<null | number>(null)
|
||||
|
||||
// Same reason as the close timer of ConfirmDialog. An unmount inside the
|
||||
// 1500ms window used to leave this armed. The callback then called setState
|
||||
// on a tree that is gone.
|
||||
// The write below is a timer handle, and not a mirror of a reactive value.
|
||||
// It happens on unmount only, and it clears the handle this component owns.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
resetRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copy = async () => {
|
||||
if (!onCopy) {
|
||||
@@ -125,7 +141,15 @@ function Toolbar({
|
||||
|
||||
await onCopy()
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
|
||||
resetRef.current = window.setTimeout(() => {
|
||||
resetRef.current = null
|
||||
setCopied(false)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Vendored
+6
@@ -297,6 +297,8 @@ declare global {
|
||||
// resolved by Electron independently of the connected backend (#66899).
|
||||
// Created on demand; returns the normalized absolute path.
|
||||
desktopPluginsRoot?: () => Promise<string>
|
||||
/** LOCAL `<HERMES_HOME>/logs` (profile-aware) — error card "Open Logs". */
|
||||
logsRoot?: () => Promise<string>
|
||||
// Local AGENT-plugin root (<HERMES_HOME>/plugins), same Electron-local
|
||||
// resolution. The disk door also scans it for `<name>/desktop/plugin.js`
|
||||
// so one agent-plugin package can ship a desktop UI half. Optional:
|
||||
@@ -998,6 +1000,8 @@ export interface DesktopCloudAgentSignInResult {
|
||||
export interface DesktopBootProgress {
|
||||
error: string | null
|
||||
fakeMode: boolean
|
||||
/** True when the boot failure is a Nous Cloud agent that is down (HTTP 502/503/504). */
|
||||
isCloudBackendDown?: boolean
|
||||
message: string
|
||||
phase: string
|
||||
progress: number
|
||||
@@ -1009,6 +1013,8 @@ export interface DesktopBootProgress {
|
||||
*/
|
||||
retryable?: boolean
|
||||
running: boolean
|
||||
/** Structured HTTP status when the boot failure carried one (e.g. 503). */
|
||||
statusCode?: number | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
export const ar = defineLocale({
|
||||
sendDiagnostics: {
|
||||
title: 'إرسال التشخيصات إلى Nous',
|
||||
privacyNotice:
|
||||
'سيؤدي هذا إلى رفع حزمة تصحيح إلى التخزين الداخلي لدى Nous (ليست لصيقة عامة). تتضمن معلومات النظام (نظام التشغيل، الإصدارات، المزوّد، وأنواع مفاتيح API المُهيأة — وليس المفاتيح نفسها أبداً) والسجلات الكاملة للوكيل والبوابة وسطح المكتب (حتى 512 كيلوبايت لكل منها، ومن المرجح أن تحتوي على محتوى المحادثات ومخرجات الأدوات ومسارات الملفات). تُحجب الأسرار قبل الرفع. لا يمكن الاطلاع عليها إلا لموظفي Nous ومشرفي Discord المعتمدين، وتُحذف تلقائياً بعد 14 يوماً.',
|
||||
upload: 'رفع',
|
||||
uploading: 'جارٍ الرفع…',
|
||||
cancel: 'إلغاء',
|
||||
close: 'إغلاق',
|
||||
copyLink: 'نسخ الرابط',
|
||||
uploadIdFallback: id => `لم يتم إرجاع رابط عرض — اذكر معرّف الرفع ${id} للدعم`,
|
||||
doneTitle: 'تم إرسال التشخيصات',
|
||||
doneDescription: 'تم رفع الحزمة بشكل خاص. شارك الرابط أدناه في محادثة الدعم لكي يتمكن الفريق من رؤية سجلاتك.',
|
||||
failedTitle: 'فشل الرفع',
|
||||
failedHint:
|
||||
'يمكنك أيضاً تشغيل `hermes debug share --nous` من الطرفية، أو `hermes debug share --local` لعرض التقرير دون رفعه.',
|
||||
handoffLead: 'تابع النقاش في:',
|
||||
links: {
|
||||
github: 'GitHub Issues',
|
||||
portal: 'دعم بوابة Nous',
|
||||
discord: 'Discord'
|
||||
}
|
||||
},
|
||||
common: {
|
||||
apply: 'تطبيق',
|
||||
back: 'رجوع',
|
||||
@@ -85,6 +107,12 @@ export const ar = defineLocale({
|
||||
retry: 'إعادة المحاولة',
|
||||
repairInstall: 'إصلاح التثبيت',
|
||||
useLocalGateway: 'استخدام البوابة المحلية',
|
||||
cloudDownTitle: 'عامل Nous Cloud معطّل',
|
||||
cloudDownDescription:
|
||||
'يعيد عامل السحابة المُدار من Nous الذي يتصل به هذا البوابة خطأً من الخادم. لا يمكن إعادة تشغيله من هنا — تحقق من حالته، أو بدّل إلى البوابة المحلية، أو احصل على الدعم.',
|
||||
cloudDownHint: 'تفتح الأزرار أدناه بوابة Nous (حالة المثيل وعناصر التحكم) أو Discord للحصول على الدعم.',
|
||||
cloudDownCheckPortal: 'التحقق من حالة البوابة',
|
||||
cloudDownDiscord: 'الحصول على مساعدة عبر Discord',
|
||||
openLogs: 'فتح السجلات',
|
||||
repairHint: 'يعيد الإصلاح تشغيل المثبت وقد يستغرق بضع دقائق على جهاز جديد.',
|
||||
remoteSignInHint: signInLabel =>
|
||||
@@ -2345,8 +2373,8 @@ export const ar = defineLocale({
|
||||
}
|
||||
},
|
||||
zones: {
|
||||
showHeader: 'إظهار الرأس',
|
||||
hideHeader: 'إخفاء الرأس',
|
||||
showTabStrip: 'إظهار علامات التبويب',
|
||||
hideTabStrip: 'إخفاء علامات التبويب',
|
||||
showStripTab: title => `إظهار ${title}`,
|
||||
hideStripTab: title => `إخفاء ${title}`,
|
||||
lastTabKeptTitle: 'يبقى آخر تبويب',
|
||||
@@ -2434,6 +2462,24 @@ export const ar = defineLocale({
|
||||
branchNewChat: 'تفريع إلى محادثة جديدة',
|
||||
react: 'تفاعل',
|
||||
dismissError: 'تجاهل الخطأ',
|
||||
errorLayers: {
|
||||
auth: 'خطأ في المصادقة',
|
||||
billing: 'نفاد الرصيد',
|
||||
disk: 'القرص ممتلئ',
|
||||
endpoint: 'خطأ في نقطة النهاية المخصصة',
|
||||
gateway: 'خطأ في البوابة',
|
||||
generic: 'فشلت الجولة',
|
||||
provider: 'خطأ من المزوّد',
|
||||
runtime: 'خطأ في بيئة التشغيل المحلية',
|
||||
streaming: 'خطأ في اتصال البث'
|
||||
},
|
||||
errorRetry: 'إعادة المحاولة',
|
||||
errorSwitchProvider: 'تبديل المزوّد',
|
||||
errorOpenLogs: 'فتح السجلات',
|
||||
errorOpenLogsFailed: 'تعذّر فتح مجلد السجلات',
|
||||
errorOpenDesktopLogs: 'فتح سجلات سطح المكتب',
|
||||
errorCopyDiagnostics: 'نسخ تفاصيل الخطأ',
|
||||
errorSendDiagnostics: 'إرسال التشخيصات',
|
||||
filesChanged: count => `${count} ملفات تم تغييرها`,
|
||||
reviewChanges: 'مراجعة',
|
||||
readAloudFailed: 'فشلت القراءة بصوت عال',
|
||||
|
||||
@@ -102,6 +102,13 @@ export const en: Translations = {
|
||||
`Signs out of the saved remote browser session, then opens ${signInLabel}. Use local gateway to switch to the bundled backend instead.`,
|
||||
signOutAndSignIn: 'Sign out & sign in',
|
||||
remoteFailureHint: 'Check the gateway URL and sign-in under Gateway settings, or switch to the local gateway.',
|
||||
cloudDownTitle: 'Nous Cloud agent is down',
|
||||
cloudDownDescription:
|
||||
'The Nous-managed cloud agent this gateway connects to is returning a server error. It cannot be restarted from here — check its status, switch to the local gateway, or get support.',
|
||||
cloudDownHint:
|
||||
'The buttons below open the Nous Portal (instance status and controls) and our Discord for support.',
|
||||
cloudDownCheckPortal: 'Check Portal status',
|
||||
cloudDownDiscord: 'Get help on Discord',
|
||||
hideRecentLogs: 'Hide recent logs',
|
||||
showRecentLogs: 'Show recent logs',
|
||||
signedInTitle: 'Signed in',
|
||||
@@ -203,6 +210,30 @@ export const en: Translations = {
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
|
||||
sendDiagnostics: {
|
||||
title: 'Send diagnostics to Nous',
|
||||
privacyNotice:
|
||||
'This uploads a debug bundle to Nous-internal storage (not a public paste). It includes system info (OS, versions, provider, which API keys are configured — never the keys themselves) and full agent, gateway, and desktop logs (up to 512 KB each), which likely contain conversation content, tool outputs, and file paths. Secrets are redacted before upload. The bundle is viewable only by Nous staff and allowlisted Discord moderators, and auto-deletes after 14 days.',
|
||||
upload: 'Upload',
|
||||
uploading: 'Uploading…',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
copyLink: 'Copy link',
|
||||
uploadIdFallback: id => `No view link returned — quote upload ID ${id} to support`,
|
||||
doneTitle: 'Diagnostics sent',
|
||||
doneDescription:
|
||||
'Your bundle was uploaded privately. Share the link below in your support thread so the team can see your logs.',
|
||||
failedTitle: 'Upload failed',
|
||||
failedHint:
|
||||
'You can also run `hermes debug share --nous` from a terminal, or `hermes debug share --local` to print the report without uploading.',
|
||||
handoffLead: 'Pick up the discussion in:',
|
||||
links: {
|
||||
github: 'GitHub Issues',
|
||||
portal: 'Nous Portal Support',
|
||||
discord: 'Discord'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'Hide sidebar',
|
||||
showSidebar: 'Show sidebar',
|
||||
@@ -276,6 +307,7 @@ export const en: Translations = {
|
||||
'view.toggleRightSidebar': 'Toggle file browser',
|
||||
'view.toggleReview': 'Toggle review pane',
|
||||
'view.toggleStatusbar': 'Toggle status bar',
|
||||
'view.toggleTabStrip': 'Toggle tabs',
|
||||
'view.showFiles': 'Show file browser',
|
||||
'view.showBrowser': 'Open browser',
|
||||
'view.toggleHud': 'Toggle HUD mode',
|
||||
@@ -516,6 +548,11 @@ export const en: Translations = {
|
||||
sessionDensityCompact: 'Compact',
|
||||
sessionDensityComfortable: 'Comfortable',
|
||||
sessionDensityDetailed: 'Detailed',
|
||||
tabStripTitle: 'Tab Strip',
|
||||
tabStripDesc: 'Show tabs above a zone. Auto hides them when a zone holds a single pane.',
|
||||
tabStripAuto: 'Auto',
|
||||
tabStripAlways: 'Always',
|
||||
tabStripNever: 'Never',
|
||||
terminalFontTitle: 'Terminal Font',
|
||||
terminalFontDesc:
|
||||
'Choose an installed font for Desktop terminals. Nerd Fonts render Powerlevel10k and shell icons; leave blank to use bundled JetBrains Mono.',
|
||||
@@ -2980,8 +3017,8 @@ export const en: Translations = {
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: 'Show header',
|
||||
hideHeader: 'Hide header',
|
||||
showTabStrip: 'Show tabs',
|
||||
hideTabStrip: 'Hide tabs',
|
||||
showStripTab: title => `Show ${title}`,
|
||||
hideStripTab: title => `Hide ${title}`,
|
||||
lastTabKeptTitle: 'Last tab stays',
|
||||
@@ -3073,6 +3110,24 @@ export const en: Translations = {
|
||||
branchNewChat: 'Branch in new chat',
|
||||
react: 'React',
|
||||
dismissError: 'Dismiss error',
|
||||
errorLayers: {
|
||||
auth: 'Authentication error',
|
||||
billing: 'Out of credits',
|
||||
disk: 'Disk full',
|
||||
endpoint: 'Custom endpoint error',
|
||||
gateway: 'Gateway error',
|
||||
generic: 'Turn failed',
|
||||
provider: 'Provider error',
|
||||
runtime: 'Local runtime error',
|
||||
streaming: 'Streaming connection error'
|
||||
},
|
||||
errorRetry: 'Retry',
|
||||
errorSwitchProvider: 'Switch provider',
|
||||
errorOpenLogs: 'Open logs',
|
||||
errorOpenLogsFailed: 'Could not open the logs folder',
|
||||
errorOpenDesktopLogs: 'Open Desktop logs',
|
||||
errorCopyDiagnostics: 'Copy error details',
|
||||
errorSendDiagnostics: 'Send diagnostics',
|
||||
filesChanged: count => (count === 1 ? '1 file changed' : `${count} files changed`),
|
||||
reviewChanges: 'Review',
|
||||
readAloudFailed: 'Read aloud failed',
|
||||
|
||||
@@ -103,6 +103,13 @@ export const ja = defineLocale({
|
||||
signOutAndSignIn: 'サインアウトして再サインイン',
|
||||
remoteFailureHint:
|
||||
'「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。',
|
||||
cloudDownTitle: 'Nous Cloud エージェントが停止しています',
|
||||
cloudDownDescription:
|
||||
'このゲートウェイが接続している Nous 管理のクラウドエージェントがサーバーエラーを返しています。ここから再起動することはできません。ステータスを確認するか、ローカルゲートウェイに切り替えるか、サポートに連絡してください。',
|
||||
cloudDownHint:
|
||||
'下のボタンから Nous Portal(インスタンスの状態と操作)を開くか、Discord でサポートを受けられます。',
|
||||
cloudDownCheckPortal: 'Portal のステータスを確認',
|
||||
cloudDownDiscord: 'Discord でサポートを受ける',
|
||||
hideRecentLogs: '最近のログを非表示',
|
||||
showRecentLogs: '最近のログを表示',
|
||||
signedInTitle: 'サインインしました',
|
||||
@@ -204,6 +211,30 @@ export const ja = defineLocale({
|
||||
dismiss: '閉じる'
|
||||
},
|
||||
|
||||
sendDiagnostics: {
|
||||
title: 'Nous に診断情報を送信',
|
||||
privacyNotice:
|
||||
'デバッグバンドルを Nous 内部ストレージにアップロードします(公開ペーストではありません)。システム情報(OS、バージョン、プロバイダー、設定済み API キーの種類 — キー自体は含まれません)と、エージェント/ゲートウェイ/デスクトップの完全なログ(各最大 512 KB。会話内容、ツール出力、ファイルパスを含む可能性が高い)が含まれます。シークレットはアップロード前にマスクされます。閲覧できるのは Nous スタッフと許可された Discord モデレーターのみで、14 日後に自動削除されます。',
|
||||
upload: 'アップロード',
|
||||
uploading: 'アップロード中…',
|
||||
cancel: 'キャンセル',
|
||||
close: '閉じる',
|
||||
copyLink: 'リンクをコピー',
|
||||
uploadIdFallback: id => `表示リンクが返されませんでした — サポートにアップロード ID ${id} をお伝えください`,
|
||||
doneTitle: '診断情報を送信しました',
|
||||
doneDescription:
|
||||
'バンドルは非公開でアップロードされました。サポートスレッドで以下のリンクを共有すると、チームがログを確認できます。',
|
||||
failedTitle: 'アップロードに失敗しました',
|
||||
failedHint:
|
||||
'ターミナルから `hermes debug share --nous` を実行するか、`hermes debug share --local` でアップロードせずにレポートを表示することもできます。',
|
||||
handoffLead: '続きは次の場所で:',
|
||||
links: {
|
||||
github: 'GitHub Issues',
|
||||
portal: 'Nous Portal サポート',
|
||||
discord: 'Discord'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'サイドバーを非表示',
|
||||
showSidebar: 'サイドバーを表示',
|
||||
@@ -340,6 +371,11 @@ export const ja = defineLocale({
|
||||
sessionDensityCompact: 'コンパクト',
|
||||
sessionDensityComfortable: '標準',
|
||||
sessionDensityDetailed: '詳細',
|
||||
tabStripTitle: 'タブバー',
|
||||
tabStripDesc: 'ゾーンの上にタブを表示します。自動ではペインが1つのときに隠します。',
|
||||
tabStripAuto: '自動',
|
||||
tabStripAlways: '常に表示',
|
||||
tabStripNever: '表示しない',
|
||||
terminalFontTitle: 'ターミナルフォント',
|
||||
terminalFontDesc:
|
||||
'Desktop のターミナルで使用するインストール済みフォントを選びます。Nerd Font は Powerlevel10k とシェルアイコンを表示できます。空欄では内蔵の JetBrains Mono を使用します。',
|
||||
@@ -2636,8 +2672,8 @@ export const ja = defineLocale({
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: 'ヘッダーを表示',
|
||||
hideHeader: 'ヘッダーを隠す',
|
||||
showTabStrip: 'タブを表示',
|
||||
hideTabStrip: 'タブを隠す',
|
||||
showStripTab: title => `${title} を表示`,
|
||||
hideStripTab: title => `${title} を隠す`,
|
||||
lastTabKeptTitle: '最後のタブは残ります',
|
||||
@@ -2726,6 +2762,24 @@ export const ja = defineLocale({
|
||||
branchNewChat: '新しいチャットでブランチ',
|
||||
react: 'リアクション',
|
||||
dismissError: 'エラーを閉じる',
|
||||
errorLayers: {
|
||||
auth: '認証エラー',
|
||||
billing: 'クレジット不足',
|
||||
disk: 'ディスク容量不足',
|
||||
endpoint: 'カスタムエンドポイントのエラー',
|
||||
gateway: 'ゲートウェイのエラー',
|
||||
generic: 'ターンが失敗しました',
|
||||
provider: 'プロバイダーのエラー',
|
||||
runtime: 'ローカルランタイムのエラー',
|
||||
streaming: 'ストリーミング接続のエラー'
|
||||
},
|
||||
errorRetry: '再試行',
|
||||
errorSwitchProvider: 'プロバイダーを切り替え',
|
||||
errorOpenLogs: 'ログを開く',
|
||||
errorOpenLogsFailed: 'ログフォルダを開けませんでした',
|
||||
errorOpenDesktopLogs: 'デスクトップのログを開く',
|
||||
errorCopyDiagnostics: 'エラー詳細をコピー',
|
||||
errorSendDiagnostics: '診断情報を送信',
|
||||
filesChanged: count => `${count} 件のファイルを変更`,
|
||||
reviewChanges: 'レビュー',
|
||||
readAloudFailed: '読み上げに失敗しました',
|
||||
|
||||
@@ -145,6 +145,11 @@ export interface Translations {
|
||||
remoteSignInHint: (signInLabel: string) => string
|
||||
signOutAndSignIn: string
|
||||
remoteFailureHint: string
|
||||
cloudDownTitle: string
|
||||
cloudDownDescription: string
|
||||
cloudDownHint: string
|
||||
cloudDownCheckPortal: string
|
||||
cloudDownDiscord: string
|
||||
hideRecentLogs: string
|
||||
showRecentLogs: string
|
||||
signedInTitle: string
|
||||
@@ -245,6 +250,27 @@ export interface Translations {
|
||||
dismiss: string
|
||||
}
|
||||
|
||||
sendDiagnostics: {
|
||||
title: string
|
||||
privacyNotice: string
|
||||
upload: string
|
||||
uploading: string
|
||||
cancel: string
|
||||
close: string
|
||||
copyLink: string
|
||||
uploadIdFallback: (id: string) => string
|
||||
doneTitle: string
|
||||
doneDescription: string
|
||||
failedTitle: string
|
||||
failedHint: string
|
||||
handoffLead: string
|
||||
links: {
|
||||
discord: string
|
||||
github: string
|
||||
portal: string
|
||||
}
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: string
|
||||
showSidebar: string
|
||||
@@ -415,6 +441,11 @@ export interface Translations {
|
||||
sessionDensityCompact: string
|
||||
sessionDensityComfortable: string
|
||||
sessionDensityDetailed: string
|
||||
tabStripTitle: string
|
||||
tabStripDesc: string
|
||||
tabStripAuto: string
|
||||
tabStripAlways: string
|
||||
tabStripNever: string
|
||||
terminalFontTitle: string
|
||||
terminalFontDesc: string
|
||||
terminalFontPlaceholder: string
|
||||
@@ -2554,8 +2585,8 @@ export interface Translations {
|
||||
}
|
||||
|
||||
zones: {
|
||||
showHeader: string
|
||||
hideHeader: string
|
||||
showTabStrip: string
|
||||
hideTabStrip: string
|
||||
showStripTab: (title: string) => string
|
||||
hideStripTab: (title: string) => string
|
||||
lastTabKeptTitle: string
|
||||
@@ -2643,6 +2674,26 @@ export interface Translations {
|
||||
branchNewChat: string
|
||||
react: string
|
||||
dismissError: string
|
||||
/** Layer titles for the structured error card (agent/error_surface.py).
|
||||
* `generic` is the fallback when the backend sent no descriptor. */
|
||||
errorLayers: {
|
||||
auth: string
|
||||
billing: string
|
||||
disk: string
|
||||
endpoint: string
|
||||
gateway: string
|
||||
generic: string
|
||||
provider: string
|
||||
runtime: string
|
||||
streaming: string
|
||||
}
|
||||
errorRetry: string
|
||||
errorSwitchProvider: string
|
||||
errorOpenLogs: string
|
||||
errorOpenLogsFailed: string
|
||||
errorOpenDesktopLogs: string
|
||||
errorCopyDiagnostics: string
|
||||
errorSendDiagnostics: string
|
||||
filesChanged: (count: number) => string
|
||||
reviewChanges: string
|
||||
readAloudFailed: string
|
||||
|
||||
@@ -100,6 +100,12 @@ export const zhHant = defineLocale({
|
||||
`先登出已儲存的遠端瀏覽器工作階段,然後開啟${signInLabel}。使用本機閘道可切換至內建後端。`,
|
||||
signOutAndSignIn: '登出並重新登入',
|
||||
remoteFailureHint: '在「閘道設定」中檢查閘道 URL 與登入,或切換至本機閘道。',
|
||||
cloudDownTitle: 'Nous Cloud 代理已停機',
|
||||
cloudDownDescription:
|
||||
'此閘道連線的 Nous 託管雲端代理正在回傳伺服器錯誤。無法在此處重新啟動——請檢查其狀態、切換至本機閘道,或取得支援。',
|
||||
cloudDownHint: '使用下方按鈕開啟 Nous Portal(檢視執行個體狀態與操作)或加入 Discord 取得支援。',
|
||||
cloudDownCheckPortal: '查看 Portal 狀態',
|
||||
cloudDownDiscord: '在 Discord 取得協助',
|
||||
hideRecentLogs: '隱藏最近記錄',
|
||||
showRecentLogs: '顯示最近記錄',
|
||||
signedInTitle: '已登入',
|
||||
@@ -198,6 +204,29 @@ export const zhHant = defineLocale({
|
||||
dismiss: '忽略'
|
||||
},
|
||||
|
||||
sendDiagnostics: {
|
||||
title: '向 Nous 傳送診斷資訊',
|
||||
privacyNotice:
|
||||
'這會將偵錯套件上傳到 Nous 內部儲存空間(並非公開貼上板)。內容包括系統資訊(作業系統、版本、服務商、已設定的 API 金鑰種類 — 絕不包含金鑰本身)以及完整的 agent、gateway 與桌面端日誌(每個最多 512 KB,很可能包含對話內容、工具輸出與檔案路徑)。上傳前會先遮罩機密資訊。僅 Nous 員工與獲准的 Discord 版主可檢視,14 天後自動刪除。',
|
||||
upload: '上傳',
|
||||
uploading: '上傳中…',
|
||||
cancel: '取消',
|
||||
close: '關閉',
|
||||
copyLink: '複製連結',
|
||||
uploadIdFallback: id => `未回傳檢視連結 — 請向支援人員提供上傳 ID ${id}`,
|
||||
doneTitle: '診斷資訊已傳送',
|
||||
doneDescription: '偵錯套件已私密上傳。在您的支援討論串中分享以下連結,團隊即可檢視您的日誌。',
|
||||
failedTitle: '上傳失敗',
|
||||
failedHint:
|
||||
'您也可以在終端機執行 `hermes debug share --nous`,或執行 `hermes debug share --local` 在不上傳的情況下檢視報告。',
|
||||
handoffLead: '在以下位置繼續討論:',
|
||||
links: {
|
||||
github: 'GitHub Issues',
|
||||
portal: 'Nous Portal 支援',
|
||||
discord: 'Discord'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: '隱藏側邊欄',
|
||||
showSidebar: '顯示側邊欄',
|
||||
@@ -332,6 +361,11 @@ export const zhHant = defineLocale({
|
||||
sessionDensityCompact: '緊湊',
|
||||
sessionDensityComfortable: '舒適',
|
||||
sessionDensityDetailed: '詳細',
|
||||
tabStripTitle: '分頁列',
|
||||
tabStripDesc: '在分區上方顯示分頁。自動模式會在分區只有一個面板時隱藏分頁。',
|
||||
tabStripAuto: '自動',
|
||||
tabStripAlways: '一律',
|
||||
tabStripNever: '永不',
|
||||
terminalFontTitle: '終端機字型',
|
||||
terminalFontDesc:
|
||||
'選擇已安裝的字型用於桌面端終端機。Nerd Font 可正確顯示 Powerlevel10k 與 Shell 圖示;留空則使用內建的 JetBrains Mono。',
|
||||
@@ -2549,8 +2583,8 @@ export const zhHant = defineLocale({
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: '顯示標題列',
|
||||
hideHeader: '隱藏標題列',
|
||||
showTabStrip: '顯示分頁',
|
||||
hideTabStrip: '隱藏分頁',
|
||||
showStripTab: title => `顯示 ${title}`,
|
||||
hideStripTab: title => `隱藏 ${title}`,
|
||||
lastTabKeptTitle: '保留最後一個分頁',
|
||||
@@ -2636,6 +2670,24 @@ export const zhHant = defineLocale({
|
||||
branchNewChat: '在新聊天中分支',
|
||||
react: '回應',
|
||||
dismissError: '关闭错误',
|
||||
errorLayers: {
|
||||
auth: '認證錯誤',
|
||||
billing: '額度不足',
|
||||
disk: '磁碟已滿',
|
||||
endpoint: '自訂端點錯誤',
|
||||
gateway: '閘道錯誤',
|
||||
generic: '本輪失敗',
|
||||
provider: '模型服務商錯誤',
|
||||
runtime: '本機執行環境錯誤',
|
||||
streaming: '串流連線錯誤'
|
||||
},
|
||||
errorRetry: '重試',
|
||||
errorSwitchProvider: '切換服務商',
|
||||
errorOpenLogs: '開啟日誌',
|
||||
errorOpenLogsFailed: '無法開啟日誌資料夾',
|
||||
errorOpenDesktopLogs: '開啟桌面端日誌',
|
||||
errorCopyDiagnostics: '複製錯誤詳細資訊',
|
||||
errorSendDiagnostics: '傳送診斷資訊',
|
||||
filesChanged: count => `${count} 個檔案已變更`,
|
||||
reviewChanges: '檢視',
|
||||
readAloudFailed: '朗讀失敗',
|
||||
|
||||
@@ -100,6 +100,12 @@ export const zh: Translations = {
|
||||
`先退出已保存的远程浏览器会话,然后打开${signInLabel}。也可以使用本地网关切换到随应用提供的后端。`,
|
||||
signOutAndSignIn: '退出并重新登录',
|
||||
remoteFailureHint: '在“网关设置”中检查网关 URL 和登录,或切换到本地网关。',
|
||||
cloudDownTitle: 'Nous Cloud 代理已宕机',
|
||||
cloudDownDescription:
|
||||
'此网关连接的 Nous 托管云代理正在返回服务器错误。无法在此处重启——请检查其状态、切换到本地网关或获取支持。',
|
||||
cloudDownHint: '使用下方按钮打开 Nous Portal(查看实例状态与操作)或加入 Discord 获取支持。',
|
||||
cloudDownCheckPortal: '查看 Portal 状态',
|
||||
cloudDownDiscord: '在 Discord 获取帮助',
|
||||
hideRecentLogs: '隐藏最近日志',
|
||||
showRecentLogs: '显示最近日志',
|
||||
signedInTitle: '已登录',
|
||||
@@ -198,6 +204,29 @@ export const zh: Translations = {
|
||||
dismiss: '忽略'
|
||||
},
|
||||
|
||||
sendDiagnostics: {
|
||||
title: '向 Nous 发送诊断信息',
|
||||
privacyNotice:
|
||||
'这会将调试包上传到 Nous 内部存储(并非公开粘贴板)。内容包括系统信息(操作系统、版本、服务商、已配置的 API 密钥种类 — 绝不包含密钥本身)以及完整的 agent、gateway 和桌面端日志(每个最多 512 KB,很可能包含对话内容、工具输出与文件路径)。上传前会先脱敏。仅 Nous 员工与获准的 Discord 版主可查看,14 天后自动删除。',
|
||||
upload: '上传',
|
||||
uploading: '上传中…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
copyLink: '复制链接',
|
||||
uploadIdFallback: id => `未返回查看链接 — 请向支持人员提供上传 ID ${id}`,
|
||||
doneTitle: '诊断信息已发送',
|
||||
doneDescription: '调试包已私密上传。在您的支持会话中分享以下链接,团队即可查看您的日志。',
|
||||
failedTitle: '上传失败',
|
||||
failedHint:
|
||||
'您也可以在终端运行 `hermes debug share --nous`,或运行 `hermes debug share --local` 在不上传的情况下查看报告。',
|
||||
handoffLead: '在以下位置继续讨论:',
|
||||
links: {
|
||||
github: 'GitHub Issues',
|
||||
portal: 'Nous Portal 支持',
|
||||
discord: 'Discord'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: '隐藏侧边栏',
|
||||
showSidebar: '显示侧边栏',
|
||||
@@ -271,6 +300,7 @@ export const zh: Translations = {
|
||||
'view.toggleRightSidebar': '切换文件浏览器',
|
||||
'view.toggleReview': '切换审查面板',
|
||||
'view.toggleStatusbar': '切换状态栏',
|
||||
'view.toggleTabStrip': '切换标签',
|
||||
'view.showFiles': '显示文件浏览器',
|
||||
'view.showBrowser': '打开浏览器',
|
||||
'view.showTerminal': '显示终端',
|
||||
@@ -505,6 +535,11 @@ export const zh: Translations = {
|
||||
sessionDensityCompact: '紧凑',
|
||||
sessionDensityComfortable: '舒适',
|
||||
sessionDensityDetailed: '详细',
|
||||
tabStripTitle: '标签栏',
|
||||
tabStripDesc: '在分区上方显示标签。自动模式会在分区只有一个面板时隐藏标签。',
|
||||
tabStripAuto: '自动',
|
||||
tabStripAlways: '始终',
|
||||
tabStripNever: '从不',
|
||||
terminalFontTitle: '终端字体',
|
||||
terminalFontDesc:
|
||||
'选择已安装的字体用于桌面端终端。Nerd Font 可正确显示 Powerlevel10k 和 Shell 图标;留空则使用内置的 JetBrains Mono。',
|
||||
@@ -3147,8 +3182,8 @@ export const zh: Translations = {
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: '显示标题栏',
|
||||
hideHeader: '隐藏标题栏',
|
||||
showTabStrip: '显示标签',
|
||||
hideTabStrip: '隐藏标签',
|
||||
showStripTab: title => `显示 ${title}`,
|
||||
hideStripTab: title => `隐藏 ${title}`,
|
||||
lastTabKeptTitle: '保留最后一个标签',
|
||||
@@ -3237,6 +3272,24 @@ export const zh: Translations = {
|
||||
branchNewChat: '在新对话中分支',
|
||||
react: '回应',
|
||||
dismissError: '关闭错误',
|
||||
errorLayers: {
|
||||
auth: '认证错误',
|
||||
billing: '额度不足',
|
||||
disk: '磁盘已满',
|
||||
endpoint: '自定义端点错误',
|
||||
gateway: '网关错误',
|
||||
generic: '本轮失败',
|
||||
provider: '模型服务商错误',
|
||||
runtime: '本地运行时错误',
|
||||
streaming: '流式连接错误'
|
||||
},
|
||||
errorRetry: '重试',
|
||||
errorSwitchProvider: '切换服务商',
|
||||
errorOpenLogs: '打开日志',
|
||||
errorOpenLogsFailed: '无法打开日志文件夹',
|
||||
errorOpenDesktopLogs: '打开桌面端日志',
|
||||
errorCopyDiagnostics: '复制错误详情',
|
||||
errorSendDiagnostics: '发送诊断信息',
|
||||
filesChanged: count => `${count} 个文件已更改`,
|
||||
reviewChanges: '查看',
|
||||
readAloudFailed: '朗读失败',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
import { type BillingBlock } from '@hermes/shared'
|
||||
|
||||
import type { ErrorSurface } from '@/lib/error-surface'
|
||||
import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes'
|
||||
|
||||
export interface TimelinePartMetadata {
|
||||
@@ -21,6 +22,10 @@ export type ChatMessage = {
|
||||
completedAt?: number
|
||||
pending?: boolean
|
||||
error?: string
|
||||
/** Structured layer descriptor for a failed turn (parsed error_surface).
|
||||
* Drives the error card's layer label + actions; absent on older
|
||||
* backends, where the card falls back to generic copy. */
|
||||
errorSurface?: ErrorSurface
|
||||
branchGroupId?: string
|
||||
hidden?: boolean
|
||||
/** Sealed mid-turn commentary (`message.interim`) — rendered without the
|
||||
@@ -59,6 +64,10 @@ export type GatewayEventPayload = {
|
||||
result?: unknown
|
||||
summary?: string
|
||||
error?: string | boolean
|
||||
// message.complete with status "error" — structured {layer, code, retryable}
|
||||
// descriptor naming which stack layer failed (agent/error_surface.py).
|
||||
// Absent on older gateways; consumers must fall back to string heuristics.
|
||||
error_surface?: unknown
|
||||
inline_diff?: string
|
||||
duration_s?: number
|
||||
todos?: unknown
|
||||
|
||||
@@ -484,6 +484,8 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
||||
...timelineMeta,
|
||||
...(message.completedAt !== undefined ? { timelineCompletedAt: message.completedAt } : {}),
|
||||
...(message.durationS !== undefined ? { durationS: message.durationS } : {}),
|
||||
// Structured failure layer for the error card (see lib/error-surface).
|
||||
...(message.errorSurface ? { errorSurface: message.errorSurface } : {}),
|
||||
...reactionMeta
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatErrorDiagnostics, parseErrorSurface } from './error-surface'
|
||||
|
||||
describe('parseErrorSurface', () => {
|
||||
it('accepts a valid descriptor', () => {
|
||||
expect(parseErrorSurface({ layer: 'streaming', code: 'stream_drop', retryable: true })).toEqual({
|
||||
layer: 'streaming',
|
||||
code: 'stream_drop',
|
||||
retryable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts every documented layer', () => {
|
||||
for (const layer of ['provider', 'endpoint', 'streaming', 'auth', 'billing', 'gateway', 'runtime', 'disk']) {
|
||||
expect(parseErrorSurface({ layer, code: 'x', retryable: false })?.layer).toBe(layer)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown layers and non-objects', () => {
|
||||
expect(parseErrorSurface({ layer: 'blockchain', code: 'x', retryable: true })).toBeNull()
|
||||
expect(parseErrorSurface('provider')).toBeNull()
|
||||
expect(parseErrorSurface(null)).toBeNull()
|
||||
expect(parseErrorSurface(undefined)).toBeNull()
|
||||
expect(parseErrorSurface(7)).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults code and retryable when missing', () => {
|
||||
expect(parseErrorSurface({ layer: 'gateway' })).toEqual({ layer: 'gateway', code: 'unknown', retryable: true })
|
||||
})
|
||||
|
||||
it('honors retryable=false', () => {
|
||||
expect(parseErrorSurface({ layer: 'auth', code: 'auth_permanent', retryable: false })?.retryable).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the failing session identity when present', () => {
|
||||
const surface = parseErrorSurface({
|
||||
layer: 'provider',
|
||||
code: 'rate_limit',
|
||||
retryable: true,
|
||||
provider: 'openrouter',
|
||||
model: 'test/m1'
|
||||
})
|
||||
|
||||
expect(surface?.provider).toBe('openrouter')
|
||||
expect(surface?.model).toBe('test/m1')
|
||||
// Absent identity yields no keys, not empty strings.
|
||||
expect(parseErrorSurface({ layer: 'provider', code: 'x', retryable: true })?.provider).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatErrorDiagnostics', () => {
|
||||
it('includes layer, code, model and error', () => {
|
||||
const text = formatErrorDiagnostics({
|
||||
errorText: 'boom',
|
||||
model: 'anthropic/claude-opus-4.6',
|
||||
surface: { layer: 'provider', code: 'rate_limit', retryable: true }
|
||||
})
|
||||
|
||||
expect(text).toContain('layer: provider')
|
||||
expect(text).toContain('code: rate_limit')
|
||||
expect(text).toContain('model: anthropic/claude-opus-4.6')
|
||||
expect(text).toContain('error: boom')
|
||||
})
|
||||
|
||||
it('prefers the descriptor identity over the caller fallback', () => {
|
||||
const text = formatErrorDiagnostics({
|
||||
errorText: 'boom',
|
||||
// Foreground composer atom — potentially stale by click time.
|
||||
model: 'some/other-model',
|
||||
surface: { layer: 'provider', code: 'rate_limit', retryable: true, provider: 'openrouter', model: 'failed/model' }
|
||||
})
|
||||
|
||||
expect(text).toContain('provider: openrouter')
|
||||
expect(text).toContain('model: failed/model')
|
||||
expect(text).not.toContain('some/other-model')
|
||||
})
|
||||
|
||||
it('omits absent fields without leaving blank lines', () => {
|
||||
const text = formatErrorDiagnostics({ errorText: 'boom' })
|
||||
|
||||
expect(text).not.toContain('layer:')
|
||||
expect(text).not.toContain('model:')
|
||||
expect(text.split('\n').every(line => line.trim().length > 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// Structured turn-error descriptor forwarded by the gateway (see
|
||||
// agent/error_surface.py). Names WHICH layer of the stack failed so the error
|
||||
// card can say "Provider error" / "Gateway error" and offer layer-appropriate
|
||||
// recovery actions, instead of toasting an opaque string.
|
||||
//
|
||||
// Advisory contract: older backends never send this — every consumer must
|
||||
// keep working when it is absent (legacy string-sniffing stays as fallback).
|
||||
|
||||
export const ERROR_SURFACE_LAYERS = [
|
||||
'provider',
|
||||
'endpoint',
|
||||
'streaming',
|
||||
'auth',
|
||||
'billing',
|
||||
'gateway',
|
||||
'runtime',
|
||||
'disk'
|
||||
] as const
|
||||
|
||||
export type ErrorSurfaceLayer = (typeof ERROR_SURFACE_LAYERS)[number]
|
||||
|
||||
export interface ErrorSurface {
|
||||
layer: ErrorSurfaceLayer
|
||||
/** Specific failure code (a FailoverReason value or site-specific code). */
|
||||
code: string
|
||||
/** False when retrying unchanged reproduces the same failure. */
|
||||
retryable: boolean
|
||||
/** The failing session's provider/model, captured at classification time —
|
||||
* preferred over the foreground composer's atoms, which can point at a
|
||||
* different model by the time the user clicks an action. */
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
/** Validate a wire payload into an ErrorSurface, or null when absent/garbled. */
|
||||
export function parseErrorSurface(value: unknown): ErrorSurface | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = value as { code?: unknown; layer?: unknown; model?: unknown; provider?: unknown; retryable?: unknown }
|
||||
const layer = typeof raw.layer === 'string' ? (raw.layer as ErrorSurfaceLayer) : null
|
||||
|
||||
if (!layer || !ERROR_SURFACE_LAYERS.includes(layer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
layer,
|
||||
code: typeof raw.code === 'string' && raw.code ? raw.code : 'unknown',
|
||||
retryable: raw.retryable !== false,
|
||||
...(typeof raw.provider === 'string' && raw.provider ? { provider: raw.provider } : {}),
|
||||
...(typeof raw.model === 'string' && raw.model ? { model: raw.model } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-text error-details blob for the error card's "Copy error details". */
|
||||
export function formatErrorDiagnostics(input: {
|
||||
appVersion?: string
|
||||
errorText: string
|
||||
model?: string
|
||||
provider?: string
|
||||
surface?: ErrorSurface | null
|
||||
}): string {
|
||||
// The descriptor's identity (captured when the turn failed) beats the
|
||||
// caller-supplied fallback (typically the foreground composer's atoms).
|
||||
const provider = input.surface?.provider || input.provider
|
||||
const model = input.surface?.model || input.model
|
||||
|
||||
const lines = [
|
||||
'── Hermes error details ──',
|
||||
`time: ${new Date().toISOString()}`,
|
||||
input.surface ? `layer: ${input.surface.layer}` : null,
|
||||
input.surface ? `code: ${input.surface.code}` : null,
|
||||
input.surface ? `retryable: ${input.surface.retryable}` : null,
|
||||
provider ? `provider: ${provider}` : null,
|
||||
model ? `model: ${model}` : null,
|
||||
input.appVersion ? `app: ${input.appVersion}` : null,
|
||||
`error: ${input.errorText}`
|
||||
]
|
||||
|
||||
return lines.filter((line): line is string => Boolean(line)).join('\n')
|
||||
}
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
IconPalette as Palette,
|
||||
IconLayoutBottombar as PanelBottom,
|
||||
IconLayoutSidebar as PanelLeftIcon,
|
||||
IconLayoutNavbar as PanelTop,
|
||||
IconPlayerPause as Pause,
|
||||
IconPaw as PawPrint,
|
||||
IconPencil as Pencil,
|
||||
@@ -215,6 +216,7 @@ export {
|
||||
Palette,
|
||||
PanelBottom,
|
||||
PanelLeftIcon,
|
||||
PanelTop,
|
||||
Pause,
|
||||
PawPrint,
|
||||
Pencil,
|
||||
|
||||
@@ -122,6 +122,12 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||
// gap in their View family) and Hermes has no chord dispatcher, so this
|
||||
// takes the nearest free single combo instead of a ⌘K ⌘S two-stroke.
|
||||
{ id: 'view.toggleStatusbar', category: 'view', defaults: ['mod+shift+s'] },
|
||||
// ⌥⌘T — "t" for tabs, reaching past ⇧ because ⌘⇧T is reopen-closed-tab
|
||||
// everywhere. Ships BOUND, unlike VS Code's settings-only tab-bar switch:
|
||||
// here the hide can take away every other affordance the zone had, so the
|
||||
// way back has to already exist. (⌥+letter emits a symbol on macOS; the
|
||||
// binding resolves through KeyT via comboFromEvent's `event.code` fallback.)
|
||||
{ id: 'view.toggleTabStrip', category: 'view', defaults: ['mod+alt+t'] },
|
||||
// ⌘G — "g" for git; the review pane is the source-control view.
|
||||
{ id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] },
|
||||
{ id: 'view.showFiles', category: 'view', defaults: [] },
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
* web/src/lib/model-search-text.ts, and hermes_cli/model_search.py.
|
||||
*/
|
||||
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
|
||||
k3: ['kimi-k3', 'kimi']
|
||||
k3: ['kimi-k3', 'kimi'],
|
||||
// OpenCode Zen serves the "Ox Alpha" stealth model under an opaque
|
||||
// preview slug; let users find it by its public codename.
|
||||
'x-preview-f-free': ['ox-alpha', 'ox']
|
||||
}
|
||||
|
||||
/** Haystack for fuzzy/substring model search; never changes the wire id. */
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
installSelectionCopyColorGuard,
|
||||
selectionInkLuma,
|
||||
serializeSelectionStructure,
|
||||
textColorLuma
|
||||
} from './selection-copy-colors'
|
||||
|
||||
function makeCopyEvent(): {
|
||||
event: ClipboardEvent
|
||||
setData: ReturnType<typeof vi.fn>
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const event = new Event('copy', { bubbles: true, cancelable: true }) as ClipboardEvent
|
||||
const setData = vi.fn()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
Object.defineProperty(event, 'clipboardData', { value: { setData } })
|
||||
Object.defineProperty(event, 'preventDefault', { value: preventDefault })
|
||||
|
||||
return { event, setData, preventDefault }
|
||||
}
|
||||
|
||||
/** Stage styled text and arm a real DOM selection over it. */
|
||||
function armSelection(html: string): HTMLDivElement {
|
||||
const host = document.createElement('div')
|
||||
|
||||
host.innerHTML = html
|
||||
document.body.append(host)
|
||||
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(host)
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
describe('textColorLuma', () => {
|
||||
it('scores white near the top of the range', () => {
|
||||
expect(textColorLuma('rgb(255, 255, 255)')).toBeGreaterThan(240)
|
||||
})
|
||||
|
||||
it('scores black near the bottom of the range', () => {
|
||||
expect(textColorLuma('rgb(10, 10, 10)')).toBeLessThan(20)
|
||||
})
|
||||
|
||||
it('composites partial alpha over mid-gray', () => {
|
||||
// White at 50% alpha lands halfway between white and gray.
|
||||
const halfWhite = textColorLuma('rgba(255, 255, 255, 0.5)')
|
||||
|
||||
expect(halfWhite).not.toBeNull()
|
||||
expect(halfWhite!).toBeGreaterThan(185)
|
||||
expect(halfWhite!).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('treats fully transparent paint as invisible', () => {
|
||||
expect(textColorLuma('rgba(255, 255, 255, 0)')).toBeNull()
|
||||
expect(textColorLuma('rgba(255, 255, 255, 0.03)')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unparsable colors', () => {
|
||||
expect(textColorLuma('var(--foreground)')).toBeNull()
|
||||
expect(textColorLuma('inherit')).toBeNull()
|
||||
expect(textColorLuma('')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses the color(srgb …) computed form Chromium serializes', () => {
|
||||
// The app's dark-theme ink: color-mix(in srgb, #e6edf3 94%, transparent)
|
||||
// computes to exactly this serialization.
|
||||
const luma = textColorLuma('color(srgb 0.901961 0.929412 0.952941 / 0.94)')
|
||||
|
||||
expect(luma).not.toBeNull()
|
||||
expect(luma!).toBeGreaterThan(220)
|
||||
})
|
||||
|
||||
it('parses color(srgb …) with percentage components and alpha', () => {
|
||||
const luma = textColorLuma('color(srgb 100% 100% 100% / 50%)')
|
||||
|
||||
expect(luma).not.toBeNull()
|
||||
expect(luma!).toBeCloseTo(191.5, 0)
|
||||
})
|
||||
|
||||
it('parses hex colors including alpha', () => {
|
||||
expect(textColorLuma('#ffffff')).toBeGreaterThan(240)
|
||||
expect(textColorLuma('#111111')).toBeLessThan(20)
|
||||
expect(textColorLuma('#ffffff80')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectionInkLuma', () => {
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
})
|
||||
|
||||
it('scores the live computed ink of the selected text', () => {
|
||||
const host = armSelection('<p style="color: rgb(230, 237, 243)">bright transcript ink</p>')
|
||||
const luma = selectionInkLuma(window.getSelection()!, document)
|
||||
|
||||
expect(luma).not.toBeNull()
|
||||
expect(luma!).toBeGreaterThan(220)
|
||||
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('scores dark ink as dark', () => {
|
||||
const host = armSelection('<p style="color: rgb(17, 17, 17)">dim transcript ink</p>')
|
||||
const luma = selectionInkLuma(window.getSelection()!, document)
|
||||
|
||||
expect(luma).not.toBeNull()
|
||||
expect(luma!).toBeLessThan(20)
|
||||
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('returns null for a collapsed selection', () => {
|
||||
const host = armSelection('<p style="color: rgb(255, 255, 255)">text</p>')
|
||||
|
||||
window.getSelection()?.collapseToEnd()
|
||||
|
||||
expect(selectionInkLuma(window.getSelection()!, document)).toBeNull()
|
||||
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeSelectionStructure', () => {
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
})
|
||||
|
||||
it('keeps semantic structure and hrefs while dropping paint and classes', () => {
|
||||
const host = armSelection(
|
||||
'<p class="aui-md" style="color: rgb(230, 237, 243)">see <a href="https://example.com">the doc</a> and <strong>this</strong></p>'
|
||||
)
|
||||
|
||||
const html = serializeSelectionStructure(window.getSelection()!, document)
|
||||
|
||||
expect(html).toContain('<strong>this</strong>')
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
expect(html).toContain('the doc')
|
||||
// The only style attribute allowed is the wrapper's generic font anchor.
|
||||
expect(html.replace('<div style="font-family: sans-serif;">', '')).not.toContain('style=')
|
||||
expect(html).not.toContain('class=')
|
||||
expect(html).not.toContain('rgb(230, 237, 243)')
|
||||
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('keeps list and code structure', () => {
|
||||
const host = armSelection('<ul><li>alpha</li><li><em>beta</em></li></ul><pre>code line</pre>')
|
||||
const html = serializeSelectionStructure(window.getSelection()!, document)
|
||||
|
||||
expect(html).toContain('<li>alpha</li>')
|
||||
expect(html).toContain('<em>beta</em>')
|
||||
expect(html).toContain('code line')
|
||||
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('anchors a generic sans family so rich-text receivers keep their own font', () => {
|
||||
const host = armSelection(
|
||||
'<p style="color: rgb(230, 237, 243); font-family: -apple-system, sans-serif">plain prose</p>'
|
||||
)
|
||||
|
||||
const html = serializeSelectionStructure(window.getSelection()!, document)
|
||||
|
||||
// Generic family on the wrapper: resolves to each platform's own face,
|
||||
// never the Times browser default, and carries no vendor font names.
|
||||
expect(html).toContain('sans-serif')
|
||||
expect(html).not.toContain('-apple-system')
|
||||
expect(html).not.toContain('color')
|
||||
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('pins monospace inside code elements', () => {
|
||||
const host = armSelection('<pre><code>npm run build</code></pre>')
|
||||
const html = serializeSelectionStructure(window.getSelection()!, document)
|
||||
|
||||
expect(html).toContain('monospace')
|
||||
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installSelectionCopyColorGuard', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.dataset.hermesMode = 'dark'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.hermesMode
|
||||
window.getSelection()?.removeAllRanges()
|
||||
})
|
||||
|
||||
it('owns the payload when a light-ink selection is copied under a dark theme', () => {
|
||||
const dispose = installSelectionCopyColorGuard(document)
|
||||
const host = armSelection('<p style="color: rgb(230, 237, 243)">bright transcript ink</p>')
|
||||
|
||||
try {
|
||||
const { event, setData, preventDefault } = makeCopyEvent()
|
||||
|
||||
document.body.dispatchEvent(event)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
|
||||
const plainCall = setData.mock.calls.find(([type]) => type === 'text/plain')
|
||||
const htmlCall = setData.mock.calls.find(([type]) => type === 'text/html')
|
||||
|
||||
expect(plainCall?.[1]).toContain('bright transcript ink')
|
||||
expect(htmlCall?.[1]).toContain('bright transcript ink')
|
||||
expect(htmlCall?.[1]).not.toContain('rgb(230, 237, 243)')
|
||||
} finally {
|
||||
dispose()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves dark-ink selections to Chromium under a dark theme', () => {
|
||||
const dispose = installSelectionCopyColorGuard(document)
|
||||
const host = armSelection('<p style="color: rgb(17, 17, 17)">dim transcript ink</p>')
|
||||
|
||||
try {
|
||||
const { event, setData, preventDefault } = makeCopyEvent()
|
||||
|
||||
document.body.dispatchEvent(event)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(setData).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves light-ink selections alone when the theme is light', () => {
|
||||
document.documentElement.dataset.hermesMode = 'light'
|
||||
|
||||
const dispose = installSelectionCopyColorGuard(document)
|
||||
const host = armSelection('<p style="color: rgb(230, 237, 243)">bright transcript ink</p>')
|
||||
|
||||
try {
|
||||
const { event, setData, preventDefault } = makeCopyEvent()
|
||||
|
||||
document.body.dispatchEvent(event)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(setData).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips selections that start inside an editable field', () => {
|
||||
const dispose = installSelectionCopyColorGuard(document)
|
||||
const host = document.createElement('div')
|
||||
|
||||
host.setAttribute('contenteditable', 'true')
|
||||
host.innerHTML = '<span style="color: rgb(230, 237, 243)">composer text</span>'
|
||||
document.body.append(host)
|
||||
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(host)
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
|
||||
try {
|
||||
const { event, setData, preventDefault } = makeCopyEvent()
|
||||
|
||||
document.body.dispatchEvent(event)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(setData).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops intercepting after disposal', () => {
|
||||
const dispose = installSelectionCopyColorGuard(document)
|
||||
const host = armSelection('<p style="color: rgb(230, 237, 243)">bright transcript ink</p>')
|
||||
|
||||
dispose()
|
||||
|
||||
try {
|
||||
const { event, setData, preventDefault } = makeCopyEvent()
|
||||
|
||||
document.body.dispatchEvent(event)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(setData).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
// Chromium's native selection copy (Cmd+C, right-click Copy) serializes the
|
||||
// selection as `text/html` with every element's COMPUTED paint inlined as
|
||||
// style attributes. Copied from a dark theme, body text lands on the
|
||||
// clipboard near-white; pasted into a light-background target (an email, a
|
||||
// shared doc) it is invisible.
|
||||
//
|
||||
// The serializer only runs AFTER `copy` handlers decline to intercept —
|
||||
// inside the handler, `clipboardData` reads back empty — so the guard cannot
|
||||
// inspect or patch Chromium's payload. Instead it decides from the LIVE DOM:
|
||||
// it scores the ink that paints the current selection against the theme
|
||||
// Hermes is rendering, and when the two are opposite schemes it takes over
|
||||
// the clipboard write entirely, emitting `text/plain` plus a tag-structured
|
||||
// `text/html` with no paint declarations. Structure — headings, lists,
|
||||
// tables, links, bold/italic, code layout — survives; colors come from the
|
||||
// paste target's own defaults. Same-scheme selections are left to Chromium's
|
||||
// default copy untouched.
|
||||
|
||||
type RenderedMode = 'light' | 'dark'
|
||||
|
||||
/** At or above this perceived luma, text reads as "light" (dark-theme ink). */
|
||||
const LIGHT_TEXT_LUMA_MIN = 150
|
||||
/** At or below this perceived luma, text reads as "dark" (light-theme ink). */
|
||||
const DARK_TEXT_LUMA_MAX = 105
|
||||
|
||||
/** Upper bound on text nodes probed per copy; long selections stay O(50). */
|
||||
const MAX_PROBE_NODES = 50
|
||||
|
||||
const RGB_FN_RE = /rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*(?:[/,]\s*([\d.%]+)\s*)?\)/i
|
||||
const SRGB_FN_RE = /^color\(\s*srgb\s+([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)(?:\s*[/\s]+\s*([\d.%]+))?\s*\)$/i
|
||||
const HEX_RE = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
||||
|
||||
/** Parse one component: percentage or 0–255 integer. */
|
||||
const channelValue = (raw: string): number => {
|
||||
const v = raw.trim()
|
||||
|
||||
return v.endsWith('%') ? (Number.parseFloat(v) / 100) * 255 : Number.parseFloat(v)
|
||||
}
|
||||
|
||||
/** color(srgb …) components are 0–1 floats (or percentages), not 0–255. */
|
||||
const srgbChannelValue = (raw: string): number => {
|
||||
const v = raw.trim()
|
||||
|
||||
return v.endsWith('%') ? (Number.parseFloat(v) / 100) * 255 : Number.parseFloat(v) * 255
|
||||
}
|
||||
|
||||
const alphaValue = (raw: string): number => (raw.endsWith('%') ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw))
|
||||
|
||||
function expandHex(hex: string): [number, number, number, number] | null {
|
||||
const h = hex.slice(1)
|
||||
const wide = h.length >= 6
|
||||
|
||||
const pick = (i: number) => Number.parseInt(wide ? h.slice(i, i + 2) : h[i]! + h[i]!, 16)
|
||||
|
||||
const r = pick(0)
|
||||
const g = pick(1 * (wide ? 2 : 1))
|
||||
const b = pick(2 * (wide ? 2 : 1))
|
||||
|
||||
if ([r, g, b].some(Number.isNaN)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const aRaw = wide ? h.slice(6, 8) : h[3]
|
||||
|
||||
return [r, g, b, aRaw ? Number.parseInt(aRaw, 16) / 255 : 1]
|
||||
}
|
||||
|
||||
function parseCssColor(cssColor: string): { r: number; g: number; b: number; alpha: number } | null {
|
||||
const value = cssColor.trim()
|
||||
|
||||
const rgb = value.match(RGB_FN_RE)
|
||||
|
||||
if (rgb) {
|
||||
return {
|
||||
r: Number(rgb[1]),
|
||||
g: Number(rgb[2]),
|
||||
b: Number(rgb[3]),
|
||||
alpha: rgb[4] ? alphaValue(rgb[4]) : 1
|
||||
}
|
||||
}
|
||||
|
||||
const srgb = value.match(SRGB_FN_RE)
|
||||
|
||||
if (srgb) {
|
||||
return {
|
||||
r: srgbChannelValue(srgb[1] ?? ''),
|
||||
g: srgbChannelValue(srgb[2] ?? ''),
|
||||
b: srgbChannelValue(srgb[3] ?? ''),
|
||||
alpha: srgb[4] ? alphaValue(srgb[4]) : 1
|
||||
}
|
||||
}
|
||||
|
||||
if (HEX_RE.test(value)) {
|
||||
const expanded = expandHex(value)
|
||||
|
||||
if (expanded) {
|
||||
return { r: expanded[0], g: expanded[1], b: expanded[2], alpha: expanded[3] }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Perceived luma (0–255, ITU-R BT.601 weighting) of a CSS color, with alpha
|
||||
* composited over mid-gray so partial opacity neither overstates nor hides
|
||||
* the paint. Accepts the serialization forms Chromium computes for the app's
|
||||
* tokens: rgb()/rgba(), color(srgb …) (the computed form of modern functions
|
||||
* like color-mix()), and hex. Returns null for anything else (keywords,
|
||||
* variables, non-sRGB spaces) or effectively invisible paint (alpha ≤ 0.05).
|
||||
*/
|
||||
export function textColorLuma(cssColor: string): number | null {
|
||||
const parsed = parseCssColor(cssColor)
|
||||
|
||||
if (!parsed || parsed.alpha <= 0.05) {
|
||||
return null
|
||||
}
|
||||
|
||||
const composite = (channel: number) => channel * parsed.alpha + 128 * (1 - parsed.alpha)
|
||||
|
||||
return 0.2126 * composite(parsed.r) + 0.7152 * composite(parsed.g) + 0.0722 * composite(parsed.b)
|
||||
}
|
||||
|
||||
function isOppositeScheme(textLuma: number, mode: RenderedMode): boolean {
|
||||
return mode === 'dark' ? textLuma >= LIGHT_TEXT_LUMA_MIN : textLuma <= DARK_TEXT_LUMA_MAX
|
||||
}
|
||||
|
||||
function renderedMode(doc: Document): RenderedMode {
|
||||
const attr = doc.documentElement.dataset.hermesMode
|
||||
|
||||
if (attr === 'light' || attr === 'dark') {
|
||||
return attr
|
||||
}
|
||||
|
||||
// Boot frame or a host that skipped theme application: fall back to the OS
|
||||
// preference, which is what `system` mode resolves to anyway.
|
||||
try {
|
||||
return doc.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
} catch {
|
||||
return 'light'
|
||||
}
|
||||
}
|
||||
|
||||
function rangeContainsNode(range: Range, node: Node): boolean {
|
||||
try {
|
||||
return range.intersectsNode(node)
|
||||
} catch {
|
||||
// Environments without intersectsNode: fall back to a boundary compare.
|
||||
try {
|
||||
range.comparePoint(node, 0)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean perceived luma of the ink that paints the current selection, read
|
||||
* from the LIVE DOM: the computed color (preferring -webkit-text-fill-color
|
||||
* when set) of each selected text node's parent. Returns null when the
|
||||
* selection holds no scoreable ink.
|
||||
*/
|
||||
export function selectionInkLuma(sel: Selection, doc: Document): number | null {
|
||||
const view = doc.defaultView
|
||||
|
||||
if (!view) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lumas: number[] = []
|
||||
const seen = new Set<Element>()
|
||||
|
||||
for (let i = 0; i < sel.rangeCount && lumas.length < MAX_PROBE_NODES; i++) {
|
||||
const range = sel.getRangeAt(i)
|
||||
const root = range.commonAncestorContainer
|
||||
|
||||
const walker = doc.createTreeWalker(
|
||||
root.nodeType === 1 ? root : (root.parentElement ?? doc.body),
|
||||
NodeFilter.SHOW_TEXT
|
||||
)
|
||||
|
||||
let node = walker.nextNode()
|
||||
|
||||
while (node && lumas.length < MAX_PROBE_NODES) {
|
||||
if (rangeContainsNode(range, node)) {
|
||||
const el = node.parentElement
|
||||
|
||||
if (el && !seen.has(el)) {
|
||||
seen.add(el)
|
||||
|
||||
const style = view.getComputedStyle(el)
|
||||
const fill = style.getPropertyValue('-webkit-text-fill-color')
|
||||
const ink = textColorLuma(fill) ?? textColorLuma(style.color)
|
||||
|
||||
if (ink !== null) {
|
||||
lumas.push(ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node = walker.nextNode()
|
||||
}
|
||||
}
|
||||
|
||||
if (lumas.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return lumas.reduce((sum, l) => sum + l, 0) / lumas.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the selection as tag-structured HTML with no paint and no
|
||||
* app-internal attributes: semantic elements (p, strong, em, a, ul, pre,
|
||||
* table, …) survive with their content and hrefs; style/class attributes —
|
||||
* the only carriers of Hermes' palette — are dropped so the paste target's
|
||||
* own color defaults apply.
|
||||
*
|
||||
* One exception to the strip-everything rule: the result carries a GENERIC
|
||||
* font-family (`sans-serif`, `monospace` inside code). With no family at
|
||||
* all, receivers that convert HTML to rich text (macOS Mail, Notes,
|
||||
* TextEdit) fall back to the BROWSER default — Times — instead of their own
|
||||
* compose font. Naming the generic family keeps the paste sans like the app
|
||||
* renders it, while each platform resolves it to its own system face.
|
||||
*/
|
||||
export function serializeSelectionStructure(sel: Selection, doc: Document): string {
|
||||
const container = doc.createElement('div')
|
||||
|
||||
for (let i = 0; i < sel.rangeCount; i++) {
|
||||
container.append(sel.getRangeAt(i).cloneContents())
|
||||
}
|
||||
|
||||
for (const el of container.querySelectorAll('[style]')) {
|
||||
el.removeAttribute('style')
|
||||
}
|
||||
|
||||
for (const el of container.querySelectorAll('[class]')) {
|
||||
el.removeAttribute('class')
|
||||
}
|
||||
|
||||
const wrapper = doc.createElement('div')
|
||||
|
||||
wrapper.style.fontFamily = 'sans-serif'
|
||||
|
||||
while (container.firstChild) {
|
||||
wrapper.append(container.firstChild)
|
||||
}
|
||||
|
||||
for (const el of wrapper.querySelectorAll<HTMLElement>('pre, code')) {
|
||||
el.style.fontFamily = 'monospace'
|
||||
}
|
||||
|
||||
// outerHTML, not innerHTML: the styled wrapper IS the font anchor.
|
||||
return wrapper.outerHTML
|
||||
}
|
||||
|
||||
function selectionStartsInEditable(sel: Selection): boolean {
|
||||
const anchor = sel.anchorNode
|
||||
|
||||
if (!anchor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const el = anchor.nodeType === 1 ? (anchor as Element) : anchor.parentElement
|
||||
|
||||
return Boolean(el?.closest('input, textarea, [contenteditable="true"], [contenteditable=""]'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the document-level `copy` interceptor. Returns a dispose function.
|
||||
*
|
||||
* Runs in the CAPTURE phase so inner handlers cannot run first. Only an
|
||||
* off-scheme selection is intercepted (payload owned and rewritten); every
|
||||
* other copy — same-scheme, editable-field, empty — passes through to
|
||||
* Chromium's default untouched.
|
||||
*/
|
||||
export function installSelectionCopyColorGuard(doc: Document = document): () => void {
|
||||
const trace = (entry: Record<string, unknown>) => {
|
||||
if (import.meta.env.DEV) {
|
||||
const view = doc.defaultView as (Window & { __copyGuardLog?: unknown[] }) | null
|
||||
|
||||
if (view) {
|
||||
;(view.__copyGuardLog ??= []).push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onCopy = (event: ClipboardEvent) => {
|
||||
const sel = doc.getSelection()
|
||||
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectionStartsInEditable(sel)) {
|
||||
trace({ step: 'editable-skip' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const mode = renderedMode(doc)
|
||||
const inkLuma = selectionInkLuma(sel, doc)
|
||||
|
||||
if (inkLuma === null || !isOppositeScheme(inkLuma, mode)) {
|
||||
trace({ step: inkLuma === null ? 'no-ink' : 'same-scheme', inkLuma, mode })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const clipboard = event.clipboardData
|
||||
|
||||
if (!clipboard) {
|
||||
return
|
||||
}
|
||||
|
||||
const plain = sel.toString()
|
||||
const html = serializeSelectionStructure(sel, doc)
|
||||
|
||||
// Owning the payload is the only way to change it: Chromium's own
|
||||
// serialization is produced after handlers decline, and preventDefault
|
||||
// is required for setData writes to stick.
|
||||
event.preventDefault()
|
||||
clipboard.setData('text/plain', plain)
|
||||
clipboard.setData('text/html', html)
|
||||
trace({ step: 'owned-payload', mode, inkLuma, plainChars: plain.length, htmlChars: html.length })
|
||||
}
|
||||
|
||||
doc.addEventListener('copy', onCopy, true)
|
||||
|
||||
return () => doc.removeEventListener('copy', onCopy, true)
|
||||
}
|
||||
@@ -26,9 +26,14 @@ import { I18nProvider } from './i18n'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { queryClient } from './lib/query-client'
|
||||
import { installRendererAnimationPauseState } from './lib/renderer-loop-pause'
|
||||
import { installSelectionCopyColorGuard } from './lib/selection-copy-colors'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
|
||||
installClipboardShim()
|
||||
// Chromium serializes selection copies (Cmd+C, right-click Copy) with the
|
||||
// theme's computed colors inlined; without this guard a dark-theme selection
|
||||
// pastes as near-white text into light-background targets.
|
||||
installSelectionCopyColorGuard()
|
||||
|
||||
// The perf probe ships in dev, and in a production build ONLY when explicitly
|
||||
// opted in (VITE_PERF_PROBE=1) — this lets the perf harness measure a real,
|
||||
|
||||
@@ -1276,57 +1276,22 @@ function fallbackSelectionAfterHide(name) {
|
||||
|
||||
/** One-time reconciliation: Bot Mode sessions are always hidden, but rooms
|
||||
* and Bot Chats created before this policy (or while the old pref was off)
|
||||
* left visible rows behind. On every plugin load, sweep every session id we
|
||||
* own — canonical chats from bot meta plus each group room's member
|
||||
* sessions — through the core session.set_hidden RPC, then run the
|
||||
* ownership-based sweep for the rows we DON'T know by id. Idempotent (the DB
|
||||
* setter is a no-op on already-hidden rows) and feature-detected: older
|
||||
* gateways lack session.set_hidden and simply keep the rows visible. */
|
||||
* left visible rows behind. On every plugin load, sweep the session ids we
|
||||
* own by id (each group room's member sessions) through the core
|
||||
* session.set_hidden RPC, then run the TITLE-based ownership sweep for
|
||||
* everything else — canonical Bot Chats are identified by name (the
|
||||
* registry row titled "Bot Chat"), so the title sweep is what hides them;
|
||||
* no stored-id pointer is consulted. Idempotent (the DB setter is a no-op
|
||||
* on already-hidden rows) and feature-detected: older gateways lack
|
||||
* session.set_hidden and simply keep the rows visible. */
|
||||
function hideOwnedBotSessions() {
|
||||
const canonical = Object.entries($botMeta.get())
|
||||
.map(([name, meta]) => ({ name, id: meta && meta.chat }))
|
||||
.filter(entry => Boolean(entry.id))
|
||||
const rooms = Object.values($groupChats.get())
|
||||
.flatMap(room => Object.values(room?.sessions || {}))
|
||||
.filter(sid => Boolean(sid) && sid !== true)
|
||||
|
||||
// A stale local/server pointer must not be trusted merely because it looks
|
||||
// like a session id. Resolve every canonical pointer through the backend and
|
||||
// require the canonical Bot Chat title before the hide write. This is
|
||||
// deliberately fail-closed: an unavailable/old gateway may leave an old
|
||||
// Bot Chat visible, but it must never hide an unrelated user conversation.
|
||||
const verifiedCanonical = Promise.resolve()
|
||||
.then(() =>
|
||||
host.request('profiles.list', {
|
||||
include_sessions: true,
|
||||
preferred_session_ids: Object.fromEntries(canonical.map(entry => [entry.name, entry.id]))
|
||||
})
|
||||
)
|
||||
.then(res => {
|
||||
const profiles = Array.isArray(res?.profiles) ? res.profiles : []
|
||||
const valid = []
|
||||
|
||||
for (const entry of canonical) {
|
||||
const profile = profiles.find(item => item?.name === entry.name)
|
||||
const preferred = profile?.preferred_session
|
||||
const ids = [preferred?.id, preferred?.resolved_id, preferred?.session_id, preferred?.session_key]
|
||||
.filter(Boolean)
|
||||
.map(String)
|
||||
|
||||
if (String(preferred?.title || '').trim() === 'Bot Chat' && ids.includes(String(entry.id))) {
|
||||
valid.push(entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
.catch(() => [])
|
||||
|
||||
const known = verifiedCanonical.then(validCanonical =>
|
||||
Promise.all(
|
||||
[...new Set([...validCanonical, ...rooms])].map(sid =>
|
||||
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
|
||||
)
|
||||
const known = Promise.all(
|
||||
[...new Set(rooms)].map(sid =>
|
||||
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1573,15 +1538,10 @@ function mergeServerMeta(roster, fetchedAt = 0) {
|
||||
merged.image = mine.image
|
||||
}
|
||||
|
||||
// Server metadata is authoritative for the canonical chat pointer.
|
||||
// Without this deletion sync, ctx.storage resurrects stale sessions
|
||||
// after the server pin is cleared and even after a full app restart.
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(mine, 'chat') &&
|
||||
!Object.prototype.hasOwnProperty.call(server, 'chat')
|
||||
) {
|
||||
delete merged.chat
|
||||
}
|
||||
// Legacy canonical-chat pointers (meta.chat) are dead: identity is the
|
||||
// profile's "Bot Chat" registry row, resolved by name. Drop the key on
|
||||
// sight so old ui_meta can never look meaningful again.
|
||||
delete merged.chat
|
||||
|
||||
// Canonical multi-group metadata is authoritative for the compatibility
|
||||
// scalar too. A server-side `group: null` is represented by omission,
|
||||
@@ -3592,21 +3552,6 @@ function PetTab({ image, onImage }) {
|
||||
* Gates every SOUL.md protocol append below. */
|
||||
let serverInjectsProtocol = false
|
||||
|
||||
/** Pins to resolve precisely on the next roster poll: {profile: chatId}.
|
||||
* The backend answers "what about THIS conversation" per entry
|
||||
* (preferred_session), so a row's preview can describe the same session its
|
||||
* click opens (hermes-agent#88200). Unknown params are ignored by older
|
||||
* gateways, which simply omit the field. */
|
||||
function preferredSessionIds(allMeta) {
|
||||
const pins = {}
|
||||
for (const [name, meta] of Object.entries(allMeta || {})) {
|
||||
if (meta?.chat) {
|
||||
pins[name] = meta.chat
|
||||
}
|
||||
}
|
||||
return pins
|
||||
}
|
||||
|
||||
function useRoster() {
|
||||
const activeConnectionId = useValue(host.state.connectionId)
|
||||
|
||||
@@ -3618,13 +3563,11 @@ function useRoster() {
|
||||
// a write can only carry pre-write ui_meta. (Issue time is the
|
||||
// conservative bound — the server answered no earlier than this.)
|
||||
const issuedAt = Date.now()
|
||||
// Rich rows (last_session, ui_meta, has_avatar) come from the ACTIVE
|
||||
// gateway's profiles.list — unchanged single-source behavior.
|
||||
const pins = preferredSessionIds($botMeta.get())
|
||||
const local = await host.request(
|
||||
'profiles.list',
|
||||
Object.keys(pins).length ? { preferred_session_ids: pins } : {}
|
||||
)
|
||||
// Rich rows (last_session, canonical_session, ui_meta, has_avatar)
|
||||
// come from the ACTIVE gateway's profiles.list — the canonical Bot
|
||||
// Chat is resolved server-side by NAME (the "Bot Chat" registry row),
|
||||
// so the roster never sends session pointers.
|
||||
const local = await host.request('profiles.list', {})
|
||||
// Newer backends inject the teammate-messaging protocol into every
|
||||
// session's system prompt (agent.bot_mode_protocol) — SOUL.md must not
|
||||
// carry a second copy. Older gateways lack the flag: keep appending.
|
||||
@@ -4016,171 +3959,6 @@ function resolveRosterMentions(text, roster, active = {}) {
|
||||
return mentioned
|
||||
}
|
||||
|
||||
const REMOTE_DM_TIMEOUT_MS = 180000
|
||||
const REMOTE_DM_POLL_MS = 2000
|
||||
|
||||
/** The remote bot's canonical Bot Chat: pinned stored-id from its profile's
|
||||
* ui_meta first, then resume-by-title, then create. Mirrors
|
||||
* ensureGroupChatSession so DMs land in the ONE forever-chat instead of
|
||||
* minting a fresh "Bot Chat" per mention. */
|
||||
async function ensureRemoteCanonicalChat(route, profile) {
|
||||
let pinned = null
|
||||
|
||||
try {
|
||||
const listed = await host.requestProfile(route, 'profiles.list', {})
|
||||
const owner = listed?.profiles?.find(p => p.name === profile)
|
||||
pinned = owner?.ui_meta?.['hermes-bots']?.chat || null
|
||||
} catch {
|
||||
/* older remote gateway — title lookup below still works */
|
||||
}
|
||||
|
||||
for (const target of [pinned, 'Bot Chat']) {
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await host.requestProfile(route, 'session.resume', {
|
||||
session_id: target,
|
||||
profile,
|
||||
omit_messages: true
|
||||
})
|
||||
|
||||
if (res?.session_id) {
|
||||
return { runtime: res.session_id, stored: res.session_key || pinned }
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const created = await host.requestProfile(route, 'session.create', {
|
||||
profile,
|
||||
title: 'Bot Chat',
|
||||
// Bot Mode sessions are always hidden from the global sidebar.
|
||||
hidden: true
|
||||
})
|
||||
|
||||
return { runtime: created?.session_id || null, stored: created?.stored_session_id || null }
|
||||
}
|
||||
|
||||
/** Bounded reply poll on the recipient's session — same shape as a group
|
||||
* member turn: wait for a NEW assistant message after `before`, or time out. */
|
||||
async function pollRemoteDmReply(route, profile, sessionRef, before) {
|
||||
const deadline = Date.now() + REMOTE_DM_TIMEOUT_MS
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, REMOTE_DM_POLL_MS))
|
||||
|
||||
let state = null
|
||||
|
||||
try {
|
||||
state = await host.requestProfile(route, 'session.resume', { session_id: sessionRef, profile })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const messages = Array.isArray(state?.messages) ? state.messages : []
|
||||
const done = !state?.inflight && !state?.running
|
||||
|
||||
if (messages.length > before && done) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
|
||||
if (msg?.role === 'assistant') {
|
||||
const text = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
|
||||
: msg?.text || ''
|
||||
|
||||
return String(text).trim() || null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Deliver a user mention to bots on OTHER connections: into each bot's
|
||||
* canonical Bot Chat, with the standard sender-attribution prefix (so the
|
||||
* recipient's messaging protocol recognizes an agent-to-agent message), then
|
||||
* relay the reply back as a notification. Sequential and fire-and-forget
|
||||
* from the composer's perspective. */
|
||||
async function deliverRemoteRosterMentions(bots, userText, sender) {
|
||||
const text = String(userText || '').trim()
|
||||
|
||||
if (!text || typeof host.requestProfile !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const senderName = String(sender?.name || 'the user').trim()
|
||||
const senderHandle = String(sender?.handle || senderName).trim()
|
||||
|
||||
for (const bot of bots) {
|
||||
const connectionId = String(bot?.connectionId || '').trim()
|
||||
const profile = String(bot?.name || '').trim() || 'default'
|
||||
|
||||
if (!connectionId || connectionId === 'local') {
|
||||
continue
|
||||
}
|
||||
|
||||
const route = { connectionId, mode: 'remote', profile, targetProfile: profile }
|
||||
const label = bot.connectionLabel || connectionId
|
||||
|
||||
try {
|
||||
const { runtime, stored } = await ensureRemoteCanonicalChat(route, profile)
|
||||
|
||||
if (!runtime) {
|
||||
throw new Error('No remote session')
|
||||
}
|
||||
|
||||
// Baseline before our submit, so the poll can spot the NEW reply.
|
||||
let before = 0
|
||||
|
||||
try {
|
||||
const pre = await host.requestProfile(route, 'session.resume', { session_id: stored || runtime, profile })
|
||||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||||
} catch {
|
||||
/* lazy session — zero messages */
|
||||
}
|
||||
|
||||
// The delivery prefix is the recipient's cue that an agent (not its
|
||||
// human) is talking — same contract as the local CLI handoff.
|
||||
await host.requestProfile(route, 'prompt.submit', {
|
||||
session_id: runtime,
|
||||
text: `Message from \u{1F916} ${senderName} (@${senderHandle}): ${text}`
|
||||
})
|
||||
host.notify?.({
|
||||
kind: 'info',
|
||||
title: displayName(bot),
|
||||
message: `Messaged @${botHandle(profile, bot)} on ${label} — will relay the reply here.`
|
||||
})
|
||||
|
||||
const reply = await pollRemoteDmReply(route, profile, stored || runtime, before)
|
||||
|
||||
if (reply) {
|
||||
host.notify?.({
|
||||
kind: 'info',
|
||||
title: `\u{1F916} ${displayName(bot)} (${label})`,
|
||||
message: reply.slice(0, 500)
|
||||
})
|
||||
} else {
|
||||
host.notify?.({
|
||||
kind: 'info',
|
||||
title: displayName(bot),
|
||||
message: `No reply from @${botHandle(profile, bot)} yet — check its Bot Chat on ${label}.`
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
host.notifyError?.(error, `Could not reach ${label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Source-qualified identity for a roster row — the React list key AND the
|
||||
* cross-surface roster identity. Names alone are NOT unique in a
|
||||
* multi-source roster (two connections can both expose 'default');
|
||||
@@ -4244,18 +4022,14 @@ function showsHandle(name, meta, bot) {
|
||||
}
|
||||
|
||||
// ── canonical bot chat ───────────────────────────────────────────────────────
|
||||
// Each bot has ONE forever chat, pinned by stored-session id in bot meta
|
||||
// (meta.chat — synced server-side via ui_meta, so it follows the profile).
|
||||
// Opening a bot ALWAYS lands there: never "most recent session", which
|
||||
// drifts whenever the profile is used from the CLI, Sessions mode, or a
|
||||
// cronjob. The pin only changes through explicit adoption:
|
||||
// - grandfather: first open of a bot that already has history pins its
|
||||
// current latest session, so continuity starts from the chat in use
|
||||
// - fresh bot: opens a draft; when the first message persists a stored
|
||||
// session, we adopt that id (empty sessions are pruned server-side, so
|
||||
// pre-creating one at enable time is not possible)
|
||||
// - recovery: if the pinned id vanishes from the DB (compaction rewrote
|
||||
// the lineage), re-pin the newest session carrying the canonical title.
|
||||
// Each bot has ONE forever chat, identified by NAME, never by pointer: the
|
||||
// session titled exactly "Bot Chat" on that bot's profile. The core
|
||||
// UNIQUE(title) index makes (profile, "Bot Chat") an exact registry, so every
|
||||
// open consults that registry directly — there is nothing to verify, re-pin,
|
||||
// grandfather, or recover. Stored-id pins (ui_meta['hermes-bots'].chat) were
|
||||
// the previous identity and are REMOVED: every lost-chat incident traced to a
|
||||
// dangled or stolen pointer that later guards then welded in. Legacy
|
||||
// ui_meta.chat keys are simply ignored.
|
||||
|
||||
// In-flight creations, keyed by bot name — double-clicking a row must not
|
||||
// mint two canonical chats.
|
||||
@@ -4266,6 +4040,10 @@ const canonicalCreations = new Map()
|
||||
const PROFILE_SESSION_LIST_LIMIT = 200
|
||||
let botOpenGeneration = 0
|
||||
|
||||
/** The one canonical title. (profile, CANONICAL_CHAT_TITLE) IS the bot's
|
||||
* forever-chat identity — see the header above. */
|
||||
const CANONICAL_CHAT_TITLE = 'Bot Chat'
|
||||
|
||||
async function openStoredBotChat(name, storedId, summary) {
|
||||
if (!storedId || typeof host.openSession !== 'function') {
|
||||
throw new Error('This Hermes Desktop version cannot open stored sessions')
|
||||
@@ -4286,34 +4064,42 @@ async function openStoredBotChat(name, storedId, summary) {
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory,
|
||||
keepAllProfilesScope: true,
|
||||
// Move the WORKSPACE onto this bot, not just the transcript.
|
||||
//
|
||||
// With the default (true) the bot's chat opened against its own backend
|
||||
// while `$activeGatewayProfile` stayed on whatever profile was active
|
||||
// before — so "New session" from inside any bot was created on that other
|
||||
// backend. Measured: four consecutive new chats started from different
|
||||
// bots all landed in the `ops` profile's state.db. Clicking a bot is a
|
||||
// workspace switch in this product (one bot = one workspace), so the
|
||||
// chrome has to follow.
|
||||
keepAllProfilesScope: false,
|
||||
retryHydrationTimeoutOnce: true
|
||||
})
|
||||
|
||||
return storedId
|
||||
}
|
||||
|
||||
/** Adopt-before-mint: the profile may already own a canonical Bot Chat that
|
||||
* the pin lost track of (pin cleared during an outage, ui_meta rolled back,
|
||||
* a fork squatting the title). The core UNIQUE title index guarantees at
|
||||
* most ONE session titled "Bot Chat" per profile db — Profile → Named
|
||||
* Session is an exact registry, so consult it exactly: `title` asks the
|
||||
* gateway for an indexed WHERE title = ? lookup (window-free; a busy
|
||||
* profile can push the forever-chat past any recency window, which would
|
||||
* re-open the fork loop with a higher trigger threshold). Minting while a
|
||||
* "Bot Chat" row exists is always wrong twice over: it forks the
|
||||
* forever-chat AND the new row can never take the (already held) canonical
|
||||
* title, so the next identity check misreads it and forks again — the
|
||||
* infinite-fork loop. An older gateway ignores the unknown `title` param
|
||||
* and returns the plain windowed listing instead — the pre-exact-lookup
|
||||
* behavior — so the local scan below stays as the compatibility rung.
|
||||
* include_hidden is required (canonical chats are always hidden); a gateway
|
||||
* without it simply finds nothing and we fall through to mint. */
|
||||
/** True when a session summary IS the canonical registry row. root_title is
|
||||
* the durable lineage-root title reported by exact-lookup gateways; plain
|
||||
* title covers windowed listings. */
|
||||
function isCanonicalBotChatHistory(history) {
|
||||
const rootTitle = String(history?.root_title || '').trim()
|
||||
const title = String(history?.title || '').trim()
|
||||
return rootTitle === CANONICAL_CHAT_TITLE || (!rootTitle && title === CANONICAL_CHAT_TITLE)
|
||||
}
|
||||
|
||||
/** THE identity lookup: the profile's session titled exactly "Bot Chat".
|
||||
* The core UNIQUE title index guarantees at most ONE such row per profile
|
||||
* db — Profile → Named Session is an exact registry, so consult it exactly:
|
||||
* `title` asks the gateway for an indexed WHERE title = ? lookup
|
||||
* (window-free; a busy profile can push the forever-chat past any recency
|
||||
* window). include_hidden is required (canonical chats are always hidden). */
|
||||
async function findExistingCanonicalChat(name) {
|
||||
try {
|
||||
const res = await host.request('session.list', {
|
||||
profile: name,
|
||||
title: 'Bot Chat',
|
||||
title: CANONICAL_CHAT_TITLE,
|
||||
limit: PROFILE_SESSION_LIST_LIMIT,
|
||||
include_hidden: true
|
||||
})
|
||||
@@ -4324,11 +4110,13 @@ async function findExistingCanonicalChat(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the bot's ONE forever chat: a real session opened with a kickoff
|
||||
* message (the gateway prunes zero-message sessions, so the chat is born
|
||||
* with the bot introducing itself). Pins the stored id in bot meta and
|
||||
* returns it. Adopts an existing "Bot Chat" row instead of creating when
|
||||
* the profile already has one (see findExistingCanonicalChat). */
|
||||
/** Create the bot's ONE forever chat: a real session titled "Bot Chat",
|
||||
* opened with a kickoff message (the gateway prunes zero-message sessions,
|
||||
* so the chat is born with the bot introducing itself). Adopts the existing
|
||||
* "Bot Chat" row instead of creating when the profile already has one —
|
||||
* minting while a "Bot Chat" row exists is always wrong twice over: it
|
||||
* forks the forever-chat AND the new row can never take the (already held)
|
||||
* canonical title. */
|
||||
function createCanonicalChat(name) {
|
||||
const inflight = canonicalCreations.get(name)
|
||||
|
||||
@@ -4340,12 +4128,9 @@ function createCanonicalChat(name) {
|
||||
const existing = await findExistingCanonicalChat(name)
|
||||
|
||||
if (existing?.id) {
|
||||
saveBotMeta(name, { chat: existing.id })
|
||||
|
||||
if (typeof host.openSession === 'function') {
|
||||
// The exact-lookup gateway reports the compression-lineage tip as
|
||||
// resolved_id; the pin stays the durable row id (same split the
|
||||
// preferred_session path uses).
|
||||
// resolved_id; open the tip, the registry row stays the identity.
|
||||
await openStoredBotChat(name, existing.resolved_id || existing.id, existing)
|
||||
}
|
||||
|
||||
@@ -4354,7 +4139,7 @@ function createCanonicalChat(name) {
|
||||
|
||||
const res = await host.request('session.create', {
|
||||
profile: name,
|
||||
title: 'Bot Chat',
|
||||
title: CANONICAL_CHAT_TITLE,
|
||||
// Always born hidden from the global sidebar — Bot Mode sessions are
|
||||
// plugin-owned. Core applies this via the generic `hidden` flag
|
||||
// (deferred as pending_hidden until the row exists); older gateways
|
||||
@@ -4364,8 +4149,22 @@ function createCanonicalChat(name) {
|
||||
const sid = res?.stored_session_id
|
||||
const runtime = res?.session_id
|
||||
|
||||
if (sid) {
|
||||
saveBotMeta(name, { chat: sid })
|
||||
// session.create is intentionally lazy: its stored row does not exist until
|
||||
// the first prompt. Mounting `sid` immediately therefore emits a noisy REST
|
||||
// 404 ("Session not found"), and the turn-start auto-titler can win the race
|
||||
// against the deferred `title: 'Bot Chat'` — under name-identity that is an
|
||||
// identity outage: until the row is titled, the registry has no "Bot Chat"
|
||||
// entry, so a second click during the intro turn mints a duplicate.
|
||||
// session.title materializes the row now and records a user-authority title
|
||||
// before either the open or kickoff, closing both the 404 race and the
|
||||
// untitled window. Older gateways may not support the eager write; retain
|
||||
// the kickoff-and-retry fallback below.
|
||||
if (runtime) {
|
||||
try {
|
||||
await host.request('session.title', { session_id: runtime, title: CANONICAL_CHAT_TITLE })
|
||||
} catch {
|
||||
/* compatibility fallback: prompt.submit will persist the lazy row */
|
||||
}
|
||||
}
|
||||
|
||||
// Mount the session view FIRST, then send the kickoff — submitting into
|
||||
@@ -4374,7 +4173,7 @@ function createCanonicalChat(name) {
|
||||
|
||||
if (sid && typeof host.openSession === 'function') {
|
||||
try {
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
|
||||
opened = true
|
||||
} catch {
|
||||
// The stored row may not exist until the kickoff persists it. Retry
|
||||
@@ -4389,11 +4188,11 @@ function createCanonicalChat(name) {
|
||||
await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
|
||||
|
||||
if (!opened && sid && typeof host.openSession === 'function') {
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
|
||||
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: false })
|
||||
}
|
||||
} catch {
|
||||
// The chat already exists. Keep the pin so the next click
|
||||
// opens it instead of making a second Bot Chat.
|
||||
// The chat already exists under the canonical title — the next click
|
||||
// finds it by name instead of making a second Bot Chat.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4405,126 +4204,25 @@ function createCanonicalChat(name) {
|
||||
return run
|
||||
}
|
||||
|
||||
/** Open the bot's ONE forever chat and return the opened id (or the pin).
|
||||
/** Open the bot's ONE forever chat and return the opened registry id.
|
||||
*
|
||||
* Identity rules (hermes-agent#88200 — the row must open the session its
|
||||
* preview describes):
|
||||
* - grandfather: no pin + an existing Bot Chat adopts the previewed session
|
||||
* (`history`, the roster's last_session for this bot) instead of minting
|
||||
* a new empty chat. Ordinary user conversations are never adopted;
|
||||
* `last_session` is only a recency hint, not an ownership proof;
|
||||
* - a live pin is verified through the backend's precise preferred_session
|
||||
* resolver (hidden rows still resolve; compression lineages resolve to
|
||||
* the live tip) — never inferred from a paginated, hidden-excluding
|
||||
* session.list window, which misjudged real hidden pins as gone;
|
||||
* - transient lookup failures keep the pin: try the stored id as-is, and
|
||||
* only a rejected open enters recovery. */
|
||||
function isCanonicalBotChatHistory(history) {
|
||||
const rootTitle = String(history?.root_title || '').trim()
|
||||
const title = String(history?.title || '').trim()
|
||||
return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat')
|
||||
}
|
||||
* The whole resolution is one registry consultation: the profile's session
|
||||
* titled "Bot Chat" exists → open it (lineage tip); it doesn't → create it.
|
||||
* No id pointer is read or written anywhere in this path. */
|
||||
async function openBotCanonicalChat(name) {
|
||||
const existing = await findExistingCanonicalChat(name)
|
||||
|
||||
async function openBotCanonicalChat(name, pinned, history) {
|
||||
if (!pinned) {
|
||||
// Grandfather only an actual Bot Chat. `last_session` is merely the most
|
||||
// recent row for the profile; adopting it blindly can claim an unrelated
|
||||
// user conversation and the hide sweep would then hide that conversation.
|
||||
const adoptId = isCanonicalBotChatHistory(history) ? history.id : null
|
||||
if (adoptId && typeof host.openSession === 'function') {
|
||||
await openStoredBotChat(name, adoptId, history)
|
||||
saveBotMeta(name, { chat: adoptId })
|
||||
return adoptId
|
||||
}
|
||||
return createCanonicalChat(name)
|
||||
if (existing?.id && typeof host.openSession === 'function') {
|
||||
await openStoredBotChat(name, existing.resolved_id || existing.id, existing)
|
||||
return existing.id
|
||||
}
|
||||
|
||||
// Precise verification. An older gateway ignores the unknown param and
|
||||
// omits the key — that reads as a lookup failure below, NOT as a missing
|
||||
// session, so legacy backends keep the try-as-is escape hatch.
|
||||
let preferred
|
||||
let lookupFailed = false
|
||||
try {
|
||||
const res = await host.request('profiles.list', {
|
||||
include_sessions: true,
|
||||
preferred_session_ids: { [name]: pinned }
|
||||
})
|
||||
const row = (res?.profiles ?? []).find(p => p.name === name)
|
||||
preferred = row?.preferred_session
|
||||
if (preferred === undefined) {
|
||||
lookupFailed = true
|
||||
}
|
||||
} catch {
|
||||
lookupFailed = true
|
||||
}
|
||||
|
||||
if (lookupFailed) {
|
||||
// Transient gateway state (or an older backend): the pin is innocent
|
||||
// until proven guilty — try it as-is. A rejected open is still ambiguous:
|
||||
// it can be the same reconnect/hydration outage that broke this lookup, so
|
||||
// preserve the forever-chat pin and surface Retry instead of forking it.
|
||||
return openStoredBotChat(name, pinned, history)
|
||||
}
|
||||
|
||||
if (preferred && isCanonicalBotChatHistory(preferred)) {
|
||||
try {
|
||||
await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred)
|
||||
return pinned
|
||||
} catch (error) {
|
||||
// The precise lookup JUST confirmed this session exists, so a failed
|
||||
// open is transient (reconnect, backend restart). Clearing the pin or
|
||||
// minting a replacement here would fork the bot's forever-chat on
|
||||
// every hiccup — report and keep everything as it is.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (preferred) {
|
||||
// The stored pointer resolved to a real session, but not to Bot Mode's
|
||||
// titled plumbing session. Two legitimate ways to get here, and neither
|
||||
// means "mint a new chat":
|
||||
// - the pin IS the forever-chat but its title drifted (grandfathered
|
||||
// pre-convention chats; the LLM auto-titler renaming an untitled row
|
||||
// after a silent unique-title conflict dropped "Bot Chat"). A pinned
|
||||
// session carrying real history is the user's conversation — forking
|
||||
// away from it silently loses their thread, the exact bug this whole
|
||||
// resolver exists to prevent. The pin is the durable intent: keep it
|
||||
// and open it, even when some other (likely forked) row holds the
|
||||
// "Bot Chat" title. The hide sweep only matches plumbing titles, so
|
||||
// an adopted odd-titled chat is never swept out of the user's
|
||||
// ordinary session list.
|
||||
// - the pin resolves to an EMPTY non-plumbing session (a stray draft):
|
||||
// genuinely corrupted metadata. Clear it — createCanonicalChat then
|
||||
// adopts the profile's existing "Bot Chat" row if one exists before
|
||||
// ever creating a new one.
|
||||
const messageCount = Number(preferred.message_count) || 0
|
||||
|
||||
if (messageCount > 0) {
|
||||
await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred)
|
||||
return pinned
|
||||
}
|
||||
|
||||
await saveBotMeta(name, { chat: null })
|
||||
return createCanonicalChat(name)
|
||||
}
|
||||
|
||||
// Definitively gone (db reset, or the lineage was rewritten past
|
||||
// recovery): re-anchor on the previewed session when there is one.
|
||||
// A previewed row is safe to re-anchor only when it is Bot Mode plumbing.
|
||||
// Otherwise a stale pin must not steal the profile's ordinary latest chat.
|
||||
const recoveryId = isCanonicalBotChatHistory(history) ? history.id : null
|
||||
if (recoveryId && typeof host.openSession === 'function') {
|
||||
await openStoredBotChat(name, recoveryId, history)
|
||||
saveBotMeta(name, { chat: recoveryId })
|
||||
return recoveryId
|
||||
}
|
||||
saveBotMeta(name, { chat: null })
|
||||
return createCanonicalChat(name)
|
||||
}
|
||||
|
||||
async function prepareBotSource(bot, pinnedChat) {
|
||||
async function prepareBotSource(bot) {
|
||||
if (!bot.sourceScoped) {
|
||||
return pinnedChat
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof host.ensureAgent !== 'function') {
|
||||
@@ -4534,7 +4232,7 @@ async function prepareBotSource(bot, pinnedChat) {
|
||||
await host.ensureAgent(bot.connectionId, bot.name)
|
||||
|
||||
if (!bot.remoteSource) {
|
||||
return pinnedChat
|
||||
return
|
||||
}
|
||||
|
||||
const liveId = String(typeof host.activeConnectionId === 'function' ? host.activeConnectionId() || '' : '').trim()
|
||||
@@ -4544,18 +4242,8 @@ async function prepareBotSource(bot, pinnedChat) {
|
||||
throw new Error(`Still on ${liveId || 'this device'}, not ${bot.connectionLabel || targetId}`)
|
||||
}
|
||||
|
||||
// Thin rows deliberately omit metadata from the active source. Once their
|
||||
// owner is active, recover that source's canonical-chat pointer so
|
||||
// same-named agents never reuse or overwrite each other's pin.
|
||||
try {
|
||||
const refreshed = await host.request('profiles.list', {})
|
||||
const owner = refreshed?.profiles?.find(profile => profile.name === bot.name)
|
||||
|
||||
return owner?.ui_meta?.['hermes-bots']?.chat || null
|
||||
} catch {
|
||||
// Metadata refresh is best-effort; canonical creation remains the fallback.
|
||||
return null
|
||||
}
|
||||
// The canonical chat is found by NAME on the now-active owner source —
|
||||
// there is no per-source pointer to recover.
|
||||
}
|
||||
|
||||
function displayName(bot, meta) {
|
||||
@@ -6190,18 +5878,18 @@ function generatedSessionTitle(session, preview) {
|
||||
const ACTIVE_WINDOW_S = 90
|
||||
|
||||
/** The session whose activity best represents this bot — the FRESHER of the
|
||||
* pinned canonical Bot Chat (preferred_session) and the profile's newest
|
||||
* visible conversation (last_session).
|
||||
* canonical Bot Chat (canonical_session, the profile's "Bot Chat" registry
|
||||
* row resolved server-side by name) and the profile's newest visible
|
||||
* conversation (last_session).
|
||||
*
|
||||
* Canonical Bot Chats are hidden from the session list by design, so
|
||||
* last_session alone never sees them: a bot you talk to all day through its
|
||||
* Bot Chat reads "6d ago" because its newest VISIBLE session is a week old.
|
||||
* #88690 moved the preview text to preferred_session but left every activity
|
||||
* signal (age label, pulse dot, unread watermark, recency sort) on
|
||||
* last_session. All of them key off this helper now. Older gateways without
|
||||
* the preferred_session resolver degrade to last_session unchanged. */
|
||||
* Every activity signal (age label, pulse dot, unread watermark, recency
|
||||
* sort) keys off this helper. Older gateways without the canonical_session
|
||||
* field degrade to last_session unchanged. */
|
||||
function botActivitySession(bot) {
|
||||
const preferred = bot?.preferred_session
|
||||
const preferred = bot?.canonical_session
|
||||
const last = bot?.last_session
|
||||
|
||||
if (!preferred || !last) {
|
||||
@@ -6268,7 +5956,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||||
// (age label, pulse dot) follow the same rule via botActivitySession:
|
||||
// the canonical Bot Chat is hidden from last_session, so keying age off
|
||||
// last_session alone shows "6d ago" on a bot you just messaged.
|
||||
const previewSession = bot.preferred_session || last
|
||||
const previewSession = bot.canonical_session || last
|
||||
const activitySession = botActivitySession(bot)
|
||||
// A live kanban/tool worker counts as activity (#90268): pulse + fresh
|
||||
// age while it runs, falling back to chat activity when it ends.
|
||||
@@ -6336,8 +6024,6 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||||
return
|
||||
}
|
||||
|
||||
let pinnedChat = meta?.chat
|
||||
|
||||
if (!bot.remoteSource && $botUnread.get()[bot.name]) {
|
||||
const next = { ...$botUnread.get() }
|
||||
delete next[bot.name]
|
||||
@@ -6347,7 +6033,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||||
// Activate the owner first so every canonical-chat RPC lands on the
|
||||
// backend that owns this bot's state database.
|
||||
try {
|
||||
pinnedChat = await prepareBotSource(bot, pinnedChat)
|
||||
await prepareBotSource(bot)
|
||||
} catch (error) {
|
||||
host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`)
|
||||
|
||||
@@ -6359,7 +6045,10 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession)
|
||||
// Identity is the NAMED registry row (profile → session titled
|
||||
// "Bot Chat"), resolved fresh on every click — preview identity and
|
||||
// click identity agree because both describe that same row (#88200).
|
||||
const id = await openBotCanonicalChat(bot.name)
|
||||
|
||||
if (generation === botOpenGeneration && id) {
|
||||
return
|
||||
@@ -8564,13 +8253,6 @@ function shellQuote(value) {
|
||||
return `'${String(value).replaceAll("'", "'\"'\"'")}'`
|
||||
}
|
||||
|
||||
/** Escape for interpolation INSIDE an existing double-quoted shell string:
|
||||
* keeps ", `, $, and \ literal so free-text titles (which sync from ui_meta)
|
||||
* and gateway profile names can't expand or break out of the quotes. */
|
||||
function shellDoubleQuote(value) {
|
||||
return String(value).replace(/[\\"`$]/g, ch => '\\' + ch)
|
||||
}
|
||||
|
||||
function routineInputError(title, instruction) {
|
||||
if (String(title).includes('\0')) {
|
||||
return 'Cronjob name cannot contain NUL (U+0000).'
|
||||
@@ -8935,6 +8617,11 @@ function CreateRoutineDialog({ bot, open, onClose }) {
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [sched, setSched] = useState(defaultScheduleState())
|
||||
const [continuity, setContinuity] = useState(false)
|
||||
// Where the run's output lands: 'history' = the run session only (Run
|
||||
// history / cron page, today's behavior); 'bot-chat' = inject into this
|
||||
// bot's canonical Bot Chat as a real message — the bot reads it, acts on
|
||||
// it, and responds there (costs the bot one agent turn per run).
|
||||
const [target, setTarget] = useState('history')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const activeProfile = useValue(host.state.profile)
|
||||
@@ -8945,6 +8632,7 @@ function CreateRoutineDialog({ bot, open, onClose }) {
|
||||
setInstruction('')
|
||||
setSched(defaultScheduleState())
|
||||
setContinuity(false)
|
||||
setTarget('history')
|
||||
setBusy(false)
|
||||
setError(null)
|
||||
}
|
||||
@@ -8978,7 +8666,11 @@ function CreateRoutineDialog({ bot, open, onClose }) {
|
||||
prompt: routinePrompt(bot, title, task, activeProfile),
|
||||
...(bot ? { profile: bot } : {}),
|
||||
...(repeatN ? { repeat: repeatN } : {}),
|
||||
...(continuity ? { continuity: true } : {})
|
||||
...(continuity ? { continuity: true } : {}),
|
||||
// 'bot-chat' (bare, no name): the job is created IN the bot's own
|
||||
// cron store (profile scoping above), so the scheduler resolves the
|
||||
// token to that profile — no cross-gateway name ambiguity possible.
|
||||
...(target === 'bot-chat' ? { deliver: 'bot-chat' } : {})
|
||||
})
|
||||
await invalidateRoutineOwner(bot)
|
||||
host.notify({ kind: 'success', message: `Cronjob "${title}" scheduled` })
|
||||
@@ -9031,6 +8723,13 @@ function CreateRoutineDialog({ bot, open, onClose }) {
|
||||
})
|
||||
),
|
||||
labeled('When to run', jsx(SchedulePicker, { state: sched, setState: setSched })),
|
||||
labeled(
|
||||
'Send results to',
|
||||
pickerSelect(target, setTarget, [
|
||||
{ id: 'history', label: 'Run history only' },
|
||||
{ id: 'bot-chat', label: `${displayName({ name: bot }, $botMeta.get()[bot])}\u2019s chat (bot responds)` }
|
||||
])
|
||||
),
|
||||
jsxs('label', {
|
||||
className: 'flex items-center gap-2 text-xs text-(--ui-text-tertiary) cursor-pointer select-none',
|
||||
children: [
|
||||
@@ -11394,10 +11093,8 @@ function BotsPane() {
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let pinnedChat = botRosterMeta(bot, allMeta)?.chat
|
||||
|
||||
try {
|
||||
pinnedChat = await prepareBotSource(bot, pinnedChat)
|
||||
await prepareBotSource(bot)
|
||||
} catch (error) {
|
||||
host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`)
|
||||
|
||||
@@ -11409,11 +11106,7 @@ function BotsPane() {
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await openBotCanonicalChat(
|
||||
bot.name,
|
||||
pinnedChat,
|
||||
bot.preferred_session || bot.last_session
|
||||
)
|
||||
const id = await openBotCanonicalChat(bot.name)
|
||||
|
||||
if (generation === botOpenGeneration && id) {
|
||||
return
|
||||
@@ -11840,7 +11533,7 @@ export default {
|
||||
// sessions pane collapses alone without this flag. The zone then keeps
|
||||
// a stranded BOTS tab on screen. The narrow edge overlay mirrors the
|
||||
// zone's tab strip, so the pane stays reachable while collapsed.
|
||||
data: { placement: 'left', width: '260px', collapsible: true, showCloseButton: false, hideOnly: true, dock: { pane: 'sessions', pos: 'center', enforce: true } },
|
||||
data: { placement: 'left', width: '260px', collapsible: true, hideOnly: true, dock: { pane: 'sessions', pos: 'center', enforce: true } },
|
||||
render: () => jsx(BotsPane, {})
|
||||
})
|
||||
|
||||
@@ -11909,10 +11602,13 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
// @-mention middleware: "@<bot> do the thing" in any chat becomes an
|
||||
// explicit handoff instruction the active agent's SOUL.md knows how to
|
||||
// execute. Names are validated against the LIVE roster so
|
||||
// "user@example.com" or an unknown @ passes through untouched.
|
||||
// @-mention middleware: "@<bot> do the thing" in any chat gets an
|
||||
// IDENTIFICATION note — who the user is referring to, resolved against
|
||||
// the LIVE roster ("user@example.com" or an unknown @ passes through
|
||||
// untouched). The middleware never delivers anything itself: the agent
|
||||
// owns messaging via its message_agent tool (Bot Chats), so there is
|
||||
// exactly one send path and user text is never forwarded verbatim by
|
||||
// the renderer. The composer's @-autocomplete remains the picking aid.
|
||||
ctx.register({
|
||||
id: 'mention-middleware',
|
||||
area: COMPOSER_AREAS.middleware,
|
||||
@@ -11930,11 +11626,17 @@ export default {
|
||||
|
||||
if (slashNew) {
|
||||
const activeBot = $selectedBot.get()
|
||||
const meta = activeBot ? $botMeta.get()[activeBot] : null
|
||||
const pinnedId = meta?.chat || null
|
||||
// Canonical identity is the profile's "Bot Chat" registry row —
|
||||
// read it from the roster cache (canonical_session, resolved
|
||||
// server-side by name), matching either the durable row id or
|
||||
// the compression-lineage tip currently on screen.
|
||||
const roster = $lastRoster.get()
|
||||
const row = Array.isArray(roster) ? roster.find(bot => bot?.name === activeBot) : null
|
||||
const canonical = row?.canonical_session || null
|
||||
const currentId = host.activeSessionId?.get?.() ?? null
|
||||
const canonicalIds = [canonical?.id, canonical?.resolved_id].filter(Boolean).map(String)
|
||||
|
||||
if (activeBot && pinnedId && currentId && String(currentId) === String(pinnedId)) {
|
||||
if (activeBot && currentId && canonicalIds.includes(String(currentId))) {
|
||||
host.notify({
|
||||
kind: 'info',
|
||||
title: 'This chat never resets',
|
||||
@@ -11974,35 +11676,22 @@ export default {
|
||||
return draft
|
||||
}
|
||||
|
||||
const localMentions = mentionedBots.filter(bot => !bot.remoteSource)
|
||||
const remoteMentions = mentionedBots.filter(bot => bot.remoteSource)
|
||||
|
||||
const activeMeta = $botMeta.get()[live.name]
|
||||
const senderName = displayName({ name: live.name, title: activeMeta?.title }, activeMeta)
|
||||
|
||||
if (remoteMentions.length && typeof host.requestProfile === 'function') {
|
||||
void deliverRemoteRosterMentions(remoteMentions, text, {
|
||||
name: senderName,
|
||||
handle: botHandle(live.name)
|
||||
})
|
||||
}
|
||||
let note = ''
|
||||
|
||||
if (localMentions.length) {
|
||||
note +=
|
||||
'\n\n[@mention handoff — for each mentioned agent (' + localMentions.map(bot => botHandle(bot.name, bot)).join(', ') + '): ' +
|
||||
'COMPOSE a message from you (' + senderName + ') to that agent conveying what the user wants — do not forward this text verbatim (avoid double quotes in your composed message). Send it with exactly one terminal call, run with background=true AND notify_on_complete=true (the recipient may take minutes; the user must not be blocked):\n' +
|
||||
localMentions.map(bot => '`hermes -p ' + shellQuote(bot.name) + ' chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "Message from 🤖 ' + shellDoubleQuote(senderName) + ' (@' + shellDoubleQuote(botHandle(live.name)) + '): <your composed message>"`').join('\n') +
|
||||
'\nAfter dispatching, tell the user the message was sent and END YOUR TURN — do not wait or poll; when the background process completes, its notification carries the reply — relay it then, attributed to that agent. ' +
|
||||
'Relay the reply back to the user, attributed to that agent.]'
|
||||
}
|
||||
|
||||
if (remoteMentions.length) {
|
||||
const labels = remoteMentions.map(bot => `@${botHandle(bot.name, bot)} (${bot.connectionLabel || bot.connectionId})`).join(', ')
|
||||
note +=
|
||||
'\n\n[@mention — stay on this device. Desktop is delivering to ' + labels +
|
||||
' over Connections in the background. Do not run hermes -p for them and do not switch Gateway. Tell the user they were messaged here; when a reply lands, relay it attributed to that agent.]'
|
||||
}
|
||||
// Identification only. Each line names the agent the user's tag
|
||||
// resolves to (friendly title + device for cross-connection rows),
|
||||
// so the agent knows exactly who "@research-buddy" is without the
|
||||
// renderer ever acting on the user's behalf.
|
||||
const lines = mentionedBots.map(bot => {
|
||||
const handle = botHandle(bot.name, bot)
|
||||
const title = String(botRosterMeta(bot, $botMeta.get())?.title || bot.ui_meta?.['hermes-bots']?.title || bot.title || '').trim()
|
||||
const where = bot.remoteSource
|
||||
? ` — on ${bot.connectionLabel || bot.connectionId}`
|
||||
: ''
|
||||
return `@${handle} = agent profile "${bot.name}"${title ? ` ("${title}")` : ''}${where}`
|
||||
})
|
||||
const note =
|
||||
'\n\n[@mentions resolved from the Bot Mode roster — the user is referring to: ' +
|
||||
lines.join('; ') +
|
||||
'. If they want one of these agents contacted, compose your own message and send it with your message_agent tool; never forward the user\u2019s text verbatim. If this session has no message_agent tool, agent messaging is unavailable here — say so.]'
|
||||
|
||||
return { ...draft, text: text + note }
|
||||
} }
|
||||
|
||||
@@ -84,11 +84,11 @@ test('roster without profiles never throws', () => {
|
||||
|
||||
// ── botActivitySession: canonical Bot Chat activity counts (hermes-agent "6d ago" bug) ──
|
||||
|
||||
test('botActivitySession picks the fresher preferred_session over a stale last_session', () => {
|
||||
test('botActivitySession picks the fresher canonical_session over a stale last_session', () => {
|
||||
const botActivitySession = loadBotActivitySession()
|
||||
const bot = {
|
||||
// Canonical Bot Chat (hidden from session lists): messaged seconds ago.
|
||||
preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' },
|
||||
canonical_session: { id: 'bot-chat', last_active: NOW / 1000 - 5, preview: 'fresh DM' },
|
||||
// Newest VISIBLE session: 6 days old — what last_session alone reports.
|
||||
last_session: { id: 'old-scratch', last_active: NOW / 1000 - 6 * 86400, preview: 'ancient' }
|
||||
}
|
||||
@@ -98,7 +98,7 @@ test('botActivitySession picks the fresher preferred_session over a stale last_s
|
||||
test('botActivitySession keeps last_session when it is the fresher one', () => {
|
||||
const botActivitySession = loadBotActivitySession()
|
||||
const bot = {
|
||||
preferred_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 },
|
||||
canonical_session: { id: 'bot-chat', last_active: NOW / 1000 - 3600 },
|
||||
last_session: { id: 'scratch', last_active: NOW / 1000 - 10 }
|
||||
}
|
||||
assert.equal(botActivitySession(bot).id, 'scratch')
|
||||
@@ -107,7 +107,7 @@ test('botActivitySession keeps last_session when it is the fresher one', () => {
|
||||
test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => {
|
||||
const botActivitySession = loadBotActivitySession()
|
||||
assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only')
|
||||
assert.equal(botActivitySession({ preferred_session: { id: 'pin', last_active: 1 } }).id, 'pin')
|
||||
assert.equal(botActivitySession({ canonical_session: { id: 'pin', last_active: 1 } }).id, 'pin')
|
||||
assert.equal(botActivitySession({}), null)
|
||||
assert.equal(botActivitySession(null), null)
|
||||
})
|
||||
@@ -117,7 +117,7 @@ test('activeBots counts Bot Chat activity that last_session cannot see', () => {
|
||||
const bots = [
|
||||
{
|
||||
name: 'default',
|
||||
preferred_session: { last_active: NOW / 1000 - 5 },
|
||||
canonical_session: { last_active: NOW / 1000 - 5 },
|
||||
last_session: { last_active: NOW / 1000 - 6 * 86400 }
|
||||
}
|
||||
]
|
||||
@@ -179,7 +179,6 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac
|
||||
// a list key; a `key:` prop leaves chips unkeyed (index identity).
|
||||
assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s)
|
||||
assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/)
|
||||
assert.match(source, /let pinnedChat = botRosterMeta\(bot, allMeta\)\?\.chat/)
|
||||
assert.match(source, /await prepareBotSource\(bot, pinnedChat\)/)
|
||||
assert.match(source, /bot\.preferred_session \|\| bot\.last_session/)
|
||||
assert.match(source, /await prepareBotSource\(bot\)/)
|
||||
assert.match(source, /bot\.canonical_session \|\| last/)
|
||||
})
|
||||
|
||||
@@ -77,13 +77,13 @@ test('pref defaults OFF and persists via ctx.storage under activity-toasts', ()
|
||||
|
||||
test('activity in the hidden canonical Bot Chat still badges (the "6d ago" class)', () => {
|
||||
// The canonical Bot Chat is hidden from session lists, so last_session
|
||||
// never advances when a DM lands there — only preferred_session does.
|
||||
// never advances when a DM lands there — only canonical_session does.
|
||||
const t = loadTracker(false)
|
||||
const at = ts => [
|
||||
{
|
||||
name: 'researcher',
|
||||
last_session: { last_active: 100, preview: 'ancient scratch chat' },
|
||||
preferred_session: { last_active: ts, preview: 'Message from writer: hi' }
|
||||
canonical_session: { last_active: ts, preview: 'Message from writer: hi' }
|
||||
}
|
||||
]
|
||||
t.trackInboundActivity(at(150)) // seeding poll
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
// Regression suite for the infinite-fork loop (hermes-agent#88200 follow-up):
|
||||
// the core UNIQUE title index means at most one session per profile db holds
|
||||
// the "Bot Chat" title. When a fork squats it, every later mint's title is
|
||||
// silently dropped, the LLM titler renames the untitled row, and the next
|
||||
// title-based identity check misreads the fresh chat as "not plumbing" —
|
||||
// clearing the pin and minting again, forever. Two invariants kill the loop:
|
||||
// 1. createCanonicalChat ADOPTS an existing "Bot Chat" row before creating.
|
||||
// 2. A pin that resolves to a NON-plumbing session with real history is the
|
||||
// user's conversation — keep it; only an empty stray draft is replaced.
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
function loadOpenPath({ openSession, request }) {
|
||||
const start = source.indexOf('const canonicalCreations = new Map()')
|
||||
const end = source.indexOf('function displayName(', start)
|
||||
const saved = []
|
||||
const requests = []
|
||||
const context = {
|
||||
host: {
|
||||
openSession,
|
||||
request: async (method, params) => {
|
||||
requests.push({ method, params: JSON.parse(JSON.stringify(params ?? null)) })
|
||||
return request(method, params)
|
||||
}
|
||||
},
|
||||
saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }),
|
||||
$hideBotChats: { get: () => false },
|
||||
window: { setTimeout: callback => callback() }
|
||||
}
|
||||
const section = source
|
||||
.slice(start, end)
|
||||
.concat('\nglobalThis.__open = { createCanonicalChat, openBotCanonicalChat };\n')
|
||||
|
||||
assert.notEqual(start, -1, 'canonical creation section is missing')
|
||||
assert.notEqual(end, -1, 'canonical creation section delimiter is missing')
|
||||
vm.runInNewContext(section, context, { filename: 'canonical-adopt.js' })
|
||||
return { ...context.__open, saved, requests }
|
||||
}
|
||||
|
||||
// ── invariant 1: adopt-before-mint ──────────────────────────────────────────
|
||||
|
||||
test('createCanonicalChat adopts an existing hidden "Bot Chat" row instead of creating', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'session.list') {
|
||||
return {
|
||||
sessions: [
|
||||
{ id: 'newer-ordinary', title: 'help me with x', message_count: 12 },
|
||||
{ id: 'real-forever-chat', title: 'Bot Chat', message_count: 930 }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (method === 'session.create') {
|
||||
throw new Error('must not create: the profile already owns a Bot Chat')
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.createCanonicalChat('ops'), 'real-forever-chat')
|
||||
assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'real-forever-chat' } }])
|
||||
const list = runtime.requests.find(r => r.method === 'session.list')
|
||||
assert.equal(list?.params?.include_hidden, true,
|
||||
'adoption scan must see hidden rows — canonical chats are always hidden')
|
||||
})
|
||||
|
||||
test('createCanonicalChat still creates when no Bot Chat row exists', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'session.list') {
|
||||
return { sessions: [{ id: 'ordinary', title: 'help me with x', message_count: 3 }] }
|
||||
}
|
||||
if (method === 'session.create') return { stored_session_id: 'fresh-1', session_id: 'rt-1' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.createCanonicalChat('newbie'), 'fresh-1')
|
||||
})
|
||||
|
||||
test('createCanonicalChat mints when the adoption scan fails (older gateway)', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'session.list') throw new Error('unknown method')
|
||||
if (method === 'session.create') return { stored_session_id: 'fresh-2', session_id: 'rt-2' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.createCanonicalChat('legacy'), 'fresh-2')
|
||||
})
|
||||
|
||||
// ── invariant 2: a resolving pin with history is never abandoned ────────────
|
||||
|
||||
test('pin resolving to a renamed session WITH history keeps the pin (no fork)', async () => {
|
||||
const opened = []
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async id => opened.push(id),
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'grandfathered', resolved_id: 'grandfathered',
|
||||
root_title: 'Use computer use to inspect…', title: 'Use computer use to inspect…',
|
||||
message_count: 930
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (method === 'session.create') throw new Error('must not fork a chat with 930 messages')
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', 'grandfathered', null), 'grandfathered')
|
||||
assert.equal(opened.includes('grandfathered'), true)
|
||||
assert.deepEqual(runtime.saved, [], 'pin must not be cleared or rewritten')
|
||||
})
|
||||
|
||||
test('pin resolving to an EMPTY stray draft is replaced via adoption, not a blind mint', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: { id: 'stray', resolved_id: 'stray', root_title: 'Untitled', title: 'Untitled', message_count: 0 }
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (method === 'session.list') {
|
||||
return { sessions: [{ id: 'real-forever-chat', title: 'Bot Chat', message_count: 42 }] }
|
||||
}
|
||||
if (method === 'session.create') throw new Error('must adopt the existing Bot Chat')
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', 'stray', null), 'real-forever-chat')
|
||||
assert.deepEqual(runtime.saved, [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'real-forever-chat' } }
|
||||
])
|
||||
})
|
||||
@@ -8,11 +8,8 @@ const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
function loadCanonicalCreation({ openSession, request }) {
|
||||
const start = source.indexOf('const canonicalCreations = new Map()')
|
||||
const end = source.indexOf('function displayName(', start)
|
||||
const saved = []
|
||||
const context = {
|
||||
host: { openSession, request },
|
||||
saveBotMeta: (name, patch) => saved.push({ name, patch }),
|
||||
$hideBotChats: { get: () => false },
|
||||
window: { setTimeout: callback => callback() }
|
||||
}
|
||||
const section = source
|
||||
@@ -22,10 +19,34 @@ function loadCanonicalCreation({ openSession, request }) {
|
||||
assert.notEqual(start, -1, 'canonical creation section is missing')
|
||||
assert.notEqual(end, -1, 'canonical creation section delimiter is missing')
|
||||
vm.runInNewContext(section, context, { filename: 'canonical-creation.js' })
|
||||
return { ...context.__canonical, saved }
|
||||
return { ...context.__canonical }
|
||||
}
|
||||
|
||||
test('regression: navigation retries after the kickoff persists a new canonical chat', async () => {
|
||||
test('regression: creation materializes and titles the lazy row before opening it', async () => {
|
||||
const events = []
|
||||
const runtime = loadCanonicalCreation({
|
||||
openSession: async id => events.push(`open:${id}`),
|
||||
request: async (method, params) => {
|
||||
events.push(method)
|
||||
if (method === 'session.create') return { stored_session_id: 'stored-1', session_id: 'runtime-1' }
|
||||
if (method === 'session.title') {
|
||||
assert.deepEqual(params, { session_id: 'runtime-1', title: 'Bot Chat' })
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.createCanonicalChat('ops'), 'stored-1')
|
||||
assert.deepEqual(events, [
|
||||
'session.list',
|
||||
'session.create',
|
||||
'session.title',
|
||||
'open:stored-1',
|
||||
'prompt.submit'
|
||||
])
|
||||
})
|
||||
|
||||
test('compatibility: navigation retries after kickoff when eager title persistence is unavailable', async () => {
|
||||
const events = []
|
||||
let attempts = 0
|
||||
const runtime = loadCanonicalCreation({
|
||||
@@ -36,6 +57,7 @@ test('regression: navigation retries after the kickoff persists a new canonical
|
||||
},
|
||||
request: async method => {
|
||||
if (method === 'session.create') return { stored_session_id: 'stored-1', session_id: 'runtime-1' }
|
||||
if (method === 'session.title') throw new Error('unknown method')
|
||||
if (method === 'prompt.submit') events.push('kickoff:persisted')
|
||||
return {}
|
||||
}
|
||||
@@ -45,7 +67,7 @@ test('regression: navigation retries after the kickoff persists a new canonical
|
||||
assert.deepEqual(events, ['open:stored-1', 'kickoff:persisted', 'open:stored-1'])
|
||||
})
|
||||
|
||||
test('regression: a failed intro keeps the pin', async () => {
|
||||
test('regression: a failed intro still returns the created registry row', async () => {
|
||||
const runtime = loadCanonicalCreation({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
@@ -55,8 +77,7 @@ test('regression: a failed intro keeps the pin', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// The chat exists under the canonical title — the next click finds it by
|
||||
// NAME (the registry), so a failed kickoff can never orphan or fork it.
|
||||
assert.equal(await runtime.createCanonicalChat('newbie'), 'new-bot-chat')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [
|
||||
{ name: 'newbie', patch: { chat: 'new-bot-chat' } }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
function loadCanonicalRecovery({ openSession, request }) {
|
||||
const start = source.indexOf('const canonicalCreations = new Map()')
|
||||
const end = source.indexOf('function displayName(', start)
|
||||
const saved = []
|
||||
const requests = []
|
||||
const context = {
|
||||
host: {
|
||||
openSession,
|
||||
request: async (method, params) => {
|
||||
requests.push(method)
|
||||
return request(method, params)
|
||||
}
|
||||
},
|
||||
saveBotMeta: (name, patch) => saved.push({ name, patch }),
|
||||
$hideBotChats: { get: () => false },
|
||||
window: { setTimeout: callback => callback() }
|
||||
}
|
||||
const section = source.slice(start, end).concat('\nglobalThis.__canonical = { openBotCanonicalChat };\n')
|
||||
|
||||
assert.notEqual(start, -1, 'canonical chat section is missing')
|
||||
assert.notEqual(end, -1, 'canonical chat section delimiter is missing')
|
||||
vm.runInNewContext(section, context, { filename: 'canonical-recovery.js' })
|
||||
return { ...context.__canonical, saved, requests }
|
||||
}
|
||||
|
||||
test('regression: a definitively-gone pin with no history clears and creates a replacement', async () => {
|
||||
// New contract (hermes-agent#88200): the pin is verified through the
|
||||
// backend's precise preferred_session resolver — NOT a paginated,
|
||||
// hidden-excluding session.list window. preferred_session=null is the
|
||||
// definitive "this session is gone"; with no previewed history to
|
||||
// re-anchor on, recovery clears the pin and creates a fresh chat.
|
||||
const opened = []
|
||||
const runtime = loadCanonicalRecovery({
|
||||
openSession: async id => opened.push(id),
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] }
|
||||
if (method === 'session.create') return { stored_session_id: 'replacement', session_id: 'replacement-runtime' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', 'stale-pin', null), 'replacement')
|
||||
assert.deepEqual(opened, ['replacement'])
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'replacement' } }
|
||||
])
|
||||
})
|
||||
|
||||
test('regression: an unpinned bot adopts its previewed chat instead of creating another', async () => {
|
||||
// A CLI/A2A exchange can create the canonical chat before the desktop
|
||||
// saves ui_meta.chat. Grandfathering adopts the session the row already
|
||||
// previews (the roster's last_session) rather than minting a new one.
|
||||
const opened = []
|
||||
const runtime = loadCanonicalRecovery({
|
||||
openSession: async id => opened.push(id),
|
||||
request: async method => {
|
||||
if (method === 'session.create') throw new Error('must not create')
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const history = { id: 'existing-canonical', title: 'Bot Chat', preview: 'hey', last_active: 5 }
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', null, history), 'existing-canonical')
|
||||
assert.deepEqual(opened, ['existing-canonical'])
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [
|
||||
{ name: 'ops', patch: { chat: 'existing-canonical' } }
|
||||
])
|
||||
})
|
||||
|
||||
test('regression: a dead pin re-anchors on the previewed chat instead of the newest session', async () => {
|
||||
// hermes-agent#88146: recovery must never steal an unrelated scratch
|
||||
// session. A pin the backend reports definitively gone re-anchors on the
|
||||
// previewed history row when one exists — session.create never fires.
|
||||
const opened = []
|
||||
const runtime = loadCanonicalRecovery({
|
||||
openSession: async id => opened.push(id),
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] }
|
||||
if (method === 'session.create') throw new Error('must not create')
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const history = { id: 'the-real-bot-chat', title: 'Bot Chat', preview: 'p', last_active: 9 }
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', 'old-pin', history), 'the-real-bot-chat')
|
||||
assert.deepEqual(opened, ['the-real-bot-chat'])
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(runtime.saved)), [
|
||||
{ name: 'ops', patch: { chat: 'the-real-bot-chat' } }
|
||||
])
|
||||
})
|
||||
|
||||
test('regression: an inconclusive lookup opens the stored pin as-is and never rewrites it', async () => {
|
||||
// hermes-agent#88146: an older backend (profiles.list without the
|
||||
// preferred_session_ids param) or a transient hiccup is NOT proof the pin
|
||||
// is gone. The pin is opened as-is; nothing is saved, nothing is created.
|
||||
const opened = []
|
||||
const runtime = loadCanonicalRecovery({
|
||||
openSession: async id => opened.push(id),
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') return { profiles: [{ name: 'ops' }] }
|
||||
if (method === 'session.create') throw new Error('must not create')
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(await runtime.openBotCanonicalChat('ops', 'old-pin-outside-page', null), 'old-pin-outside-page')
|
||||
assert.deepEqual(opened, ['old-pin-outside-page'])
|
||||
assert.equal(runtime.saved.length, 0)
|
||||
})
|
||||
@@ -1,412 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
// ── canonical open-path harness (slice: createCanonicalChat + openBotCanonicalChat)
|
||||
function loadOpenPath({ openSession, request }) {
|
||||
const start = source.indexOf('const canonicalCreations = new Map()')
|
||||
const end = source.indexOf('function displayName(', start)
|
||||
const saved = []
|
||||
const requests = []
|
||||
const context = {
|
||||
host: {
|
||||
openSession,
|
||||
request: async (method, params) => {
|
||||
requests.push({ method, params })
|
||||
return request(method, params)
|
||||
}
|
||||
},
|
||||
saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }),
|
||||
$hideBotChats: { get: () => false },
|
||||
window: { setTimeout: callback => callback() }
|
||||
}
|
||||
const section = source
|
||||
.slice(start, end)
|
||||
.concat('\nglobalThis.__open = { openBotCanonicalChat };\n')
|
||||
|
||||
assert.notEqual(start, -1, 'canonical creation section is missing')
|
||||
assert.notEqual(end, -1, 'canonical creation section delimiter is missing')
|
||||
vm.runInNewContext(section, context, { filename: 'canonical-open.js' })
|
||||
return { ...context.__open, saved, requests, host: context.host }
|
||||
}
|
||||
|
||||
const HISTORY = { id: 'hist-1', title: 'Bot Chat', preview: 'history preview', last_active: 1000 }
|
||||
|
||||
// ── grandfather: no pin + existing history adopts the previewed session ────
|
||||
|
||||
test('grandfather: no pin + history opens and pins THAT session, no new chat', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async () => ({})
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', null, HISTORY)
|
||||
|
||||
assert.equal(result, 'hist-1')
|
||||
assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'hist-1' } }])
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false,
|
||||
'must not mint a new chat when the previewed session can be adopted')
|
||||
})
|
||||
|
||||
test('safety: no pin + ordinary latest history creates a Bot Chat instead of claiming it', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method =>
|
||||
method === 'session.create'
|
||||
? { stored_session_id: 'safe-bot-chat', session_id: 'safe-bot-chat-runtime' }
|
||||
: {}
|
||||
})
|
||||
|
||||
const ordinary = { ...HISTORY, id: 'ordinary-1', title: '生产调度会优化' }
|
||||
const result = await runtime.openBotCanonicalChat('ops', null, ordinary)
|
||||
|
||||
assert.equal(result, 'safe-bot-chat')
|
||||
assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'safe-bot-chat' } }])
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), true)
|
||||
})
|
||||
|
||||
test('grandfather: no pin + no history keeps the creation flow', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method =>
|
||||
method === 'session.create' ? { stored_session_id: 'stored-1', session_id: 'runtime-1' } : {}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', null, null)
|
||||
|
||||
assert.equal(result, 'stored-1')
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), true)
|
||||
})
|
||||
|
||||
test('grandfather: adoption hydration failure surfaces without forking a replacement chat', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async id => {
|
||||
if (id === 'hist-1') throw new Error('session vanished')
|
||||
},
|
||||
request: async method =>
|
||||
method === 'session.create' ? { stored_session_id: 'stored-2', session_id: 'runtime-2' } : {}
|
||||
})
|
||||
|
||||
await assert.rejects(runtime.openBotCanonicalChat('ops', null, HISTORY), /session vanished/)
|
||||
assert.equal(runtime.saved.some(s => s.patch?.chat === 'hist-1'), false,
|
||||
'a failed adoption must not persist the dead id as the pin')
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false,
|
||||
'a transient hydration failure must not fork the canonical chat')
|
||||
})
|
||||
|
||||
// ── precise pin verification (no session.list pagination/hidden semantics) ─
|
||||
|
||||
test('pin: preferred_session present opens the resolved session and keeps the pin', async () => {
|
||||
const opened = []
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async (id, options) => { opened.push({ id, options }) },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
|
||||
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY)
|
||||
|
||||
assert.equal(result, 'pin-1')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(opened)), [{
|
||||
id: 'pin-1',
|
||||
options: {
|
||||
profile: 'ops',
|
||||
intent: 'main',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
keepAllProfilesScope: true,
|
||||
retryHydrationTimeoutOnce: true
|
||||
}
|
||||
}])
|
||||
assert.equal(runtime.saved.length, 0, 'a live pin must not be rewritten')
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false)
|
||||
// The pin is verified through the precise resolver, never session.list.
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.list'), false)
|
||||
})
|
||||
|
||||
test('safety: a pinned ordinary session is rejected and replaced with a Bot Chat', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: { id: 'ordinary-3', resolved_id: 'ordinary-3', title: '生产调度会优化' }
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (method === 'session.create') return { stored_session_id: 'safe-pinned-chat', session_id: 'safe-pinned-runtime' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'ordinary-3', HISTORY)
|
||||
|
||||
assert.equal(result, 'safe-pinned-chat')
|
||||
assert.deepEqual(runtime.saved, [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'safe-pinned-chat' } }
|
||||
])
|
||||
})
|
||||
|
||||
test('pin: compression-rotated pin opens the live tip, keeps the durable pin', async () => {
|
||||
const opened = []
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async id => { opened.push(id) },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'root-1', resolved_id: 'tip-9', root_title: 'Bot Chat', title: 'Bot Chat (continued)',
|
||||
preview: 'post-compression', started_at: 1, last_active: 9, message_count: 42
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'root-1', HISTORY)
|
||||
|
||||
assert.deepEqual(opened, ['tip-9'])
|
||||
assert.equal(result, 'root-1', 'the stored pin keeps its durable identity')
|
||||
assert.equal(runtime.saved.length, 0)
|
||||
})
|
||||
|
||||
test('pin: definitively gone pin re-pins to the previewed session, not rows[0]', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return { profiles: [{ name: 'ops', preferred_session: null }] }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', HISTORY)
|
||||
|
||||
assert.equal(result, 'hist-1')
|
||||
assert.deepEqual(runtime.saved, [{ name: 'ops', patch: { chat: 'hist-1' } }])
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false)
|
||||
})
|
||||
|
||||
test('safety: a dead pin does not re-anchor on an ordinary latest session', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') return { profiles: [{ name: 'ops', preferred_session: null }] }
|
||||
if (method === 'session.create') return { stored_session_id: 'safe-replacement', session_id: 'safe-replacement-runtime' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const ordinary = { ...HISTORY, id: 'ordinary-2', title: '生产调度会优化' }
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', ordinary)
|
||||
|
||||
assert.equal(result, 'safe-replacement')
|
||||
assert.deepEqual(runtime.saved, [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'safe-replacement' } }
|
||||
])
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), true)
|
||||
})
|
||||
|
||||
test('pin: gone pin + no history clears the pin and creates', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return { profiles: [{ name: 'ops', preferred_session: null }] }
|
||||
}
|
||||
if (method === 'session.create') return { stored_session_id: 'stored-3', session_id: 'runtime-3' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'dead-pin', null)
|
||||
|
||||
assert.equal(result, 'stored-3')
|
||||
// Pin cleared first (dead pin is provably unusable), then the freshly
|
||||
// created chat pins itself inside createCanonicalChat.
|
||||
assert.deepEqual(runtime.saved, [
|
||||
{ name: 'ops', patch: { chat: null } },
|
||||
{ name: 'ops', patch: { chat: 'stored-3' } }
|
||||
])
|
||||
})
|
||||
|
||||
test('pin: precise hit but failed hydration keeps the pin and surfaces the failure', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => { throw new Error('socket hiccup') },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
|
||||
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /socket hiccup/)
|
||||
assert.equal(runtime.saved.length, 0, 'a confirmed-live pin must survive a transient open failure')
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false,
|
||||
'must not fork the forever-chat on a hiccup')
|
||||
})
|
||||
|
||||
test('pin: a waking-backend hydration timeout asks the SDK to retry internally', async () => {
|
||||
// The internal retry-and-succeed behavior lives in host.openSession itself
|
||||
// (apps/desktop/src/sdk/index.ts) now, because only that layer sees the
|
||||
// $resumeExhaustedSessionId latch that the core stranded-session overlay
|
||||
// reads — a plugin-side retry can silently resolve while that overlay stays
|
||||
// latched (hermes-agent#89617). This harness stubs host.openSession with a
|
||||
// bare mock, so it can only prove the plugin ASKS for the retry, not that
|
||||
// the overlay never appears; see profile-routing.test.ts for that.
|
||||
const opts = []
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async (id, options) => { opts.push(options) },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
|
||||
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY)
|
||||
|
||||
assert.equal(result, 'pin-1')
|
||||
assert.equal(opts.length, 1)
|
||||
assert.equal(opts[0].retryHydrationTimeoutOnce, true, 'the SDK must own the hydration-timeout retry')
|
||||
})
|
||||
|
||||
test('pin: a persistent hydration timeout still surfaces the failure', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => { throw new Error("Timed out loading ops's session history.") },
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') {
|
||||
return {
|
||||
profiles: [{
|
||||
name: 'ops',
|
||||
preferred_session: {
|
||||
id: 'pin-1', resolved_id: 'pin-1', title: 'Bot Chat',
|
||||
preview: 'latest', started_at: 1, last_active: 2, message_count: 3
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /Timed out loading/)
|
||||
assert.equal(runtime.saved.length, 0, 'a confirmed-live pin must survive a persistent hydration timeout')
|
||||
})
|
||||
|
||||
// ── transient failures must never destroy the pin ──────────────────────────
|
||||
|
||||
test('transient: profiles.list failure keeps the pin when the direct open works', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async () => undefined,
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') throw new Error('gateway reconnecting')
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY)
|
||||
|
||||
assert.equal(result, 'pin-1')
|
||||
assert.equal(runtime.saved.length, 0, 'a hiccup must not clear or rewrite the pin')
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false,
|
||||
'a hiccup must not mint a replacement chat')
|
||||
})
|
||||
|
||||
test('transient: profiles.list failure + failed direct open preserves pin and surfaces Retry', async () => {
|
||||
const runtime = loadOpenPath({
|
||||
openSession: async id => {
|
||||
if (id === 'pin-1') throw new Error('resume rejected')
|
||||
},
|
||||
request: async method => {
|
||||
if (method === 'profiles.list') throw new Error('gateway reconnecting')
|
||||
if (method === 'session.create') return { stored_session_id: 'stored-4', session_id: 'runtime-4' }
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(runtime.openBotCanonicalChat('ops', 'pin-1', HISTORY), /resume rejected/)
|
||||
assert.deepEqual(runtime.saved, [], 'an inconclusive outage must never clear the canonical pin')
|
||||
assert.equal(runtime.requests.some(r => r.method === 'session.create'), false,
|
||||
'an inconclusive outage must never fork the canonical chat')
|
||||
})
|
||||
|
||||
// ── preferred_session_ids request shaping (pure helper) ────────────────────
|
||||
|
||||
function loadHelpers() {
|
||||
const atom = value => ({ get: () => value, set: () => undefined })
|
||||
const jsx = (type, props = {}) => ({ type, props })
|
||||
const context = {
|
||||
atom,
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
useQuery: () => ({}),
|
||||
useValue: value => (value?.get ? value.get() : value),
|
||||
useState: value => [value, () => undefined],
|
||||
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } },
|
||||
host: { state: { profile: { get: () => 'ops', listen: () => undefined } }, request: () => undefined }
|
||||
}
|
||||
const code = source
|
||||
.replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
|
||||
.replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '')
|
||||
.replace(/^import .* from 'react'\r?\n/m, '')
|
||||
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
|
||||
.replace('export default {', 'globalThis.plugin = {')
|
||||
.concat('\nglobalThis.__preferredSessionIds = preferredSessionIds;')
|
||||
vm.runInNewContext(code, context)
|
||||
return context
|
||||
}
|
||||
|
||||
test('preferredSessionIds: collects only live pins', () => {
|
||||
// vm-realm objects fail assert.deepEqual prototype checks — compare via JSON.
|
||||
const collect = meta => JSON.parse(JSON.stringify(loadHelpers().__preferredSessionIds(meta)))
|
||||
assert.deepEqual(
|
||||
collect({ ops: { chat: 'pin-1' }, scribe: { chat: null }, chef: { title: 'Chef' } }),
|
||||
{ ops: 'pin-1' }
|
||||
)
|
||||
assert.deepEqual(collect({}), {})
|
||||
assert.deepEqual(collect(undefined), {})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user