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"
|
version: "0.9.28"
|
||||||
|
|
||||||
- name: Set up Python 3.11 (for docker tests)
|
- 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)
|
- name: Install Python dependencies (for docker tests)
|
||||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||||
|
|||||||
@@ -66,8 +66,12 @@ jobs:
|
|||||||
cache-dependency-glob: |
|
cache-dependency-glob: |
|
||||||
pyproject.toml
|
pyproject.toml
|
||||||
uv.lock
|
uv.lock
|
||||||
|
|
||||||
- name: Set up Python 3.11
|
- 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
|
- name: Install Python dependencies
|
||||||
uses: ./.github/actions/retry
|
uses: ./.github/actions/retry
|
||||||
with:
|
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
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Install uv
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||||
with:
|
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
|
- name: Run footgun checker
|
||||||
run: python scripts/check-windows-footguns.py --all
|
run: python scripts/check-windows-footguns.py --all
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ jobs:
|
|||||||
uv.lock
|
uv.lock
|
||||||
|
|
||||||
- name: Set up Python 3.11
|
- 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
|
- name: Install dependencies
|
||||||
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
# `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)
|
# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent)
|
||||||
.hermes-sandbox/
|
.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
|
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||||
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||||
|
|||||||
@@ -158,9 +158,16 @@ def make_approval_callback(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
response = future.result(timeout=timeout)
|
response = future.result(timeout=timeout)
|
||||||
except (FutureTimeout, Exception) as exc:
|
except FutureTimeout:
|
||||||
future.cancel()
|
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"
|
return "deny"
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
|
|||||||
+23
-1
@@ -209,7 +209,18 @@ def _context_route_mismatch(
|
|||||||
|
|
||||||
if active_route:
|
if active_route:
|
||||||
configured_routes = _provider_default_routes(configured_provider)
|
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(
|
return bool(
|
||||||
configured_provider
|
configured_provider
|
||||||
and active_provider
|
and active_provider
|
||||||
@@ -1580,6 +1591,17 @@ def init_agent(
|
|||||||
"reasoning_config": reasoning_config,
|
"reasoning_config": reasoning_config,
|
||||||
"max_tokens": max_tokens,
|
"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)
|
# In-memory todo list for task planning (one per agent/session)
|
||||||
from tools.todo_tool import TodoStore
|
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.prompt_builder import format_steer_marker
|
||||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
||||||
from agent.trajectory import convert_scratchpad_to_think
|
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.error_classifier import FailoverReason
|
||||||
from agent.turn_context import drop_stale_api_content
|
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
|
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
|
_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():
|
def _ra():
|
||||||
"""Lazy ``run_agent`` reference for test-patch routing."""
|
"""Lazy ``run_agent`` reference for test-patch routing."""
|
||||||
import run_agent
|
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
|
# 1. Closed tag pairs — case-insensitive for all variants so
|
||||||
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
|
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
|
||||||
# the unterminated-tag pass and take trailing content with them.
|
# the unterminated-tag pass and take trailing content with them.
|
||||||
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
for _pattern in _REASONING_BLOCK_PATTERNS:
|
||||||
content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
content = _pattern.sub('', content)
|
||||||
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)
|
|
||||||
# 1b. Tool-call XML blocks (openclaw/openclaw#67318). Handle the
|
# 1b. Tool-call XML blocks (openclaw/openclaw#67318). Handle the
|
||||||
# generic tag names first — they have no attribute gating since
|
# generic tag names first — they have no attribute gating since
|
||||||
# a literal <tool_call> in prose is already vanishingly rare.
|
# a literal <tool_call> in prose is already vanishingly rare.
|
||||||
for _tc_name in ("tool_call", "tool_calls", "tool_result",
|
for _pattern in _TOOL_CALL_BLOCK_PATTERNS:
|
||||||
"function_call", "function_calls"):
|
content = _pattern.sub('', content)
|
||||||
content = re.sub(
|
|
||||||
rf'<{_tc_name}\b[^>]*>.*?</{_tc_name}>',
|
|
||||||
'',
|
|
||||||
content,
|
|
||||||
flags=re.DOTALL | re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# 1c. <function name="...">...</function> — Gemma-style standalone
|
# 1c. <function name="...">...</function> — Gemma-style standalone
|
||||||
# tool call. Only strip when the tag sits at a block boundary
|
# tool call. Only strip when the tag sits at a block boundary
|
||||||
# (start of text, after a newline, or after sentence-ending
|
# (start of text, after a newline, or after sentence-ending
|
||||||
# punctuation) AND carries a name="..." attribute. This keeps
|
# punctuation) AND carries a name="..." attribute. This keeps
|
||||||
# prose mentions like "Use <function> to declare" safe.
|
# prose mentions like "Use <function> to declare" safe.
|
||||||
content = re.sub(
|
content = _NAMED_FUNCTION_BLOCK_PATTERN.sub('', content)
|
||||||
r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
|
|
||||||
r'<function\b[^>]*\bname\s*=[^>]*>'
|
|
||||||
r'(?:(?:(?!</function>).)*)</function>',
|
|
||||||
'',
|
|
||||||
content,
|
|
||||||
flags=re.DOTALL | re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# 2. Unterminated reasoning block — open tag at a block boundary
|
# 2. Unterminated reasoning block — open tag at a block boundary
|
||||||
# (start of text, or after a newline) with no matching close.
|
# (start of text, or after a newline) with no matching close.
|
||||||
# Strip from the tag to end of string. Fixes #8878 / #9568
|
# Strip from the tag to end of string. Fixes #8878 / #9568
|
||||||
# (MiniMax M2.7 leaking raw reasoning into assistant content).
|
# (MiniMax M2.7 leaking raw reasoning into assistant content).
|
||||||
content = re.sub(
|
content = _UNTERMINATED_REASONING_BLOCK_PATTERN.sub('', content)
|
||||||
r'(?:^|\n)[ \t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*$',
|
|
||||||
'',
|
|
||||||
content,
|
|
||||||
flags=re.DOTALL | re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# 3. Stray orphan open/close tags that slipped through.
|
# 3. Stray orphan open/close tags that slipped through.
|
||||||
content = re.sub(
|
content = _ORPHAN_REASONING_TAG_PATTERN.sub('', content)
|
||||||
r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*',
|
|
||||||
'',
|
|
||||||
content,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# 3b. Stray tool-call closers. (We do NOT strip bare <function> or
|
# 3b. Stray tool-call closers. (We do NOT strip bare <function> or
|
||||||
# unterminated <function name="..."> because a truncated tail
|
# unterminated <function name="..."> because a truncated tail
|
||||||
# during streaming may still be valuable to the user; matches
|
# during streaming may still be valuable to the user; matches
|
||||||
# OpenClaw's intentional asymmetry.)
|
# OpenClaw's intentional asymmetry.)
|
||||||
content = re.sub(
|
content = _STRAY_TOOL_CALL_CLOSER_PATTERN.sub('', content)
|
||||||
r'</(?:tool_call|tool_calls|tool_result|function_call|function_calls|function)>\s*',
|
|
||||||
'',
|
|
||||||
content,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
@@ -1012,6 +1020,13 @@ def recover_with_credential_pool(
|
|||||||
}
|
}
|
||||||
if _credential_id:
|
if _credential_id:
|
||||||
kwargs["credential_id"] = _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)
|
return pool.mark_exhausted_and_rotate(**kwargs)
|
||||||
|
|
||||||
effective_reason = classified_reason
|
effective_reason = classified_reason
|
||||||
@@ -1456,6 +1471,64 @@ def restore_primary_runtime(agent) -> bool:
|
|||||||
if getattr(agent, "_rate_limited_until", 0) > time.monotonic():
|
if getattr(agent, "_rate_limited_until", 0) > time.monotonic():
|
||||||
return False # primary still in rate-limit cooldown, stay on fallback
|
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
|
rt = agent._primary_runtime
|
||||||
try:
|
try:
|
||||||
# ── Core runtime state ──
|
# ── Core runtime state ──
|
||||||
@@ -1549,6 +1622,11 @@ def restore_primary_runtime(agent) -> bool:
|
|||||||
agent._credential_pool = None
|
agent._credential_pool = None
|
||||||
agent._credential_pool_entry_id = None
|
agent._credential_pool_entry_id = None
|
||||||
try:
|
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
|
from agent.credential_pool import load_pool
|
||||||
|
|
||||||
agent._credential_pool = load_pool(primary_provider)
|
agent._credential_pool = load_pool(primary_provider)
|
||||||
@@ -1631,6 +1709,7 @@ def restore_primary_runtime(agent) -> bool:
|
|||||||
# ── Reset fallback chain for the new turn ──
|
# ── Reset fallback chain for the new turn ──
|
||||||
agent._fallback_activated = False
|
agent._fallback_activated = False
|
||||||
agent._fallback_index = 0
|
agent._fallback_index = 0
|
||||||
|
agent._rate_limit_backoff_count = 0 # reset exponential backoff counter
|
||||||
|
|
||||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||||
# the FALLBACK provider we're leaving; the restored primary deserves
|
# 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
|
gateway implements the Anthropic cache_control contract
|
||||||
(MiniMax, Zhipu GLM, LiteLLM's Anthropic proxy mode all do).
|
(MiniMax, Zhipu GLM, LiteLLM's Anthropic proxy mode all do).
|
||||||
|
|
||||||
Qwen models on OpenCode and direct Alibaba (DashScope), plus DeepSeek
|
Qwen / Alibaba-family models on OpenCode, OpenCode Go, and direct
|
||||||
models on OpenCode, also honour Anthropic-style ``cache_control`` markers
|
Alibaba (DashScope) also honour Anthropic-style ``cache_control``
|
||||||
on OpenAI-wire chat completions. Upstream pi-mono #3392 / pi #3393
|
markers on OpenAI-wire chat completions. Upstream pi-mono #3392 /
|
||||||
documented this for opencode-go Qwen; #24617 reports the same gateway
|
pi #3393 documented this for opencode-go Qwen. Without markers
|
||||||
contract for DeepSeek. Without markers these providers serve zero cache
|
these providers serve zero cache hits, re-billing the full prompt
|
||||||
hits, re-billing the full prompt on every turn.
|
on every turn.
|
||||||
|
|
||||||
If the operator has set ``prompt_caching.cache_ttl`` to a falsy value
|
If the operator has set ``prompt_caching.cache_ttl`` to a falsy value
|
||||||
(``false``, ``null``, ``"off"``, etc.) in config.yaml, prompt caching
|
(``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:
|
if is_minimax_provider or is_minimax_host:
|
||||||
return True, True
|
return True, True
|
||||||
|
|
||||||
# Qwen on OpenCode (Zen/Go) and native DashScope, plus DeepSeek on
|
# Qwen/Alibaba on OpenCode (Zen/Go) and native DashScope: OpenAI-wire
|
||||||
# OpenCode only: OpenAI-wire transports that accept Anthropic-style
|
# transport that accepts Anthropic-style cache_control markers and
|
||||||
# cache_control markers and reward them with real cache hits. Keep direct
|
# rewards them with real cache hits. Without this branch
|
||||||
# Alibaba specific to Qwen; its catalog does not establish the same
|
# qwen3.6-plus on opencode-go reports 0% cached tokens and burns
|
||||||
# contract for DeepSeek.
|
# 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_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 {
|
provider_is_alibaba_family = provider_lower in {
|
||||||
"opencode", "opencode-zen", "opencode-go", "alibaba",
|
"opencode", "opencode-zen", "opencode-go", "alibaba",
|
||||||
}
|
}
|
||||||
if (provider_is_alibaba_family and model_is_qwen) or (
|
if provider_is_alibaba_family and model_is_qwen:
|
||||||
provider_is_opencode and model_is_deepseek
|
|
||||||
):
|
|
||||||
# Envelope layout (native_anthropic=False): markers on inner
|
# Envelope layout (native_anthropic=False): markers on inner
|
||||||
# content parts, not top-level tool messages. Matches
|
# content parts, not top-level tool messages. Matches
|
||||||
# pi-mono's "alibaba" cacheControlFormat.
|
# 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()
|
# to auth.json or trigger a network refresh from a bare resolve. select()
|
||||||
# is deliberately NOT used — it runs clear_expired=True, refresh=True,
|
# is deliberately NOT used — it runs clear_expired=True, refresh=True,
|
||||||
# which would violate this read-only contract.
|
# 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:
|
except Exception:
|
||||||
logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
|
logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -1360,19 +1360,27 @@ def resolve_anthropic_token() -> Optional[str]:
|
|||||||
Priority:
|
Priority:
|
||||||
1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
|
||||||
2. CLAUDE_CODE_OAUTH_TOKEN env var
|
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
|
— with automatic refresh if expired and a refresh token is available
|
||||||
4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)
|
5. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)
|
||||||
5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
|
||||||
|
|
||||||
Returns the token string or None.
|
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 = read_claude_code_credentials()
|
||||||
|
creds_loaded = True
|
||||||
|
return creds
|
||||||
|
|
||||||
# 1. Hermes-managed OAuth/setup token env var
|
# 1. Hermes-managed OAuth/setup token env var
|
||||||
token = _getenv("ANTHROPIC_TOKEN").strip()
|
token = _getenv("ANTHROPIC_TOKEN").strip()
|
||||||
if token:
|
if token:
|
||||||
preferred = _prefer_refreshable_claude_code_token(token, creds)
|
preferred = _prefer_refreshable_claude_code_token(token, _read_creds())
|
||||||
if preferred:
|
if preferred:
|
||||||
return preferred
|
return preferred
|
||||||
return token
|
return token
|
||||||
@@ -1380,27 +1388,27 @@ def resolve_anthropic_token() -> Optional[str]:
|
|||||||
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||||
cc_token = _getenv("CLAUDE_CODE_OAUTH_TOKEN").strip()
|
cc_token = _getenv("CLAUDE_CODE_OAUTH_TOKEN").strip()
|
||||||
if cc_token:
|
if cc_token:
|
||||||
preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
|
preferred = _prefer_refreshable_claude_code_token(cc_token, _read_creds())
|
||||||
if preferred:
|
if preferred:
|
||||||
return preferred
|
return preferred
|
||||||
return cc_token
|
return cc_token
|
||||||
|
|
||||||
# 3. Claude Code credential file
|
# 3. Regular API key. An explicit user-configured key must not be shadowed
|
||||||
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
# by auto-discovered Claude Code or credential-pool OAuth credentials.
|
||||||
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.
|
|
||||||
api_key = _getenv("ANTHROPIC_API_KEY").strip()
|
api_key = _getenv("ANTHROPIC_API_KEY").strip()
|
||||||
if api_key:
|
if api_key:
|
||||||
return 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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -2311,13 +2319,14 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
|
|||||||
"""Validate and convert a user message to anthropic format."""
|
"""Validate and convert a user message to anthropic format."""
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
converted_blocks = _convert_content_to_anthropic(content)
|
converted_blocks = _convert_content_to_anthropic(content)
|
||||||
if not converted_blocks or all(
|
kept_blocks = _fix_blank_text_blocks_in_list(
|
||||||
(b.get("text") or "").strip() == ""
|
converted_blocks,
|
||||||
for b in converted_blocks
|
placeholder_text="(empty message)",
|
||||||
if isinstance(b, dict) and b.get("type") == "text"
|
msg_index=-1,
|
||||||
):
|
role="user",
|
||||||
converted_blocks = [{"type": "text", "text": "(empty message)"}]
|
location="_convert_user_message",
|
||||||
return {"role": "user", "content": converted_blocks}
|
)
|
||||||
|
return {"role": "user", "content": kept_blocks}
|
||||||
else:
|
else:
|
||||||
if not content or (isinstance(content, str) and not content.strip()):
|
if not content or (isinstance(content, str) and not content.strip()):
|
||||||
content = "(empty message)"
|
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
|
Mirror the Bedrock Converse adapter, which unconditionally prepends a
|
||||||
minimal user turn when the first message is not user
|
minimal user turn when the first message is not user
|
||||||
(convert_messages_to_converse).
|
(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":
|
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(
|
def convert_messages_to_anthropic(
|
||||||
@@ -2686,6 +2800,7 @@ def convert_messages_to_anthropic(
|
|||||||
_ensure_leading_user_turn(result)
|
_ensure_leading_user_turn(result)
|
||||||
_manage_thinking_signatures(result, base_url, model)
|
_manage_thinking_signatures(result, base_url, model)
|
||||||
_evict_old_screenshots(result)
|
_evict_old_screenshots(result)
|
||||||
|
_scrub_blank_text_blocks(result)
|
||||||
|
|
||||||
return system, result
|
return system, result
|
||||||
|
|
||||||
|
|||||||
@@ -7604,6 +7604,77 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
|||||||
return result
|
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
|
# Anthropic-compatible endpoint detection + image block conversion
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -8476,6 +8547,75 @@ def call_llm(
|
|||||||
api_mode: str = None,
|
api_mode: str = None,
|
||||||
stream: bool = False,
|
stream: bool = False,
|
||||||
stream_options: dict = None,
|
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:
|
) -> Any:
|
||||||
"""Centralized synchronous LLM call.
|
"""Centralized synchronous LLM call.
|
||||||
|
|
||||||
@@ -9239,6 +9379,47 @@ async def async_call_llm(
|
|||||||
timeout: float = None,
|
timeout: float = None,
|
||||||
extra_body: dict = None,
|
extra_body: dict = None,
|
||||||
reasoning_config: Optional[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:
|
) -> Any:
|
||||||
"""Centralized asynchronous LLM call.
|
"""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()
|
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||||
primary_provider = ((agent._primary_runtime or {}).get("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):
|
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):
|
if agent._fallback_index >= len(agent._fallback_chain):
|
||||||
# Chain exhausted. If we actually walked a non-empty chain and the
|
# Chain exhausted. If we actually walked a non-empty chain and the
|
||||||
# failure was NOT a rate-limit/billing event (those already armed
|
# 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)
|
_strip_persistence_markers(compressed)
|
||||||
self._last_compression_made_progress = True
|
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
|
# Batch compaction invalidates micro-compaction state: the batch
|
||||||
# marker now holds MORE history than the in-memory rolling summary
|
# marker now holds MORE history than the in-memory rolling summary
|
||||||
# (it summarized everything in the window, including exchanges micro
|
# (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_images_from_messages,
|
||||||
_strip_non_ascii,
|
_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 (
|
from agent.model_metadata import (
|
||||||
MINIMUM_CONTEXT_LENGTH,
|
MINIMUM_CONTEXT_LENGTH,
|
||||||
_estimate_tools_tokens_rough,
|
_estimate_tools_tokens_rough,
|
||||||
@@ -1286,6 +1291,14 @@ def run_conversation(
|
|||||||
agent._last_compression_attempt_recorded = False
|
agent._last_compression_attempt_recorded = False
|
||||||
agent._last_compression_attempt_in_place = None
|
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) ──
|
# ── Per-turn setup (the prologue) ──
|
||||||
# All once-per-turn setup — stdio guarding, retry-counter resets, user
|
# All once-per-turn setup — stdio guarding, retry-counter resets, user
|
||||||
# message sanitization, todo/nudge hydration, system-prompt restore-or-
|
# 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}"
|
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
|
# Check for interrupt before deciding to retry
|
||||||
if agent._interrupt_requested:
|
if agent._interrupt_requested:
|
||||||
# Preserve a pending redirect (mid-stream correction): the
|
# Preserve a pending redirect (mid-stream correction): the
|
||||||
@@ -4360,9 +4395,11 @@ def run_conversation(
|
|||||||
compression_attempts += 1
|
compression_attempts += 1
|
||||||
if compression_attempts <= max_compression_attempts:
|
if compression_attempts <= max_compression_attempts:
|
||||||
original_len = len(messages)
|
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, active_system_prompt = agent._compress_context(
|
||||||
messages, system_message,
|
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,
|
task_id=effective_task_id,
|
||||||
)
|
)
|
||||||
conversation_history = conversation_history_after_compression(
|
conversation_history = conversation_history_after_compression(
|
||||||
@@ -4617,8 +4654,11 @@ def run_conversation(
|
|||||||
original_len = len(messages)
|
original_len = len(messages)
|
||||||
original_tokens = estimate_messages_tokens_rough(messages)
|
original_tokens = estimate_messages_tokens_rough(messages)
|
||||||
_overflow_input = 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, 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,
|
task_id=effective_task_id,
|
||||||
)
|
)
|
||||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
||||||
@@ -4754,6 +4794,41 @@ def run_conversation(
|
|||||||
"failed": True,
|
"failed": True,
|
||||||
"compression_exhausted": 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
|
_retry.restart_with_compressed_messages = True
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -4878,8 +4953,13 @@ def run_conversation(
|
|||||||
original_len = len(messages)
|
original_len = len(messages)
|
||||||
original_tokens = estimate_messages_tokens_rough(messages)
|
original_tokens = estimate_messages_tokens_rough(messages)
|
||||||
_overflow_input = 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, 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,
|
task_id=effective_task_id,
|
||||||
)
|
)
|
||||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
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 ""
|
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.
|
# Classify tools in this turn to determine if they are all housekeeping.
|
||||||
# This classification is needed regardless of whether the turn has visible content,
|
# This classification is needed regardless of whether the turn has visible content,
|
||||||
# because a substantive tool-only turn must invalidate any older housekeeping fallback.
|
# because a substantive tool-only turn must invalidate any older housekeeping fallback.
|
||||||
@@ -6373,9 +6469,12 @@ def run_conversation(
|
|||||||
_clear_warn()
|
_clear_warn()
|
||||||
agent._safe_print(" ⟳ compacting context…")
|
agent._safe_print(" ⟳ compacting context…")
|
||||||
_post_tool_input = messages
|
_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, active_system_prompt = agent._compress_context(
|
||||||
messages, system_message,
|
messages, system_message,
|
||||||
approx_tokens=agent.context_compressor.last_prompt_tokens,
|
approx_tokens=_real_tokens,
|
||||||
task_id=effective_task_id,
|
task_id=effective_task_id,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
@@ -6662,14 +6761,46 @@ def run_conversation(
|
|||||||
)
|
)
|
||||||
if _truly_empty and (not _has_structured or _prefill_exhausted) and agent._empty_content_retries < 3:
|
if _truly_empty and (not _has_structured or _prefill_exhausted) and agent._empty_content_retries < 3:
|
||||||
agent._empty_content_retries += 1
|
agent._empty_content_retries += 1
|
||||||
|
wait_time = jittered_backoff(
|
||||||
|
agent._empty_content_retries,
|
||||||
|
base_delay=5.0,
|
||||||
|
max_delay=60.0,
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Empty response (no content or reasoning) — "
|
"Empty response (no content or reasoning) — "
|
||||||
"retry %d/3 (model=%s)",
|
"retry %d/3 in %.1fs (model=%s)",
|
||||||
agent._empty_content_retries, agent.model,
|
agent._empty_content_retries, wait_time, agent.model,
|
||||||
)
|
)
|
||||||
agent._buffer_status(
|
agent._buffer_status(
|
||||||
f"⚠️ Empty response from model — retrying "
|
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
|
continue
|
||||||
|
|
||||||
|
|||||||
+321
-63
@@ -124,6 +124,17 @@ SUPPORTED_POOL_STRATEGIES = {
|
|||||||
EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
|
EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
|
||||||
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
|
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
|
||||||
EXHAUSTED_TTL_DEFAULT_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
|
# Throttle window for the "no available entries" INFO line. Credential
|
||||||
# selection runs on a hot path (every model call, plus auxiliary tasks like
|
# 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",
|
"token_type", "scope", "client_id", "portal_base_url", "obtained_at",
|
||||||
"expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused",
|
"expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused",
|
||||||
"agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint",
|
"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}:")
|
return normalized == SOURCE_MANUAL or normalized.startswith(f"{SOURCE_MANUAL}:")
|
||||||
|
|
||||||
|
|
||||||
def _exhausted_ttl(error_code: Optional[int]) -> int:
|
def _exhausted_ttl(
|
||||||
"""Return cooldown seconds based on the HTTP status that caused exhaustion."""
|
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:
|
if error_code == 401:
|
||||||
return EXHAUSTED_TTL_401_SECONDS
|
return EXHAUSTED_TTL_401_SECONDS
|
||||||
if error_code == 429:
|
base = EXHAUSTED_TTL_429_SECONDS if error_code == 429 else EXHAUSTED_TTL_DEFAULT_SECONDS
|
||||||
return EXHAUSTED_TTL_429_SECONDS
|
# Sole credential: shorten only TRANSIENT throttles (429 rate-limit, 403
|
||||||
return EXHAUSTED_TTL_DEFAULT_SECONDS
|
# 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]:
|
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
|
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:
|
if entry.last_status != STATUS_EXHAUSTED:
|
||||||
return None
|
return None
|
||||||
reset_at = _parse_absolute_timestamp(getattr(entry, "last_error_reset_at", None))
|
reset_at = _parse_absolute_timestamp(getattr(entry, "last_error_reset_at", None))
|
||||||
if reset_at is not None:
|
if reset_at is not None:
|
||||||
return reset_at
|
return reset_at
|
||||||
if entry.last_status_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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -588,7 +636,12 @@ class CredentialPool:
|
|||||||
self._entries = sorted(entries, key=lambda entry: entry.priority)
|
self._entries = sorted(entries, key=lambda entry: entry.priority)
|
||||||
self._current_id: Optional[str] = None
|
self._current_id: Optional[str] = None
|
||||||
self._strategy = get_pool_strategy(provider)
|
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._active_leases: Dict[str, int] = {}
|
||||||
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
|
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
|
||||||
# Monotonic timestamp of the last "no available entries" log, used to
|
# 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`` /
|
# otherwise a status probe here can race a concurrent ``select`` /
|
||||||
# rotation and tear ``self._entries`` or double-write auth.json.
|
# rotation and tear ``self._entries`` or double-write auth.json.
|
||||||
with self._lock:
|
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]:
|
def entries(self) -> List[PooledCredential]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -656,13 +745,22 @@ class CredentialPool:
|
|||||||
return matches[0].id if len(matches) == 1 else None
|
return matches[0].id if len(matches) == 1 else None
|
||||||
|
|
||||||
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> 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):
|
for idx, entry in enumerate(self._entries):
|
||||||
if entry.id == old.id:
|
if entry.id == old.id:
|
||||||
self._entries[idx] = new
|
self._entries[idx] = new
|
||||||
return
|
return
|
||||||
|
|
||||||
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
|
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(
|
write_credential_pool(
|
||||||
self.provider,
|
self.provider,
|
||||||
[entry.to_dict() for entry in self._entries],
|
[entry.to_dict() for entry in self._entries],
|
||||||
@@ -699,6 +797,7 @@ class CredentialPool:
|
|||||||
error_context: Optional[Dict[str, Any]] = None,
|
error_context: Optional[Dict[str, Any]] = None,
|
||||||
*,
|
*,
|
||||||
persist: bool = True,
|
persist: bool = True,
|
||||||
|
failure_reason: Optional[str] = None,
|
||||||
) -> PooledCredential:
|
) -> PooledCredential:
|
||||||
normalized_error = _normalize_error_context(error_context)
|
normalized_error = _normalize_error_context(error_context)
|
||||||
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
||||||
@@ -712,6 +811,15 @@ class CredentialPool:
|
|||||||
terminal_status = STATUS_DEAD
|
terminal_status = STATUS_DEAD
|
||||||
else:
|
else:
|
||||||
terminal_status = STATUS_EXHAUSTED
|
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(
|
updated = replace(
|
||||||
entry,
|
entry,
|
||||||
last_status=terminal_status,
|
last_status=terminal_status,
|
||||||
@@ -720,6 +828,7 @@ class CredentialPool:
|
|||||||
last_error_reason=normalized_error.get("reason"),
|
last_error_reason=normalized_error.get("reason"),
|
||||||
last_error_message=normalized_error.get("message"),
|
last_error_message=normalized_error.get("message"),
|
||||||
last_error_reset_at=normalized_error.get("reset_at"),
|
last_error_reset_at=normalized_error.get("reset_at"),
|
||||||
|
extra=updated_extra,
|
||||||
)
|
)
|
||||||
self._replace_entry(entry, updated)
|
self._replace_entry(entry, updated)
|
||||||
if persist:
|
if persist:
|
||||||
@@ -1415,6 +1524,11 @@ class CredentialPool:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"Failed to clear terminal xAI OAuth state: %s", clear_exc
|
"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 = [
|
removed_ids = [
|
||||||
item.id for item in self._entries
|
item.id for item in self._entries
|
||||||
if item.source == "device_code"
|
if item.source == "device_code"
|
||||||
@@ -1485,6 +1599,11 @@ class CredentialPool:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"Failed to clear terminal Codex OAuth state: %s", clear_exc
|
"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 = [
|
removed_ids = [
|
||||||
item.id for item in self._entries
|
item.id for item in self._entries
|
||||||
if item.source == "device_code"
|
if item.source == "device_code"
|
||||||
@@ -1552,6 +1671,8 @@ class CredentialPool:
|
|||||||
auth_mod.NOUS_DEVICE_CODE_SOURCE,
|
auth_mod.NOUS_DEVICE_CODE_SOURCE,
|
||||||
f"manual:{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 = [
|
removed_ids = [
|
||||||
item.id for item in self._entries
|
item.id for item in self._entries
|
||||||
if item.source in singleton_sources
|
if item.source in singleton_sources
|
||||||
@@ -1646,26 +1767,70 @@ class CredentialPool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def select(self) -> Optional[PooledCredential]:
|
def select(self) -> Optional[PooledCredential]:
|
||||||
with self._lock:
|
entry, pending_refresh = self._select_under_lock()
|
||||||
entry = self._select_unlocked()
|
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:
|
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
|
self._unmatched_rotation_streak = 0
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def _available_entries(self, *, clear_expired: bool = False, refresh: bool = False) -> List[PooledCredential]:
|
def _select_under_lock(self) -> Tuple[Optional[PooledCredential], List[tuple]]:
|
||||||
"""Return entries not currently in exhaustion cooldown.
|
"""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
|
When *clear_expired* is True, entries whose cooldown has elapsed are
|
||||||
reset to STATUS_OK and persisted. When *refresh* is True, entries
|
reset to STATUS_OK and persisted. When *refresh* is True, entries
|
||||||
that need a token refresh are refreshed (skipped on failure).
|
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()
|
now = time.time()
|
||||||
cleared_any = False
|
cleared_any = False
|
||||||
entries_to_prune: List[str] = []
|
entries_to_prune: List[str] = []
|
||||||
available: List[PooledCredential] = []
|
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:
|
for entry in self._entries:
|
||||||
# Borrowed credentials persist as metadata-only references and are
|
# Borrowed credentials persist as metadata-only references and are
|
||||||
# hydrated from their live source on load. A stale duplicate row
|
# hydrated from their live source on load. A stale duplicate row
|
||||||
@@ -1746,7 +1911,7 @@ class CredentialPool:
|
|||||||
# the re-auth case for OAuth singletons.
|
# the re-auth case for OAuth singletons.
|
||||||
continue
|
continue
|
||||||
if entry.last_status == STATUS_EXHAUSTED:
|
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:
|
if exhausted_until is not None and now < exhausted_until:
|
||||||
# Codex quota windows can reopen EARLY: the user redeems a
|
# Codex quota windows can reopen EARLY: the user redeems a
|
||||||
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
|
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
|
||||||
@@ -1774,6 +1939,16 @@ class CredentialPool:
|
|||||||
entry = cleared
|
entry = cleared
|
||||||
cleared_any = True
|
cleared_any = True
|
||||||
if refresh and self._entry_needs_refresh(entry):
|
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)
|
refreshed = self._refresh_entry(entry, force=False)
|
||||||
if refreshed is None:
|
if refreshed is None:
|
||||||
continue
|
continue
|
||||||
@@ -1784,7 +1959,7 @@ class CredentialPool:
|
|||||||
self._entries = [e for e in self._entries if e.id not in pruned_ids]
|
self._entries = [e for e in self._entries if e.id not in pruned_ids]
|
||||||
if cleared_any:
|
if cleared_any:
|
||||||
self._persist(removed_ids=entries_to_prune)
|
self._persist(removed_ids=entries_to_prune)
|
||||||
return available
|
return available, pending_refresh
|
||||||
|
|
||||||
def _log_no_available_entries(self) -> None:
|
def _log_no_available_entries(self) -> None:
|
||||||
"""Emit the empty-pool INFO line at most once per throttle window.
|
"""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
|
self._last_no_entries_log_at = now
|
||||||
logger.info("credential pool: no available entries (all exhausted or empty)")
|
logger.info("credential pool: no available entries (all exhausted or empty)")
|
||||||
|
|
||||||
def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
|
def _select_unlocked(self, *, refresh: bool = True) -> Tuple[Optional[PooledCredential], List[tuple]]:
|
||||||
available = self._available_entries(clear_expired=True, refresh=refresh)
|
"""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:
|
if not available:
|
||||||
self._current_id = None
|
self._current_id = None
|
||||||
self._log_no_available_entries()
|
self._log_no_available_entries()
|
||||||
return None
|
return None, pending_refresh
|
||||||
|
|
||||||
# A successful selection means the pool recovered; re-arm the throttle
|
# A successful selection means the pool recovered; re-arm the throttle
|
||||||
# so a later re-exhaustion logs immediately rather than being silenced
|
# so a later re-exhaustion logs immediately rather than being silenced
|
||||||
@@ -1815,7 +1995,7 @@ class CredentialPool:
|
|||||||
if self._strategy == STRATEGY_RANDOM:
|
if self._strategy == STRATEGY_RANDOM:
|
||||||
entry = random.choice(available)
|
entry = random.choice(available)
|
||||||
self._current_id = entry.id
|
self._current_id = entry.id
|
||||||
return entry
|
return entry, pending_refresh
|
||||||
|
|
||||||
if self._strategy == STRATEGY_LEAST_USED and len(available) > 1:
|
if self._strategy == STRATEGY_LEAST_USED and len(available) > 1:
|
||||||
entry = min(available, key=lambda e: e.request_count)
|
entry = min(available, key=lambda e: e.request_count)
|
||||||
@@ -1823,7 +2003,7 @@ class CredentialPool:
|
|||||||
updated = replace(entry, request_count=entry.request_count + 1)
|
updated = replace(entry, request_count=entry.request_count + 1)
|
||||||
self._replace_entry(entry, updated)
|
self._replace_entry(entry, updated)
|
||||||
self._current_id = entry.id
|
self._current_id = entry.id
|
||||||
return updated
|
return updated, pending_refresh
|
||||||
|
|
||||||
if self._strategy == STRATEGY_ROUND_ROBIN and len(available) > 1:
|
if self._strategy == STRATEGY_ROUND_ROBIN and len(available) > 1:
|
||||||
entry = available[0]
|
entry = available[0]
|
||||||
@@ -1832,11 +2012,11 @@ class CredentialPool:
|
|||||||
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
|
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
|
||||||
self._persist()
|
self._persist()
|
||||||
self._current_id = entry.id
|
self._current_id = entry.id
|
||||||
return self._current_unlocked() or entry
|
return self._current_unlocked() or entry, pending_refresh
|
||||||
|
|
||||||
entry = available[0]
|
entry = available[0]
|
||||||
self._current_id = entry.id
|
self._current_id = entry.id
|
||||||
return entry
|
return entry, pending_refresh
|
||||||
|
|
||||||
def peek(self) -> Optional[PooledCredential]:
|
def peek(self) -> Optional[PooledCredential]:
|
||||||
# Single lock acquisition for the whole read; call the unlocked
|
# Single lock acquisition for the whole read; call the unlocked
|
||||||
@@ -1845,7 +2025,7 @@ class CredentialPool:
|
|||||||
current = self._current_unlocked()
|
current = self._current_unlocked()
|
||||||
if current is not None:
|
if current is not None:
|
||||||
return current
|
return current
|
||||||
available = self._available_entries()
|
available, _pending = self._available_entries()
|
||||||
return available[0] if available else None
|
return available[0] if available else None
|
||||||
|
|
||||||
def mark_exhausted_and_rotate(
|
def mark_exhausted_and_rotate(
|
||||||
@@ -1855,6 +2035,7 @@ class CredentialPool:
|
|||||||
error_context: Optional[Dict[str, Any]] = None,
|
error_context: Optional[Dict[str, Any]] = None,
|
||||||
api_key_hint: Optional[str] = None,
|
api_key_hint: Optional[str] = None,
|
||||||
credential_id: Optional[str] = None,
|
credential_id: Optional[str] = None,
|
||||||
|
failure_reason: Optional[str] = None,
|
||||||
) -> Optional[PooledCredential]:
|
) -> Optional[PooledCredential]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
entry = None
|
entry = None
|
||||||
@@ -1894,7 +2075,8 @@ class CredentialPool:
|
|||||||
# guessing and surface the error (no cooldown is written for
|
# guessing and surface the error (no cooldown is written for
|
||||||
# anybody — healthy keys stay available for the next turn).
|
# anybody — healthy keys stay available for the next turn).
|
||||||
self._unmatched_rotation_streak += 1
|
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):
|
if self._unmatched_rotation_streak > max(available_count, 1):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"credential pool: failed credential identity matched no "
|
"credential pool: failed credential identity matched no "
|
||||||
@@ -1913,8 +2095,9 @@ class CredentialPool:
|
|||||||
self.provider,
|
self.provider,
|
||||||
)
|
)
|
||||||
self._current_id = None
|
self._current_id = None
|
||||||
next_entry = self._select_unlocked()
|
next_entry, _pending = self._select_unlocked(refresh=False)
|
||||||
if next_entry is not None and len(self._available_entries()) == 1:
|
avail, _ = self._available_entries()
|
||||||
|
if next_entry is not None and len(avail) == 1:
|
||||||
# A single-entry pool cannot rotate. Returning its only
|
# A single-entry pool cannot rotate. Returning its only
|
||||||
# entry reports a successful recovery without changing
|
# entry reports a successful recovery without changing
|
||||||
# the credential, so the caller retries the same 401
|
# the credential, so the caller retries the same 401
|
||||||
@@ -1927,11 +2110,13 @@ class CredentialPool:
|
|||||||
# streak is stale (this mark WILL advance pool state).
|
# streak is stale (this mark WILL advance pool state).
|
||||||
self._unmatched_rotation_streak = 0
|
self._unmatched_rotation_streak = 0
|
||||||
if entry is None:
|
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:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
_label = entry.label or entry.id[:8]
|
_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
|
# 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
|
# 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
|
# back more than one pool entry (e.g. an explicit pool entry plus a
|
||||||
@@ -1951,7 +2136,11 @@ class CredentialPool:
|
|||||||
continue
|
continue
|
||||||
if sibling.runtime_api_key == failed_runtime_key:
|
if sibling.runtime_api_key == failed_runtime_key:
|
||||||
self._mark_exhausted(
|
self._mark_exhausted(
|
||||||
sibling, status_code, error_context, persist=False
|
sibling,
|
||||||
|
status_code,
|
||||||
|
error_context,
|
||||||
|
persist=False,
|
||||||
|
failure_reason=failure_reason,
|
||||||
)
|
)
|
||||||
siblings_marked = True
|
siblings_marked = True
|
||||||
if siblings_marked:
|
if siblings_marked:
|
||||||
@@ -1972,7 +2161,7 @@ class CredentialPool:
|
|||||||
_label, status_code,
|
_label, status_code,
|
||||||
)
|
)
|
||||||
self._current_id = None
|
self._current_id = None
|
||||||
next_entry = self._select_unlocked()
|
next_entry, _pending = self._select_unlocked(refresh=False)
|
||||||
if next_entry:
|
if next_entry:
|
||||||
_next_label = next_entry.label or next_entry.id[:8]
|
_next_label = next_entry.label or next_entry.id[:8]
|
||||||
logger.info("credential pool: rotated to %s", _next_label)
|
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,
|
a stable tie-breaker. When every credential is already at the soft cap,
|
||||||
still return the least-leased one instead of blocking.
|
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:
|
with self._lock:
|
||||||
if credential_id:
|
if credential_id:
|
||||||
self._active_leases[credential_id] = self._active_leases.get(credential_id, 0) + 1
|
self._active_leases[credential_id] = self._active_leases.get(credential_id, 0) + 1
|
||||||
self._current_id = credential_id
|
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:
|
if not available:
|
||||||
return None
|
return None, pending_refresh
|
||||||
|
|
||||||
below_cap = [
|
below_cap = [
|
||||||
entry for entry in available
|
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._active_leases[chosen.id] = self._active_leases.get(chosen.id, 0) + 1
|
||||||
self._current_id = chosen.id
|
self._current_id = chosen.id
|
||||||
return chosen.id
|
return chosen.id, pending_refresh
|
||||||
|
|
||||||
def release_lease(self, credential_id: str) -> None:
|
def release_lease(self, credential_id: str) -> None:
|
||||||
"""Release a previously acquired credential lease."""
|
"""Release a previously acquired credential lease."""
|
||||||
@@ -2059,7 +2265,7 @@ class CredentialPool:
|
|||||||
else:
|
else:
|
||||||
entry = self._current_unlocked() or self._select_unlocked(
|
entry = self._current_unlocked() or self._select_unlocked(
|
||||||
refresh=False
|
refresh=False
|
||||||
)
|
)[0]
|
||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
self._current_id = entry.id
|
self._current_id = entry.id
|
||||||
@@ -2173,6 +2379,11 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
|||||||
field_updates = {}
|
field_updates = {}
|
||||||
extra_updates = {}
|
extra_updates = {}
|
||||||
_field_names = {f.name for f in fields(existing)}
|
_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():
|
for key, value in payload.items():
|
||||||
if key in {"id", "priority"} or value is None:
|
if key in {"id", "priority"} or value is None:
|
||||||
continue
|
continue
|
||||||
@@ -2184,6 +2395,15 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
|||||||
elif key in _EXTRA_KEYS:
|
elif key in _EXTRA_KEYS:
|
||||||
if existing.extra.get(key) != value:
|
if existing.extra.get(key) != value:
|
||||||
extra_updates[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 field_updates or extra_updates:
|
||||||
if extra_updates:
|
if extra_updates:
|
||||||
field_updates["extra"] = {**existing.extra, **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
|
# env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN). They don't live in
|
||||||
# the auth store or credential pool, so we resolve them here.
|
# the auth store or credential pool, so we resolve them here.
|
||||||
try:
|
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()
|
token, source = resolve_copilot_token()
|
||||||
if 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)
|
api_token, enterprise_base_url = get_copilot_api_token(token)
|
||||||
# Observability: get_copilot_api_token falls back to returning
|
# Observability: get_copilot_api_token falls back to returning
|
||||||
# the RAW token when the exchange fails. A raw ~40-char token
|
# 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 "
|
"unavailable); enterprise-only models may 400 with "
|
||||||
"model_not_available_for_integrator until exchange recovers."
|
"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)
|
active_sources.add(source_name)
|
||||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||||
# Use enterprise base URL from token exchange if available,
|
# 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
|
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]]:
|
def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
|
||||||
changed = False
|
changed = False
|
||||||
active_sources: Set[str] = set()
|
active_sources: Set[str] = set()
|
||||||
@@ -2589,27 +2864,10 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
|||||||
if provider == "copilot":
|
if provider == "copilot":
|
||||||
return False, active_sources
|
return False, active_sources
|
||||||
|
|
||||||
# Prefer ~/.hermes/.env over os.environ — the user's config file is the
|
# The .env-preferring resolution lives at module level
|
||||||
# authoritative source for Hermes credentials. Stale env vars from parent
|
# (``get_env_prefer_dotenv``) so the pool seeder and the per-turn
|
||||||
# processes (Codex CLI, test scripts, etc.) should not override deliberate
|
# credential refresh share one implementation.
|
||||||
# changes to the .env file.
|
_get_env_prefer_dotenv = get_env_prefer_dotenv
|
||||||
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
|
|
||||||
|
|
||||||
# Honour user suppression — `hermes auth remove <provider> <N>` for an
|
# Honour user suppression — `hermes auth remove <provider> <N>` for an
|
||||||
# env-seeded credential marks the env:<VAR> source as suppressed so it
|
# 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,
|
credential_pool=_credential_pool,
|
||||||
request_overrides=_request_overrides,
|
request_overrides=_request_overrides,
|
||||||
**_agent_kwargs,
|
**_agent_kwargs,
|
||||||
|
enabled_toolsets=["skills", "terminal"],
|
||||||
# Umbrella-building over a large skill collection is worth a
|
# Umbrella-building over a large skill collection is worth a
|
||||||
# high iteration ceiling — the pass typically takes 50-100
|
# high iteration ceiling — the pass typically takes 50-100
|
||||||
# API calls against hundreds of candidate skills. The
|
# 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 difflib import unified_diff
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from utils import safe_json_loads
|
from utils import safe_json_loads
|
||||||
from agent.redact import redact_sensitive_text
|
from agent.redact import redact_sensitive_text
|
||||||
@@ -187,6 +188,15 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
|
|||||||
return text
|
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_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
|
||||||
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
|
_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
|
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)
|
# 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)
|
# 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",
|
"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
|
# Malformed-message-array 400s. Deterministic request-shape rejections that
|
||||||
# describe the *transcript* being invalid, not a parameter. The canonical
|
# describe the *transcript* being invalid, not a parameter. The canonical
|
||||||
# case: a stream dies mid-response and Hermes persists a content-less
|
# case: a stream dies mid-response and Hermes persists a content-less
|
||||||
@@ -1061,6 +1087,18 @@ def _classify_by_status(
|
|||||||
retryable=False,
|
retryable=False,
|
||||||
should_fallback=True,
|
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
|
# Generic 404 with no "model not found" signal — could be a wrong
|
||||||
# endpoint path (common with local llama.cpp / Ollama / vLLM when
|
# endpoint path (common with local llama.cpp / Ollama / vLLM when
|
||||||
# the URL is slightly misconfigured), a proxy routing glitch, or
|
# the URL is slightly misconfigured), a proxy routing glitch, or
|
||||||
|
|||||||
+91
-28
@@ -99,6 +99,31 @@ class InsightsEngine:
|
|||||||
"""
|
"""
|
||||||
self.db = db
|
self.db = db
|
||||||
self._conn = db._conn
|
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]:
|
def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -171,6 +196,21 @@ class InsightsEngine:
|
|||||||
"top_sessions": top_sessions,
|
"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)
|
# Data gathering (SQL queries)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -195,6 +235,53 @@ class InsightsEngine:
|
|||||||
" ORDER BY started_at DESC"
|
" 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]:
|
def _get_sessions(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||||
"""Fetch sessions within the time window."""
|
"""Fetch sessions within the time window."""
|
||||||
if source:
|
if source:
|
||||||
@@ -243,22 +330,10 @@ class InsightsEngine:
|
|||||||
# (covers CLI sessions where tool_name is NULL on tool responses)
|
# (covers CLI sessions where tool_name is NULL on tool responses)
|
||||||
if source:
|
if source:
|
||||||
cursor2 = self._conn.execute(
|
cursor2 = self._conn.execute(
|
||||||
"""SELECT m.tool_calls
|
self._GET_TOOL_CALLS_WITH_SOURCE, (cutoff, source)
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor2 = self._conn.execute(
|
cursor2 = self._conn.execute(self._GET_TOOL_CALLS_ALL, (cutoff,))
|
||||||
"""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,),
|
|
||||||
)
|
|
||||||
|
|
||||||
tool_calls_counts = Counter()
|
tool_calls_counts = Counter()
|
||||||
for row in cursor2.fetchall():
|
for row in cursor2.fetchall():
|
||||||
@@ -301,22 +376,10 @@ class InsightsEngine:
|
|||||||
|
|
||||||
if source:
|
if source:
|
||||||
cursor = self._conn.execute(
|
cursor = self._conn.execute(
|
||||||
"""SELECT m.tool_calls, m.timestamp
|
self._GET_SKILL_CALLS_WITH_SOURCE, (cutoff, source)
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = self._conn.execute(
|
cursor = self._conn.execute(self._GET_SKILL_CALLS_ALL, (cutoff,))
|
||||||
"""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,),
|
|
||||||
)
|
|
||||||
|
|
||||||
for row in cursor.fetchall():
|
for row in cursor.fetchall():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -34,12 +34,50 @@ Optional hooks (override to opt in):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class MemoryProvider(ABC):
|
||||||
"""Abstract base class for memory providers."""
|
"""Abstract base class for memory providers."""
|
||||||
|
|
||||||
@@ -253,6 +291,10 @@ class MemoryProvider(ABC):
|
|||||||
required: True if required (default: False)
|
required: True if required (default: False)
|
||||||
default: default value (optional)
|
default: default value (optional)
|
||||||
choices: list of valid values (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)
|
url: URL where user can get this credential (optional)
|
||||||
env_var: explicit env var name for secrets (default: auto-generated)
|
env_var: explicit env var name for secrets (default: auto-generated)
|
||||||
|
|
||||||
|
|||||||
+67
-18
@@ -12,6 +12,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait as _futures_wait
|
from concurrent.futures import ThreadPoolExecutor, wait as _futures_wait
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
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
|
# Upper bound on concurrent reference-model calls. References are independent
|
||||||
# advisory calls (no tools, no inter-dependence), so we fan them out the same
|
# 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
|
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,
|
gets its provider's real API surface — e.g. MiniMax → anthropic_messages,
|
||||||
GPT-5/o-series → max_completion_tokens, custom endpoints → their base_url.
|
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
|
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
|
resolved base_url/api_key when available). Falls back to the bare
|
||||||
provider/model on any resolution error so a misconfigured slot still
|
provider/model on any resolution error so a misconfigured slot still
|
||||||
attempts the call rather than aborting the whole MoA turn.
|
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()
|
provider = str(slot.get("provider") or "").strip()
|
||||||
model = str(slot.get("model") 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}
|
out: dict[str, Any] = {"provider": provider, "model": model}
|
||||||
try:
|
try:
|
||||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
|
|
||||||
rt = resolve_runtime_provider(requested=provider, target_model=model)
|
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"):
|
if rt.get("base_url"):
|
||||||
out["base_url"] = rt["base_url"]
|
out["base_url"] = rt["base_url"]
|
||||||
if rt.get("api_key"):
|
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:
|
if isinstance(extra_body, dict) and extra_body:
|
||||||
out["extra_body"] = dict(extra_body)
|
out["extra_body"] = dict(extra_body)
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -1830,11 +1855,35 @@ class MoAChatCompletions:
|
|||||||
raise TypeError("_moa_prepared_request must be a dict")
|
raise TypeError("_moa_prepared_request must be a dict")
|
||||||
return self._call_prepared_aggregator(prepared_request, api_kwargs)
|
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
|
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 {}
|
_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)
|
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
|
# Privacy filter mode: '' (off, default) | 'display' | 'full'. See
|
||||||
# coerce_privacy_filter / the pattern block at the top of this module.
|
# coerce_privacy_filter / the pattern block at the top of this module.
|
||||||
# Remembered on self so _call_prepared_aggregator (which may run on a
|
# 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_TTL_SECONDS = 3600.0
|
||||||
_endpoint_probe_path_cache: Dict[str, tuple] = {}
|
_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 ────────────────────────────────
|
# ── Disk L2 for local-endpoint probe results ────────────────────────────────
|
||||||
# The in-process caches above die with the process, so every CLI cold start
|
# 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__:
|
# with a local model re-paid the probe waterfall in AIAgent.__init__:
|
||||||
@@ -382,6 +472,7 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||||||
"llama": 131072,
|
"llama": 131072,
|
||||||
# Qwen — specific model families before the catch-all.
|
# Qwen — specific model families before the catch-all.
|
||||||
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
|
# 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.6-plus": 1048576, # 1M context (DashScope/Alibaba & OpenRouter)
|
||||||
"qwen3.7-plus": 1048576, # 1M context (DashScope/Alibaba)
|
"qwen3.7-plus": 1048576, # 1M context (DashScope/Alibaba)
|
||||||
"qwen3-coder-plus": 1000000, # 1M context
|
"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``)
|
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
|
||||||
passes through untouched.
|
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 url
|
||||||
return re.sub(
|
return re.sub(
|
||||||
r"^(https?://)localhost(?=[:/]|$)",
|
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:
|
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
|
||||||
return cached[0]
|
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
|
# Disk L2: a fresh cross-process verdict skips the HTTP waterfall
|
||||||
# entirely (back-to-back CLI invocations, cron ticks).
|
# entirely (back-to-back CLI invocations, cron ticks).
|
||||||
disk_hit = _local_probe_disk_get("server_type", server_url)
|
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)
|
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
|
result: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=2.0, headers=headers) as client:
|
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")
|
r = client.get(f"{lmstudio_url}/api/v1/models")
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
result = "lm-studio"
|
result = "lm-studio"
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
_probe_failed(exc)
|
||||||
if result is None:
|
if result is None:
|
||||||
# Ollama exposes /api/tags and responds with {"models": [...]}
|
# Ollama exposes /api/tags and responds with {"models": [...]}
|
||||||
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
|
# 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"
|
result = "ollama"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
_probe_failed(exc)
|
||||||
if result is None:
|
if result is None:
|
||||||
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
||||||
try:
|
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
|
r = client.get(f"{server_url}/props") # fallback for older builds
|
||||||
if r.status_code == 200 and "default_generation_settings" in r.text:
|
if r.status_code == 200 and "default_generation_settings" in r.text:
|
||||||
result = "llamacpp"
|
result = "llamacpp"
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
_probe_failed(exc)
|
||||||
if result is None:
|
if result is None:
|
||||||
# vLLM: /version
|
# vLLM: /version
|
||||||
try:
|
try:
|
||||||
@@ -925,8 +1036,8 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
if "version" in data:
|
if "version" in data:
|
||||||
result = "vllm"
|
result = "vllm"
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
_probe_failed(exc)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1120,6 +1231,12 @@ def fetch_endpoint_model_metadata(
|
|||||||
if cached is not None and (time.time() - cached_at) < _ENDPOINT_MODEL_CACHE_TTL:
|
if cached is not None and (time.time() - cached_at) < _ENDPOINT_MODEL_CACHE_TTL:
|
||||||
return cached
|
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]
|
candidates = [normalized]
|
||||||
if normalized.endswith("/v1"):
|
if normalized.endswith("/v1"):
|
||||||
alternate = normalized[:-3].rstrip("/")
|
alternate = normalized[:-3].rstrip("/")
|
||||||
@@ -1182,9 +1299,20 @@ def fetch_endpoint_model_metadata(
|
|||||||
return cache
|
return cache
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
|
if _is_connect_timeout(exc):
|
||||||
|
_note_endpoint_blackholed(normalized)
|
||||||
|
|
||||||
for candidate in candidates:
|
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
|
response = None
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -1230,7 +1358,7 @@ def fetch_endpoint_model_metadata(
|
|||||||
if is_llamacpp:
|
if is_llamacpp:
|
||||||
try:
|
try:
|
||||||
# Try /v1/props first (current llama.cpp); fall back to /props for older builds
|
# 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()
|
_verify = _resolve_requests_verify()
|
||||||
props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5, verify=_verify)
|
props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5, verify=_verify)
|
||||||
if not props_resp.ok:
|
if not props_resp.ok:
|
||||||
@@ -1250,6 +1378,8 @@ def fetch_endpoint_model_metadata(
|
|||||||
return cache
|
return cache
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
|
if _is_connect_timeout(exc):
|
||||||
|
_note_endpoint_blackholed(normalized)
|
||||||
finally:
|
finally:
|
||||||
if response is not None:
|
if response is not None:
|
||||||
response.close()
|
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"):
|
if server_url.endswith("/v1"):
|
||||||
server_url = server_url[:-3]
|
server_url = server_url[:-3]
|
||||||
|
|
||||||
|
if _endpoint_blackholed(server_url):
|
||||||
|
return None
|
||||||
|
|
||||||
headers = _auth_headers(api_key)
|
headers = _auth_headers(api_key)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1844,8 +1977,9 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = ""
|
|||||||
return ctx
|
return ctx
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
if _is_connect_timeout(exc):
|
||||||
|
_note_endpoint_blackholed(server_url)
|
||||||
return None
|
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]
|
server_url = server_url[:-3]
|
||||||
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
|
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
|
||||||
|
|
||||||
|
if _endpoint_blackholed(server_url):
|
||||||
|
return None
|
||||||
|
|
||||||
headers = _auth_headers(api_key)
|
headers = _auth_headers(api_key)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2003,13 +2140,39 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
|
|||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
models_list = data.get("data", [])
|
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:
|
for m in models_list:
|
||||||
if _model_id_matches(m.get("id", ""), model):
|
if _model_id_matches(m.get("id", ""), model):
|
||||||
ctx = m.get("max_model_len") or m.get("context_length") or m.get("max_tokens")
|
matched = m
|
||||||
if ctx and isinstance(ctx, (int, float)):
|
break
|
||||||
return int(ctx)
|
if matched is None and len(models_list) == 1:
|
||||||
except Exception:
|
matched = models_list[0]
|
||||||
pass
|
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
|
return None
|
||||||
|
|
||||||
|
|||||||
+54
-20
@@ -482,11 +482,9 @@ class RelayTurnContext:
|
|||||||
default_factory=threading.RLock,
|
default_factory=threading.RLock,
|
||||||
repr=False,
|
repr=False,
|
||||||
)
|
)
|
||||||
_token: contextvars.Token[RelayTurnContext | None] | None = field(
|
_previous_turn: RelayTurnContext | None = field(default=None, repr=False)
|
||||||
default=None,
|
|
||||||
repr=False,
|
|
||||||
)
|
|
||||||
_active_registered: bool = field(default=False, repr=False)
|
_active_registered: bool = field(default=False, repr=False)
|
||||||
|
relay_enabled: bool = True
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -600,7 +598,28 @@ class RelaySessionCoordinator:
|
|||||||
if lease.released:
|
if lease.released:
|
||||||
raise RuntimeError("Hermes Relay conversation lease is released")
|
raise RuntimeError("Hermes Relay conversation lease is released")
|
||||||
turn = RelayTurnContext(lease=lease, turn_id=turn_id, task_id=task_id)
|
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:
|
try:
|
||||||
turn.handle = lease.host.run_in_session(
|
turn.handle = lease.host.run_in_session(
|
||||||
lease.session,
|
lease.session,
|
||||||
@@ -617,11 +636,8 @@ class RelaySessionCoordinator:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Hermes Relay turn initialization failed", exc_info=True)
|
logger.warning("Hermes Relay turn initialization failed", exc_info=True)
|
||||||
turn._token = _CURRENT_TURN.set(turn)
|
turn._previous_turn = _CURRENT_TURN.get()
|
||||||
key = (lease.profile_key, lease.session_id)
|
_CURRENT_TURN.set(turn)
|
||||||
with self._active_turns_lock:
|
|
||||||
self._active_turns.setdefault(key, set()).add(id(turn))
|
|
||||||
turn._active_registered = True
|
|
||||||
return turn
|
return turn
|
||||||
|
|
||||||
def end_turn(
|
def end_turn(
|
||||||
@@ -755,16 +771,18 @@ class RelaySessionCoordinator:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _reset_turn_context(turn: RelayTurnContext) -> None:
|
def _reset_turn_context(turn: RelayTurnContext) -> None:
|
||||||
"""Reset the originating ContextVar token when called in that context."""
|
"""Unwind ``turn`` without disturbing a newer context-local turn."""
|
||||||
if turn._token is None:
|
if _CURRENT_TURN.get() is not turn:
|
||||||
return
|
return
|
||||||
try:
|
previous = turn._previous_turn
|
||||||
_CURRENT_TURN.reset(turn._token)
|
seen = {id(turn)}
|
||||||
except ValueError:
|
while previous is not None and previous.closed:
|
||||||
# A copied async/thread context may own terminal cleanup. Keep the
|
if id(previous) in seen:
|
||||||
# token so the originating context can clear its stale reference.
|
previous = None
|
||||||
return
|
break
|
||||||
turn._token = None
|
seen.add(id(previous))
|
||||||
|
previous = previous._previous_turn
|
||||||
|
_CURRENT_TURN.set(previous)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def release_conversation(lease: ConversationLease) -> None:
|
def release_conversation(lease: ConversationLease) -> None:
|
||||||
@@ -793,10 +811,21 @@ def current_turn() -> RelayTurnContext | None:
|
|||||||
return _CURRENT_TURN.get()
|
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:
|
def active_turn(session_id: str | None = None) -> RelayTurnContext | None:
|
||||||
"""Return a live turn only when it belongs to the active profile/session."""
|
"""Return a live turn only when it belongs to the active profile/session."""
|
||||||
turn = current_turn()
|
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
|
return None
|
||||||
if turn.lease.profile_key != current_profile_key():
|
if turn.lease.profile_key != current_profile_key():
|
||||||
return None
|
return None
|
||||||
@@ -814,6 +843,11 @@ def resolve_execution_context(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
) -> tuple[RelayRuntime | None, RelaySession | None, Any]:
|
) -> tuple[RelayRuntime | None, RelaySession | None, Any]:
|
||||||
"""Resolve one active turn/session parent for managed Relay execution."""
|
"""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)
|
turn = active_turn(session_id)
|
||||||
if (
|
if (
|
||||||
turn is not None
|
turn is not None
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ the conversation without modifying the system prompt (preserving prompt caching)
|
|||||||
Inspired by Block/goose's SubdirectoryHintTracker.
|
Inspired by Block/goose's SubdirectoryHintTracker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
@@ -45,6 +46,18 @@ _COMMAND_TOOLS = {"terminal"}
|
|||||||
# Prevents scanning all the way to / for deeply nested paths.
|
# Prevents scanning all the way to / for deeply nested paths.
|
||||||
_MAX_ANCESTOR_WALK = 5
|
_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:
|
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)."""
|
"""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:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class SubdirectoryHintTracker:
|
class SubdirectoryHintTracker:
|
||||||
"""Track which directories the agent visits and load hints on first access.
|
"""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):
|
def __init__(self, working_dir: Optional[str] = None):
|
||||||
self.working_dir = Path(working_dir or os.getcwd()).resolve()
|
self.working_dir = Path(working_dir or os.getcwd()).resolve()
|
||||||
self._loaded_dirs: Set[Path] = set()
|
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)
|
# Pre-mark the working dir as loaded (startup context handles it)
|
||||||
self._loaded_dirs.add(self.working_dir)
|
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(
|
def check_tool_call(
|
||||||
self,
|
self,
|
||||||
@@ -193,8 +233,25 @@ class SubdirectoryHintTracker:
|
|||||||
# check as a best-effort safeguard.
|
# check as a best-effort safeguard.
|
||||||
if not _is_ancestor_or_same(self.working_dir, path):
|
if not _is_ancestor_or_same(self.working_dir, path):
|
||||||
return False
|
return False
|
||||||
|
if self._is_excluded(path):
|
||||||
|
return False
|
||||||
return True
|
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]:
|
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
|
||||||
"""Load hint files from a directory. Returns formatted text or None.
|
"""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()
|
content = hint_path.read_text(encoding="utf-8").strip()
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
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
|
# Same security scan as startup context loading
|
||||||
content = _scan_context_content(content, filename)
|
content = _scan_context_content(content, filename)
|
||||||
if len(content) > _MAX_HINT_CHARS:
|
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
|
* ``stable`` — identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool
|
||||||
guidance, computer-use guidance, nous subscription block, tool-use
|
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,
|
alibaba model-name workaround, environment hints, coding guidance,
|
||||||
platform hints.
|
platform hints.
|
||||||
* ``context`` — caller-supplied ``system_message`` plus context files
|
* ``context`` — caller-supplied ``system_message`` plus context files
|
||||||
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``,
|
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``,
|
||||||
plus the session's coding-workspace snapshot.
|
plus the session's coding-workspace snapshot.
|
||||||
* ``volatile`` — memory snapshot, USER.md profile, external memory
|
* ``volatile`` — skills index, memory snapshot, USER.md profile, external
|
||||||
provider block, timestamp/session/model/provider line.
|
memory provider block, timestamp/session/model/provider line.
|
||||||
|
|
||||||
Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
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
|
* ``context`` — the workspace snapshot followed by the remaining
|
||||||
session-stable guidance, context files, and caller-supplied
|
session-stable guidance, context files, and caller-supplied
|
||||||
system_message.
|
system_message.
|
||||||
* ``volatile`` — memory snapshot, user profile, external
|
* ``volatile`` — skills index, memory snapshot, user profile,
|
||||||
memory provider block, timestamp line.
|
external memory provider block, timestamp line.
|
||||||
|
|
||||||
Joined into a single string by :func:`build_system_prompt` and
|
Joined into a single string by :func:`build_system_prompt` and
|
||||||
cached on ``agent._cached_system_prompt`` for the lifetime of the
|
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:
|
else:
|
||||||
skills_prompt = ""
|
skills_prompt = ""
|
||||||
if skills_prompt:
|
|
||||||
stable_parts.append(skills_prompt)
|
|
||||||
|
|
||||||
# Alibaba Coding Plan API always returns "glm-4.7" as model name regardless
|
# 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
|
# 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:
|
if context_files_prompt:
|
||||||
context_parts.append(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] = []
|
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_store:
|
||||||
if agent._memory_enabled:
|
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,
|
Layers are ordered cache-friendly: stable identity/guidance first,
|
||||||
then session-stable context files, then per-call volatile content
|
then session-stable context files, then per-call volatile content
|
||||||
(memory, USER profile, timestamp). The whole string is treated as
|
(skills index, memory, USER profile, timestamp). For explicit
|
||||||
one cached block — Hermes never rebuilds or reinjects parts of it
|
cache_control backends the whole string is one cached block. For
|
||||||
mid-session, which is the only way to keep upstream prompt caches
|
implicit longest-prefix backends the order is what matters: the
|
||||||
warm across turns.
|
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)
|
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)
|
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
|
Safety: the rebuilt stable tier is used ONLY when the stored prompt
|
||||||
literally starts with it (checked here AND re-checked by
|
literally starts with it (checked here AND re-checked by
|
||||||
``_apply_system_cache_markers``'s ``startswith`` gate). If any
|
``_apply_system_cache_markers``'s ``startswith`` gate). If any
|
||||||
stable-tier input changed since the prompt was persisted (skills
|
stable-tier input changed since the prompt was persisted (identity
|
||||||
edited, identity changed), the prefix mismatches, the static stays
|
changed, SOUL.md edited), the prefix mismatches, the static stays
|
||||||
None, and requests fall back to the legacy layout with the stored
|
None, and requests fall back to the legacy layout with the stored
|
||||||
prompt bytes untouched — never a rewritten prompt.
|
prompt bytes untouched — never a rewritten prompt.
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ _PARALLEL_SAFE_TOOLS = frozenset({
|
|||||||
"ha_get_state",
|
"ha_get_state",
|
||||||
"ha_list_entities",
|
"ha_list_entities",
|
||||||
"ha_list_services",
|
"ha_list_services",
|
||||||
|
"image_generate",
|
||||||
"read_file",
|
"read_file",
|
||||||
"search_files",
|
"search_files",
|
||||||
"session_search",
|
"session_search",
|
||||||
|
|||||||
+42
-1
@@ -93,6 +93,7 @@ def _budget_for_agent(agent) -> BudgetConfig:
|
|||||||
# Maximum number of concurrent worker threads for parallel tool execution.
|
# Maximum number of concurrent worker threads for parallel tool execution.
|
||||||
# Mirrors the constant in ``run_agent`` for tests/imports that look here.
|
# Mirrors the constant in ``run_agent`` for tests/imports that look here.
|
||||||
_MAX_TOOL_WORKERS = 8
|
_MAX_TOOL_WORKERS = 8
|
||||||
|
_DEFAULT_IMAGE_PARALLEL_REQUESTS = 4
|
||||||
# Keep this above the stock auxiliary.web_extract timeout (360s) so the batch
|
# Keep this above the stock auxiliary.web_extract timeout (360s) so the batch
|
||||||
# guard does not preempt a slow-but-valid summarization attempt.
|
# guard does not preempt a slow-but-valid summarization attempt.
|
||||||
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
|
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
|
||||||
@@ -159,6 +160,46 @@ def _flush_session_db_after_tool_progress(
|
|||||||
return False
|
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():
|
def _ra():
|
||||||
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
|
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
|
||||||
import run_agent
|
import run_agent
|
||||||
@@ -953,7 +994,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||||||
timeout_s = _resolve_concurrent_tool_timeout()
|
timeout_s = _resolve_concurrent_tool_timeout()
|
||||||
deadline = time.monotonic() + timeout_s if timeout_s is not None else None
|
deadline = time.monotonic() + timeout_s if timeout_s is not None else None
|
||||||
if runnable_calls:
|
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
|
# Daemon workers: an interrupted/timed-out batch is abandoned with
|
||||||
# shutdown(wait=False), but stdlib ThreadPoolExecutor workers are
|
# shutdown(wait=False), but stdlib ThreadPoolExecutor workers are
|
||||||
# non-daemon and registered in concurrent.futures' atexit hook,
|
# 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.
|
reasoning configuration, temperature handling, and extra_body assembly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
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
|
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:
|
def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None:
|
||||||
"""Return the model's wire-compatible reasoning config."""
|
"""Return the model's wire-compatible reasoning config."""
|
||||||
if not isinstance(reasoning_config, dict):
|
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")
|
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:
|
def _model_consumes_thought_signature(model: Any) -> bool:
|
||||||
"""True when the outgoing model is a Gemini family model that requires
|
"""True when the outgoing model is a Gemini family model that requires
|
||||||
``extra_content`` (thought_signature) to be replayed on tool calls.
|
``extra_content`` (thought_signature) to be replayed on tool calls.
|
||||||
@@ -327,6 +396,8 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||||||
# Claude on OpenRouter/Nous max output
|
# Claude on OpenRouter/Nous max output
|
||||||
anthropic_max_output: int | None
|
anthropic_max_output: int | None
|
||||||
extra_body_additions: dict | 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.
|
# Codex sanitization: drop reasoning_items / call_id / response_item_id.
|
||||||
# Pass model so the Gemini thought_signature (extra_content) is kept for
|
# Pass model so the Gemini thought_signature (extra_content) is kept for
|
||||||
@@ -507,6 +578,14 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||||||
if overrides:
|
if overrides:
|
||||||
api_kwargs.update(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
|
return api_kwargs
|
||||||
|
|
||||||
def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
|
def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
|
||||||
@@ -649,6 +728,13 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||||||
if extra_body:
|
if extra_body:
|
||||||
api_kwargs["extra_body"] = 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
|
return api_kwargs
|
||||||
|
|
||||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
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}"
|
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 = (
|
_EXTENDED_PROMPT_CACHE_MODELS = (
|
||||||
"gpt-5.5-pro",
|
"gpt-5.5-pro",
|
||||||
"gpt-5.5",
|
"gpt-5.5",
|
||||||
@@ -232,63 +278,41 @@ class ResponsesApiTransport(ProviderTransport):
|
|||||||
|
|
||||||
response_tools = _responses_tools(tools)
|
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 models on xAI's /v1/responses surface have a *native*,
|
||||||
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
|
# server-executed web search. A client-side function literally named
|
||||||
# server-executed web search. When the model is handed a
|
# ``web_search`` collides with that engine: declared as a plain
|
||||||
# client-side function literally named ``web_search``, it routes
|
# ``function`` rather than ``{"type": "web_search"}``, the search
|
||||||
# the intent to that native engine — but because the tool is
|
# dispatches but never reconciles → incomplete turn + 3 retries.
|
||||||
# declared as a plain ``function`` rather than xAI's first-class
|
# Verified live against grok-composer-2.5-fast (2026-06); see #48108.
|
||||||
# ``{"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).
|
|
||||||
#
|
#
|
||||||
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
|
# Two modes, chosen by the user's web-search backend config:
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Scope: we ONLY swap in the native built-in when the client
|
# 1. **Native** (active/configured backend is ``xai``, or resolution
|
||||||
# ``web_search`` was actually present. We do NOT force-enable Grok
|
# fails): drop the client ``web_search`` function and declare
|
||||||
# server-side search on turns where the user never had web enabled —
|
# xAI's built-in instead. 1:1 swap only when client ``web_search``
|
||||||
# that would silently route around Hermes's web-provider config and
|
# was already present — never an additive grant.
|
||||||
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
|
# 2. **Client** (Firecrawl / Tavily / Exa / … configured or resolved):
|
||||||
# a 1:1 replacement of an already-requested capability, not an
|
# keep Hermes dispatch so ``web.backend`` / ``web.search_backend``
|
||||||
# additive grant.
|
# is honored, but rename the wire tool to
|
||||||
#
|
# ``hermes_web_search`` so Grok cannot hijack the name. The alias
|
||||||
# NOTE: for the swapped case this routes ``web_search`` to Grok's
|
# is mapped back to ``web_search`` in ``normalize_response``.
|
||||||
# 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.
|
|
||||||
if is_xai_responses and response_tools:
|
if is_xai_responses and response_tools:
|
||||||
has_client_web_search = any(
|
has_client_web_search = any(
|
||||||
isinstance(t, dict) and t.get("name") == "web_search"
|
isinstance(t, dict) and t.get("name") == "web_search"
|
||||||
for t in response_tools
|
for t in response_tools
|
||||||
)
|
)
|
||||||
if has_client_web_search:
|
if has_client_web_search:
|
||||||
|
if _xai_prefers_native_web_search():
|
||||||
filtered = [
|
filtered = [
|
||||||
t for t in response_tools
|
t for t in response_tools
|
||||||
if not (isinstance(t, dict) and t.get("name") == "web_search")
|
if not (isinstance(t, dict) and t.get("name") == "web_search")
|
||||||
]
|
]
|
||||||
filtered.append({"type": "web_search"})
|
filtered.append({"type": "web_search"})
|
||||||
response_tools = filtered
|
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
|
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||||
@@ -487,9 +511,14 @@ class ResponsesApiTransport(ProviderTransport):
|
|||||||
provider_data["call_id"] = tc.call_id
|
provider_data["call_id"] = tc.call_id
|
||||||
if hasattr(tc, "response_item_id") and tc.response_item_id:
|
if hasattr(tc, "response_item_id") and tc.response_item_id:
|
||||||
provider_data["response_item_id"] = 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(
|
tool_calls.append(ToolCall(
|
||||||
id=tc.id if hasattr(tc, "id") else (tc.function.name if hasattr(tc, "function") else None),
|
id=tc.id if hasattr(tc, "id") else (name or None),
|
||||||
name=tc.function.name if hasattr(tc, "function") else getattr(tc, "name", ""),
|
name=name,
|
||||||
arguments=tc.function.arguments if hasattr(tc, "function") else getattr(tc, "arguments", "{}"),
|
arguments=tc.function.arguments if hasattr(tc, "function") else getattr(tc, "arguments", "{}"),
|
||||||
provider_data=provider_data or None,
|
provider_data=provider_data or None,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1259,6 +1259,8 @@ def _approval_choice_to_codex_decision(choice: str) -> str:
|
|||||||
return "accept"
|
return "accept"
|
||||||
if choice in {"session", "always"}:
|
if choice in {"session", "always"}:
|
||||||
return "acceptForSession"
|
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"
|
return "decline"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from agent.conversation_compression import (
|
|||||||
from agent.context_engine import automatic_compaction_status_message
|
from agent.context_engine import automatic_compaction_status_message
|
||||||
from agent.iteration_budget import IterationBudget
|
from agent.iteration_budget import IterationBudget
|
||||||
from agent.memory_manager import build_memory_context_block
|
from agent.memory_manager import build_memory_context_block
|
||||||
|
from agent.memory_provider import is_trivial_prompt
|
||||||
from agent.model_metadata import (
|
from agent.model_metadata import (
|
||||||
estimate_messages_tokens_rough,
|
estimate_messages_tokens_rough,
|
||||||
estimate_request_tokens_rough,
|
estimate_request_tokens_rough,
|
||||||
@@ -1152,10 +1153,14 @@ def build_turn_context(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# External memory provider: prefetch once before the tool loop.
|
# 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 = ""
|
ext_prefetch_cache = ""
|
||||||
if agent._memory_manager:
|
if agent._memory_manager:
|
||||||
try:
|
try:
|
||||||
_query = original_user_message if isinstance(original_user_message, str) else ""
|
_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 ""
|
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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: 'cut' },
|
||||||
{ role: 'copy' },
|
{ role: 'copy' },
|
||||||
{ role: 'paste' },
|
{ 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: 'delete' },
|
||||||
{ role: 'selectAll' }
|
{ 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.
|
// 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
|
// 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.
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10504,6 +10510,22 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
|||||||
return true
|
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
|
// Paired reader for the GUI terminal's paste chord: the renderer's
|
||||||
// navigator.clipboard.readText() throws "Document is not focused" whenever a
|
// 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
|
// 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),
|
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
||||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||||
|
selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options),
|
||||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||||
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
|
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
|
||||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
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) |
|
| `transcript` | ci | large-transcript mount + paint cost | (new) |
|
||||||
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (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) |
|
| `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) |
|
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
|
||||||
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
||||||
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
|
| `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 multitab from './multitab.mjs'
|
||||||
import profileSwitch from './profile-switch.mjs'
|
import profileSwitch from './profile-switch.mjs'
|
||||||
import renderChurn from './render-churn.mjs'
|
import renderChurn from './render-churn.mjs'
|
||||||
|
import rightPane from './right-pane.mjs'
|
||||||
import sessionLoad from './session-load.mjs'
|
import sessionLoad from './session-load.mjs'
|
||||||
import sessionSwitch from './session-switch.mjs'
|
import sessionSwitch from './session-switch.mjs'
|
||||||
import stream from './stream.mjs'
|
import stream from './stream.mjs'
|
||||||
@@ -22,6 +23,7 @@ export const SCENARIOS = {
|
|||||||
[transcript.name]: transcript,
|
[transcript.name]: transcript,
|
||||||
[multitab.name]: multitab,
|
[multitab.name]: multitab,
|
||||||
[renderChurn.name]: renderChurn,
|
[renderChurn.name]: renderChurn,
|
||||||
|
[rightPane.name]: rightPane,
|
||||||
[idleCost.name]: idleCost,
|
[idleCost.name]: idleCost,
|
||||||
[coldStart.name]: coldStart,
|
[coldStart.name]: coldStart,
|
||||||
[firstToken.name]: firstToken,
|
[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 { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||||
|
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { FadeText } from '@/components/ui/fade-text'
|
import { FadeText } from '@/components/ui/fade-text'
|
||||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
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 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 cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
|
||||||
|
|
||||||
|
const visible = usePaneVisible()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active <= 0 || typeof window === 'undefined') {
|
if (active <= 0 || !visible || typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
||||||
|
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [active])
|
}, [active, visible])
|
||||||
|
|
||||||
if (tree.length === 0) {
|
if (tree.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -446,10 +446,7 @@ export function ChatBar({
|
|||||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||||
|
|
||||||
if (imageBlobs.length > 0) {
|
if (imageBlobs.length > 0 && onAttachImageBlob) {
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
if (onAttachImageBlob) {
|
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
|
|
||||||
for (const blob of imageBlobs) {
|
for (const blob of imageBlobs) {
|
||||||
@@ -457,9 +454,6 @@ export function ChatBar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||||
@@ -469,6 +463,10 @@ export function ChatBar({
|
|||||||
if (!pastedText) {
|
if (!pastedText) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (imageBlobs.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
|
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
|
||||||
// the Linux clipboard the DOM paste event reads, so a host screenshot
|
// 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
|
// 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])
|
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', () => {
|
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.
|
// `:` 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 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. */
|
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||||
export function blobDedupeKey(blob: Blob): string {
|
export function blobDedupeKey(blob: Blob): string {
|
||||||
if (blob instanceof File) {
|
if (blob instanceof File) {
|
||||||
@@ -125,16 +130,22 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
|||||||
|
|
||||||
if (DATA_IMAGE_URL_RE.test(text)) {
|
if (DATA_IMAGE_URL_RE.test(text)) {
|
||||||
push(dataUrlToBlob(text))
|
push(dataUrlToBlob(text))
|
||||||
|
|
||||||
|
return blobs
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blobs.length === 0) {
|
// Inline `<img src="data:…">` in the clipboard's HTML — but only for a copy
|
||||||
const html = clipboard.getData('text/html')
|
// 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) {
|
if (blob && blob.size >= MIN_INLINE_IMAGE_BYTES) {
|
||||||
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
|
push(blob)
|
||||||
|
|
||||||
for (const match of matches) {
|
|
||||||
push(dataUrlToBlob(match[1]))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useQuery } from '@tanstack/react-query'
|
||||||
import type { ReadableAtom } from 'nanostores'
|
import type { ReadableAtom } from 'nanostores'
|
||||||
import type * as React from 'react'
|
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 { useLocation } from 'react-router'
|
||||||
|
|
||||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||||
import { Thread } from '@/components/assistant-ui/thread'
|
import { Thread } from '@/components/assistant-ui/thread'
|
||||||
|
import { TranscriptWindowProvider } from '@/components/assistant-ui/thread/transcript-window'
|
||||||
import { Backdrop } from '@/components/Backdrop'
|
import { Backdrop } from '@/components/Backdrop'
|
||||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
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 { useSessionView } from './session-view'
|
||||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||||
import { threadLoadingState } from './thread-loading'
|
import { threadLoadingState } from './thread-loading'
|
||||||
|
import { selectTranscriptWindow } from './transcript-window'
|
||||||
|
|
||||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||||
gateway: HermesGateway | null
|
gateway: HermesGateway | null
|
||||||
@@ -220,9 +222,34 @@ function ChatRuntimeBoundary({
|
|||||||
onThreadMessagesChange,
|
onThreadMessagesChange,
|
||||||
suppressMessages
|
suppressMessages
|
||||||
}: ChatRuntimeBoundaryProps) {
|
}: 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 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>({
|
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||||
messageRepository: runtimeMessageRepository,
|
messageRepository: runtimeMessageRepository,
|
||||||
@@ -237,10 +264,17 @@ function ChatRuntimeBoundary({
|
|||||||
onReload
|
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,
|
className,
|
||||||
gateway,
|
gateway,
|
||||||
modelMenuContent,
|
modelMenuContent,
|
||||||
@@ -596,4 +630,4 @@ export function ChatView({
|
|||||||
</ChatRuntimeBoundary>
|
</ChatRuntimeBoundary>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
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 { $gateway } from '@/store/gateway'
|
||||||
import { $messages, setBusy, setMessages } from '@/store/session'
|
import { $currentCwd, $messages, setBusy, setCurrentCwdTransient, setMessages } from '@/store/session'
|
||||||
|
|
||||||
type Sample = {
|
type Sample = {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,6 +49,12 @@ declare global {
|
|||||||
* backend) doesn't contaminate frame-pacing numbers.
|
* backend) doesn't contaminate frame-pacing numbers.
|
||||||
*/
|
*/
|
||||||
connected: () => boolean
|
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
|
reset: () => void
|
||||||
snapshotMsgs: () => number
|
snapshotMsgs: () => number
|
||||||
}
|
}
|
||||||
@@ -102,11 +119,32 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
|||||||
let baseline: ReturnType<typeof $messages.get> | null = null
|
let baseline: ReturnType<typeof $messages.get> | null = null
|
||||||
let activeHandle: SyntheticDriverHandle | 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 = () => {
|
const stop = () => {
|
||||||
activeHandle = null
|
activeHandle = null
|
||||||
setBusy(false)
|
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
|
// 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
|
// 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
|
// 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
|
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) => {
|
loadTranscript: (turns = 200) => {
|
||||||
if (!baseline) {
|
if (!baseline) {
|
||||||
baseline = $messages.get()
|
baseline = $messages.get()
|
||||||
@@ -190,6 +291,7 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
|||||||
},
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
activeHandle?.stop()
|
activeHandle?.stop()
|
||||||
|
resetRightPane()
|
||||||
|
|
||||||
if (baseline) {
|
if (baseline) {
|
||||||
setMessages(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'])
|
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 { useStore } from '@nanostores/react'
|
||||||
|
|
||||||
|
import { StatusPulse } from '@/components/ui/status-pulse'
|
||||||
import { type Translations, useI18n } from '@/i18n'
|
import { type Translations, useI18n } from '@/i18n'
|
||||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -17,6 +18,10 @@ import { type SessionDotState, sessionDotState } from './sidebar/session-row-sta
|
|||||||
type DotVariant = {
|
type DotVariant = {
|
||||||
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||||
className: string
|
className: string
|
||||||
|
pulse?: {
|
||||||
|
className: string
|
||||||
|
opacity: number
|
||||||
|
}
|
||||||
role?: 'status'
|
role?: 'status'
|
||||||
title?: (r: Translations['sidebar']['row']) => string
|
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.
|
// Shared base for every active dot; idle is smaller and uses its own class.
|
||||||
const DOT_BASE = 'relative size-1.5 rounded-full'
|
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> = {
|
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
// 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.
|
// Accent pulse — the LLM turn is actively running.
|
||||||
working: {
|
working: {
|
||||||
ariaLabel: r => r.sessionRunning,
|
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'
|
role: 'status'
|
||||||
},
|
},
|
||||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||||
// stream activity has arrived for the watchdog window.
|
// stream activity has arrived for the watchdog window.
|
||||||
stalled: {
|
stalled: {
|
||||||
ariaLabel: r => r.sessionRunning,
|
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',
|
role: 'status',
|
||||||
title: r => r.sessionRunning
|
title: r => r.sessionRunning
|
||||||
},
|
},
|
||||||
@@ -58,7 +65,11 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
|||||||
// than muted-foreground so it's visible against the surface.
|
// than muted-foreground so it's visible against the surface.
|
||||||
background: {
|
background: {
|
||||||
ariaLabel: r => r.backgroundRunning,
|
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',
|
role: 'status',
|
||||||
title: r => r.backgroundRunning
|
title: r => r.backgroundRunning
|
||||||
},
|
},
|
||||||
@@ -123,6 +134,7 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
|||||||
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
|
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
|
||||||
|
|
||||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||||
|
const variant = DOT_VARIANTS[dotState]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn('flex items-center gap-0.5', className)}>
|
<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-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
|
aria-label={variant.ariaLabel?.(r)}
|
||||||
className={DOT_VARIANTS[dotState].className}
|
className={variant.className}
|
||||||
role={DOT_VARIANTS[dotState].role}
|
role={variant.role}
|
||||||
title={DOT_VARIANTS[dotState].title?.(r)}
|
title={variant.title?.(r)}
|
||||||
|
>
|
||||||
|
{variant.pulse ? (
|
||||||
|
<StatusPulse
|
||||||
|
aria-hidden="true"
|
||||||
|
className={variant.pulse.className}
|
||||||
|
kind="ping"
|
||||||
|
opacity={variant.pulse.opacity}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</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({
|
function TileChat({
|
||||||
runtimeId,
|
runtimeId,
|
||||||
storedSessionId,
|
storedSessionId,
|
||||||
@@ -144,6 +151,29 @@ function TileChat({
|
|||||||
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
|
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
|
// 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).
|
// + switch target THIS runtime, not the primary (which may be mid-turn).
|
||||||
const modelMenuContent = useMemo(
|
const modelMenuContent = useMemo(
|
||||||
@@ -165,27 +195,27 @@ function TileChat({
|
|||||||
<ChatView
|
<ChatView
|
||||||
gateway={gateway}
|
gateway={gateway}
|
||||||
modelMenuContent={modelMenuContent}
|
modelMenuContent={modelMenuContent}
|
||||||
onAddContextRef={composer.addContextRefAttachment}
|
onAddContextRef={addContextRefAttachment}
|
||||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
onAddUrl={onAddUrl}
|
||||||
onAttachDroppedItems={composer.attachDroppedItems}
|
onAttachDroppedItems={composer.attachDroppedItems}
|
||||||
onAttachImageBlob={composer.attachImageBlob}
|
onAttachImageBlob={composer.attachImageBlob}
|
||||||
onCancel={actions.cancelRun}
|
onCancel={actions.cancelRun}
|
||||||
onDeleteSelectedSession={() => undefined}
|
onDeleteSelectedSession={noop}
|
||||||
onDismissError={actions.dismissError}
|
onDismissError={actions.dismissError}
|
||||||
onEdit={actions.editMessage}
|
onEdit={actions.editMessage}
|
||||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
onPasteClipboardImage={onPasteClipboardImage}
|
||||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
onPickFiles={onPickFiles}
|
||||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
onPickFolders={onPickFolders}
|
||||||
onPickImages={() => void composer.pickImages()}
|
onPickImages={onPickImages}
|
||||||
onReload={actions.reloadFromMessage}
|
onReload={actions.reloadFromMessage}
|
||||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
onRemoveAttachment={onRemoveAttachment}
|
||||||
onRestoreToMessage={actions.restoreToMessage}
|
onRestoreToMessage={actions.restoreToMessage}
|
||||||
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
|
onRetryResume={onRetryResume}
|
||||||
onSteer={actions.steerPrompt}
|
onSteer={actions.steerPrompt}
|
||||||
onSubmit={actions.submitText}
|
onSubmit={actions.submitText}
|
||||||
onThreadMessagesChange={actions.handleThreadMessagesChange}
|
onThreadMessagesChange={actions.handleThreadMessagesChange}
|
||||||
onToggleSelectedPin={() => undefined}
|
onToggleSelectedPin={noop}
|
||||||
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
|
onTranscribeAudio={tileTranscribeAudio}
|
||||||
/>
|
/>
|
||||||
</ComposerScopeProvider>
|
</ComposerScopeProvider>
|
||||||
</SessionViewProvider>
|
</SessionViewProvider>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useEffect, useMemo, useState } from '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 { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
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".
|
// Rows revealed so far; starts compact, grows in steps via "load more".
|
||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
|
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
|
||||||
|
|
||||||
|
const visible = usePaneVisible()
|
||||||
|
|
||||||
// One clock for the whole section (rows are pure) so the countdowns tick
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open || !visible) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||||
|
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [open])
|
}, [open, visible])
|
||||||
|
|
||||||
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
|
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
|
||||||
// then alphabetical for stability.
|
// then alphabetical for stability.
|
||||||
@@ -328,6 +331,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
|||||||
const changeEventsAvailable = useStore($changeEventsAvailable)
|
const changeEventsAvailable = useStore($changeEventsAvailable)
|
||||||
const cronChangeTick = useStore($cronChangeTick)
|
const cronChangeTick = useStore($cronChangeTick)
|
||||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||||
|
const visible = usePaneVisible()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
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()
|
void load()
|
||||||
|
|
||||||
const intervalId = window.setInterval(
|
const intervalId = window.setInterval(
|
||||||
@@ -361,7 +374,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
|||||||
window.clearInterval(intervalId)
|
window.clearInterval(intervalId)
|
||||||
}
|
}
|
||||||
// cronChangeTick: a fired run reloads the peek immediately.
|
// cronChangeTick: a fired run reloads the peek immediately.
|
||||||
}, [changeEventsAvailable, cronChangeTick, jobId])
|
}, [changeEventsAvailable, cronChangeTick, jobId, visible])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
setShowAllProfiles,
|
setShowAllProfiles,
|
||||||
sortByProfileOrder
|
sortByProfileOrder
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
|
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
||||||
import type { ProfileInfo } from '@/types/hermes'
|
import type { ProfileInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
||||||
@@ -264,6 +265,7 @@ export function ProfileRail() {
|
|||||||
profiles={named}
|
profiles={named}
|
||||||
/>
|
/>
|
||||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||||
|
<ImportProfileButton label={p.importProfile} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -302,6 +304,7 @@ export function ProfileRail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||||
|
<ImportProfileButton label={p.importProfile} />
|
||||||
</div>
|
</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
|
// The condensed rail: every named profile in one compact select. The trigger
|
||||||
// shows the active profile (tinted initial + name); on default/all scope it
|
// 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.
|
// 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" />
|
<Codicon name="edit" size="0.875rem" />
|
||||||
<span>{p.editSoul}</span>
|
<span>{p.editSoul}</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => void runExportProfileFlow(label)}>
|
||||||
|
<Codicon name="package" size="0.875rem" />
|
||||||
|
<span>{p.exportProfile}</span>
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onSelect={onDelete}
|
onSelect={onDelete}
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ vi.mock('@/i18n', () => ({
|
|||||||
t: {
|
t: {
|
||||||
common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' },
|
common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' },
|
||||||
sidebar: {
|
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: {
|
row: {
|
||||||
archive: 'Archive',
|
archive: 'Archive',
|
||||||
branchFrom: 'Branch from here',
|
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('@/lib/session-export', () => ({ exportSession: vi.fn() }))
|
||||||
vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) }))
|
vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) }))
|
||||||
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
|
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', () => ({
|
vi.mock('@/store/session', () => ({
|
||||||
$activeSessionId: atom<null | string>(null),
|
$activeSessionId: atom<null | string>(null),
|
||||||
$selectedStoredSessionId: atom<null | string>(null),
|
$selectedStoredSessionId: atom<null | string>(null),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
closeAllTreeTabs,
|
closeAllTreeTabs,
|
||||||
closeOtherTreeTabs,
|
closeOtherTreeTabs,
|
||||||
closeTreeTabsToRight,
|
closeTreeTabsToRight,
|
||||||
|
reloadTreePane,
|
||||||
treeTabCloseTargets
|
treeTabCloseTargets
|
||||||
} from '@/components/pane-shell/tree/store'
|
} from '@/components/pane-shell/tree/store'
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,7 @@ import { PROFILE_SWATCHES } from '@/lib/profile-color'
|
|||||||
import { exportSession } from '@/lib/session-export'
|
import { exportSession } from '@/lib/session-export'
|
||||||
import { activeGateway } from '@/store/gateway'
|
import { activeGateway } from '@/store/gateway'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
|
import { $projectTree, moveSessionToProject, projectIdForCwd, projectRootCwd } from '@/store/projects'
|
||||||
import {
|
import {
|
||||||
$activeSessionId,
|
$activeSessionId,
|
||||||
$selectedStoredSessionId,
|
$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({
|
function useSessionActions({
|
||||||
sessionId,
|
sessionId,
|
||||||
title,
|
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 closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
|
||||||
|
|
||||||
const tabCloseItems: ActionItemSpec[] =
|
const tabItems: ActionItemSpec[] =
|
||||||
surface === 'tab'
|
surface === 'tab'
|
||||||
? [
|
? [
|
||||||
|
...(tabPaneId
|
||||||
|
? [
|
||||||
|
spec({
|
||||||
|
icon: 'refresh',
|
||||||
|
label: t.zones.reload,
|
||||||
|
onSelect: () => {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
reloadTreePane(tabPaneId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(onClose
|
...(onClose
|
||||||
? [
|
? [
|
||||||
spec({
|
spec({
|
||||||
@@ -342,10 +394,19 @@ function useSessionActions({
|
|||||||
/>
|
/>
|
||||||
<kit.Separator />
|
<kit.Separator />
|
||||||
{workItems.map(item => renderActionItem(kit, item))}
|
{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 />
|
<kit.Separator />
|
||||||
{tabCloseItems.map(item => renderActionItem(kit, item))}
|
{tabItems.map(item => renderActionItem(kit, item))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<kit.Separator />
|
<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 { useSensors } from '@dnd-kit/core'
|
||||||
import type * as React from 'react'
|
import type * as React from 'react'
|
||||||
import { useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
import { SidebarPanelLabel } from '@/app/shell/sidebar-label'
|
import { SidebarPanelLabel } from '@/app/shell/sidebar-label'
|
||||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||||
@@ -225,7 +225,8 @@ export function SidebarSessionsSection({
|
|||||||
[sessions, preserveInputOrder]
|
[sessions, preserveInputOrder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => {
|
const renderRow = useCallback(
|
||||||
|
(session: SessionInfo, draggable: boolean, branchStem?: string) => {
|
||||||
const rowProps = {
|
const rowProps = {
|
||||||
branchStem,
|
branchStem,
|
||||||
isPinned: pinned,
|
isPinned: pinned,
|
||||||
@@ -246,31 +247,57 @@ export function SidebarSessionsSection({
|
|||||||
) : (
|
) : (
|
||||||
<SidebarSessionRow key={session.id} {...rowProps} />
|
<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.
|
// 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' ? (
|
row.kind === 'divider' ? (
|
||||||
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
|
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
|
||||||
) : (
|
) : (
|
||||||
renderRow(row.entry.session, draggable, row.entry.branchStem)
|
renderRow(row.entry.session, draggable, row.entry.branchStem)
|
||||||
|
),
|
||||||
|
[dividerLabels, renderRow]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sessions inside repos/worktrees are date-ordered and static.
|
// Sessions inside repos/worktrees are date-ordered and static.
|
||||||
const renderRows = (items: SessionInfo[]) =>
|
const renderRows = useCallback(
|
||||||
flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem))
|
(items: SessionInfo[]) =>
|
||||||
|
flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)),
|
||||||
|
[renderRow]
|
||||||
|
)
|
||||||
|
|
||||||
// Same as `renderRows`, but with date dividers folded in — used for
|
// Same as `renderRows`, but with date dividers folded in — used for
|
||||||
// entered-project lanes so a lane spanning multiple days reads
|
// entered-project lanes so a lane spanning multiple days reads
|
||||||
// chronologically, matching the flat recents list.
|
// chronologically, matching the flat recents list.
|
||||||
const renderRowsDated = (items: SessionInfo[]) => {
|
const renderRowsDated = useCallback(
|
||||||
|
(items: SessionInfo[]) => {
|
||||||
const entries = flattenSessionsWithBranches(items)
|
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.
|
// 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 =
|
const flatVirtualized =
|
||||||
!showEmptyState &&
|
!showEmptyState &&
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface SessionRowCommonProps {
|
|||||||
showProfile?: boolean
|
showProfile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualSessionListProps {
|
export interface VirtualSessionListProps {
|
||||||
activeSessionId: null | string
|
activeSessionId: null | string
|
||||||
className?: string
|
className?: string
|
||||||
rows: SidebarListRow[]
|
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 { registry } from '@/contrib/registry'
|
||||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||||
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
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 { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||||
import { setYoloEnabled } from '@/lib/yolo-session'
|
import { setYoloEnabled } from '@/lib/yolo-session'
|
||||||
import { pruneComposerPopoutZones } from '@/store/composer-popout'
|
import { pruneComposerPopoutZones } from '@/store/composer-popout'
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
SIDEBAR_MAX_WIDTH
|
SIDEBAR_MAX_WIDTH
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview'
|
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 { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review'
|
||||||
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
|
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
|
||||||
import { watchSessionPins } from '@/store/session-pin-sync'
|
import { watchSessionPins } from '@/store/session-pin-sync'
|
||||||
@@ -314,6 +315,31 @@ registry.registerMany([
|
|||||||
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
|
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
|
||||||
run: () => window.dispatchEvent(new CustomEvent('hermes:open-keybinds'))
|
run: () => window.dispatchEvent(new CustomEvent('hermes:open-keybinds'))
|
||||||
} satisfies PaletteContribution
|
} 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', {
|
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||||
session_id: 'stored-x',
|
session_id: 'stored-x',
|
||||||
cols: 96,
|
cols: 96,
|
||||||
profile: 'ai-engineer'
|
profile: 'ai-engineer',
|
||||||
|
omit_messages: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +95,8 @@ describe('useSessionTileDelegate resumeTile', () => {
|
|||||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||||
session_id: 'stored-y',
|
session_id: 'stored-y',
|
||||||
cols: 96,
|
cols: 96,
|
||||||
profile: 'default'
|
profile: 'default',
|
||||||
|
omit_messages: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function useSessionTileDelegate({
|
|||||||
requestGateway<SessionResumeResponse>('session.resume', {
|
requestGateway<SessionResumeResponse>('session.resume', {
|
||||||
session_id: storedSessionId,
|
session_id: storedSessionId,
|
||||||
cols: 96,
|
cols: 96,
|
||||||
|
omit_messages: true,
|
||||||
...(profile ? { profile } : {})
|
...(profile ? { profile } : {})
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { HermesConnection } from '@/global'
|
|||||||
import { HermesGateway } from '@/hermes'
|
import { HermesGateway } from '@/hermes'
|
||||||
import { translateNow } from '@/i18n'
|
import { translateNow } from '@/i18n'
|
||||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||||
|
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||||
import {
|
import {
|
||||||
$desktopBoot,
|
$desktopBoot,
|
||||||
applyDesktopBootProgress,
|
applyDesktopBootProgress,
|
||||||
@@ -42,12 +43,16 @@ import type { RpcEvent } from '@/types/hermes'
|
|||||||
|
|
||||||
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
|
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
|
||||||
|
|
||||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
// After the reconnect loop has been failing for this long, raise a recoverable
|
||||||
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
|
// boot error. Otherwise a dropped remote gateway loops the backoff forever
|
||||||
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
|
// behind the fullscreen CONNECTING overlay with no way to reach Settings /
|
||||||
// Settings / sign in / switch to local — the "lost connection breaks the app"
|
// sign in / switch to local — the "lost connection breaks the app" dead end.
|
||||||
// dead end. The next successful reconnect clears it.
|
// The next successful reconnect clears it. Time-based (not attempt-count)
|
||||||
const RECONNECT_ESCALATE_AFTER = 6
|
// 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 {
|
interface GatewayBootOptions {
|
||||||
beforeConnectionSwitch: () => void
|
beforeConnectionSwitch: () => void
|
||||||
@@ -114,13 +119,18 @@ export function useGatewayBoot({
|
|||||||
let reconnecting = false
|
let reconnecting = false
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let reconnectAttempt = 0
|
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
|
// Surface "sign in again" once per disconnect episode, not on every backoff
|
||||||
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
||||||
// identical error toasts (and their haptics). Reset on the next clean open.
|
// identical error toasts (and their haptics). Reset on the next clean open.
|
||||||
let reauthNotified = false
|
let reauthNotified = false
|
||||||
// Raised once the reconnect loop crosses RECONNECT_ESCALATE_AFTER so the
|
// Raised once the reconnect loop has been failing for
|
||||||
// recovery overlay replaces the dead-end CONNECTING screen. Reset on a clean
|
// RECONNECT_ESCALATE_AFTER_MS so the recovery overlay replaces the
|
||||||
// open or a manual/wake-driven reconnect.
|
// dead-end CONNECTING screen. Reset on a clean open or a manual/
|
||||||
|
// wake-driven reconnect.
|
||||||
let escalated = false
|
let escalated = false
|
||||||
|
|
||||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||||
@@ -173,6 +183,7 @@ export function useGatewayBoot({
|
|||||||
}
|
}
|
||||||
|
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
reconnectFailingSince = null
|
||||||
// A respawned backend re-mints (recycles) runtime ids, so any tile's
|
// 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.
|
// bound runtime id is now stale — drop them so each tile re-resumes.
|
||||||
resetTileRuntimeBindings()
|
resetTileRuntimeBindings()
|
||||||
@@ -192,7 +203,11 @@ export function useGatewayBoot({
|
|||||||
reconnecting = false
|
reconnecting = false
|
||||||
|
|
||||||
if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) {
|
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
|
escalated = true
|
||||||
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
|
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
|
||||||
}
|
}
|
||||||
@@ -207,8 +222,11 @@ export function useGatewayBoot({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1s, 2s, 4s … capped at 15s.
|
// Full-jitter exponential backoff (300ms base, 15s cap) so a gateway
|
||||||
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4))
|
// 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
|
reconnectAttempt += 1
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
@@ -223,6 +241,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
clearReconnectTimer()
|
clearReconnectTimer()
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
reconnectFailingSince = null
|
||||||
escalated = false
|
escalated = false
|
||||||
reconnectSecondaryGateways()
|
reconnectSecondaryGateways()
|
||||||
|
|
||||||
@@ -269,6 +288,7 @@ export function useGatewayBoot({
|
|||||||
$gatewaySwitching.set(true)
|
$gatewaySwitching.set(true)
|
||||||
clearReconnectTimer()
|
clearReconnectTimer()
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
reconnectFailingSince = null
|
||||||
escalated = false
|
escalated = false
|
||||||
reauthNotified = false
|
reauthNotified = false
|
||||||
callbacksRef.current.beforeConnectionSwitch()
|
callbacksRef.current.beforeConnectionSwitch()
|
||||||
@@ -371,6 +391,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
if (st === 'open') {
|
if (st === 'open') {
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
reconnectFailingSince = null
|
||||||
reauthNotified = false
|
reauthNotified = false
|
||||||
escalated = false
|
escalated = false
|
||||||
clearReconnectTimer()
|
clearReconnectTimer()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
|
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
|
||||||
import { toggleReview } from '@/store/review'
|
import { toggleReview } from '@/store/review'
|
||||||
import { setModelPickerOpen } from '@/store/session'
|
import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session'
|
||||||
import { reopenLastClosedTile } from '@/store/session-states'
|
import { reopenLastClosedTile } from '@/store/session-states'
|
||||||
import {
|
import {
|
||||||
$switcherOpen,
|
$switcherOpen,
|
||||||
@@ -62,11 +62,13 @@ import { useTheme } from '@/themes/context'
|
|||||||
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
|
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
|
||||||
import { openSession } from '../open-session'
|
import { openSession } from '../open-session'
|
||||||
import {
|
import {
|
||||||
|
$workspaceIsPage,
|
||||||
AGENTS_ROUTE,
|
AGENTS_ROUTE,
|
||||||
ARTIFACTS_ROUTE,
|
ARTIFACTS_ROUTE,
|
||||||
CRON_ROUTE,
|
CRON_ROUTE,
|
||||||
MESSAGING_ROUTE,
|
MESSAGING_ROUTE,
|
||||||
navigateToWorkspacePage,
|
navigateToWorkspacePage,
|
||||||
|
NEW_CHAT_ROUTE,
|
||||||
PROFILES_ROUTE,
|
PROFILES_ROUTE,
|
||||||
sessionRoute,
|
sessionRoute,
|
||||||
SETTINGS_ROUTE,
|
SETTINGS_ROUTE,
|
||||||
@@ -99,11 +101,29 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
|
|
||||||
const profileSwitchHandlers: HandlerMap = {}
|
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) {
|
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
|
// ⌘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.
|
// single-pane (or unfocused) layout falls through to the profile switch.
|
||||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
||||||
if (!activateTreeTabSlot(slot)) {
|
const pane = activateTreeTabSlot(slot)
|
||||||
|
|
||||||
|
if (pane) {
|
||||||
|
leavePageForWorkspaceChat(pane)
|
||||||
|
} else {
|
||||||
switchProfileToSlot(slot)
|
switchProfileToSlot(slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +152,19 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
goToSession(openOrAdvanceSwitcher(direction))
|
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 = () => {
|
const showFiles = () => {
|
||||||
setFileBrowserOpen(true)
|
setFileBrowserOpen(true)
|
||||||
setTerminalTakeover(false)
|
setTerminalTakeover(false)
|
||||||
@@ -170,10 +203,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
},
|
},
|
||||||
'session.newTab': () => deps.openNewSessionTab(),
|
'session.newTab': () => deps.openNewSessionTab(),
|
||||||
'session.newWindow': () => void openNewWindow(),
|
'session.newWindow': () => void openNewWindow(),
|
||||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
'session.next': () => cycleTab(1),
|
||||||
// falls through to the recent-session switcher.
|
'session.prev': () => cycleTab(-1),
|
||||||
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
|
|
||||||
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
|
|
||||||
...sessionSlotHandlers,
|
...sessionSlotHandlers,
|
||||||
'session.focusSearch': requestSessionSearchFocus,
|
'session.focusSearch': requestSessionSearchFocus,
|
||||||
'session.togglePin': deps.toggleSelectedPin,
|
'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 { useStore } from '@nanostores/react'
|
||||||
import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from '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 { type NodeApi, type NodeRendererProps, type RowRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||||
|
|
||||||
import { TreeSkeleton } from '@/components/chat/skeletons'
|
import { TreeSkeleton } from '@/components/chat/skeletons'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
import { cn } from '@/lib/utils'
|
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 { $renamingPath, beginInlineRename } from '@/store/file-actions'
|
||||||
import { $revealInTreeRequest } from '@/store/layout'
|
import { $revealInTreeRequest } from '@/store/layout'
|
||||||
|
|
||||||
@@ -55,19 +57,20 @@ export function ProjectTree({
|
|||||||
onPreviewFile,
|
onPreviewFile,
|
||||||
openState
|
openState
|
||||||
}: ProjectTreeProps) {
|
}: ProjectTreeProps) {
|
||||||
|
markRightPanePerf('project-tree-render')
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
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
|
const el = containerRef.current
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height, width } = el.getBoundingClientRect()
|
const { height, width } = projectTreeViewportSize(entries, el)
|
||||||
|
|
||||||
setSize(prev => {
|
setSize(prev => {
|
||||||
if (prev.height === height && prev.width === width) {
|
if (prev.height === height && prev.width === width) {
|
||||||
@@ -175,7 +178,12 @@ export function ProjectTree({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
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 ? (
|
{size.height > 0 && size.width > 0 ? (
|
||||||
<Tree<TreeNode>
|
<Tree<TreeNode>
|
||||||
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
||||||
@@ -200,7 +208,6 @@ export function ProjectTree({
|
|||||||
{props => (
|
{props => (
|
||||||
<ProjectTreeRow
|
<ProjectTreeRow
|
||||||
{...props}
|
{...props}
|
||||||
changeKind={props.node.data ? changeByPath.get(props.node.data.id) : undefined}
|
|
||||||
onAttachFile={onActivateFile}
|
onAttachFile={onActivateFile}
|
||||||
onAttachFolder={onActivateFolder}
|
onAttachFolder={onActivateFolder}
|
||||||
onPreviewFile={onPreviewFile}
|
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() {
|
function TreeSizingState() {
|
||||||
return <TreeSkeleton />
|
return <TreeSkeleton />
|
||||||
}
|
}
|
||||||
@@ -244,7 +261,6 @@ const CHANGE_TINT: Record<RepoChangeKind, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectTreeRow({
|
function ProjectTreeRow({
|
||||||
changeKind,
|
|
||||||
dragHandle,
|
dragHandle,
|
||||||
node,
|
node,
|
||||||
onAttachFile,
|
onAttachFile,
|
||||||
@@ -253,13 +269,17 @@ function ProjectTreeRow({
|
|||||||
relativeTo,
|
relativeTo,
|
||||||
style
|
style
|
||||||
}: NodeRendererProps<TreeNode> & {
|
}: NodeRendererProps<TreeNode> & {
|
||||||
changeKind?: RepoChangeKind
|
|
||||||
onAttachFile: (path: string) => void
|
onAttachFile: (path: string) => void
|
||||||
onAttachFolder: (path: string) => void
|
onAttachFolder: (path: string) => void
|
||||||
onPreviewFile?: (path: string) => void
|
onPreviewFile?: (path: string) => void
|
||||||
relativeTo?: null | string
|
relativeTo?: null | string
|
||||||
}) {
|
}) {
|
||||||
const renamingPath = useStore($renamingPath)
|
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) {
|
if (!node.data) {
|
||||||
return <div style={style} />
|
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 { createRoot, type Root } from 'react-dom/client'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { $paneStates } from '@/store/panes'
|
||||||
|
|
||||||
import { PersistentTerminal, TerminalSlot } from './persistent'
|
import { PersistentTerminal, TerminalSlot } from './persistent'
|
||||||
|
|
||||||
vi.mock('./terminals', () => ({
|
vi.mock('./terminals', () => ({
|
||||||
@@ -212,7 +214,8 @@ describe('PersistentTerminal rect tracking', () => {
|
|||||||
|
|
||||||
render(<Harness />)
|
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(() => {
|
act(() => {
|
||||||
raf.runNext()
|
raf.runNext()
|
||||||
@@ -244,6 +247,27 @@ describe('PersistentTerminal rect tracking', () => {
|
|||||||
expect(raf.pending()).toBe(0)
|
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', () => {
|
it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => {
|
||||||
const raf = installRaf()
|
const raf = installRaf()
|
||||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
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 { atom } from 'nanostores'
|
||||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
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 { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||||
|
import { $paneStates } from '@/store/panes'
|
||||||
|
|
||||||
import { $terminalTakeover } from '../store'
|
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 {
|
interface PersistentTerminalProps {
|
||||||
@@ -86,6 +89,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
|||||||
let prev: Rect | null = null
|
let prev: Rect | null = null
|
||||||
let frame = 0
|
let frame = 0
|
||||||
let stopped = false
|
let stopped = false
|
||||||
|
let pendingReason = 'initial'
|
||||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||||
|
|
||||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
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()) {
|
if (rendererPaused()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markRightPanePerf('terminal-measure', reason)
|
||||||
const r = slot.getBoundingClientRect()
|
const r = slot.getBoundingClientRect()
|
||||||
// floor top/left + ceil right/bottom: overlay always covers the slot's
|
// 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.
|
// full pixel footprint, so half-pixel rects can't leak page bg through.
|
||||||
@@ -123,16 +128,18 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleMeasure = () => {
|
const scheduleMeasure = (reason = 'unknown') => {
|
||||||
if (stopped || rendererPaused() || frame !== 0) {
|
if (stopped || rendererPaused() || frame !== 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingReason = reason
|
||||||
frame = window.requestAnimationFrame(() => {
|
frame = window.requestAnimationFrame(() => {
|
||||||
frame = 0
|
frame = 0
|
||||||
|
const reason = pendingReason
|
||||||
|
|
||||||
if (measure()) {
|
if (measure(reason)) {
|
||||||
scheduleMeasure()
|
scheduleMeasure('settle')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -144,50 +151,69 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleMeasure()
|
scheduleMeasure('visibility')
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer =
|
const observer =
|
||||||
typeof ResizeObserver === 'undefined'
|
typeof ResizeObserver === 'undefined'
|
||||||
? null
|
? null
|
||||||
: new ResizeObserver(() => {
|
: new ResizeObserver(() => {
|
||||||
scheduleMeasure()
|
scheduleMeasure('resize-observer')
|
||||||
})
|
})
|
||||||
|
|
||||||
const positionObserver =
|
const positionObserver =
|
||||||
typeof MutationObserver === 'undefined'
|
typeof MutationObserver === 'undefined'
|
||||||
? null
|
? null
|
||||||
: new MutationObserver(() => {
|
: new MutationObserver(() => {
|
||||||
scheduleMeasure()
|
scheduleMeasure('ancestor-mutation')
|
||||||
})
|
})
|
||||||
|
|
||||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||||
|
|
||||||
if (measure()) {
|
if (measure('initial')) {
|
||||||
scheduleMeasure()
|
scheduleMeasure('settle')
|
||||||
}
|
}
|
||||||
|
|
||||||
observer?.observe(slot)
|
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) {
|
for (let node: HTMLElement | null = slot; node; node = node.parentElement) {
|
||||||
positionObserver?.observe(node, {
|
positionObserver?.observe(node, {
|
||||||
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'],
|
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'],
|
||||||
attributes: true,
|
attributes: true,
|
||||||
childList: 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)
|
// Nested layout-tree and pane-state commits can move the slot without
|
||||||
window.addEventListener('scroll', scheduleMeasure, true)
|
// 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 () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
cancelFrame()
|
cancelFrame()
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
positionObserver?.disconnect()
|
positionObserver?.disconnect()
|
||||||
window.removeEventListener('resize', scheduleMeasure)
|
unsubscribeLayout()
|
||||||
window.removeEventListener('scroll', scheduleMeasure, true)
|
unsubscribePanes()
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
scrollTargets.forEach(target => target.removeEventListener('scroll', handleScroll))
|
||||||
pauseController?.dispose()
|
pauseController?.dispose()
|
||||||
}
|
}
|
||||||
}, [slot])
|
}, [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
|
// 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.
|
// conhost on Windows. After that `mounted` latches: shells persist while hidden.
|
||||||
return (
|
return (
|
||||||
<div aria-hidden={!visible} style={style}>
|
<div aria-hidden={!visible} data-persistent-terminal="" style={style}>
|
||||||
{mounted && <TerminalWorkspace onAddSelectionToChat={onAddSelectionToChat} />}
|
{mounted && <TerminalWorkspace onAddSelectionToChat={onAddSelectionToChat} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { Terminal } from '@xterm/xterm'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||||
|
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
|
import { observeActiveTerminalResize } from './active-resize'
|
||||||
import { registerAgentTerminalWriter } from './agent-terminal-stream'
|
import { registerAgentTerminalWriter } from './agent-terminal-stream'
|
||||||
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
||||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
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 hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const termRef = useRef<Terminal | null>(null)
|
const termRef = useRef<Terminal | null>(null)
|
||||||
const webglRef = useRef<WebglAddon | 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 { latestFontFamilyRef, mountedRef } = useTerminalFontController({ fitRef, termRef, webglRef })
|
||||||
|
|
||||||
const surfaceTheme = () => {
|
const surfaceTheme = () => {
|
||||||
@@ -47,7 +50,6 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let observer: ResizeObserver | null = null
|
|
||||||
|
|
||||||
let unregister = () => {}
|
let unregister = () => {}
|
||||||
|
|
||||||
@@ -101,10 +103,11 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
fitRef.current = () => {
|
fitRef.current = visible => {
|
||||||
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
||||||
try {
|
try {
|
||||||
fit.fit()
|
fit.fit()
|
||||||
|
markRightPanePerf(visible ? 'terminal-fit-active' : 'terminal-fit-hidden', id)
|
||||||
} catch {
|
} catch {
|
||||||
// Mid-transition layout — the next observer tick refits.
|
// 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.
|
// No WebGL — xterm falls back to the DOM renderer.
|
||||||
}
|
}
|
||||||
|
|
||||||
fitRef.current?.()
|
fitRef.current?.(active)
|
||||||
observer = new ResizeObserver(() => fitRef.current?.())
|
initialActiveFitRef.current = active
|
||||||
observer.observe(host)
|
|
||||||
|
|
||||||
// Stream live output straight into the terminal (replays backlog on attach).
|
// Stream live output straight into the terminal (replays backlog on attach).
|
||||||
unregister = registerAgentTerminalWriter(procId, chunk => term.write(chunk))
|
unregister = registerAgentTerminalWriter(procId, chunk => term.write(chunk))
|
||||||
@@ -159,7 +161,6 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
|||||||
unregister()
|
unregister()
|
||||||
unregisterReader()
|
unregisterReader()
|
||||||
selectionDisposable.dispose()
|
selectionDisposable.dispose()
|
||||||
observer?.disconnect()
|
|
||||||
fitRef.current = null
|
fitRef.current = null
|
||||||
term.dispose()
|
term.dispose()
|
||||||
termRef.current = null
|
termRef.current = null
|
||||||
@@ -184,25 +185,39 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [renderedMode, themeName])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
|
initialActiveFitRef.current = false
|
||||||
|
|
||||||
return
|
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
|
const term = termRef.current
|
||||||
|
|
||||||
fitRef.current?.()
|
|
||||||
webglRef.current?.clearTextureAtlas()
|
webglRef.current?.clearTextureAtlas()
|
||||||
term?.refresh(0, term.rows - 1)
|
term?.refresh(0, term.rows - 1)
|
||||||
// Take focus on activation (parity with the user terminal) so the active
|
// Take focus on activation (parity with the user terminal) so the active
|
||||||
// agent tab holds focus and ⌘W's isFocusWithin('[data-terminal]') routes
|
// agent tab holds focus and ⌘W's isFocusWithin('[data-terminal]') routes
|
||||||
// the close to this tab rather than to a preview.
|
// the close to this tab rather than to a preview.
|
||||||
term?.focus()
|
term?.focus()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [active])
|
}, [active])
|
||||||
|
|
||||||
return { hostRef }
|
return { hostRef }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { RefObject } from 'react'
|
|||||||
import { $terminalFontFamily, applyTerminalFontFamily, resolveTerminalFontFamily } from './terminal-font'
|
import { $terminalFontFamily, applyTerminalFontFamily, resolveTerminalFontFamily } from './terminal-font'
|
||||||
|
|
||||||
interface TerminalFontControllerOptions {
|
interface TerminalFontControllerOptions {
|
||||||
fitRef: RefObject<(() => void) | null>
|
fitRef: RefObject<((visible: boolean) => void) | null>
|
||||||
termRef: RefObject<Terminal | null>
|
termRef: RefObject<Terminal | null>
|
||||||
webglRef: RefObject<WebglAddon | null>
|
webglRef: RefObject<WebglAddon | null>
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ export function useTerminalFontController({ fitRef, termRef, webglRef }: Termina
|
|||||||
|
|
||||||
void applyTerminalFontFamily({
|
void applyTerminalFontFamily({
|
||||||
clearTextureAtlas: () => webglRef.current?.clearTextureAtlas(),
|
clearTextureAtlas: () => webglRef.current?.clearTextureAtlas(),
|
||||||
fit: () => fitRef.current?.(),
|
fit: () => fitRef.current?.(true),
|
||||||
fontFamily,
|
fontFamily,
|
||||||
isCurrent: () => !cancelled && generationRef.current === generation,
|
isCurrent: () => !cancelled && generationRef.current === generation,
|
||||||
term
|
term
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||||
|
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { $previewTarget } from '@/store/preview'
|
import { $previewTarget } from '@/store/preview'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
import { $terminalInjection } from '../store'
|
import { $terminalInjection } from '../store'
|
||||||
|
|
||||||
|
import { observeActiveTerminalResize } from './active-resize'
|
||||||
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
||||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
||||||
import { terminalLinkHandler, terminalWebLinksAddon } from './links'
|
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
|
// drag-and-drop paths, or an injected command). Gates idle-buffer handling in
|
||||||
// persistSnapshot so an untouched tab never re-saves an accumulating snapshot.
|
// persistSnapshot so an untouched tab never re-saves an accumulating snapshot.
|
||||||
const hasSessionActivityRef = useRef(false)
|
const hasSessionActivityRef = useRef(false)
|
||||||
|
const initialActiveRef = useRef(active)
|
||||||
const shellNameRef = useRef('shell')
|
const shellNameRef = useRef('shell')
|
||||||
const selectionLabelRef = useRef('')
|
const selectionLabelRef = useRef('')
|
||||||
const selectionRef = useRef('')
|
const selectionRef = useRef('')
|
||||||
@@ -420,7 +423,8 @@ export function useTerminalSession({
|
|||||||
const onShellRef = useRef(onShell)
|
const onShellRef = useRef(onShell)
|
||||||
// Re-fit on activation: a tab hidden via display:none has a 0×0 host, so its
|
// 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.
|
// 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 { latestFontFamilyRef, mountedRef } = useTerminalFontController({ fitRef, termRef, webglRef })
|
||||||
const [status, setStatus] = useState<TerminalStatus>('starting')
|
const [status, setStatus] = useState<TerminalStatus>('starting')
|
||||||
const [selection, setSelection] = useState('')
|
const [selection, setSelection] = useState('')
|
||||||
@@ -742,56 +746,28 @@ export function useTerminalSession({
|
|||||||
term.write(next)
|
term.write(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fitAndResize = () => {
|
const fitAndResize = (visible: boolean) => {
|
||||||
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fit.fit()
|
fit.fit()
|
||||||
|
markRightPanePerf(visible ? 'terminal-fit-active' : 'terminal-fit-hidden', id)
|
||||||
} catch {
|
} catch {
|
||||||
return
|
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 }
|
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
|
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 => {
|
const dataDisposable = term.onData(data => {
|
||||||
hasSessionActivityRef.current = true
|
hasSessionActivityRef.current = true
|
||||||
const id = sessionIdRef.current
|
const id = sessionIdRef.current
|
||||||
@@ -901,9 +877,7 @@ export function useTerminalSession({
|
|||||||
)
|
)
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
fitAndResize()
|
|
||||||
term.clearSelection() // drop any selection painted over transient boot rows
|
term.clearSelection() // drop any selection painted over transient boot rows
|
||||||
term.focus()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -938,7 +912,8 @@ export function useTerminalSession({
|
|||||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fitAndResize()
|
fitAndResize(initialActiveRef.current)
|
||||||
|
initialActiveFitRef.current = initialActiveRef.current
|
||||||
startSession()
|
startSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,24 +988,39 @@ export function useTerminalSession({
|
|||||||
return term ? registerTerminalReader(id, makeTerminalReader(term)) : undefined
|
return term ? registerTerminalReader(id, makeTerminalReader(term)) : undefined
|
||||||
}, [id, status])
|
}, [id, status])
|
||||||
|
|
||||||
// On (re)activation: a WebGL terminal doesn't paint while visibility:hidden, so
|
// Only the active terminal observes its host. Every terminal stays mounted
|
||||||
// it reveals a stale/garbled frame. Refit, rebuild the glyph atlas, and force a
|
// (PTY + scrollback preserved), but hidden tabs do no FitAddon/layout work.
|
||||||
// full redraw against the live buffer, then focus.
|
// Re-activation owns one fit + atlas rebuild + redraw.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- lifecycle flag prevents a duplicate first-mount fit
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || status !== 'open') {
|
if (!active || status !== 'open') {
|
||||||
|
if (!active) {
|
||||||
|
initialActiveFitRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
return
|
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
|
const term = termRef.current
|
||||||
|
|
||||||
fitRef.current?.()
|
|
||||||
webglRef.current?.clearTextureAtlas()
|
webglRef.current?.clearTextureAtlas()
|
||||||
term?.refresh(0, term.rows - 1)
|
term?.refresh(0, term.rows - 1)
|
||||||
term?.focus()
|
term?.focus()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [active, status])
|
}, [active, status])
|
||||||
|
|
||||||
// Flush a queued command (e.g. a provider-disconnect) into the live session.
|
// Flush a queued command (e.g. a provider-disconnect) into the live session.
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
import { act, cleanup, render } from '@testing-library/react'
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type { ClientSessionState } from '@/app/types'
|
import type { ClientSessionState } from '@/app/types'
|
||||||
|
import type { ChatMessage } from '@/lib/chat-messages'
|
||||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||||
|
|
||||||
|
import { useSessionStateCache } from '../use-session-state-cache'
|
||||||
|
|
||||||
import { useMessageStream } from './index'
|
import { useMessageStream } from './index'
|
||||||
|
|
||||||
const SID = 'session-1'
|
const SID = 'session-1'
|
||||||
@@ -90,6 +93,42 @@ describe('useMessageStream delta flush scheduling', () => {
|
|||||||
expect(assistantText()).toBe('still streaming')
|
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 () => {
|
it('cancels the pending timer on unmount and flushes exactly once', async () => {
|
||||||
vi.mocked(performance.now).mockReturnValue(0)
|
vi.mocked(performance.now).mockReturnValue(0)
|
||||||
mountStream()
|
mountStream()
|
||||||
@@ -108,4 +147,275 @@ describe('useMessageStream delta flush scheduling', () => {
|
|||||||
expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
|
expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
|
||||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
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
|
// What the previous flush cost on the main thread — drives the adaptive
|
||||||
// flush floor in scheduleDeltaFlush so multi-stream load yields to input.
|
// flush floor in scheduleDeltaFlush so multi-stream load yields to input.
|
||||||
const lastFlushCostRef = useRef<number>(0)
|
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())
|
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
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
|
// 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
|
// 30fps of text growth, expensive multi-stream flushes degrade text fps
|
||||||
// instead of interactivity — capped so text never updates slower than 4/s.
|
// 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 sinceLast = performance.now() - lastFlushAtRef.current
|
||||||
|
|
||||||
const adaptiveFloor = Math.min(
|
const adaptiveFloor = Math.min(
|
||||||
@@ -269,7 +274,39 @@ export function useMessageStream({
|
|||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
lastFlushAtRef.current = startedAt
|
lastFlushAtRef.current = startedAt
|
||||||
flushQueuedDeltas()
|
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
|
// Always a timer, never requestAnimationFrame. Chromium pauses rAF for a
|
||||||
@@ -312,11 +349,46 @@ export function useMessageStream({
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushHandleRef.current = null
|
flushHandleRef.current = null
|
||||||
|
|
||||||
|
if (measureRafRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.cancelAnimationFrame(measureRafRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
measureRafRef.current = null
|
||||||
flushQueuedDeltas()
|
flushQueuedDeltas()
|
||||||
},
|
},
|
||||||
[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(
|
const appendAssistantDelta = useCallback(
|
||||||
(sessionId: string, delta: string) => {
|
(sessionId: string, delta: string) => {
|
||||||
if (!delta) {
|
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' }])
|
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.
|
// The flush must not have depended on a frame: this mock parks every rAF
|
||||||
expect(rafSpy).not.toHaveBeenCalled()
|
// 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(accepted).toBe(true)
|
||||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||||
session_id: 'stored-session-b',
|
session_id: 'stored-session-b',
|
||||||
source: 'desktop'
|
source: 'desktop',
|
||||||
|
omit_messages: true
|
||||||
})
|
})
|
||||||
expect(requestGateway).toHaveBeenCalledWith(
|
expect(requestGateway).toHaveBeenCalledWith(
|
||||||
'prompt.submit',
|
'prompt.submit',
|
||||||
@@ -1933,7 +1934,8 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||||||
// Must resume the correct stored session to get the right runtime id.
|
// Must resume the correct stored session to get the right runtime id.
|
||||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||||
session_id: 'stored-session-a',
|
session_id: 'stored-session-a',
|
||||||
source: 'desktop'
|
source: 'desktop',
|
||||||
|
omit_messages: true
|
||||||
})
|
})
|
||||||
// The prompt must land in the resumed session, NOT the foreground.
|
// The prompt must land in the resumed session, NOT the foreground.
|
||||||
expect(requestGateway).toHaveBeenCalledWith(
|
expect(requestGateway).toHaveBeenCalledWith(
|
||||||
@@ -2194,7 +2196,7 @@ describe('usePromptActions redirectPrompt', () => {
|
|||||||
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
|
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
|
||||||
expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect'])
|
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[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(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' })
|
||||||
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
|
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
|
||||||
})
|
})
|
||||||
@@ -2735,7 +2737,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||||||
expect(ok).toBe(true)
|
expect(ok).toBe(true)
|
||||||
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
|
// 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.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' })
|
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(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(() => [])
|
setSessions(() => [])
|
||||||
})
|
})
|
||||||
@@ -2826,7 +2833,12 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
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()
|
vi.mocked(getSession).mockReset()
|
||||||
setSessions(() => [])
|
setSessions(() => [])
|
||||||
@@ -2887,7 +2899,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||||||
session_id: 'rt-background-stale',
|
session_id: 'rt-background-stale',
|
||||||
text: 'queued background message after wake'
|
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({
|
expect(calls[2]?.params).toEqual({
|
||||||
queued: true,
|
queued: true,
|
||||||
session_id: RECOVERED_SESSION_ID,
|
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.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
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 })
|
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(ok).toBe(true)
|
||||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
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({
|
expect(calls[2]?.params).toEqual({
|
||||||
session_id: RECOVERED_SESSION_ID,
|
session_id: RECOVERED_SESSION_ID,
|
||||||
text: 'message during starved loop'
|
text: 'message during starved loop'
|
||||||
@@ -3110,7 +3134,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||||||
expect(ok).toBe(true)
|
expect(ok).toBe(true)
|
||||||
expect(createBackendSessionForSend).not.toHaveBeenCalled()
|
expect(createBackendSessionForSend).not.toHaveBeenCalled()
|
||||||
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
|
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 })
|
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.some(c => c.method === 'prompt.submit')).toBe(false)
|
||||||
expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({
|
expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({
|
||||||
session_id: STORED_SESSION_A,
|
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', {
|
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||||
session_id: selectedStoredSessionIdRef.current,
|
session_id: selectedStoredSessionIdRef.current,
|
||||||
source: 'desktop',
|
source: 'desktop',
|
||||||
|
omit_messages: true,
|
||||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -744,6 +745,7 @@ export function usePromptActions({
|
|||||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||||
session_id: selectedStoredSessionIdRef.current,
|
session_id: selectedStoredSessionIdRef.current,
|
||||||
source: 'desktop',
|
source: 'desktop',
|
||||||
|
omit_messages: true,
|
||||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||||
session_id: targetStoredSessionId,
|
session_id: targetStoredSessionId,
|
||||||
source: 'desktop',
|
source: 'desktop',
|
||||||
|
omit_messages: true,
|
||||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -637,6 +638,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||||
session_id: recoverStoredSessionId,
|
session_id: recoverStoredSessionId,
|
||||||
source: 'desktop',
|
source: 'desktop',
|
||||||
|
omit_messages: true,
|
||||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -896,7 +896,7 @@ describe('resumeSession failure recovery', () => {
|
|||||||
|
|
||||||
expect(resumeParams).not.toHaveProperty('lazy')
|
expect(resumeParams).not.toHaveProperty('lazy')
|
||||||
expect(resumeParams).not.toHaveProperty('eager_build')
|
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 () => {
|
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).toContain('session.activate')
|
||||||
expect(methods).not.toContain('session.resume')
|
expect(methods).not.toContain('session.resume')
|
||||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
|
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')
|
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -700,7 +700,8 @@ export function useSessionActions({
|
|||||||
try {
|
try {
|
||||||
activated = await requestGateway<SessionResumeResponse>('session.activate', {
|
activated = await requestGateway<SessionResumeResponse>('session.activate', {
|
||||||
session_id: cachedRuntimeId,
|
session_id: cachedRuntimeId,
|
||||||
cols: 96
|
cols: 96,
|
||||||
|
omit_messages: true
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Compatibility for older backends. Modern backends require
|
// Compatibility for older backends. Modern backends require
|
||||||
@@ -866,12 +867,14 @@ export function useSessionActions({
|
|||||||
session_id: storedSessionId,
|
session_id: storedSessionId,
|
||||||
cols: 96,
|
cols: 96,
|
||||||
source: 'desktop',
|
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
|
// Watch windows attach lazily (live mirror). Every other cold resume
|
||||||
// gets the gateway's default deferred build: the RPC returns the
|
// gets the gateway's default deferred build: the RPC returns the
|
||||||
// transcript immediately instead of blocking the switch on _make_agent
|
// transcript immediately instead of blocking the switch on _make_agent
|
||||||
// (MCP discovery / prompt build), and the agent pre-warms in the
|
// (MCP discovery / prompt build), and the agent pre-warms in the
|
||||||
// background while the prefetch above paints the transcript.
|
// background while the prefetch above paints the transcript.
|
||||||
...(watchWindow ? { lazy: true } : {}),
|
...(watchWindow ? { lazy: true } : { omit_messages: true }),
|
||||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
...(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)
|
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 { 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 { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||||
import { $desktopOnboarding } from '@/store/onboarding'
|
import { $desktopOnboarding } from '@/store/onboarding'
|
||||||
import { $activeGatewayProfile } from '@/store/profile'
|
import { $activeGatewayProfile } from '@/store/profile'
|
||||||
@@ -24,6 +25,21 @@ import {
|
|||||||
const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
|
||||||
({ id, role, parts: [{ type: 'text', text }], ...extra }) as 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
|
const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo
|
||||||
|
|
||||||
describe('applyRuntimeInfo approval mode', () => {
|
describe('applyRuntimeInfo approval mode', () => {
|
||||||
@@ -394,6 +410,126 @@ describe('reconcileResumeMessages', () => {
|
|||||||
|
|
||||||
expect(out.attachmentRefs).toBeUndefined()
|
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', () => {
|
describe('preserveLocalPendingTurnMessages', () => {
|
||||||
@@ -556,7 +692,7 @@ describe('preserveLocalPendingTurnMessages', () => {
|
|||||||
// `attachmentRefs`. A naive text compare (chatMessageText a === b) therefore
|
// `attachmentRefs`. A naive text compare (chatMessageText a === b) therefore
|
||||||
// always mismatched whenever an image was attached and re-appended the
|
// always mismatched whenever an image was attached and re-appended the
|
||||||
// optimistic row as a distinct, duplicate user bubble. Both sides must now
|
// 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', () => {
|
it('does not duplicate the optimistic image turn when the persisted turn carries @image refs', () => {
|
||||||
const previous = [
|
const previous = [
|
||||||
msg('1-user', 'user', 'first'),
|
msg('1-user', 'user', 'first'),
|
||||||
@@ -575,6 +711,91 @@ describe('preserveLocalPendingTurnMessages', () => {
|
|||||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
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', () => {
|
it('still keeps a genuinely uncommitted optimistic image turn when the persisted text differs', () => {
|
||||||
const previous = [
|
const previous = [
|
||||||
msg('1-user', 'user', 'first'),
|
msg('1-user', 'user', 'first'),
|
||||||
@@ -599,6 +820,177 @@ describe('preserveLocalPendingTurnMessages', () => {
|
|||||||
'user-optimistic'
|
'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', () => {
|
describe('appendLiveSessionProjection', () => {
|
||||||
@@ -730,4 +1122,73 @@ describe('appendLiveSessionProjection', () => {
|
|||||||
|
|
||||||
expect(appendLiveSessionProjection(stored, { session_id: 'runtime-1' })).toBe(stored)
|
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 { getSession } from '@/hermes'
|
||||||
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
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 { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||||
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||||
@@ -46,6 +47,41 @@ function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
|
|||||||
return appended ? { ...message, parts } : message
|
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.
|
* Carry structural parts an authoritative row cannot express.
|
||||||
*
|
*
|
||||||
@@ -250,8 +286,20 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
|||||||
const nextText = chatMessageText(message).trim()
|
const nextText = chatMessageText(message).trim()
|
||||||
const previousText = chatMessageText(previous)
|
const previousText = chatMessageText(previous)
|
||||||
const previousVisibleText = textWithoutEmbeddedImages(previousText)
|
const previousVisibleText = textWithoutEmbeddedImages(previousText)
|
||||||
|
const previousTrimmed = previousVisibleText.trim()
|
||||||
let preserved = message
|
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()
|
const sameText = nextText === previousVisibleText || nextText === previousText.trim()
|
||||||
|
|
||||||
// Mid-turn, the authoritative text has advanced past the cached copy by one
|
// 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
|
// for structural carry-over. Attachment refs and image re-appending stay on
|
||||||
// the strict equality path — they reconcile a SETTLED row, and a growing
|
// the strict equality path — they reconcile a SETTLED row, and a growing
|
||||||
// row is by definition not settled.
|
// 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 =
|
const sameTurn =
|
||||||
sameText ||
|
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) {
|
if (sameTurn) {
|
||||||
preserved = preserveStructuralParts(preserved, previous)
|
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 (
|
if (
|
||||||
@@ -315,8 +387,10 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
|||||||
* history window. Preserve only the newest optimistic user row: compression
|
* history window. Preserve only the newest optimistic user row: compression
|
||||||
* rewrites past context, so older `user-*` rows in a warm cache are stale
|
* rewrites past context, so older `user-*` rows in a warm cache are stale
|
||||||
* history, not in-flight work. The latest authoritative user confirms whether
|
* history, not in-flight work. The latest authoritative user confirms whether
|
||||||
* that tail has persisted; any authoritative assistant at the same ordinal
|
* that tail has persisted. An authoritative assistant at the same ordinal
|
||||||
* supersedes the local stream.
|
* 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
|
* Gateway bookkeeping markers (the model-switch / personality notices written
|
||||||
* by tui_gateway/server.py) are persisted as role=user but are not user turns.
|
* 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 =>
|
const isGatewaySystemMarker = (message: ChatMessage): boolean =>
|
||||||
message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:')
|
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(
|
export function preserveLocalPendingTurnMessages(
|
||||||
nextMessages: ChatMessage[],
|
nextMessages: ChatMessage[],
|
||||||
previousMessages: ChatMessage[]
|
previousMessages: ChatMessage[]
|
||||||
@@ -378,6 +510,9 @@ export function preserveLocalPendingTurnMessages(
|
|||||||
|
|
||||||
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
|
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
|
||||||
const preserved: ChatMessage[] = []
|
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) {
|
for (const message of previousMessages) {
|
||||||
if (isGatewaySystemMarker(message)) {
|
if (isGatewaySystemMarker(message)) {
|
||||||
@@ -392,7 +527,21 @@ export function preserveLocalPendingTurnMessages(
|
|||||||
const isPendingAssistant =
|
const isPendingAssistant =
|
||||||
message.role === 'assistant' && (message.pending === true || message.id.startsWith('assistant-stream-'))
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,19 +552,50 @@ export function preserveLocalPendingTurnMessages(
|
|||||||
if (
|
if (
|
||||||
isOptimisticUser &&
|
isOptimisticUser &&
|
||||||
latestAuthoritativeUser &&
|
latestAuthoritativeUser &&
|
||||||
textWithoutImageRefs(chatMessageText(latestAuthoritativeUser)) === textWithoutImageRefs(chatMessageText(message))
|
textWithoutReferenceLines(chatMessageText(latestAuthoritativeUser)) ===
|
||||||
|
textWithoutReferenceLines(chatMessageText(message))
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
|
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
|
||||||
|
|
||||||
if (authoritative) {
|
// A settled stream row (`pending: false` after message.complete) whose reply
|
||||||
if (isPendingAssistant) {
|
// 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
|
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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,7 +603,10 @@ export function preserveLocalPendingTurnMessages(
|
|||||||
preserved.push(message)
|
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 =>
|
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)
|
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
|
// Keep a pending assistant boundary even before the first delta when a
|
||||||
// queued user turn follows it. This preserves the two distinct turns.
|
// 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 (inflightAssistant || inflightStreaming || inflightError || (inflightUser && queuedUser)) {
|
||||||
|
if (turnAlreadyStructured && !inflightError) {
|
||||||
|
// Structure is authoritative; skip the text-only dump row.
|
||||||
|
} else {
|
||||||
projected.push({
|
projected.push({
|
||||||
id: `assistant-stream-${sessionId}`,
|
id: liveStreamId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [],
|
parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [],
|
||||||
pending: inflightStreaming,
|
pending: inflightStreaming,
|
||||||
...(inflightError ? { error: inflightError } : {})
|
...(inflightError ? { error: inflightError } : {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (queuedUser) {
|
if (queuedUser) {
|
||||||
projected.push({
|
projected.push({
|
||||||
|
|||||||
@@ -157,3 +157,17 @@ const REFERENCE_PATTERN = /@(file|folder|url|image|tool|line|terminal|session):(
|
|||||||
export function referenceRe(): RegExp {
|
export function referenceRe(): RegExp {
|
||||||
return new RegExp(REFERENCE_PATTERN.source, 'g')
|
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 { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { messageRenderWeight, RENDER_WEIGHT_CHARS } from '@/lib/render-weight'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildGroups,
|
buildGroups,
|
||||||
firstVisibleGroupIndex,
|
firstVisibleGroupIndex,
|
||||||
@@ -7,8 +9,7 @@ import {
|
|||||||
LIVE_TAIL_PARTS,
|
LIVE_TAIL_PARTS,
|
||||||
liveTailStart,
|
liveTailStart,
|
||||||
type MessageGroup,
|
type MessageGroup,
|
||||||
messageRenderWeight,
|
resolveThreadScrollTarget
|
||||||
RENDER_WEIGHT_CHARS
|
|
||||||
} from './list'
|
} from './list'
|
||||||
|
|
||||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
// 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', () => {
|
describe('firstVisibleGroupIndex', () => {
|
||||||
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
|
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
|
||||||
|
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { messageRenderWeight } from '@/lib/render-weight'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
onScrollToBottomRequest,
|
onScrollToBottomRequest,
|
||||||
@@ -28,6 +29,8 @@ import { isSecondaryWindow } from '@/store/windows'
|
|||||||
|
|
||||||
import { MessageRenderBoundary } from '../message-render-boundary'
|
import { MessageRenderBoundary } from '../message-render-boundary'
|
||||||
|
|
||||||
|
import { resolveShowEarlierAction, useTranscriptWindow } from './transcript-window'
|
||||||
|
|
||||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||||
|
|
||||||
export type MessageGroup = { id: string; weight: number } & (
|
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
|
// a virtualizer — pure rendering, never touches scrollTop, so it can't fight
|
||||||
// use-stick-to-bottom (the single scroll owner).
|
// use-stick-to-bottom (the single scroll owner).
|
||||||
const RENDER_BUDGET = 300
|
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)
|
// 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
|
// the user actually sees after scroll-to-bottom), then bump to the full budget
|
||||||
// in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render
|
// 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.
|
// blocks the click-to-paint path.
|
||||||
const FIRST_PAINT_BUDGET = 20
|
const FIRST_PAINT_BUDGET = 20
|
||||||
|
|
||||||
const contentWeightCache = new WeakMap<object, number>()
|
// Browsers may quantize a requested scrollTop to a nearby device-pixel
|
||||||
const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type'])
|
// 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
|
||||||
|
|
||||||
/**
|
export const resolveThreadScrollTarget: GetTargetScrollTop = (targetScrollTop, { scrollElement }) => {
|
||||||
* Estimate the synchronous renderer cost of one assistant-ui message.
|
const currentScrollTop = scrollElement.scrollTop
|
||||||
*
|
const remaining = targetScrollTop - currentScrollTop
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = contentWeightCache.get(content)
|
return remaining >= 0 && remaining <= SCROLL_TARGET_EPSILON_PX ? currentScrollTop : targetScrollTop
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadMessageListProps {
|
interface ThreadMessageListProps {
|
||||||
@@ -297,9 +248,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
||||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||||
initial: 'instant',
|
initial: 'instant',
|
||||||
resize: 'instant'
|
resize: 'instant',
|
||||||
|
targetScrollTop: resolveThreadScrollTarget
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { olderAvailable, expandWindow } = useTranscriptWindow()
|
||||||
|
|
||||||
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
|
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
|
||||||
|
|
||||||
// Cut the budget during RENDER, not in the post-commit layout effect. An
|
// 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
|
// 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
|
// 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 showEarlier = useCallback(() => {
|
||||||
|
const action = resolveShowEarlierAction(hiddenCount, olderAvailable)
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
anchorBeforePrepend()
|
anchorBeforePrepend()
|
||||||
|
|
||||||
|
if (action === 'dom') {
|
||||||
setRenderBudget(budget => budget + RENDER_BUDGET)
|
setRenderBudget(budget => budget + RENDER_BUDGET)
|
||||||
}, [anchorBeforePrepend])
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expandWindow()
|
||||||
|
}, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -538,7 +507,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
||||||
restoreFromBottomRef.current = null
|
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
|
// 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
|
// 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"
|
data-slot="aui_thread-content"
|
||||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||||
>
|
>
|
||||||
{hiddenCount > 0 && (
|
{(hiddenCount > 0 || olderAvailable) && (
|
||||||
<button
|
<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"
|
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}
|
onClick={showEarlier}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
|||||||
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
|
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
|
import { StatusPulse } from '@/components/ui/status-pulse'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $backgroundResume } from '@/store/background-delegation'
|
import { $backgroundResume } from '@/store/background-delegation'
|
||||||
@@ -130,7 +131,11 @@ export const ResponseLoadingIndicator: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusRow data-slot="aui_response-loading" label={hint || t.assistant.thread.loadingResponse}>
|
<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>}
|
{hint && <HintText>{hint}</HintText>}
|
||||||
<ActivityTimerText seconds={elapsed} />
|
<ActivityTimerText seconds={elapsed} />
|
||||||
</StatusRow>
|
</StatusRow>
|
||||||
@@ -236,7 +241,11 @@ export const StreamStallIndicator: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusRow data-slot="aui_stream-stall" label={hint || 'Hermes is thinking'}>
|
<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>}
|
{hint && <HintText>{hint}</HintText>}
|
||||||
<ActivityTimerText seconds={elapsed} />
|
<ActivityTimerText seconds={elapsed} />
|
||||||
</StatusRow>
|
</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.noteActiveTreeGroup('grp-main')
|
||||||
tree.noteHoveredTreeGroup('grp-side')
|
tree.noteHoveredTreeGroup('grp-side')
|
||||||
|
|
||||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||||
// The focused zone is untouched — the pointer won the target, not both.
|
// The focused zone is untouched — the pointer won the target, not both.
|
||||||
expect(activeOf('grp-main')).toBe('workspace')
|
expect(activeOf('grp-main')).toBe('workspace')
|
||||||
|
|
||||||
// Same key, pointer moved: the other zone's slot 2.
|
// Same key, pointer moved: the other zone's slot 2.
|
||||||
tree.noteHoveredTreeGroup('grp-main')
|
tree.noteHoveredTreeGroup('grp-main')
|
||||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||||
expect(activeOf('grp-main')).toBe('session-tile:a')
|
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('grp-main')
|
||||||
tree.noteHoveredTreeGroup(null)
|
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-side')).toBe('session-tile:c')
|
||||||
expect(activeOf('grp-main')).toBe('workspace')
|
expect(activeOf('grp-main')).toBe('workspace')
|
||||||
})
|
})
|
||||||
@@ -77,7 +77,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
|||||||
tree.noteActiveTreeGroup('grp-main')
|
tree.noteActiveTreeGroup('grp-main')
|
||||||
tree.noteHoveredTreeGroup('grp-side')
|
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-side')).toBe('session-tile:c')
|
||||||
expect(activeOf('grp-main')).toBe('workspace')
|
expect(activeOf('grp-main')).toBe('workspace')
|
||||||
|
|
||||||
@@ -98,9 +98,9 @@ describe('hovered zone retargets the tab verbs', () => {
|
|||||||
tree.noteActiveTreeGroup('grp-side')
|
tree.noteActiveTreeGroup('grp-side')
|
||||||
tree.noteHoveredTreeGroup(null)
|
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-side')).toBe('session-tile:c')
|
||||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
expect(tree.cycleTreeTabInFocusedZone(1)).toBeTruthy()
|
||||||
expect(activeOf('grp-side')).toBe('session-tile:b')
|
expect(activeOf('grp-side')).toBe('session-tile:b')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
|||||||
tree.noteActiveTreeGroup(null)
|
tree.noteActiveTreeGroup(null)
|
||||||
tree.noteHoveredTreeGroup(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-main')).toBe('session-tile:a')
|
||||||
expect(activeOf('grp-side')).toBe('session-tile:b')
|
expect(activeOf('grp-side')).toBe('session-tile:b')
|
||||||
})
|
})
|
||||||
@@ -138,7 +138,7 @@ describe('hovered zone retargets the tab verbs', () => {
|
|||||||
tree.noteActiveTreeGroup(null)
|
tree.noteActiveTreeGroup(null)
|
||||||
tree.noteHoveredTreeGroup('grp-files')
|
tree.noteHoveredTreeGroup('grp-files')
|
||||||
|
|
||||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
expect(tree.activateTreeTabSlot(2)).toBeTruthy()
|
||||||
expect(activeOf('grp-main')).toBe('session-tile:a')
|
expect(activeOf('grp-main')).toBe('session-tile:a')
|
||||||
// ⌘W must not close the file tree from a rung that can't serve it.
|
// ⌘W must not close the file tree from a rung that can't serve it.
|
||||||
expect(activeOf('grp-files')).toBe('files')
|
expect(activeOf('grp-files')).toBe('files')
|
||||||
|
|||||||
@@ -317,6 +317,66 @@ export function movePane(
|
|||||||
return shapeSignature(next) === shapeSignature(root) ? root : next
|
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. */
|
/** Group ids of every leaf under a node, in tree order. */
|
||||||
export function groupLeafIds(node: LayoutNode): string[] {
|
export function groupLeafIds(node: LayoutNode): string[] {
|
||||||
return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds)
|
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
|
* FancyZones span: merge the highlighted zones into ONE group holding the
|
||||||
* `paneId`, absorbing any panes that lived in those zones as tabs. Only works
|
* dragged pane block (one pane, or a multi-tab selection in strip order),
|
||||||
* when the highlighted set forms a rectangular subtree (it always does for a
|
* absorbing any panes that lived in those zones as tabs. Only works when the
|
||||||
* combined zone range on a guillotine tree); returns null otherwise so the
|
* highlighted set forms a rectangular subtree (it always does for a combined
|
||||||
* caller can fall back to a single-zone drop.
|
* 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)
|
const set = new Set(groupIds)
|
||||||
|
|
||||||
if (set.size <= 1 || !findCover(root, set)) {
|
if (set.size <= 1 || !findCover(root, set)) {
|
||||||
return null
|
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 panesInSet: string[] = []
|
||||||
|
|
||||||
const collect = (n: LayoutNode) => {
|
const collect = (n: LayoutNode) => {
|
||||||
if (n.type === 'group') {
|
if (n.type === 'group') {
|
||||||
if (set.has(n.id)) {
|
if (set.has(n.id)) {
|
||||||
panesInSet.push(...n.panes.filter(p => p !== paneId))
|
panesInSet.push(...n.panes.filter(p => !paneIds.includes(p)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
n.children.forEach(collect)
|
n.children.forEach(collect)
|
||||||
@@ -375,16 +441,19 @@ export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId:
|
|||||||
|
|
||||||
collect(root)
|
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.
|
// first (leaving that origin an empty zone). Inside the set it's absorbed.
|
||||||
const origin = findGroupOfPane(root, paneId)
|
|
||||||
let working = root
|
let working = root
|
||||||
|
|
||||||
|
for (const id of paneIds) {
|
||||||
|
const origin = findGroupOfPane(working, id)
|
||||||
|
|
||||||
if (origin && !set.has(origin.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 => {
|
const replace = (n: LayoutNode): LayoutNode => {
|
||||||
if (sameSet(groupLeafIds(n), set)) {
|
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))
|
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). */
|
/** Reorder a block of panes within a group as one unit (browser-tab drag
|
||||||
export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode {
|
* 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 => {
|
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
|
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 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 }
|
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 { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder'
|
||||||
|
|
||||||
import type { DropPosition } from '../model'
|
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'
|
import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine'
|
||||||
|
|
||||||
const DRAG_THRESHOLD_PX = 4
|
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:
|
/** Insertion slot from the pointer x against the OTHER tabs' midpoints:
|
||||||
* stack BEFORE the returned pane id (`null` = append). */
|
* stack BEFORE the returned pane id (`null` = append). `exclude` is the
|
||||||
export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } {
|
* 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) {
|
for (const slot of slots) {
|
||||||
if (slot.id === excludePaneId) {
|
if (excluded.includes(slot.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +431,11 @@ export function startPaneDrag(
|
|||||||
onTap?: () => void,
|
onTap?: () => void,
|
||||||
reorder?: ReorderContext,
|
reorder?: ReorderContext,
|
||||||
double?: DoubleTapContext,
|
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) {
|
if (e.button !== 0) {
|
||||||
return
|
return
|
||||||
@@ -431,17 +444,28 @@ export function startPaneDrag(
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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()
|
const highlighted = new HighlightedZones()
|
||||||
let zones: EngineZone[] = []
|
let zones: EngineZone[] = []
|
||||||
let strips: StripSnapshot[] = []
|
let strips: StripSnapshot[] = []
|
||||||
let mode: 'reorder' | 'zone' | null = null
|
let mode: 'reorder' | 'zone' | null = null
|
||||||
let dimmed: HTMLElement | null = null
|
let dimmed: HTMLElement[] = []
|
||||||
|
|
||||||
const markSource = () => {
|
const markSource = () => {
|
||||||
// The dragged tab dims for the drag's life — the divider says where it
|
// Every dragged tab dims for the drag's life — the divider says where they
|
||||||
// GOES, the dim says what MOVES. No live shuffle (placement-on-release).
|
// GO, the dim says what MOVES. No live shuffle (placement-on-release).
|
||||||
dimmed ??= reorder?.strip.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null
|
if (dimmed.length === 0 && reorder) {
|
||||||
dimmed?.style.setProperty('opacity', '0.45')
|
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 = () => {
|
const enterZoneMode = () => {
|
||||||
@@ -494,7 +518,7 @@ export function startPaneDrag(
|
|||||||
groupId: reorder!.groupId,
|
groupId: reorder!.groupId,
|
||||||
groupIds: [reorder!.groupId],
|
groupIds: [reorder!.groupId],
|
||||||
pos: 'center',
|
pos: 'center',
|
||||||
stack: slotBefore(reorderStrip().slots, x, paneId)
|
stack: slotBefore(reorderStrip().slots, x, moving)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +549,7 @@ export function startPaneDrag(
|
|||||||
const strip =
|
const strip =
|
||||||
groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null
|
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
|
const pos: DropPosition = stack
|
||||||
? 'center'
|
? 'center'
|
||||||
@@ -537,17 +561,26 @@ export function startPaneDrag(
|
|||||||
},
|
},
|
||||||
|
|
||||||
onCommit(hint) {
|
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) {
|
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]')]
|
const others = [...reorder.strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
|
||||||
.map(el => el.dataset.treeTab)
|
.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
|
const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length
|
||||||
|
|
||||||
if (toIndex >= 0) {
|
if (toIndex >= 0) {
|
||||||
reorderTreePane(reorder.groupId, paneId, toIndex)
|
reorderTreePanes(reorder.groupId, moving, toIndex)
|
||||||
reorderCommitHaptic()
|
reorderCommitHaptic()
|
||||||
|
spendSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,18 +592,28 @@ export function startPaneDrag(
|
|||||||
const targets = hint?.groupIds ?? []
|
const targets = hint?.groupIds ?? []
|
||||||
|
|
||||||
if (targets.length > 1) {
|
if (targets.length > 1) {
|
||||||
// Shift-span: merge the highlighted zones, dropping the pane across them.
|
// Shift-span: merge the highlighted zones, dropping the block across them.
|
||||||
mergeTreeZones([...targets], paneId, hint?.groupId ?? null)
|
mergeTreeZones([...targets], moving, hint?.groupId ?? null)
|
||||||
|
spendSelection()
|
||||||
} else if (hint?.groupId) {
|
} else if (hint?.groupId) {
|
||||||
// strip = stack at the divider slot; center = join the stack;
|
// strip = stack at the divider slot; center = join the stack;
|
||||||
// an edge = split the zone and land there.
|
// an edge = split the zone and land there. The whole selection
|
||||||
moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before })
|
// rides — the pressed tab fronts at the destination.
|
||||||
|
moveTreePanes(
|
||||||
|
moving,
|
||||||
|
{ groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before },
|
||||||
|
paneId
|
||||||
|
)
|
||||||
|
spendSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onEnd() {
|
onEnd() {
|
||||||
dimmed?.style.removeProperty('opacity')
|
for (const el of dimmed) {
|
||||||
|
el.style.removeProperty('opacity')
|
||||||
|
}
|
||||||
|
|
||||||
highlighted.reset()
|
highlighted.reset()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
$newSessionTabAction,
|
$newSessionTabAction,
|
||||||
$panesWithCloser,
|
$panesWithCloser,
|
||||||
$treeDragging,
|
$treeDragging,
|
||||||
|
$treePaneEpochs,
|
||||||
activateTreePane,
|
activateTreePane,
|
||||||
closeAllTreeTabs,
|
closeAllTreeTabs,
|
||||||
closeOtherTreeTabs,
|
closeOtherTreeTabs,
|
||||||
@@ -42,12 +43,21 @@ import {
|
|||||||
isCollapsePane,
|
isCollapsePane,
|
||||||
isSessionStripPane,
|
isSessionStripPane,
|
||||||
noteActiveTreeGroup,
|
noteActiveTreeGroup,
|
||||||
|
reloadTreePane,
|
||||||
restoreTreePane,
|
restoreTreePane,
|
||||||
SESSION_TILE_DRAG,
|
SESSION_TILE_DRAG,
|
||||||
setTreeGroupHeaderHidden,
|
setTreeGroupHeaderHidden,
|
||||||
setTreeGroupMinimized,
|
setTreeGroupMinimized,
|
||||||
treeTabCloseTargets
|
treeTabCloseTargets
|
||||||
} from '../store'
|
} from '../store'
|
||||||
|
import {
|
||||||
|
$tabSelection,
|
||||||
|
clearTabSelection,
|
||||||
|
isToggleSelectClick,
|
||||||
|
selectionFor,
|
||||||
|
selectTabRange,
|
||||||
|
toggleTabSelected
|
||||||
|
} from '../tab-selection'
|
||||||
|
|
||||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||||
import { forceLoneHeaderForPanes } from './lone-header'
|
import { forceLoneHeaderForPanes } from './lone-header'
|
||||||
@@ -96,6 +106,12 @@ function ZoneMenu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderActionItem(kit, {
|
||||||
|
icon: 'refresh',
|
||||||
|
label: t.zones.reload,
|
||||||
|
onSelect: () => reloadTreePane(targetPane())
|
||||||
|
})}
|
||||||
|
<kit.Separator />
|
||||||
{paneId !== undefined &&
|
{paneId !== undefined &&
|
||||||
renderActionItem(kit, {
|
renderActionItem(kit, {
|
||||||
icon: 'close',
|
icon: 'close',
|
||||||
@@ -178,6 +194,12 @@ export function TreeGroup({
|
|||||||
const narrow = useStore($narrowViewport)
|
const narrow = useStore($narrowViewport)
|
||||||
const newSessionTabAction = useStore($newSessionTabAction)
|
const newSessionTabAction = useStore($newSessionTabAction)
|
||||||
const panesWithCloser = useStore($panesWithCloser)
|
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)
|
const paneFor = (id: string) => panes.find(p => p.id === id)
|
||||||
|
|
||||||
@@ -424,6 +446,7 @@ export function TreeGroup({
|
|||||||
const chrome = paneChrome(paneFor(paneId))
|
const chrome = paneChrome(paneFor(paneId))
|
||||||
const closeable = closeableTab(paneId)
|
const closeable = closeableTab(paneId)
|
||||||
const title = paneFor(paneId)?.title ?? paneId
|
const title = paneFor(paneId)?.title ?? paneId
|
||||||
|
const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId)
|
||||||
|
|
||||||
const tab = (
|
const tab = (
|
||||||
<PaneTab
|
<PaneTab
|
||||||
@@ -433,11 +456,36 @@ export function TreeGroup({
|
|||||||
key={paneId}
|
key={paneId}
|
||||||
onClose={closeable ? () => closeTab(paneId) : undefined}
|
onClose={closeable ? () => closeTab(paneId) : undefined}
|
||||||
onPointerDown={e => {
|
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
|
// Tabs ACTIVATE (restoring a collapsed group). Minimize
|
||||||
// lives on the chevron / single-pane label — overloading
|
// lives on the chevron / single-pane label — overloading
|
||||||
// the active tab made double-click a minimize/restore/hide
|
// 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 = () => {
|
const onTap = () => {
|
||||||
|
clearTabSelection()
|
||||||
|
|
||||||
if (node.minimized) {
|
if (node.minimized) {
|
||||||
restoreTreePane(paneId)
|
restoreTreePane(paneId)
|
||||||
}
|
}
|
||||||
@@ -454,6 +502,26 @@ export function TreeGroup({
|
|||||||
e.stopPropagation()
|
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
|
// A pane may own its tab drag (a session tab speaks the
|
||||||
// session drop language — link/stack/split); `false` defers
|
// session drop language — link/stack/split); `false` defers
|
||||||
// to the generic pane move (the workspace tab on a fresh
|
// to the generic pane move (the workspace tab on a fresh
|
||||||
@@ -470,6 +538,7 @@ export function TreeGroup({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
selected={isSelected}
|
||||||
style={{ cursor: 'grab' }}
|
style={{ cursor: 'grab' }}
|
||||||
>
|
>
|
||||||
{chrome.tabLead ? (
|
{chrome.tabLead ? (
|
||||||
@@ -557,9 +626,14 @@ export function TreeGroup({
|
|||||||
// can gate its hot (per-token) subscriptions while hidden;
|
// can gate its hot (per-token) subscriptions while hidden;
|
||||||
// the group id identifies the ZONE it lives in, for state
|
// the group id identifies the ZONE it lives in, for state
|
||||||
// that is per-zone rather than per-tab (composer pop-out).
|
// 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}>
|
<PaneGroupContext.Provider value={node.id}>
|
||||||
<PaneVisibleContext.Provider value={isActive}>
|
<PaneVisibleContext.Provider value={isActive}>
|
||||||
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
|
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
|
||||||
|
{pane.render()}
|
||||||
|
</ContribBoundary>
|
||||||
</PaneVisibleContext.Provider>
|
</PaneVisibleContext.Provider>
|
||||||
</PaneGroupContext.Provider>
|
</PaneGroupContext.Provider>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ import {
|
|||||||
mergeZonesWithPane as mergeZonesWithPaneOp,
|
mergeZonesWithPane as mergeZonesWithPaneOp,
|
||||||
mirrorTreeHorizontal,
|
mirrorTreeHorizontal,
|
||||||
movePane as movePaneOp,
|
movePane as movePaneOp,
|
||||||
|
movePanes as movePanesOp,
|
||||||
normalize,
|
normalize,
|
||||||
removePane,
|
removePane,
|
||||||
reorderPaneInGroup as reorderPaneInGroupOp,
|
reorderPanesInGroup as reorderPanesInGroupOp,
|
||||||
setActivePane as setActivePaneOp,
|
setActivePane as setActivePaneOp,
|
||||||
setGroupHeaderHidden as setGroupHeaderHiddenOp,
|
setGroupHeaderHidden as setGroupHeaderHiddenOp,
|
||||||
setGroupMinimized,
|
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 }
|
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
|
/** 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. */
|
* syncs its toggle), everything else routes through its owning Close. */
|
||||||
export function closeTabPane(paneId: string) {
|
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
|
/** ⌘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).
|
* hovered / focused / workspace that is a real tab strip (≥2 shown panes).
|
||||||
* Pointing at the sidebar (or nothing) therefore still switches main's tabs
|
* 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. */
|
* 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 group = tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2)
|
||||||
const panes = group ? shownPanesInGroup(group) : []
|
const panes = group ? shownPanesInGroup(group) : []
|
||||||
|
|
||||||
if (!group || slot < 1 || slot > panes.length) {
|
if (!group || slot < 1 || slot > panes.length) {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
activateTreePane(group.id, panes[slot - 1])
|
activateTreePane(group.id, panes[slot - 1])
|
||||||
|
|
||||||
return true
|
return panes[slot - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ⌃Tab / ⌃⇧Tab: cycle the target zone's *visible* tabs (wrapping) — the first
|
/** ⌃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.
|
* 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
|
* Returns the activated pane id (see `activateTreeTabSlot` — landing on the
|
||||||
* no zone qualifies. */
|
* workspace under a full page must route back to the chat), or null so the
|
||||||
export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
* 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 group = tabTargetGroup(candidate => {
|
||||||
const shown = shownPanesInGroup(candidate)
|
const shown = shownPanesInGroup(candidate)
|
||||||
|
|
||||||
@@ -560,7 +580,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const panes = shownPanesInGroup(group)
|
const panes = shownPanesInGroup(group)
|
||||||
@@ -579,7 +599,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
|||||||
setTreeGroupHeaderHidden(group.id, false)
|
setTreeGroupHeaderHidden(group.id, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return nextId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a pane from the tree WITHOUT a dismissal record — for surfaces
|
/** 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
|
* Move a multi-tab SELECTION in one commit (drag any selected tab): the lead
|
||||||
* back to a single-zone move at `fallbackGroupId` when the set can't merge
|
* pane takes the drop geometry, the rest stack in behind it in strip order,
|
||||||
* (non-rectangular selection).
|
* 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()
|
const tree = $layoutTree.get()
|
||||||
|
|
||||||
if (!tree) {
|
if (!tree) {
|
||||||
return
|
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)
|
const merged = mergeZonesWithPaneOp(tree, groupIds, paneId)
|
||||||
|
|
||||||
if (merged) {
|
if (merged) {
|
||||||
commit(merged)
|
commit(merged)
|
||||||
markActivePreset('custom')
|
markActivePreset('custom')
|
||||||
markPaneUserPlaced(paneId)
|
|
||||||
|
for (const id of paneIds) {
|
||||||
|
markPaneUserPlaced(id)
|
||||||
|
}
|
||||||
} else if (fallbackGroupId) {
|
} 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()
|
const tree = $layoutTree.get()
|
||||||
|
|
||||||
if (tree) {
|
if (tree) {
|
||||||
commit(reorderPaneInGroupOp(tree, groupId, paneId, toIndex))
|
commit(reorderPanesInGroupOp(tree, groupId, paneIds, toIndex))
|
||||||
markActivePreset('custom')
|
markActivePreset('custom')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1577,7 +1635,7 @@ export function resetLayoutTree() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dev hook for automation.
|
// 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__ = {
|
;(window as unknown as Record<string, unknown>).__HERMES_LAYOUT_TREE__ = {
|
||||||
close: closeTreePane,
|
close: closeTreePane,
|
||||||
dismissed: () => $dismissedPanes.get(),
|
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 () => {
|
it('⌘1 is workspace and ⌘2 is the first SESSION tab when files is hidden', async () => {
|
||||||
const { activeOf, tree } = await setup()
|
const { activeOf, tree } = await setup()
|
||||||
|
|
||||||
expect(tree.activateTreeTabSlot(1)).toBe(true)
|
expect(tree.activateTreeTabSlot(1)).toBe('workspace')
|
||||||
expect(activeOf()).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(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')
|
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
|
// 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.
|
// (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 () => {
|
it('⌃Tab cycles only visible chips', async () => {
|
||||||
const { activeOf, tree } = await setup()
|
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(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(activeOf()).toBe('session-tile:b')
|
||||||
|
|
||||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
expect(tree.cycleTreeTabInFocusedZone(1)).toBe('workspace')
|
||||||
expect(activeOf()).toBe('workspace')
|
expect(activeOf()).toBe('workspace')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
$petRoam,
|
$petRoam,
|
||||||
$petRoamDir,
|
$petRoamDir,
|
||||||
clearPetUnread,
|
clearPetUnread,
|
||||||
|
hasPetSpriteForMeta,
|
||||||
|
mergePetInfoMeta,
|
||||||
type PetInfo,
|
type PetInfo,
|
||||||
|
type PetInfoMeta,
|
||||||
petProfile,
|
petProfile,
|
||||||
setPetInfo
|
setPetInfo
|
||||||
} from '@/store/pet'
|
} from '@/store/pet'
|
||||||
@@ -39,25 +42,6 @@ interface Point {
|
|||||||
y: number
|
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
|
// 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.
|
// size; the live size flows in once `info` arrives.
|
||||||
function clampPoint(x: number, y: number, w: number, h: number): Point {
|
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
|
// pet.changed already carries the meta payload — an enabled=false
|
||||||
// broadcast clears the mascot with zero round-trips, and an unchanged
|
// broadcast clears the mascot with zero round-trips, and an unchanged
|
||||||
// revision (scale-only move still changes the sig) short-circuits below
|
// 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) {
|
if (changeEventsAvailable && petChange.tick > 0 && petChange.meta?.enabled === false) {
|
||||||
setPetInfo({ enabled: false })
|
setPetInfo({ enabled: false })
|
||||||
|
|
||||||
@@ -184,7 +168,15 @@ export function FloatingPet() {
|
|||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -223,7 +215,14 @@ export function FloatingPet() {
|
|||||||
// so no timer. Legacy backend: the historical poll.
|
// so no timer. Legacy backend: the historical poll.
|
||||||
const timer = changeEventsAvailable
|
const timer = changeEventsAvailable
|
||||||
? null
|
? 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 () => {
|
return () => {
|
||||||
cancelled = true
|
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 spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'
|
||||||
|
|
||||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||||
|
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type { SpinnerName }
|
export type { SpinnerName }
|
||||||
@@ -43,29 +44,66 @@ interface GlyphSpinnerProps {
|
|||||||
*/
|
*/
|
||||||
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
|
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
|
||||||
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
|
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
|
// 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()
|
const visible = usePaneVisible()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
const glyph = glyphRef.current
|
||||||
|
|
||||||
|
if (!visible || !glyph) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setFrame(0)
|
let frame = 0
|
||||||
const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
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])
|
}, [spin, visible])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
|
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
|
||||||
|
ref={glyphRef}
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
{spin.frames[frame]}
|
{spin.frames[0]}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user