Merge updated client resource metrics into active-install metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
@@ -107,7 +107,9 @@ jobs:
|
||||
version: "0.9.28"
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
run: uv python install 3.11
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
|
||||
@@ -66,8 +66,12 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Install & Update E2E (reusable)
|
||||
|
||||
# Runs ONE update route against ONE starting commit, in the dev sandbox, with a
|
||||
# real install (uv, a managed Python, Node, the venv) behind it.
|
||||
#
|
||||
# Reusable so callers can fan out over the combinations that matter -- update
|
||||
# from the tip vs. from an older release, `hermes update` vs. re-running the
|
||||
# installer -- without duplicating the runner setup. Each leg is independent:
|
||||
# its own sandbox, its own install, nothing rewound or shared.
|
||||
#
|
||||
# Call it:
|
||||
#
|
||||
# jobs:
|
||||
# tip:
|
||||
# uses: ./.github/workflows/install-e2e-run.yml
|
||||
# with:
|
||||
# route: update
|
||||
# install-ref: refs/heads/main
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
route:
|
||||
description: 'Update path to exercise: update (hermes update) or installer (re-run install.sh).'
|
||||
required: true
|
||||
type: string
|
||||
install-ref:
|
||||
description: 'What to install before updating: a branch, a tag (v2026.7.7), or a SHA reachable from main.'
|
||||
required: false
|
||||
type: string
|
||||
default: refs/heads/main
|
||||
runner:
|
||||
description: 'Runner label.'
|
||||
required: false
|
||||
type: string
|
||||
default: ubuntu-latest
|
||||
timeout-minutes:
|
||||
description: 'Job timeout. A cold run installs real toolchains twice.'
|
||||
required: false
|
||||
type: number
|
||||
default: 45
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: ${{ inputs.route }} from ${{ inputs.install-ref }}
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||
|
||||
steps:
|
||||
# Full history: the sandbox fetches the starting commit and the test
|
||||
# compares against this commit, so a shallow clone is not enough.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# bubblewrap + slirp4netns are what the sandbox is built on; util-linux
|
||||
# supplies the `unshare` that builds the multi-uid userns for the
|
||||
# user-level (non-root) install.
|
||||
- name: Install sandbox dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq bubblewrap slirp4netns uidmap util-linux
|
||||
|
||||
# Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor,
|
||||
# which is exactly what bwrap needs. Report the state before touching it
|
||||
# so a future runner-image change is visible in the log rather than
|
||||
# silently altering what this job proves.
|
||||
- name: Permit unprivileged user namespaces
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "--- kernel userns settings (before)"
|
||||
sysctl kernel.unprivileged_userns_clone 2>/dev/null || echo " (sysctl absent)"
|
||||
sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo " (sysctl absent)"
|
||||
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
echo "--- subuid/subgid for $(id -un)"
|
||||
grep "^$(id -un):" /etc/subuid /etc/subgid || echo " (none — sandbox will say so)"
|
||||
|
||||
- name: Run install + update E2E
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tests/install/install-update-e2e.sh \
|
||||
--route '${{ inputs.route }}' \
|
||||
--install-ref '${{ inputs.install-ref }}'
|
||||
env:
|
||||
# Outside the workspace on purpose: the script creates this directory
|
||||
# up front, and an untracked dir inside the repo makes the worktree
|
||||
# dirty -- which dev-sandbox reacts to by snapshotting the working
|
||||
# copy into a fresh fake-main commit on every invocation, moving the
|
||||
# update target mid-run.
|
||||
HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs
|
||||
|
||||
# Artifact names cannot contain '/', and install-ref may be a full ref
|
||||
# like refs/heads/main. GitHub Actions expressions have no string-replace
|
||||
# function, so build the safe name here. Runs even on failure -- that is
|
||||
# exactly when the logs are wanted.
|
||||
- name: Build artifact name
|
||||
if: always()
|
||||
id: artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
safe_ref='${{ inputs.install-ref }}'
|
||||
safe_ref="${safe_ref//\//-}"
|
||||
echo "name=install-e2e-${{ inputs.route }}-${safe_ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The installer's own transcripts say far more than the assertion that
|
||||
# tripped when a real install breaks.
|
||||
- name: Upload installer logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# Unique per leg: a matrix over releases runs this workflow several
|
||||
# times per route, and same-named artifacts collide.
|
||||
name: ${{ steps.artifact.outputs.name }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/e2e-logs
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Install & Update E2E
|
||||
|
||||
# Can a user on a released version get to this commit?
|
||||
#
|
||||
# For each release we sample, a leg installs that release through the real
|
||||
# `curl | install.sh` one-liner (uv, a managed Python, Node, the venv) inside
|
||||
# scripts/dev-sandbox.sh, then applies one update route and requires the
|
||||
# checkout to land on this commit with a working `hermes`.
|
||||
#
|
||||
# The starting versions are chosen at runtime from the repo's release tags
|
||||
# (scripts/sandbox/pick-release-tags.sh): newest, oldest, and a spread between.
|
||||
# A hardcoded list would stop covering the newest release the day after it
|
||||
# ships, and would pin an "oldest" that nobody still runs.
|
||||
#
|
||||
# Triggers:
|
||||
# * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change)
|
||||
# surfaces on a schedule rather than in someone's review cycle;
|
||||
# * when a release tag is created -- the moment the set of versions users can
|
||||
# update FROM changes, and the moment a broken updater would strand them;
|
||||
# * manually, where you can pick the route and how many releases to sample.
|
||||
#
|
||||
# Deliberately NOT on pull_request: a leg takes ~11 minutes of real toolchain
|
||||
# installation, and the matrix multiplies that. Updating is release-shaped work,
|
||||
# so it is gated on releases and the clock instead.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
route:
|
||||
description: 'Which update route to exercise.'
|
||||
required: false
|
||||
type: choice
|
||||
default: both
|
||||
options: [both, update, installer]
|
||||
tag-count:
|
||||
description: 'How many release tags to sample (newest, oldest, and a spread between).'
|
||||
required: false
|
||||
type: string
|
||||
default: '5'
|
||||
schedule:
|
||||
# Every 12 hours, off the hour to avoid the top-of-hour runner crunch.
|
||||
- cron: '20 7,19 * * *'
|
||||
push:
|
||||
tags:
|
||||
# Release tags only: the repo also carries backup/* and one-off tags.
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+.[0-9]+'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: install-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Which released versions do we test updating FROM? Resolved once and shared
|
||||
# by both route matrices, so the two routes cover the same set.
|
||||
pick-releases:
|
||||
name: Pick release tags
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
tags: ${{ steps.pick.outputs.tags }}
|
||||
steps:
|
||||
# This job only reads tag names and runs one script, so take the cheap
|
||||
# checkout: no blobs (filter), no other files (sparse), but DO fetch tags
|
||||
# -- they are the whole input, and the default shallow checkout has none.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
filter: blob:none
|
||||
fetch-tags: true
|
||||
sparse-checkout: scripts/sandbox/pick-release-tags.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
- id: pick
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags="$(scripts/sandbox/pick-release-tags.sh --count '${{ inputs.tag-count || 5 }}')"
|
||||
echo "Testing updates from: $tags"
|
||||
echo "tags=$tags" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# `hermes update` -- the route most users take.
|
||||
update:
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.route != 'installer'
|
||||
needs: pick-releases
|
||||
strategy:
|
||||
# One release breaking is worth knowing about even if another already
|
||||
# failed, so let every leg report.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }}
|
||||
uses: ./.github/workflows/install-e2e-run.yml
|
||||
with:
|
||||
route: update
|
||||
install-ref: ${{ matrix.install-ref }}
|
||||
|
||||
# Re-running the curl one-liner over an existing checkout: autostash + pull
|
||||
# rather than the updater's own git handling.
|
||||
installer:
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.route != 'update'
|
||||
needs: pick-releases
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }}
|
||||
uses: ./.github/workflows/install-e2e-run.yml
|
||||
with:
|
||||
route: installer
|
||||
install-ref: ${{ matrix.install-ref }}
|
||||
@@ -163,10 +163,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
|
||||
# raw.githubusercontent.com every job; transient fetch failures
|
||||
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
|
||||
version: "0.9.28"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv python install 3.11
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
@@ -90,7 +90,9 @@ jobs:
|
||||
uv.lock
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||
|
||||
@@ -145,6 +145,9 @@ docs/superpowers/*
|
||||
|
||||
# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent)
|
||||
.hermes-sandbox/
|
||||
# Sandbox dirs used by the install/update E2E (tests/install/). The suffix is
|
||||
# the route name, so each route gets its own tree and two can run at once.
|
||||
.hermes-sandbox-e2e*/
|
||||
|
||||
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||
|
||||
@@ -158,9 +158,16 @@ def make_approval_callback(
|
||||
|
||||
try:
|
||||
response = future.result(timeout=timeout)
|
||||
except (FutureTimeout, Exception) as exc:
|
||||
except FutureTimeout:
|
||||
future.cancel()
|
||||
logger.warning("Permission request timed out or failed: %s", exc)
|
||||
logger.warning("Permission request timed out after %ss", timeout)
|
||||
# Distinct from an explicit deny: the client never answered.
|
||||
# tools.approval callers report this as "timed out without user
|
||||
# response" instead of a user denial.
|
||||
return "timeout"
|
||||
except Exception as exc:
|
||||
future.cancel()
|
||||
logger.warning("Permission request failed: %s", exc)
|
||||
return "deny"
|
||||
|
||||
if response is None:
|
||||
|
||||
+23
-1
@@ -209,7 +209,18 @@ def _context_route_mismatch(
|
||||
|
||||
if active_route:
|
||||
configured_routes = _provider_default_routes(configured_provider)
|
||||
return not configured_routes or active_route not in configured_routes
|
||||
if configured_routes:
|
||||
return active_route not in configured_routes
|
||||
# Named/custom providers have no catalog default routes. An empty
|
||||
# configured URL with a matching provider identity is still the same
|
||||
# route — agent_init fills base_url from custom_providers before this
|
||||
# check, but gateway display/hygiene paths historically compared the
|
||||
# raw empty model.base_url and falsely dropped model.context_length,
|
||||
# falling through to family defaults (e.g. qwen → 131072) on Discord
|
||||
# session-reset banners while /status still showed the config pin.
|
||||
if active_provider and configured_provider == active_provider:
|
||||
return False
|
||||
return True
|
||||
return bool(
|
||||
configured_provider
|
||||
and active_provider
|
||||
@@ -1580,6 +1591,17 @@ def init_agent(
|
||||
"reasoning_config": reasoning_config,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Persist a process-scoped --yolo launch into the session row so a later
|
||||
# `hermes --resume <id>` can restore the bypass (CLI resume paths read
|
||||
# model_config.yolo_mode back via SessionDB.session_yolo_enabled).
|
||||
# Session-scoped /yolo toggles persist separately through
|
||||
# SessionDB.set_session_yolo at toggle time.
|
||||
try:
|
||||
from tools.approval import _YOLO_MODE_FROZEN
|
||||
if _YOLO_MODE_FROZEN:
|
||||
agent._session_init_model_config["yolo_mode"] = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# In-memory todo list for task planning (one per agent/session)
|
||||
from tools.todo_tool import TodoStore
|
||||
|
||||
+136
-58
@@ -36,7 +36,7 @@ from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from agent.prompt_builder import format_steer_marker
|
||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
||||
from agent.trajectory import convert_scratchpad_to_think
|
||||
from agent.credential_pool import STATUS_EXHAUSTED
|
||||
from agent.credential_pool import STATUS_EXHAUSTED, credential_pool_matches_provider
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.turn_context import drop_stale_api_content
|
||||
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
|
||||
@@ -52,6 +52,45 @@ logger = logging.getLogger(__name__)
|
||||
_MAX_AUTH_REFRESH_ATTEMPTS = 2
|
||||
|
||||
|
||||
_REASONING_TAG_NAMES = ("think", "thinking", "reasoning", "REASONING_SCRATCHPAD", "thought")
|
||||
_TOOL_CALL_TAG_NAMES = ("tool_call", "tool_calls", "tool_result", "function_call", "function_calls")
|
||||
|
||||
_REASONING_BLOCK_PATTERNS = tuple(
|
||||
re.compile(rf"<{name}>.*?</{name}>", re.DOTALL | re.IGNORECASE)
|
||||
for name in _REASONING_TAG_NAMES
|
||||
)
|
||||
|
||||
_TOOL_CALL_BLOCK_PATTERNS = tuple(
|
||||
re.compile(rf"<{name}\b[^>]*>.*?</{name}>", re.DOTALL | re.IGNORECASE)
|
||||
for name in _TOOL_CALL_TAG_NAMES
|
||||
)
|
||||
|
||||
# Named <function name=...> blocks — see strip_think_blocks step 1c for the
|
||||
# full rationale (sentence-boundary lookbehind + tempered-dot body so a plain
|
||||
# prose mention of "function" is never eaten).
|
||||
_NAMED_FUNCTION_BLOCK_PATTERN = re.compile(
|
||||
r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
|
||||
r'<function\b[^>]*\bname\s*=[^>]*>'
|
||||
r'(?:(?:(?!</function>).)*)</function>',
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
_UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile(
|
||||
rf'(?:^|\n)[ \t]*<(?:{"|".join(_REASONING_TAG_NAMES)})\b[^>]*>.*$',
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
_ORPHAN_REASONING_TAG_PATTERN = re.compile(
|
||||
rf'</?(?:{"|".join(_REASONING_TAG_NAMES)})>\s*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile(
|
||||
rf'</(?:{"|".join(_TOOL_CALL_TAG_NAMES)}|function)>\s*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy ``run_agent`` reference for test-patch routing."""
|
||||
import run_agent
|
||||
@@ -826,62 +865,31 @@ def strip_think_blocks(agent, content: str) -> str:
|
||||
# 1. Closed tag pairs — case-insensitive for all variants so
|
||||
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
|
||||
# the unterminated-tag pass and take trailing content with them.
|
||||
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<thought>.*?</thought>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
for _pattern in _REASONING_BLOCK_PATTERNS:
|
||||
content = _pattern.sub('', content)
|
||||
# 1b. Tool-call XML blocks (openclaw/openclaw#67318). Handle the
|
||||
# generic tag names first — they have no attribute gating since
|
||||
# a literal <tool_call> in prose is already vanishingly rare.
|
||||
for _tc_name in ("tool_call", "tool_calls", "tool_result",
|
||||
"function_call", "function_calls"):
|
||||
content = re.sub(
|
||||
rf'<{_tc_name}\b[^>]*>.*?</{_tc_name}>',
|
||||
'',
|
||||
content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
for _pattern in _TOOL_CALL_BLOCK_PATTERNS:
|
||||
content = _pattern.sub('', content)
|
||||
# 1c. <function name="...">...</function> — Gemma-style standalone
|
||||
# tool call. Only strip when the tag sits at a block boundary
|
||||
# (start of text, after a newline, or after sentence-ending
|
||||
# punctuation) AND carries a name="..." attribute. This keeps
|
||||
# prose mentions like "Use <function> to declare" safe.
|
||||
content = re.sub(
|
||||
r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
|
||||
r'<function\b[^>]*\bname\s*=[^>]*>'
|
||||
r'(?:(?:(?!</function>).)*)</function>',
|
||||
'',
|
||||
content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
content = _NAMED_FUNCTION_BLOCK_PATTERN.sub('', content)
|
||||
# 2. Unterminated reasoning block — open tag at a block boundary
|
||||
# (start of text, or after a newline) with no matching close.
|
||||
# Strip from the tag to end of string. Fixes #8878 / #9568
|
||||
# (MiniMax M2.7 leaking raw reasoning into assistant content).
|
||||
content = re.sub(
|
||||
r'(?:^|\n)[ \t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*$',
|
||||
'',
|
||||
content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
content = _UNTERMINATED_REASONING_BLOCK_PATTERN.sub('', content)
|
||||
# 3. Stray orphan open/close tags that slipped through.
|
||||
content = re.sub(
|
||||
r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*',
|
||||
'',
|
||||
content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
content = _ORPHAN_REASONING_TAG_PATTERN.sub('', content)
|
||||
# 3b. Stray tool-call closers. (We do NOT strip bare <function> or
|
||||
# unterminated <function name="..."> because a truncated tail
|
||||
# during streaming may still be valuable to the user; matches
|
||||
# OpenClaw's intentional asymmetry.)
|
||||
content = re.sub(
|
||||
r'</(?:tool_call|tool_calls|tool_result|function_call|function_calls|function)>\s*',
|
||||
'',
|
||||
content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
content = _STRAY_TOOL_CALL_CLOSER_PATTERN.sub('', content)
|
||||
return content
|
||||
|
||||
|
||||
@@ -1012,6 +1020,13 @@ def recover_with_credential_pool(
|
||||
}
|
||||
if _credential_id:
|
||||
kwargs["credential_id"] = _credential_id
|
||||
# Hand the pool the classified semantics, not just the status. A
|
||||
# billing 403 (OpenRouter "key limit exceeded", xAI spending limit)
|
||||
# and an edge-throttle 403 are the same number but need opposite
|
||||
# cooldowns — the pool can only tell them apart if we say which.
|
||||
# ``effective_reason`` is resolved below; this closure runs after.
|
||||
if effective_reason is not None:
|
||||
kwargs["failure_reason"] = effective_reason.value
|
||||
return pool.mark_exhausted_and_rotate(**kwargs)
|
||||
|
||||
effective_reason = classified_reason
|
||||
@@ -1456,6 +1471,64 @@ def restore_primary_runtime(agent) -> bool:
|
||||
if getattr(agent, "_rate_limited_until", 0) > time.monotonic():
|
||||
return False # primary still in rate-limit cooldown, stay on fallback
|
||||
|
||||
# ── Reset-aware gate ──
|
||||
# The 60s ``_rate_limited_until`` cooldown covers transient rate limits,
|
||||
# but subscription-style providers (Claude Pro/Max 5-hour windows, ChatGPT
|
||||
# weekly limits) report reset times hours or days away. The credential
|
||||
# pool already stores those timestamps (``last_error_reset_at``); until
|
||||
# the earliest one elapses, every restore attempt is a *guaranteed*
|
||||
# failure that costs two prompt-cache invalidations per turn (switch to
|
||||
# primary, fail, switch back to fallback) and re-marshals the full
|
||||
# context each way. Skip the restore while the pool says nobody can
|
||||
# serve, and come back the moment the reset time passes.
|
||||
#
|
||||
# Fail-open by design: any error (unreadable auth store, legacy pool
|
||||
# adapter without ``next_available_at``) falls through to the existing
|
||||
# every-turn retry. A pool with no reset info returns ``None`` and also
|
||||
# falls through — this gate only ever *adds* skips for provably
|
||||
# limited windows, so recovery can never be later than it is today.
|
||||
#
|
||||
# When the attached pool belongs to the fallback provider (cross-provider
|
||||
# fallback rebinds it), the primary pool is loaded here and handed to the
|
||||
# pool-rebind block below via ``prefetched_primary_pool`` so the load
|
||||
# happens at most once per restore.
|
||||
prefetched_primary_pool = None
|
||||
try:
|
||||
primary_provider = str(
|
||||
(agent._primary_runtime or {}).get("provider") or ""
|
||||
).strip().lower()
|
||||
pool = getattr(agent, "_credential_pool", None)
|
||||
if not credential_pool_matches_provider(
|
||||
pool,
|
||||
primary_provider,
|
||||
base_url=str((agent._primary_runtime or {}).get("base_url") or ""),
|
||||
):
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
prefetched_primary_pool = (
|
||||
load_pool(primary_provider) if primary_provider else None
|
||||
)
|
||||
pool = prefetched_primary_pool
|
||||
next_at = getattr(pool, "next_available_at", lambda: None)()
|
||||
if next_at is not None and next_at > time.time():
|
||||
if not getattr(agent, "_restore_wait_logged", False):
|
||||
agent._restore_wait_logged = True
|
||||
logger.info(
|
||||
"Primary %s rate-limited until %s; staying on fallback "
|
||||
"%s/%s until the reset elapses",
|
||||
primary_provider or "?",
|
||||
datetime.fromtimestamp(next_at).isoformat(timespec="seconds"),
|
||||
agent.provider,
|
||||
agent.model,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Reset-aware restore gate failed; falling back to per-turn retry",
|
||||
exc_info=True,
|
||||
)
|
||||
agent._restore_wait_logged = False
|
||||
|
||||
rt = agent._primary_runtime
|
||||
try:
|
||||
# ── Core runtime state ──
|
||||
@@ -1549,6 +1622,11 @@ def restore_primary_runtime(agent) -> bool:
|
||||
agent._credential_pool = None
|
||||
agent._credential_pool_entry_id = None
|
||||
try:
|
||||
if prefetched_primary_pool is not None:
|
||||
# Reuse the pool the reset-aware gate already loaded for
|
||||
# this restore — avoids a second disk read of auth.json.
|
||||
agent._credential_pool = prefetched_primary_pool
|
||||
else:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
agent._credential_pool = load_pool(primary_provider)
|
||||
@@ -1631,6 +1709,7 @@ def restore_primary_runtime(agent) -> bool:
|
||||
# ── Reset fallback chain for the new turn ──
|
||||
agent._fallback_activated = False
|
||||
agent._fallback_index = 0
|
||||
agent._rate_limit_backoff_count = 0 # reset exponential backoff counter
|
||||
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the FALLBACK provider we're leaving; the restored primary deserves
|
||||
@@ -2003,12 +2082,12 @@ def anthropic_prompt_cache_policy(
|
||||
gateway implements the Anthropic cache_control contract
|
||||
(MiniMax, Zhipu GLM, LiteLLM's Anthropic proxy mode all do).
|
||||
|
||||
Qwen models on OpenCode and direct Alibaba (DashScope), plus DeepSeek
|
||||
models on OpenCode, also honour Anthropic-style ``cache_control`` markers
|
||||
on OpenAI-wire chat completions. Upstream pi-mono #3392 / pi #3393
|
||||
documented this for opencode-go Qwen; #24617 reports the same gateway
|
||||
contract for DeepSeek. Without markers these providers serve zero cache
|
||||
hits, re-billing the full prompt on every turn.
|
||||
Qwen / Alibaba-family models on OpenCode, OpenCode Go, and direct
|
||||
Alibaba (DashScope) also honour Anthropic-style ``cache_control``
|
||||
markers on OpenAI-wire chat completions. Upstream pi-mono #3392 /
|
||||
pi #3393 documented this for opencode-go Qwen. Without markers
|
||||
these providers serve zero cache hits, re-billing the full prompt
|
||||
on every turn.
|
||||
|
||||
If the operator has set ``prompt_caching.cache_ttl`` to a falsy value
|
||||
(``false``, ``null``, ``"off"``, etc.) in config.yaml, prompt caching
|
||||
@@ -2135,22 +2214,21 @@ def anthropic_prompt_cache_policy(
|
||||
if is_minimax_provider or is_minimax_host:
|
||||
return True, True
|
||||
|
||||
# Qwen on OpenCode (Zen/Go) and native DashScope, plus DeepSeek on
|
||||
# OpenCode only: OpenAI-wire transports that accept Anthropic-style
|
||||
# cache_control markers and reward them with real cache hits. Keep direct
|
||||
# Alibaba specific to Qwen; its catalog does not establish the same
|
||||
# contract for DeepSeek.
|
||||
# Qwen/Alibaba on OpenCode (Zen/Go) and native DashScope: OpenAI-wire
|
||||
# transport that accepts Anthropic-style cache_control markers and
|
||||
# rewards them with real cache hits. Without this branch
|
||||
# qwen3.6-plus on opencode-go reports 0% cached tokens and burns
|
||||
# through the subscription on every turn.
|
||||
#
|
||||
# NOTE: DeepSeek models on OpenCode are intentionally excluded.
|
||||
# OpenCode Zen's relay rejects the Anthropic-style content block
|
||||
# format that cache markers produce (content becomes a block array
|
||||
# instead of a plain string), causing HTTP 400 (#77217).
|
||||
model_is_qwen = "qwen" in model_lower
|
||||
model_is_deepseek = "deepseek" in model_lower
|
||||
provider_is_opencode = provider_lower in {
|
||||
"opencode", "opencode-zen", "opencode-go",
|
||||
}
|
||||
provider_is_alibaba_family = provider_lower in {
|
||||
"opencode", "opencode-zen", "opencode-go", "alibaba",
|
||||
}
|
||||
if (provider_is_alibaba_family and model_is_qwen) or (
|
||||
provider_is_opencode and model_is_deepseek
|
||||
):
|
||||
if provider_is_alibaba_family and model_is_qwen:
|
||||
# Envelope layout (native_anthropic=False): markers on inner
|
||||
# content parts, not top-level tool messages. Matches
|
||||
# pi-mono's "alibaba" cacheControlFormat.
|
||||
|
||||
+141
-26
@@ -1334,7 +1334,7 @@ def _resolve_anthropic_pool_token() -> Optional[str]:
|
||||
# to auth.json or trigger a network refresh from a bare resolve. select()
|
||||
# is deliberately NOT used — it runs clear_expired=True, refresh=True,
|
||||
# which would violate this read-only contract.
|
||||
entries = pool._available_entries(clear_expired=False, refresh=False)
|
||||
entries, _pending = pool._available_entries(clear_expired=False, refresh=False)
|
||||
except Exception:
|
||||
logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
|
||||
return None
|
||||
@@ -1360,19 +1360,27 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
Priority:
|
||||
1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
||||
2. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
3. ANTHROPIC_API_KEY env var (explicit regular API key)
|
||||
4. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
— with automatic refresh if expired and a refresh token is available
|
||||
4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)
|
||||
5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
5. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)
|
||||
|
||||
Returns the token string or None.
|
||||
"""
|
||||
creds: Optional[Dict[str, Any]] = None
|
||||
creds_loaded = False
|
||||
|
||||
def _read_creds() -> Optional[Dict[str, Any]]:
|
||||
nonlocal creds, creds_loaded
|
||||
if not creds_loaded:
|
||||
creds = read_claude_code_credentials()
|
||||
creds_loaded = True
|
||||
return creds
|
||||
|
||||
# 1. Hermes-managed OAuth/setup token env var
|
||||
token = _getenv("ANTHROPIC_TOKEN").strip()
|
||||
if token:
|
||||
preferred = _prefer_refreshable_claude_code_token(token, creds)
|
||||
preferred = _prefer_refreshable_claude_code_token(token, _read_creds())
|
||||
if preferred:
|
||||
return preferred
|
||||
return token
|
||||
@@ -1380,27 +1388,27 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||
cc_token = _getenv("CLAUDE_CODE_OAUTH_TOKEN").strip()
|
||||
if cc_token:
|
||||
preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
|
||||
preferred = _prefer_refreshable_claude_code_token(cc_token, _read_creds())
|
||||
if preferred:
|
||||
return preferred
|
||||
return cc_token
|
||||
|
||||
# 3. Claude Code credential file
|
||||
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
||||
if resolved_claude_token:
|
||||
return resolved_claude_token
|
||||
|
||||
# 4. Hermes credential_pool OAuth entry.
|
||||
resolved_pool_token = _resolve_anthropic_pool_token()
|
||||
if resolved_pool_token:
|
||||
return resolved_pool_token
|
||||
|
||||
# 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
||||
# 3. Regular API key. An explicit user-configured key must not be shadowed
|
||||
# by auto-discovered Claude Code or credential-pool OAuth credentials.
|
||||
api_key = _getenv("ANTHROPIC_API_KEY").strip()
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# 4. Claude Code credential file
|
||||
resolved_claude_token = _resolve_claude_code_token_from_credentials(_read_creds())
|
||||
if resolved_claude_token:
|
||||
return resolved_claude_token
|
||||
|
||||
# 5. Hermes credential_pool OAuth entry.
|
||||
resolved_pool_token = _resolve_anthropic_pool_token()
|
||||
if resolved_pool_token:
|
||||
return resolved_pool_token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -2311,13 +2319,14 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
|
||||
"""Validate and convert a user message to anthropic format."""
|
||||
if isinstance(content, list):
|
||||
converted_blocks = _convert_content_to_anthropic(content)
|
||||
if not converted_blocks or all(
|
||||
(b.get("text") or "").strip() == ""
|
||||
for b in converted_blocks
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
):
|
||||
converted_blocks = [{"type": "text", "text": "(empty message)"}]
|
||||
return {"role": "user", "content": converted_blocks}
|
||||
kept_blocks = _fix_blank_text_blocks_in_list(
|
||||
converted_blocks,
|
||||
placeholder_text="(empty message)",
|
||||
msg_index=-1,
|
||||
role="user",
|
||||
location="_convert_user_message",
|
||||
)
|
||||
return {"role": "user", "content": kept_blocks}
|
||||
else:
|
||||
if not content or (isinstance(content, str) and not content.strip()):
|
||||
content = "(empty message)"
|
||||
@@ -2620,9 +2629,114 @@ def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
Mirror the Bedrock Converse adapter, which unconditionally prepends a
|
||||
minimal user turn when the first message is not user
|
||||
(convert_messages_to_converse).
|
||||
|
||||
The inserted text block must be non-whitespace: Anthropic separately
|
||||
rejects any text content block whose text is empty or whitespace-only
|
||||
("text content blocks must contain non-whitespace text"), so a single
|
||||
space here traded the "leading assistant turn" 400 for that one (#69512
|
||||
class). Uses the same placeholder as every other synthesized filler
|
||||
block in this module for consistency.
|
||||
"""
|
||||
if result and result[0].get("role") != "user":
|
||||
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
|
||||
result.insert(
|
||||
0, {"role": "user", "content": [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]}
|
||||
)
|
||||
|
||||
|
||||
def _fix_blank_text_blocks_in_list(
|
||||
blocks: List[Any],
|
||||
*,
|
||||
placeholder_text: str,
|
||||
msg_index: int,
|
||||
role: Any,
|
||||
location: str,
|
||||
) -> List[Any]:
|
||||
"""Drop blank/whitespace-only text blocks from ``blocks``, in place logic.
|
||||
|
||||
Non-text blocks (tool_use, tool_result, image, document, thinking, …)
|
||||
and the relative order of everything else are left untouched. A
|
||||
cache_control marker riding on a dropped block is relocated onto the
|
||||
last surviving text/tool_use block so a breakpoint is never silently
|
||||
lost. If nothing survives, a single non-blank placeholder text block
|
||||
takes the dropped blocks' place (carrying the relocated cache_control,
|
||||
if any) so the message never has empty content.
|
||||
|
||||
Returns a new list; does not mutate ``blocks``.
|
||||
"""
|
||||
kept: List[Any] = []
|
||||
relocated_cache_control = None
|
||||
for block_index, blk in enumerate(blocks):
|
||||
if (
|
||||
isinstance(blk, dict)
|
||||
and blk.get("type") == "text"
|
||||
and not (isinstance(blk.get("text"), str) and blk["text"].strip())
|
||||
):
|
||||
if isinstance(blk.get("cache_control"), dict):
|
||||
relocated_cache_control = blk["cache_control"]
|
||||
logger.warning(
|
||||
"Pre-call sanitizer: dropped blank text content block "
|
||||
"(message_index=%d role=%s location=%s block_index=%d "
|
||||
"block_type=text)",
|
||||
msg_index,
|
||||
role,
|
||||
location,
|
||||
block_index,
|
||||
)
|
||||
continue
|
||||
kept.append(blk)
|
||||
if not kept:
|
||||
placeholder: Dict[str, Any] = {"type": "text", "text": placeholder_text}
|
||||
if relocated_cache_control is not None:
|
||||
placeholder["cache_control"] = relocated_cache_control
|
||||
kept.append(placeholder)
|
||||
elif relocated_cache_control is not None:
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(kept, relocated_cache_control)
|
||||
return kept
|
||||
|
||||
|
||||
def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
"""Final provider-boundary guard against blank Anthropic text blocks.
|
||||
|
||||
Anthropic rejects any text content block whose ``text`` is empty or
|
||||
whitespace-only with HTTP 400 ("text content blocks must contain
|
||||
non-whitespace text"). ``_convert_assistant_message``,
|
||||
``_convert_user_message`` and ``_ensure_leading_user_turn`` already
|
||||
avoid emitting these for the paths that build them, but this pass runs
|
||||
last — after every other transform in ``convert_messages_to_anthropic``
|
||||
— so a blank block from any current or future producer (including one
|
||||
nested inside a ``tool_result``'s own content list) never reaches the
|
||||
wire. Diagnostics are structural only: message index, role, content
|
||||
location, block index/type. Never logs message text, tool arguments,
|
||||
tokens, or credentials. Mutates ``result`` in place.
|
||||
"""
|
||||
for msg_index, msg in enumerate(result):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
continue
|
||||
placeholder_text = _EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)"
|
||||
new_content = _fix_blank_text_blocks_in_list(
|
||||
content,
|
||||
placeholder_text=placeholder_text,
|
||||
msg_index=msg_index,
|
||||
role=role,
|
||||
location="content",
|
||||
)
|
||||
for blk in new_content:
|
||||
if not isinstance(blk, dict) or blk.get("type") != "tool_result":
|
||||
continue
|
||||
inner = blk.get("content")
|
||||
if isinstance(inner, list) and inner:
|
||||
blk["content"] = _fix_blank_text_blocks_in_list(
|
||||
inner,
|
||||
placeholder_text="(no output)",
|
||||
msg_index=msg_index,
|
||||
role=role,
|
||||
location="tool_result",
|
||||
)
|
||||
msg["content"] = new_content
|
||||
|
||||
|
||||
def convert_messages_to_anthropic(
|
||||
@@ -2686,6 +2800,7 @@ def convert_messages_to_anthropic(
|
||||
_ensure_leading_user_turn(result)
|
||||
_manage_thinking_signatures(result, base_url, model)
|
||||
_evict_old_screenshots(result)
|
||||
_scrub_blank_text_blocks(result)
|
||||
|
||||
return system, result
|
||||
|
||||
|
||||
@@ -7604,6 +7604,77 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-task concurrency limiting (#23324)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background auxiliary work (title generation, context compression, etc.) can
|
||||
# spawn unbounded concurrent LLM calls when many sessions are active. During
|
||||
# provider incidents each call also retries / fans out across the fallback
|
||||
# chain, multiplying request volume on already-degraded endpoints. A per-task
|
||||
# semaphore caps in-flight calls so retry amplification stays bounded.
|
||||
|
||||
_aux_sync_semaphores: Dict[str, Tuple[int, threading.BoundedSemaphore]] = {}
|
||||
_aux_async_semaphores: Dict[Tuple[str, int], Tuple[int, Any]] = {}
|
||||
_aux_sem_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_task_max_concurrency(task: Optional[str]) -> Optional[int]:
|
||||
"""Return ``auxiliary.<task>.max_concurrency`` as a positive int, or None."""
|
||||
if not task or task == "vision":
|
||||
# Vision already uses this key for its encode/resize CPU worker pool;
|
||||
# its LLM calls deliberately remain concurrent.
|
||||
return None
|
||||
raw = _get_auxiliary_task_config(task).get("max_concurrency")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _acquire_sync_aux_semaphore(task: Optional[str]) -> Optional[threading.BoundedSemaphore]:
|
||||
"""Get a per-task sync semaphore, rebuilding it after a config change."""
|
||||
limit = _get_task_max_concurrency(task)
|
||||
if limit is None:
|
||||
return None
|
||||
with _aux_sem_lock:
|
||||
entry = _aux_sync_semaphores.get(task)
|
||||
if entry is None or entry[0] != limit:
|
||||
semaphore = threading.BoundedSemaphore(limit)
|
||||
_aux_sync_semaphores[task] = (limit, semaphore)
|
||||
return semaphore
|
||||
return entry[1]
|
||||
|
||||
|
||||
def _acquire_async_aux_semaphore(task: Optional[str]):
|
||||
"""Get a per-task, per-event-loop async semaphore after config lookup."""
|
||||
limit = _get_task_max_concurrency(task)
|
||||
if limit is None:
|
||||
return None
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return None
|
||||
key = (task, id(loop))
|
||||
with _aux_sem_lock:
|
||||
entry = _aux_async_semaphores.get(key)
|
||||
if entry is None or entry[0] != limit:
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
_aux_async_semaphores[key] = (limit, semaphore)
|
||||
return semaphore
|
||||
return entry[1]
|
||||
|
||||
|
||||
def _reset_aux_semaphores() -> None:
|
||||
"""Drop cached semaphores (test helper)."""
|
||||
with _aux_sem_lock:
|
||||
_aux_sync_semaphores.clear()
|
||||
_aux_async_semaphores.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic-compatible endpoint detection + image block conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -8476,6 +8547,75 @@ def call_llm(
|
||||
api_mode: str = None,
|
||||
stream: bool = False,
|
||||
stream_options: dict = None,
|
||||
) -> Any:
|
||||
"""Run an auxiliary LLM request, applying the configured task limit."""
|
||||
semaphore = _acquire_sync_aux_semaphore(task)
|
||||
if semaphore is not None:
|
||||
semaphore.acquire()
|
||||
try:
|
||||
response = _call_llm_impl(
|
||||
task=task,
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
main_runtime=main_runtime,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
timeout=timeout,
|
||||
extra_body=extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
extra_headers=extra_headers,
|
||||
api_mode=api_mode,
|
||||
stream=stream,
|
||||
stream_options=stream_options,
|
||||
)
|
||||
if stream and semaphore is not None:
|
||||
stream_semaphore = semaphore
|
||||
semaphore = None
|
||||
return _release_sync_semaphore_after_stream(response, stream_semaphore)
|
||||
return response
|
||||
finally:
|
||||
if semaphore is not None:
|
||||
semaphore.release()
|
||||
|
||||
|
||||
def _release_sync_semaphore_after_stream(
|
||||
stream: Any, semaphore: threading.BoundedSemaphore,
|
||||
):
|
||||
"""Release a permit only after a streaming response is consumed or closed."""
|
||||
try:
|
||||
yield from stream
|
||||
finally:
|
||||
try:
|
||||
close = getattr(stream, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
finally:
|
||||
semaphore.release()
|
||||
|
||||
|
||||
def _call_llm_impl(
|
||||
task: str = None,
|
||||
*,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
messages: list,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: int = None,
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
api_mode: str = None,
|
||||
stream: bool = False,
|
||||
stream_options: dict = None,
|
||||
) -> Any:
|
||||
"""Centralized synchronous LLM call.
|
||||
|
||||
@@ -9239,6 +9379,47 @@ async def async_call_llm(
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
) -> Any:
|
||||
"""Run an asynchronous auxiliary LLM request under the configured limit."""
|
||||
semaphore = _acquire_async_aux_semaphore(task)
|
||||
if semaphore is not None:
|
||||
await semaphore.acquire()
|
||||
try:
|
||||
return await _async_call_llm_impl(
|
||||
task=task,
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
main_runtime=main_runtime,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
timeout=timeout,
|
||||
extra_body=extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
)
|
||||
finally:
|
||||
if semaphore is not None:
|
||||
semaphore.release()
|
||||
|
||||
|
||||
async def _async_call_llm_impl(
|
||||
task: str = None,
|
||||
*,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
messages: list,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: int = None,
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
) -> Any:
|
||||
"""Centralized asynchronous LLM call.
|
||||
|
||||
|
||||
@@ -1712,7 +1712,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
primary_provider = ((agent._primary_runtime or {}).get("provider") or "").strip().lower()
|
||||
if (not fallback_already_active) or (primary_provider and current_provider == primary_provider):
|
||||
agent._rate_limited_until = time.monotonic() + 60
|
||||
# Exponential backoff: keep upstream's 60s first-hit cooldown and
|
||||
# escalate on CONSECUTIVE rate-limits: 60s → 2m → 4m → 8m → ... →
|
||||
# 4h cap. The first 429 must NOT bench the primary for half an
|
||||
# hour — fast primary restore is the common case; escalation only
|
||||
# punishes providers that keep 429ing.
|
||||
# Counter is reset by restore_primary_runtime on successful restore.
|
||||
backoff_count = getattr(agent, "_rate_limit_backoff_count", 0)
|
||||
agent._rate_limit_backoff_count = backoff_count + 1
|
||||
backoff_seconds = min(60 * (2 ** backoff_count), 14400)
|
||||
agent._rate_limited_until = time.monotonic() + backoff_seconds
|
||||
logging.info(
|
||||
"Rate-limit backoff level %d: cooldown %d s (%.1f min, backoff#%d)",
|
||||
backoff_count, backoff_seconds, backoff_seconds / 60, backoff_count + 1,
|
||||
)
|
||||
if agent._fallback_index >= len(agent._fallback_chain):
|
||||
# Chain exhausted. If we actually walked a non-empty chain and the
|
||||
# failure was NOT a rate-limit/billing event (those already armed
|
||||
|
||||
@@ -6730,6 +6730,26 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
_strip_persistence_markers(compressed)
|
||||
self._last_compression_made_progress = True
|
||||
|
||||
# A successful compaction just freed the largest allocation a long
|
||||
# session ever drops (the compressed-away message dicts), which makes
|
||||
# this the natural point to hand allocator pages back to the OS.
|
||||
# #76905's trim lifecycle covers the gateway/TUI housekeeping loops but
|
||||
# not the CLI compression path, so RSS keeps the pre-compaction
|
||||
# high-water mark until exit. The helper is glibc-gated, config-gated
|
||||
# and rate-limited, so this is a safe no-op elsewhere. (#70782)
|
||||
try:
|
||||
from hermes_cli.mem_trim import trim_memory
|
||||
|
||||
trim_memory(reason="post-compression")
|
||||
except Exception as exc:
|
||||
# debug, not warning: sibling trim sites all log failures at
|
||||
# debug, and compression must never fail because of a trim.
|
||||
logger.debug(
|
||||
"post-compression memory trim failed: %s: %s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Batch compaction invalidates micro-compaction state: the batch
|
||||
# marker now holds MORE history than the in-memory rolling summary
|
||||
# (it summarized everything in the window, including exchanges micro
|
||||
|
||||
+138
-7
@@ -58,6 +58,11 @@ from agent.message_sanitization import (
|
||||
_strip_images_from_messages,
|
||||
_strip_non_ascii,
|
||||
)
|
||||
# Must mirror _STALE_TOOL_CALL_MARKER_RE in hermes_state.py — kept local
|
||||
# to avoid importing hermes_state at module load time (its module-level
|
||||
# DEFAULT_DB_PATH = get_hermes_home() / "state.db" breaks tests that
|
||||
# monkeypatch get_hermes_home to return a str).
|
||||
_STALE_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$")
|
||||
from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
_estimate_tools_tokens_rough,
|
||||
@@ -1286,6 +1291,14 @@ def run_conversation(
|
||||
agent._last_compression_attempt_recorded = False
|
||||
agent._last_compression_attempt_in_place = None
|
||||
|
||||
# Adopt any ~/.hermes/.env credential/base-url edits made since the last
|
||||
# turn — a Settings save updates .env but not this worker's client, which
|
||||
# was built at agent init (#67821). No-op when .env is unchanged.
|
||||
try:
|
||||
agent._try_refresh_env_client_credentials()
|
||||
except Exception:
|
||||
logger.debug("per-turn env credential refresh failed", exc_info=True)
|
||||
|
||||
# ── Per-turn setup (the prologue) ──
|
||||
# All once-per-turn setup — stdio guarding, retry-counter resets, user
|
||||
# message sanitization, todo/nudge hydration, system-prompt restore-or-
|
||||
@@ -4229,6 +4242,28 @@ def run_conversation(
|
||||
f" Check which providers support tools: https://openrouter.ai/models/{_model}"
|
||||
)
|
||||
|
||||
# Actionable hint for a bare 404 on a provider whose catalogue
|
||||
# uses ``vendor/model`` ids. A model id that lost its prefix
|
||||
# (e.g. ``nemotron-…`` instead of ``nvidia/nemotron-…``) gets
|
||||
# a content-free "404 page not found" from the provider that
|
||||
# never names the model, so it reads like an outage or an auth
|
||||
# failure. Name the real cause and the exact id to use (#78796).
|
||||
if getattr(api_error, "status_code", None) == 404:
|
||||
try:
|
||||
from hermes_cli.model_normalize import suggest_prefixed_model_id
|
||||
|
||||
_suggestion = suggest_prefixed_model_id(_provider, _model)
|
||||
except Exception:
|
||||
_suggestion = None
|
||||
if _suggestion:
|
||||
agent._buffer_vprint(
|
||||
f" 💡 Model '{_model}' is not a valid id for provider {_provider} — "
|
||||
f"it is missing its vendor prefix."
|
||||
)
|
||||
agent._buffer_vprint(
|
||||
f" Did you mean '{_suggestion}'? Re-pick it with `hermes model`."
|
||||
)
|
||||
|
||||
# Check for interrupt before deciding to retry
|
||||
if agent._interrupt_requested:
|
||||
# Preserve a pending redirect (mid-stream correction): the
|
||||
@@ -4360,9 +4395,11 @@ def run_conversation(
|
||||
compression_attempts += 1
|
||||
if compression_attempts <= max_compression_attempts:
|
||||
original_len = len(messages)
|
||||
# Option A (LCM issue 441): overhead-aware request size so recovery arms on
|
||||
# the true request (msgs + tools + system), not the tool-blind message count.
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message,
|
||||
approx_tokens=approx_tokens,
|
||||
approx_tokens=estimate_request_tokens_rough(api_messages, tools=agent.tools or None),
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
@@ -4617,8 +4654,11 @@ def run_conversation(
|
||||
original_len = len(messages)
|
||||
original_tokens = estimate_messages_tokens_rough(messages)
|
||||
_overflow_input = messages
|
||||
# Option A (LCM issue 441): overhead-aware request size so recovery arms on the
|
||||
# true request (msgs + tools + system), not the tool-blind message count.
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
messages, system_message,
|
||||
approx_tokens=estimate_request_tokens_rough(api_messages, tools=agent.tools or None),
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
||||
@@ -4754,6 +4794,41 @@ def run_conversation(
|
||||
"failed": True,
|
||||
"compression_exhausted": True,
|
||||
}
|
||||
# Also compress the message history so the output-cap
|
||||
# retry does not just spin on max_tokens alone. The
|
||||
# compressor drops the middle window, freeing enough
|
||||
# tokens for the total to fit inside context_length.
|
||||
# (#55546)
|
||||
try:
|
||||
original_len = len(messages)
|
||||
original_tokens = estimate_messages_tokens_rough(messages)
|
||||
_overflow_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message,
|
||||
approx_tokens=request_input_estimate,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
||||
compression_attempts -= 1
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return _compression_deferred_result(
|
||||
agent, messages, api_call_count
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
new_tokens = estimate_messages_tokens_rough(messages)
|
||||
if len(messages) < original_len:
|
||||
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
|
||||
elif new_tokens > 0 and new_tokens < original_tokens * 0.95:
|
||||
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
|
||||
except Exception:
|
||||
# Compression must never turn an output-cap error
|
||||
# fatal — fall through and retry on max_tokens alone.
|
||||
logger.warning(
|
||||
"%sOutput-cap compression hit an error; retrying on max_tokens only.",
|
||||
agent.log_prefix,
|
||||
)
|
||||
_retry.restart_with_compressed_messages = True
|
||||
break
|
||||
|
||||
@@ -4878,8 +4953,13 @@ def run_conversation(
|
||||
original_len = len(messages)
|
||||
original_tokens = estimate_messages_tokens_rough(messages)
|
||||
_overflow_input = messages
|
||||
# Option A (LCM issue 441): pass the OVERHEAD-AWARE request size (msgs + tool
|
||||
# schemas + system), not the tool-blind message count, so LCM forced-overflow
|
||||
# recovery arms on the TRUE request that overflowed. See hermes-lcm engine
|
||||
# _should_force_overflow_recovery. (approx_tokens stays for the status display.)
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
messages, system_message,
|
||||
approx_tokens=estimate_request_tokens_rough(api_messages, tools=agent.tools or None),
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
||||
@@ -6110,6 +6190,22 @@ def run_conversation(
|
||||
|
||||
turn_content = assistant_message.content or ""
|
||||
|
||||
# Some local tool-call templates emit a bare bracketed token
|
||||
# (for example ``[memory]``) as assistant content alongside a
|
||||
# function call. It is protocol scaffolding, not an answer.
|
||||
# Persisting or caching it as visible content lets the empty
|
||||
# post-tool fallback replay that token forever after compaction (#78148).
|
||||
if (
|
||||
assistant_message.tool_calls
|
||||
and _STALE_MARKER_RE.fullmatch(turn_content.strip())
|
||||
):
|
||||
logger.warning(
|
||||
"Discarding bare tool-call marker from assistant content: %s",
|
||||
turn_content,
|
||||
)
|
||||
turn_content = ""
|
||||
assistant_msg["content"] = ""
|
||||
|
||||
# Classify tools in this turn to determine if they are all housekeeping.
|
||||
# This classification is needed regardless of whether the turn has visible content,
|
||||
# because a substantive tool-only turn must invalidate any older housekeeping fallback.
|
||||
@@ -6373,9 +6469,12 @@ def run_conversation(
|
||||
_clear_warn()
|
||||
agent._safe_print(" ⟳ compacting context…")
|
||||
_post_tool_input = messages
|
||||
# Route the overhead-aware _real_tokens (computed above) into compression, not
|
||||
# the bare last_prompt_tokens — which is 0 in the no-usage fallback, hiding the
|
||||
# true request size from the engine's overflow guard (upstream PR #77169 review).
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message,
|
||||
approx_tokens=agent.context_compressor.last_prompt_tokens,
|
||||
approx_tokens=_real_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if (
|
||||
@@ -6662,14 +6761,46 @@ def run_conversation(
|
||||
)
|
||||
if _truly_empty and (not _has_structured or _prefill_exhausted) and agent._empty_content_retries < 3:
|
||||
agent._empty_content_retries += 1
|
||||
wait_time = jittered_backoff(
|
||||
agent._empty_content_retries,
|
||||
base_delay=5.0,
|
||||
max_delay=60.0,
|
||||
)
|
||||
logger.warning(
|
||||
"Empty response (no content or reasoning) — "
|
||||
"retry %d/3 (model=%s)",
|
||||
agent._empty_content_retries, agent.model,
|
||||
"retry %d/3 in %.1fs (model=%s)",
|
||||
agent._empty_content_retries, wait_time, agent.model,
|
||||
)
|
||||
agent._buffer_status(
|
||||
f"⚠️ Empty response from model — retrying "
|
||||
f"({agent._empty_content_retries}/3)"
|
||||
f"({agent._empty_content_retries}/3) in {wait_time:.0f}s"
|
||||
)
|
||||
# Sleep in small increments to stay responsive to interrupts
|
||||
sleep_end = time.time() + wait_time
|
||||
_backoff_touch_counter = 0
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during empty-response retry wait, aborting.", force=True)
|
||||
_interrupt_text = (
|
||||
f"Operation interrupted: retrying empty response from model "
|
||||
f"(retry {agent._empty_content_retries}/3)."
|
||||
)
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"interrupted": True,
|
||||
}
|
||||
time.sleep(0.2)
|
||||
_backoff_touch_counter += 1
|
||||
if _backoff_touch_counter % 150 == 0: # 150 × 0.2s = 30s
|
||||
agent._touch_activity(
|
||||
f"empty response retry backoff ({agent._empty_content_retries}/3), "
|
||||
f"{int(sleep_end - time.time())}s remaining"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
+321
-63
@@ -124,6 +124,17 @@ SUPPORTED_POOL_STRATEGIES = {
|
||||
EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
|
||||
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
|
||||
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
|
||||
# When a pool has no other credential to rotate to (the offending key is the
|
||||
# sole non-DEAD entry), a 1-hour bench means an hour of hard failures with
|
||||
# nothing to fall back to. Throttles (429/403/5xx) are transient and reset in
|
||||
# seconds, so a sole credential cools down briefly instead — same rationale as
|
||||
# the short 401 cooldown above. Provider-supplied reset_at still overrides.
|
||||
EXHAUSTED_TTL_SOLE_CREDENTIAL_SECONDS = 60 # 1 minute
|
||||
|
||||
# ``FailoverReason.billing`` as a bare string. The pool stores classified
|
||||
# failure semantics as plain text (it persists to JSON and must not import
|
||||
# the classifier), so the value is duplicated here rather than referenced.
|
||||
FAILURE_REASON_BILLING = "billing"
|
||||
|
||||
# Throttle window for the "no available entries" INFO line. Credential
|
||||
# selection runs on a hot path (every model call, plus auxiliary tasks like
|
||||
@@ -150,6 +161,13 @@ _EXTRA_KEYS = frozenset({
|
||||
"token_type", "scope", "client_id", "portal_base_url", "obtained_at",
|
||||
"expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused",
|
||||
"agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint",
|
||||
# Classified failure semantics for the last exhaustion, as decided by
|
||||
# agent/error_classifier.py. The raw HTTP status is not enough to size a
|
||||
# cooldown: providers return 403 for both an edge throttle (transient,
|
||||
# seconds) and a spending/key limit (billing, needs a real fix). Persisted
|
||||
# with the entry so a restart doesn't downgrade a billing bench back to a
|
||||
# 60s transient cooldown.
|
||||
"failure_reason",
|
||||
})
|
||||
|
||||
|
||||
@@ -289,13 +307,39 @@ def _is_manual_source(source: str) -> bool:
|
||||
return normalized == SOURCE_MANUAL or normalized.startswith(f"{SOURCE_MANUAL}:")
|
||||
|
||||
|
||||
def _exhausted_ttl(error_code: Optional[int]) -> int:
|
||||
"""Return cooldown seconds based on the HTTP status that caused exhaustion."""
|
||||
def _exhausted_ttl(
|
||||
error_code: Optional[int],
|
||||
*,
|
||||
sole_credential: bool = False,
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Return cooldown seconds based on the HTTP status that caused exhaustion.
|
||||
|
||||
When *sole_credential* is True the pool has no other entry to rotate to, so
|
||||
a long bench just blocks the only key. Transient throttles (429 and the
|
||||
catch-all default, which covers 403/5xx/unknown) are capped to a brief
|
||||
cooldown so the sole key can recover — mirroring the short 401 path. 401
|
||||
keeps its own (already short) TTL.
|
||||
|
||||
*failure_reason* is the classified semantics from
|
||||
``agent/error_classifier.py``. The raw status alone can't size the
|
||||
cooldown: an OpenRouter ``key limit exceeded`` and an xAI spending-limit
|
||||
block both arrive as **403** but classify as ``billing``, and a 60s retry
|
||||
on a spent account just re-fails every minute. Billing keeps the full
|
||||
bench regardless of status; 402 does too, since it is billing by
|
||||
definition even when nothing classified it.
|
||||
"""
|
||||
if error_code == 401:
|
||||
return EXHAUSTED_TTL_401_SECONDS
|
||||
if error_code == 429:
|
||||
return EXHAUSTED_TTL_429_SECONDS
|
||||
return EXHAUSTED_TTL_DEFAULT_SECONDS
|
||||
base = EXHAUSTED_TTL_429_SECONDS if error_code == 429 else EXHAUSTED_TTL_DEFAULT_SECONDS
|
||||
# Sole credential: shorten only TRANSIENT throttles (429 rate-limit, 403
|
||||
# edge-throttle, 5xx server, or unknown). Billing exhaustion — whether
|
||||
# classified as such or self-evident from a 402 — is a genuine depletion
|
||||
# where a quick retry can't help, so it keeps the full bench.
|
||||
is_billing = error_code == 402 or failure_reason == FAILURE_REASON_BILLING
|
||||
if sole_credential and not is_billing:
|
||||
return min(base, EXHAUSTED_TTL_SOLE_CREDENTIAL_SECONDS)
|
||||
return base
|
||||
|
||||
|
||||
def _parse_absolute_timestamp(value: Any) -> Optional[float]:
|
||||
@@ -376,14 +420,18 @@ def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[st
|
||||
return normalized
|
||||
|
||||
|
||||
def _exhausted_until(entry: PooledCredential) -> Optional[float]:
|
||||
def _exhausted_until(entry: PooledCredential, *, sole_credential: bool = False) -> Optional[float]:
|
||||
if entry.last_status != STATUS_EXHAUSTED:
|
||||
return None
|
||||
reset_at = _parse_absolute_timestamp(getattr(entry, "last_error_reset_at", None))
|
||||
if reset_at is not None:
|
||||
return reset_at
|
||||
if entry.last_status_at:
|
||||
return entry.last_status_at + _exhausted_ttl(entry.last_error_code)
|
||||
return entry.last_status_at + _exhausted_ttl(
|
||||
entry.last_error_code,
|
||||
sole_credential=sole_credential,
|
||||
failure_reason=getattr(entry, "failure_reason", None),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -588,7 +636,12 @@ class CredentialPool:
|
||||
self._entries = sorted(entries, key=lambda entry: entry.priority)
|
||||
self._current_id: Optional[str] = None
|
||||
self._strategy = get_pool_strategy(provider)
|
||||
self._lock = threading.Lock()
|
||||
# RLock: the mutation primitives below (_replace_entry/_persist)
|
||||
# self-acquire this lock so the DEFERRED single-use-token refresh
|
||||
# path (which runs network I/O outside the lock by design) still
|
||||
# serializes its pool mutations. In-lock callers re-acquire
|
||||
# reentrantly at negligible cost.
|
||||
self._lock = threading.RLock()
|
||||
self._active_leases: Dict[str, int] = {}
|
||||
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
|
||||
# Monotonic timestamp of the last "no available entries" log, used to
|
||||
@@ -618,7 +671,43 @@ class CredentialPool:
|
||||
# otherwise a status probe here can race a concurrent ``select`` /
|
||||
# rotation and tear ``self._entries`` or double-write auth.json.
|
||||
with self._lock:
|
||||
return bool(self._available_entries())
|
||||
available, _pending = self._available_entries()
|
||||
return bool(available)
|
||||
|
||||
def next_available_at(self) -> Optional[float]:
|
||||
"""Earliest epoch time (seconds) any entry re-enters rotation.
|
||||
|
||||
Returns ``None`` when at least one entry is available right now, or
|
||||
when no exhausted entry carries a usable recovery time (empty pool,
|
||||
or only ``STATUS_DEAD`` entries, which never re-enter via TTL).
|
||||
Callers must treat ``None`` as "no wait information", not
|
||||
"unavailable".
|
||||
|
||||
Like :meth:`has_available`, expired cooldowns are left uncleared
|
||||
(``clear_expired=False``); the only writes are the same
|
||||
re-auth/token sync paths ``has_available`` already performs — which
|
||||
is exactly why this must run under ``self._lock`` like every other
|
||||
``_available_entries`` caller (see the comment on ``has_available``).
|
||||
"""
|
||||
with self._lock:
|
||||
available, _pending = self._available_entries()
|
||||
if available:
|
||||
return None
|
||||
# Mirror _available_entries: if the pool has no other credential
|
||||
# to rotate to, the sole entry's transient throttle cools down in
|
||||
# seconds — next_available_at must report that shorter window too,
|
||||
# or the fallback restore gate waits an hour for a 60s cooldown.
|
||||
sole_credential = sum(
|
||||
1 for e in self._entries if e.last_status != STATUS_DEAD
|
||||
) <= 1
|
||||
candidates: List[float] = []
|
||||
for entry in self._entries:
|
||||
if entry.last_status != STATUS_EXHAUSTED:
|
||||
continue
|
||||
until = _exhausted_until(entry, sole_credential=sole_credential)
|
||||
if until is not None:
|
||||
candidates.append(until)
|
||||
return min(candidates) if candidates else None
|
||||
|
||||
def entries(self) -> List[PooledCredential]:
|
||||
with self._lock:
|
||||
@@ -656,13 +745,22 @@ class CredentialPool:
|
||||
return matches[0].id if len(matches) == 1 else None
|
||||
|
||||
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
|
||||
"""Swap an entry in-place by id, preserving sort order."""
|
||||
"""Swap an entry in-place by id, preserving sort order.
|
||||
|
||||
Self-locking (RLock) so the deferred refresh path — which
|
||||
deliberately runs outside the pool lock — cannot tear
|
||||
``self._entries`` against a concurrent select()/rotation.
|
||||
"""
|
||||
with self._lock:
|
||||
for idx, entry in enumerate(self._entries):
|
||||
if entry.id == old.id:
|
||||
self._entries[idx] = new
|
||||
return
|
||||
|
||||
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
|
||||
# Self-locking (RLock): snapshotting self._entries must not race a
|
||||
# concurrent rotation when called from the deferred refresh path.
|
||||
with self._lock:
|
||||
write_credential_pool(
|
||||
self.provider,
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
@@ -699,6 +797,7 @@ class CredentialPool:
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
persist: bool = True,
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> PooledCredential:
|
||||
normalized_error = _normalize_error_context(error_context)
|
||||
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
||||
@@ -712,6 +811,15 @@ class CredentialPool:
|
||||
terminal_status = STATUS_DEAD
|
||||
else:
|
||||
terminal_status = STATUS_EXHAUSTED
|
||||
# Carry the classifier's verdict onto the entry so the cooldown can be
|
||||
# sized by what actually failed, not just the HTTP status (a billing
|
||||
# 403 must not get the sole-credential transient cooldown). Absent a
|
||||
# classification, clear any stale verdict from a previous failure.
|
||||
updated_extra = dict(entry.extra)
|
||||
if failure_reason:
|
||||
updated_extra["failure_reason"] = failure_reason
|
||||
else:
|
||||
updated_extra.pop("failure_reason", None)
|
||||
updated = replace(
|
||||
entry,
|
||||
last_status=terminal_status,
|
||||
@@ -720,6 +828,7 @@ class CredentialPool:
|
||||
last_error_reason=normalized_error.get("reason"),
|
||||
last_error_message=normalized_error.get("message"),
|
||||
last_error_reset_at=normalized_error.get("reset_at"),
|
||||
extra=updated_extra,
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
if persist:
|
||||
@@ -1415,6 +1524,11 @@ class CredentialPool:
|
||||
logger.debug(
|
||||
"Failed to clear terminal xAI OAuth state: %s", clear_exc
|
||||
)
|
||||
# Read-modify-write of self._entries: must be atomic.
|
||||
# This runs on the DEFERRED refresh path (outside the
|
||||
# pool lock), so take it here. self._lock is an RLock,
|
||||
# so the still-locked callers re-enter safely.
|
||||
with self._lock:
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source == "device_code"
|
||||
@@ -1485,6 +1599,11 @@ class CredentialPool:
|
||||
logger.debug(
|
||||
"Failed to clear terminal Codex OAuth state: %s", clear_exc
|
||||
)
|
||||
# Read-modify-write of self._entries: must be atomic.
|
||||
# This runs on the DEFERRED refresh path (outside the
|
||||
# pool lock), so take it here. self._lock is an RLock,
|
||||
# so the still-locked callers re-enter safely.
|
||||
with self._lock:
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source == "device_code"
|
||||
@@ -1552,6 +1671,8 @@ class CredentialPool:
|
||||
auth_mod.NOUS_DEVICE_CODE_SOURCE,
|
||||
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
|
||||
}
|
||||
# Atomic read-modify-write; see the note above.
|
||||
with self._lock:
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source in singleton_sources
|
||||
@@ -1646,26 +1767,70 @@ class CredentialPool:
|
||||
return False
|
||||
|
||||
def select(self) -> Optional[PooledCredential]:
|
||||
with self._lock:
|
||||
entry = self._select_unlocked()
|
||||
entry, pending_refresh = self._select_under_lock()
|
||||
if pending_refresh:
|
||||
self._refresh_pending_entries(pending_refresh)
|
||||
if entry is not None:
|
||||
self._unmatched_rotation_streak = 0
|
||||
return entry
|
||||
# If no entry was available but we just refreshed some, re-select
|
||||
# now that the refreshed entries are back in the pool.
|
||||
if pending_refresh:
|
||||
entry, _ = self._select_under_lock()
|
||||
if entry is not None:
|
||||
# A normal (non-recovery) selection starts a fresh episode —
|
||||
# don't let a leftover unmatched-rotation streak from an old
|
||||
# failure trip the #70401 bound early next time.
|
||||
self._unmatched_rotation_streak = 0
|
||||
return entry
|
||||
|
||||
def _available_entries(self, *, clear_expired: bool = False, refresh: bool = False) -> List[PooledCredential]:
|
||||
"""Return entries not currently in exhaustion cooldown.
|
||||
def _select_under_lock(self) -> Tuple[Optional[PooledCredential], List[tuple]]:
|
||||
"""Run selection under the lock, returning entry + pending refreshes."""
|
||||
with self._lock:
|
||||
return self._select_unlocked()
|
||||
|
||||
def _refresh_pending_entries(self, pending: List[tuple]) -> None:
|
||||
"""Refresh deferred single-use-token entries outside the lock.
|
||||
|
||||
Each entry is refreshed under the cross-process ``_auth_store_lock``
|
||||
(which can block for 20+ seconds) and then merged into the pool.
|
||||
On failure the entry is silently skipped.
|
||||
"""
|
||||
for entry, sync_fn in pending:
|
||||
# _refresh_entry merges the refreshed entry into the pool
|
||||
# internally. Its mutation primitives (_replace_entry, _persist)
|
||||
# are self-locking, and the quarantine paths inside
|
||||
# _refresh_entry_impl take self._lock explicitly around their
|
||||
# read-modify-write of self._entries — required because this
|
||||
# call site runs OUTSIDE the pool lock.
|
||||
self._refresh_entry(entry, force=False)
|
||||
|
||||
def _available_entries(
|
||||
self, *, clear_expired: bool = False, refresh: bool = False,
|
||||
) -> Tuple[List[PooledCredential], List[tuple]]:
|
||||
"""Return (available, pending_refresh) for entries not in cooldown.
|
||||
|
||||
When *clear_expired* is True, entries whose cooldown has elapsed are
|
||||
reset to STATUS_OK and persisted. When *refresh* is True, entries
|
||||
that need a token refresh are refreshed (skipped on failure).
|
||||
|
||||
Single-use-token refreshes (openai-codex, xai-oauth) are returned as
|
||||
*pending_refresh* tuples so the caller can execute them outside the
|
||||
lock, avoiding stalling all pool consumers during cross-process flock
|
||||
acquisition + OAuth network I/O.
|
||||
"""
|
||||
now = time.time()
|
||||
cleared_any = False
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
# Entries that need an OAuth refresh via a single-use token provider
|
||||
# (openai-codex, xai-oauth). These require a cross-process file lock
|
||||
# that can block for 20+ seconds. We collect them under self._lock
|
||||
# and refresh outside the lock to avoid stalling all pool consumers.
|
||||
pending_refresh: List[tuple] = [] # (entry, sync_entry_fn)
|
||||
# DEAD entries never re-enter rotation, so if at most one non-DEAD entry
|
||||
# exists there is nothing to rotate to: an exhausted sole credential
|
||||
# should cool down briefly rather than bench the only key for an hour.
|
||||
sole_credential = sum(
|
||||
1 for e in self._entries if e.last_status != STATUS_DEAD
|
||||
) <= 1
|
||||
for entry in self._entries:
|
||||
# Borrowed credentials persist as metadata-only references and are
|
||||
# hydrated from their live source on load. A stale duplicate row
|
||||
@@ -1746,7 +1911,7 @@ class CredentialPool:
|
||||
# the re-auth case for OAuth singletons.
|
||||
continue
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
exhausted_until = _exhausted_until(entry, sole_credential=sole_credential)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
# Codex quota windows can reopen EARLY: the user redeems a
|
||||
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
|
||||
@@ -1774,6 +1939,16 @@ class CredentialPool:
|
||||
entry = cleared
|
||||
cleared_any = True
|
||||
if refresh and self._entry_needs_refresh(entry):
|
||||
if self.provider in ("openai-codex", "xai-oauth"):
|
||||
# Defer single-use-token refresh to avoid holding the
|
||||
# threading lock during cross-process flock + network I/O.
|
||||
sync_fn = (
|
||||
self._sync_codex_entry_from_auth_store
|
||||
if self.provider == "openai-codex"
|
||||
else self._sync_xai_oauth_entry_from_pool_store
|
||||
)
|
||||
pending_refresh.append((entry, sync_fn))
|
||||
continue
|
||||
refreshed = self._refresh_entry(entry, force=False)
|
||||
if refreshed is None:
|
||||
continue
|
||||
@@ -1784,7 +1959,7 @@ class CredentialPool:
|
||||
self._entries = [e for e in self._entries if e.id not in pruned_ids]
|
||||
if cleared_any:
|
||||
self._persist(removed_ids=entries_to_prune)
|
||||
return available
|
||||
return available, pending_refresh
|
||||
|
||||
def _log_no_available_entries(self) -> None:
|
||||
"""Emit the empty-pool INFO line at most once per throttle window.
|
||||
@@ -1800,12 +1975,17 @@ class CredentialPool:
|
||||
self._last_no_entries_log_at = now
|
||||
logger.info("credential pool: no available entries (all exhausted or empty)")
|
||||
|
||||
def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
|
||||
available = self._available_entries(clear_expired=True, refresh=refresh)
|
||||
def _select_unlocked(self, *, refresh: bool = True) -> Tuple[Optional[PooledCredential], List[tuple]]:
|
||||
"""Select the best available credential entry.
|
||||
|
||||
Returns ``(entry, pending_refresh)`` where *pending_refresh* contains
|
||||
single-use-token entries that must be refreshed outside the lock.
|
||||
"""
|
||||
available, pending_refresh = self._available_entries(clear_expired=True, refresh=refresh)
|
||||
if not available:
|
||||
self._current_id = None
|
||||
self._log_no_available_entries()
|
||||
return None
|
||||
return None, pending_refresh
|
||||
|
||||
# A successful selection means the pool recovered; re-arm the throttle
|
||||
# so a later re-exhaustion logs immediately rather than being silenced
|
||||
@@ -1815,7 +1995,7 @@ class CredentialPool:
|
||||
if self._strategy == STRATEGY_RANDOM:
|
||||
entry = random.choice(available)
|
||||
self._current_id = entry.id
|
||||
return entry
|
||||
return entry, pending_refresh
|
||||
|
||||
if self._strategy == STRATEGY_LEAST_USED and len(available) > 1:
|
||||
entry = min(available, key=lambda e: e.request_count)
|
||||
@@ -1823,7 +2003,7 @@ class CredentialPool:
|
||||
updated = replace(entry, request_count=entry.request_count + 1)
|
||||
self._replace_entry(entry, updated)
|
||||
self._current_id = entry.id
|
||||
return updated
|
||||
return updated, pending_refresh
|
||||
|
||||
if self._strategy == STRATEGY_ROUND_ROBIN and len(available) > 1:
|
||||
entry = available[0]
|
||||
@@ -1832,11 +2012,11 @@ class CredentialPool:
|
||||
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
|
||||
self._persist()
|
||||
self._current_id = entry.id
|
||||
return self._current_unlocked() or entry
|
||||
return self._current_unlocked() or entry, pending_refresh
|
||||
|
||||
entry = available[0]
|
||||
self._current_id = entry.id
|
||||
return entry
|
||||
return entry, pending_refresh
|
||||
|
||||
def peek(self) -> Optional[PooledCredential]:
|
||||
# Single lock acquisition for the whole read; call the unlocked
|
||||
@@ -1845,7 +2025,7 @@ class CredentialPool:
|
||||
current = self._current_unlocked()
|
||||
if current is not None:
|
||||
return current
|
||||
available = self._available_entries()
|
||||
available, _pending = self._available_entries()
|
||||
return available[0] if available else None
|
||||
|
||||
def mark_exhausted_and_rotate(
|
||||
@@ -1855,6 +2035,7 @@ class CredentialPool:
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
api_key_hint: Optional[str] = None,
|
||||
credential_id: Optional[str] = None,
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> Optional[PooledCredential]:
|
||||
with self._lock:
|
||||
entry = None
|
||||
@@ -1894,7 +2075,8 @@ class CredentialPool:
|
||||
# guessing and surface the error (no cooldown is written for
|
||||
# anybody — healthy keys stay available for the next turn).
|
||||
self._unmatched_rotation_streak += 1
|
||||
available_count = len(self._available_entries())
|
||||
available_count, _ = self._available_entries()
|
||||
available_count = len(available_count)
|
||||
if self._unmatched_rotation_streak > max(available_count, 1):
|
||||
logger.warning(
|
||||
"credential pool: failed credential identity matched no "
|
||||
@@ -1913,8 +2095,9 @@ class CredentialPool:
|
||||
self.provider,
|
||||
)
|
||||
self._current_id = None
|
||||
next_entry = self._select_unlocked()
|
||||
if next_entry is not None and len(self._available_entries()) == 1:
|
||||
next_entry, _pending = self._select_unlocked(refresh=False)
|
||||
avail, _ = self._available_entries()
|
||||
if next_entry is not None and len(avail) == 1:
|
||||
# A single-entry pool cannot rotate. Returning its only
|
||||
# entry reports a successful recovery without changing
|
||||
# the credential, so the caller retries the same 401
|
||||
@@ -1927,11 +2110,13 @@ class CredentialPool:
|
||||
# streak is stale (this mark WILL advance pool state).
|
||||
self._unmatched_rotation_streak = 0
|
||||
if entry is None:
|
||||
entry = self._current_unlocked() or self._select_unlocked()
|
||||
entry = self._current_unlocked() or self._select_unlocked(refresh=False)[0]
|
||||
if entry is None:
|
||||
return None
|
||||
_label = entry.label or entry.id[:8]
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
self._mark_exhausted(
|
||||
entry, status_code, error_context, failure_reason=failure_reason
|
||||
)
|
||||
# A 402/429/401 is an API-key–level failure: the account is out of
|
||||
# balance, rate-limited, or its key is rejected. The same key can
|
||||
# back more than one pool entry (e.g. an explicit pool entry plus a
|
||||
@@ -1951,7 +2136,11 @@ class CredentialPool:
|
||||
continue
|
||||
if sibling.runtime_api_key == failed_runtime_key:
|
||||
self._mark_exhausted(
|
||||
sibling, status_code, error_context, persist=False
|
||||
sibling,
|
||||
status_code,
|
||||
error_context,
|
||||
persist=False,
|
||||
failure_reason=failure_reason,
|
||||
)
|
||||
siblings_marked = True
|
||||
if siblings_marked:
|
||||
@@ -1972,7 +2161,7 @@ class CredentialPool:
|
||||
_label, status_code,
|
||||
)
|
||||
self._current_id = None
|
||||
next_entry = self._select_unlocked()
|
||||
next_entry, _pending = self._select_unlocked(refresh=False)
|
||||
if next_entry:
|
||||
_next_label = next_entry.label or next_entry.id[:8]
|
||||
logger.info("credential pool: rotated to %s", _next_label)
|
||||
@@ -1986,15 +2175,32 @@ class CredentialPool:
|
||||
a stable tie-breaker. When every credential is already at the soft cap,
|
||||
still return the least-leased one instead of blocking.
|
||||
"""
|
||||
chosen_id, pending_refresh = self._acquire_lease_under_lock(credential_id)
|
||||
if pending_refresh:
|
||||
self._refresh_pending_entries(pending_refresh)
|
||||
# Mirror select(): if nothing was leasable but we just refreshed
|
||||
# deferred single-use-token entries, retry now that they are back
|
||||
# in rotation. Without this, a pool whose only entries all needed
|
||||
# a refresh returns None even though the refresh succeeded — the
|
||||
# caller sees "no credentials available" and fails a request that
|
||||
# should have gone through.
|
||||
if chosen_id is None:
|
||||
chosen_id, _ = self._acquire_lease_under_lock(credential_id)
|
||||
return chosen_id
|
||||
|
||||
def _acquire_lease_under_lock(
|
||||
self, credential_id: Optional[str],
|
||||
) -> Tuple[Optional[str], List[tuple]]:
|
||||
"""Run lease acquisition under the lock, returning id + pending refreshes."""
|
||||
with self._lock:
|
||||
if credential_id:
|
||||
self._active_leases[credential_id] = self._active_leases.get(credential_id, 0) + 1
|
||||
self._current_id = credential_id
|
||||
return credential_id
|
||||
return credential_id, []
|
||||
|
||||
available = self._available_entries(clear_expired=True, refresh=True)
|
||||
available, pending_refresh = self._available_entries(clear_expired=True, refresh=True)
|
||||
if not available:
|
||||
return None
|
||||
return None, pending_refresh
|
||||
|
||||
below_cap = [
|
||||
entry for entry in available
|
||||
@@ -2007,7 +2213,7 @@ class CredentialPool:
|
||||
)
|
||||
self._active_leases[chosen.id] = self._active_leases.get(chosen.id, 0) + 1
|
||||
self._current_id = chosen.id
|
||||
return chosen.id
|
||||
return chosen.id, pending_refresh
|
||||
|
||||
def release_lease(self, credential_id: str) -> None:
|
||||
"""Release a previously acquired credential lease."""
|
||||
@@ -2059,7 +2265,7 @@ class CredentialPool:
|
||||
else:
|
||||
entry = self._current_unlocked() or self._select_unlocked(
|
||||
refresh=False
|
||||
)
|
||||
)[0]
|
||||
if entry is None:
|
||||
return None
|
||||
self._current_id = entry.id
|
||||
@@ -2173,6 +2379,11 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
||||
field_updates = {}
|
||||
extra_updates = {}
|
||||
_field_names = {f.name for f in fields(existing)}
|
||||
token_changed = (
|
||||
"access_token" in payload
|
||||
and payload["access_token"] is not None
|
||||
and payload["access_token"] != existing.access_token
|
||||
)
|
||||
for key, value in payload.items():
|
||||
if key in {"id", "priority"} or value is None:
|
||||
continue
|
||||
@@ -2184,6 +2395,15 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
||||
elif key in _EXTRA_KEYS:
|
||||
if existing.extra.get(key) != value:
|
||||
extra_updates[key] = value
|
||||
# When the credential token itself changes (key rotation), clear any
|
||||
# exhaustion/error state — the old status is stale for the new key.
|
||||
if token_changed and existing.last_status is not None:
|
||||
field_updates["last_status"] = None
|
||||
field_updates["last_status_at"] = None
|
||||
field_updates["last_error_code"] = None
|
||||
field_updates["last_error_reason"] = None
|
||||
field_updates["last_error_message"] = None
|
||||
field_updates["last_error_reset_at"] = None
|
||||
if field_updates or extra_updates:
|
||||
if extra_updates:
|
||||
field_updates["extra"] = {**existing.extra, **extra_updates}
|
||||
@@ -2387,9 +2607,41 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
# env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN). They don't live in
|
||||
# the auth store or credential pool, so we resolve them here.
|
||||
try:
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
|
||||
from hermes_cli.copilot_auth import (
|
||||
COPILOT_ENV_VARS,
|
||||
resolve_copilot_token,
|
||||
get_copilot_api_token,
|
||||
)
|
||||
# All-sources suppression gate BEFORE any work — including the
|
||||
# `gh auth token` subprocess spawn. resolve_copilot_token()
|
||||
# shells out (~30ms), and the exchange retries 3x with backoff
|
||||
# (~35s worst case); a user who suppressed every copilot source
|
||||
# (hermes auth remove copilot gh_cli) must not pay either on
|
||||
# every pool load (model picker open, /model, agent startup).
|
||||
# Enumerating the full source space here matches what
|
||||
# credential_sources._remove_copilot_gh suppresses, so an
|
||||
# all-suppressed check is stable.
|
||||
copilot_sources = ["gh_cli"] + [f"env:{v}" for v in COPILOT_ENV_VARS]
|
||||
if all(_is_suppressed(provider, s) for s in copilot_sources):
|
||||
return changed, active_sources
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
# ``resolve_copilot_token`` returns exactly "gh auth token"
|
||||
# for the CLI path; env-sourced tokens return the var name.
|
||||
# Match exactly — a substring test classifies GH_TOKEN and
|
||||
# GITHUB_TOKEN as gh_cli, silently bypassing a user's
|
||||
# per-env-var suppression.
|
||||
source_name = "gh_cli" if source == "gh auth token" else f"env:{source}"
|
||||
# Per-source suppression gate (a user may suppress only the
|
||||
# gh CLI path and keep an env var, or vice versa) BEFORE the
|
||||
# network exchange. The exchange retries 3x with 10s
|
||||
# timeouts and 4.5s total backoff (~35s worst case), so a
|
||||
# source the user already suppressed
|
||||
# must not burn that dead time just to have the entry
|
||||
# discarded afterwards. Same early-gate pattern every other
|
||||
# singleton branch uses.
|
||||
if _is_suppressed(provider, source_name):
|
||||
return changed, active_sources
|
||||
api_token, enterprise_base_url = get_copilot_api_token(token)
|
||||
# Observability: get_copilot_api_token falls back to returning
|
||||
# the RAW token when the exchange fails. A raw ~40-char token
|
||||
@@ -2405,8 +2657,6 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"unavailable); enterprise-only models may 400 with "
|
||||
"model_not_available_for_integrator until exchange recovers."
|
||||
)
|
||||
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
|
||||
if not _is_suppressed(provider, source_name):
|
||||
active_sources.add(source_name)
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
# Use enterprise base URL from token exchange if available,
|
||||
@@ -2571,6 +2821,31 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
return changed, active_sources
|
||||
|
||||
|
||||
# Prefer ~/.hermes/.env over os.environ — the user's config file is the
|
||||
# authoritative source for Hermes credentials. Stale env vars from parent
|
||||
# processes (Codex CLI, test scripts, etc.) should not override deliberate
|
||||
# changes to the .env file. load_env() memoizes on the .env mtime, so
|
||||
# per-call reads (pool seeding, per-turn credential refresh) cost a stat()
|
||||
# when the file is unchanged.
|
||||
def get_env_prefer_dotenv(key: str) -> str:
|
||||
env_file = load_env()
|
||||
raw = env_file.get(key, "").strip()
|
||||
scoped_value = (_get_secret(key, "") or "").strip()
|
||||
# If .env contains an unresolved op:// reference, prefer the
|
||||
# already-resolved value supplied by the active secret scope (or by
|
||||
# os.environ in legacy single-profile mode), set by
|
||||
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
|
||||
# "op://Vault/Item/field" string would otherwise win and every
|
||||
# provider auth attempt would receive a URL instead of a key. This
|
||||
# happens during a partial migration, or when the user wrote op://
|
||||
# references straight into .env rather than the secrets.onepassword
|
||||
# config block. For every non-op:// value the original
|
||||
# .env-takes-precedence behaviour is preserved unchanged.
|
||||
if raw.startswith("op://") and scoped_value:
|
||||
return scoped_value
|
||||
return raw or scoped_value
|
||||
|
||||
|
||||
def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
|
||||
changed = False
|
||||
active_sources: Set[str] = set()
|
||||
@@ -2589,27 +2864,10 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
if provider == "copilot":
|
||||
return False, active_sources
|
||||
|
||||
# Prefer ~/.hermes/.env over os.environ — the user's config file is the
|
||||
# authoritative source for Hermes credentials. Stale env vars from parent
|
||||
# processes (Codex CLI, test scripts, etc.) should not override deliberate
|
||||
# changes to the .env file.
|
||||
def _get_env_prefer_dotenv(key: str) -> str:
|
||||
env_file = load_env()
|
||||
raw = env_file.get(key, "").strip()
|
||||
scoped_value = (_get_secret(key, "") or "").strip()
|
||||
# If .env contains an unresolved op:// reference, prefer the
|
||||
# already-resolved value supplied by the active secret scope (or by
|
||||
# os.environ in legacy single-profile mode), set by
|
||||
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
|
||||
# "op://Vault/Item/field" string would otherwise win and every
|
||||
# provider auth attempt would receive a URL instead of a key. This
|
||||
# happens during a partial migration, or when the user wrote op://
|
||||
# references straight into .env rather than the secrets.onepassword
|
||||
# config block. For every non-op:// value the original
|
||||
# .env-takes-precedence behaviour is preserved unchanged.
|
||||
if raw.startswith("op://") and scoped_value:
|
||||
return scoped_value
|
||||
return raw or scoped_value
|
||||
# The .env-preferring resolution lives at module level
|
||||
# (``get_env_prefer_dotenv``) so the pool seeder and the per-turn
|
||||
# credential refresh share one implementation.
|
||||
_get_env_prefer_dotenv = get_env_prefer_dotenv
|
||||
|
||||
# Honour user suppression — `hermes auth remove <provider> <N>` for an
|
||||
# env-seeded credential marks the env:<VAR> source as suppressed so it
|
||||
|
||||
@@ -1923,6 +1923,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
credential_pool=_credential_pool,
|
||||
request_overrides=_request_overrides,
|
||||
**_agent_kwargs,
|
||||
enabled_toolsets=["skills", "terminal"],
|
||||
# Umbrella-building over a large skill collection is worth a
|
||||
# high iteration ceiling — the pass typically takes 50-100
|
||||
# API calls against hundreds of candidate skills. The
|
||||
|
||||
+39
-2
@@ -14,6 +14,7 @@ from dataclasses import dataclass, field
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.redact import redact_sensitive_text
|
||||
@@ -187,6 +188,15 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPreview:
|
||||
"""A compact tool preview plus presentation facts lost to truncation."""
|
||||
|
||||
text: str
|
||||
truncated: bool = False
|
||||
url: str | None = None
|
||||
|
||||
|
||||
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
|
||||
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
|
||||
|
||||
@@ -556,6 +566,35 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
return preview
|
||||
|
||||
|
||||
def prepare_tool_preview(
|
||||
tool_name: str,
|
||||
args: dict | None,
|
||||
*,
|
||||
fallback: str,
|
||||
max_len: int,
|
||||
) -> ToolPreview:
|
||||
"""Build one canonical compact preview before platform formatting.
|
||||
|
||||
The uncapped preview is rebuilt from the tool arguments when possible so
|
||||
an upstream display cap cannot discard its link target. Platforms then
|
||||
receive explicit truncation and URL metadata instead of inferring either
|
||||
fact from the rendered text.
|
||||
"""
|
||||
full_text = build_tool_preview(tool_name, args, max_len=0) or fallback
|
||||
text = _truncate_preview(full_text, max_len)
|
||||
truncated = text != full_text
|
||||
url = None
|
||||
if truncated:
|
||||
candidate = _display_url(full_text)
|
||||
try:
|
||||
parsed = urlsplit(candidate)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if parsed and parsed.scheme.lower() in {"http", "https"} and parsed.netloc:
|
||||
url = candidate
|
||||
return ToolPreview(text=text, truncated=truncated, url=url)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Friendly tool labels (human-phrased verbs for built-in tools)
|
||||
#
|
||||
@@ -1506,5 +1545,3 @@ def get_cute_tool_message(
|
||||
# =========================================================================
|
||||
# Honcho session line (one-liner with clickable OSC 8 hyperlink)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
|
||||
@@ -339,6 +339,32 @@ _MODEL_NOT_FOUND_PATTERNS = [
|
||||
"no endpoints found that support tool use",
|
||||
]
|
||||
|
||||
|
||||
def _model_id_missing_known_prefix(model: str, provider: str) -> bool:
|
||||
"""True when a bare model id is only known to the provider as ``vendor/id``.
|
||||
|
||||
Some providers answer a malformed model id with a naked 404 that names
|
||||
nothing — NVIDIA NIM returns ``404 page not found`` for a bare
|
||||
``nemotron-3-ultra-550b-a55b``, indistinguishable from a bad endpoint
|
||||
path. Consulting the curated catalogue tells the two apart: if the id
|
||||
carries no ``/`` but the catalogue has exactly one entry ending in
|
||||
``/<id>``, the prefix was dropped and the failure is deterministic.
|
||||
|
||||
Never guesses — an id absent from the catalogue (a local NIM container,
|
||||
a proxied model) returns False so genuine endpoint problems keep their
|
||||
retryable ``unknown`` classification.
|
||||
"""
|
||||
name = (model or "").strip()
|
||||
if not name or "/" in name:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.model_normalize import suggest_prefixed_model_id
|
||||
|
||||
return bool(suggest_prefixed_model_id((provider or "").strip(), name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Malformed-message-array 400s. Deterministic request-shape rejections that
|
||||
# describe the *transcript* being invalid, not a parameter. The canonical
|
||||
# case: a stream dies mid-response and Hermes persists a content-less
|
||||
@@ -1061,6 +1087,18 @@ def _classify_by_status(
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
# A bare id that the provider's catalogue only knows in prefixed form
|
||||
# is a malformed model id, not a routing glitch — NVIDIA NIM answers
|
||||
# one with a naked ``404 page not found`` that names nothing, so the
|
||||
# generic branch below burns three retries and reports what looks
|
||||
# like an outage (#78796). Deterministic: don't retry, and let the
|
||||
# model_not_found surface carry the real cause.
|
||||
if _model_id_missing_known_prefix(model, provider):
|
||||
return result_fn(
|
||||
FailoverReason.model_not_found,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
# Generic 404 with no "model not found" signal — could be a wrong
|
||||
# endpoint path (common with local llama.cpp / Ollama / vLLM when
|
||||
# the URL is slightly misconfigured), a proxy routing glitch, or
|
||||
|
||||
+91
-28
@@ -99,6 +99,31 @@ class InsightsEngine:
|
||||
"""
|
||||
self.db = db
|
||||
self._conn = db._conn
|
||||
# INDEXED BY is a hard dependency (SQLite errors on a missing index).
|
||||
# A read-only open of a state.db written by an older version skips
|
||||
# schema init and lacks the partial index — probe once and fall back
|
||||
# to the unpinned variants (identical rows, optimizer-chosen plan).
|
||||
try:
|
||||
self._has_assistant_calls_index = bool(
|
||||
self._conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
||||
(self._MESSAGES_ASSISTANT_CALLS_INDEX,),
|
||||
).fetchone()
|
||||
)
|
||||
except sqlite3.Error:
|
||||
self._has_assistant_calls_index = False
|
||||
if not self._has_assistant_calls_index:
|
||||
_strip = f" INDEXED BY {self._MESSAGES_ASSISTANT_CALLS_INDEX}"
|
||||
# Loop over every pinned statement so adding a new one can't
|
||||
# forget its strip line (which would be a hard `no such index`
|
||||
# crash on read-only DBs — the exact bug this fallback prevents).
|
||||
for _attr in (
|
||||
"_GET_TOOL_CALLS_WITH_SOURCE",
|
||||
"_GET_TOOL_CALLS_ALL",
|
||||
"_GET_SKILL_CALLS_WITH_SOURCE",
|
||||
"_GET_SKILL_CALLS_ALL",
|
||||
):
|
||||
setattr(self, _attr, getattr(self, _attr).replace(_strip, ""))
|
||||
|
||||
def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -171,6 +196,21 @@ class InsightsEngine:
|
||||
"top_sessions": top_sessions,
|
||||
}
|
||||
|
||||
def get_usage_breakdown(self, days: int = 30, source: str = None) -> Dict[str, Any]:
|
||||
"""Return the analytics-usage payload without running a full generate().
|
||||
|
||||
Uses the instr()-prefiltered _get_skill_usage query so only messages
|
||||
that reference skill_view or skill_manage are loaded from SQLite, while
|
||||
still preserving the per-tool breakdown used by the dashboard route.
|
||||
"""
|
||||
cutoff = time.time() - (days * 86400)
|
||||
tool_usage = self._get_tool_usage(cutoff, source)
|
||||
skill_usage = self._get_skill_usage(cutoff, source)
|
||||
return {
|
||||
"tools": self._compute_tool_breakdown(tool_usage),
|
||||
"skills": self._compute_skill_breakdown(skill_usage),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Data gathering (SQL queries)
|
||||
# =========================================================================
|
||||
@@ -195,6 +235,53 @@ class InsightsEngine:
|
||||
" ORDER BY started_at DESC"
|
||||
)
|
||||
|
||||
# Assistant ``tool_calls`` scan for tool/skill usage. ``INDEXED BY`` pins
|
||||
# the partial index ``idx_messages_assistant_calls_by_session`` so the plan
|
||||
# is deterministic on a freshly initialized state.db (before ANALYZE has
|
||||
# run) for BOTH the unfiltered and source-filtered branches — without the
|
||||
# hint the optimizer falls back to ``idx_messages_session_active`` for the
|
||||
# source-filtered probe and scans each session's non-tool-call rows.
|
||||
#
|
||||
# The pin is a HARD dependency: SQLite raises ``no such index`` when the
|
||||
# named index is absent. That happens in practice — the web dashboard's
|
||||
# usage analytics open the DB ``read_only=True`` (skipping
|
||||
# ``_init_schema``), so a state.db created by an older writer has no
|
||||
# partial index yet. ``__init__`` probes for the index once and falls
|
||||
# back to the unpinned (still-correct, just optimizer-chosen) variants.
|
||||
_MESSAGES_ASSISTANT_CALLS_INDEX = "idx_messages_assistant_calls_by_session"
|
||||
_GET_TOOL_CALLS_WITH_SOURCE = (
|
||||
"SELECT m.tool_calls"
|
||||
f" FROM messages m INDEXED BY {_MESSAGES_ASSISTANT_CALLS_INDEX}"
|
||||
" JOIN sessions s ON s.id = m.session_id"
|
||||
" WHERE s.started_at >= ? AND s.source = ?"
|
||||
" AND m.role = 'assistant' AND m.tool_calls IS NOT NULL"
|
||||
)
|
||||
_GET_TOOL_CALLS_ALL = (
|
||||
"SELECT m.tool_calls"
|
||||
f" FROM messages m INDEXED BY {_MESSAGES_ASSISTANT_CALLS_INDEX}"
|
||||
" JOIN sessions s ON s.id = m.session_id"
|
||||
" WHERE s.started_at >= ?"
|
||||
" AND m.role = 'assistant' AND m.tool_calls IS NOT NULL"
|
||||
)
|
||||
_GET_SKILL_CALLS_WITH_SOURCE = (
|
||||
"SELECT m.tool_calls, m.timestamp"
|
||||
f" FROM messages m INDEXED BY {_MESSAGES_ASSISTANT_CALLS_INDEX}"
|
||||
" JOIN sessions s ON s.id = m.session_id"
|
||||
" WHERE s.started_at >= ? AND s.source = ?"
|
||||
" AND m.role = 'assistant' AND m.tool_calls IS NOT NULL"
|
||||
" AND (instr(m.tool_calls, 'skill_view') > 0"
|
||||
" OR instr(m.tool_calls, 'skill_manage') > 0)"
|
||||
)
|
||||
_GET_SKILL_CALLS_ALL = (
|
||||
"SELECT m.tool_calls, m.timestamp"
|
||||
f" FROM messages m INDEXED BY {_MESSAGES_ASSISTANT_CALLS_INDEX}"
|
||||
" JOIN sessions s ON s.id = m.session_id"
|
||||
" WHERE s.started_at >= ?"
|
||||
" AND m.role = 'assistant' AND m.tool_calls IS NOT NULL"
|
||||
" AND (instr(m.tool_calls, 'skill_view') > 0"
|
||||
" OR instr(m.tool_calls, 'skill_manage') > 0)"
|
||||
)
|
||||
|
||||
def _get_sessions(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||
"""Fetch sessions within the time window."""
|
||||
if source:
|
||||
@@ -243,22 +330,10 @@ class InsightsEngine:
|
||||
# (covers CLI sessions where tool_name is NULL on tool responses)
|
||||
if source:
|
||||
cursor2 = self._conn.execute(
|
||||
"""SELECT m.tool_calls
|
||||
FROM messages m
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE s.started_at >= ? AND s.source = ?
|
||||
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
|
||||
(cutoff, source),
|
||||
self._GET_TOOL_CALLS_WITH_SOURCE, (cutoff, source)
|
||||
)
|
||||
else:
|
||||
cursor2 = self._conn.execute(
|
||||
"""SELECT m.tool_calls
|
||||
FROM messages m
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE s.started_at >= ?
|
||||
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
|
||||
(cutoff,),
|
||||
)
|
||||
cursor2 = self._conn.execute(self._GET_TOOL_CALLS_ALL, (cutoff,))
|
||||
|
||||
tool_calls_counts = Counter()
|
||||
for row in cursor2.fetchall():
|
||||
@@ -301,22 +376,10 @@ class InsightsEngine:
|
||||
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT m.tool_calls, m.timestamp
|
||||
FROM messages m
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE s.started_at >= ? AND s.source = ?
|
||||
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
|
||||
(cutoff, source),
|
||||
self._GET_SKILL_CALLS_WITH_SOURCE, (cutoff, source)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT m.tool_calls, m.timestamp
|
||||
FROM messages m
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE s.started_at >= ?
|
||||
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
|
||||
(cutoff,),
|
||||
)
|
||||
cursor = self._conn.execute(self._GET_SKILL_CALLS_ALL, (cutoff,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
try:
|
||||
|
||||
@@ -34,12 +34,50 @@ Optional hooks (override to opt in):
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Prompts that carry no semantic signal — trivial acknowledgements, greetings,
|
||||
# slash commands, empty input. Single source of truth shared by the core
|
||||
# per-turn prefetch gate (agent/turn_context.py, run_agent.py) and provider-
|
||||
# side classifiers (plugins/memory/honcho) so the two can never drift apart.
|
||||
# The alternation is anchored and may only be followed by whitespace or
|
||||
# punctuation, so words that merely START with a trivial word ("k8s", "yolo",
|
||||
# "note", "hindsight") do NOT match, while trailing-punctuation variants
|
||||
# ("hi!", "hey.", "thanks :)", "done???") do.
|
||||
TRIVIAL_PROMPT_RE = re.compile(
|
||||
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
|
||||
r'hi|hey|hello|yo|sup|'
|
||||
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)'
|
||||
r'[\s!?.:;,"' + "'" + r'~\u2018\u2019\u201c\u201d\u2014\u2013\u2026()\[\]{}<>*&^%$#@!+=`\u00a0]*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_trivial_prompt(text: Optional[str]) -> bool:
|
||||
"""Return True if a user prompt is too trivial to warrant memory recall.
|
||||
|
||||
Empty/whitespace-only input, slash commands, and bare greetings or
|
||||
acknowledgements (with optional trailing punctuation) all count as
|
||||
trivial. Callers use this to skip memory-provider prefetch/injection
|
||||
on turns that carry no semantic signal — saving a blocking network
|
||||
round-trip and preventing stale user-model context from derailing
|
||||
one-word replies.
|
||||
"""
|
||||
if not text:
|
||||
return True
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return True
|
||||
if stripped.startswith("/"):
|
||||
return True
|
||||
return bool(TRIVIAL_PROMPT_RE.match(stripped))
|
||||
|
||||
|
||||
class MemoryProvider(ABC):
|
||||
"""Abstract base class for memory providers."""
|
||||
|
||||
@@ -253,6 +291,10 @@ class MemoryProvider(ABC):
|
||||
required: True if required (default: False)
|
||||
default: default value (optional)
|
||||
choices: list of valid values (optional)
|
||||
type: text, integer, number, or boolean (optional)
|
||||
minimum: numeric lower bound for integer/number fields (optional)
|
||||
maximum: numeric upper bound for integer/number fields (optional)
|
||||
step: numeric input step for Dashboard rendering (optional)
|
||||
url: URL where user can get this credential (optional)
|
||||
env_var: explicit env var name for secrets (default: auto-generated)
|
||||
|
||||
|
||||
+67
-18
@@ -12,6 +12,7 @@ import hashlib
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, wait as _futures_wait
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -151,6 +152,24 @@ def _redact_trace_accounting(acct: Any) -> Any:
|
||||
)
|
||||
|
||||
|
||||
# Cold-start caches. A MoA preset switch used to re-resolve the full
|
||||
# config + preset + every slot's provider runtime on EACH create() call
|
||||
# (once per tool-loop iteration), serially before the parallel fan-out could
|
||||
# start — adding 5-30s of "frozen" latency on complex presets
|
||||
# (#66793). The preset structure is immutable for the life of a turn, so
|
||||
# cache both the resolved preset and each (provider, model) runtime.
|
||||
_preset_cache_lock = threading.Lock()
|
||||
_preset_cache: dict[tuple, Any] = {}
|
||||
|
||||
_runtime_cache_lock = threading.Lock()
|
||||
_runtime_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
# Runtime entries go stale when providers/credentials change (key rotation,
|
||||
# base_url edits). Deliberately short-lived: 300s collapses the per-iteration
|
||||
# re-resolution inside a turn while bounding credential staleness between
|
||||
# turns — the non-MoA path picks up rotated keys immediately, this path
|
||||
# within 5 minutes.
|
||||
_RUNTIME_CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
# Upper bound on concurrent reference-model calls. References are independent
|
||||
# advisory calls (no tools, no inter-dependence), so we fan them out the same
|
||||
@@ -322,34 +341,33 @@ def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
api_key resolver the CLI, gateway, and delegate_task all use), so the slot
|
||||
gets its provider's real API surface — e.g. MiniMax → anthropic_messages,
|
||||
GPT-5/o-series → max_completion_tokens, custom endpoints → their base_url.
|
||||
|
||||
Returns the kwargs to pass through to ``call_llm`` (provider/model plus the
|
||||
resolved base_url/api_key when available). Falls back to the bare
|
||||
provider/model on any resolution error so a misconfigured slot still
|
||||
attempts the call rather than aborting the whole MoA turn.
|
||||
|
||||
The resolved runtime is cached per (provider, model) with a short TTL
|
||||
(``_RUNTIME_CACHE_TTL_SECONDS``): the resolution does real I/O (catalog
|
||||
query + config read) that used to run serially per create() call before
|
||||
the parallel fan-out could start — the dominant source of MoA cold-start
|
||||
latency (#66793). The TTL bounds credential staleness (key rotation,
|
||||
base_url edits) instead of caching for the process lifetime.
|
||||
"""
|
||||
provider = str(slot.get("provider") or "").strip()
|
||||
model = str(slot.get("model") or "").strip()
|
||||
cache_key = (provider, model)
|
||||
now = time.monotonic()
|
||||
with _runtime_cache_lock:
|
||||
entry = _runtime_cache.get(cache_key)
|
||||
if entry is not None:
|
||||
stamped_at, cached = entry
|
||||
if now - stamped_at < _RUNTIME_CACHE_TTL_SECONDS:
|
||||
return cached
|
||||
out: dict[str, Any] = {"provider": provider, "model": model}
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
rt = resolve_runtime_provider(requested=provider, target_model=model)
|
||||
# Forward the resolved endpoint through to call_llm unconditionally.
|
||||
# call_llm's _resolve_task_provider_model() is the single chokepoint that
|
||||
# decides whether an explicit base_url collapses a call to the generic
|
||||
# ``custom`` route or keeps the provider's real identity: it preserves
|
||||
# identity for any first-class provider (via
|
||||
# _preserve_provider_with_base_url, a provider-catalog capability check),
|
||||
# so provider branches that add auth refresh / request metadata /
|
||||
# request-shape adapters — anthropic OAuth (Bearer + anthropic-beta),
|
||||
# openai-codex Responses wrapping + Cloudflare headers, xai-oauth,
|
||||
# bedrock SigV4 signing, nous Portal tags — still fire. Those branches
|
||||
# re-resolve their own credentials by name and ignore a forwarded
|
||||
# base_url/api_key, so forwarding is safe even for a placeholder key
|
||||
# (bedrock's "aws-sdk"). We used to maintain a name-preservation set here
|
||||
# too; that duplicated the chokepoint and drifted out of sync, so the
|
||||
# single source of truth now lives in call_llm.
|
||||
if rt.get("base_url"):
|
||||
out["base_url"] = rt["base_url"]
|
||||
if rt.get("api_key"):
|
||||
@@ -362,7 +380,14 @@ def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(extra_body, dict) and extra_body:
|
||||
out["extra_body"] = dict(extra_body)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
|
||||
logger.debug("MoA slot runtime resolution failed for %s: %s",
|
||||
_slot_label(slot), exc)
|
||||
# Never cache a fallback-shaped result: a transient resolution error
|
||||
# (config mid-write, catalog hiccup) would otherwise pin the bare
|
||||
# provider/model kwargs for a full TTL.
|
||||
return out
|
||||
with _runtime_cache_lock:
|
||||
_runtime_cache[cache_key] = (now, out)
|
||||
return out
|
||||
|
||||
|
||||
@@ -1830,11 +1855,35 @@ class MoAChatCompletions:
|
||||
raise TypeError("_moa_prepared_request must be a dict")
|
||||
return self._call_prepared_aggregator(prepared_request, api_kwargs)
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.config import get_config_path, load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
|
||||
# Resolve the preset once per (config st_mtime_ns, preset_name).
|
||||
# resolve_moa_preset re-normalizes + re-validates the whole moa
|
||||
# config block on every call, and create() runs once per tool-loop
|
||||
# iteration — a serial cold-start cost before the parallel fan-out
|
||||
# can begin (#66793). Keyed on the config FILE's mtime_ns (not a
|
||||
# config-object attribute, which load_config()'s dicts don't carry),
|
||||
# so a config edit invalidates on the next call.
|
||||
try:
|
||||
_cfg_stamp = get_config_path().stat().st_mtime_ns
|
||||
except OSError:
|
||||
_cfg_stamp = None
|
||||
# load_config() is itself (mtime_ns, size)-cached upstream, so this
|
||||
# read is cheap; the expensive part this cache skips is
|
||||
# resolve_moa_preset's re-normalization + re-validation.
|
||||
_moa_raw = load_config().get("moa") or {}
|
||||
preset_cache_key = (_cfg_stamp, self.preset_name)
|
||||
preset = None
|
||||
if _cfg_stamp is not None:
|
||||
with _preset_cache_lock:
|
||||
preset = _preset_cache.get(preset_cache_key)
|
||||
if preset is None:
|
||||
preset = resolve_moa_preset(_moa_raw, self.preset_name)
|
||||
if _cfg_stamp is not None:
|
||||
with _preset_cache_lock:
|
||||
_preset_cache.clear() # one live config stamp at a time
|
||||
_preset_cache[preset_cache_key] = preset
|
||||
# Privacy filter mode: '' (off, default) | 'display' | 'full'. See
|
||||
# coerce_privacy_filter / the pattern block at the top of this module.
|
||||
# Remembered on self so _call_prepared_aggregator (which may run on a
|
||||
|
||||
+181
-18
@@ -145,6 +145,96 @@ _ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
|
||||
_endpoint_probe_path_cache: Dict[str, tuple] = {}
|
||||
|
||||
# A configured endpoint that is routable-but-dead — e.g. a corp LAN address
|
||||
# while off-VPN — blackholes TCP: the SYN draws no SYN-ACK, no RST and no ICMP
|
||||
# error, so a probe waits out its full timeout instead of failing fast. Startup
|
||||
# runs a whole waterfall of such probes across several functions here, and the
|
||||
# stalls stack into a minute-long hang before the banner renders.
|
||||
#
|
||||
# Once ANY probe has actually observed a connect timeout for an endpoint, the
|
||||
# others have nothing to gain by repeating it. Recording that observation and
|
||||
# short-circuiting on it performs no network I/O of its own — it adds no probe
|
||||
# for callers or tests to mock, and it can only ever fire after a real timeout
|
||||
# has already been paid, so it cannot suppress a probe that would have worked.
|
||||
_ENDPOINT_BLACKHOLE_TTL_SECONDS = 30.0
|
||||
# Values are monotonic timestamps of the last observed connect timeout.
|
||||
_endpoint_blackhole_cache: Dict[str, float] = {}
|
||||
|
||||
|
||||
def _endpoint_host_key(base_url: str) -> Optional[str]:
|
||||
"""Return a ``host:port`` key for ``base_url``, or None if it has no host.
|
||||
|
||||
Keyed on host:port rather than the full URL so every probe path for one
|
||||
server — ``/v1``-suffixed or not, LM Studio root or API root — shares a
|
||||
single entry.
|
||||
"""
|
||||
normalized = _normalize_base_url(base_url)
|
||||
if not normalized:
|
||||
return None
|
||||
url = normalized if "://" in normalized else f"http://{normalized}"
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
except Exception:
|
||||
return None
|
||||
return f"{host}:{port}" if host else None
|
||||
|
||||
|
||||
def _note_endpoint_blackholed(base_url: str) -> None:
|
||||
"""Record that a probe to ``base_url`` timed out during TCP connect."""
|
||||
key = _endpoint_host_key(base_url)
|
||||
if key is None:
|
||||
return
|
||||
_endpoint_blackhole_cache[key] = time.monotonic()
|
||||
logger.debug(
|
||||
"Endpoint %s timed out connecting — skipping further probes for %.0fs",
|
||||
key, _ENDPOINT_BLACKHOLE_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _endpoint_blackholed(base_url: str) -> bool:
|
||||
"""True if a recent probe to ``base_url`` timed out during TCP connect.
|
||||
|
||||
Pure cache lookup; never touches the network. The entry expires after
|
||||
_ENDPOINT_BLACKHOLE_TTL_SECONDS — long enough to collapse one startup's
|
||||
burst of probes, short enough that bringing the VPN up mid-session is
|
||||
picked up without a restart.
|
||||
"""
|
||||
if _ENDPOINT_BLACKHOLE_TTL_SECONDS <= 0:
|
||||
return False
|
||||
key = _endpoint_host_key(base_url)
|
||||
if key is None:
|
||||
return False
|
||||
seen = _endpoint_blackhole_cache.get(key)
|
||||
if seen is None:
|
||||
return False
|
||||
if (time.monotonic() - seen) >= _ENDPOINT_BLACKHOLE_TTL_SECONDS:
|
||||
del _endpoint_blackhole_cache[key]
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_connect_timeout(exc: BaseException) -> bool:
|
||||
"""True for connect-phase timeouts raised by httpx or requests.
|
||||
|
||||
Read timeouts are deliberately excluded: those mean the server accepted
|
||||
the connection, which is the opposite of the blackhole this guards.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
if isinstance(exc, httpx.ConnectTimeout):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from requests.exceptions import ConnectTimeout
|
||||
if isinstance(exc, ConnectTimeout):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ── Disk L2 for local-endpoint probe results ────────────────────────────────
|
||||
# The in-process caches above die with the process, so every CLI cold start
|
||||
# with a local model re-paid the probe waterfall in AIAgent.__init__:
|
||||
@@ -382,6 +472,7 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"llama": 131072,
|
||||
# Qwen — specific model families before the catch-all.
|
||||
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
|
||||
"qwen3.8-max": 1_000_000, # 1M context (OpenRouter & Nous portal, verified 2026-08-03)
|
||||
"qwen3.6-plus": 1048576, # 1M context (DashScope/Alibaba & OpenRouter)
|
||||
"qwen3.7-plus": 1048576, # 1M context (DashScope/Alibaba)
|
||||
"qwen3-coder-plus": 1000000, # 1M context
|
||||
@@ -836,7 +927,10 @@ def _localhost_to_ipv4(url: str) -> str:
|
||||
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
|
||||
passes through untouched.
|
||||
"""
|
||||
if not url:
|
||||
if not url or not isinstance(url, str):
|
||||
# Non-string values (test doubles, lazily-resolved config objects)
|
||||
# previously flowed through these call sites untouched — keep that
|
||||
# contract; re.sub would raise TypeError.
|
||||
return url
|
||||
return re.sub(
|
||||
r"^(https?://)localhost(?=[:/]|$)",
|
||||
@@ -873,6 +967,13 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
|
||||
return cached[0]
|
||||
|
||||
# The host already blackholed a connect: skip the waterfall below, each leg
|
||||
# of which would otherwise burn its full 2s timeout. Deliberately NOT
|
||||
# written to _endpoint_probe_path_cache — that entry lives for an hour,
|
||||
# which would pin the endpoint to "undetected" long after it comes back.
|
||||
if _endpoint_blackholed(server_url):
|
||||
return None
|
||||
|
||||
# Disk L2: a fresh cross-process verdict skips the HTTP waterfall
|
||||
# entirely (back-to-back CLI invocations, cron ticks).
|
||||
disk_hit = _local_probe_disk_get("server_type", server_url)
|
||||
@@ -882,6 +983,16 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
def _probe_failed(exc: Exception) -> None:
|
||||
"""Swallow a probe error — or abort the waterfall if we were blackholed.
|
||||
|
||||
Re-raising propagates out of the ``with`` block to the outer handler,
|
||||
so the remaining legs are skipped instead of each stalling in turn.
|
||||
"""
|
||||
if _is_connect_timeout(exc):
|
||||
_note_endpoint_blackholed(server_url)
|
||||
raise exc
|
||||
|
||||
result: Optional[str] = None
|
||||
try:
|
||||
with httpx.Client(timeout=2.0, headers=headers) as client:
|
||||
@@ -890,8 +1001,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
r = client.get(f"{lmstudio_url}/api/v1/models")
|
||||
if r.status_code == 200:
|
||||
result = "lm-studio"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_probe_failed(exc)
|
||||
if result is None:
|
||||
# Ollama exposes /api/tags and responds with {"models": [...]}
|
||||
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
|
||||
@@ -905,8 +1016,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
result = "ollama"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_probe_failed(exc)
|
||||
if result is None:
|
||||
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
||||
try:
|
||||
@@ -915,8 +1026,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
r = client.get(f"{server_url}/props") # fallback for older builds
|
||||
if r.status_code == 200 and "default_generation_settings" in r.text:
|
||||
result = "llamacpp"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_probe_failed(exc)
|
||||
if result is None:
|
||||
# vLLM: /version
|
||||
try:
|
||||
@@ -925,8 +1036,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
data = r.json()
|
||||
if "version" in data:
|
||||
result = "vllm"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_probe_failed(exc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1120,6 +1231,12 @@ def fetch_endpoint_model_metadata(
|
||||
if cached is not None and (time.time() - cached_at) < _ENDPOINT_MODEL_CACHE_TTL:
|
||||
return cached
|
||||
|
||||
# Blackholed endpoint: every candidate below would spend its full 5s
|
||||
# connect budget. Returned empty rather than cached, so the endpoint is
|
||||
# retried as soon as the blackhole entry expires.
|
||||
if _endpoint_blackholed(normalized):
|
||||
return {}
|
||||
|
||||
candidates = [normalized]
|
||||
if normalized.endswith("/v1"):
|
||||
alternate = normalized[:-3].rstrip("/")
|
||||
@@ -1182,9 +1299,20 @@ def fetch_endpoint_model_metadata(
|
||||
return cache
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if _is_connect_timeout(exc):
|
||||
_note_endpoint_blackholed(normalized)
|
||||
|
||||
for candidate in candidates:
|
||||
url = candidate.rstrip("/") + "/models"
|
||||
# A connect timeout on one candidate condemns the host, not the path:
|
||||
# the remaining candidates differ only by URL suffix, so trying them
|
||||
# would repeat the same stall.
|
||||
if _endpoint_blackholed(normalized):
|
||||
break
|
||||
# normalized/candidates stay unrewritten (cache key stability); only
|
||||
# the outbound request target is IPv4-resolved to skip the multi-second
|
||||
# dual-stack IPv6 connect timeout (see _localhost_to_ipv4).
|
||||
request_candidate = _localhost_to_ipv4(candidate)
|
||||
url = request_candidate.rstrip("/") + "/models"
|
||||
response = None
|
||||
try:
|
||||
response = requests.get(
|
||||
@@ -1230,7 +1358,7 @@ def fetch_endpoint_model_metadata(
|
||||
if is_llamacpp:
|
||||
try:
|
||||
# Try /v1/props first (current llama.cpp); fall back to /props for older builds
|
||||
base = candidate.rstrip("/").replace("/v1", "")
|
||||
base = request_candidate.rstrip("/").replace("/v1", "")
|
||||
_verify = _resolve_requests_verify()
|
||||
props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5, verify=_verify)
|
||||
if not props_resp.ok:
|
||||
@@ -1250,6 +1378,8 @@ def fetch_endpoint_model_metadata(
|
||||
return cache
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if _is_connect_timeout(exc):
|
||||
_note_endpoint_blackholed(normalized)
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
@@ -1813,6 +1943,9 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = ""
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
if _endpoint_blackholed(server_url):
|
||||
return None
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
try:
|
||||
@@ -1844,8 +1977,9 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = ""
|
||||
return ctx
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
if _is_connect_timeout(exc):
|
||||
_note_endpoint_blackholed(server_url)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1933,6 +2067,9 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
|
||||
|
||||
if _endpoint_blackholed(server_url):
|
||||
return None
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
try:
|
||||
@@ -2003,13 +2140,39 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
models_list = data.get("data", [])
|
||||
# Match by id; on single-model servers (e.g. llama.cpp) the
|
||||
# configured name rarely equals the reported id (a GGUF path),
|
||||
# so fall back to the sole model when nothing matches.
|
||||
matched = None
|
||||
for m in models_list:
|
||||
if _model_id_matches(m.get("id", ""), model):
|
||||
ctx = m.get("max_model_len") or m.get("context_length") or m.get("max_tokens")
|
||||
if ctx and isinstance(ctx, (int, float)):
|
||||
return int(ctx)
|
||||
except Exception:
|
||||
pass
|
||||
matched = m
|
||||
break
|
||||
if matched is None and len(models_list) == 1:
|
||||
matched = models_list[0]
|
||||
if matched is not None:
|
||||
# llama.cpp nests the runtime context under meta.n_ctx; the
|
||||
# vLLM/OpenAI keys are also checked. Runtime n_ctx is
|
||||
# preferred over n_ctx_train (the training maximum, which
|
||||
# can be larger than what the server actually allocates).
|
||||
for source in (matched, matched.get("meta") or {}):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
for key in (
|
||||
"n_ctx",
|
||||
"context_length",
|
||||
"context_window",
|
||||
"max_model_len",
|
||||
"max_context_length",
|
||||
"max_tokens",
|
||||
"n_ctx_train",
|
||||
):
|
||||
val = source.get(key)
|
||||
if isinstance(val, (int, float)) and val:
|
||||
return int(val)
|
||||
except Exception as exc:
|
||||
if _is_connect_timeout(exc):
|
||||
_note_endpoint_blackholed(server_url)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
+54
-20
@@ -482,11 +482,9 @@ class RelayTurnContext:
|
||||
default_factory=threading.RLock,
|
||||
repr=False,
|
||||
)
|
||||
_token: contextvars.Token[RelayTurnContext | None] | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
)
|
||||
_previous_turn: RelayTurnContext | None = field(default=None, repr=False)
|
||||
_active_registered: bool = field(default=False, repr=False)
|
||||
relay_enabled: bool = True
|
||||
closed: bool = False
|
||||
|
||||
|
||||
@@ -600,7 +598,28 @@ class RelaySessionCoordinator:
|
||||
if lease.released:
|
||||
raise RuntimeError("Hermes Relay conversation lease is released")
|
||||
turn = RelayTurnContext(lease=lease, turn_id=turn_id, task_id=task_id)
|
||||
if isinstance(lease.host, RelayRuntime) and lease.session is not None:
|
||||
key = (lease.profile_key, lease.session_id)
|
||||
with self._active_turns_lock:
|
||||
active = self._active_turns.get(key)
|
||||
if active:
|
||||
# A Relay session owns one physical scope stack. Concurrent
|
||||
# Hermes turns would create sibling scopes on that stack, but
|
||||
# their completion order is not guaranteed to be LIFO.
|
||||
turn.relay_enabled = False
|
||||
logger.warning(
|
||||
"Skipping Relay instrumentation for concurrent Hermes turn "
|
||||
"%s in session %s",
|
||||
turn_id,
|
||||
lease.session_id,
|
||||
)
|
||||
else:
|
||||
self._active_turns[key] = {id(turn)}
|
||||
turn._active_registered = True
|
||||
if (
|
||||
turn.relay_enabled
|
||||
and isinstance(lease.host, RelayRuntime)
|
||||
and lease.session is not None
|
||||
):
|
||||
try:
|
||||
turn.handle = lease.host.run_in_session(
|
||||
lease.session,
|
||||
@@ -617,11 +636,8 @@ class RelaySessionCoordinator:
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Hermes Relay turn initialization failed", exc_info=True)
|
||||
turn._token = _CURRENT_TURN.set(turn)
|
||||
key = (lease.profile_key, lease.session_id)
|
||||
with self._active_turns_lock:
|
||||
self._active_turns.setdefault(key, set()).add(id(turn))
|
||||
turn._active_registered = True
|
||||
turn._previous_turn = _CURRENT_TURN.get()
|
||||
_CURRENT_TURN.set(turn)
|
||||
return turn
|
||||
|
||||
def end_turn(
|
||||
@@ -755,16 +771,18 @@ class RelaySessionCoordinator:
|
||||
|
||||
@staticmethod
|
||||
def _reset_turn_context(turn: RelayTurnContext) -> None:
|
||||
"""Reset the originating ContextVar token when called in that context."""
|
||||
if turn._token is None:
|
||||
"""Unwind ``turn`` without disturbing a newer context-local turn."""
|
||||
if _CURRENT_TURN.get() is not turn:
|
||||
return
|
||||
try:
|
||||
_CURRENT_TURN.reset(turn._token)
|
||||
except ValueError:
|
||||
# A copied async/thread context may own terminal cleanup. Keep the
|
||||
# token so the originating context can clear its stale reference.
|
||||
return
|
||||
turn._token = None
|
||||
previous = turn._previous_turn
|
||||
seen = {id(turn)}
|
||||
while previous is not None and previous.closed:
|
||||
if id(previous) in seen:
|
||||
previous = None
|
||||
break
|
||||
seen.add(id(previous))
|
||||
previous = previous._previous_turn
|
||||
_CURRENT_TURN.set(previous)
|
||||
|
||||
@staticmethod
|
||||
def release_conversation(lease: ConversationLease) -> None:
|
||||
@@ -793,10 +811,21 @@ def current_turn() -> RelayTurnContext | None:
|
||||
return _CURRENT_TURN.get()
|
||||
|
||||
|
||||
def relay_instrumentation_enabled() -> bool:
|
||||
"""Return whether this inherited turn may create Relay instrumentation."""
|
||||
turn = current_turn()
|
||||
return turn is None or (turn.relay_enabled and not turn.closed)
|
||||
|
||||
|
||||
def active_turn(session_id: str | None = None) -> RelayTurnContext | None:
|
||||
"""Return a live turn only when it belongs to the active profile/session."""
|
||||
turn = current_turn()
|
||||
if turn is None or turn.closed or turn.lease.released:
|
||||
if (
|
||||
turn is None
|
||||
or not turn.relay_enabled
|
||||
or turn.closed
|
||||
or turn.lease.released
|
||||
):
|
||||
return None
|
||||
if turn.lease.profile_key != current_profile_key():
|
||||
return None
|
||||
@@ -814,6 +843,11 @@ def resolve_execution_context(
|
||||
session_id: str,
|
||||
) -> tuple[RelayRuntime | None, RelaySession | None, Any]:
|
||||
"""Resolve one active turn/session parent for managed Relay execution."""
|
||||
inherited_turn = current_turn()
|
||||
if inherited_turn is not None and (
|
||||
not inherited_turn.relay_enabled or inherited_turn.closed
|
||||
):
|
||||
return None, None, None
|
||||
turn = active_turn(session_id)
|
||||
if (
|
||||
turn is not None
|
||||
|
||||
@@ -13,6 +13,7 @@ the conversation without modifying the system prompt (preserving prompt caching)
|
||||
Inspired by Block/goose's SubdirectoryHintTracker.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
@@ -45,6 +46,18 @@ _COMMAND_TOOLS = {"terminal"}
|
||||
# Prevents scanning all the way to / for deeply nested paths.
|
||||
_MAX_ANCESTOR_WALK = 5
|
||||
|
||||
# Directory names that never contain authoritative project context.
|
||||
# Backups, vendored deps, VCS internals, and caches routinely hold *copies* of
|
||||
# AGENTS.md; loading those duplicates real context and inflates the prompt.
|
||||
_EXCLUDED_DIR_NAMES = frozenset({
|
||||
"node_modules", "venv", ".venv", "__pycache__",
|
||||
".git", ".hg", ".svn",
|
||||
".Trash", ".cache", ".tox", ".mypy_cache", ".pytest_cache",
|
||||
"site-packages", "dist-packages",
|
||||
"backups", "backup", ".backups",
|
||||
"vendor", "third_party",
|
||||
})
|
||||
|
||||
|
||||
def _is_ancestor_or_same(a: Path, b: Path) -> bool:
|
||||
"""Check if *a* is the same as or an ancestor of *b* (parent directory check)."""
|
||||
@@ -54,6 +67,7 @@ def _is_ancestor_or_same(a: Path, b: Path) -> bool:
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class SubdirectoryHintTracker:
|
||||
"""Track which directories the agent visits and load hints on first access.
|
||||
|
||||
@@ -70,8 +84,34 @@ class SubdirectoryHintTracker:
|
||||
def __init__(self, working_dir: Optional[str] = None):
|
||||
self.working_dir = Path(working_dir or os.getcwd()).resolve()
|
||||
self._loaded_dirs: Set[Path] = set()
|
||||
# Content digests already injected — prevents re-sending the same file
|
||||
# reachable through symlinks, hardlinks, or duplicated copies.
|
||||
self._loaded_digests: Set[str] = set()
|
||||
# Pre-mark the working dir as loaded (startup context handles it)
|
||||
self._loaded_dirs.add(self.working_dir)
|
||||
self._seed_working_dir_digest()
|
||||
|
||||
def _seed_working_dir_digest(self) -> None:
|
||||
"""Record the CWD context file's digest so it is never re-injected.
|
||||
|
||||
``prompt_builder`` already loads the working directory's context file at
|
||||
startup. Seeding its digest here means the same content reached through
|
||||
a different path (a symlink farm, a shared workspace) is recognised as a
|
||||
duplicate instead of being sent a second time.
|
||||
"""
|
||||
for filename in _HINT_FILENAMES:
|
||||
candidate = self.working_dir / filename
|
||||
try:
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
content = candidate.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if content:
|
||||
self._loaded_digests.add(
|
||||
hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
)
|
||||
break # first match wins, mirroring startup loading
|
||||
|
||||
def check_tool_call(
|
||||
self,
|
||||
@@ -193,8 +233,25 @@ class SubdirectoryHintTracker:
|
||||
# check as a best-effort safeguard.
|
||||
if not _is_ancestor_or_same(self.working_dir, path):
|
||||
return False
|
||||
if self._is_excluded(path):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _is_excluded(self, path: Path) -> bool:
|
||||
"""True when the path sits inside a directory that holds copies, not context.
|
||||
|
||||
Directories the user is deliberately working inside are never excluded —
|
||||
if ``working_dir`` is itself under ``vendor/``, that segment is legitimate
|
||||
and only segments *below* the working dir are screened.
|
||||
"""
|
||||
try:
|
||||
rel_parts = path.relative_to(self.working_dir).parts
|
||||
except ValueError:
|
||||
# Paths outside the working dir are already rejected by
|
||||
# _is_valid_subdir before this runs; treat as excluded defensively.
|
||||
return True
|
||||
return any(part in _EXCLUDED_DIR_NAMES for part in rel_parts)
|
||||
|
||||
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
|
||||
"""Load hint files from a directory. Returns formatted text or None.
|
||||
|
||||
@@ -230,6 +287,19 @@ class SubdirectoryHintTracker:
|
||||
content = hint_path.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
continue
|
||||
# Skip content we've already injected. The same AGENTS.md is
|
||||
# routinely reachable through several paths (symlinked shared
|
||||
# workspaces, hardlinks, copied backups); re-sending it burns
|
||||
# context for zero new information.
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
if digest in self._loaded_digests:
|
||||
logger.debug(
|
||||
"Skipping duplicate hint content at %s (digest %s)",
|
||||
hint_path,
|
||||
digest[:12],
|
||||
)
|
||||
break
|
||||
self._loaded_digests.add(digest)
|
||||
# Same security scan as startup context loading
|
||||
content = _scan_context_content(content, filename)
|
||||
if len(content) > _MAX_HINT_CHARS:
|
||||
|
||||
+28
-14
@@ -11,14 +11,14 @@ Three tiers are joined with ``\\n\\n``:
|
||||
|
||||
* ``stable`` — identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool
|
||||
guidance, computer-use guidance, nous subscription block, tool-use
|
||||
enforcement guidance + per-model operational guidance, skills prompt,
|
||||
enforcement guidance + per-model operational guidance,
|
||||
alibaba model-name workaround, environment hints, coding guidance,
|
||||
platform hints.
|
||||
* ``context`` — caller-supplied ``system_message`` plus context files
|
||||
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``,
|
||||
plus the session's coding-workspace snapshot.
|
||||
* ``volatile`` — memory snapshot, USER.md profile, external memory
|
||||
provider block, timestamp/session/model/provider line.
|
||||
* ``volatile`` — skills index, memory snapshot, USER.md profile, external
|
||||
memory provider block, timestamp/session/model/provider line.
|
||||
|
||||
Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
"""
|
||||
@@ -158,8 +158,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
* ``context`` — the workspace snapshot followed by the remaining
|
||||
session-stable guidance, context files, and caller-supplied
|
||||
system_message.
|
||||
* ``volatile`` — memory snapshot, user profile, external
|
||||
memory provider block, timestamp line.
|
||||
* ``volatile`` — skills index, memory snapshot, user profile,
|
||||
external memory provider block, timestamp line.
|
||||
|
||||
Joined into a single string by :func:`build_system_prompt` and
|
||||
cached on ``agent._cached_system_prompt`` for the lifetime of the
|
||||
@@ -325,8 +325,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
)
|
||||
else:
|
||||
skills_prompt = ""
|
||||
if skills_prompt:
|
||||
stable_parts.append(skills_prompt)
|
||||
|
||||
# Alibaba Coding Plan API always returns "glm-4.7" as model name regardless
|
||||
# of the requested model. Inject explicit model identity into the system prompt
|
||||
@@ -497,8 +495,22 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
# ── Volatile tier (changes per session/turn — never cached) ───
|
||||
# ── Volatile tier (most likely to differ on a rebuild; kept last so the stable prefix stays reusable) ──
|
||||
volatile_parts: List[str] = []
|
||||
# Skills are runtime-mutable: the agent adds and patches them across a
|
||||
# session (SKILLS_GUIDANCE tells it to patch a skill the moment it goes
|
||||
# stale). The built prompt is cached per session and only rebuilt on
|
||||
# compaction/restore (see build_system_prompt), so a skill change is not
|
||||
# byte-stable across rebuilds. With the index in the stable band, a rebuild
|
||||
# that picked up a skill change would bust the cached prefix from the index
|
||||
# down, taking the whole scaffold with it. Render it at the FRONT of the
|
||||
# volatile band instead, ahead of the turn-varying memory/timestamp tail:
|
||||
# on an implicit longest-prefix backend an unchanged index still falls
|
||||
# inside the reused prefix, and a changed one only re-prefills from here on.
|
||||
# (No effect for single-block cache_control backends, where the whole
|
||||
# system message is one cache unit regardless of internal order.)
|
||||
if skills_prompt:
|
||||
volatile_parts.append(skills_prompt)
|
||||
|
||||
if agent._memory_store:
|
||||
if agent._memory_enabled:
|
||||
@@ -556,10 +568,12 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
|
||||
|
||||
Layers are ordered cache-friendly: stable identity/guidance first,
|
||||
then session-stable context files, then per-call volatile content
|
||||
(memory, USER profile, timestamp). The whole string is treated as
|
||||
one cached block — Hermes never rebuilds or reinjects parts of it
|
||||
mid-session, which is the only way to keep upstream prompt caches
|
||||
warm across turns.
|
||||
(skills index, memory, USER profile, timestamp). For explicit
|
||||
cache_control backends the whole string is one cached block. For
|
||||
implicit longest-prefix backends the order is what matters: the
|
||||
content most likely to change is rendered last, so when the prompt is
|
||||
rebuilt (on compaction/restore) the unchanged stable scaffold ahead of
|
||||
the change stays in the reused prefix.
|
||||
"""
|
||||
parts = build_system_prompt_parts(agent, system_message=system_message)
|
||||
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||
@@ -602,8 +616,8 @@ def reconstruct_static_prefix(
|
||||
Safety: the rebuilt stable tier is used ONLY when the stored prompt
|
||||
literally starts with it (checked here AND re-checked by
|
||||
``_apply_system_cache_markers``'s ``startswith`` gate). If any
|
||||
stable-tier input changed since the prompt was persisted (skills
|
||||
edited, identity changed), the prefix mismatches, the static stays
|
||||
stable-tier input changed since the prompt was persisted (identity
|
||||
changed, SOUL.md edited), the prefix mismatches, the static stays
|
||||
None, and requests fall back to the legacy layout with the stored
|
||||
prompt bytes untouched — never a rewritten prompt.
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ _PARALLEL_SAFE_TOOLS = frozenset({
|
||||
"ha_get_state",
|
||||
"ha_list_entities",
|
||||
"ha_list_services",
|
||||
"image_generate",
|
||||
"read_file",
|
||||
"search_files",
|
||||
"session_search",
|
||||
|
||||
+42
-1
@@ -93,6 +93,7 @@ def _budget_for_agent(agent) -> BudgetConfig:
|
||||
# Maximum number of concurrent worker threads for parallel tool execution.
|
||||
# Mirrors the constant in ``run_agent`` for tests/imports that look here.
|
||||
_MAX_TOOL_WORKERS = 8
|
||||
_DEFAULT_IMAGE_PARALLEL_REQUESTS = 4
|
||||
# Keep this above the stock auxiliary.web_extract timeout (360s) so the batch
|
||||
# guard does not preempt a slow-but-valid summarization attempt.
|
||||
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
|
||||
@@ -159,6 +160,46 @@ def _flush_session_db_after_tool_progress(
|
||||
return False
|
||||
|
||||
|
||||
def _image_generate_parallel_limit() -> int:
|
||||
"""Return the configured image-generation parallelism cap.
|
||||
|
||||
Image-generation calls are slow enough that concurrent execution is useful,
|
||||
but backend bursts can hit TTFB or rate-limit failures. Keep the default
|
||||
intentionally conservative while allowing users to tune it per install.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config() or {}
|
||||
image_gen = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
value = (
|
||||
image_gen.get("max_parallel_requests")
|
||||
if isinstance(image_gen, dict)
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
value = None
|
||||
|
||||
try:
|
||||
limit = int(value)
|
||||
except (TypeError, ValueError):
|
||||
limit = _DEFAULT_IMAGE_PARALLEL_REQUESTS
|
||||
return max(1, min(limit, _MAX_TOOL_WORKERS))
|
||||
|
||||
|
||||
def _max_workers_for_tool_batch(runnable_calls) -> int:
|
||||
"""Return the worker cap for a concurrent tool batch."""
|
||||
if not runnable_calls:
|
||||
return 0
|
||||
max_workers = _MAX_TOOL_WORKERS
|
||||
if any(
|
||||
(call[2] if len(call) >= 3 else None) == "image_generate"
|
||||
for call in runnable_calls
|
||||
):
|
||||
max_workers = min(max_workers, _image_generate_parallel_limit())
|
||||
return min(len(runnable_calls), max_workers)
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
|
||||
import run_agent
|
||||
@@ -953,7 +994,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
timeout_s = _resolve_concurrent_tool_timeout()
|
||||
deadline = time.monotonic() + timeout_s if timeout_s is not None else None
|
||||
if runnable_calls:
|
||||
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
|
||||
max_workers = _max_workers_for_tool_batch(runnable_calls)
|
||||
# Daemon workers: an interrupted/timed-out batch is abandoned with
|
||||
# shutdown(wait=False), but stdlib ThreadPoolExecutor workers are
|
||||
# non-daemon and registered in concurrent.futures' atexit hook,
|
||||
|
||||
@@ -9,6 +9,7 @@ which has provider-specific conditionals for max_tokens defaults,
|
||||
reasoning configuration, temperature handling, and extra_body assembly.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
@@ -18,6 +19,56 @@ from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall, Usage
|
||||
|
||||
|
||||
def _static_prompt_instructions(messages: list[dict[str, Any]]) -> str:
|
||||
"""Return the stable system/developer prefix used for cache routing.
|
||||
|
||||
Chat Completions carries instructions in its message list rather than a
|
||||
separate ``instructions`` field. Only a leading system/developer message
|
||||
is static by contract; later messages are conversation state and must not
|
||||
split a warm prefix bucket on every turn.
|
||||
"""
|
||||
if not messages or not isinstance(messages[0], dict):
|
||||
return ""
|
||||
first = messages[0]
|
||||
if first.get("role") not in {"system", "developer"}:
|
||||
return ""
|
||||
content = first.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
try:
|
||||
return json.dumps(content, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
return str(content or "")
|
||||
|
||||
|
||||
def _add_prompt_cache_key(
|
||||
api_kwargs: dict[str, Any],
|
||||
*,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
supports_prompt_cache_key: bool,
|
||||
) -> None:
|
||||
"""Add a content-addressed key only for an explicitly capable endpoint."""
|
||||
if not supports_prompt_cache_key:
|
||||
return
|
||||
|
||||
# An explicit caller body field is authoritative too. Do not add a
|
||||
# duplicate top-level field whose SDK merge precedence could overwrite it.
|
||||
extra_body = api_kwargs.get("extra_body")
|
||||
if "prompt_cache_key" in api_kwargs or (
|
||||
isinstance(extra_body, dict) and "prompt_cache_key" in extra_body
|
||||
):
|
||||
return
|
||||
|
||||
# Reuse the Responses transport's single authoritative hash algorithm so
|
||||
# equivalent static prefixes route to the same cache bucket across modes.
|
||||
from agent.transports.codex import _content_cache_key
|
||||
|
||||
cache_key = _content_cache_key(_static_prompt_instructions(messages), tools)
|
||||
if cache_key:
|
||||
api_kwargs["prompt_cache_key"] = cache_key
|
||||
|
||||
|
||||
def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None:
|
||||
"""Return the model's wire-compatible reasoning config."""
|
||||
if not isinstance(reasoning_config, dict):
|
||||
@@ -112,6 +163,24 @@ def _is_gemini_openai_compat_base_url(base_url: Any) -> bool:
|
||||
return normalized.endswith("/openai")
|
||||
|
||||
|
||||
def _is_openai_api_base_url(base_url: Any) -> bool:
|
||||
"""True only for api.openai.com itself (exact host).
|
||||
|
||||
OpenAI documents ``prompt_cache_key`` as a first-class body field and
|
||||
GPT-5.6+ docs recommend it for reliable cache routing, so the flag is
|
||||
implied for the real endpoint. Deliberately NOT a substring match:
|
||||
Azure OpenAI and strict OpenAI-compat endpoints may reject unknown
|
||||
fields and must stay opt-in via ``supports_prompt_cache_key``.
|
||||
"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = (urlparse(str(base_url or "").strip()).hostname or "").lower()
|
||||
except Exception:
|
||||
return False
|
||||
return host == "api.openai.com"
|
||||
|
||||
|
||||
def _model_consumes_thought_signature(model: Any) -> bool:
|
||||
"""True when the outgoing model is a Gemini family model that requires
|
||||
``extra_content`` (thought_signature) to be replayed on tool calls.
|
||||
@@ -327,6 +396,8 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
# Claude on OpenRouter/Nous max output
|
||||
anthropic_max_output: int | None
|
||||
extra_body_additions: dict | None
|
||||
supports_prompt_cache_key: bool — explicit endpoint capability for
|
||||
the top-level Chat Completions request field; defaults off.
|
||||
"""
|
||||
# Codex sanitization: drop reasoning_items / call_id / response_item_id.
|
||||
# Pass model so the Gemini thought_signature (extra_content) is kept for
|
||||
@@ -507,6 +578,14 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if overrides:
|
||||
api_kwargs.update(overrides)
|
||||
|
||||
_add_prompt_cache_key(
|
||||
api_kwargs,
|
||||
messages=sanitized,
|
||||
tools=api_kwargs.get("tools"),
|
||||
supports_prompt_cache_key=bool(params.get("supports_prompt_cache_key"))
|
||||
or _is_openai_api_base_url(params.get("base_url")),
|
||||
)
|
||||
|
||||
return api_kwargs
|
||||
|
||||
def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
|
||||
@@ -649,6 +728,13 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
|
||||
_add_prompt_cache_key(
|
||||
api_kwargs,
|
||||
messages=sanitized,
|
||||
tools=api_kwargs.get("tools"),
|
||||
supports_prompt_cache_key=bool(profile.supports_prompt_cache_key),
|
||||
)
|
||||
|
||||
return api_kwargs
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
|
||||
+73
-44
@@ -28,6 +28,52 @@ def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
|
||||
return f"pck_{digest}"
|
||||
|
||||
|
||||
# Wire-name used when Hermes keeps client-side web_search on xAI Responses.
|
||||
# A function literally named ``web_search`` collides with Grok's native
|
||||
# server-side tool (incomplete hang or HTTP 400 duplicate names); this alias
|
||||
# avoids that while still dispatching through Hermes's configured provider
|
||||
# (Firecrawl / Tavily / …). Mapped back to ``web_search`` in normalize_response.
|
||||
_XAI_CLIENT_WEB_SEARCH_ALIAS = "hermes_web_search"
|
||||
|
||||
|
||||
def _xai_prefers_native_web_search() -> bool:
|
||||
"""True when xAI Responses should use Grok's native ``web_search`` built-in.
|
||||
|
||||
Delegates to the web-search registry's provider resolution (which reads
|
||||
``web.search_backend`` / ``web.backend`` from config) and checks whether
|
||||
the resolved provider is xAI. Falls back to the legacy ``_get_search_backend``
|
||||
probe when the registry has no providers loaded. On any resolution failure,
|
||||
returns True (fail-closed to native — preserves the #48108 incomplete-hang
|
||||
fix rather than risk reintroducing it).
|
||||
"""
|
||||
try:
|
||||
from agent.web_search_registry import get_active_search_provider
|
||||
|
||||
provider = get_active_search_provider()
|
||||
if provider is not None:
|
||||
return getattr(provider, "name", None) == "xai"
|
||||
|
||||
from tools.web_tools import _get_search_backend
|
||||
|
||||
return (_get_search_backend() or "").strip().lower() == "xai"
|
||||
except Exception:
|
||||
# Fail closed to native — same behavior as pre-fix main.
|
||||
return True
|
||||
|
||||
|
||||
def _rename_client_web_search_for_xai(response_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Rename client ``web_search`` → alias so xAI won't hijack it server-side."""
|
||||
rewritten: List[Dict[str, Any]] = []
|
||||
for tool in response_tools:
|
||||
if isinstance(tool, dict) and tool.get("name") == "web_search":
|
||||
aliased = dict(tool)
|
||||
aliased["name"] = _XAI_CLIENT_WEB_SEARCH_ALIAS
|
||||
rewritten.append(aliased)
|
||||
else:
|
||||
rewritten.append(tool)
|
||||
return rewritten
|
||||
|
||||
|
||||
_EXTENDED_PROMPT_CACHE_MODELS = (
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
@@ -232,63 +278,41 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
|
||||
response_tools = _responses_tools(tools)
|
||||
|
||||
# xAI server-side web search.
|
||||
# xAI server-side web search vs Hermes web providers.
|
||||
#
|
||||
# grok models on xAI's /v1/responses surface (notably
|
||||
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
|
||||
# server-executed web search. When the model is handed a
|
||||
# client-side function literally named ``web_search``, it routes
|
||||
# the intent to that native engine — but because the tool is
|
||||
# declared as a plain ``function`` rather than xAI's first-class
|
||||
# ``{"type": "web_search"}`` built-in, the server-side search is
|
||||
# dispatched but never reconciled: the response streams reasoning
|
||||
# + ``web_search_call`` progress items, the searches never reach
|
||||
# ``status="completed"`` in the assembled output, no final
|
||||
# message is emitted, and ``_normalize_codex_response`` correctly
|
||||
# sees reasoning-with-no-answer and reports ``incomplete``. The
|
||||
# turn then burns 3 continuation retries and fails with "Codex
|
||||
# response remained incomplete after 3 continuation attempts".
|
||||
# Verified live against grok-composer-2.5-fast (2026-06).
|
||||
# grok models on xAI's /v1/responses surface have a *native*,
|
||||
# server-executed web search. A client-side function literally named
|
||||
# ``web_search`` collides with that engine: declared as a plain
|
||||
# ``function`` rather than ``{"type": "web_search"}``, the search
|
||||
# dispatches but never reconciles → incomplete turn + 3 retries.
|
||||
# Verified live against grok-composer-2.5-fast (2026-06); see #48108.
|
||||
#
|
||||
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
|
||||
# the user enabled the web toolset), declare xAI's native
|
||||
# ``web_search`` built-in instead so the search actually runs to
|
||||
# completion server-side and the model streams a real answer. The
|
||||
# Responses API rejects two tools sharing the name ``web_search``
|
||||
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
|
||||
# ``web_search`` function for the xAI path and let the native tool
|
||||
# satisfy it. All other client-side tools (read_file, terminal,
|
||||
# web_extract, MCP tools, …) are untouched and continue to dispatch
|
||||
# through Hermes's agent loop.
|
||||
# Two modes, chosen by the user's web-search backend config:
|
||||
#
|
||||
# Scope: we ONLY swap in the native built-in when the client
|
||||
# ``web_search`` was actually present. We do NOT force-enable Grok
|
||||
# server-side search on turns where the user never had web enabled —
|
||||
# that would silently route around Hermes's web-provider config and
|
||||
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
|
||||
# a 1:1 replacement of an already-requested capability, not an
|
||||
# additive grant.
|
||||
#
|
||||
# NOTE: for the swapped case this routes ``web_search`` to Grok's
|
||||
# native search engine for xAI sessions instead of Hermes's
|
||||
# configured web provider (Tavily/etc.), and those results bypass
|
||||
# Hermes's tool-trace / citation plumbing (they arrive baked into the
|
||||
# model's answer rather than as a tool result the loop observes).
|
||||
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
|
||||
# models if a future grok variant should keep the client-side
|
||||
# function.
|
||||
# 1. **Native** (active/configured backend is ``xai``, or resolution
|
||||
# fails): drop the client ``web_search`` function and declare
|
||||
# xAI's built-in instead. 1:1 swap only when client ``web_search``
|
||||
# was already present — never an additive grant.
|
||||
# 2. **Client** (Firecrawl / Tavily / Exa / … configured or resolved):
|
||||
# keep Hermes dispatch so ``web.backend`` / ``web.search_backend``
|
||||
# is honored, but rename the wire tool to
|
||||
# ``hermes_web_search`` so Grok cannot hijack the name. The alias
|
||||
# is mapped back to ``web_search`` in ``normalize_response``.
|
||||
if is_xai_responses and response_tools:
|
||||
has_client_web_search = any(
|
||||
isinstance(t, dict) and t.get("name") == "web_search"
|
||||
for t in response_tools
|
||||
)
|
||||
if has_client_web_search:
|
||||
if _xai_prefers_native_web_search():
|
||||
filtered = [
|
||||
t for t in response_tools
|
||||
if not (isinstance(t, dict) and t.get("name") == "web_search")
|
||||
]
|
||||
filtered.append({"type": "web_search"})
|
||||
response_tools = filtered
|
||||
else:
|
||||
response_tools = _rename_client_web_search_for_xai(response_tools)
|
||||
|
||||
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||
@@ -487,9 +511,14 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
provider_data["call_id"] = tc.call_id
|
||||
if hasattr(tc, "response_item_id") and tc.response_item_id:
|
||||
provider_data["response_item_id"] = tc.response_item_id
|
||||
name = tc.function.name if hasattr(tc, "function") else getattr(tc, "name", "")
|
||||
# Undo the xAI client-path wire alias so Hermes dispatches
|
||||
# the real ``web_search`` tool (Firecrawl / etc.).
|
||||
if name == _XAI_CLIENT_WEB_SEARCH_ALIAS:
|
||||
name = "web_search"
|
||||
tool_calls.append(ToolCall(
|
||||
id=tc.id if hasattr(tc, "id") else (tc.function.name if hasattr(tc, "function") else None),
|
||||
name=tc.function.name if hasattr(tc, "function") else getattr(tc, "name", ""),
|
||||
id=tc.id if hasattr(tc, "id") else (name or None),
|
||||
name=name,
|
||||
arguments=tc.function.arguments if hasattr(tc, "function") else getattr(tc, "arguments", "{}"),
|
||||
provider_data=provider_data or None,
|
||||
))
|
||||
|
||||
@@ -1259,6 +1259,8 @@ def _approval_choice_to_codex_decision(choice: str) -> str:
|
||||
return "accept"
|
||||
if choice in {"session", "always"}:
|
||||
return "acceptForSession"
|
||||
# "deny" and "timeout" both map to decline — codex has no wire value for
|
||||
# "prompt expired"; the Hermes-side messaging already distinguishes them.
|
||||
return "decline"
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from agent.conversation_compression import (
|
||||
from agent.context_engine import automatic_compaction_status_message
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
from agent.memory_provider import is_trivial_prompt
|
||||
from agent.model_metadata import (
|
||||
estimate_messages_tokens_rough,
|
||||
estimate_request_tokens_rough,
|
||||
@@ -1152,10 +1153,14 @@ def build_turn_context(
|
||||
pass
|
||||
|
||||
# External memory provider: prefetch once before the tool loop.
|
||||
#
|
||||
# Skip prefetch on trivial prompts (greetings, acknowledgements) to
|
||||
# prevent memory-context injection on turns that carry no semantic signal.
|
||||
ext_prefetch_cache = ""
|
||||
if agent._memory_manager:
|
||||
try:
|
||||
_query = original_user_message if isinstance(original_user_message, str) else ""
|
||||
if not is_trivial_prompt(_query):
|
||||
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('persistent terminal overlay follows the pane after split dragging', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await page.keyboard.press('Control+`')
|
||||
await page.locator('[data-terminal-slot]').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await page.locator('[data-persistent-terminal] .xterm').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const slot = document.querySelector('[data-terminal-slot]')
|
||||
const overlay = document.querySelector('[data-persistent-terminal]')
|
||||
|
||||
if (!slot || !overlay) {
|
||||
return { drift: -1, moved: 0, target: false }
|
||||
}
|
||||
|
||||
const before = slot.getBoundingClientRect()
|
||||
const target = [...document.querySelectorAll<HTMLElement>('[role="separator"]')]
|
||||
.map(element => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const horizontal = box.width > box.height
|
||||
const center = horizontal
|
||||
? (box.top + box.bottom) / 2
|
||||
: (box.left + box.right) / 2
|
||||
const sides = horizontal
|
||||
? [before.top, before.bottom]
|
||||
: [before.left, before.right]
|
||||
|
||||
return {
|
||||
element,
|
||||
box,
|
||||
horizontal,
|
||||
score: Math.min(...sides.map(side => Math.abs(center - side))),
|
||||
}
|
||||
})
|
||||
.filter(item => item.box.width > 0 && item.box.height > 0)
|
||||
.sort((a, b) => a.score - b.score)[0]
|
||||
|
||||
if (!target) {
|
||||
return { drift: -1, moved: 0, target: false }
|
||||
}
|
||||
|
||||
const x = target.box.left + target.box.width / 2
|
||||
const y0 = target.box.top + target.box.height / 2
|
||||
const nearestSide = target.horizontal
|
||||
? Math.abs(y0 - before.top) < Math.abs(y0 - before.bottom)
|
||||
? 'top'
|
||||
: 'bottom'
|
||||
: Math.abs(x - before.left) < Math.abs(x - before.right)
|
||||
? 'left'
|
||||
: 'right'
|
||||
const deltaX = nearestSide === 'left' ? -1 : nearestSide === 'right' ? 1 : 0
|
||||
const deltaY = nearestSide === 'top' ? -1 : nearestSide === 'bottom' ? 1 : 0
|
||||
let currentX = x
|
||||
let y = y0
|
||||
const pointer = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 71,
|
||||
pointerType: 'mouse',
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
}
|
||||
|
||||
target.element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }),
|
||||
)
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
currentX += deltaX
|
||||
y += deltaY
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
...pointer,
|
||||
clientX: currentX,
|
||||
clientY: y,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
...pointer,
|
||||
buttons: 0,
|
||||
clientX: currentX,
|
||||
clientY: y,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 350))
|
||||
await new Promise<void>(resolve =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
)
|
||||
|
||||
const next = slot.getBoundingClientRect()
|
||||
const fixed = overlay.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
drift: Math.max(
|
||||
Math.abs(next.top - fixed.top),
|
||||
Math.abs(next.left - fixed.left),
|
||||
Math.abs(next.width - fixed.width),
|
||||
Math.abs(next.height - fixed.height),
|
||||
),
|
||||
moved: Math.max(
|
||||
Math.abs(next.top - before.top),
|
||||
Math.abs(next.left - before.left),
|
||||
Math.abs(next.width - before.width),
|
||||
Math.abs(next.height - before.height),
|
||||
),
|
||||
target: true,
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.target).toBe(true)
|
||||
expect(result.moved).toBeGreaterThan(10)
|
||||
expect(result.drift).toBeLessThanOrEqual(1)
|
||||
})
|
||||
@@ -5410,6 +5410,12 @@ function buildApplicationMenu() {
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
// ⌘⇧V is only wired up by this item existing: an accelerator with no menu
|
||||
// entry is never translated into an editor command, so the chord was a
|
||||
// no-op in every input in the app. The composer inserts plain text on
|
||||
// every paste anyway, so this is the same result as ⌘V there — it's the
|
||||
// terminal, preview, and other editable surfaces that need the strip.
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' }
|
||||
]
|
||||
@@ -10327,7 +10333,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
// kind+session can arrive here twice. Collapse it at this single choke point.
|
||||
// Return true (not false): a notification for the event IS being shown by the
|
||||
// first caller, so the settings "send test" success probe stays honest.
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? payload?.tag ?? ''}`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10504,6 +10510,22 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// Native save-location picker (profile export etc.) — the write itself happens
|
||||
// elsewhere (the backend, for profile archives); this only picks the path.
|
||||
ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: options?.title || 'Save',
|
||||
defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePath
|
||||
})
|
||||
|
||||
// Paired reader for the GUI terminal's paste chord: the renderer's
|
||||
// navigator.clipboard.readText() throws "Document is not focused" whenever a
|
||||
// portaled overlay has focus, and there's no way to route a read through the
|
||||
|
||||
@@ -115,6 +115,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
},
|
||||
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
|
||||
@@ -53,6 +53,7 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
|
||||
| `transcript` | ci | large-transcript mount + paint cost | (new) |
|
||||
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
|
||||
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
|
||||
| `right-pane` | report | file tree + persistent xterm tabs under chat/terminal output and split dragging | (new) |
|
||||
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
|
||||
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
||||
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
|
||||
|
||||
@@ -8,6 +8,7 @@ import keystroke from './keystroke.mjs'
|
||||
import multitab from './multitab.mjs'
|
||||
import profileSwitch from './profile-switch.mjs'
|
||||
import renderChurn from './render-churn.mjs'
|
||||
import rightPane from './right-pane.mjs'
|
||||
import sessionLoad from './session-load.mjs'
|
||||
import sessionSwitch from './session-switch.mjs'
|
||||
import stream from './stream.mjs'
|
||||
@@ -22,6 +23,7 @@ export const SCENARIOS = {
|
||||
[transcript.name]: transcript,
|
||||
[multitab.name]: multitab,
|
||||
[renderChurn.name]: renderChurn,
|
||||
[rightPane.name]: rightPane,
|
||||
[idleCost.name]: idleCost,
|
||||
[coldStart.name]: coldStart,
|
||||
[firstToken.name]: firstToken,
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// File-tree + terminal workspace stress. This is the regression scene for the
|
||||
// desktop symptom where opening the project tree/terminal made the whole page
|
||||
// hitch while chat and PTY output continued.
|
||||
//
|
||||
// It mounts a real project tree, one PTY plus multiple persistent xterm tabs,
|
||||
// streams chat and terminal output together, mutates Git decoration state, and
|
||||
// drags the terminal split. The debug probe records the specific work we care
|
||||
// about rather than inferring it from CPU alone:
|
||||
// - fixed-overlay measurements
|
||||
// - active/hidden xterm fits
|
||||
// - ProjectTree + per-path row renders
|
||||
// - frame pacing / slow frames
|
||||
//
|
||||
// npm run perf -- right-pane --spawn --prod --runs 3
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
import { frameHistogram, percentile } from '../lib/stats.mjs'
|
||||
|
||||
const DEFAULT_CWD = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
|
||||
const RECORDERS = `
|
||||
(() => {
|
||||
window.__RP_FRAME_GEN__ = (window.__RP_FRAME_GEN__ || 0) + 1
|
||||
const generation = window.__RP_FRAME_GEN__
|
||||
window.__RP_FRAMES__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__RP_FRAME_GEN__ !== generation || window.__RP_FRAMES__.stop) return
|
||||
const now = performance.now()
|
||||
window.__RP_FRAMES__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__RP_LONG__ = { entries: [], stop: false }
|
||||
try {
|
||||
const observer = new PerformanceObserver(list => {
|
||||
if (window.__RP_LONG__.stop) return
|
||||
for (const entry of list.getEntries()) {
|
||||
window.__RP_LONG__.entries.push({ duration: entry.duration, startTime: entry.startTime })
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['longtask'] })
|
||||
window.__RP_LONG__.observer = observer
|
||||
} catch {}
|
||||
return 'armed'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT_RECORDERS = `
|
||||
(() => {
|
||||
window.__RP_FRAMES__.stop = true
|
||||
window.__RP_LONG__.stop = true
|
||||
try { window.__RP_LONG__.observer && window.__RP_LONG__.observer.disconnect() } catch {}
|
||||
return JSON.stringify({ frames: window.__RP_FRAMES__.times, longtasks: window.__RP_LONG__.entries })
|
||||
})()
|
||||
`
|
||||
|
||||
const START_COUNTERS = `window.__RIGHT_PANE_PERF__.start(); 'recording'`
|
||||
const SNAPSHOT_COUNTERS = `
|
||||
(() => {
|
||||
window.__RIGHT_PANE_PERF__.stop()
|
||||
return JSON.stringify(window.__RIGHT_PANE_PERF__.snapshot())
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG_TERMINAL_SPLIT = `
|
||||
(async () => {
|
||||
const slot = document.querySelector('[data-terminal-slot]')
|
||||
const overlay = document.querySelector('[data-persistent-terminal]')
|
||||
if (!slot || !overlay) return JSON.stringify({ target: 'none', drift: -1, moved: 0 })
|
||||
|
||||
const slotBox = slot.getBoundingClientRect()
|
||||
const candidates = [...document.querySelectorAll('[role="separator"]')]
|
||||
.map(element => ({ element, box: element.getBoundingClientRect() }))
|
||||
.filter(item => item.box.width > item.box.height * 3)
|
||||
.sort((a, b) =>
|
||||
Math.abs((a.box.top + a.box.bottom) / 2 - slotBox.top) -
|
||||
Math.abs((b.box.top + b.box.bottom) / 2 - slotBox.top)
|
||||
)
|
||||
const target = candidates[0]
|
||||
if (!target) return JSON.stringify({ target: 'none', drift: -1, moved: 0 })
|
||||
|
||||
const x = target.box.left + target.box.width / 2
|
||||
const y0 = target.box.top + target.box.height / 2
|
||||
let y = y0
|
||||
const pointer = {
|
||||
bubbles: true, cancelable: true, pointerId: 91, pointerType: 'mouse',
|
||||
isPrimary: true, button: 0, buttons: 1
|
||||
}
|
||||
target.element.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }))
|
||||
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
y -= 1
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: x, clientY: y }))
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
}
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
y += 1
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: x, clientY: y }))
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientX: x, clientY: y }))
|
||||
// Track-size transitions continue briefly after pointerup. Wait through
|
||||
// that animation, then give the overlay its normal two-frame calibration.
|
||||
await new Promise(resolve => setTimeout(resolve, 350))
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
|
||||
const a = slot.getBoundingClientRect()
|
||||
const b = overlay.getBoundingClientRect()
|
||||
const drift = Math.max(
|
||||
Math.abs(a.top - b.top),
|
||||
Math.abs(a.left - b.left),
|
||||
Math.abs(a.width - b.width),
|
||||
Math.abs(a.height - b.height)
|
||||
)
|
||||
return JSON.stringify({ target: 'horizontal-separator', drift, moved: 24 })
|
||||
})()
|
||||
`
|
||||
|
||||
async function waitFor(cdp, expression, label, timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await cdp.eval(expression)) {
|
||||
return
|
||||
}
|
||||
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
throw new Error(`right-pane timed out waiting for ${label}`)
|
||||
}
|
||||
|
||||
const trimWarmup = (frames, warmupMs = 300) => {
|
||||
const kept = []
|
||||
let elapsed = 0
|
||||
|
||||
for (const frame of frames) {
|
||||
elapsed += frame
|
||||
|
||||
if (elapsed >= warmupMs) {
|
||||
kept.push(frame)
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'right-pane',
|
||||
tier: 'report',
|
||||
description: 'Project tree + persistent terminal tabs under chat/terminal output and split dragging.',
|
||||
async run(cdp, opts = {}) {
|
||||
const cwd = resolve(String(opts.cwd ?? DEFAULT_CWD))
|
||||
const terminalCount = Math.max(2, Number(opts.terminals ?? 3))
|
||||
const tokens = Number(opts.tokens ?? 90)
|
||||
const outputChunks = Number(opts.outputChunks ?? 160)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ready = await cdp.eval(
|
||||
`!!(window.__PERF_DRIVE__?.rightPaneSetup && window.__RIGHT_PANE_PERF__ && window.__HERMES_LAYOUT_TREE__)`
|
||||
)
|
||||
|
||||
if (!ready) {
|
||||
throw new Error('right-pane needs a dev renderer or a production build with VITE_PERF_PROBE=1.')
|
||||
}
|
||||
|
||||
let setup
|
||||
|
||||
try {
|
||||
setup = await cdp.eval(
|
||||
`window.__PERF_DRIVE__.rightPaneSetup(${JSON.stringify({ cwd, terminals: terminalCount })})`
|
||||
)
|
||||
await cdp.eval(`window.__HERMES_LAYOUT_TREE__.reveal('files'); window.__HERMES_LAYOUT_TREE__.reveal('terminal')`)
|
||||
await waitFor(cdp, `!!document.querySelector('[data-project-tree]')`, 'project tree')
|
||||
await waitFor(
|
||||
cdp,
|
||||
`!!document.querySelector('[data-terminal-slot]') && !!document.querySelector('[data-persistent-terminal]')`,
|
||||
'persistent terminal'
|
||||
)
|
||||
await waitFor(
|
||||
cdp,
|
||||
`document.querySelectorAll('[data-terminal] .xterm').length >= ${terminalCount}`,
|
||||
`${terminalCount} mounted xterms`,
|
||||
30000
|
||||
)
|
||||
await sleep(1200)
|
||||
|
||||
// Activate every keep-alive tab once, then return to the output tab.
|
||||
// Each activation should restore exactly one fit; inactive tabs must stay
|
||||
// at zero even while another tab resizes or writes output.
|
||||
await cdp.eval(START_COUNTERS)
|
||||
|
||||
for (const id of setup.terminalIds) {
|
||||
await cdp.eval(`window.__PERF_DRIVE__.rightPaneSelect(${JSON.stringify(id)})`)
|
||||
await sleep(180)
|
||||
}
|
||||
|
||||
const activation = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
|
||||
await cdp.eval(RECORDERS)
|
||||
|
||||
// Chat DOM churn is deliberately measured in its own counter window:
|
||||
// terminal positioning should receive no wakeups from transcript changes.
|
||||
await cdp.eval(START_COUNTERS)
|
||||
await cdp.eval(
|
||||
`window.__PERF_DRIVE__.stream({
|
||||
chunk: 'Right pane streaming sentence with **bold** and \`code\`.\\n\\n',
|
||||
intervalMs: 16,
|
||||
totalTokens: ${tokens},
|
||||
flushMinMs: 33
|
||||
})`
|
||||
)
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
let n = 0
|
||||
window.__RP_OUTPUT_TIMER__ = setInterval(() => {
|
||||
window.__PERF_DRIVE__.rightPaneWrite(
|
||||
${JSON.stringify(setup.procId)},
|
||||
'terminal output line ' + n + ' ........................................\\r\\n'
|
||||
)
|
||||
n += 1
|
||||
if (n >= ${outputChunks}) clearInterval(window.__RP_OUTPUT_TIMER__)
|
||||
}, 16)
|
||||
return 'writing'
|
||||
})()
|
||||
`)
|
||||
await sleep(Math.max(tokens, outputChunks) * 16 + 900)
|
||||
const stream = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
|
||||
// An unrelated Git status publication should render neither the tree root
|
||||
// nor any visible row. A status for one visible path should touch only it.
|
||||
await cdp.eval(START_COUNTERS)
|
||||
await cdp.eval(`window.__PERF_DRIVE__.rightPaneGit('__right_pane_unrelated__.txt', 'modified')`)
|
||||
await sleep(250)
|
||||
const unrelatedGit = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
|
||||
const visiblePath = await cdp.eval(
|
||||
`document.querySelector('[data-project-tree] [title]')?.getAttribute('title') || ''`
|
||||
)
|
||||
let affectedGit = { counts: { 'project-tree-render': 0, 'project-tree-row-render': 0 }, rows: {} }
|
||||
|
||||
if (visiblePath) {
|
||||
const relative = String(visiblePath).startsWith(`${cwd}/`)
|
||||
? String(visiblePath).slice(cwd.length + 1)
|
||||
: String(visiblePath)
|
||||
await cdp.eval(START_COUNTERS)
|
||||
await cdp.eval(`window.__PERF_DRIVE__.rightPaneGit(${JSON.stringify(relative)}, 'modified')`)
|
||||
await sleep(250)
|
||||
affectedGit = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
}
|
||||
|
||||
await cdp.eval(START_COUNTERS)
|
||||
const drag = JSON.parse(await cdp.eval(DRAG_TERMINAL_SPLIT))
|
||||
const dragCounters = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
const recorded = JSON.parse(await cdp.eval(COLLECT_RECORDERS))
|
||||
const frames = trimWarmup(recorded.frames)
|
||||
const longtasks = recorded.longtasks.map(entry => entry.duration)
|
||||
const streamCounts = stream.counts
|
||||
const activationCounts = activation.counts
|
||||
const unrelatedCounts = unrelatedGit.counts
|
||||
const affectedRows = Object.values(affectedGit.rows).reduce((sum, count) => sum + count, 0)
|
||||
const affectedPaths = Object.keys(affectedGit.rows).length
|
||||
|
||||
if (drag.target === 'none') {
|
||||
throw new Error('right-pane found no horizontal terminal split separator.')
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
chat_terminal_measures: streamCounts['terminal-measure'],
|
||||
hidden_terminal_fits: activationCounts['terminal-fit-hidden'] + streamCounts['terminal-fit-hidden'],
|
||||
activation_fit_mismatch: Math.abs(activationCounts['terminal-fit-active'] - setup.terminalIds.length),
|
||||
unrelated_tree_renders: unrelatedCounts['project-tree-render'],
|
||||
unrelated_row_renders: unrelatedCounts['project-tree-row-render'],
|
||||
affected_tree_renders: affectedGit.counts['project-tree-render'],
|
||||
affected_row_path_excess: Math.max(0, affectedPaths - 1),
|
||||
terminal_drift_px: Math.round(drag.drift * 10) / 10,
|
||||
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
|
||||
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
|
||||
slow_frames_33: frames.filter(frame => frame > 33).length,
|
||||
longtask_max_ms: Math.round((longtasks.length ? Math.max(...longtasks) : 0) * 10) / 10
|
||||
},
|
||||
detail: {
|
||||
cwd,
|
||||
terminals: setup.terminalIds.length,
|
||||
activation,
|
||||
stream,
|
||||
unrelatedGit,
|
||||
affectedGit,
|
||||
affectedRows,
|
||||
drag,
|
||||
dragCounters,
|
||||
frameHistogram: frameHistogram(frames),
|
||||
frames: frames.length
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
clearInterval(window.__RP_OUTPUT_TIMER__)
|
||||
window.__RIGHT_PANE_PERF__?.stop()
|
||||
window.__PERF_DRIVE__?.reset()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
@@ -189,15 +190,17 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0)
|
||||
const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
|
||||
|
||||
const visible = usePaneVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (active <= 0 || typeof window === 'undefined') {
|
||||
if (active <= 0 || !visible || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active])
|
||||
}, [active, visible])
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -446,10 +446,7 @@ export function ChatBar({
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
if (imageBlobs.length > 0 && onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
@@ -457,9 +454,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
@@ -469,6 +463,10 @@ export function ChatBar({
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
|
||||
// the Linux clipboard the DOM paste event reads, so a host screenshot
|
||||
// arrives as an empty paste (no blobs, no text). Fall back to the main
|
||||
|
||||
@@ -212,6 +212,47 @@ describe('extractClipboardImageBlobs', () => {
|
||||
|
||||
expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
|
||||
})
|
||||
|
||||
// A rich-text copy (Discord thread, web page, doc) carries prose plus whatever
|
||||
// inline images the page decorated it with. That is a TEXT paste: attaching the
|
||||
// page's placeholder graphics as composer images while the text vanished is the
|
||||
// "blank attachments, no message" bug.
|
||||
it('ignores inline HTML images when the copy carries its own text', () => {
|
||||
const clipboard = {
|
||||
files: { length: 0, item: () => null },
|
||||
getData: (type: string) =>
|
||||
type === 'text/html'
|
||||
? `<p>hello from the thread</p><img src="data:image/png;base64,${'A'.repeat(20_000)}">`
|
||||
: 'hello from the thread',
|
||||
items: []
|
||||
} as unknown as DataTransfer
|
||||
|
||||
expect(extractClipboardImageBlobs(clipboard)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps inline HTML images when the copy is image-only', () => {
|
||||
const clipboard = {
|
||||
files: { length: 0, item: () => null },
|
||||
getData: (type: string) =>
|
||||
type === 'text/html' ? `<img src="data:image/png;base64,${'A'.repeat(20_000)}">` : '',
|
||||
items: []
|
||||
} as unknown as DataTransfer
|
||||
|
||||
const blobs = extractClipboardImageBlobs(clipboard)
|
||||
|
||||
expect(blobs).toHaveLength(1)
|
||||
expect(blobs[0]?.type).toBe('image/png')
|
||||
})
|
||||
|
||||
it('drops sub-thumbnail inline images — spacers, trackers, blurhash placeholders', () => {
|
||||
const clipboard = {
|
||||
files: { length: 0, item: () => null },
|
||||
getData: (type: string) => (type === 'text/html' ? `<img src="data:image/png;base64,${'A'.repeat(64)}">` : ''),
|
||||
items: []
|
||||
} as unknown as DataTransfer
|
||||
|
||||
expect(extractClipboardImageBlobs(clipboard)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('blobDedupeKey', () => {
|
||||
|
||||
@@ -70,6 +70,11 @@ const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
|
||||
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
|
||||
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/
|
||||
|
||||
const INLINE_IMAGE_SRC_RE = /<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi
|
||||
// Below this, an inline data URL is chrome rather than content — a spacer, a
|
||||
// 1×1 tracker, or a blurhash placeholder. Real pasted artwork clears it easily.
|
||||
const MIN_INLINE_IMAGE_BYTES = 4096
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
if (blob instanceof File) {
|
||||
@@ -125,16 +130,22 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(text)) {
|
||||
push(dataUrlToBlob(text))
|
||||
|
||||
return blobs
|
||||
}
|
||||
|
||||
if (blobs.length === 0) {
|
||||
const html = clipboard.getData('text/html')
|
||||
// Inline `<img src="data:…">` in the clipboard's HTML — but only for a copy
|
||||
// that carried no text of its own. A rich-text copy WITH prose is a text
|
||||
// paste that happens to contain images, and its data URLs are the page's
|
||||
// decorations rather than content: Discord ships a 32×5 blurhash placeholder
|
||||
// beside every image embed, so copying a thread attached a blank thumbnail
|
||||
// and (because an image paste swallows the event) dropped the text entirely.
|
||||
if (!text) {
|
||||
for (const match of clipboard.getData('text/html').matchAll(INLINE_IMAGE_SRC_RE)) {
|
||||
const blob = dataUrlToBlob(match[1])
|
||||
|
||||
if (html) {
|
||||
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
|
||||
|
||||
for (const match of matches) {
|
||||
push(dataUrlToBlob(match[1]))
|
||||
if (blob && blob.size >= MIN_INLINE_IMAGE_BYTES) {
|
||||
push(blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$contextSuggestions,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$selectedStoredSessionId,
|
||||
$sessions
|
||||
} from '@/store/session'
|
||||
|
||||
const threadRenderCount = vi.hoisted(() => ({ current: 0 }))
|
||||
|
||||
vi.mock('@/components/assistant-ui/thread', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
return {
|
||||
Thread: () => {
|
||||
threadRenderCount.current += 1
|
||||
|
||||
return React.createElement('div', { 'data-testid': 'thread' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/Backdrop', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
return { Backdrop: () => React.createElement('div', { 'data-testid': 'backdrop' }) }
|
||||
})
|
||||
|
||||
vi.mock('@/components/prompt-overlays', () => ({ PromptOverlays: () => null }))
|
||||
vi.mock('@/components/chat/vibe-hearts', () => ({ COMPOSER_HEART_CONFIG: {}, HeartField: () => null }))
|
||||
vi.mock('@/lib/model-options', () => ({
|
||||
modelOptionsQueryKey: (...parts: unknown[]) => ['model-options', ...parts],
|
||||
requestModelOptions: vi.fn(async () => ({ models: [] }))
|
||||
}))
|
||||
vi.mock('./chat-drop-overlay', () => ({ ChatDropOverlay: () => null }))
|
||||
vi.mock('./chat-swap-overlay', () => ({ ChatSwapOverlay: () => null }))
|
||||
vi.mock('./composer', () => ({ ChatBar: () => null, ChatBarFallback: () => null }))
|
||||
vi.mock('./hooks/use-file-drop-zone', () => ({
|
||||
useFileDropZone: () => ({ dragKind: null, dropHandlers: {} })
|
||||
}))
|
||||
vi.mock('./sidebar/session-actions-menu', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
return {
|
||||
SessionActionsMenu: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', { 'data-testid': 'session-actions-menu' }, children)
|
||||
}
|
||||
})
|
||||
|
||||
const { ChatView } = await import('./index')
|
||||
|
||||
function assistantMessage(id: string, text: string): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
parts: [assistantTextPart(text)],
|
||||
role: 'assistant'
|
||||
}
|
||||
}
|
||||
|
||||
describe('ChatView render isolation', () => {
|
||||
beforeEach(() => {
|
||||
threadRenderCount.current = 0
|
||||
$activeSessionId.set('runtime-1')
|
||||
$awaitingResponse.set(false)
|
||||
$busy.set(false)
|
||||
$contextSuggestions.set([])
|
||||
$currentCwd.set('/work')
|
||||
$currentModel.set('test-model')
|
||||
$currentProvider.set('test-provider')
|
||||
$freshDraftReady.set(false)
|
||||
$gatewayState.set('closed')
|
||||
$messages.set([assistantMessage('assistant-1', 'Stable historical answer')])
|
||||
$selectedStoredSessionId.set('stored-1')
|
||||
$sessions.set([{ id: 'stored-1', message_count: 1, title: 'Stable chat' } as never])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
$activeSessionId.set(null)
|
||||
$awaitingResponse.set(false)
|
||||
$busy.set(false)
|
||||
$contextSuggestions.set([])
|
||||
$currentCwd.set('')
|
||||
$currentModel.set('')
|
||||
$currentProvider.set('')
|
||||
$freshDraftReady.set(false)
|
||||
$gatewayState.set('idle')
|
||||
$messages.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$sessions.set([])
|
||||
})
|
||||
|
||||
it('does not re-render chat history when an unrelated parent idle tick updates', () => {
|
||||
const props = {
|
||||
gateway: null,
|
||||
maxVoiceRecordingSeconds: 120,
|
||||
onAddContextRef: vi.fn(),
|
||||
onAddUrl: vi.fn(),
|
||||
onAttachDroppedItems: vi.fn(),
|
||||
onAttachImageBlob: vi.fn(),
|
||||
onBranchInNewChat: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onDeleteSelectedSession: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onPasteClipboardImage: vi.fn(),
|
||||
onPickFiles: vi.fn(),
|
||||
onPickFolders: vi.fn(),
|
||||
onPickImages: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
onRemoveAttachment: vi.fn(),
|
||||
onRetryResume: vi.fn(),
|
||||
onSteer: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onThreadMessagesChange: vi.fn(),
|
||||
onToggleSelectedPin: vi.fn(),
|
||||
onTranscribeAudio: vi.fn()
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } }
|
||||
})
|
||||
|
||||
function ParentTickHarness() {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/stored-1']}>
|
||||
<button onClick={() => setTick(value => value + 1)} type="button">
|
||||
parent tick {tick}
|
||||
</button>
|
||||
<ChatView {...props} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ParentTickHarness />)
|
||||
|
||||
expect(screen.getByTestId('thread')).toBeTruthy()
|
||||
expect(threadRenderCount.current).toBe(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /parent tick/i }))
|
||||
|
||||
// memo(ChatView) with stable props must absorb the parent's idle tick —
|
||||
// the transcript (Thread) must not re-render. This is PR #38470's contract.
|
||||
expect(threadRenderCount.current).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,12 @@ import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { TranscriptWindowProvider } from '@/components/assistant-ui/thread/transcript-window'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
@@ -63,6 +64,7 @@ import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { useSessionView } from './session-view'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { threadLoadingState } from './thread-loading'
|
||||
import { selectTranscriptWindow } from './transcript-window'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
@@ -220,9 +222,34 @@ function ChatRuntimeBoundary({
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useMessagesWhileVisible(useSessionView().$messages)
|
||||
const view = useSessionView()
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const storeMessages = useMessagesWhileVisible(view.$messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
|
||||
|
||||
const [windowPages, setWindowPages] = useState(1)
|
||||
const [windowSessionKey, setWindowSessionKey] = useState(runtimeId)
|
||||
|
||||
// Reset the window on session swap during RENDER, so a large expand from the
|
||||
// previous chat can't leak into the next one's first paint (#55191).
|
||||
if (windowSessionKey !== runtimeId) {
|
||||
setWindowSessionKey(runtimeId)
|
||||
setWindowPages(1)
|
||||
}
|
||||
|
||||
const { messages: windowedMessages, windowed } = useMemo(
|
||||
() => selectTranscriptWindow(messages, windowPages),
|
||||
[messages, windowPages]
|
||||
)
|
||||
|
||||
const runtimeMessageRepository = useRuntimeMessageRepository(windowedMessages)
|
||||
|
||||
const expandWindow = useCallback(() => setWindowPages(pages => pages + 1), [])
|
||||
|
||||
const transcriptWindow = useMemo(
|
||||
() => ({ olderAvailable: windowed, expandWindow }),
|
||||
[expandWindow, windowed]
|
||||
)
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
@@ -237,10 +264,17 @@ function ChatRuntimeBoundary({
|
||||
onReload
|
||||
})
|
||||
|
||||
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
|
||||
return (
|
||||
<TranscriptWindowProvider value={transcriptWindow}>
|
||||
<AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
|
||||
</TranscriptWindowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
// Memoized: the tile caller (session-tile.tsx) and the contrib surface re-render
|
||||
// on idle ticks unrelated to the chat; with stable callback props (hoisted to
|
||||
// useCallback at the call sites) memo() lets the whole chat shell skip those.
|
||||
export const ChatView = memo(function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
modelMenuContent,
|
||||
@@ -596,4 +630,4 @@ export function ChatView({
|
||||
</ChatRuntimeBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
||||
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
|
||||
import {
|
||||
$activeTerminalId,
|
||||
$terminals,
|
||||
createTerminal,
|
||||
ensureAgentTerminal,
|
||||
selectTerminal,
|
||||
type TerminalEntry
|
||||
} from '@/app/right-sidebar/terminal/terminals'
|
||||
import { $repoStatusByCwd } from '@/store/coding-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { $messages, setBusy, setMessages } from '@/store/session'
|
||||
import { $currentCwd, $messages, setBusy, setCurrentCwdTransient, setMessages } from '@/store/session'
|
||||
|
||||
type Sample = {
|
||||
id: string
|
||||
@@ -38,6 +49,12 @@ declare global {
|
||||
* backend) doesn't contaminate frame-pacing numbers.
|
||||
*/
|
||||
connected: () => boolean
|
||||
/** Mount files + multiple xterms for the synthetic right-pane scenario. */
|
||||
rightPaneSetup: (opts: { cwd: string; terminals?: number }) => { procId: string; terminalIds: string[] }
|
||||
rightPaneGit: (path: string, kind?: 'added' | 'conflicted' | 'modified') => void
|
||||
rightPaneReset: () => void
|
||||
rightPaneSelect: (id: string) => void
|
||||
rightPaneWrite: (procId: string, chunk: string) => void
|
||||
reset: () => void
|
||||
snapshotMsgs: () => number
|
||||
}
|
||||
@@ -102,11 +119,32 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
let baseline: ReturnType<typeof $messages.get> | null = null
|
||||
let activeHandle: SyntheticDriverHandle | null = null
|
||||
|
||||
let rightPaneBaseline: null | {
|
||||
activeTerminalId: null | string
|
||||
cwd: string
|
||||
repoStatusByCwd: ReturnType<typeof $repoStatusByCwd.get>
|
||||
takeover: boolean
|
||||
terminals: readonly TerminalEntry[]
|
||||
} = null
|
||||
|
||||
const stop = () => {
|
||||
activeHandle = null
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
const resetRightPane = () => {
|
||||
if (!rightPaneBaseline) {
|
||||
return
|
||||
}
|
||||
|
||||
setTerminalTakeover(rightPaneBaseline.takeover)
|
||||
$terminals.set(rightPaneBaseline.terminals)
|
||||
$activeTerminalId.set(rightPaneBaseline.activeTerminalId)
|
||||
$repoStatusByCwd.set(rightPaneBaseline.repoStatusByCwd)
|
||||
setCurrentCwdTransient(rightPaneBaseline.cwd)
|
||||
rightPaneBaseline = null
|
||||
}
|
||||
|
||||
// One synthetic turn's worth of mixed markdown — prose, a list, a fenced
|
||||
// code block, inline code, a link, and a short table — so a loaded transcript
|
||||
// exercises the same render cost (Streamdown blocks, code cards) a real one
|
||||
@@ -166,6 +204,69 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
rightPaneGit: (path, kind = 'modified') => {
|
||||
const file = {
|
||||
conflicted: kind === 'conflicted',
|
||||
path,
|
||||
staged: false,
|
||||
unstaged: kind === 'modified',
|
||||
untracked: kind === 'added'
|
||||
}
|
||||
|
||||
const cwd = $currentCwd.get().trim()
|
||||
$repoStatusByCwd.set({
|
||||
...$repoStatusByCwd.get(),
|
||||
[cwd]: {
|
||||
added: 0,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
branch: 'perf',
|
||||
changed: 1,
|
||||
conflicted: kind === 'conflicted' ? 1 : 0,
|
||||
defaultBranch: 'main',
|
||||
detached: false,
|
||||
files: [file],
|
||||
removed: 0,
|
||||
staged: 0,
|
||||
unstaged: kind === 'modified' ? 1 : 0,
|
||||
untracked: kind === 'added' ? 1 : 0
|
||||
}
|
||||
})
|
||||
},
|
||||
rightPaneReset: resetRightPane,
|
||||
rightPaneSelect: selectTerminal,
|
||||
rightPaneSetup: ({ cwd, terminals = 3 }) => {
|
||||
resetRightPane()
|
||||
rightPaneBaseline = {
|
||||
activeTerminalId: $activeTerminalId.get(),
|
||||
cwd: $currentCwd.get(),
|
||||
repoStatusByCwd: $repoStatusByCwd.get(),
|
||||
takeover: $terminalTakeover.get(),
|
||||
terminals: $terminals.get()
|
||||
}
|
||||
|
||||
setCurrentCwdTransient(cwd)
|
||||
const terminalIds = [createTerminal(cwd)]
|
||||
let procId = ''
|
||||
|
||||
for (let index = 1; index < Math.max(1, terminals); index += 1) {
|
||||
procId = `right-pane-perf-${Date.now()}-${index}`
|
||||
const id = ensureAgentTerminal(procId, `perf output ${index}`)
|
||||
|
||||
if (id) {
|
||||
terminalIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (procId) {
|
||||
selectTerminal(terminalIds.at(-1) ?? terminalIds[0])
|
||||
}
|
||||
|
||||
setTerminalTakeover(true)
|
||||
|
||||
return { procId, terminalIds }
|
||||
},
|
||||
rightPaneWrite: (procId, chunk) => writeAgentTerminalChunk(procId, chunk),
|
||||
loadTranscript: (turns = 200) => {
|
||||
if (!baseline) {
|
||||
baseline = $messages.get()
|
||||
@@ -190,6 +291,7 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
},
|
||||
reset: () => {
|
||||
activeHandle?.stop()
|
||||
resetRightPane()
|
||||
|
||||
if (baseline) {
|
||||
setMessages(baseline)
|
||||
|
||||
@@ -51,4 +51,32 @@ describe('useRuntimeMessageRepository', () => {
|
||||
|
||||
expect(feedToRepository(result.current).map(item => item.id)).toEqual(['user-1', 'assistant-stream-1', 'user-2'])
|
||||
})
|
||||
|
||||
it('anchors a branch group to its fork point, and a windowed cut keeps it', () => {
|
||||
// Branch groups record their fork parent the first time they are seen. A
|
||||
// window that started mid-group would anchor the survivors to whatever
|
||||
// preceded them instead — selectTranscriptWindow aligns the cut so the
|
||||
// whole group arrives together (#55191).
|
||||
const branch = (id: string): ChatMessage => ({
|
||||
...text(id, 'assistant', 'branch'),
|
||||
branchGroupId: 'group-1'
|
||||
})
|
||||
|
||||
const messages = [text('user-1', 'user', 'hi'), branch('a-1'), branch('a-2'), text('user-2', 'user', 'more')]
|
||||
|
||||
const { result } = renderHook(() => useRuntimeMessageRepository(messages))
|
||||
|
||||
const parents = new Map(result.current.messages.map(item => [item.message.id, item.parentId]))
|
||||
|
||||
expect(parents.get('a-1')).toBe('user-1')
|
||||
expect(parents.get('a-2')).toBe('user-1')
|
||||
|
||||
// The same group fed as a window that begins AT the group start keeps the
|
||||
// fork intact (parent becomes null: the group is now the transcript root).
|
||||
const { result: windowed } = renderHook(() => useRuntimeMessageRepository(messages.slice(1)))
|
||||
|
||||
const windowedParents = new Map(windowed.current.messages.map(item => [item.message.id, item.parentId]))
|
||||
|
||||
expect(windowedParents.get('a-1')).toBe(windowedParents.get('a-2'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { StatusPulse } from '@/components/ui/status-pulse'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -17,6 +18,10 @@ import { type SessionDotState, sessionDotState } from './sidebar/session-row-sta
|
||||
type DotVariant = {
|
||||
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||
className: string
|
||||
pulse?: {
|
||||
className: string
|
||||
opacity: number
|
||||
}
|
||||
role?: 'status'
|
||||
title?: (r: Translations['sidebar']['row']) => string
|
||||
}
|
||||
@@ -24,12 +29,6 @@ type DotVariant = {
|
||||
// Shared base for every active dot; idle is smaller and uses its own class.
|
||||
const DOT_BASE = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
@@ -42,14 +41,22 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)]`,
|
||||
pulse: {
|
||||
className: 'absolute inset-0 rounded-full bg-(--ui-accent) opacity-0',
|
||||
opacity: 0.7
|
||||
},
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
stalled: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70`,
|
||||
pulse: {
|
||||
className: 'absolute inset-0 rounded-full bg-(--ui-accent) opacity-0',
|
||||
opacity: 0.4
|
||||
},
|
||||
role: 'status',
|
||||
title: r => r.sessionRunning
|
||||
},
|
||||
@@ -58,7 +65,11 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// than muted-foreground so it's visible against the surface.
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
|
||||
className: `${DOT_BASE} bg-muted-foreground/80`,
|
||||
pulse: {
|
||||
className: 'absolute inset-0 rounded-full bg-muted-foreground/80 opacity-0',
|
||||
opacity: 0.6
|
||||
},
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
@@ -123,6 +134,7 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
||||
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
|
||||
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
@@ -135,11 +147,20 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
||||
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
|
||||
) : (
|
||||
<span
|
||||
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
|
||||
className={DOT_VARIANTS[dotState].className}
|
||||
role={DOT_VARIANTS[dotState].role}
|
||||
title={DOT_VARIANTS[dotState].title?.(r)}
|
||||
aria-label={variant.ariaLabel?.(r)}
|
||||
className={variant.className}
|
||||
role={variant.role}
|
||||
title={variant.title?.(r)}
|
||||
>
|
||||
{variant.pulse ? (
|
||||
<StatusPulse
|
||||
aria-hidden="true"
|
||||
className={variant.pulse.className}
|
||||
kind="ping"
|
||||
opacity={variant.pulse.opacity}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -104,6 +104,13 @@ function buildTileView(storedSessionId: string): SessionView {
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level constants so these ChatView props are referentially stable —
|
||||
// tiles have no pin/delete affordance, and transcription needs no per-tile state.
|
||||
const noop = () => undefined
|
||||
|
||||
const tileTranscribeAudio = async (audio: Blob) =>
|
||||
(await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript
|
||||
|
||||
function TileChat({
|
||||
runtimeId,
|
||||
storedSessionId,
|
||||
@@ -144,6 +151,29 @@ function TileChat({
|
||||
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
|
||||
})
|
||||
|
||||
// ChatView is memo()d — every callback prop must be referentially stable or
|
||||
// the memo never holds and each tile-level render (idle ticks, unrelated
|
||||
// store updates) re-renders the whole chat shell. The individual composer
|
||||
// functions are useCallback'd inside useComposerActions, so hoisting these
|
||||
// wrappers onto them keeps identity stable across renders.
|
||||
const { addContextRefAttachment, pasteClipboardImage, pickContextPaths, pickImages, removeAttachment } = composer
|
||||
|
||||
const onAddUrl = useCallback(
|
||||
(url: string) => addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
|
||||
[addContextRefAttachment]
|
||||
)
|
||||
|
||||
const onPasteClipboardImage = useCallback(
|
||||
(opts?: { silent?: boolean }) => pasteClipboardImage(opts),
|
||||
[pasteClipboardImage]
|
||||
)
|
||||
|
||||
const onPickFiles = useCallback(() => void pickContextPaths('file'), [pickContextPaths])
|
||||
const onPickFolders = useCallback(() => void pickContextPaths('folder'), [pickContextPaths])
|
||||
const onPickImages = useCallback(() => void pickImages(), [pickImages])
|
||||
const onRemoveAttachment = useCallback((id: string) => void removeAttachment(id), [removeAttachment])
|
||||
const onRetryResume = useCallback(() => patchSessionTile(storedSessionId, { error: undefined }), [storedSessionId])
|
||||
|
||||
// Per-tile model menu — rendered under this tile's SessionView so the pill
|
||||
// + switch target THIS runtime, not the primary (which may be mid-turn).
|
||||
const modelMenuContent = useMemo(
|
||||
@@ -165,27 +195,27 @@ function TileChat({
|
||||
<ChatView
|
||||
gateway={gateway}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAddContextRef={addContextRefAttachment}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
onAttachImageBlob={composer.attachImageBlob}
|
||||
onCancel={actions.cancelRun}
|
||||
onDeleteSelectedSession={() => undefined}
|
||||
onDeleteSelectedSession={noop}
|
||||
onDismissError={actions.dismissError}
|
||||
onEdit={actions.editMessage}
|
||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onReload={actions.reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onRestoreToMessage={actions.restoreToMessage}
|
||||
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
|
||||
onRetryResume={onRetryResume}
|
||||
onSteer={actions.steerPrompt}
|
||||
onSubmit={actions.submitText}
|
||||
onThreadMessagesChange={actions.handleThreadMessagesChange}
|
||||
onToggleSelectedPin={() => undefined}
|
||||
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
|
||||
onToggleSelectedPin={noop}
|
||||
onTranscribeAudio={tileTranscribeAudio}
|
||||
/>
|
||||
</ComposerScopeProvider>
|
||||
</SessionViewProvider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
@@ -92,17 +93,19 @@ export function SidebarCronJobsSection({
|
||||
// Rows revealed so far; starts compact, grows in steps via "load more".
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
|
||||
|
||||
const visible = usePaneVisible()
|
||||
|
||||
// One clock for the whole section (rows are pure) so the countdowns tick
|
||||
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
||||
// without re-rendering the rest of the sidebar. Only runs while expanded and visible.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (!open || !visible) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [open])
|
||||
}, [open, visible])
|
||||
|
||||
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
|
||||
// then alphabetical for stability.
|
||||
@@ -328,6 +331,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
||||
const changeEventsAvailable = useStore($changeEventsAvailable)
|
||||
const cronChangeTick = useStore($cronChangeTick)
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
const visible = usePaneVisible()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -345,6 +349,15 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
||||
}
|
||||
})
|
||||
|
||||
// Hidden pane: skip the peek entirely — no initial load, no interval.
|
||||
// `visible` is in the dep array, so becoming visible re-runs this effect
|
||||
// and starts the load + timer fresh (same shape as the section clock).
|
||||
if (!visible) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(
|
||||
@@ -361,7 +374,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
// cronChangeTick: a fired run reloads the peek immediately.
|
||||
}, [changeEventsAvailable, cronChangeTick, jobId])
|
||||
}, [changeEventsAvailable, cronChangeTick, jobId, visible])
|
||||
|
||||
return (
|
||||
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
setShowAllProfiles,
|
||||
sortByProfileOrder
|
||||
} from '@/store/profile'
|
||||
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
||||
@@ -264,6 +265,7 @@ export function ProfileRail() {
|
||||
profiles={named}
|
||||
/>
|
||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
<ImportProfileButton label={p.importProfile} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -302,6 +304,7 @@ export function ProfileRail() {
|
||||
)}
|
||||
|
||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
<ImportProfileButton label={p.importProfile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -435,6 +438,24 @@ function AddProfileButton({ label, onClick }: { label: string; onClick: () => vo
|
||||
)
|
||||
}
|
||||
|
||||
// Import-archive door beside the "+": adopt a shared profile bundle (theme,
|
||||
// skills, layout) as a new profile. Same chrome as AddProfileButton; the whole
|
||||
// flow (picker → import → apply overlay → switch) lives in the store.
|
||||
function ImportProfileButton({ label }: { label: string }) {
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||
onClick={() => void runImportProfileFlow()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="cloud-download" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
// The condensed rail: every named profile in one compact select. The trigger
|
||||
// shows the active profile (tinted initial + name); on default/all scope it
|
||||
// falls back to the placeholder since the left toggle pill carries that state.
|
||||
@@ -692,6 +713,10 @@ function ProfileSquare({
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.editSoul}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => void runExportProfileFlow(label)}>
|
||||
<Codicon name="package" size="0.875rem" />
|
||||
<span>{p.exportProfile}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={onDelete}
|
||||
|
||||
@@ -22,7 +22,14 @@ vi.mock('@/i18n', () => ({
|
||||
t: {
|
||||
common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' },
|
||||
sidebar: {
|
||||
projects: { menuAppearance: 'Appearance', noColor: 'No color' },
|
||||
projects: {
|
||||
menuAppearance: 'Appearance',
|
||||
moveFailed: 'Could not move session',
|
||||
moveNoProjects: 'No other projects',
|
||||
movedTo: (name: string) => `Moved to ${name}`,
|
||||
moveToProject: 'Move to project',
|
||||
noColor: 'No color'
|
||||
},
|
||||
row: {
|
||||
archive: 'Archive',
|
||||
branchFrom: 'Branch from here',
|
||||
@@ -50,6 +57,12 @@ vi.mock('@/lib/profile-color', () => ({ PROFILE_SWATCHES: [] }))
|
||||
vi.mock('@/lib/session-export', () => ({ exportSession: vi.fn() }))
|
||||
vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) }))
|
||||
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
|
||||
vi.mock('@/store/projects', () => ({
|
||||
$projectTree: atom<unknown[]>([]),
|
||||
moveSessionToProject: vi.fn(),
|
||||
projectIdForCwd: vi.fn(() => null),
|
||||
projectRootCwd: vi.fn(() => '')
|
||||
}))
|
||||
vi.mock('@/store/session', () => ({
|
||||
$activeSessionId: atom<null | string>(null),
|
||||
$selectedStoredSessionId: atom<null | string>(null),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
closeAllTreeTabs,
|
||||
closeOtherTreeTabs,
|
||||
closeTreeTabsToRight,
|
||||
reloadTreePane,
|
||||
treeTabCloseTargets
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import { PROFILE_SWATCHES } from '@/lib/profile-color'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { activeGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $projectTree, moveSessionToProject, projectIdForCwd, projectRootCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$selectedStoredSessionId,
|
||||
@@ -132,6 +134,44 @@ function SessionColorSwatches({ sessionId }: { sessionId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The project list inside the session menu's "Move to project" submenu. Its own
|
||||
// component so only an OPEN submenu subscribes to the stores (same reasoning as
|
||||
// SessionColorSwatches). Re-homes the session's workspace at the target
|
||||
// project's root — the fix for a chat created in the wrong folder. The current
|
||||
// owner and folderless projects (the Home bucket) are excluded: there is
|
||||
// nothing to move into.
|
||||
function MoveToProjectItems({ kit, sessionId, profile }: { kit: MenuKit; sessionId: string; profile?: string }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const tree = useStore($projectTree)
|
||||
const session = useStore($sessions).find(s => sessionMatchesStoredId(s, sessionId))
|
||||
const cwd = session?.cwd?.trim() || ''
|
||||
const currentProjectId = cwd ? projectIdForCwd(cwd) : null
|
||||
const targets = tree.filter(node => node.id !== currentProjectId && !node.isNoProject && projectRootCwd(node))
|
||||
|
||||
if (targets.length === 0) {
|
||||
return <kit.Item disabled>{p.moveNoProjects}</kit.Item>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{targets.map(node => (
|
||||
<kit.Item
|
||||
key={node.id}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
moveSessionToProject(sessionId, node.id, profile)
|
||||
.then(() => notify({ durationMs: 2_000, kind: 'success', message: p.movedTo(node.label) }))
|
||||
.catch(err => notifyError(err, p.moveFailed))
|
||||
}}
|
||||
>
|
||||
{node.label}
|
||||
</kit.Item>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useSessionActions({
|
||||
sessionId,
|
||||
title,
|
||||
@@ -239,12 +279,24 @@ function useSessionActions({
|
||||
})
|
||||
]
|
||||
|
||||
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
|
||||
// TAB — verbs that act on the strip (tabs only; a row isn't a tab).
|
||||
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
|
||||
|
||||
const tabCloseItems: ActionItemSpec[] =
|
||||
const tabItems: ActionItemSpec[] =
|
||||
surface === 'tab'
|
||||
? [
|
||||
...(tabPaneId
|
||||
? [
|
||||
spec({
|
||||
icon: 'refresh',
|
||||
label: t.zones.reload,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
reloadTreePane(tabPaneId)
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(onClose
|
||||
? [
|
||||
spec({
|
||||
@@ -342,10 +394,19 @@ function useSessionActions({
|
||||
/>
|
||||
<kit.Separator />
|
||||
{workItems.map(item => renderActionItem(kit, item))}
|
||||
{tabCloseItems.length > 0 && (
|
||||
<kit.Sub>
|
||||
<kit.SubTrigger disabled={!sessionId}>
|
||||
<Codicon name="folder" size="0.875rem" />
|
||||
<span>{t.sidebar.projects.moveToProject}</span>
|
||||
</kit.SubTrigger>
|
||||
<kit.SubContent>
|
||||
<MoveToProjectItems kit={kit} profile={profile} sessionId={sessionId} />
|
||||
</kit.SubContent>
|
||||
</kit.Sub>
|
||||
{tabItems.length > 0 && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{tabCloseItems.map(item => renderActionItem(kit, item))}
|
||||
{tabItems.map(item => renderActionItem(kit, item))}
|
||||
</>
|
||||
)}
|
||||
<kit.Separator />
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type * as React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
|
||||
import type { VirtualSessionListProps } from './virtual-session-list'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
sidebar: {
|
||||
dateDivider: {
|
||||
earlierThisMonth: 'Earlier this month',
|
||||
lastMonth: 'Last month',
|
||||
lastWeek: 'Last week',
|
||||
older: 'Older',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockVirtualListPropsHistory: VirtualSessionListProps[] = []
|
||||
|
||||
vi.mock('./virtual-session-list', () => ({
|
||||
VirtualSessionList: (props: VirtualSessionListProps) => {
|
||||
mockVirtualListPropsHistory.push(props)
|
||||
|
||||
return <div data-testid="virtual-session-list">Virtual List ({props.rows.length} rows)</div>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./session-row', () => ({
|
||||
SidebarSessionRow: ({ session }: { session: SessionInfo }) => (
|
||||
<div data-testid={`session-row-${session.id}`}>{session.id}</div>
|
||||
)
|
||||
}))
|
||||
|
||||
function makeSession(id: string, startedAt = 1000): SessionInfo {
|
||||
return {
|
||||
handoff_platform: null,
|
||||
handoff_state: null,
|
||||
id,
|
||||
last_active: startedAt,
|
||||
profile: 'default',
|
||||
started_at: startedAt
|
||||
} as unknown as SessionInfo
|
||||
}
|
||||
|
||||
function generateSessions(count: number): SessionInfo[] {
|
||||
return Array.from({ length: count }, (_, i) => makeSession(`session-${i + 1}`, 10000 - i * 100))
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
describe('SidebarSessionsSection memoization & virtualizer stability', () => {
|
||||
it('memoizes flatRows and passes the exact same rows array reference across parent re-renders', () => {
|
||||
mockVirtualListPropsHistory.length = 0
|
||||
|
||||
const sessions = generateSessions(VIRTUALIZE_THRESHOLD + 5)
|
||||
|
||||
const { rerender } = render(
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={null}
|
||||
emptyState={<div>Empty</div>}
|
||||
label="Sessions"
|
||||
onArchiveSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onResumeSession={noop}
|
||||
onToggle={noop}
|
||||
onTogglePin={noop}
|
||||
open={true}
|
||||
pinned={false}
|
||||
sessions={sessions}
|
||||
workingSessionIdSet={new Set()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mockVirtualListPropsHistory.length).toBe(1)
|
||||
const initialRowsRef = mockVirtualListPropsHistory[0].rows
|
||||
expect(initialRowsRef.length).toBeGreaterThan(VIRTUALIZE_THRESHOLD)
|
||||
|
||||
// Re-render parent with the exact same sessions array and props
|
||||
rerender(
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={null}
|
||||
emptyState={<div>Empty</div>}
|
||||
label="Sessions"
|
||||
onArchiveSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onResumeSession={noop}
|
||||
onToggle={noop}
|
||||
onTogglePin={noop}
|
||||
open={true}
|
||||
pinned={false}
|
||||
sessions={sessions}
|
||||
workingSessionIdSet={new Set()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mockVirtualListPropsHistory.length).toBe(2)
|
||||
const nextRowsRef = mockVirtualListPropsHistory[1].rows
|
||||
|
||||
// Confirm that the flatRows array reference remains strictly identical across renders (useMemo proof)
|
||||
expect(nextRowsRef).toBe(initialRowsRef)
|
||||
})
|
||||
|
||||
it('re-computes flatRows reference when dateGrouped or sessions change', () => {
|
||||
mockVirtualListPropsHistory.length = 0
|
||||
|
||||
const initialSessions = generateSessions(VIRTUALIZE_THRESHOLD + 2)
|
||||
|
||||
const { rerender } = render(
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={null}
|
||||
dateGrouped={false}
|
||||
emptyState={<div>Empty</div>}
|
||||
label="Sessions"
|
||||
onArchiveSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onResumeSession={noop}
|
||||
onToggle={noop}
|
||||
onTogglePin={noop}
|
||||
open={true}
|
||||
pinned={false}
|
||||
sessions={initialSessions}
|
||||
workingSessionIdSet={new Set()}
|
||||
/>
|
||||
)
|
||||
|
||||
const firstRowsRef = mockVirtualListPropsHistory[0].rows
|
||||
|
||||
// Change dateGrouped to true
|
||||
rerender(
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={null}
|
||||
dateGrouped={true}
|
||||
emptyState={<div>Empty</div>}
|
||||
label="Sessions"
|
||||
onArchiveSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onResumeSession={noop}
|
||||
onToggle={noop}
|
||||
onTogglePin={noop}
|
||||
open={true}
|
||||
pinned={false}
|
||||
sessions={initialSessions}
|
||||
workingSessionIdSet={new Set()}
|
||||
/>
|
||||
)
|
||||
|
||||
const secondRowsRef = mockVirtualListPropsHistory[1].rows
|
||||
expect(secondRowsRef).not.toBe(firstRowsRef)
|
||||
|
||||
// Change sessions array identity
|
||||
const updatedSessions = generateSessions(VIRTUALIZE_THRESHOLD + 4)
|
||||
rerender(
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={null}
|
||||
dateGrouped={true}
|
||||
emptyState={<div>Empty</div>}
|
||||
label="Sessions"
|
||||
onArchiveSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onResumeSession={noop}
|
||||
onToggle={noop}
|
||||
onTogglePin={noop}
|
||||
open={true}
|
||||
pinned={false}
|
||||
sessions={updatedSessions}
|
||||
workingSessionIdSet={new Set()}
|
||||
/>
|
||||
)
|
||||
|
||||
const thirdRowsRef = mockVirtualListPropsHistory[2].rows
|
||||
expect(thirdRowsRef).not.toBe(secondRowsRef)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { useSensors } from '@dnd-kit/core'
|
||||
import type * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { SidebarPanelLabel } from '@/app/shell/sidebar-label'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
@@ -225,7 +225,8 @@ export function SidebarSessionsSection({
|
||||
[sessions, preserveInputOrder]
|
||||
)
|
||||
|
||||
const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => {
|
||||
const renderRow = useCallback(
|
||||
(session: SessionInfo, draggable: boolean, branchStem?: string) => {
|
||||
const rowProps = {
|
||||
branchStem,
|
||||
isPinned: pinned,
|
||||
@@ -246,31 +247,57 @@ export function SidebarSessionsSection({
|
||||
) : (
|
||||
<SidebarSessionRow key={session.id} {...rowProps} />
|
||||
)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
onArchiveSession,
|
||||
onBranchSession,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
showProfileTags,
|
||||
workingSessionIdSet
|
||||
]
|
||||
)
|
||||
|
||||
// A single flat/virtual/lane list row — either a date divider or a session.
|
||||
const renderListRow = (row: SidebarListRow, draggable: boolean) =>
|
||||
const renderListRow = useCallback(
|
||||
(row: SidebarListRow, draggable: boolean) =>
|
||||
row.kind === 'divider' ? (
|
||||
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
|
||||
) : (
|
||||
renderRow(row.entry.session, draggable, row.entry.branchStem)
|
||||
),
|
||||
[dividerLabels, renderRow]
|
||||
)
|
||||
|
||||
// Sessions inside repos/worktrees are date-ordered and static.
|
||||
const renderRows = (items: SessionInfo[]) =>
|
||||
flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem))
|
||||
const renderRows = useCallback(
|
||||
(items: SessionInfo[]) =>
|
||||
flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)),
|
||||
[renderRow]
|
||||
)
|
||||
|
||||
// Same as `renderRows`, but with date dividers folded in — used for
|
||||
// entered-project lanes so a lane spanning multiple days reads
|
||||
// chronologically, matching the flat recents list.
|
||||
const renderRowsDated = (items: SessionInfo[]) => {
|
||||
const renderRowsDated = useCallback(
|
||||
(items: SessionInfo[]) => {
|
||||
const entries = flattenSessionsWithBranches(items)
|
||||
|
||||
return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row => renderListRow(row, false))
|
||||
}
|
||||
return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row =>
|
||||
renderListRow(row, false)
|
||||
)
|
||||
},
|
||||
[dateGrouped, renderListRow]
|
||||
)
|
||||
|
||||
// Flat recents as list rows: grouped by recency when enabled, plain otherwise.
|
||||
const flatRows: SidebarListRow[] = dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries)
|
||||
const flatRows: SidebarListRow[] = useMemo(
|
||||
() => (dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries)),
|
||||
[dateGrouped, displayEntries]
|
||||
)
|
||||
|
||||
const flatVirtualized =
|
||||
!showEmptyState &&
|
||||
|
||||
@@ -27,7 +27,7 @@ interface SessionRowCommonProps {
|
||||
showProfile?: boolean
|
||||
}
|
||||
|
||||
interface VirtualSessionListProps {
|
||||
export interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
rows: SidebarListRow[]
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { RENDER_WEIGHT_CHARS } from '@/lib/render-weight'
|
||||
|
||||
import {
|
||||
alignToBranchGroup,
|
||||
selectTranscriptWindow,
|
||||
TRANSCRIPT_WINDOW_BUDGET,
|
||||
TRANSCRIPT_WINDOW_MIN_MESSAGES
|
||||
} from './transcript-window'
|
||||
|
||||
const message = (id: string, chars: number, branchGroupId?: string): ChatMessage => ({
|
||||
id,
|
||||
parts: [{ type: 'text', text: 'x'.repeat(chars) }],
|
||||
role: id.startsWith('u') ? 'user' : 'assistant',
|
||||
...(branchGroupId ? { branchGroupId } : {})
|
||||
})
|
||||
|
||||
/** Messages of `chars` each, newest last. */
|
||||
const transcript = (count: number, chars: number): ChatMessage[] =>
|
||||
Array.from({ length: count }, (_, i) => message(`m-${i}`, chars))
|
||||
|
||||
describe('selectTranscriptWindow', () => {
|
||||
it('does not window a transcript that fits the budget', () => {
|
||||
const messages = transcript(50, 100)
|
||||
|
||||
const window = selectTranscriptWindow(messages)
|
||||
|
||||
expect(window.windowed).toBe(false)
|
||||
// Reference identity preserved — a fresh array would re-render the runtime.
|
||||
expect(window.messages).toBe(messages)
|
||||
})
|
||||
|
||||
it('windows a HEAVY-but-SHORT transcript that a message-count cap would miss', () => {
|
||||
// 40 messages, each a big tool result. Well under any sane count cap, but
|
||||
// this is the shape that exhausts the renderer heap (#55191).
|
||||
const messages = transcript(40, RENDER_WEIGHT_CHARS * 400)
|
||||
|
||||
const window = selectTranscriptWindow(messages)
|
||||
|
||||
expect(window.windowed).toBe(true)
|
||||
expect(window.messages.length).toBeLessThan(messages.length)
|
||||
expect(window.messages.at(-1)).toBe(messages.at(-1))
|
||||
})
|
||||
|
||||
it('keeps far MORE messages when they are light than when they are heavy', () => {
|
||||
// The contract is weight, not count: a message-count cap would treat these
|
||||
// two identically. 500 tiny messages are cheaper than 500 tool results, so
|
||||
// many more of them survive the same budget.
|
||||
const light = selectTranscriptWindow(transcript(500, 20))
|
||||
const heavy = selectTranscriptWindow(transcript(500, RENDER_WEIGHT_CHARS * 40))
|
||||
|
||||
expect(light.messages.length).toBeGreaterThan(heavy.messages.length * 10)
|
||||
})
|
||||
|
||||
it('leaves a long transcript whole when the whole thing is cheap', () => {
|
||||
const messages = transcript(600, 20)
|
||||
|
||||
const window = selectTranscriptWindow(messages)
|
||||
|
||||
expect(window.windowed).toBe(false)
|
||||
expect(window.messages).toBe(messages)
|
||||
})
|
||||
|
||||
it('keeps a floor of messages when single turns are enormous', () => {
|
||||
const messages = transcript(80, RENDER_WEIGHT_CHARS * TRANSCRIPT_WINDOW_BUDGET)
|
||||
|
||||
const window = selectTranscriptWindow(messages)
|
||||
|
||||
expect(window.messages.length).toBeGreaterThanOrEqual(TRANSCRIPT_WINDOW_MIN_MESSAGES)
|
||||
})
|
||||
|
||||
it('grows by one budget page per expand and eventually covers everything', () => {
|
||||
const messages = transcript(400, RENDER_WEIGHT_CHARS * 40)
|
||||
|
||||
const first = selectTranscriptWindow(messages, 1)
|
||||
const second = selectTranscriptWindow(messages, 2)
|
||||
|
||||
expect(first.windowed).toBe(true)
|
||||
expect(second.messages.length).toBeGreaterThan(first.messages.length)
|
||||
|
||||
let pages = 1
|
||||
let window = selectTranscriptWindow(messages, pages)
|
||||
|
||||
while (window.windowed && pages < 100) {
|
||||
window = selectTranscriptWindow(messages, ++pages)
|
||||
}
|
||||
|
||||
// Paging terminates at the full transcript — never a dead end.
|
||||
expect(window.windowed).toBe(false)
|
||||
expect(window.messages).toHaveLength(messages.length)
|
||||
})
|
||||
|
||||
it('never cuts inside a branch group, so branches keep their fork point', () => {
|
||||
const heavy = RENDER_WEIGHT_CHARS * 200
|
||||
|
||||
// A branch group sits right where a weight-only cut would land.
|
||||
const messages: ChatMessage[] = [
|
||||
...transcript(20, heavy),
|
||||
message('a-branch-1', heavy, 'group-1'),
|
||||
message('a-branch-2', heavy, 'group-1'),
|
||||
message('a-branch-3', heavy, 'group-1'),
|
||||
...transcript(20, heavy).map(m => ({ ...m, id: `tail-${m.id}` }))
|
||||
]
|
||||
|
||||
for (let pages = 1; pages <= 6; pages++) {
|
||||
const kept = selectTranscriptWindow(messages, pages).messages
|
||||
const groupMembers = kept.filter(m => m.branchGroupId === 'group-1')
|
||||
|
||||
// Either the whole group survives or none of it does — never a partial
|
||||
// group, which would re-parent the surviving branches.
|
||||
expect([0, 3]).toContain(groupMembers.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles an empty transcript', () => {
|
||||
const messages: ChatMessage[] = []
|
||||
|
||||
expect(selectTranscriptWindow(messages)).toEqual({ messages, windowed: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('alignToBranchGroup', () => {
|
||||
const messages = [
|
||||
message('u-1', 10),
|
||||
message('a-1', 10, 'g'),
|
||||
message('a-2', 10, 'g'),
|
||||
message('u-2', 10)
|
||||
]
|
||||
|
||||
it('widens a cut that lands mid-group back to the group start', () => {
|
||||
expect(alignToBranchGroup(messages, 2)).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves a cut on a non-branch message alone', () => {
|
||||
expect(alignToBranchGroup(messages, 3)).toBe(3)
|
||||
})
|
||||
|
||||
it('clamps out-of-range indices', () => {
|
||||
expect(alignToBranchGroup(messages, -5)).toBe(0)
|
||||
expect(alignToBranchGroup(messages, 99)).toBe(messages.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { messageRenderWeight } from '@/lib/render-weight'
|
||||
|
||||
/**
|
||||
* Bound what reaches assistant-ui at all.
|
||||
*
|
||||
* Rendering the full transcript of an oversized session rebuilds an unbounded
|
||||
* runtime repository on every store update and exhausts the renderer's V8 heap
|
||||
* (#55191). The DOM budget in `thread/list.tsx` already bounds what PAINTS, but
|
||||
* every message still gets normalized into the repository first — so a session
|
||||
* only has to be heavy, not visible, to crash the window.
|
||||
*
|
||||
* The window spends the same currency as the DOM budget: render weight, not
|
||||
* message count. A count cap gets this wrong in both directions — measured on a
|
||||
* real 1,175-session store, a 400-message cap would disable itself on 37
|
||||
* sessions that are heavy but short (one was 133 messages / 1.05MB) while
|
||||
* engaging on 92 long-but-light sessions that were never at risk.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One window page, in render-weight units.
|
||||
*
|
||||
* Four DOM pages (the `RENDER_BUDGET` of 300 in `thread/list.tsx`). "Show
|
||||
* earlier" spends the DOM budget first, so the user pages through the
|
||||
* already-materialized window three times before this asks the store for more
|
||||
* — and the reported crash shape (~231K tokens ≈ 2,260 units) is windowed
|
||||
* rather than handed to the repository whole.
|
||||
*/
|
||||
export const TRANSCRIPT_WINDOW_BUDGET = 1200
|
||||
|
||||
/**
|
||||
* Floor on messages kept regardless of weight. A transcript of enormous turns
|
||||
* must still render the turn the user is having; without this a single
|
||||
* multi-megabyte tool result could window everything after it away.
|
||||
*/
|
||||
export const TRANSCRIPT_WINDOW_MIN_MESSAGES = 30
|
||||
|
||||
export interface TranscriptWindow {
|
||||
/** The tail assistant-ui is allowed to materialize. */
|
||||
messages: ChatMessage[]
|
||||
/** Store holds older messages than this window. */
|
||||
windowed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Widen a cut backwards so it never lands inside an assistant branch group.
|
||||
*
|
||||
* `useRuntimeMessageRepository` records a group's fork point the first time it
|
||||
* sees the group (`branchParentByGroup`). A cut through the middle of a group
|
||||
* therefore anchors the surviving branches to whatever message happens to
|
||||
* precede them in the window — silently re-parenting a branch. Include the
|
||||
* whole group or none of it.
|
||||
*/
|
||||
export function alignToBranchGroup(messages: readonly ChatMessage[], start: number): number {
|
||||
if (start <= 0 || start >= messages.length) {
|
||||
return Math.max(0, Math.min(start, messages.length))
|
||||
}
|
||||
|
||||
const group = messages[start].branchGroupId
|
||||
|
||||
if (!group) {
|
||||
return start
|
||||
}
|
||||
|
||||
let aligned = start
|
||||
|
||||
while (aligned > 0 && messages[aligned - 1].branchGroupId === group) {
|
||||
aligned--
|
||||
}
|
||||
|
||||
return aligned
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the tail of the transcript that fits one window, grown by `pages`.
|
||||
*
|
||||
* Walks newest-first accumulating weight until the budget is met, keeps at
|
||||
* least MIN messages, then aligns the cut off a branch-group boundary.
|
||||
*/
|
||||
export function selectTranscriptWindow(
|
||||
messages: readonly ChatMessage[],
|
||||
pages = 1
|
||||
): TranscriptWindow {
|
||||
const budget = TRANSCRIPT_WINDOW_BUDGET * Math.max(1, Math.floor(pages))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { messages: messages as ChatMessage[], windowed: false }
|
||||
}
|
||||
|
||||
let start = messages.length
|
||||
let weight = 0
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
weight += messageRenderWeight(messages[i].parts)
|
||||
start = i
|
||||
|
||||
if (weight >= budget && messages.length - i >= TRANSCRIPT_WINDOW_MIN_MESSAGES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
start = alignToBranchGroup(messages, start)
|
||||
|
||||
if (start <= 0) {
|
||||
// Preserve reference identity when the whole transcript fits: handing React
|
||||
// a fresh array of the same messages re-renders the runtime for nothing.
|
||||
return { messages: messages as ChatMessage[], windowed: false }
|
||||
}
|
||||
|
||||
return { messages: messages.slice(start), windowed: true }
|
||||
}
|
||||
@@ -39,7 +39,7 @@ import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||
import { FileText, LayoutDashboard, PanelBottom, Terminal, Zap } from '@/lib/icons'
|
||||
import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { setYoloEnabled } from '@/lib/yolo-session'
|
||||
import { pruneComposerPopoutZones } from '@/store/composer-popout'
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
SIDEBAR_MAX_WIDTH
|
||||
} from '@/store/layout'
|
||||
import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview'
|
||||
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
||||
import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
|
||||
import { watchSessionPins } from '@/store/session-pin-sync'
|
||||
@@ -314,6 +315,31 @@ registry.registerMany([
|
||||
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
|
||||
run: () => window.dispatchEvent(new CustomEvent('hermes:open-keybinds'))
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
// Profile sharing: bundle the active profile (config, skills, theme, layout)
|
||||
// into a portable archive, or adopt someone else's. Both open native dialogs,
|
||||
// so the palette closing on select is correct.
|
||||
{
|
||||
id: 'profile.export',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'profile.export',
|
||||
label: 'Export profile…',
|
||||
icon: Upload,
|
||||
keywords: ['profile', 'export', 'share', 'bundle', 'theme', 'settings', 'backup'],
|
||||
run: () => void runExportProfileFlow()
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
{
|
||||
id: 'profile.import',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'profile.import',
|
||||
label: 'Import profile…',
|
||||
icon: Download,
|
||||
keywords: ['profile', 'import', 'share', 'bundle', 'archive', 'restore'],
|
||||
run: () => void runImportProfileFlow()
|
||||
} satisfies PaletteContribution
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('useSessionTileDelegate resumeTile', () => {
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-x',
|
||||
cols: 96,
|
||||
profile: 'ai-engineer'
|
||||
profile: 'ai-engineer',
|
||||
omit_messages: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,7 +95,8 @@ describe('useSessionTileDelegate resumeTile', () => {
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-y',
|
||||
cols: 96,
|
||||
profile: 'default'
|
||||
profile: 'default',
|
||||
omit_messages: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,6 +79,7 @@ export function useSessionTileDelegate({
|
||||
requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
omit_messages: true,
|
||||
...(profile ? { profile } : {})
|
||||
})
|
||||
])
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
@@ -42,12 +43,16 @@ import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
|
||||
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
|
||||
// Settings / sign in / switch to local — the "lost connection breaks the app"
|
||||
// dead end. The next successful reconnect clears it.
|
||||
const RECONNECT_ESCALATE_AFTER = 6
|
||||
// After the reconnect loop has been failing for this long, raise a recoverable
|
||||
// boot error. Otherwise a dropped remote gateway loops the backoff forever
|
||||
// behind the fullscreen CONNECTING overlay with no way to reach Settings /
|
||||
// sign in / switch to local — the "lost connection breaks the app" dead end.
|
||||
// The next successful reconnect clears it. Time-based (not attempt-count)
|
||||
// because the full-jitter backoff makes attempt counts a meaningless clock:
|
||||
// six jittered attempts can elapse in ~9s, while the old deterministic
|
||||
// 1→15s ladder took ~45s to reach six failures — this threshold keeps that
|
||||
// original ~45s calibration.
|
||||
const RECONNECT_ESCALATE_AFTER_MS = 45_000
|
||||
|
||||
interface GatewayBootOptions {
|
||||
beforeConnectionSwitch: () => void
|
||||
@@ -114,13 +119,18 @@ export function useGatewayBoot({
|
||||
let reconnecting = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
// Wall-clock start of the current disconnect episode (first failed
|
||||
// reconnect attempt); null while healthy. Drives the time-based
|
||||
// escalation below. Reset on a clean open or a manual/wake reconnect.
|
||||
let reconnectFailingSince: number | null = null
|
||||
// Surface "sign in again" once per disconnect episode, not on every backoff
|
||||
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
||||
// identical error toasts (and their haptics). Reset on the next clean open.
|
||||
let reauthNotified = false
|
||||
// Raised once the reconnect loop crosses RECONNECT_ESCALATE_AFTER so the
|
||||
// recovery overlay replaces the dead-end CONNECTING screen. Reset on a clean
|
||||
// open or a manual/wake-driven reconnect.
|
||||
// Raised once the reconnect loop has been failing for
|
||||
// RECONNECT_ESCALATE_AFTER_MS so the recovery overlay replaces the
|
||||
// dead-end CONNECTING screen. Reset on a clean open or a manual/
|
||||
// wake-driven reconnect.
|
||||
let escalated = false
|
||||
|
||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||
@@ -173,6 +183,7 @@ export function useGatewayBoot({
|
||||
}
|
||||
|
||||
reconnectAttempt = 0
|
||||
reconnectFailingSince = null
|
||||
// A respawned backend re-mints (recycles) runtime ids, so any tile's
|
||||
// bound runtime id is now stale — drop them so each tile re-resumes.
|
||||
resetTileRuntimeBindings()
|
||||
@@ -192,7 +203,11 @@ export function useGatewayBoot({
|
||||
reconnecting = false
|
||||
|
||||
if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) {
|
||||
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
|
||||
if (reconnectFailingSince === null) {
|
||||
reconnectFailingSince = Date.now()
|
||||
}
|
||||
|
||||
if (Date.now() - reconnectFailingSince >= RECONNECT_ESCALATE_AFTER_MS && !escalated) {
|
||||
escalated = true
|
||||
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
|
||||
}
|
||||
@@ -207,8 +222,11 @@ export function useGatewayBoot({
|
||||
return
|
||||
}
|
||||
|
||||
// 1s, 2s, 4s … capped at 15s.
|
||||
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4))
|
||||
// Full-jitter exponential backoff (300ms base, 15s cap) so a gateway
|
||||
// restart doesn't get redialed by every desktop client in lockstep —
|
||||
// an immediate-retry reconnect storm can exhaust the gateway's file
|
||||
// descriptors while it's still coming back up.
|
||||
const delay = reconnectBackoffDelayMs(reconnectAttempt)
|
||||
reconnectAttempt += 1
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
@@ -223,6 +241,7 @@ export function useGatewayBoot({
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
reconnectFailingSince = null
|
||||
escalated = false
|
||||
reconnectSecondaryGateways()
|
||||
|
||||
@@ -269,6 +288,7 @@ export function useGatewayBoot({
|
||||
$gatewaySwitching.set(true)
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
reconnectFailingSince = null
|
||||
escalated = false
|
||||
reauthNotified = false
|
||||
callbacksRef.current.beforeConnectionSwitch()
|
||||
@@ -371,6 +391,7 @@ export function useGatewayBoot({
|
||||
|
||||
if (st === 'open') {
|
||||
reconnectAttempt = 0
|
||||
reconnectFailingSince = null
|
||||
reauthNotified = false
|
||||
escalated = false
|
||||
clearReconnectTimer()
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from '@/store/profile'
|
||||
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session'
|
||||
import { reopenLastClosedTile } from '@/store/session-states'
|
||||
import {
|
||||
$switcherOpen,
|
||||
@@ -62,11 +62,13 @@ import { useTheme } from '@/themes/context'
|
||||
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { openSession } from '../open-session'
|
||||
import {
|
||||
$workspaceIsPage,
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
CRON_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
navigateToWorkspacePage,
|
||||
NEW_CHAT_ROUTE,
|
||||
PROFILES_ROUTE,
|
||||
sessionRoute,
|
||||
SETTINGS_ROUTE,
|
||||
@@ -99,11 +101,29 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
|
||||
const profileSwitchHandlers: HandlerMap = {}
|
||||
|
||||
// A tab key that lands on the WORKSPACE tab while a full page (skills /
|
||||
// messaging / artifacts / a plugin route) covers it must also route back to
|
||||
// the chat: the workspace pane is already the zone's active tab behind the
|
||||
// page, so fronting it alone changes nothing on screen and the key reads
|
||||
// dead. Mirrors `openSession`'s full-page rule — only a route change puts
|
||||
// the chat back.
|
||||
const leavePageForWorkspaceChat = (paneId: null | string) => {
|
||||
if (paneId === 'workspace' && $workspaceIsPage.get()) {
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
|
||||
navigate(selected ? sessionRoute(selected) : NEW_CHAT_ROUTE)
|
||||
}
|
||||
}
|
||||
|
||||
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
|
||||
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
|
||||
// single-pane (or unfocused) layout falls through to the profile switch.
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
||||
if (!activateTreeTabSlot(slot)) {
|
||||
const pane = activateTreeTabSlot(slot)
|
||||
|
||||
if (pane) {
|
||||
leavePageForWorkspaceChat(pane)
|
||||
} else {
|
||||
switchProfileToSlot(slot)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +152,19 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
goToSession(openOrAdvanceSwitcher(direction))
|
||||
}
|
||||
|
||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
||||
// falls through to the recent-session switcher. Landing on the workspace
|
||||
// under a full page routes back to the chat (same as ⌘1).
|
||||
const cycleTab = (direction: 1 | -1) => {
|
||||
const pane = cycleTreeTabInFocusedZone(direction)
|
||||
|
||||
if (pane) {
|
||||
leavePageForWorkspaceChat(pane)
|
||||
} else {
|
||||
stepSession(direction)
|
||||
}
|
||||
}
|
||||
|
||||
const showFiles = () => {
|
||||
setFileBrowserOpen(true)
|
||||
setTerminalTakeover(false)
|
||||
@@ -170,10 +203,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
},
|
||||
'session.newTab': () => deps.openNewSessionTab(),
|
||||
'session.newWindow': () => void openNewWindow(),
|
||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
||||
// falls through to the recent-session switcher.
|
||||
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
|
||||
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
|
||||
'session.next': () => cycleTab(1),
|
||||
'session.prev': () => cycleTab(-1),
|
||||
...sessionSlotHandlers,
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { projectTreeViewportSize } from './tree'
|
||||
|
||||
describe('projectTreeViewportSize', () => {
|
||||
it('uses ResizeObserver contentRect without forcing another layout read', () => {
|
||||
const element = document.createElement('div')
|
||||
const getBoundingClientRect = vi.spyOn(element, 'getBoundingClientRect')
|
||||
const contentRect = { height: 480, width: 320 } as DOMRectReadOnly
|
||||
|
||||
expect(
|
||||
projectTreeViewportSize([{ contentRect, target: element } as unknown as ResizeObserverEntry], element)
|
||||
).toEqual({
|
||||
height: 480,
|
||||
width: 320
|
||||
})
|
||||
expect(getBoundingClientRect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a rect when ResizeObserver is unavailable', () => {
|
||||
const element = document.createElement('div')
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
|
||||
height: 240,
|
||||
width: 160
|
||||
} as DOMRect)
|
||||
|
||||
expect(projectTreeViewportSize([], element)).toEqual({ height: 240, width: 160 })
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, type RowRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { TreeSkeleton } from '@/components/chat/skeletons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $repoChangeByPath, type RepoChangeKind } from '@/store/coding-status'
|
||||
import { type RepoChangeKind, repoChangeKindForPath } from '@/store/coding-status'
|
||||
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
|
||||
import { $revealInTreeRequest } from '@/store/layout'
|
||||
|
||||
@@ -55,19 +57,20 @@ export function ProjectTree({
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: ProjectTreeProps) {
|
||||
markRightPanePerf('project-tree-render')
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
||||
const changeByPath = useStore($repoChangeByPath)
|
||||
|
||||
const syncTreeSize = useCallback(() => {
|
||||
const syncTreeSize = useCallback((entries: readonly ResizeObserverEntry[]) => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const { height, width } = el.getBoundingClientRect()
|
||||
const { height, width } = projectTreeViewportSize(entries, el)
|
||||
|
||||
setSize(prev => {
|
||||
if (prev.height === height && prev.width === width) {
|
||||
@@ -175,7 +178,12 @@ export function ProjectTree({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-hidden" onKeyDownCapture={handleRenameShortcut} ref={containerRef}>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
data-project-tree=""
|
||||
onKeyDownCapture={handleRenameShortcut}
|
||||
ref={containerRef}
|
||||
>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
||||
@@ -200,7 +208,6 @@ export function ProjectTree({
|
||||
{props => (
|
||||
<ProjectTreeRow
|
||||
{...props}
|
||||
changeKind={props.node.data ? changeByPath.get(props.node.data.id) : undefined}
|
||||
onAttachFile={onActivateFile}
|
||||
onAttachFolder={onActivateFolder}
|
||||
onPreviewFile={onPreviewFile}
|
||||
@@ -215,6 +222,16 @@ export function ProjectTree({
|
||||
)
|
||||
}
|
||||
|
||||
export function projectTreeViewportSize(
|
||||
entries: readonly ResizeObserverEntry[],
|
||||
element: HTMLElement
|
||||
): { height: number; width: number } {
|
||||
const entry = entries.find(item => item.target === element)
|
||||
const box = entry?.contentRect ?? element.getBoundingClientRect()
|
||||
|
||||
return { height: box.height, width: box.width }
|
||||
}
|
||||
|
||||
function TreeSizingState() {
|
||||
return <TreeSkeleton />
|
||||
}
|
||||
@@ -244,7 +261,6 @@ const CHANGE_TINT: Record<RepoChangeKind, string> = {
|
||||
}
|
||||
|
||||
function ProjectTreeRow({
|
||||
changeKind,
|
||||
dragHandle,
|
||||
node,
|
||||
onAttachFile,
|
||||
@@ -253,13 +269,17 @@ function ProjectTreeRow({
|
||||
relativeTo,
|
||||
style
|
||||
}: NodeRendererProps<TreeNode> & {
|
||||
changeKind?: RepoChangeKind
|
||||
onAttachFile: (path: string) => void
|
||||
onAttachFolder: (path: string) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
relativeTo?: null | string
|
||||
}) {
|
||||
const renamingPath = useStore($renamingPath)
|
||||
const path = node.data?.id ?? ''
|
||||
const changeStore = useMemo(() => repoChangeKindForPath(path), [path])
|
||||
const changeKind: RepoChangeKind | undefined = useStore(changeStore)
|
||||
|
||||
markRightPanePerf('project-tree-row-render', path)
|
||||
|
||||
if (!node.data) {
|
||||
return <div style={style} />
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { observeActiveTerminalResize } from './active-resize'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function installRaf() {
|
||||
let nextId = 1
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
frames.set(id, callback)
|
||||
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id))
|
||||
|
||||
return {
|
||||
flush() {
|
||||
const pending = [...frames.entries()]
|
||||
frames.clear()
|
||||
pending.forEach(([, callback]) => callback(0))
|
||||
},
|
||||
pending: () => frames.size
|
||||
}
|
||||
}
|
||||
|
||||
describe('observeActiveTerminalResize', () => {
|
||||
it('fits once on activation and coalesces later resize bursts', () => {
|
||||
const raf = installRaf()
|
||||
const resize = { current: null as ResizeObserverCallback | null }
|
||||
const disconnect = vi.fn()
|
||||
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resize.current = callback
|
||||
}
|
||||
|
||||
disconnect = disconnect
|
||||
observe = vi.fn((target: Element) => {
|
||||
resize.current?.([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||
})
|
||||
unobserve = vi.fn()
|
||||
} as unknown as typeof ResizeObserver
|
||||
)
|
||||
|
||||
const onFit = vi.fn()
|
||||
const onActivate = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
const dispose = observeActiveTerminalResize(host, { onActivate, onFit })
|
||||
|
||||
// ResizeObserver's initial delivery is absorbed by the activation frame.
|
||||
expect(raf.pending()).toBe(1)
|
||||
raf.flush()
|
||||
expect(onFit).toHaveBeenCalledTimes(1)
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
|
||||
resize.current?.([], {} as ResizeObserver)
|
||||
resize.current?.([], {} as ResizeObserver)
|
||||
resize.current?.([], {} as ResizeObserver)
|
||||
expect(raf.pending()).toBe(1)
|
||||
raf.flush()
|
||||
expect(onFit).toHaveBeenCalledTimes(2)
|
||||
|
||||
dispose()
|
||||
expect(disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels activation without fitting when hidden before the first frame', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
} as unknown as typeof ResizeObserver
|
||||
)
|
||||
|
||||
const onFit = vi.fn()
|
||||
|
||||
const dispose = observeActiveTerminalResize(document.createElement('div'), {
|
||||
onActivate: vi.fn(),
|
||||
onFit
|
||||
})
|
||||
|
||||
dispose()
|
||||
|
||||
raf.flush()
|
||||
expect(onFit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('absorbs a real browser-style initial resize delivered after activation', () => {
|
||||
const raf = installRaf()
|
||||
const resize = { current: null as ResizeObserverCallback | null }
|
||||
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resize.current = callback
|
||||
}
|
||||
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
} as unknown as typeof ResizeObserver
|
||||
)
|
||||
|
||||
const onFit = vi.fn()
|
||||
observeActiveTerminalResize(document.createElement('div'), { onActivate: vi.fn(), onFit })
|
||||
|
||||
raf.flush()
|
||||
expect(onFit).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Browser initial delivery: the activation fit already covered this size.
|
||||
resize.current?.([], {} as ResizeObserver)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
// A later real resize schedules exactly one fit.
|
||||
resize.current?.([], {} as ResizeObserver)
|
||||
expect(raf.pending()).toBe(1)
|
||||
raf.flush()
|
||||
expect(onFit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses a first-mount fit without fitting again on activation', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
} as unknown as typeof ResizeObserver
|
||||
)
|
||||
|
||||
const onActivate = vi.fn()
|
||||
const onFit = vi.fn()
|
||||
|
||||
observeActiveTerminalResize(document.createElement('div'), {
|
||||
fitOnActivate: false,
|
||||
onActivate,
|
||||
onFit
|
||||
})
|
||||
|
||||
raf.flush()
|
||||
|
||||
expect(onActivate).toHaveBeenCalledOnce()
|
||||
expect(onFit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
interface ActiveTerminalResizeOptions {
|
||||
fitOnActivate?: boolean
|
||||
onActivate: () => void
|
||||
onFit: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe one visible xterm host.
|
||||
*
|
||||
* Inactive terminals never call this helper, so their preserved DOM/PTY stays
|
||||
* mounted without paying for ResizeObserver delivery or FitAddon work. The
|
||||
* first frame owns activation and ignores the observer's initial delivery;
|
||||
* later resize bursts are coalesced to one fit per animation frame.
|
||||
*/
|
||||
export function observeActiveTerminalResize(
|
||||
host: HTMLElement,
|
||||
{ fitOnActivate = true, onActivate, onFit }: ActiveTerminalResizeOptions
|
||||
): () => void {
|
||||
let activated = false
|
||||
let frame = 0
|
||||
let initialResizeDelivered = false
|
||||
let stopped = false
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (!activated || stopped || frame !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
|
||||
if (!stopped) {
|
||||
onFit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
// ResizeObserver's initial delivery is asynchronous in browsers and may
|
||||
// arrive before OR after the activation rAF. Activation already fits the
|
||||
// current box, so absorb that first delivery in either ordering.
|
||||
if (!initialResizeDelivered) {
|
||||
initialResizeDelivered = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scheduleFit()
|
||||
})
|
||||
|
||||
observer.observe(host)
|
||||
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
activated = true
|
||||
|
||||
if (fitOnActivate) {
|
||||
onFit()
|
||||
}
|
||||
|
||||
onActivate()
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
observer.disconnect()
|
||||
|
||||
if (frame !== 0) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $paneStates } from '@/store/panes'
|
||||
|
||||
import { PersistentTerminal, TerminalSlot } from './persistent'
|
||||
|
||||
vi.mock('./terminals', () => ({
|
||||
@@ -212,7 +214,8 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(mutationObserveCalls.some(call => call.options?.subtree === true)).toBe(true)
|
||||
expect(mutationObserveCalls.length).toBeGreaterThan(0)
|
||||
expect(mutationObserveCalls.every(call => call.options?.subtree === false)).toBe(true)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
@@ -244,6 +247,27 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('remeasures from an explicit pane-layout state change', () => {
|
||||
const raf = installRaf()
|
||||
const before = $paneStates.get()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
raf.runNext()
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
$paneStates.set({ ...before, __terminal_rect_test__: { open: true } })
|
||||
})
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
$paneStates.set(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { $layoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
import { $paneStates } from '@/store/panes'
|
||||
|
||||
import { $terminalTakeover } from '../store'
|
||||
|
||||
@@ -39,7 +42,7 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div className={className} ref={ref} />
|
||||
return <div className={className} data-terminal-slot="" ref={ref} />
|
||||
}
|
||||
|
||||
interface PersistentTerminalProps {
|
||||
@@ -86,6 +89,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
let prev: Rect | null = null
|
||||
let frame = 0
|
||||
let stopped = false
|
||||
let pendingReason = 'initial'
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
@@ -97,11 +101,12 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
}
|
||||
}
|
||||
|
||||
const measure = (): boolean => {
|
||||
const measure = (reason: string): boolean => {
|
||||
if (rendererPaused()) {
|
||||
return false
|
||||
}
|
||||
|
||||
markRightPanePerf('terminal-measure', reason)
|
||||
const r = slot.getBoundingClientRect()
|
||||
// floor top/left + ceil right/bottom: overlay always covers the slot's
|
||||
// full pixel footprint, so half-pixel rects can't leak page bg through.
|
||||
@@ -123,16 +128,18 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
return false
|
||||
}
|
||||
|
||||
const scheduleMeasure = () => {
|
||||
const scheduleMeasure = (reason = 'unknown') => {
|
||||
if (stopped || rendererPaused() || frame !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingReason = reason
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
const reason = pendingReason
|
||||
|
||||
if (measure()) {
|
||||
scheduleMeasure()
|
||||
if (measure(reason)) {
|
||||
scheduleMeasure('settle')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -144,50 +151,69 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMeasure()
|
||||
scheduleMeasure('visibility')
|
||||
}
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
scheduleMeasure()
|
||||
scheduleMeasure('resize-observer')
|
||||
})
|
||||
|
||||
const positionObserver =
|
||||
typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(() => {
|
||||
scheduleMeasure()
|
||||
scheduleMeasure('ancestor-mutation')
|
||||
})
|
||||
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||
|
||||
if (measure()) {
|
||||
scheduleMeasure()
|
||||
if (measure('initial')) {
|
||||
scheduleMeasure('settle')
|
||||
}
|
||||
|
||||
observer?.observe(slot)
|
||||
|
||||
const handleScroll = () => scheduleMeasure('scroll')
|
||||
const scrollTargets: Array<HTMLElement | Window> = [window]
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
for (let node: HTMLElement | null = slot; node; node = node.parentElement) {
|
||||
positionObserver?.observe(node, {
|
||||
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: false
|
||||
})
|
||||
// Scroll does not bubble. Listen only on the slot's own ancestor chain,
|
||||
// so a transcript/file-tree/xterm viewport scroll elsewhere cannot wake
|
||||
// terminal positioning.
|
||||
node.addEventListener('scroll', handleScroll)
|
||||
scrollTargets.push(node)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scheduleMeasure)
|
||||
window.addEventListener('scroll', scheduleMeasure, true)
|
||||
// Nested layout-tree and pane-state commits can move the slot without
|
||||
// changing its own size. Subscribe to the actual layout authorities instead
|
||||
// of observing every descendant mutation under every ancestor (chat stream
|
||||
// and file-tree updates are unrelated and used to wake this tracker).
|
||||
const unsubscribeLayout = $layoutTree.listen(() => scheduleMeasure('layout-tree'))
|
||||
const unsubscribePanes = $paneStates.listen(() => scheduleMeasure('pane-state'))
|
||||
|
||||
const handleResize = () => scheduleMeasure('window-resize')
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
cancelFrame()
|
||||
observer?.disconnect()
|
||||
positionObserver?.disconnect()
|
||||
window.removeEventListener('resize', scheduleMeasure)
|
||||
window.removeEventListener('scroll', scheduleMeasure, true)
|
||||
unsubscribeLayout()
|
||||
unsubscribePanes()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
scrollTargets.forEach(target => target.removeEventListener('scroll', handleScroll))
|
||||
pauseController?.dispose()
|
||||
}
|
||||
}, [slot])
|
||||
@@ -215,7 +241,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
// booting xterm/node-pty at 0×0 starts the shell at 80×24 and spawns a visible
|
||||
// conhost on Windows. After that `mounted` latches: shells persist while hidden.
|
||||
return (
|
||||
<div aria-hidden={!visible} style={style}>
|
||||
<div aria-hidden={!visible} data-persistent-terminal="" style={style}>
|
||||
{mounted && <TerminalWorkspace onAddSelectionToChat={onAddSelectionToChat} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Terminal } from '@xterm/xterm'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { observeActiveTerminalResize } from './active-resize'
|
||||
import { registerAgentTerminalWriter } from './agent-terminal-stream'
|
||||
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
||||
@@ -24,7 +26,8 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const webglRef = useRef<WebglAddon | null>(null)
|
||||
const fitRef = useRef<(() => void) | null>(null)
|
||||
const fitRef = useRef<((visible: boolean) => void) | null>(null)
|
||||
const initialActiveFitRef = useRef(false)
|
||||
const { latestFontFamilyRef, mountedRef } = useTerminalFontController({ fitRef, termRef, webglRef })
|
||||
|
||||
const surfaceTheme = () => {
|
||||
@@ -47,7 +50,6 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
let unregister = () => {}
|
||||
|
||||
@@ -101,10 +103,11 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
||||
return false
|
||||
})
|
||||
|
||||
fitRef.current = () => {
|
||||
fitRef.current = visible => {
|
||||
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
||||
try {
|
||||
fit.fit()
|
||||
markRightPanePerf(visible ? 'terminal-fit-active' : 'terminal-fit-hidden', id)
|
||||
} catch {
|
||||
// Mid-transition layout — the next observer tick refits.
|
||||
}
|
||||
@@ -132,9 +135,8 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
||||
// No WebGL — xterm falls back to the DOM renderer.
|
||||
}
|
||||
|
||||
fitRef.current?.()
|
||||
observer = new ResizeObserver(() => fitRef.current?.())
|
||||
observer.observe(host)
|
||||
fitRef.current?.(active)
|
||||
initialActiveFitRef.current = active
|
||||
|
||||
// Stream live output straight into the terminal (replays backlog on attach).
|
||||
unregister = registerAgentTerminalWriter(procId, chunk => term.write(chunk))
|
||||
@@ -159,7 +161,6 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
||||
unregister()
|
||||
unregisterReader()
|
||||
selectionDisposable.dispose()
|
||||
observer?.disconnect()
|
||||
fitRef.current = null
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
@@ -184,25 +185,39 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [renderedMode, themeName])
|
||||
|
||||
// A visibility:hidden xterm doesn't paint — refit + redraw on re-activation.
|
||||
// Keep inactive agent terminals mounted for their backlog, but do not observe
|
||||
// or fit them until they become the visible tab.
|
||||
// eslint-disable-next-line no-restricted-syntax -- lifecycle flag prevents a duplicate first-mount fit
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
initialActiveFitRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const host = hostRef.current
|
||||
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
|
||||
const fitOnActivate = !initialActiveFitRef.current
|
||||
initialActiveFitRef.current = false
|
||||
|
||||
return observeActiveTerminalResize(host, {
|
||||
fitOnActivate,
|
||||
onFit: () => fitRef.current?.(true),
|
||||
onActivate: () => {
|
||||
const term = termRef.current
|
||||
|
||||
fitRef.current?.()
|
||||
webglRef.current?.clearTextureAtlas()
|
||||
term?.refresh(0, term.rows - 1)
|
||||
// Take focus on activation (parity with the user terminal) so the active
|
||||
// agent tab holds focus and ⌘W's isFocusWithin('[data-terminal]') routes
|
||||
// the close to this tab rather than to a preview.
|
||||
term?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [active])
|
||||
|
||||
return { hostRef }
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { RefObject } from 'react'
|
||||
import { $terminalFontFamily, applyTerminalFontFamily, resolveTerminalFontFamily } from './terminal-font'
|
||||
|
||||
interface TerminalFontControllerOptions {
|
||||
fitRef: RefObject<(() => void) | null>
|
||||
fitRef: RefObject<((visible: boolean) => void) | null>
|
||||
termRef: RefObject<Terminal | null>
|
||||
webglRef: RefObject<WebglAddon | null>
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function useTerminalFontController({ fitRef, termRef, webglRef }: Termina
|
||||
|
||||
void applyTerminalFontFamily({
|
||||
clearTextureAtlas: () => webglRef.current?.clearTextureAtlas(),
|
||||
fit: () => fitRef.current?.(),
|
||||
fit: () => fitRef.current?.(true),
|
||||
fontFamily,
|
||||
isCurrent: () => !cancelled && generationRef.current === generation,
|
||||
term
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { $terminalInjection } from '../store'
|
||||
|
||||
import { observeActiveTerminalResize } from './active-resize'
|
||||
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
||||
import { terminalLinkHandler, terminalWebLinksAddon } from './links'
|
||||
@@ -413,6 +415,7 @@ export function useTerminalSession({
|
||||
// drag-and-drop paths, or an injected command). Gates idle-buffer handling in
|
||||
// persistSnapshot so an untouched tab never re-saves an accumulating snapshot.
|
||||
const hasSessionActivityRef = useRef(false)
|
||||
const initialActiveRef = useRef(active)
|
||||
const shellNameRef = useRef('shell')
|
||||
const selectionLabelRef = useRef('')
|
||||
const selectionRef = useRef('')
|
||||
@@ -420,7 +423,8 @@ export function useTerminalSession({
|
||||
const onShellRef = useRef(onShell)
|
||||
// Re-fit on activation: a tab hidden via display:none has a 0×0 host, so its
|
||||
// last fit is stale by the time it's shown again.
|
||||
const fitRef = useRef<(() => void) | null>(null)
|
||||
const fitRef = useRef<((visible: boolean) => void) | null>(null)
|
||||
const initialActiveFitRef = useRef(false)
|
||||
const { latestFontFamilyRef, mountedRef } = useTerminalFontController({ fitRef, termRef, webglRef })
|
||||
const [status, setStatus] = useState<TerminalStatus>('starting')
|
||||
const [selection, setSelection] = useState('')
|
||||
@@ -742,56 +746,28 @@ export function useTerminalSession({
|
||||
term.write(next)
|
||||
}
|
||||
|
||||
const fitAndResize = () => {
|
||||
const fitAndResize = (visible: boolean) => {
|
||||
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
fit.fit()
|
||||
markRightPanePerf(visible ? 'terminal-fit-active' : 'terminal-fit-hidden', id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const id = sessionIdRef.current
|
||||
const sessionId = sessionIdRef.current
|
||||
|
||||
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
||||
if (sessionId && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
||||
void terminalApi.resize(sessionId, { cols: term.cols, rows: term.rows })
|
||||
}
|
||||
}
|
||||
|
||||
fitRef.current = fitAndResize
|
||||
|
||||
// Coalesce ResizeObserver bursts through rAF — running fit.fit()
|
||||
// synchronously while sibling panes are mid-transition (e.g. file browser
|
||||
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
|
||||
let pendingFrame = 0
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (pendingFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingFrame = window.requestAnimationFrame(() => {
|
||||
pendingFrame = 0
|
||||
|
||||
if (!disposed) {
|
||||
fitAndResize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleResize)
|
||||
resizeObserver.observe(host)
|
||||
cleanup.push(() => {
|
||||
resizeObserver.disconnect()
|
||||
|
||||
if (pendingFrame) {
|
||||
window.cancelAnimationFrame(pendingFrame)
|
||||
}
|
||||
})
|
||||
|
||||
const dataDisposable = term.onData(data => {
|
||||
hasSessionActivityRef.current = true
|
||||
const id = sessionIdRef.current
|
||||
@@ -901,9 +877,7 @@ export function useTerminalSession({
|
||||
)
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
fitAndResize()
|
||||
term.clearSelection() // drop any selection painted over transient boot rows
|
||||
term.focus()
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -938,7 +912,8 @@ export function useTerminalSession({
|
||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||
}
|
||||
|
||||
fitAndResize()
|
||||
fitAndResize(initialActiveRef.current)
|
||||
initialActiveFitRef.current = initialActiveRef.current
|
||||
startSession()
|
||||
}
|
||||
|
||||
@@ -1013,24 +988,39 @@ export function useTerminalSession({
|
||||
return term ? registerTerminalReader(id, makeTerminalReader(term)) : undefined
|
||||
}, [id, status])
|
||||
|
||||
// On (re)activation: a WebGL terminal doesn't paint while visibility:hidden, so
|
||||
// it reveals a stale/garbled frame. Refit, rebuild the glyph atlas, and force a
|
||||
// full redraw against the live buffer, then focus.
|
||||
// Only the active terminal observes its host. Every terminal stays mounted
|
||||
// (PTY + scrollback preserved), but hidden tabs do no FitAddon/layout work.
|
||||
// Re-activation owns one fit + atlas rebuild + redraw.
|
||||
// eslint-disable-next-line no-restricted-syntax -- lifecycle flag prevents a duplicate first-mount fit
|
||||
useEffect(() => {
|
||||
if (!active || status !== 'open') {
|
||||
if (!active) {
|
||||
initialActiveFitRef.current = false
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const host = hostRef.current
|
||||
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
|
||||
const fitOnActivate = !initialActiveFitRef.current
|
||||
initialActiveFitRef.current = false
|
||||
|
||||
return observeActiveTerminalResize(host, {
|
||||
fitOnActivate,
|
||||
onFit: () => fitRef.current?.(true),
|
||||
onActivate: () => {
|
||||
const term = termRef.current
|
||||
|
||||
fitRef.current?.()
|
||||
webglRef.current?.clearTextureAtlas()
|
||||
term?.refresh(0, term.rows - 1)
|
||||
term?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [active, status])
|
||||
|
||||
// Flush a queued command (e.g. a provider-disconnect) into the live session.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
|
||||
import { useSessionStateCache } from '../use-session-state-cache'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'session-1'
|
||||
@@ -90,6 +93,42 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
expect(assistantText()).toBe('still streaming')
|
||||
})
|
||||
|
||||
it('flushes queued text immediately when a hidden window becomes visible', () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'caught up on focus'))
|
||||
expect(assistantText()).toBe('')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
Object.defineProperty(globalThis.document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible'
|
||||
})
|
||||
|
||||
act(() => globalThis.document.dispatchEvent(new Event('visibilitychange')))
|
||||
|
||||
expect(assistantText()).toBe('caught up on focus')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('flushes queued text on focus when visibility remains visible', () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
Object.defineProperty(globalThis.document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible'
|
||||
})
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'focused without visibility change'))
|
||||
expect(assistantText()).toBe('')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => globalThis.window.dispatchEvent(new Event('focus')))
|
||||
|
||||
expect(assistantText()).toBe('focused without visibility change')
|
||||
})
|
||||
|
||||
it('cancels the pending timer on unmount and flushes exactly once', async () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
mountStream()
|
||||
@@ -108,4 +147,275 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stretches the flush gap when the deferred commit frame is expensive', async () => {
|
||||
// The streaming-path $messages publish (React commit + Streamdown
|
||||
// re-parse) is deferred to a view-sync rAF inside updateSessionState, so
|
||||
// the flush cost must be measured through that frame. Simulate one
|
||||
// expensive frame and expect the next gap to adapt to 3x the frame cost.
|
||||
let now = 1000
|
||||
vi.mocked(performance.now).mockImplementation(() => now)
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.mocked(window.requestAnimationFrame).mockImplementation(cb => {
|
||||
rafCallbacks.push(cb)
|
||||
|
||||
return rafCallbacks.length
|
||||
})
|
||||
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'first'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('first')
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Frame started at 1040, the measurement callback runs at 1100: 60ms of
|
||||
// in-frame work (view sync + commit), so the next floor is 180ms.
|
||||
now = 1100
|
||||
act(() => rafCallbacks[0](1040))
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'second'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(79)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('first')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('keeps the write-cost floor when no frame fires (hidden renderer)', async () => {
|
||||
// A parked renderer never runs rAF callbacks. The cost must stay at the
|
||||
// synchronous store-write measurement so the gap falls back to the fixed
|
||||
// 33ms floor instead of waiting on a frame that will never come.
|
||||
let now = 1000
|
||||
vi.mocked(performance.now).mockImplementation(() => now)
|
||||
vi.mocked(window.requestAnimationFrame).mockImplementation(() => 1)
|
||||
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'first'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('first')
|
||||
|
||||
// 100ms later (well past the 33ms floor): the next flush is immediate.
|
||||
now = 1100
|
||||
act(() => appendAssistantDelta!(SID, 'second'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('ignores a late frame measurement once a newer flush has started', async () => {
|
||||
let now = 1000
|
||||
vi.mocked(performance.now).mockImplementation(() => now)
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.mocked(window.requestAnimationFrame).mockImplementation(cb => {
|
||||
rafCallbacks.push(cb)
|
||||
|
||||
return rafCallbacks.length
|
||||
})
|
||||
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'a'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
// A second flush starts before the first flush's frame lands.
|
||||
now = 1010
|
||||
act(() => appendAssistantDelta!(SID, 'b'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(23)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('ab')
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
|
||||
// The stale callback must not overwrite the newer flush's cost. If it
|
||||
// did, cost would read 30ms and the next gap would stretch to 70ms.
|
||||
now = 1030
|
||||
act(() => rafCallbacks[0](1000))
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'c'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(13)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMessageStream composed with the real useSessionStateCache', () => {
|
||||
// The tests above mock updateSessionState, so they validate the adaptive
|
||||
// arithmetic but not the production ordering contract: runFlush's
|
||||
// measurement rAF must be registered AFTER the view-sync rAF that the real
|
||||
// updateSessionState schedules inside syncSessionStateToView, so the
|
||||
// measured frame cost includes the deferred $messages commit it adapts to.
|
||||
let cache: ReturnType<typeof useSessionStateCache> | null = null
|
||||
let published: ChatMessage[]
|
||||
|
||||
function ComposedHarness() {
|
||||
const busyRef: MutableRefObject<boolean> = { current: false }
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const sessionCache = useSessionStateCache({
|
||||
activeSessionId: SID,
|
||||
busyRef,
|
||||
selectedStoredSessionId: null,
|
||||
setAwaitingResponse: () => undefined,
|
||||
setBusy: () => undefined,
|
||||
setMessages: messages => {
|
||||
published = messages
|
||||
}
|
||||
})
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef: sessionCache.activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef: sessionCache.sessionStateByRuntimeIdRef,
|
||||
updateSessionState: sessionCache.updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
appendAssistantDelta = stream.appendAssistantDelta
|
||||
cache = sessionCache
|
||||
}, [stream.appendAssistantDelta, sessionCache])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function cachedText() {
|
||||
const message = cache?.sessionStateByRuntimeIdRef.current.get(SID)?.messages.at(-1)
|
||||
const part = message?.parts.at(-1)
|
||||
|
||||
return part?.type === 'text' ? part.text : ''
|
||||
}
|
||||
|
||||
function publishedText() {
|
||||
const part = published.at(-1)?.parts.at(-1)
|
||||
|
||||
return part?.type === 'text' ? part.text : ''
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appendAssistantDelta = null
|
||||
cache = null
|
||||
published = []
|
||||
vi.spyOn(performance, 'now').mockReturnValue(100)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('measures the frame cost through the real view-sync rAF and adapts the next gap', async () => {
|
||||
let now = 1000
|
||||
vi.mocked(performance.now).mockImplementation(() => now)
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.mocked(window.requestAnimationFrame).mockImplementation(cb => {
|
||||
rafCallbacks.push(cb)
|
||||
|
||||
return rafCallbacks.length
|
||||
})
|
||||
|
||||
render(<ComposedHarness />)
|
||||
expect(appendAssistantDelta).not.toBeNull()
|
||||
|
||||
// Mid-turn state: busy keeps the view sync on the deferred rAF path
|
||||
// (terminal/needing-input states flush synchronously instead).
|
||||
act(() => {
|
||||
cache!.updateSessionState(SID, state => ({ ...state, busy: true }))
|
||||
})
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
// Drain the seed's own view-sync rAF so the flush below starts clean.
|
||||
act(() => rafCallbacks.shift()!(now))
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'first'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
// The store write landed synchronously, but the $messages publish is
|
||||
// deferred: exactly two rAF callbacks are pending — first the cache's
|
||||
// view-sync, then runFlush's measurement.
|
||||
expect(cachedText()).toBe('first')
|
||||
expect(publishedText()).toBe('')
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
|
||||
// Draining the FIRST registered callback must be what publishes the
|
||||
// deferred commit; that identity is the ordering contract. It runs until
|
||||
// 60ms into the frame (React commit + Streamdown re-parse).
|
||||
now = 1100
|
||||
act(() => rafCallbacks[0](1040))
|
||||
expect(publishedText()).toBe('first')
|
||||
|
||||
// The measurement callback closes the same frame: 60ms of in-frame work,
|
||||
// so the next adaptive floor is 3x = 180ms.
|
||||
act(() => rafCallbacks[1](1040))
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'second'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(79)
|
||||
})
|
||||
|
||||
expect(cachedText()).toBe('first')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(cachedText()).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('keeps the write-cost fallback when the parked renderer never fires rAF', async () => {
|
||||
let now = 1000
|
||||
vi.mocked(performance.now).mockImplementation(() => now)
|
||||
// Parked renderer: rAF callbacks are accepted but never run.
|
||||
vi.mocked(window.requestAnimationFrame).mockImplementation(() => 1)
|
||||
|
||||
render(<ComposedHarness />)
|
||||
|
||||
act(() => {
|
||||
cache!.updateSessionState(SID, state => ({ ...state, busy: true }))
|
||||
})
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'first'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(cachedText()).toBe('first')
|
||||
|
||||
// 100ms later (well past the 33ms floor): the next flush is immediate.
|
||||
now = 1100
|
||||
act(() => appendAssistantDelta!(SID, 'second'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(cachedText()).toBe('firstsecond')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,6 +188,9 @@ export function useMessageStream({
|
||||
// What the previous flush cost on the main thread — drives the adaptive
|
||||
// flush floor in scheduleDeltaFlush so multi-stream load yields to input.
|
||||
const lastFlushCostRef = useRef<number>(0)
|
||||
// The pending commit-cost measurement rAF, so a newer flush (or unmount)
|
||||
// can cancel it instead of letting parked callbacks pile up while hidden.
|
||||
const measureRafRef = useRef<number | null>(null)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||
@@ -257,6 +260,8 @@ export function useMessageStream({
|
||||
// keeps the thread ~75% idle for input at any load: cheap flushes stay at
|
||||
// 30fps of text growth, expensive multi-stream flushes degrade text fps
|
||||
// instead of interactivity — capped so text never updates slower than 4/s.
|
||||
// The cost has to include the deferred view-sync frame where the commit
|
||||
// actually happens; see runFlush below.
|
||||
const sinceLast = performance.now() - lastFlushAtRef.current
|
||||
|
||||
const adaptiveFloor = Math.min(
|
||||
@@ -269,7 +274,39 @@ export function useMessageStream({
|
||||
const startedAt = performance.now()
|
||||
lastFlushAtRef.current = startedAt
|
||||
flushQueuedDeltas()
|
||||
lastFlushCostRef.current = performance.now() - startedAt
|
||||
// The store write above is only the cheap half of a flush. While a
|
||||
// session streams, syncSessionStateToView defers the $messages publish
|
||||
// (and with it the React commit + Streamdown re-parse the floor is meant
|
||||
// to account for) to its own rAF inside updateSessionState, which runs
|
||||
// after this timer task. Stopping the clock here pins lastFlushCostRef
|
||||
// near zero and collapses the adaptive floor to 33ms no matter the load.
|
||||
// Our rAF is registered after the view-sync one, so it runs in the same
|
||||
// frame right after that commit; its timestamp marks frame start, so
|
||||
// (now - frameStart) counts only work done inside the frame, not the
|
||||
// vsync wait. A hidden renderer never fires rAF, so the write cost
|
||||
// stays as the fallback.
|
||||
const writeCost = performance.now() - startedAt
|
||||
lastFlushCostRef.current = writeCost
|
||||
|
||||
// At most one measurement rAF may be pending: only the newest flush's
|
||||
// measurement matters (the guard below discards stale frames), and a
|
||||
// hidden renderer parks rAF callbacks — without cancellation a long
|
||||
// hidden stream at the floor would accumulate thousands of parked
|
||||
// closures that all fire in the first frame on refocus.
|
||||
if (measureRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(measureRafRef.current)
|
||||
}
|
||||
|
||||
measureRafRef.current = window.requestAnimationFrame(frameStart => {
|
||||
measureRafRef.current = null
|
||||
|
||||
// A newer flush already started; its own measurement wins.
|
||||
if (lastFlushAtRef.current !== startedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFlushCostRef.current = writeCost + Math.max(0, performance.now() - frameStart)
|
||||
})
|
||||
}
|
||||
|
||||
// Always a timer, never requestAnimationFrame. Chromium pauses rAF for a
|
||||
@@ -312,11 +349,46 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
flushHandleRef.current = null
|
||||
|
||||
if (measureRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(measureRafRef.current)
|
||||
}
|
||||
|
||||
measureRafRef.current = null
|
||||
flushQueuedDeltas()
|
||||
},
|
||||
[flushQueuedDeltas]
|
||||
)
|
||||
|
||||
// Page Visibility does not report every Windows/Linux focus transition.
|
||||
// Flush queued deltas on both signals so returning to a chat cannot leave a
|
||||
// completed chunk waiting for the next throttled timer.
|
||||
// eslint-disable-next-line no-restricted-syntax -- timer-handle clear inside effect, not an atom mirror
|
||||
useEffect(() => {
|
||||
const flushPendingDeltas = () => {
|
||||
if (flushHandleRef.current !== null) {
|
||||
window.clearTimeout(flushHandleRef.current)
|
||||
flushHandleRef.current = null
|
||||
}
|
||||
|
||||
flushQueuedDeltas()
|
||||
}
|
||||
|
||||
const flushWhenVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
flushPendingDeltas()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', flushWhenVisible)
|
||||
window.addEventListener('focus', flushPendingDeltas)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', flushWhenVisible)
|
||||
window.removeEventListener('focus', flushPendingDeltas)
|
||||
}
|
||||
}, [flushQueuedDeltas])
|
||||
|
||||
const appendAssistantDelta = useCallback(
|
||||
(sessionId: string, delta: string) => {
|
||||
if (!delta) {
|
||||
|
||||
@@ -87,7 +87,10 @@ describe('stream delta delivery', () => {
|
||||
})
|
||||
|
||||
expect(states.get(SID)?.messages.at(-1)?.parts).toEqual([{ type: 'text', text: 'first and the rest' }])
|
||||
// The flush must not have depended on a frame at all.
|
||||
expect(rafSpy).not.toHaveBeenCalled()
|
||||
// The flush must not have depended on a frame: this mock parks every rAF
|
||||
// callback, yet the text arrived. runFlush still registers its
|
||||
// adaptive-floor measurement callback here; that one is allowed to wait
|
||||
// for a frame that may never come.
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1797,7 +1797,8 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-session-b',
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
@@ -1933,7 +1934,8 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
// Must resume the correct stored session to get the right runtime id.
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-session-a',
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
// The prompt must land in the resumed session, NOT the foreground.
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
@@ -2194,7 +2196,7 @@ describe('usePromptActions redirectPrompt', () => {
|
||||
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID, text: 'reconnect nudge' })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', omit_messages: true })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' })
|
||||
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
|
||||
})
|
||||
@@ -2735,7 +2737,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(ok).toBe(true)
|
||||
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', omit_messages: true })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
@@ -2779,7 +2781,12 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: STORED_SESSION_ID,
|
||||
source: 'desktop',
|
||||
omit_messages: true,
|
||||
profile: 'work'
|
||||
})
|
||||
|
||||
setSessions(() => [])
|
||||
})
|
||||
@@ -2826,7 +2833,12 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: STORED_SESSION_ID,
|
||||
source: 'desktop',
|
||||
omit_messages: true,
|
||||
profile: 'work'
|
||||
})
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
setSessions(() => [])
|
||||
@@ -2887,7 +2899,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
session_id: 'rt-background-stale',
|
||||
text: 'queued background message after wake'
|
||||
})
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: STORED_SESSION_ID,
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
expect(calls[2]?.params).toEqual({
|
||||
queued: true,
|
||||
session_id: RECOVERED_SESSION_ID,
|
||||
@@ -2935,7 +2951,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
|
||||
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: STORED_SESSION_ID,
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
@@ -3067,7 +3087,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: STORED_SESSION_ID,
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
expect(calls[2]?.params).toEqual({
|
||||
session_id: RECOVERED_SESSION_ID,
|
||||
text: 'message during starved loop'
|
||||
@@ -3110,7 +3134,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(ok).toBe(true)
|
||||
expect(createBackendSessionForSend).not.toHaveBeenCalled()
|
||||
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[0]?.params).toEqual({
|
||||
session_id: STORED_SESSION_ID,
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
@@ -3421,7 +3449,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
|
||||
expect(calls.some(c => c.method === 'prompt.submit')).toBe(false)
|
||||
expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({
|
||||
session_id: STORED_SESSION_A,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
omit_messages: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -639,6 +639,7 @@ export function usePromptActions({
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop',
|
||||
omit_messages: true,
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
@@ -744,6 +745,7 @@ export function usePromptActions({
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop',
|
||||
omit_messages: true,
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -484,6 +484,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: targetStoredSessionId,
|
||||
source: 'desktop',
|
||||
omit_messages: true,
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
@@ -637,6 +638,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: recoverStoredSessionId,
|
||||
source: 'desktop',
|
||||
omit_messages: true,
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -896,7 +896,7 @@ describe('resumeSession failure recovery', () => {
|
||||
|
||||
expect(resumeParams).not.toHaveProperty('lazy')
|
||||
expect(resumeParams).not.toHaveProperty('eager_build')
|
||||
expect(resumeParams).toMatchObject({ source: 'desktop' })
|
||||
expect(resumeParams).toMatchObject({ source: 'desktop', omit_messages: true })
|
||||
})
|
||||
|
||||
it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {
|
||||
@@ -1431,6 +1431,10 @@ describe('resumeSession warm-cache mapping integrity', () => {
|
||||
expect(methods).toContain('session.activate')
|
||||
expect(methods).not.toContain('session.resume')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'session.activate',
|
||||
expect.objectContaining({ omit_messages: true, session_id: 'rt-A' })
|
||||
)
|
||||
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
|
||||
})
|
||||
|
||||
|
||||
@@ -700,7 +700,8 @@ export function useSessionActions({
|
||||
try {
|
||||
activated = await requestGateway<SessionResumeResponse>('session.activate', {
|
||||
session_id: cachedRuntimeId,
|
||||
cols: 96
|
||||
cols: 96,
|
||||
omit_messages: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Compatibility for older backends. Modern backends require
|
||||
@@ -866,12 +867,14 @@ export function useSessionActions({
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
// REST is the transcript authority for Desktop. Avoid duplicating a
|
||||
// potentially huge compression lineage in the WebSocket response.
|
||||
// Watch windows attach lazily (live mirror). Every other cold resume
|
||||
// gets the gateway's default deferred build: the RPC returns the
|
||||
// transcript immediately instead of blocking the switch on _make_agent
|
||||
// (MCP discovery / prompt build), and the agent pre-warms in the
|
||||
// background while the prefetch above paints the transcript.
|
||||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(watchWindow ? { lazy: true } : { omit_messages: true }),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
|
||||
|
||||
+66
@@ -73,4 +73,70 @@ describe('reconcileResumeMessages — structural parts on a mid-turn switch', ()
|
||||
|
||||
expect(assistant.parts.filter(p => p.type === 'tool-call')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps live-tail structure when the flat dump is not a strict text extension', () => {
|
||||
// Mid-turn sandwich path: cache holds reasoning/tools; resume returns a
|
||||
// longer non-extending dump. Structure source must be live-tail.
|
||||
const cached: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-stream-1',
|
||||
pending: true,
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'thinking about tools' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal', args: {} },
|
||||
{ type: 'text', text: 'partial' }
|
||||
],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const authoritative: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-stream-1',
|
||||
pending: true,
|
||||
parts: [{ type: 'text', text: 'thinking about tools\nRan terminal\npartial and more dump' }],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const [assistant] = reconcileResumeMessages(authoritative, cached)
|
||||
|
||||
expect(assistant.parts.some(part => part.type === 'reasoning')).toBe(true)
|
||||
expect(assistant.parts.some(part => part.type === 'tool-call')).toBe(true)
|
||||
expect(assistant.parts.filter(part => part.type === 'text').map(part => ('text' in part ? part.text : ''))).toEqual(
|
||||
['partial']
|
||||
)
|
||||
})
|
||||
|
||||
it('does not graft historical structure onto a live text-only row after compression rewrote ordinals', () => {
|
||||
// Previous cache still has a completed structured assistant at ordinal 0.
|
||||
// Resume after compression returns a new live text-only assistant at the
|
||||
// same role ordinal for an unrelated turn — must not inherit foreign parts.
|
||||
const cached: ChatMessage[] = [
|
||||
{
|
||||
id: 'old-assistant',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'old thinking' },
|
||||
{ type: 'tool-call', toolCallId: 'old-call', toolName: 'terminal', args: {} },
|
||||
{ type: 'text', text: 'old answer' }
|
||||
],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const authoritative: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-stream-runtime-1',
|
||||
pending: true,
|
||||
parts: [{ type: 'text', text: 'brand new partial' }],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const [assistant] = reconcileResumeMessages(authoritative, cached)
|
||||
|
||||
expect(assistant.parts.some(part => part.type === 'reasoning')).toBe(false)
|
||||
expect(assistant.parts.some(part => part.type === 'tool-call')).toBe(false)
|
||||
expect(assistant.parts).toEqual([{ type: 'text', text: 'brand new partial' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { textWithoutReferenceLines, WIRE_REFERENCE_KINDS } from '@/components/assistant-ui/reference-kinds'
|
||||
import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/chat-messages'
|
||||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
@@ -24,6 +25,21 @@ import {
|
||||
const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
||||
({ id, role, parts: [{ type: 'text', text }], ...extra }) as ChatMessage
|
||||
|
||||
// A live assistant row carrying the structure the gateway's text-only inflight
|
||||
// snapshot cannot: reasoning and tool calls, with or without any text yet.
|
||||
const streamingMsg = (id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
||||
({
|
||||
id,
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'planning' },
|
||||
{ type: 'tool-call', toolCallId: 'call-1', toolName: 'terminal', result: 'done' },
|
||||
...(text ? [{ type: 'text', text } as ChatMessagePart] : [])
|
||||
],
|
||||
pending: true,
|
||||
...extra
|
||||
}) as ChatMessage
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo
|
||||
|
||||
describe('applyRuntimeInfo approval mode', () => {
|
||||
@@ -394,6 +410,126 @@ describe('reconcileResumeMessages', () => {
|
||||
|
||||
expect(out.attachmentRefs).toBeUndefined()
|
||||
})
|
||||
|
||||
// #75825: switching sessions mid-stream can re-hydrate an empty inflight shell
|
||||
// at the same ordinal as the live stream row that still holds the full reply.
|
||||
it('prefers a richer local pending assistant over an empty projection shell', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'hello from stream', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('assistant-stream-sess', 'assistant', '', { pending: true })]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(reconciled[1]).toMatchObject({ id: 'assistant-stream-live', pending: true })
|
||||
expect(chatMessageText(reconciled[1])).toBe('hello from stream')
|
||||
})
|
||||
|
||||
it('prefers a richer local pending assistant when the projection lags mid-stream', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'hello world', { pending: true })
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'hello', { pending: true })
|
||||
]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(chatMessageText(reconciled[1])).toBe('hello world')
|
||||
expect(reconciled[1].id).toBe('assistant-stream-live')
|
||||
})
|
||||
|
||||
it('does not override when the authoritative assistant has advanced further', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'hello', { pending: true })
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'hello world', { pending: true })
|
||||
]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(chatMessageText(reconciled[1])).toBe('hello world')
|
||||
expect(reconciled[1].id).toBe('assistant-stream-sess')
|
||||
})
|
||||
|
||||
// The reported "no inference traces or tool calls": mid tool-work, the local
|
||||
// row holds reasoning + tool calls and NO text yet, so both bodies are empty
|
||||
// text and a text-length comparison cannot tell them apart.
|
||||
it('prefers a traces-only local pending row over an empty shell', () => {
|
||||
const previous = [msg('1-user', 'user', 'run the tools'), streamingMsg('assistant-stream-live', '')]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'run the tools'),
|
||||
msg('assistant-stream-sess', 'assistant', '', { pending: true })
|
||||
]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(reconciled[1].id).toBe('assistant-stream-live')
|
||||
expect(reconciled[1].parts.map(part => part.type)).toEqual(['reasoning', 'tool-call'])
|
||||
})
|
||||
|
||||
// A longer local body that is NOT an extension of the authoritative text is a
|
||||
// different turn at the same ordinal (compression rewrites history) and must
|
||||
// not hijack the slot.
|
||||
it('leaves a shorter non-prefix authoritative assistant intact', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'a long local reply about something else entirely', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('9-assistant', 'assistant', 'short authoritative answer')]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(reconciled[1].id).toBe('9-assistant')
|
||||
expect(chatMessageText(reconciled[1])).toBe('short authoritative answer')
|
||||
})
|
||||
|
||||
// A retained failure snapshot (`inflight.error`) is projected with empty text.
|
||||
// Preferring the local partial over it would erase the error and repaint the
|
||||
// turn as healthy.
|
||||
it('does not treat an errored authoritative row as an empty shell', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'do the thing'),
|
||||
msg('assistant-stream-live', 'assistant', 'partial answer before the failure', { pending: true })
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'do the thing'),
|
||||
msg('assistant-stream-sess', 'assistant', '', { error: 'model call failed: 500' })
|
||||
]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(reconciled[1].error).toBe('model call failed: 500')
|
||||
})
|
||||
|
||||
// Content comes from the renderer; liveness stays the backend's call. A
|
||||
// settled shell (queued turn behind a finished inflight one) must not leave
|
||||
// the preserved reply spinning forever.
|
||||
it('takes the local body but the authoritative settled state', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'streamed body', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('assistant-stream-sess', 'assistant', '', { pending: false })]
|
||||
|
||||
const reconciled = reconcileResumeMessages(next, previous)
|
||||
|
||||
expect(reconciled[1]).toMatchObject({ id: 'assistant-stream-live', pending: false })
|
||||
expect(chatMessageText(reconciled[1])).toBe('streamed body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserveLocalPendingTurnMessages', () => {
|
||||
@@ -556,7 +692,7 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
// `attachmentRefs`. A naive text compare (chatMessageText a === b) therefore
|
||||
// always mismatched whenever an image was attached and re-appended the
|
||||
// optimistic row as a distinct, duplicate user bubble. Both sides must now
|
||||
// reduce to the same visible text via textWithoutImageRefs.
|
||||
// reduce to the same visible text via textWithoutReferenceLines.
|
||||
it('does not duplicate the optimistic image turn when the persisted turn carries @image refs', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
@@ -575,6 +711,91 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it('does not duplicate the optimistic file turn when the persisted turn carries @file refs', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'text', {
|
||||
attachmentRefs: ['@file:X']
|
||||
})
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first'),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer'),
|
||||
msg('3-user-stored', 'user', '@file:X\n\ntext')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it.each(WIRE_REFERENCE_KINDS.filter(kind => kind !== 'file' && kind !== 'image'))(
|
||||
'does not duplicate the optimistic %s turn when the persisted turn carries its directive',
|
||||
kind => {
|
||||
const ref = `@${kind}:X`
|
||||
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'text', {
|
||||
attachmentRefs: [ref]
|
||||
})
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first'),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer'),
|
||||
msg('3-user-stored', 'user', `${ref}\n\ntext`)
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not duplicate a directive-only file turn', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', '', {
|
||||
attachmentRefs: ['@file:X']
|
||||
})
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first'),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer'),
|
||||
msg('3-user-stored', 'user', '@file:X')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it('does not duplicate a turn with multiple CRLF directives and Unicode payloads', () => {
|
||||
const refs = ['@file:`資料/über notes.md`', '@url:`https://example.com/café?q=✓`']
|
||||
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'text', {
|
||||
attachmentRefs: refs
|
||||
})
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first'),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer'),
|
||||
msg('3-user-stored', 'user', `${refs.join('\r\n')}\r\n\r\ntext`)
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it('strips only complete reference lines from visible text', () => {
|
||||
expect(textWithoutReferenceLines('see @file:X here')).toBe('see @file:X here')
|
||||
expect(textWithoutReferenceLines('@file:X trailing prose')).toBe('@file:X trailing prose')
|
||||
expect(textWithoutReferenceLines(' @file:X')).toBe('@file:X')
|
||||
})
|
||||
|
||||
it('still keeps a genuinely uncommitted optimistic image turn when the persisted text differs', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
@@ -599,6 +820,177 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
'user-optimistic'
|
||||
])
|
||||
})
|
||||
|
||||
// #75825: an empty inflight projection shell at the same ordinal must not
|
||||
// discard the local pending assistant that still holds the streamed content.
|
||||
// Replace the shell (do not append) so the transcript shows one reply.
|
||||
it('replaces an empty inflight shell with a fuller local pending assistant', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'partial answer so far', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('assistant-stream-sess', 'assistant', '', { pending: true })]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(preserved.map(message => message.id)).toEqual(['1-user', 'assistant-stream-live'])
|
||||
expect(chatMessageText(preserved[1])).toBe('partial answer so far')
|
||||
expect(preserved[1].pending).toBe(true)
|
||||
})
|
||||
|
||||
it('replaces a lagging same-id shell with the fuller local pending body', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'full streamed content', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('assistant-stream-sess', 'assistant', '', { pending: true })]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(preserved.map(message => message.id)).toEqual(['1-user', 'assistant-stream-sess'])
|
||||
expect(chatMessageText(preserved[1])).toBe('full streamed content')
|
||||
})
|
||||
|
||||
it('still drops local pending when authoritative text is at least as complete', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'partial', { pending: true })
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'partial and more', { pending: true })
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
// Mid tool-work both bodies are empty text, so only the parts distinguish the
|
||||
// live row from the shell — the reported "no inference traces or tool calls".
|
||||
it('replaces an empty shell with a traces-only local pending row', () => {
|
||||
const previous = [msg('1-user', 'user', 'run the tools'), streamingMsg('assistant-stream-live', '')]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'run the tools'),
|
||||
msg('assistant-stream-sess', 'assistant', '', { pending: true })
|
||||
]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(preserved).toHaveLength(2)
|
||||
expect(preserved[1].parts.map(part => part.type)).toEqual(['reasoning', 'tool-call'])
|
||||
})
|
||||
|
||||
// Length alone is not identity: a longer local row that does not extend the
|
||||
// authoritative text belongs to another turn and must not take its slot — by
|
||||
// ordinal or by reusing the stream id.
|
||||
it('leaves a shorter non-prefix authoritative assistant intact', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'a long local reply about something else entirely', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('9-assistant', 'assistant', 'short authoritative answer')]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(preserved.map(message => message.id)).toEqual(['1-user', '9-assistant'])
|
||||
expect(chatMessageText(preserved[1])).toBe('short authoritative answer')
|
||||
})
|
||||
|
||||
it('leaves a shorter non-prefix authoritative assistant intact on the same stream id', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'a long local reply about something else entirely', { pending: true })
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'short authoritative answer')
|
||||
]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(chatMessageText(preserved[1])).toBe('short authoritative answer')
|
||||
})
|
||||
|
||||
it('does not erase a retained failure with the local partial', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'do the thing'),
|
||||
msg('assistant-stream-live', 'assistant', 'partial answer before the failure', { pending: true })
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user', 'user', 'do the thing'),
|
||||
msg('assistant-stream-sess', 'assistant', '', { error: 'model call failed: 500' })
|
||||
]
|
||||
|
||||
const assistant = preserveLocalPendingTurnMessages(next, previous).find(message => message.role === 'assistant')
|
||||
|
||||
expect(assistant?.error).toBe('model call failed: 500')
|
||||
expect(assistant?.pending).not.toBe(true)
|
||||
})
|
||||
|
||||
it('takes the local body but the authoritative settled state', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-live', 'assistant', 'streamed body', { pending: true })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question'), msg('assistant-stream-sess', 'assistant', '', { pending: false })]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(preserved[1]).toMatchObject({ id: 'assistant-stream-live', pending: false })
|
||||
expect(chatMessageText(preserved[1])).toBe('streamed body')
|
||||
})
|
||||
|
||||
// #70209: history committed the reply under its own id, so the settled local
|
||||
// stream row sits at a later ordinal, pairs with nothing, and gets appended —
|
||||
// the same answer twice.
|
||||
it('does not re-append a settled stream row the authoritative history already carries', () => {
|
||||
const next = [msg('1-user-stored', 'user', 'question'), msg('2-assistant-stored', 'assistant', 'answer')]
|
||||
const settledLocalStream = msg('assistant-stream-runtime-1', 'assistant', 'answer', { pending: false })
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, [...next, settledLocalStream])).toBe(next)
|
||||
})
|
||||
|
||||
// The reply finished locally but the gateway had not committed it when the
|
||||
// session was reopened — the local row is the only copy and must survive.
|
||||
it('keeps a settled stream row the authoritative history has not committed', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'question'),
|
||||
msg('assistant-stream-sess', 'assistant', 'the finished reply', { pending: false })
|
||||
]
|
||||
|
||||
const next = [msg('1-user', 'user', 'question')]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
|
||||
'1-user',
|
||||
'assistant-stream-sess'
|
||||
])
|
||||
})
|
||||
|
||||
// The whole point of replacing rather than appending: one reply on screen,
|
||||
// and the committed history around the live turn untouched.
|
||||
it('does not duplicate or rewrite committed history around the live turn', () => {
|
||||
const history = [
|
||||
msg('1-user', 'user', 'first question'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('3-user', 'user', 'run the tools')
|
||||
]
|
||||
|
||||
const previous = [...history, streamingMsg('assistant-stream-live', 'here is the full reply')]
|
||||
const next = [...history, msg('assistant-stream-sess', 'assistant', '', { pending: true })]
|
||||
|
||||
const preserved = preserveLocalPendingTurnMessages(next, previous)
|
||||
|
||||
expect(preserved).toHaveLength(4)
|
||||
expect(chatMessageText(preserved[1])).toBe('first answer')
|
||||
expect(preserved.filter(message => message.role === 'assistant')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendLiveSessionProjection', () => {
|
||||
@@ -730,4 +1122,73 @@ describe('appendLiveSessionProjection', () => {
|
||||
|
||||
expect(appendLiveSessionProjection(stored, { session_id: 'runtime-1' })).toBe(stored)
|
||||
})
|
||||
|
||||
it('does not sandwich a structured mid-turn row with the inflight flat dump (#76444)', () => {
|
||||
const stored: ChatMessage[] = [
|
||||
msg('stored-user', 'user', 'do the work'),
|
||||
{
|
||||
id: 'live-assistant',
|
||||
role: 'assistant',
|
||||
pending: true,
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'thinking about tools' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal', args: {} },
|
||||
{ type: 'text', text: 'partial' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const restored = appendLiveSessionProjection(stored, {
|
||||
session_id: 'runtime-1',
|
||||
inflight: {
|
||||
user: 'do the work',
|
||||
// Flat dump includes thinking chatter + tool narration — longer than
|
||||
// the answer text alone, which is how the sandwich used to grow.
|
||||
assistant: 'thinking about tools\nRan terminal\npartial and more dump',
|
||||
streaming: true
|
||||
}
|
||||
})
|
||||
|
||||
const assistants = restored.filter(message => message.role === 'assistant')
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(assistants[0].id).toBe('live-assistant')
|
||||
expect(assistants[0].parts.some(part => part.type === 'reasoning')).toBe(true)
|
||||
expect(assistants[0].parts.some(part => part.type === 'tool-call')).toBe(true)
|
||||
// Answer text stays the structured row's text, not the dump.
|
||||
expect(
|
||||
assistants[0].parts.filter(part => part.type === 'text').map(part => ('text' in part ? part.text : ''))
|
||||
).toEqual(['partial'])
|
||||
})
|
||||
|
||||
it('still projects inflight when only a completed historical tool reply has structure', () => {
|
||||
// Older completed assistants keep reasoning/tool parts in the full
|
||||
// transcript; they must not suppress a new turn's text projection.
|
||||
const stored: ChatMessage[] = [
|
||||
msg('old-user', 'user', 'previous task'),
|
||||
{
|
||||
id: 'old-assistant',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 'old', toolName: 'terminal', args: {} },
|
||||
{ type: 'text', text: 'done earlier' }
|
||||
]
|
||||
},
|
||||
msg('new-user', 'user', 'new task')
|
||||
]
|
||||
|
||||
const restored = appendLiveSessionProjection(stored, {
|
||||
session_id: 'runtime-1',
|
||||
inflight: {
|
||||
user: 'new task',
|
||||
assistant: 'working on it',
|
||||
streaming: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(restored.map(message => message.id)).toContain('assistant-stream-runtime-1')
|
||||
expect(restored.at(-1)).toMatchObject({
|
||||
id: 'assistant-stream-runtime-1',
|
||||
pending: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { textWithoutReferenceLines } from '@/components/assistant-ui/reference-kinds'
|
||||
import { getSession } from '@/hermes'
|
||||
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages, textWithoutImageRefs } from '@/lib/embedded-images'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -46,6 +47,41 @@ function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
|
||||
return appended ? { ...message, parts } : message
|
||||
}
|
||||
|
||||
/** Reasoning / tool-call parts that the gateway inflight dump cannot express. */
|
||||
function hasStructuralParts(message: ChatMessage): boolean {
|
||||
return message.parts.some(part => part.type === 'reasoning' || part.type === 'tool-call')
|
||||
}
|
||||
|
||||
/**
|
||||
* A live-turn row — the gateway's text-only `inflight` projection, a
|
||||
* still-streaming local bubble, or an interim row sealed inside the running
|
||||
* turn — as opposed to a committed transcript row.
|
||||
*/
|
||||
function isLiveTailRow(message: ChatMessage): boolean {
|
||||
return (
|
||||
message.pending === true ||
|
||||
message.id.startsWith('assistant-stream-') ||
|
||||
message.id.startsWith('inflight-assistant-') ||
|
||||
message.interim === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `next` is a pure forward extension of the previous *answer* text.
|
||||
* Empty previous answer never accepts a dump as an extension — that is how the
|
||||
* mid-turn inflight flat dump used to sandwich structured rows (#76444).
|
||||
*/
|
||||
export function isStrictAnswerTextExtension(next: string, previous: string): boolean {
|
||||
const n = next.trim()
|
||||
const p = previous.trim()
|
||||
|
||||
if (!p || !n) {
|
||||
return false
|
||||
}
|
||||
|
||||
return n.startsWith(p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry structural parts an authoritative row cannot express.
|
||||
*
|
||||
@@ -250,8 +286,20 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
const nextText = chatMessageText(message).trim()
|
||||
const previousText = chatMessageText(previous)
|
||||
const previousVisibleText = textWithoutEmbeddedImages(previousText)
|
||||
const previousTrimmed = previousVisibleText.trim()
|
||||
let preserved = message
|
||||
|
||||
// #75825: resume can project an empty (or lagging) inflight assistant shell
|
||||
// at the same role-ordinal as the live stream row that still holds the
|
||||
// streamed text, reasoning and tool calls. Prefer that richer pending row
|
||||
// instead of painting the shell — otherwise the reply vanishes until
|
||||
// restart. Guarded to the same reply further along (see
|
||||
// localPendingSupersedes) so a different turn at the same ordinal cannot
|
||||
// hijack the slot.
|
||||
if (localPendingSupersedes(previous, message)) {
|
||||
return withAuthoritativeTurnState(previous, message)
|
||||
}
|
||||
|
||||
const sameText = nextText === previousVisibleText || nextText === previousText.trim()
|
||||
|
||||
// Mid-turn, the authoritative text has advanced past the cached copy by one
|
||||
@@ -260,12 +308,36 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
// for structural carry-over. Attachment refs and image re-appending stay on
|
||||
// the strict equality path — they reconcile a SETTLED row, and a growing
|
||||
// row is by definition not settled.
|
||||
//
|
||||
// Live-tail identity: structure-only same-turn carry is allowed only when
|
||||
// the *structure-bearing cached row* is still the in-flight stream
|
||||
// (pending / stream id / interim). Marking only the text-only next row
|
||||
// live is not enough — after compression a new live assistant can share a
|
||||
// role ordinal with an unrelated historical structured row and must not
|
||||
// inherit its reasoning/tool parts (#76444 review / salvage).
|
||||
const sameTurn =
|
||||
sameText ||
|
||||
(nextText.length > 0 && previousVisibleText.length > 0 && nextText.startsWith(previousVisibleText.trim()))
|
||||
(nextText.length > 0 && previousTrimmed.length > 0 && isStrictAnswerTextExtension(nextText, previousTrimmed)) ||
|
||||
(message.role === 'assistant' &&
|
||||
previous.role === 'assistant' &&
|
||||
hasStructuralParts(previous) &&
|
||||
!hasStructuralParts(message) &&
|
||||
isLiveTailRow(previous))
|
||||
|
||||
if (sameTurn) {
|
||||
preserved = preserveStructuralParts(preserved, previous)
|
||||
|
||||
// Never replace structured answer text with a non-extending flat dump.
|
||||
if (
|
||||
message.role === 'assistant' &&
|
||||
hasStructuralParts(previous) &&
|
||||
!hasStructuralParts(message) &&
|
||||
!isStrictAnswerTextExtension(nextText, previousVisibleText)
|
||||
) {
|
||||
const nonText = preserved.parts.filter(part => part.type !== 'text')
|
||||
const priorAnswer = previous.parts.filter(part => part.type === 'text')
|
||||
preserved = { ...preserved, parts: [...nonText, ...priorAnswer] }
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -315,8 +387,10 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
* history window. Preserve only the newest optimistic user row: compression
|
||||
* rewrites past context, so older `user-*` rows in a warm cache are stale
|
||||
* history, not in-flight work. The latest authoritative user confirms whether
|
||||
* that tail has persisted; any authoritative assistant at the same ordinal
|
||||
* supersedes the local stream.
|
||||
* that tail has persisted. An authoritative assistant at the same ordinal
|
||||
* supersedes the local stream only when it is at least as complete; an empty
|
||||
* or lagging inflight shell must not discard a fuller local pending reply
|
||||
* (#75825).
|
||||
*
|
||||
* Gateway bookkeeping markers (the model-switch / personality notices written
|
||||
* by tui_gateway/server.py) are persisted as role=user but are not user turns.
|
||||
@@ -328,6 +402,64 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
const isGatewaySystemMarker = (message: ChatMessage): boolean =>
|
||||
message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:')
|
||||
|
||||
/**
|
||||
* Does the row carry anything a viewer would miss — streamed answer text, or
|
||||
* the reasoning / tool-call structure the gateway's flat dump cannot express?
|
||||
* An empty inflight shell carries none of it.
|
||||
*/
|
||||
const hasStreamedContent = (message: ChatMessage): boolean =>
|
||||
chatMessageText(message).trim().length > 0 || hasStructuralParts(message)
|
||||
|
||||
/**
|
||||
* May the cached local row stand in for this authoritative assistant?
|
||||
*
|
||||
* Only for a live projection of the SAME reply that the local copy is further
|
||||
* along on: an empty shell, or text the local row strictly extends. Comparing
|
||||
* lengths alone lets an unrelated (merely longer) local row hijack the ordinal
|
||||
* — or the stream id — of a genuine stored reply. A retained failure snapshot
|
||||
* (`inflight.error`, projected with empty text) is never a shell: repainting it
|
||||
* from the local partial would hide the error and mark the turn healthy again.
|
||||
*/
|
||||
const localPendingSupersedes = (local: ChatMessage, authoritative: ChatMessage): boolean => {
|
||||
if (local.role !== 'assistant' || !isLiveTailRow(local)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLiveTailRow(authoritative) || authoritative.error) {
|
||||
return false
|
||||
}
|
||||
|
||||
const authoritativeText = chatMessageText(authoritative).trim()
|
||||
|
||||
if (!authoritativeText.length) {
|
||||
return hasStreamedContent(local)
|
||||
}
|
||||
|
||||
const localText = chatMessageText(local).trim()
|
||||
|
||||
return localText.length > authoritativeText.length && isStrictAnswerTextExtension(localText, authoritativeText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the cached row's content, but never its liveness. The renderer holds the
|
||||
* only copy of the streamed parts; the gateway remains the authority on whether
|
||||
* the turn is still running and on durable row identity — so a settled shell
|
||||
* must not repaint the reply as perpetually streaming.
|
||||
*/
|
||||
const withAuthoritativeTurnState = (local: ChatMessage, authoritative: ChatMessage): ChatMessage => {
|
||||
const merged: ChatMessage = { ...local, pending: authoritative.pending === true }
|
||||
|
||||
if (local.rowId === undefined && authoritative.rowId !== undefined) {
|
||||
merged.rowId = authoritative.rowId
|
||||
}
|
||||
|
||||
if (local.reactions === undefined && authoritative.reactions?.length) {
|
||||
merged.reactions = [...authoritative.reactions]
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function preserveLocalPendingTurnMessages(
|
||||
nextMessages: ChatMessage[],
|
||||
previousMessages: ChatMessage[]
|
||||
@@ -378,6 +510,9 @@ export function preserveLocalPendingTurnMessages(
|
||||
|
||||
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
|
||||
const preserved: ChatMessage[] = []
|
||||
// Authoritative id → richer local pending row. Replacing (not appending)
|
||||
// avoids painting both the empty inflight shell and the full stream bubble.
|
||||
const replacements = new Map<string, ChatMessage>()
|
||||
|
||||
for (const message of previousMessages) {
|
||||
if (isGatewaySystemMarker(message)) {
|
||||
@@ -392,7 +527,21 @@ export function preserveLocalPendingTurnMessages(
|
||||
const isPendingAssistant =
|
||||
message.role === 'assistant' && (message.pending === true || message.id.startsWith('assistant-stream-'))
|
||||
|
||||
if ((!isOptimisticUser && !isPendingAssistant) || nextIds.has(message.id)) {
|
||||
if (!isOptimisticUser && !isPendingAssistant) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Same id already present: still prefer a strictly more complete local
|
||||
// pending body over an empty/stale shell that reused the stream id.
|
||||
if (nextIds.has(message.id)) {
|
||||
if (isPendingAssistant) {
|
||||
const existing = nextMessages.find(candidate => candidate.id === message.id)
|
||||
|
||||
if (existing && localPendingSupersedes(message, existing)) {
|
||||
replacements.set(message.id, withAuthoritativeTurnState(message, existing))
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -403,19 +552,50 @@ export function preserveLocalPendingTurnMessages(
|
||||
if (
|
||||
isOptimisticUser &&
|
||||
latestAuthoritativeUser &&
|
||||
textWithoutImageRefs(chatMessageText(latestAuthoritativeUser)) === textWithoutImageRefs(chatMessageText(message))
|
||||
textWithoutReferenceLines(chatMessageText(latestAuthoritativeUser)) ===
|
||||
textWithoutReferenceLines(chatMessageText(message))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
|
||||
|
||||
if (authoritative) {
|
||||
if (isPendingAssistant) {
|
||||
// A settled stream row (`pending: false` after message.complete) whose reply
|
||||
// the authoritative transcript already carries under its committed id is
|
||||
// stale: ordinal pairing can't see it, because the commit shifted the row
|
||||
// one ordinal earlier, and re-appending it renders the same answer twice
|
||||
// (#70209). Only text-identical rows are dropped — a settled row the backend
|
||||
// has NOT committed yet is the only copy of that reply and must survive.
|
||||
if (
|
||||
isPendingAssistant &&
|
||||
message.pending !== true &&
|
||||
nextMessages.some(
|
||||
candidate =>
|
||||
candidate.role === 'assistant' &&
|
||||
textWithoutReferenceLines(chatMessageText(candidate)) === textWithoutReferenceLines(chatMessageText(message))
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (textWithoutImageRefs(chatMessageText(authoritative)) === textWithoutImageRefs(chatMessageText(message))) {
|
||||
if (authoritative) {
|
||||
if (isPendingAssistant) {
|
||||
// Keep the local pending row when it is the same reply further along
|
||||
// and the authoritative row is an empty projection shell or a prefix.
|
||||
// #75825
|
||||
if (!localPendingSupersedes(message, authoritative)) {
|
||||
continue
|
||||
}
|
||||
|
||||
replacements.set(authoritative.id, withAuthoritativeTurnState(message, authoritative))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
textWithoutReferenceLines(chatMessageText(authoritative)) ===
|
||||
textWithoutReferenceLines(chatMessageText(message))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -423,7 +603,10 @@ export function preserveLocalPendingTurnMessages(
|
||||
preserved.push(message)
|
||||
}
|
||||
|
||||
return preserved.length ? [...nextMessages, ...preserved] : nextMessages
|
||||
const withReplacements =
|
||||
replacements.size > 0 ? nextMessages.map(message => replacements.get(message.id) ?? message) : nextMessages
|
||||
|
||||
return preserved.length ? [...withReplacements, ...preserved] : withReplacements
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,7 +667,9 @@ export function appendLiveSessionProjection(
|
||||
}
|
||||
|
||||
const persistedInLatestRun = (text: string): boolean =>
|
||||
latestUserRun.some(message => textWithoutImageRefs(chatMessageText(message)) === textWithoutImageRefs(text))
|
||||
latestUserRun.some(
|
||||
message => textWithoutReferenceLines(chatMessageText(message)) === textWithoutReferenceLines(text)
|
||||
)
|
||||
|
||||
const inflightUserAlreadyPersisted = Boolean(inflightUser) && persistedInLatestRun(inflightUser)
|
||||
|
||||
@@ -514,15 +699,55 @@ export function appendLiveSessionProjection(
|
||||
|
||||
// Keep a pending assistant boundary even before the first delta when a
|
||||
// queued user turn follows it. This preserves the two distinct turns.
|
||||
//
|
||||
// When the *current live turn* already holds a structured mid-turn assistant
|
||||
// row (reasoning / tool-call from the live stream or journal), do NOT append
|
||||
// a pure-text projection of `inflight.assistant` — that flat dump re-renders
|
||||
// thinking as answer text and sandwiches the structured parts (#76444).
|
||||
// Only inspect the live tail after the latest user run — never a completed
|
||||
// historical tool-bearing reply earlier in the transcript (review feedback).
|
||||
const liveStreamId = `assistant-stream-${sessionId}`
|
||||
|
||||
const liveAssistantOfCurrentTurn = ((): ChatMessage | null => {
|
||||
const byStreamId = messages.find(message => message.id === liveStreamId)
|
||||
|
||||
if (byStreamId) {
|
||||
return byStreamId
|
||||
}
|
||||
|
||||
// Assistants after the latest user row belong to this turn's tail.
|
||||
if (latestUserIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index > latestUserIndex; index -= 1) {
|
||||
if (messages[index].role === 'assistant') {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
const turnAlreadyStructured = Boolean(
|
||||
liveAssistantOfCurrentTurn &&
|
||||
hasStructuralParts(liveAssistantOfCurrentTurn) &&
|
||||
isLiveTailRow(liveAssistantOfCurrentTurn)
|
||||
)
|
||||
|
||||
if (inflightAssistant || inflightStreaming || inflightError || (inflightUser && queuedUser)) {
|
||||
if (turnAlreadyStructured && !inflightError) {
|
||||
// Structure is authoritative; skip the text-only dump row.
|
||||
} else {
|
||||
projected.push({
|
||||
id: `assistant-stream-${sessionId}`,
|
||||
id: liveStreamId,
|
||||
role: 'assistant',
|
||||
parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [],
|
||||
pending: inflightStreaming,
|
||||
...(inflightError ? { error: inflightError } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (queuedUser) {
|
||||
projected.push({
|
||||
|
||||
@@ -157,3 +157,17 @@ const REFERENCE_PATTERN = /@(file|folder|url|image|tool|line|terminal|session):(
|
||||
export function referenceRe(): RegExp {
|
||||
return new RegExp(REFERENCE_PATTERN.source, 'g')
|
||||
}
|
||||
|
||||
/** Remove reference-only lines when comparing visible message text. */
|
||||
// Anchored + non-global: no shared `lastIndex` state (the hazard referenceRe()
|
||||
// exists to avoid), and hoisting skips a RegExp construction per call — this
|
||||
// runs on both sides of every message comparison in the reconcile loops.
|
||||
const REFERENCE_LINE_RE = new RegExp(`^(?:${REFERENCE_PATTERN.source})$`)
|
||||
|
||||
export function textWithoutReferenceLines(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.filter(line => !REFERENCE_LINE_RE.test(line.trimEnd()))
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { messageRenderWeight, RENDER_WEIGHT_CHARS } from '@/lib/render-weight'
|
||||
|
||||
import {
|
||||
buildGroups,
|
||||
firstVisibleGroupIndex,
|
||||
@@ -7,8 +9,7 @@ import {
|
||||
LIVE_TAIL_PARTS,
|
||||
liveTailStart,
|
||||
type MessageGroup,
|
||||
messageRenderWeight,
|
||||
RENDER_WEIGHT_CHARS
|
||||
resolveThreadScrollTarget
|
||||
} from './list'
|
||||
|
||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
||||
@@ -62,6 +63,53 @@ describe('buildGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveThreadScrollTarget', () => {
|
||||
const context = (scrollElement: Pick<HTMLElement, 'scrollTop'>) => ({
|
||||
contentElement: document.createElement('div'),
|
||||
scrollElement: scrollElement as HTMLElement
|
||||
})
|
||||
|
||||
it('settles when the browser clamps the requested bottom within half a CSS pixel', () => {
|
||||
let actualScrollTop = 0
|
||||
let writes = 0
|
||||
|
||||
const scrollElement = {
|
||||
get scrollTop() {
|
||||
return actualScrollTop
|
||||
},
|
||||
set scrollTop(value: number) {
|
||||
writes += 1
|
||||
actualScrollTop = value - 0.125
|
||||
}
|
||||
}
|
||||
|
||||
const target = 899
|
||||
|
||||
const requested = resolveThreadScrollTarget(target, context(scrollElement))
|
||||
scrollElement.scrollTop = requested
|
||||
const settled = resolveThreadScrollTarget(target, context(scrollElement))
|
||||
|
||||
expect(requested).toBe(target)
|
||||
expect(actualScrollTop).toBe(898.875)
|
||||
expect(settled).toBe(actualScrollTop)
|
||||
expect(actualScrollTop < settled).toBe(false)
|
||||
expect(writes).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps following while more than half a CSS pixel remains', () => {
|
||||
const scrollElement = { scrollTop: 898.25 }
|
||||
|
||||
expect(resolveThreadScrollTarget(899, context(scrollElement))).toBe(899)
|
||||
})
|
||||
|
||||
it('re-arms after streaming content increases the target', () => {
|
||||
const scrollElement = { scrollTop: 898.875 }
|
||||
|
||||
expect(resolveThreadScrollTarget(899, context(scrollElement))).toBe(898.875)
|
||||
expect(resolveThreadScrollTarget(999, context(scrollElement))).toBe(999)
|
||||
})
|
||||
})
|
||||
|
||||
describe('firstVisibleGroupIndex', () => {
|
||||
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
||||
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { messageRenderWeight } from '@/lib/render-weight'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
@@ -28,6 +29,8 @@ import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from '../message-render-boundary'
|
||||
|
||||
import { resolveShowEarlierAction, useTranscriptWindow } from './transcript-window'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
export type MessageGroup = { id: string; weight: number } & (
|
||||
@@ -47,8 +50,6 @@ export type MessageGroup = { id: string; weight: number } & (
|
||||
// a virtualizer — pure rendering, never touches scrollTop, so it can't fight
|
||||
// use-stick-to-bottom (the single scroll owner).
|
||||
const RENDER_BUDGET = 300
|
||||
export const RENDER_WEIGHT_CHARS = 512
|
||||
const MAX_MEASURED_MESSAGE_CHARS = RENDER_BUDGET * RENDER_WEIGHT_CHARS
|
||||
// On session switch, paint a small budget first (enough for the bottom turn(s)
|
||||
// the user actually sees after scroll-to-bottom), then bump to the full budget
|
||||
// in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render
|
||||
@@ -62,68 +63,18 @@ const MAX_MEASURED_MESSAGE_CHARS = RENDER_BUDGET * RENDER_WEIGHT_CHARS
|
||||
// blocks the click-to-paint path.
|
||||
const FIRST_PAINT_BUDGET = 20
|
||||
|
||||
const contentWeightCache = new WeakMap<object, number>()
|
||||
const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type'])
|
||||
// Browsers may quantize a requested scrollTop to a nearby device-pixel
|
||||
// boundary. use-stick-to-bottom otherwise compares the lower actual value to
|
||||
// the integer target forever, re-requesting the same instant scroll every
|
||||
// frame. Treat a subpixel remainder as achieved; larger gaps still follow new
|
||||
// streamed content normally.
|
||||
const SCROLL_TARGET_EPSILON_PX = 0.5
|
||||
|
||||
/**
|
||||
* Estimate the synchronous renderer cost of one assistant-ui message.
|
||||
*
|
||||
* The traversal is capped once a single message has enough text to consume a
|
||||
* complete render page. Going further cannot affect which whole turn crosses
|
||||
* the budget, and avoiding an unbounded walk matters for deeply nested tool
|
||||
* payloads. A WeakMap keeps settled history O(message count) on later store
|
||||
* updates; assistant-ui publishes a new content array when a streaming message
|
||||
* changes, so the live tail still receives a fresh weight.
|
||||
*/
|
||||
export function messageRenderWeight(content: unknown): number {
|
||||
if (!Array.isArray(content)) {
|
||||
return 1
|
||||
}
|
||||
export const resolveThreadScrollTarget: GetTargetScrollTop = (targetScrollTop, { scrollElement }) => {
|
||||
const currentScrollTop = scrollElement.scrollTop
|
||||
const remaining = targetScrollTop - currentScrollTop
|
||||
|
||||
const cached = contentWeightCache.get(content)
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const seen = new WeakSet<object>()
|
||||
const pending: unknown[] = [...content]
|
||||
let characters = 0
|
||||
|
||||
while (pending.length > 0 && characters < MAX_MEASURED_MESSAGE_CHARS) {
|
||||
const value = pending.pop()
|
||||
|
||||
if (typeof value === 'string') {
|
||||
characters += Math.min(value.length, MAX_MEASURED_MESSAGE_CHARS - characters)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const nested of value) {
|
||||
pending.push(nested)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (!NON_RENDERED_CONTENT_FIELDS.has(key)) {
|
||||
pending.push(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const weight = Math.max(1, content.length) + Math.ceil(characters / RENDER_WEIGHT_CHARS)
|
||||
contentWeightCache.set(content, weight)
|
||||
|
||||
return weight
|
||||
return remaining >= 0 && remaining <= SCROLL_TARGET_EPSILON_PX ? currentScrollTop : targetScrollTop
|
||||
}
|
||||
|
||||
interface ThreadMessageListProps {
|
||||
@@ -297,9 +248,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||
initial: 'instant',
|
||||
resize: 'instant'
|
||||
resize: 'instant',
|
||||
targetScrollTop: resolveThreadScrollTarget
|
||||
})
|
||||
|
||||
const { olderAvailable, expandWindow } = useTranscriptWindow()
|
||||
|
||||
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
|
||||
|
||||
// Cut the budget during RENDER, not in the post-commit layout effect. An
|
||||
@@ -525,11 +479,26 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
|
||||
// Prepend an older page while preserving the on-screen position. The user is
|
||||
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
|
||||
// won't fight this manual restore.
|
||||
// won't fight this manual restore. Spend the already-materialized DOM page
|
||||
// first; only when that is exhausted pull more messages out of the session
|
||||
// store (#55191).
|
||||
const showEarlier = useCallback(() => {
|
||||
const action = resolveShowEarlierAction(hiddenCount, olderAvailable)
|
||||
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
anchorBeforePrepend()
|
||||
|
||||
if (action === 'dom') {
|
||||
setRenderBudget(budget => budget + RENDER_BUDGET)
|
||||
}, [anchorBeforePrepend])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
expandWindow()
|
||||
}, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -538,7 +507,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
||||
restoreFromBottomRef.current = null
|
||||
}
|
||||
}, [scrollRef, renderBudget])
|
||||
// renderBudget covers DOM pages; groups.length covers store-window expands.
|
||||
}, [scrollRef, renderBudget, groups.length])
|
||||
|
||||
// The row array is memoized on the inputs the rows actually read. This
|
||||
// component re-renders on every isAtBottom flip — and use-stick-to-bottom
|
||||
@@ -632,7 +602,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{hiddenCount > 0 && (
|
||||
{(hiddenCount > 0 || olderAvailable) && (
|
||||
<button
|
||||
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={showEarlier}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { StatusPulse } from '@/components/ui/status-pulse'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundResume } from '@/store/background-delegation'
|
||||
@@ -130,7 +131,11 @@ export const ResponseLoadingIndicator: FC = () => {
|
||||
|
||||
return (
|
||||
<StatusRow data-slot="aui_response-loading" label={hint || t.assistant.thread.loadingResponse}>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<StatusPulse
|
||||
aria-hidden="true"
|
||||
className="dither inline-block size-3 rounded-[2px] text-midground/80"
|
||||
kind="opacity"
|
||||
/>
|
||||
{hint && <HintText>{hint}</HintText>}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
@@ -236,7 +241,11 @@ export const StreamStallIndicator: FC = () => {
|
||||
|
||||
return (
|
||||
<StatusRow data-slot="aui_stream-stall" label={hint || 'Hermes is thinking'}>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<StatusPulse
|
||||
aria-hidden="true"
|
||||
className="dither inline-block size-3 rounded-[2px] text-midground/80"
|
||||
kind="opacity"
|
||||
/>
|
||||
{hint && <HintText>{hint}</HintText>}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveShowEarlierAction } from './transcript-window'
|
||||
|
||||
describe('resolveShowEarlierAction', () => {
|
||||
it('spends the already-materialized DOM page first', () => {
|
||||
expect(resolveShowEarlierAction(3, true)).toBe('dom')
|
||||
expect(resolveShowEarlierAction(3, false)).toBe('dom')
|
||||
})
|
||||
|
||||
it('expands the store window once the DOM page is exhausted', () => {
|
||||
expect(resolveShowEarlierAction(0, true)).toBe('window')
|
||||
})
|
||||
|
||||
it('is a no-op when neither DOM nor store has older content', () => {
|
||||
expect(resolveShowEarlierAction(0, false)).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, type ReactNode, useContext } from 'react'
|
||||
|
||||
export interface TranscriptWindowValue {
|
||||
/** Store holds older messages the runtime window has not materialized. */
|
||||
olderAvailable: boolean
|
||||
/** Pull one more page of older messages out of the session store. */
|
||||
expandWindow: () => void
|
||||
}
|
||||
|
||||
const TranscriptWindowContext = createContext<TranscriptWindowValue>({
|
||||
olderAvailable: false,
|
||||
expandWindow: () => {}
|
||||
})
|
||||
|
||||
export function TranscriptWindowProvider({
|
||||
children,
|
||||
value
|
||||
}: {
|
||||
children: ReactNode
|
||||
value: TranscriptWindowValue
|
||||
}) {
|
||||
return <TranscriptWindowContext.Provider value={value}>{children}</TranscriptWindowContext.Provider>
|
||||
}
|
||||
|
||||
export function useTranscriptWindow(): TranscriptWindowValue {
|
||||
return useContext(TranscriptWindowContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Show earlier" pages the DOM budget first and only then asks the store for
|
||||
* more messages — the DOM page is already-materialized content, so spending it
|
||||
* first keeps the click cheap and the store window as small as it can be.
|
||||
*/
|
||||
export function resolveShowEarlierAction(
|
||||
hiddenCount: number,
|
||||
olderAvailable: boolean
|
||||
): 'dom' | 'window' | null {
|
||||
if (hiddenCount > 0) {
|
||||
return 'dom'
|
||||
}
|
||||
|
||||
return olderAvailable ? 'window' : null
|
||||
}
|
||||
@@ -48,14 +48,14 @@ describe('hovered zone retargets the tab verbs', () => {
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
tree.noteHoveredTreeGroup('grp-side')
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||
// The focused zone is untouched — the pointer won the target, not both.
|
||||
expect(activeOf('grp-main')).toBe('workspace')
|
||||
|
||||
// Same key, pointer moved: the other zone's slot 2.
|
||||
tree.noteHoveredTreeGroup('grp-main')
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||
expect(activeOf('grp-main')).toBe('session-tile:a')
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
||||
tree.noteHoveredTreeGroup('grp-main')
|
||||
tree.noteHoveredTreeGroup(null)
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||
expect(activeOf('grp-main')).toBe('workspace')
|
||||
})
|
||||
@@ -77,7 +77,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
tree.noteHoveredTreeGroup('grp-side')
|
||||
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBeTruthy()
|
||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||
expect(activeOf('grp-main')).toBe('workspace')
|
||||
|
||||
@@ -98,9 +98,9 @@ describe('hovered zone retargets the tab verbs', () => {
|
||||
tree.noteActiveTreeGroup('grp-side')
|
||||
tree.noteHoveredTreeGroup(null)
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBeTruthy()
|
||||
expect(activeOf('grp-side')).toBe('session-tile:b')
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
||||
tree.noteActiveTreeGroup(null)
|
||||
tree.noteHoveredTreeGroup(null)
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||
expect(activeOf('grp-main')).toBe('session-tile:a')
|
||||
expect(activeOf('grp-side')).toBe('session-tile:b')
|
||||
})
|
||||
@@ -138,7 +138,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
||||
tree.noteActiveTreeGroup(null)
|
||||
tree.noteHoveredTreeGroup('grp-files')
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||
expect(activeOf('grp-main')).toBe('session-tile:a')
|
||||
// ⌘W must not close the file tree from a rung that can't serve it.
|
||||
expect(activeOf('grp-files')).toBe('files')
|
||||
|
||||
@@ -317,6 +317,66 @@ export function movePane(
|
||||
return shapeSignature(next) === shapeSignature(root) ? root : next
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a SELECTION of panes together (multi-tab drag), preserving their strip
|
||||
* order. The lead pane lands exactly like a single `movePane` (center joins at
|
||||
* `before`, an edge opens the split); the rest stack in behind it. `activeId`
|
||||
* (the pressed tab) fronts in the landing group. Same no-op guard as
|
||||
* `movePane`: a drop that rebuilds the visible arrangement returns `root`.
|
||||
*/
|
||||
export function movePanes(
|
||||
root: LayoutNode,
|
||||
paneIds: readonly string[],
|
||||
target: { groupId: string; pos: DropPosition; before?: null | string },
|
||||
activeId: string = paneIds[0] ?? ''
|
||||
): LayoutNode {
|
||||
if (paneIds.length <= 1) {
|
||||
return paneIds.length === 1 ? movePane(root, paneIds[0], target) : root
|
||||
}
|
||||
|
||||
let without: LayoutNode | null = root
|
||||
|
||||
for (const id of paneIds) {
|
||||
without = without && removePane(without, id)
|
||||
}
|
||||
|
||||
// The selection was the whole tree, or removal dissolved the target zone
|
||||
// (the selection was its only occupancy) — nowhere left to land.
|
||||
if (!without || !findGroup(without, target.groupId)) {
|
||||
return root
|
||||
}
|
||||
|
||||
// The lead insert decides geometry; the rest stack into the lead's group at
|
||||
// the same slot (each lands before `before`, so the block keeps its order).
|
||||
// Only the lead activates — `insertAtGroup(activate)` would otherwise front
|
||||
// each follower in turn.
|
||||
const lead = paneIds[0]
|
||||
let next: LayoutNode | null = insertAtGroup(without, target.groupId, lead, target.pos, target.before)
|
||||
|
||||
for (let i = 1; next && i < paneIds.length; i++) {
|
||||
const leadGroup = findGroupOfPane(next, lead)
|
||||
|
||||
if (!leadGroup) {
|
||||
return root
|
||||
}
|
||||
|
||||
const before = target.pos === 'center' ? (target.before ?? null) : null
|
||||
next = insertAtGroup(next, leadGroup.id, paneIds[i], 'center', before, false)
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
return root
|
||||
}
|
||||
|
||||
const landed = findGroupOfPane(next, lead)
|
||||
|
||||
if (landed && landed.panes.includes(activeId)) {
|
||||
next = setActivePane(next, landed.id, activeId)
|
||||
}
|
||||
|
||||
return shapeSignature(next) === shapeSignature(root) ? root : next
|
||||
}
|
||||
|
||||
/** Group ids of every leaf under a node, in tree order. */
|
||||
export function groupLeafIds(node: LayoutNode): string[] {
|
||||
return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds)
|
||||
@@ -347,26 +407,32 @@ function findCover(node: LayoutNode, set: Set<string>): LayoutNode | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* FancyZones span: merge the highlighted zones into ONE group holding
|
||||
* `paneId`, absorbing any panes that lived in those zones as tabs. Only works
|
||||
* when the highlighted set forms a rectangular subtree (it always does for a
|
||||
* combined zone range on a guillotine tree); returns null otherwise so the
|
||||
* caller can fall back to a single-zone drop.
|
||||
* FancyZones span: merge the highlighted zones into ONE group holding the
|
||||
* dragged pane block (one pane, or a multi-tab selection in strip order),
|
||||
* absorbing any panes that lived in those zones as tabs. Only works when the
|
||||
* highlighted set forms a rectangular subtree (it always does for a combined
|
||||
* zone range on a guillotine tree); returns null otherwise so the caller can
|
||||
* fall back to a single-zone drop.
|
||||
*/
|
||||
export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null {
|
||||
export function mergeZonesWithPane(
|
||||
root: LayoutNode,
|
||||
groupIds: string[],
|
||||
paneId: string | readonly string[]
|
||||
): LayoutNode | null {
|
||||
const paneIds = typeof paneId === 'string' ? [paneId] : [...paneId]
|
||||
const set = new Set(groupIds)
|
||||
|
||||
if (set.size <= 1 || !findCover(root, set)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Panes from the merged zones (tree order), minus the dragged one.
|
||||
// Panes from the merged zones (tree order), minus the dragged block.
|
||||
const panesInSet: string[] = []
|
||||
|
||||
const collect = (n: LayoutNode) => {
|
||||
if (n.type === 'group') {
|
||||
if (set.has(n.id)) {
|
||||
panesInSet.push(...n.panes.filter(p => p !== paneId))
|
||||
panesInSet.push(...n.panes.filter(p => !paneIds.includes(p)))
|
||||
}
|
||||
} else {
|
||||
n.children.forEach(collect)
|
||||
@@ -375,16 +441,19 @@ export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId:
|
||||
|
||||
collect(root)
|
||||
|
||||
// If the dragged pane lives OUTSIDE the merged set, pull it from its origin
|
||||
// Any dragged pane living OUTSIDE the merged set is pulled from its origin
|
||||
// first (leaving that origin an empty zone). Inside the set it's absorbed.
|
||||
const origin = findGroupOfPane(root, paneId)
|
||||
let working = root
|
||||
|
||||
for (const id of paneIds) {
|
||||
const origin = findGroupOfPane(working, id)
|
||||
|
||||
if (origin && !set.has(origin.id)) {
|
||||
working = removePane(root, paneId) ?? root
|
||||
working = removePane(working, id) ?? working
|
||||
}
|
||||
}
|
||||
|
||||
const merged = group([paneId, ...panesInSet])
|
||||
const merged = group([...paneIds, ...panesInSet])
|
||||
|
||||
const replace = (n: LayoutNode): LayoutNode => {
|
||||
if (sameSet(groupLeafIds(n), set)) {
|
||||
@@ -409,16 +478,23 @@ export function setActivePane(root: LayoutNode, groupId: string, paneId: string)
|
||||
return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g))
|
||||
}
|
||||
|
||||
/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */
|
||||
export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode {
|
||||
/** Reorder a block of panes within a group as one unit (browser-tab drag
|
||||
* semantics; a single-tab drag is a one-id block): the block lands at
|
||||
* `toIndex` among the remaining tabs, keeping its own order. */
|
||||
export function reorderPanesInGroup(
|
||||
root: LayoutNode,
|
||||
groupId: string,
|
||||
paneIds: readonly string[],
|
||||
toIndex: number
|
||||
): LayoutNode {
|
||||
return mapGroups(root, g => {
|
||||
if (g.id !== groupId || !g.panes.includes(paneId)) {
|
||||
if (g.id !== groupId || !paneIds.every(p => g.panes.includes(p))) {
|
||||
return g
|
||||
}
|
||||
|
||||
const without = g.panes.filter(p => p !== paneId)
|
||||
const without = g.panes.filter(p => !paneIds.includes(p))
|
||||
const index = Math.max(0, Math.min(without.length, toIndex))
|
||||
const panes = [...without.slice(0, index), paneId, ...without.slice(index)]
|
||||
const panes = [...without.slice(0, index), ...paneIds, ...without.slice(index)]
|
||||
|
||||
return { ...g, panes }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { findGroup, findGroupOfPane, group, mergeZonesWithPane, movePanes, reorderPanesInGroup, split } from './model'
|
||||
import { $tabSelection, clearTabSelection, selectionFor, selectTabRange, toggleTabSelected } from './tab-selection'
|
||||
|
||||
describe('movePanes (multi-tab drag)', () => {
|
||||
it('stacks the whole block into the target group at the divider slot, in strip order', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b', 'c'], { active: 'a', id: 'left' }),
|
||||
group(['x', 'y'], { active: 'x', id: 'right' })
|
||||
])
|
||||
|
||||
const next = movePanes(tree, ['a', 'c'], { before: 'y', groupId: 'right', pos: 'center' }, 'c')
|
||||
const right = findGroup(next, 'right')
|
||||
|
||||
expect(right).toMatchObject({ panes: ['x', 'a', 'c', 'y'], active: 'c' })
|
||||
expect(findGroup(next, 'left')).toMatchObject({ panes: ['b'] })
|
||||
})
|
||||
|
||||
it('an edge drop opens ONE split holding the block as tabs, pressed tab fronted', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b', 'c'], { active: 'a', id: 'left' }),
|
||||
group(['x'], { active: 'x', id: 'right' })
|
||||
])
|
||||
|
||||
const next = movePanes(tree, ['b', 'c'], { groupId: 'right', pos: 'bottom' }, 'b')
|
||||
const landed = findGroupOfPane(next, 'b')
|
||||
|
||||
expect(landed).toMatchObject({ panes: ['b', 'c'], active: 'b' })
|
||||
// One new zone, not one per pane: b and c share a group.
|
||||
expect(findGroupOfPane(next, 'c')).toBe(landed)
|
||||
expect(findGroup(next, 'left')).toMatchObject({ panes: ['a'] })
|
||||
})
|
||||
|
||||
it('dragging a whole zone into a sibling dissolves the source zone', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b'], { active: 'a', id: 'left' }),
|
||||
group(['x'], { active: 'x', id: 'right' })
|
||||
])
|
||||
|
||||
const next = movePanes(tree, ['a', 'b'], { groupId: 'right', pos: 'center' }, 'a')
|
||||
|
||||
expect(next).toMatchObject({ type: 'group', panes: ['x', 'a', 'b'], active: 'a' })
|
||||
})
|
||||
|
||||
it('is a no-op when removal dissolves the target zone itself', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b'], { active: 'a', id: 'left' }),
|
||||
group(['x'], { active: 'x', id: 'right' })
|
||||
])
|
||||
|
||||
// Dropping right's only pane (as part of a block) "into right" — the
|
||||
// target vanishes with the removal, so nothing moves.
|
||||
expect(movePanes(tree, ['x', 'a'], { groupId: 'right', pos: 'center' }, 'x')).toBe(tree)
|
||||
})
|
||||
|
||||
it('falls back to single-pane semantics for a one-id block', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b'], { active: 'a', id: 'left' }),
|
||||
group(['x'], { active: 'x', id: 'right' })
|
||||
])
|
||||
|
||||
const next = movePanes(tree, ['b'], { groupId: 'right', pos: 'center' })
|
||||
|
||||
expect(findGroup(next, 'right')).toMatchObject({ panes: ['x', 'b'], active: 'b' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderPanesInGroup (block reorder)', () => {
|
||||
it('moves a selection as one unit, preserving its internal order', () => {
|
||||
const tree = group(['a', 'b', 'c', 'd'], { active: 'a', id: 'g' })
|
||||
|
||||
// [a, c] to the end: index 2 among the remaining [b, d].
|
||||
expect(reorderPanesInGroup(tree, 'g', ['a', 'c'], 2)).toMatchObject({ panes: ['b', 'd', 'a', 'c'] })
|
||||
})
|
||||
|
||||
it('leaves the group alone when any id is missing (stale selection)', () => {
|
||||
const tree = group(['a', 'b'], { active: 'a', id: 'g' })
|
||||
|
||||
expect(reorderPanesInGroup(tree, 'g', ['a', 'ghost'], 0)).toBe(tree)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeZonesWithPane with a multi-tab block', () => {
|
||||
it('merges the span into one group led by the block in strip order', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b'], { active: 'a', id: 'left' }),
|
||||
split('column', [group(['x'], { active: 'x', id: 'mid' }), group(['y'], { active: 'y', id: 'right' })])
|
||||
])
|
||||
|
||||
const next = mergeZonesWithPane(tree, ['mid', 'right'], ['a', 'b'])
|
||||
|
||||
expect(next).toMatchObject({ type: 'group', panes: ['a', 'b', 'x', 'y'] })
|
||||
})
|
||||
|
||||
it('returns null for a non-rectangular span (caller falls back to a single-zone drop)', () => {
|
||||
const tree = split('row', [
|
||||
group(['a', 'b'], { active: 'a', id: 'left' }),
|
||||
group(['x'], { active: 'x', id: 'mid' }),
|
||||
group(['y'], { active: 'y', id: 'right' })
|
||||
])
|
||||
|
||||
expect(mergeZonesWithPane(tree, ['mid', 'right'], ['a', 'b'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab selection (Chrome grammar)', () => {
|
||||
it('⌥-click seeds with the active tab, toggles, and dissolves at ≤1', () => {
|
||||
clearTabSelection()
|
||||
toggleTabSelected('g', 'c', 'a')
|
||||
|
||||
expect([...$tabSelection.get()!.ids].sort()).toEqual(['a', 'c'])
|
||||
|
||||
toggleTabSelected('g', 'c', 'a')
|
||||
|
||||
expect($tabSelection.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('shift-click ranges from the anchor and re-ranges on the next shift-click', () => {
|
||||
clearTabSelection()
|
||||
|
||||
const order = ['a', 'b', 'c', 'd']
|
||||
selectTabRange('g', order, 'c', 'a')
|
||||
|
||||
expect(selectionFor('g', order, 'b')).toEqual(['a', 'b', 'c'])
|
||||
|
||||
// Anchor holds at a (Chrome): re-ranging to d replaces, not extends.
|
||||
selectTabRange('g', order, 'd', 'a')
|
||||
|
||||
expect(selectionFor('g', order, 'd')).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('selectionFor answers null for an unselected pressed tab and drops stale ids', () => {
|
||||
clearTabSelection()
|
||||
toggleTabSelected('g', 'b', 'a')
|
||||
toggleTabSelected('g', 'c', 'a')
|
||||
|
||||
// Pressed tab outside the selection = single-tab drag.
|
||||
expect(selectionFor('g', ['a', 'b', 'c', 'd'], 'd')).toBeNull()
|
||||
// 'a' closed since: it silently falls out, strip order preserved.
|
||||
expect(selectionFor('g', ['b', 'c', 'd'], 'b')).toEqual(['b', 'c'])
|
||||
// Another zone never sees it.
|
||||
expect(selectionFor('other', ['b', 'c'], 'b')).toBeNull()
|
||||
|
||||
clearTabSelection()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Right-click a tab -> Reload remounts THAT pane's content: its epoch (the
|
||||
// React key the zone renderer hands the contribution) advances, and no other
|
||||
// pane's does. The layout tree itself must never move.
|
||||
|
||||
describe('reloadTreePane', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('advances only the reloaded pane epoch and leaves the tree alone', async () => {
|
||||
const tree = await import('@/components/pane-shell/tree/store')
|
||||
const model = await import('@/components/pane-shell/tree/model')
|
||||
|
||||
tree.declareDefaultTree(model.group(['workspace', 'files'], { active: 'workspace', id: 'grp-main' }))
|
||||
|
||||
const before = tree.$layoutTree.get()
|
||||
|
||||
tree.reloadTreePane('workspace')
|
||||
tree.reloadTreePane('workspace')
|
||||
|
||||
expect(tree.$treePaneEpochs.get().workspace).toBe(2)
|
||||
expect(tree.$treePaneEpochs.get().files).toBeUndefined()
|
||||
expect(tree.$layoutTree.get()).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,8 @@ import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder'
|
||||
|
||||
import type { DropPosition } from '../model'
|
||||
import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store'
|
||||
import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePanes, reorderTreePanes } from '../store'
|
||||
import { clearTabSelection } from '../tab-selection'
|
||||
import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine'
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
@@ -96,10 +97,18 @@ const stripSlots = (strip: HTMLElement): StripSlot[] =>
|
||||
})
|
||||
|
||||
/** Insertion slot from the pointer x against the OTHER tabs' midpoints:
|
||||
* stack BEFORE the returned pane id (`null` = append). */
|
||||
export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } {
|
||||
* stack BEFORE the returned pane id (`null` = append). `exclude` is the
|
||||
* dragged tab — or the whole selection on a multi-tab drag, so the block
|
||||
* can't target a slot inside itself. */
|
||||
export function slotBefore(
|
||||
slots: StripSlot[],
|
||||
x: number,
|
||||
exclude: readonly string[] | string = ''
|
||||
): { before: null | string } {
|
||||
const excluded = typeof exclude === 'string' ? [exclude] : exclude
|
||||
|
||||
for (const slot of slots) {
|
||||
if (slot.id === excludePaneId) {
|
||||
if (excluded.includes(slot.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -422,7 +431,11 @@ export function startPaneDrag(
|
||||
onTap?: () => void,
|
||||
reorder?: ReorderContext,
|
||||
double?: DoubleTapContext,
|
||||
ghostLabel?: string
|
||||
ghostLabel?: string,
|
||||
/** Multi-tab selection riding this drag (strip order, includes `paneId`).
|
||||
* The whole block moves/reorders together; `paneId` stays the pressed tab
|
||||
* (it fronts at the destination). */
|
||||
selection?: readonly string[]
|
||||
) {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
@@ -431,17 +444,28 @@ export function startPaneDrag(
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// The moving block: the selection when the pressed tab rides one, else just
|
||||
// the pressed tab. Order is strip order (selectionFor guarantees it).
|
||||
const moving: readonly string[] = selection && selection.length > 1 ? selection : [paneId]
|
||||
|
||||
const highlighted = new HighlightedZones()
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
let mode: 'reorder' | 'zone' | null = null
|
||||
let dimmed: HTMLElement | null = null
|
||||
let dimmed: HTMLElement[] = []
|
||||
|
||||
const markSource = () => {
|
||||
// The dragged tab dims for the drag's life — the divider says where it
|
||||
// GOES, the dim says what MOVES. No live shuffle (placement-on-release).
|
||||
dimmed ??= reorder?.strip.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null
|
||||
dimmed?.style.setProperty('opacity', '0.45')
|
||||
// Every dragged tab dims for the drag's life — the divider says where they
|
||||
// GO, the dim says what MOVES. No live shuffle (placement-on-release).
|
||||
if (dimmed.length === 0 && reorder) {
|
||||
dimmed = moving
|
||||
.map(id => reorder.strip.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(id)}"]`))
|
||||
.filter((el): el is HTMLElement => el !== null)
|
||||
}
|
||||
|
||||
for (const el of dimmed) {
|
||||
el.style.setProperty('opacity', '0.45')
|
||||
}
|
||||
}
|
||||
|
||||
const enterZoneMode = () => {
|
||||
@@ -494,7 +518,7 @@ export function startPaneDrag(
|
||||
groupId: reorder!.groupId,
|
||||
groupIds: [reorder!.groupId],
|
||||
pos: 'center',
|
||||
stack: slotBefore(reorderStrip().slots, x, paneId)
|
||||
stack: slotBefore(reorderStrip().slots, x, moving)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +549,7 @@ export function startPaneDrag(
|
||||
const strip =
|
||||
groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null
|
||||
|
||||
const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined
|
||||
const stack = strip ? slotBefore(strip.slots, x, moving) : undefined
|
||||
|
||||
const pos: DropPosition = stack
|
||||
? 'center'
|
||||
@@ -537,17 +561,26 @@ export function startPaneDrag(
|
||||
},
|
||||
|
||||
onCommit(hint) {
|
||||
// A multi-tab selection is spent by a LANDED drop (reorder or zone) —
|
||||
// a deny-area release keeps it, so a missed drop can just be retried.
|
||||
const spendSelection = () => {
|
||||
if (moving.length > 1) {
|
||||
clearTabSelection()
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'reorder' && reorder && hint?.stack !== undefined) {
|
||||
// Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there).
|
||||
// Slot -> index among the OTHER tabs (the block re-inserts there).
|
||||
const others = [...reorder.strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
|
||||
.map(el => el.dataset.treeTab)
|
||||
.filter((id): id is string => Boolean(id) && id !== paneId)
|
||||
.filter((id): id is string => Boolean(id) && !moving.includes(id!))
|
||||
|
||||
const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length
|
||||
|
||||
if (toIndex >= 0) {
|
||||
reorderTreePane(reorder.groupId, paneId, toIndex)
|
||||
reorderTreePanes(reorder.groupId, moving, toIndex)
|
||||
reorderCommitHaptic()
|
||||
spendSelection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,18 +592,28 @@ export function startPaneDrag(
|
||||
const targets = hint?.groupIds ?? []
|
||||
|
||||
if (targets.length > 1) {
|
||||
// Shift-span: merge the highlighted zones, dropping the pane across them.
|
||||
mergeTreeZones([...targets], paneId, hint?.groupId ?? null)
|
||||
// Shift-span: merge the highlighted zones, dropping the block across them.
|
||||
mergeTreeZones([...targets], moving, hint?.groupId ?? null)
|
||||
spendSelection()
|
||||
} else if (hint?.groupId) {
|
||||
// strip = stack at the divider slot; center = join the stack;
|
||||
// an edge = split the zone and land there.
|
||||
moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before })
|
||||
// an edge = split the zone and land there. The whole selection
|
||||
// rides — the pressed tab fronts at the destination.
|
||||
moveTreePanes(
|
||||
moving,
|
||||
{ groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before },
|
||||
paneId
|
||||
)
|
||||
spendSelection()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onEnd() {
|
||||
dimmed?.style.removeProperty('opacity')
|
||||
for (const el of dimmed) {
|
||||
el.style.removeProperty('opacity')
|
||||
}
|
||||
|
||||
highlighted.reset()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
$newSessionTabAction,
|
||||
$panesWithCloser,
|
||||
$treeDragging,
|
||||
$treePaneEpochs,
|
||||
activateTreePane,
|
||||
closeAllTreeTabs,
|
||||
closeOtherTreeTabs,
|
||||
@@ -42,12 +43,21 @@ import {
|
||||
isCollapsePane,
|
||||
isSessionStripPane,
|
||||
noteActiveTreeGroup,
|
||||
reloadTreePane,
|
||||
restoreTreePane,
|
||||
SESSION_TILE_DRAG,
|
||||
setTreeGroupHeaderHidden,
|
||||
setTreeGroupMinimized,
|
||||
treeTabCloseTargets
|
||||
} from '../store'
|
||||
import {
|
||||
$tabSelection,
|
||||
clearTabSelection,
|
||||
isToggleSelectClick,
|
||||
selectionFor,
|
||||
selectTabRange,
|
||||
toggleTabSelected
|
||||
} from '../tab-selection'
|
||||
|
||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
@@ -96,6 +106,12 @@ function ZoneMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderActionItem(kit, {
|
||||
icon: 'refresh',
|
||||
label: t.zones.reload,
|
||||
onSelect: () => reloadTreePane(targetPane())
|
||||
})}
|
||||
<kit.Separator />
|
||||
{paneId !== undefined &&
|
||||
renderActionItem(kit, {
|
||||
icon: 'close',
|
||||
@@ -178,6 +194,12 @@ export function TreeGroup({
|
||||
const narrow = useStore($narrowViewport)
|
||||
const newSessionTabAction = useStore($newSessionTabAction)
|
||||
const panesWithCloser = useStore($panesWithCloser)
|
||||
// Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but
|
||||
// the one holding it, so this subscription is quiet during normal use.
|
||||
const tabSelection = useStore($tabSelection)
|
||||
// Reload epochs: only an explicit tab-menu Reload writes here, so this
|
||||
// subscription costs nothing on a normal render.
|
||||
const paneEpochs = useStore($treePaneEpochs)
|
||||
|
||||
const paneFor = (id: string) => panes.find(p => p.id === id)
|
||||
|
||||
@@ -424,6 +446,7 @@ export function TreeGroup({
|
||||
const chrome = paneChrome(paneFor(paneId))
|
||||
const closeable = closeableTab(paneId)
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId)
|
||||
|
||||
const tab = (
|
||||
<PaneTab
|
||||
@@ -433,11 +456,36 @@ export function TreeGroup({
|
||||
key={paneId}
|
||||
onClose={closeable ? () => closeTab(paneId) : undefined}
|
||||
onPointerDown={e => {
|
||||
// Chrome's tab-selection grammar, ahead of activate/drag:
|
||||
// Shift-click ranges from the anchor, ⌥-click (Ctrl-click
|
||||
// off-Mac) toggles. Neither activates nor starts a drag —
|
||||
// the press IS the selection edit. ⌘-click stays close
|
||||
// (PaneTab claims it first) and ⌃-click stays the macOS
|
||||
// context menu.
|
||||
if (e.button === 0 && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
selectTabRange(node.id, shown, paneId, activeId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isToggleSelectClick(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleTabSelected(node.id, paneId, activeId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Tabs ACTIVATE (restoring a collapsed group). Minimize
|
||||
// lives on the chevron / single-pane label — overloading
|
||||
// the active tab made double-click a minimize/restore/hide
|
||||
// lottery.
|
||||
// lottery. A plain click also collapses any multi-tab
|
||||
// selection back to the one tab (Chrome semantics).
|
||||
const onTap = () => {
|
||||
clearTabSelection()
|
||||
|
||||
if (node.minimized) {
|
||||
restoreTreePane(paneId)
|
||||
}
|
||||
@@ -454,6 +502,26 @@ export function TreeGroup({
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// Dragging a SELECTED tab carries the whole selection as
|
||||
// one block through the generic pane move — a multi-tab
|
||||
// drag outranks the pane's own tab drag (the session drop
|
||||
// language is single-session).
|
||||
const dragSelection = selectionFor(node.id, shown, paneId)
|
||||
|
||||
if (dragSelection) {
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
hideHeaderDoubleTap,
|
||||
t.zones.tabCount(dragSelection.length),
|
||||
dragSelection
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A pane may own its tab drag (a session tab speaks the
|
||||
// session drop language — link/stack/split); `false` defers
|
||||
// to the generic pane move (the workspace tab on a fresh
|
||||
@@ -470,6 +538,7 @@ export function TreeGroup({
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
selected={isSelected}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
{chrome.tabLead ? (
|
||||
@@ -557,9 +626,14 @@ export function TreeGroup({
|
||||
// can gate its hot (per-token) subscriptions while hidden;
|
||||
// the group id identifies the ZONE it lives in, for state
|
||||
// that is per-zone rather than per-tab (composer pop-out).
|
||||
// The reload epoch keys the CONTENT, not this layer: a
|
||||
// Reload remounts the contribution (effects re-run, state
|
||||
// resets) while the layer — and every other tab — stays.
|
||||
<PaneGroupContext.Provider value={node.id}>
|
||||
<PaneVisibleContext.Provider value={isActive}>
|
||||
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
|
||||
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
|
||||
{pane.render()}
|
||||
</ContribBoundary>
|
||||
</PaneVisibleContext.Provider>
|
||||
</PaneGroupContext.Provider>
|
||||
) : (
|
||||
|
||||
@@ -28,9 +28,10 @@ import {
|
||||
mergeZonesWithPane as mergeZonesWithPaneOp,
|
||||
mirrorTreeHorizontal,
|
||||
movePane as movePaneOp,
|
||||
movePanes as movePanesOp,
|
||||
normalize,
|
||||
removePane,
|
||||
reorderPaneInGroup as reorderPaneInGroupOp,
|
||||
reorderPanesInGroup as reorderPanesInGroupOp,
|
||||
setActivePane as setActivePaneOp,
|
||||
setGroupHeaderHidden as setGroupHeaderHiddenOp,
|
||||
setGroupMinimized,
|
||||
@@ -450,6 +451,22 @@ export function treeTabCloseTargets(paneId: string): { all: number; others: numb
|
||||
return { all: others.length + (isUncloseablePane(paneId) ? 0 : 1), others: others.length, right: right.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* RELOAD — a pane's remount counter, the tab menu's Reload (browser parity:
|
||||
* right-click a tab, reload what's in it). The zone renderer keys a pane's
|
||||
* body layer on its epoch, so bumping it unmounts the contribution and mounts
|
||||
* it fresh — data effects re-run, measurements are retaken — while the layout
|
||||
* tree, the tab's position, and every other tab stay exactly as they were.
|
||||
* Absent until a pane is first reloaded (no key churn on a normal boot).
|
||||
*/
|
||||
export const $treePaneEpochs = atom<Readonly<Record<string, number>>>({})
|
||||
|
||||
export function reloadTreePane(paneId: string): void {
|
||||
const epochs = $treePaneEpochs.get()
|
||||
|
||||
$treePaneEpochs.set({ ...epochs, [paneId]: (epochs[paneId] ?? 0) + 1 })
|
||||
}
|
||||
|
||||
/** Close a tab the way its kind expects: a tool panel leaves the strip (and
|
||||
* syncs its toggle), everything else routes through its owning Close. */
|
||||
export function closeTabPane(paneId: string) {
|
||||
@@ -533,26 +550,29 @@ function shownPanesInGroup(group: { panes: readonly string[] }): string[] {
|
||||
/** ⌘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
|
||||
* instead of dead-ending. Returns false so the caller falls back to its
|
||||
* instead of dead-ending. Returns the activated pane id — the caller needs to
|
||||
* know when the slot landed on the workspace tab (a full page covering it
|
||||
* must also route back to the chat) — or null so it falls back to its
|
||||
* default (profile switch) when no zone qualifies. */
|
||||
export function activateTreeTabSlot(slot: number): boolean {
|
||||
export function activateTreeTabSlot(slot: number): null | string {
|
||||
const group = tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2)
|
||||
const panes = group ? shownPanesInGroup(group) : []
|
||||
|
||||
if (!group || slot < 1 || slot > panes.length) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
activateTreePane(group.id, panes[slot - 1])
|
||||
|
||||
return true
|
||||
return panes[slot - 1]
|
||||
}
|
||||
|
||||
/** ⌃Tab / ⌃⇧Tab: cycle the target zone's *visible* tabs (wrapping) — the first
|
||||
* of hovered / focused / workspace that is a chat strip with ≥2 shown tabs.
|
||||
* Returns false so the caller falls back to the recent-session switcher when
|
||||
* no zone qualifies. */
|
||||
export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
||||
* Returns the activated pane id (see `activateTreeTabSlot` — landing on the
|
||||
* workspace under a full page must route back to the chat), or null so the
|
||||
* caller falls back to the recent-session switcher when no zone qualifies. */
|
||||
export function cycleTreeTabInFocusedZone(direction: 1 | -1): null | string {
|
||||
const group = tabTargetGroup(candidate => {
|
||||
const shown = shownPanesInGroup(candidate)
|
||||
|
||||
@@ -560,7 +580,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
||||
})
|
||||
|
||||
if (!group) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
const panes = shownPanesInGroup(group)
|
||||
@@ -579,7 +599,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
||||
setTreeGroupHeaderHidden(group.id, false)
|
||||
}
|
||||
|
||||
return true
|
||||
return nextId
|
||||
}
|
||||
|
||||
/** Remove a pane from the tree WITHOUT a dismissal record — for surfaces
|
||||
@@ -1228,25 +1248,61 @@ export function applyTree(tree: LayoutNode, presetId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls
|
||||
* back to a single-zone move at `fallbackGroupId` when the set can't merge
|
||||
* (non-rectangular selection).
|
||||
* Move a multi-tab SELECTION in one commit (drag any selected tab): the lead
|
||||
* pane takes the drop geometry, the rest stack in behind it in strip order,
|
||||
* and `activeId` (the pressed tab) fronts in the landing group.
|
||||
*/
|
||||
export function mergeTreeZones(groupIds: string[], paneId: string, fallbackGroupId: string | null) {
|
||||
export function moveTreePanes(
|
||||
paneIds: readonly string[],
|
||||
target: { groupId: string; pos: DropPosition; before?: null | string },
|
||||
activeId?: string
|
||||
) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = movePanesOp(tree, paneIds, target, activeId)
|
||||
|
||||
if (next !== tree) {
|
||||
commit(next)
|
||||
markActivePreset('custom')
|
||||
|
||||
for (const paneId of paneIds) {
|
||||
markPaneUserPlaced(paneId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls
|
||||
* back to a single-zone move at `fallbackGroupId` when the set can't merge
|
||||
* (non-rectangular selection).
|
||||
*/
|
||||
export function mergeTreeZones(
|
||||
groupIds: string[],
|
||||
paneId: string | readonly string[],
|
||||
fallbackGroupId: null | string
|
||||
) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
|
||||
const paneIds = typeof paneId === 'string' ? [paneId] : paneId
|
||||
const merged = mergeZonesWithPaneOp(tree, groupIds, paneId)
|
||||
|
||||
if (merged) {
|
||||
commit(merged)
|
||||
markActivePreset('custom')
|
||||
markPaneUserPlaced(paneId)
|
||||
|
||||
for (const id of paneIds) {
|
||||
markPaneUserPlaced(id)
|
||||
}
|
||||
} else if (fallbackGroupId) {
|
||||
moveTreePane(paneId, { groupId: fallbackGroupId, pos: 'center' })
|
||||
moveTreePanes(paneIds, { groupId: fallbackGroupId, pos: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,11 +1314,13 @@ export function activateTreePane(groupId: string, paneId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderTreePane(groupId: string, paneId: string, toIndex: number) {
|
||||
/** Reorder a tab block (multi-tab selection, or a single tab) within its
|
||||
* group's strip — the block keeps its own order. */
|
||||
export function reorderTreePanes(groupId: string, paneIds: readonly string[], toIndex: number) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (tree) {
|
||||
commit(reorderPaneInGroupOp(tree, groupId, paneId, toIndex))
|
||||
commit(reorderPanesInGroupOp(tree, groupId, paneIds, toIndex))
|
||||
markActivePreset('custom')
|
||||
}
|
||||
}
|
||||
@@ -1577,7 +1635,7 @@ export function resetLayoutTree() {
|
||||
}
|
||||
|
||||
// Dev hook for automation.
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
if ((import.meta.env.DEV || import.meta.env.VITE_PERF_PROBE === '1') && typeof window !== 'undefined') {
|
||||
;(window as unknown as Record<string, unknown>).__HERMES_LAYOUT_TREE__ = {
|
||||
close: closeTreePane,
|
||||
dismissed: () => $dismissedPanes.get(),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Multi-tab selection on a zone's tab strip — Chrome's tab-selection grammar:
|
||||
*
|
||||
* - ⌥-click (Ctrl-click off-Mac) → toggle the tab in/out of the selection;
|
||||
* - Shift-click → select the range from the anchor
|
||||
* (the last explicitly clicked tab, else
|
||||
* the active one) to the clicked tab;
|
||||
* - plain click → collapse back to a single tab.
|
||||
*
|
||||
* ⌘-click stays CLOSE (middle-click.ts) and ⌃-click stays the macOS context
|
||||
* menu, so the toggle chord is ⌥ on Mac / Ctrl elsewhere. One selection at a
|
||||
* time, scoped to one zone — dragging any selected tab carries the whole set
|
||||
* (drag-session resolves it), and ids are validated against the strip's
|
||||
* current tabs at use time, so closed/moved panes fall out on their own.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export interface TabSelection {
|
||||
groupId: string
|
||||
ids: ReadonlySet<string>
|
||||
/** Range anchor: the last explicitly clicked tab (Chrome semantics). */
|
||||
anchor: string
|
||||
}
|
||||
|
||||
export const $tabSelection = atom<null | TabSelection>(null)
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
/** The toggle-select chord: ⌥-click on Mac (⌘ closes, ⌃ is the context menu),
|
||||
* Ctrl-click elsewhere — ⌥ is accepted everywhere for one muscle memory. */
|
||||
export const isToggleSelectClick = (event: { altKey: boolean; button: number; ctrlKey: boolean; metaKey: boolean }) =>
|
||||
event.button === 0 && !event.metaKey && (event.altKey || (!isMac && event.ctrlKey))
|
||||
|
||||
export function clearTabSelection() {
|
||||
if ($tabSelection.get()) {
|
||||
$tabSelection.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** ⌥/Ctrl-click: toggle `paneId`. A fresh selection seeds with the active tab
|
||||
* (it is implicitly selected, as in Chrome); collapsing to ≤1 dissolves the
|
||||
* selection entirely — a single "selected" tab is just a tab. */
|
||||
export function toggleTabSelected(groupId: string, paneId: string, activeId: string) {
|
||||
const current = $tabSelection.get()
|
||||
const ids = new Set(current?.groupId === groupId ? current.ids : [activeId])
|
||||
|
||||
if (ids.has(paneId)) {
|
||||
ids.delete(paneId)
|
||||
} else {
|
||||
ids.add(paneId)
|
||||
}
|
||||
|
||||
if (ids.size <= 1) {
|
||||
$tabSelection.set(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$tabSelection.set({ anchor: paneId, groupId, ids })
|
||||
}
|
||||
|
||||
/** Shift-click: select the contiguous range anchor→`paneId` in strip order,
|
||||
* replacing the previous range (the anchor holds, Chrome-style). */
|
||||
export function selectTabRange(groupId: string, orderedPanes: readonly string[], paneId: string, activeId: string) {
|
||||
const current = $tabSelection.get()
|
||||
const anchor = current?.groupId === groupId && orderedPanes.includes(current.anchor) ? current.anchor : activeId
|
||||
const a = orderedPanes.indexOf(anchor)
|
||||
const b = orderedPanes.indexOf(paneId)
|
||||
|
||||
if (a === -1 || b === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = new Set(orderedPanes.slice(Math.min(a, b), Math.max(a, b) + 1))
|
||||
|
||||
if (ids.size <= 1) {
|
||||
$tabSelection.set(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$tabSelection.set({ anchor, groupId, ids })
|
||||
}
|
||||
|
||||
/** The selection as an ordered slice of `orderedPanes` — but only when the
|
||||
* pressed tab rides it (dragging an unselected tab is a single-tab drag).
|
||||
* Stale ids (closed panes) drop out here. */
|
||||
export function selectionFor(groupId: string, orderedPanes: readonly string[], paneId: string): null | string[] {
|
||||
const current = $tabSelection.get()
|
||||
|
||||
if (current?.groupId !== groupId || !current.ids.has(paneId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ids = orderedPanes.filter(id => current.ids.has(id))
|
||||
|
||||
return ids.length > 1 ? ids : null
|
||||
}
|
||||
@@ -48,13 +48,13 @@ describe('activateTreeTabSlot indexes shown panes only', () => {
|
||||
it('⌘1 is workspace and ⌘2 is the first SESSION tab when files is hidden', async () => {
|
||||
const { activeOf, tree } = await setup()
|
||||
|
||||
expect(tree.activateTreeTabSlot(1)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(1)).toBe('workspace')
|
||||
expect(activeOf()).toBe('workspace')
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(2)).toBe('session-tile:a')
|
||||
expect(activeOf()).toBe('session-tile:a')
|
||||
|
||||
expect(tree.activateTreeTabSlot(3)).toBe(true)
|
||||
expect(tree.activateTreeTabSlot(3)).toBe('session-tile:b')
|
||||
expect(activeOf()).toBe('session-tile:b')
|
||||
})
|
||||
|
||||
@@ -63,19 +63,19 @@ describe('activateTreeTabSlot indexes shown panes only', () => {
|
||||
|
||||
// Shown: workspace + A + B → 3. Slot 4 would have been `B` on the raw array
|
||||
// (workspace, files, A, B) before this fix — now it correctly refuses.
|
||||
expect(tree.activateTreeTabSlot(4)).toBe(false)
|
||||
expect(tree.activateTreeTabSlot(4)).toBeNull()
|
||||
})
|
||||
|
||||
it('⌃Tab cycles only visible chips', async () => {
|
||||
const { activeOf, tree } = await setup()
|
||||
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe('session-tile:a')
|
||||
expect(activeOf()).toBe('session-tile:a')
|
||||
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe('session-tile:b')
|
||||
expect(activeOf()).toBe('session-tile:b')
|
||||
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe('workspace')
|
||||
expect(activeOf()).toBe('workspace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
$petRoam,
|
||||
$petRoamDir,
|
||||
clearPetUnread,
|
||||
hasPetSpriteForMeta,
|
||||
mergePetInfoMeta,
|
||||
type PetInfo,
|
||||
type PetInfoMeta,
|
||||
petProfile,
|
||||
setPetInfo
|
||||
} from '@/store/pet'
|
||||
@@ -39,25 +42,6 @@ interface Point {
|
||||
y: number
|
||||
}
|
||||
|
||||
interface PetInfoMeta {
|
||||
enabled: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
scale?: number
|
||||
spritesheetRevision?: string
|
||||
}
|
||||
|
||||
function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
|
||||
return (
|
||||
info.enabled &&
|
||||
Boolean(info.spritesheetBase64) &&
|
||||
info.slug === meta.slug &&
|
||||
info.displayName === meta.displayName &&
|
||||
info.scale === meta.scale &&
|
||||
info.spritesheetRevision === meta.spritesheetRevision
|
||||
)
|
||||
}
|
||||
|
||||
// Keep a w×h box fully inside the viewport. Pre-pet-load callers pass a nominal
|
||||
// size; the live size flows in once `info` arrives.
|
||||
function clampPoint(x: number, y: number, w: number, h: number): Point {
|
||||
@@ -161,7 +145,7 @@ export function FloatingPet() {
|
||||
// pet.changed already carries the meta payload — an enabled=false
|
||||
// broadcast clears the mascot with zero round-trips, and an unchanged
|
||||
// revision (scale-only move still changes the sig) short-circuits below
|
||||
// via samePetRevision.
|
||||
// via hasPetSpriteForMeta + mergePetInfoMeta.
|
||||
if (changeEventsAvailable && petChange.tick > 0 && petChange.meta?.enabled === false) {
|
||||
setPetInfo({ enabled: false })
|
||||
|
||||
@@ -184,7 +168,15 @@ export function FloatingPet() {
|
||||
return
|
||||
}
|
||||
|
||||
if (samePetRevision($petInfo.get(), meta)) {
|
||||
const current = $petInfo.get()
|
||||
|
||||
if (hasPetSpriteForMeta(current, meta)) {
|
||||
const merged = mergePetInfoMeta(current, meta)
|
||||
|
||||
if (merged !== current) {
|
||||
setPetInfo(merged)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -223,7 +215,14 @@ export function FloatingPet() {
|
||||
// so no timer. Legacy backend: the historical poll.
|
||||
const timer = changeEventsAvailable
|
||||
? null
|
||||
: window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS)
|
||||
: window.setInterval(
|
||||
() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void pull()
|
||||
}
|
||||
},
|
||||
active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { Profiler, type ProfilerOnRenderCallback } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
|
||||
|
||||
import { GlyphSpinner } from './glyph-spinner'
|
||||
|
||||
describe('GlyphSpinner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('advances its glyph without an update-phase React commit', () => {
|
||||
let updateCommits = 0
|
||||
|
||||
const onRender: ProfilerOnRenderCallback = (_id, phase) => {
|
||||
if (phase !== 'mount') {
|
||||
updateCommits += 1
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
<Profiler id="glyph-spinner" onRender={onRender}>
|
||||
<GlyphSpinner spinner="braille" />
|
||||
</Profiler>
|
||||
)
|
||||
|
||||
const status = screen.getByRole('status', { name: 'Loading' })
|
||||
expect(status.textContent).toBe('⠋')
|
||||
|
||||
act(() => vi.advanceTimersByTime(80))
|
||||
|
||||
expect(status.textContent).toBe('⠙')
|
||||
expect(updateCommits).toBe(0)
|
||||
})
|
||||
|
||||
it('does not tick while its kept-alive pane is hidden', () => {
|
||||
const { rerender } = render(
|
||||
<PaneVisibleContext.Provider value={false}>
|
||||
<GlyphSpinner spinner="braille" />
|
||||
</PaneVisibleContext.Provider>
|
||||
)
|
||||
|
||||
const status = screen.getByRole('status', { name: 'Loading' })
|
||||
|
||||
expect(status.textContent).toBe('⠋')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
rerender(
|
||||
<PaneVisibleContext.Provider value>
|
||||
<GlyphSpinner spinner="braille" />
|
||||
</PaneVisibleContext.Provider>
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => vi.advanceTimersByTime(80))
|
||||
expect(status.textContent).toBe('⠙')
|
||||
|
||||
rerender(
|
||||
<PaneVisibleContext.Provider value={false}>
|
||||
<GlyphSpinner spinner="braille" />
|
||||
</PaneVisibleContext.Provider>
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const frozen = status.textContent
|
||||
act(() => vi.advanceTimersByTime(800))
|
||||
expect(status.textContent).toBe(frozen)
|
||||
})
|
||||
|
||||
it('suspends animation while the Desktop window is inactive', () => {
|
||||
render(<GlyphSpinner spinner="braille" />)
|
||||
|
||||
const status = screen.getByRole('status', { name: 'Loading' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const frozen = status.textContent
|
||||
act(() => vi.advanceTimersByTime(800))
|
||||
expect(status.textContent).toBe(frozen)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => vi.advanceTimersByTime(80))
|
||||
expect(status.textContent).not.toBe(frozen)
|
||||
})
|
||||
|
||||
it('suspends animation while the Electron window is minimized or hidden, then resumes on restore', () => {
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
render(<GlyphSpinner spinner="braille" />)
|
||||
|
||||
const status = screen.getByRole('status', { name: 'Loading' })
|
||||
expect(windowStateCallback).not.toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => windowStateCallback?.({ isMinimized: true, isVisible: false }))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const frozen = status.textContent
|
||||
act(() => vi.advanceTimersByTime(800))
|
||||
expect(status.textContent).toBe(frozen)
|
||||
|
||||
act(() => windowStateCallback?.({ isMinimized: false, isVisible: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => vi.advanceTimersByTime(80))
|
||||
expect(status.textContent).not.toBe(frozen)
|
||||
} finally {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
it('suspends animation while the document is hidden', () => {
|
||||
render(<GlyphSpinner spinner="braille" />)
|
||||
|
||||
const status = screen.getByRole('status', { name: 'Loading' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' })
|
||||
|
||||
try {
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const frozen = status.textContent
|
||||
act(() => vi.advanceTimersByTime(800))
|
||||
expect(status.textContent).toBe(frozen)
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => vi.advanceTimersByTime(80))
|
||||
expect(status.textContent).not.toBe(frozen)
|
||||
} finally {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { SpinnerName }
|
||||
@@ -43,29 +44,66 @@ interface GlyphSpinnerProps {
|
||||
*/
|
||||
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
|
||||
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
|
||||
const [frame, setFrame] = useState(0)
|
||||
const glyphRef = useRef<HTMLSpanElement>(null)
|
||||
// Pause when this surface is a hidden (kept-alive) tab: N mounted tabs each
|
||||
// ticking a setInterval + setState burn CPU for pixels nobody can see.
|
||||
// ticking a setInterval burns CPU for pixels nobody can see.
|
||||
const visible = usePaneVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
const glyph = glyphRef.current
|
||||
|
||||
if (!visible || !glyph) {
|
||||
return
|
||||
}
|
||||
|
||||
setFrame(0)
|
||||
const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
||||
let frame = 0
|
||||
let timer: number | undefined
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | undefined
|
||||
glyph.textContent = spin.frames[frame]
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
const stopAnimation = () => {
|
||||
if (timer === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
const syncAnimation = () => {
|
||||
if (pauseController?.isPaused()) {
|
||||
stopAnimation()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (timer !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
timer = window.setInterval(() => {
|
||||
frame = (frame + 1) % spin.frames.length
|
||||
glyph.textContent = spin.frames[frame]
|
||||
}, spin.interval)
|
||||
}
|
||||
|
||||
pauseController = createRendererLoopPauseController(syncAnimation)
|
||||
syncAnimation()
|
||||
|
||||
return () => {
|
||||
pauseController.dispose()
|
||||
stopAnimation()
|
||||
}
|
||||
}, [spin, visible])
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={ariaLabel}
|
||||
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
|
||||
ref={glyphRef}
|
||||
role="status"
|
||||
>
|
||||
{spin.frames[frame]}
|
||||
{spin.frames[0]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user