Merge branch 'main' into feat/hermes-relay-shared-metrics
This commit is contained in:
@@ -115,6 +115,11 @@ jobs:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
infographic-check:
|
||||
name: Check no committed infographics
|
||||
needs: detect
|
||||
uses: ./.github/workflows/infographic-check.yml
|
||||
|
||||
lockfile-diff:
|
||||
name: package-lock.json diff
|
||||
needs: detect
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Infographic Check
|
||||
|
||||
# Rejects PRs that commit PR-infographic images into the repo.
|
||||
#
|
||||
# PR infographics are rendered to an image-provider URL (fal.media) and
|
||||
# embedded in the PR *description*. The PR body is the archive; the binary
|
||||
# never belongs in git history.
|
||||
#
|
||||
# This has now leaked twice. PR #48261 removed the first batch, PR #54564
|
||||
# removed a second batch and added `infographic/` to `.gitignore` — but
|
||||
# `.gitignore` only stops *accidental* `git add`. It does nothing against
|
||||
# `git add -f`, and it does nothing for a path that does not literally match
|
||||
# the ignore pattern. Nine more PNGs (~14MB) were committed in the four
|
||||
# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/`
|
||||
# spelling that sidestepped the pattern entirely.
|
||||
#
|
||||
# A passive ignore rule cannot enforce a policy. This check can.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review_status objects for the synthesizer."
|
||||
value: ${{ jobs.check-no-committed-infographics.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-no-committed-infographics:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.infographic-check.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- id: infographic-check
|
||||
name: Reject committed PR-infographic images
|
||||
run: |
|
||||
# Match on the IMAGE, not on a directory name. Keying this to
|
||||
# `infographic/` is what let `infograficos/` through in #70552 —
|
||||
# any localized or typo'd directory would sidestep it again.
|
||||
# Instead: find tracked raster images whose path contains an
|
||||
# infographic-ish segment, in any spelling, at any depth.
|
||||
#
|
||||
# `docs/assets` and `website/` legitimately hold product imagery
|
||||
# and are excluded; those are referenced from shipped docs pages.
|
||||
OFFENDERS=$(git ls-files -z \
|
||||
| tr '\0' '\n' \
|
||||
| grep -iE '(^|/)(infograph|infograf)[^/]*/' \
|
||||
| grep -iE '\.(png|jpe?g|webp|gif)$' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$OFFENDERS" ]; then
|
||||
COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ')
|
||||
STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached <path-to-image>\n```\nThen put it in the PR description:\n```\n## Infographic\n\n\n```\n"}]}]'
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
echo ""
|
||||
echo "::error::${COUNT} PR-infographic image(s) are tracked in git."
|
||||
echo ""
|
||||
printf '%s\n' "$OFFENDERS" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "PR infographics are rendered to an image-provider URL and"
|
||||
echo "embedded in the PR DESCRIPTION. The PR body is the archive —"
|
||||
echo "the binary never enters git history."
|
||||
echo ""
|
||||
echo "This rule has been re-established twice already (#48261,"
|
||||
echo "#54564) and leaked both times, because .gitignore cannot stop"
|
||||
echo "'git add -f' or a differently-spelled directory (#70552)."
|
||||
echo ""
|
||||
echo "To fix:"
|
||||
echo " git rm --cached <path> # keeps your local copy"
|
||||
echo " # then embed the provider URL in the PR description"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::No committed PR-infographic images."
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
@@ -175,5 +175,13 @@ apps/desktop/demo/
|
||||
# image-provider (fal.media) URL — they are NEVER committed to the repo. The
|
||||
# PR body is the archive. See the hermes-agent-dev skill's
|
||||
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
|
||||
#
|
||||
# Spelling variants are listed because a single `infographic/` pattern was
|
||||
# sidestepped by an `infograficos/` directory (#70552). .gitignore is only
|
||||
# the first line of defence and cannot stop `git add -f` at all — the
|
||||
# infographic-check CI job is what actually enforces this.
|
||||
infographic/
|
||||
infographics/
|
||||
infograficos/
|
||||
infografico/
|
||||
native/fts5_cjk/*.so
|
||||
|
||||
@@ -325,7 +325,7 @@ class AIAgent:
|
||||
provider: str = None,
|
||||
api_mode: str = None, # "chat_completions" | "codex_responses" | ...
|
||||
model: str = "", # empty → resolved from config/provider later
|
||||
max_iterations: int = 90, # tool-calling iterations (shared with subagents)
|
||||
max_iterations: int = 500, # tool-calling iterations (shared with subagents)
|
||||
enabled_toolsets: list = None,
|
||||
disabled_toolsets: list = None,
|
||||
quiet_mode: bool = False,
|
||||
|
||||
+59
@@ -1,3 +1,45 @@
|
||||
# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset
|
||||
# corruption bug. Build a pinned shared library for the runtime image instead
|
||||
# of relying on a distro backport that trixie does not currently provide.
|
||||
# See #70480 and https://sqlite.org/wal.html#walresetbug.
|
||||
FROM debian:13.4 AS sqlite_build
|
||||
ARG SQLITE_AUTOCONF_VERSION=3530400
|
||||
ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c
|
||||
RUN apt-get -o Acquire::Retries=3 update && \
|
||||
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
(curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \
|
||||
-o /tmp/sqlite.tar.gz \
|
||||
"https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \
|
||||
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \
|
||||
-o /tmp/sqlite.tar.gz \
|
||||
"https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \
|
||||
printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \
|
||||
sha256sum -c /tmp/sqlite.sha256 && \
|
||||
tar -xzf /tmp/sqlite.tar.gz -C /tmp && \
|
||||
cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \
|
||||
CFLAGS="-O2 \
|
||||
-DSQLITE_ENABLE_FTS3 \
|
||||
-DSQLITE_ENABLE_FTS3_PARENTHESIS \
|
||||
-DSQLITE_ENABLE_FTS4 \
|
||||
-DSQLITE_ENABLE_FTS5 \
|
||||
-DSQLITE_ENABLE_RTREE \
|
||||
-DSQLITE_ENABLE_GEOPOLY \
|
||||
-DSQLITE_ENABLE_COLUMN_METADATA \
|
||||
-DSQLITE_ENABLE_UNLOCK_NOTIFY \
|
||||
-DSQLITE_ENABLE_DBSTAT_VTAB \
|
||||
-DSQLITE_ENABLE_DBPAGE_VTAB \
|
||||
-DSQLITE_ENABLE_MATH_FUNCTIONS \
|
||||
-DSQLITE_ENABLE_PREUPDATE_HOOK \
|
||||
-DSQLITE_ENABLE_SESSION \
|
||||
-DSQLITE_SECURE_DELETE \
|
||||
-DSQLITE_THREADSAFE=1 \
|
||||
-DSQLITE_MAX_VARIABLE_NUMBER=250000" \
|
||||
./configure --prefix=/opt/sqlite-fixed --disable-static && \
|
||||
make -j"$(nproc)" && \
|
||||
make install
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
@@ -31,6 +73,23 @@ RUN apt-get -o Acquire::Retries=3 update && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the
|
||||
# public library name stable so both the system interpreter and the uv-created
|
||||
# venv resolve the replacement without changing Python import paths.
|
||||
COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/
|
||||
RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \
|
||||
ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \
|
||||
printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \
|
||||
ldconfig && \
|
||||
python3 -c "import sqlite3, sys; \
|
||||
v = sqlite3.sqlite_version_info; \
|
||||
sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \
|
||||
db = sqlite3.connect(':memory:'); \
|
||||
db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \
|
||||
db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \
|
||||
sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \
|
||||
db.close()"
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
# s6-overlay provides supervision for the main hermes process, the dashboard,
|
||||
# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.
|
||||
|
||||
+2
-2
@@ -455,7 +455,7 @@ def init_agent(
|
||||
command: str = None,
|
||||
args: list[str] | None = None,
|
||||
model: str = "",
|
||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||
max_iterations: int = 500, # Default tool-calling iterations (shared with subagents)
|
||||
tool_delay: float = 1.0,
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
@@ -529,7 +529,7 @@ def init_agent(
|
||||
requested_provider (str): Original provider identity before runtime canonicalization
|
||||
api_mode (str): API mode override: "chat_completions" or "codex_responses"
|
||||
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
|
||||
max_iterations (int): Maximum number of tool calling iterations (default: 90)
|
||||
max_iterations (int): Maximum number of tool calling iterations (default: 500)
|
||||
tool_delay (float): Delay between tool calls in seconds (default: 1.0)
|
||||
enabled_toolsets (List[str]): Only enable tools from these toolsets (optional)
|
||||
disabled_toolsets (List[str]): Disable tools from these toolsets (optional)
|
||||
|
||||
@@ -2615,6 +2615,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
_clarify_tool(
|
||||
question=next_args.get("question", ""),
|
||||
choices=next_args.get("choices"),
|
||||
multi_select=next_args.get("multi_select", False),
|
||||
callback=agent.clarify_callback,
|
||||
),
|
||||
next_args,
|
||||
|
||||
@@ -2060,6 +2060,28 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(
|
||||
replayed, m.get("cache_control")
|
||||
)
|
||||
# apply_anthropic_cache_control marks an assistant turn with
|
||||
# non-empty text by writing cache_control INTO ``content`` (see
|
||||
# _apply_cache_marker's list branch), not at the top level. This
|
||||
# branch rebuilds the message from ordered_blocks and never reads
|
||||
# ``content``, so that marker would be dropped -- and because
|
||||
# _can_carry_marker already counted this message as a carrier, the
|
||||
# breakpoint is burned rather than relocated. #56195 covered the
|
||||
# complementary shape (blank content -> top-level marker); this is
|
||||
# the interleaved thinking + preamble-text + tool_use shape.
|
||||
_inline_cc = None
|
||||
_msg_content = m.get("content")
|
||||
if isinstance(_msg_content, list):
|
||||
for _blk in _msg_content:
|
||||
if isinstance(_blk, dict) and isinstance(
|
||||
_blk.get("cache_control"), dict
|
||||
):
|
||||
_inline_cc = _blk["cache_control"]
|
||||
break
|
||||
if _inline_cc is not None:
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(
|
||||
replayed, _inline_cc
|
||||
)
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
|
||||
@@ -107,6 +107,22 @@ class CardInfo:
|
||||
return f"{self.masked} — {label}" if label else self.masked
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaymentMethodInfo:
|
||||
"""The payment method on file. `kind` is "card", "link", or "unknown"
|
||||
— anything else is normalised to "unknown" at parse time, so consumers
|
||||
only ever see fields that belong to the kind they are looking at."""
|
||||
|
||||
kind: str
|
||||
brand: Optional[str] = None
|
||||
last4: Optional[str] = None
|
||||
wallet: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
resolved_via: Optional[str] = None
|
||||
#: What the server called it, when we did not recognise the kind.
|
||||
raw_kind: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyCap:
|
||||
limit_usd: Optional[Decimal] = None
|
||||
@@ -150,6 +166,7 @@ class BillingState:
|
||||
min_usd: Optional[Decimal] = None
|
||||
max_usd: Optional[Decimal] = None
|
||||
card: Optional[CardInfo] = None
|
||||
payment_method: Optional[PaymentMethodInfo] = None
|
||||
monthly_cap: Optional[MonthlyCap] = None
|
||||
auto_reload: Optional[AutoReload] = None
|
||||
portal_url: Optional[str] = None
|
||||
@@ -201,6 +218,41 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
|
||||
|
||||
|
||||
def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
kind = raw.get("kind")
|
||||
if not isinstance(kind, str):
|
||||
return None
|
||||
|
||||
def _optional_string(key: str) -> Optional[str]:
|
||||
value = raw.get(key)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
resolved_via = _optional_string("resolvedVia")
|
||||
brand = _optional_string("brand")
|
||||
last4 = _optional_string("last4")
|
||||
# Settle the kind here, the way _parse_card settles a card, so nothing
|
||||
# downstream has to re-check which fields this kind is allowed to have.
|
||||
if kind == "card" and brand and last4:
|
||||
return PaymentMethodInfo(
|
||||
kind="card",
|
||||
brand=brand,
|
||||
last4=last4,
|
||||
wallet=_optional_string("wallet"),
|
||||
resolved_via=resolved_via,
|
||||
)
|
||||
if kind == "link":
|
||||
return PaymentMethodInfo(
|
||||
kind="link",
|
||||
email=_optional_string("email"),
|
||||
resolved_via=resolved_via,
|
||||
)
|
||||
return PaymentMethodInfo(
|
||||
kind="unknown", raw_kind=kind, resolved_via=resolved_via
|
||||
)
|
||||
|
||||
|
||||
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
@@ -274,6 +326,7 @@ def billing_state_from_payload(
|
||||
min_usd=parse_money(bounds.get("minUsd")),
|
||||
max_usd=parse_money(bounds.get("maxUsd")),
|
||||
card=_parse_card(payload.get("card")),
|
||||
payment_method=_parse_payment_method(payload.get("paymentMethod")),
|
||||
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
||||
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
||||
portal_url=portal_url,
|
||||
|
||||
@@ -154,3 +154,207 @@ def compute_session_context_breakdown(
|
||||
"estimated_total": estimated_total,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
}
|
||||
|
||||
|
||||
# ── /context rendering (CLI + gateway) ──────────────────────────────────────
|
||||
#
|
||||
# Pure text renderers over the payload above. The CLI shows a glyph block-grid
|
||||
# plus a category table; the gateway uses the same table without the grid
|
||||
# (proportional monospace is not guaranteed on messaging platforms).
|
||||
|
||||
_CATEGORY_GLYPHS = {
|
||||
"system_prompt": "■",
|
||||
"tool_definitions": "▣",
|
||||
"rules": "▩",
|
||||
"skills": "▤",
|
||||
"mcp": "▥",
|
||||
"subagent_definitions": "▦",
|
||||
"memory": "▧",
|
||||
"conversation": "▨",
|
||||
}
|
||||
_FREE_GLYPH = "·"
|
||||
_GRID_COLUMNS = 20
|
||||
_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window
|
||||
|
||||
# Human-readable tables cap the expanded listings; nothing is dropped from
|
||||
# the underlying data.
|
||||
_DETAILS_TABLE_LIMIT = 15
|
||||
|
||||
|
||||
def _bytes_to_tokens(size: Optional[int]) -> Optional[int]:
|
||||
if size is None:
|
||||
return None
|
||||
return (int(size) + 3) // 4
|
||||
|
||||
|
||||
def compute_context_details(agent: Any) -> Dict[str, Any]:
|
||||
"""Expanded per-skill / per-toolset cost listing for ``/context all``.
|
||||
|
||||
Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656):
|
||||
per-skill index-line bytes parsed from the live ``<available_skills>``
|
||||
block, and per-toolset schema bytes attributed via the tool registry's
|
||||
canonical tool→toolset map. Byte figures are converted to the same
|
||||
chars/4 token heuristic the categories above use.
|
||||
"""
|
||||
from hermes_cli.prompt_size import (
|
||||
_compute_skills_breakdown,
|
||||
_compute_toolsets_breakdown,
|
||||
)
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
stable = parts.get("stable", "") or ""
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_block = skills_match.group(0) if skills_match else ""
|
||||
|
||||
skills: List[Dict[str, Any]] = []
|
||||
if skills_block:
|
||||
for entry in _compute_skills_breakdown(skills_block):
|
||||
skills.append({
|
||||
"name": entry.get("name", ""),
|
||||
"index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0,
|
||||
"skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")),
|
||||
})
|
||||
|
||||
toolsets: List[Dict[str, Any]] = []
|
||||
tools = list(getattr(agent, "tools", None) or [])
|
||||
if tools:
|
||||
for group in _compute_toolsets_breakdown(tools):
|
||||
toolsets.append({
|
||||
"toolset": group.get("toolset", ""),
|
||||
"tool_count": int(group.get("tool_count", 0) or 0),
|
||||
"schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0,
|
||||
})
|
||||
|
||||
return {"skills": skills, "toolsets": toolsets}
|
||||
|
||||
|
||||
def render_context_grid(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Render the payload as a Claude Code-style glyph block grid.
|
||||
|
||||
100 cells (5×20), each one percent of the model context window. Categories
|
||||
fill in declaration order; the remainder renders as free space.
|
||||
"""
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
categories = payload.get("categories") or []
|
||||
total_cells = _GRID_COLUMNS * _GRID_ROWS
|
||||
|
||||
cells: List[str] = []
|
||||
if context_max > 0:
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
n = round(tokens / context_max * total_cells)
|
||||
if tokens > 0 and n == 0:
|
||||
n = 1 # never render a nonzero category as invisible
|
||||
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪")
|
||||
cells.extend([glyph] * n)
|
||||
cells = cells[:total_cells]
|
||||
cells.extend([_FREE_GLYPH] * (total_cells - len(cells)))
|
||||
|
||||
return [
|
||||
" ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS])
|
||||
for row in range(_GRID_ROWS)
|
||||
]
|
||||
|
||||
|
||||
def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Render the 'Estimated usage by category' table as plain-text lines."""
|
||||
categories = payload.get("categories") or []
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
estimated_total = int(payload.get("estimated_total") or 0)
|
||||
denom = context_max or estimated_total
|
||||
|
||||
lines = ["Estimated usage by category"]
|
||||
if not categories:
|
||||
lines.append(" (no data yet — send a message first)")
|
||||
return lines
|
||||
|
||||
width = max(len(str(cat.get("label") or "")) for cat in categories)
|
||||
width = max(width, len("Free space"))
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪")
|
||||
pct = tokens / denom * 100 if denom else 0.0
|
||||
label = str(cat.get("label") or cat.get("id") or "")
|
||||
lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%")
|
||||
if context_max > 0:
|
||||
free = max(0, context_max - estimated_total)
|
||||
pct = free / context_max * 100
|
||||
lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%")
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_details_lines(details: Dict[str, Any]) -> List[str]:
|
||||
"""Render the expanded ``/context all`` per-skill / per-toolset tables."""
|
||||
lines: List[str] = []
|
||||
|
||||
toolsets = details.get("toolsets") or []
|
||||
if toolsets:
|
||||
lines.append("Toolsets by schema cost (largest first)")
|
||||
for group in toolsets[:_DETAILS_TABLE_LIMIT]:
|
||||
lines.append(
|
||||
f" {group['toolset']:<24} {group['tool_count']:>3} tools"
|
||||
f" {group['schema_tokens']:>8,} tokens"
|
||||
)
|
||||
remaining = len(toolsets) - _DETAILS_TABLE_LIMIT
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more")
|
||||
|
||||
skills = details.get("skills") or []
|
||||
if skills:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)")
|
||||
for entry in skills[:_DETAILS_TABLE_LIMIT]:
|
||||
name = str(entry.get("name") or "")
|
||||
if len(name) > 28:
|
||||
name = name[:27] + "…"
|
||||
md = entry.get("skill_md_tokens")
|
||||
md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}"
|
||||
lines.append(
|
||||
f" {name:<28} index {entry['index_tokens']:>6,}"
|
||||
f" SKILL.md {md_str} tokens"
|
||||
)
|
||||
remaining = len(skills) - _DETAILS_TABLE_LIMIT
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_breakdown_lines(
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
grid: bool = True,
|
||||
) -> List[str]:
|
||||
"""Render the full /context view as plain-text lines.
|
||||
|
||||
``grid=True`` (CLI) prepends the glyph block grid; the gateway passes
|
||||
``grid=False`` and keeps its own gauge. ``details`` (from
|
||||
:func:`compute_context_details`) appends the expanded listings.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
if grid:
|
||||
lines.extend(render_context_grid(payload))
|
||||
lines.append("")
|
||||
lines.extend(render_context_category_lines(payload))
|
||||
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
context_used = int(payload.get("context_used") or 0)
|
||||
if context_max > 0:
|
||||
pct = int(payload.get("context_percent") or 0)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)"
|
||||
)
|
||||
|
||||
if details is not None:
|
||||
detail_lines = render_context_details_lines(details)
|
||||
if detail_lines:
|
||||
lines.append("")
|
||||
lines.extend(detail_lines)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("Use /context all for per-skill and per-toolset costs.")
|
||||
return lines
|
||||
|
||||
@@ -774,6 +774,41 @@ def _compression_deferred_result(
|
||||
}
|
||||
|
||||
|
||||
def _rewrite_system_content_blocks(system_message: dict, effective: str) -> bool:
|
||||
"""Rewrite a cache-decorated system message in place, keeping its blocks.
|
||||
|
||||
``apply_anthropic_cache_control`` runs once per call block, *before* the
|
||||
retry loop, and splits the system prompt into ``[static prefix, volatile
|
||||
tail]`` text blocks carrying the cache_control breakpoints. Assigning a bare
|
||||
string over that list drops both breakpoints, so the failover retry ships
|
||||
the whole system prompt uncached and re-bills it in full.
|
||||
|
||||
``rewrite_prompt_model_identity`` only touches the LAST ``Model:`` /
|
||||
``Provider:`` lines, and those live in the volatile tail — so the static
|
||||
prefix stays byte-identical and its cache entry keeps matching. Returns
|
||||
False when the shape is not one we can safely patch, so the caller falls
|
||||
back to the plain-string assignment.
|
||||
"""
|
||||
content = system_message.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
return False
|
||||
if not all(
|
||||
isinstance(part, dict) and part.get("type") == "text" for part in content
|
||||
):
|
||||
return False
|
||||
if len(content) == 1:
|
||||
content[0]["text"] = effective
|
||||
return True
|
||||
if len(content) == 2:
|
||||
head = content[0].get("text") or ""
|
||||
if head and effective.startswith(head):
|
||||
tail = effective[len(head):]
|
||||
if tail:
|
||||
content[1]["text"] = tail
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sync_failover_system_message(agent, api_messages, active_system_prompt):
|
||||
"""Refresh the in-flight system message after a provider failover.
|
||||
|
||||
@@ -796,7 +831,8 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
|
||||
effective = sp
|
||||
if agent.ephemeral_system_prompt:
|
||||
effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip()
|
||||
api_messages[0]["content"] = effective
|
||||
if not _rewrite_system_content_blocks(api_messages[0], effective):
|
||||
api_messages[0]["content"] = effective
|
||||
return sp
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or
|
||||
subagent) holds an :class:`IterationBudget`; the parent's cap comes from
|
||||
``max_iterations`` (default 90), each subagent's cap comes from
|
||||
``max_iterations`` (default 500), each subagent's cap comes from
|
||||
``delegation.max_iterations`` (default 50).
|
||||
|
||||
``run_agent`` re-exports ``IterationBudget`` so existing
|
||||
@@ -18,7 +18,7 @@ class IterationBudget:
|
||||
"""Thread-safe iteration counter for an agent.
|
||||
|
||||
Each agent (parent or subagent) gets its own ``IterationBudget``.
|
||||
The parent's budget is capped at ``max_iterations`` (default 90).
|
||||
The parent's budget is capped at ``max_iterations`` (default 500).
|
||||
Each subagent gets an independent budget capped at
|
||||
``delegation.max_iterations`` (default 50) — this means total
|
||||
iterations across parent + subagents can exceed the parent's cap.
|
||||
|
||||
@@ -1465,6 +1465,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
return _clarify_tool(
|
||||
question=next_args.get("question", ""),
|
||||
choices=next_args.get("choices"),
|
||||
multi_select=next_args.get("multi_select", False),
|
||||
callback=agent.clarify_callback,
|
||||
)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Per-turn accounting for the interactive CLI.
|
||||
|
||||
Two display-only pieces live here:
|
||||
|
||||
* :class:`TurnSummaryCollector` — a tiny observer that rides the existing
|
||||
``tool_progress_callback`` feed (``tool.completed`` events already carry
|
||||
the tool name and its raw result) and tallies what a turn actually did.
|
||||
It holds **no** agent-loop state: the display layer already sees every
|
||||
tool call, so nothing new is threaded through the conversation loop.
|
||||
* :func:`format_turn_summary` — a pure formatter that turns a tally plus a
|
||||
wall-clock duration into one dim line, e.g.::
|
||||
|
||||
⋯ 12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands
|
||||
|
||||
Ported from Claude Code's post-turn accounting line
|
||||
("Edited 1 file +6 -2, read 1 file … Worked for 10s").
|
||||
|
||||
:func:`format_token_flow` is the spinner-side counterpart: a cumulative
|
||||
token readout appended to the live elapsed timer (``↓ 1.2k tok``).
|
||||
|
||||
Everything in this module is pure/side-effect free apart from the
|
||||
collector's own counters, which makes it directly unit-testable without a
|
||||
terminal, an agent, or a network call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"TurnSummaryCollector",
|
||||
"TurnTally",
|
||||
"format_turn_summary",
|
||||
"format_token_flow",
|
||||
"format_elapsed",
|
||||
]
|
||||
|
||||
|
||||
# Leading glyph for the summary line. Deliberately not an emoji — the line is
|
||||
# meant to read as terminal chrome, not as agent speech.
|
||||
SUMMARY_PREFIX = "⋯"
|
||||
|
||||
# A turn that called no tools and finished this fast has nothing worth
|
||||
# reporting (plain chat reply). Below the threshold the formatter returns "".
|
||||
_MIN_TOOLLESS_SECONDS = 2.0
|
||||
|
||||
# Max number of "verb + count" segments rendered before collapsing the rest
|
||||
# into a "+N more" tail, so a 12-tool turn cannot blow past one line.
|
||||
_MAX_SEGMENTS = 4
|
||||
|
||||
|
||||
# Tool name -> (verb, singular noun, plural noun).
|
||||
#
|
||||
# Verbs are past tense because the line is printed *after* the turn. Tools not
|
||||
# listed here fall into a generic "called N tools" bucket rather than inventing
|
||||
# phrasing for plugin/MCP tools whose semantics we don't know.
|
||||
_VERB_GROUPS: dict[str, tuple[str, str, str]] = {
|
||||
"write_file": ("edited", "file", "files"),
|
||||
"patch": ("edited", "file", "files"),
|
||||
"read_file": ("read", "file", "files"),
|
||||
"web_extract": ("read", "page", "pages"),
|
||||
"terminal": ("ran", "command", "commands"),
|
||||
"execute_code": ("ran", "script", "scripts"),
|
||||
"search_files": ("searched", "path", "paths"),
|
||||
"web_search": ("searched the web", "time", "times"),
|
||||
"session_search": ("searched sessions", "time", "times"),
|
||||
"browser_navigate": ("browsed", "page", "pages"),
|
||||
"skill_view": ("read", "skill", "skills"),
|
||||
"skill_manage": ("updated", "skill", "skills"),
|
||||
"skills_list": ("listed skills", "time", "times"),
|
||||
"todo": ("updated", "task list", "task lists"),
|
||||
"delegate_task": ("delegated", "task", "tasks"),
|
||||
"memory": ("updated", "memory", "memories"),
|
||||
}
|
||||
|
||||
# Verb groups that carry file-edit line deltas (+X -Y) when known.
|
||||
_EDIT_VERB = "edited"
|
||||
|
||||
# Render order: edits first (the thing users most want confirmed), then reads,
|
||||
# then commands. Anything else follows in first-seen order.
|
||||
_VERB_PRIORITY: tuple[str, ...] = ("edited", "read", "ran")
|
||||
|
||||
# Tools whose results may report a unified diff we can count lines from.
|
||||
_DIFF_RESULT_TOOLS = frozenset({"patch"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnTally:
|
||||
"""What a single turn did, as observed from the tool-progress feed."""
|
||||
|
||||
# verb -> {noun_plural: count}; keeps insertion order for stable rendering.
|
||||
verbs: dict[str, dict[str, int]] = field(default_factory=dict)
|
||||
# Tools with no curated verb, counted together.
|
||||
other_tools: int = 0
|
||||
# Aggregated unified-diff line deltas across edit tools, when reported.
|
||||
lines_added: int = 0
|
||||
lines_removed: int = 0
|
||||
# True once at least one edit tool reported a countable diff, so the
|
||||
# formatter knows the difference between "+0 -0" and "unknown".
|
||||
has_line_deltas: bool = False
|
||||
|
||||
@property
|
||||
def total_tools(self) -> int:
|
||||
counted = sum(sum(nouns.values()) for nouns in self.verbs.values())
|
||||
return counted + self.other_tools
|
||||
|
||||
|
||||
def _count_diff_lines(diff: str) -> tuple[int, int]:
|
||||
"""Count added/removed lines in unified-diff text.
|
||||
|
||||
File headers (``+++``/``---``) are excluded so a one-line edit does not
|
||||
read as three additions.
|
||||
"""
|
||||
added = removed = 0
|
||||
for line in diff.splitlines():
|
||||
if line.startswith("+++") or line.startswith("---"):
|
||||
continue
|
||||
if line.startswith("+"):
|
||||
added += 1
|
||||
elif line.startswith("-"):
|
||||
removed += 1
|
||||
return added, removed
|
||||
|
||||
|
||||
def _extract_line_deltas(tool_name: str, result: Any) -> tuple[int, int] | None:
|
||||
"""Pull (added, removed) from a tool result, or None when unavailable.
|
||||
|
||||
Only tools that already report a diff in their result payload are
|
||||
inspected — we never shell out to git and never re-read files to
|
||||
synthesise a delta.
|
||||
"""
|
||||
if tool_name not in _DIFF_RESULT_TOOLS:
|
||||
return None
|
||||
payload: Any = result
|
||||
if isinstance(payload, str):
|
||||
text = payload.strip()
|
||||
if not text.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
import json
|
||||
|
||||
# strict=False tolerates literal control characters inside strings
|
||||
# (raw newlines in an embedded diff), which some tool serialisers
|
||||
# emit. A tally line is never worth failing over formatting.
|
||||
payload = json.loads(text, strict=False)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
diff = payload.get("diff")
|
||||
if not isinstance(diff, str) or not diff.strip():
|
||||
return None
|
||||
added, removed = _count_diff_lines(diff)
|
||||
# A diff that carries no +/- content lines (e.g. a bare hunk header) tells
|
||||
# us nothing — report it as unknown rather than rendering a misleading
|
||||
# "+0 -0" next to a real edit.
|
||||
if added == 0 and removed == 0:
|
||||
return None
|
||||
return added, removed
|
||||
|
||||
|
||||
class TurnSummaryCollector:
|
||||
"""Accumulate per-turn tool tallies from the tool-progress feed.
|
||||
|
||||
Wired into the CLI's existing ``_on_tool_progress`` handler: the display
|
||||
layer already receives every ``tool.completed`` event with the tool name
|
||||
and raw result, so no agent-loop bookkeeping is added.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tally = TurnTally()
|
||||
|
||||
def begin(self) -> None:
|
||||
"""Start a fresh turn (drops any prior tally)."""
|
||||
self._tally = TurnTally()
|
||||
|
||||
def record_tool(
|
||||
self,
|
||||
tool_name: str | None,
|
||||
*,
|
||||
result: Any = None,
|
||||
is_error: bool = False,
|
||||
) -> None:
|
||||
"""Record one completed tool call.
|
||||
|
||||
Failed calls are skipped: a summary claiming "edited 2 files" when one
|
||||
write was denied would be exactly the over-claim the file-mutation
|
||||
verifier exists to catch.
|
||||
"""
|
||||
if not tool_name or is_error:
|
||||
return
|
||||
# Internal/pseudo tools (``_thinking``) are not user-visible work.
|
||||
if tool_name.startswith("_"):
|
||||
return
|
||||
|
||||
group = _VERB_GROUPS.get(tool_name)
|
||||
if group is None:
|
||||
self._tally.other_tools += 1
|
||||
return
|
||||
|
||||
verb, _singular, plural = group
|
||||
nouns = self._tally.verbs.setdefault(verb, {})
|
||||
nouns[plural] = nouns.get(plural, 0) + 1
|
||||
|
||||
if verb == _EDIT_VERB:
|
||||
deltas = _extract_line_deltas(tool_name, result)
|
||||
if deltas is not None:
|
||||
added, removed = deltas
|
||||
self._tally.lines_added += added
|
||||
self._tally.lines_removed += removed
|
||||
self._tally.has_line_deltas = True
|
||||
|
||||
@property
|
||||
def tally(self) -> TurnTally:
|
||||
return self._tally
|
||||
|
||||
def render(self, elapsed_seconds: float) -> str:
|
||||
"""Render this turn's summary line (see :func:`format_turn_summary`)."""
|
||||
return format_turn_summary(elapsed_seconds, self._tally)
|
||||
|
||||
|
||||
def format_elapsed(seconds: float) -> str:
|
||||
"""Format a wall-clock duration compactly (``12.4s`` / ``2m05s``)."""
|
||||
if seconds < 0:
|
||||
seconds = 0.0
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes, rest = divmod(int(round(seconds)), 60)
|
||||
return f"{minutes}m{rest:02d}s"
|
||||
|
||||
|
||||
def _pluralize(count: int, plural_noun: str) -> str:
|
||||
"""Return ``"1 file"`` / ``"3 files"`` from a plural noun form."""
|
||||
if count == 1:
|
||||
singular = plural_noun
|
||||
if plural_noun.endswith("ies"):
|
||||
singular = plural_noun[:-3] + "y"
|
||||
elif plural_noun.endswith("ses"):
|
||||
singular = plural_noun[:-2]
|
||||
elif plural_noun.endswith("s"):
|
||||
singular = plural_noun[:-1]
|
||||
return f"1 {singular}"
|
||||
return f"{count} {plural_noun}"
|
||||
|
||||
|
||||
def _ordered_verbs(tally: TurnTally) -> list[str]:
|
||||
"""Verbs in render order: priority verbs first, then first-seen order."""
|
||||
seen = list(tally.verbs.keys())
|
||||
ranked = [v for v in _VERB_PRIORITY if v in tally.verbs]
|
||||
ranked += [v for v in seen if v not in _VERB_PRIORITY]
|
||||
return ranked
|
||||
|
||||
|
||||
def format_turn_summary(
|
||||
elapsed_seconds: float,
|
||||
tally: TurnTally | None,
|
||||
*,
|
||||
max_segments: int = _MAX_SEGMENTS,
|
||||
) -> str:
|
||||
"""Render the per-turn accounting line, or ``""`` when there's nothing to say.
|
||||
|
||||
Pure function — no config lookups, no terminal access, no I/O. Gating
|
||||
(``display.turn_summary``, quiet mode, CLI-only) is the caller's job.
|
||||
"""
|
||||
if tally is None:
|
||||
tally = TurnTally()
|
||||
|
||||
segments: list[str] = []
|
||||
for verb in _ordered_verbs(tally):
|
||||
nouns = tally.verbs[verb]
|
||||
parts = [_pluralize(count, plural) for plural, count in nouns.items() if count]
|
||||
if not parts:
|
||||
continue
|
||||
segment = f"{verb} {', '.join(parts)}"
|
||||
if verb == _EDIT_VERB and tally.has_line_deltas:
|
||||
segment += f" +{tally.lines_added} -{tally.lines_removed}"
|
||||
segments.append(segment)
|
||||
|
||||
if tally.other_tools:
|
||||
segments.append(f"called {_pluralize(tally.other_tools, 'tools')}")
|
||||
|
||||
if not segments and tally.total_tools == 0 and elapsed_seconds < _MIN_TOOLLESS_SECONDS:
|
||||
return ""
|
||||
|
||||
if max_segments > 0 and len(segments) > max_segments:
|
||||
hidden = len(segments) - max_segments
|
||||
segments = segments[:max_segments] + [f"+{hidden} more"]
|
||||
|
||||
pieces = [format_elapsed(elapsed_seconds)] + segments
|
||||
return f"{SUMMARY_PREFIX} " + " · ".join(pieces)
|
||||
|
||||
|
||||
def format_token_flow(output_tokens: Any, *, arrow: str = "↓") -> str:
|
||||
"""Render cumulative turn tokens for the live spinner (``↓ 1.2k tok``).
|
||||
|
||||
Returns ``""`` for a non-positive count so the spinner shows nothing
|
||||
rather than a misleading ``↓ 0 tok`` before the first API response lands.
|
||||
"""
|
||||
try:
|
||||
count = int(output_tokens)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if count <= 0:
|
||||
return ""
|
||||
if count < 1000:
|
||||
return f"{arrow} {count} tok"
|
||||
if count < 1_000_000:
|
||||
return f"{arrow} {count / 1000:.1f}k tok"
|
||||
return f"{arrow} {count / 1_000_000:.1f}M tok"
|
||||
@@ -12,11 +12,11 @@
|
||||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
@@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
||||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option<String> {
|
||||
if let Some(commit) = pin
|
||||
.commit
|
||||
.as_ref()
|
||||
.filter(|commit| !commit.trim().is_empty())
|
||||
{
|
||||
return Some(commit.clone());
|
||||
}
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(install_root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if commit.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(commit)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result<serde_json::Value> {
|
||||
use std::io::Write;
|
||||
|
||||
let marker_path = crate::paths::likely_bootstrap_marker(install_root);
|
||||
if let Some(parent) = marker_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"could not create bootstrap marker directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let completed_at_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
let marker = serde_json::json!({
|
||||
"schemaVersion": 1,
|
||||
"pinnedCommit": resolve_marker_commit(install_root, pin),
|
||||
"pinnedBranch": pin.branch.clone(),
|
||||
"completedAtUnix": completed_at_unix,
|
||||
});
|
||||
let mut body = serde_json::to_vec_pretty(&marker)?;
|
||||
body.push(b'\n');
|
||||
|
||||
// Atomic publish (temp sibling + flush + rename), matching Electron's
|
||||
// writeFileAtomic(). hermes_is_installed() only checks existence, so a
|
||||
// partial direct write would incorrectly enable the launcher fast path.
|
||||
let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp");
|
||||
{
|
||||
let mut file = std::fs::File::create(&tmp_path).with_context(|| {
|
||||
format!(
|
||||
"could not create temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
file.write_all(&body).with_context(|| {
|
||||
format!(
|
||||
"could not write temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
file.sync_all().with_context(|| {
|
||||
format!(
|
||||
"could not flush temp bootstrap marker {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// Windows rename fails if the destination already exists; drop any prior
|
||||
// marker first so a re-run can still publish a fresh payload.
|
||||
if marker_path.exists() {
|
||||
std::fs::remove_file(&marker_path).with_context(|| {
|
||||
format!(
|
||||
"could not replace existing bootstrap marker {}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Err(err) = std::fs::rename(&tmp_path, &marker_path) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"could not publish bootstrap marker {} → {}",
|
||||
tmp_path.display(),
|
||||
marker_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(path = %marker_path.display(), "bootstrap marker written");
|
||||
Ok(marker)
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
@@ -644,6 +745,23 @@ async fn run_bootstrap(
|
||||
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
||||
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
||||
|
||||
// Marker publish is terminal for this run: a write failure must emit Failed
|
||||
// so the UI leaves the progress state (it does not poll get_bootstrap_status).
|
||||
let marker = match write_bootstrap_complete_marker(&install_root, &pin) {
|
||||
Ok(marker) => marker,
|
||||
Err(err) => {
|
||||
let msg = format!("write bootstrap marker failed: {err:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
};
|
||||
|
||||
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
|
||||
// re-invoke us with `--update` and shortcuts have a stable target. This is
|
||||
// a one-shot install concern; an `--update` re-invocation no-ops because
|
||||
@@ -660,10 +778,7 @@ async fn run_bootstrap(
|
||||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
marker: Some(marker),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -903,4 +1018,103 @@ mod tests {
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_complete_marker_uses_desktop_compatible_schema() {
|
||||
let root = unique_tmp_dir("marker-schema");
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
let marker =
|
||||
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
||||
let marker_path = root.join(".hermes-bootstrap-complete");
|
||||
let from_disk: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(marker, from_disk);
|
||||
assert_eq!(from_disk["schemaVersion"], 1);
|
||||
assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890");
|
||||
assert_eq!(from_disk["pinnedBranch"], "main");
|
||||
assert!(
|
||||
from_disk["completedAtUnix"].as_u64().is_some(),
|
||||
"marker must carry a completion timestamp"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_complete_marker_is_published_atomically() {
|
||||
let root = unique_tmp_dir("marker-atomic");
|
||||
make_release_tree(&root);
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
||||
|
||||
let marker_path = root.join(".hermes-bootstrap-complete");
|
||||
let tmp_path = root.join(".hermes-bootstrap-complete.tmp");
|
||||
assert!(
|
||||
marker_path.is_file(),
|
||||
"final marker must exist after atomic publish"
|
||||
);
|
||||
assert!(
|
||||
!tmp_path.exists(),
|
||||
"temp sibling must not remain after atomic publish"
|
||||
);
|
||||
assert!(
|
||||
hermes_is_installed(&root),
|
||||
"atomically published marker must enable the installer fast path"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_is_installed_treats_marker_existence_as_sufficient() {
|
||||
// Documents why write_bootstrap_complete_marker must publish atomically:
|
||||
// the launcher predicate only checks existence, so a partial/corrupt
|
||||
// final marker would still enable the fast path.
|
||||
let root = unique_tmp_dir("marker-existence-only");
|
||||
make_release_tree(&root);
|
||||
std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap();
|
||||
|
||||
assert!(
|
||||
hermes_is_installed(&root),
|
||||
"empty/partial marker content still counts as installed"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_write_failure_leaves_no_final_marker() {
|
||||
// install_root is a regular file → create_dir_all on its path fails
|
||||
// before any marker bytes are published under the final name.
|
||||
let base = unique_tmp_dir("marker-fail");
|
||||
let not_a_dir = base.join("not-a-dir");
|
||||
std::fs::write(¬_a_dir, b"not a directory").unwrap();
|
||||
let pin = Pin {
|
||||
commit: Some("abcdef1234567890".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
|
||||
let err = write_bootstrap_complete_marker(¬_a_dir, &pin)
|
||||
.expect_err("marker write against a non-directory root must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("bootstrap marker"),
|
||||
"error should mention the marker path: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!not_a_dir.join(".hermes-bootstrap-complete").exists(),
|
||||
"failed write must not leave a final marker that enables the fast path"
|
||||
);
|
||||
assert!(
|
||||
!not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(),
|
||||
"failed write must not leave a temp marker sibling either"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.ts:
|
||||
/// Where the bootstrap-complete marker lives (existence-only for the Rust
|
||||
/// installer fast path; JSON schema-checked by the Electron app). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
|
||||
|
||||
const VALID_MARKER = {
|
||||
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
|
||||
schemaVersion: 1
|
||||
}
|
||||
|
||||
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
|
||||
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
|
||||
})
|
||||
|
||||
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
|
||||
assert.equal(hasValidBootstrapMarker(null, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
|
||||
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
|
||||
hasValidMarker: true,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
})
|
||||
})
|
||||
|
||||
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
|
||||
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
|
||||
// repo+venv, no desktop-managed marker was ever written, and every launch
|
||||
// dropped the user back into the first-run installer.
|
||||
const state = classifyActiveRuntime(null, 1, true)
|
||||
|
||||
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
|
||||
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
|
||||
})
|
||||
|
||||
test('a repair that deleted the marker does not strand a healthy install', () => {
|
||||
// #72166: the repair handler clears the marker unconditionally. Runtime
|
||||
// usability, not marker presence, must decide the next boot.
|
||||
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface BootstrapMarkerLike {
|
||||
pinnedCommit?: unknown
|
||||
schemaVersion?: unknown
|
||||
}
|
||||
|
||||
export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number
|
||||
): boolean {
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== schemaVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
|
||||
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
|
||||
// was installed by the CLI first, or when a past desktop build forgot the
|
||||
// marker). Runtime usability is authoritative for "can we launch local Hermes
|
||||
// right now?"; the marker is only provenance about how that install was
|
||||
// created. A missing/stale marker must never force a healthy local install into
|
||||
// the first-run bootstrap UI.
|
||||
export function classifyActiveRuntime(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number,
|
||||
runtimeUsable: boolean
|
||||
): ActiveRuntimeState {
|
||||
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
|
||||
|
||||
if (!runtimeUsable) {
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Unit tests for the pure find-in-page helpers. The IPC handlers in
|
||||
* main.ts are the only consumer — the helpers below must keep the wire
|
||||
* shape stable (match counter shape, defaults, no-throw-on-destroyed).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
import { formatFoundInPage, installFoundInPageForwarder, performFind, stopFind } from './find-in-page'
|
||||
|
||||
// Minimal webContents stub. The Electron.WebContents type is huge, so we
|
||||
// model just the slice the helpers touch (`isDestroyed`, `findInPage`,
|
||||
// `stopFindInPage`, `on`/`off`, `send`, `destroyed`, `emit`) and cast through
|
||||
// `asWC()` at call sites.
|
||||
interface FakeWebContents {
|
||||
calls: {
|
||||
find: Array<{ query: string; options: { forward: boolean; findNext: boolean } }>
|
||||
stop: Array<'clearSelection' | 'keepSelection' | 'activateSelection'>
|
||||
send: Array<{ channel: string; payload: unknown }>
|
||||
}
|
||||
isDestroyed: () => boolean
|
||||
destroy: () => void
|
||||
findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => void
|
||||
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void
|
||||
send: (channel: string, payload: unknown) => void
|
||||
on: typeof EventEmitter.prototype.on
|
||||
off: typeof EventEmitter.prototype.off
|
||||
emit: (event: string | symbol, ...args: unknown[]) => boolean
|
||||
}
|
||||
|
||||
function makeFakeWebContents(): FakeWebContents {
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
const calls = {
|
||||
find: [] as Array<{ query: string; options: { forward: boolean; findNext: boolean } }>,
|
||||
stop: [] as Array<'clearSelection' | 'keepSelection' | 'activateSelection'>,
|
||||
send: [] as Array<{ channel: string; payload: unknown }>
|
||||
}
|
||||
|
||||
let destroyed = false
|
||||
|
||||
return {
|
||||
calls,
|
||||
isDestroyed: () => destroyed,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
emitter.emit('destroyed')
|
||||
},
|
||||
findInPage(query: string, options: { forward: boolean; findNext: boolean }) {
|
||||
calls.find.push({ query, options })
|
||||
},
|
||||
stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') {
|
||||
calls.stop.push(action)
|
||||
},
|
||||
send(channel: string, payload: unknown) {
|
||||
calls.send.push({ channel, payload })
|
||||
},
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter)
|
||||
}
|
||||
}
|
||||
|
||||
function asWC(fake: FakeWebContents): Electron.WebContents {
|
||||
return fake as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
describe('formatFoundInPage', () => {
|
||||
test('maps activeMatchOrdinal + matches onto the wire payload', () => {
|
||||
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 3, matches: 12 }), {
|
||||
activeMatchOrdinal: 3,
|
||||
count: 12
|
||||
})
|
||||
})
|
||||
|
||||
test('coerces missing fields to zero so the renderer never sees NaN', () => {
|
||||
assert.deepEqual(formatFoundInPage({}), { activeMatchOrdinal: 0, count: 0 })
|
||||
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 0, matches: 0 }), {
|
||||
activeMatchOrdinal: 0,
|
||||
count: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('null / undefined inputs still produce a well-formed payload', () => {
|
||||
assert.deepEqual(formatFoundInPage(null as unknown as { activeMatchOrdinal?: number; matches?: number }), {
|
||||
activeMatchOrdinal: 0,
|
||||
count: 0
|
||||
})
|
||||
assert.deepEqual(formatFoundInPage(undefined), { activeMatchOrdinal: 0, count: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('performFind', () => {
|
||||
test('forwards the query and options to webContents.findInPage', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'hello', { forward: true, findNext: false })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'hello', options: { forward: true, findNext: false } }])
|
||||
})
|
||||
|
||||
test('defaults forward to true when omitted', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', { findNext: true })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: true } }])
|
||||
})
|
||||
|
||||
test('defaults findNext to false when omitted', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', { forward: false })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: false, findNext: false } }])
|
||||
})
|
||||
|
||||
test('treats null / non-object options as "all defaults"', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', null)
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: false } }])
|
||||
})
|
||||
|
||||
test('coerces a non-string query to string (defensive against bad renderer payloads)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 42 as unknown as string, null)
|
||||
assert.equal(wc.calls.find[0].query, '42')
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is null', () => {
|
||||
assert.doesNotThrow(() => performFind(null, 'q', null))
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is destroyed (does not throw across IPC)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
performFind(asWC(wc), 'q', null)
|
||||
assert.equal(wc.calls.find.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopFind', () => {
|
||||
test('calls stopFindInPage with the default action (clearSelection)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
stopFind(asWC(wc))
|
||||
assert.deepEqual(wc.calls.stop, ['clearSelection'])
|
||||
})
|
||||
|
||||
test('honors an explicit action argument', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
stopFind(asWC(wc), 'keepSelection')
|
||||
assert.deepEqual(wc.calls.stop, ['keepSelection'])
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is null or destroyed', () => {
|
||||
assert.doesNotThrow(() => stopFind(null))
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
stopFind(asWC(wc))
|
||||
assert.equal(wc.calls.stop.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installFoundInPageForwarder', () => {
|
||||
test('forwards found-in-page to the sender as a formatted payload', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
// Drive the fake's emit directly — this exercises the same code path
|
||||
// as Electron's actual `webContents.emit('found-in-page', …)`.
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 2, matches: 5 })
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 2, count: 5 } }])
|
||||
})
|
||||
|
||||
test('handles missing fields without throwing', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
wc.emit('found-in-page', {}, {})
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 0, count: 0 } }])
|
||||
})
|
||||
|
||||
test('skips send when webContents is destroyed at fire time', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
wc.destroy()
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
|
||||
assert.equal(wc.calls.send.length, 0, 'destroyed webContents must not be sent to')
|
||||
})
|
||||
|
||||
test('returned uninstall removes the listener', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const uninstall = installFoundInPageForwarder(asWC(wc))
|
||||
uninstall()
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 9, matches: 9 })
|
||||
assert.equal(wc.calls.send.length, 0, 'uninstalled listener must not fire')
|
||||
})
|
||||
|
||||
test('returned uninstall on a null webContents is a safe no-op', () => {
|
||||
const uninstall = installFoundInPageForwarder(null)
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
|
||||
test('returned uninstall on a destroyed webContents is a safe no-op', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
const uninstall = installFoundInPageForwarder(asWC(wc))
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
|
||||
// Regression: the original PR scoped the forwarder to the global mainWindow,
|
||||
// so Cmd+F pressed in a secondary session window routed results back to the
|
||||
// primary. Pin that the helper does NOT close over any window other than the
|
||||
// webContents it was given — two forwarders installed on two distinct fakes
|
||||
// must each send only to their own sender.
|
||||
test('two forwarders installed on distinct webContents do not cross-fire', () => {
|
||||
const wcA = makeFakeWebContents()
|
||||
const wcB = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wcA))
|
||||
installFoundInPageForwarder(asWC(wcB))
|
||||
wcA.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
|
||||
assert.deepEqual(wcA.calls.send, [
|
||||
{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 1, count: 1 } }
|
||||
])
|
||||
assert.equal(wcB.calls.send.length, 0, 'wcB must not receive wcA results')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Pure helpers for the desktop find-in-page bridge (Ctrl/Cmd+F).
|
||||
*
|
||||
* The renderer drives an Electron `webContents.findInPage` over IPC so it can
|
||||
* reuse the native "find-in-page" experience (incremental search, match
|
||||
* highlight, Enter to step, Shift+Enter to step backwards, Escape to clear)
|
||||
* across chat transcripts and editor panels. Everything in this module is
|
||||
* pure with respect to its inputs so the routing + payload shaping can be
|
||||
* unit-tested without booting a BrowserWindow.
|
||||
*
|
||||
* Multi-window correctness: the IPC handlers in main.ts resolve the
|
||||
* requesting window via `BrowserWindow.fromWebContents(event.sender)` so a
|
||||
* Cmd+F pressed in a secondary session window searches THAT window, not the
|
||||
* primary. The `found-in-page` results are forwarded back to the same sender
|
||||
* — see {@link installFoundInPageForwarder}.
|
||||
*/
|
||||
|
||||
/** Match options accepted by the renderer's `findInPage` bridge call. */
|
||||
export interface FindInPageOptions {
|
||||
/** Step direction. Defaults to `true` (forward). */
|
||||
forward?: boolean
|
||||
/**
|
||||
* `true` to advance to the next/previous match using the previous query;
|
||||
* `false` to (re)search the current `query` from scratch. The renderer
|
||||
* passes `false` on a fresh query and `true` on Enter / Shift+Enter.
|
||||
*/
|
||||
findNext?: boolean
|
||||
}
|
||||
|
||||
/** Payload shape sent back to the renderer on every `found-in-page` event. */
|
||||
export interface FoundInPagePayload {
|
||||
/** 1-indexed ordinal of the active match, or 0 when none. */
|
||||
activeMatchOrdinal: number
|
||||
/** Total matches in the document for the current query. */
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive projection of Electron's `found-in-page` event result. Electron
|
||||
* exposes more fields (finalUpdate, selectionArea, etc.) that we don't need;
|
||||
* keeping the projection explicit makes the wire shape auditable and keeps
|
||||
* tests independent of the runtime type.
|
||||
*/
|
||||
export function formatFoundInPage(result: { activeMatchOrdinal?: number; matches?: number }): FoundInPagePayload {
|
||||
return {
|
||||
activeMatchOrdinal: Number(result?.activeMatchOrdinal ?? 0),
|
||||
count: Number(result?.matches ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a `findInPage` against the given `webContents`. No-op when the
|
||||
* webContents is missing or destroyed — surfaces as a silent miss rather
|
||||
* than throwing across the IPC boundary, matching Electron's own semantics
|
||||
* for a destroyed renderer.
|
||||
*/
|
||||
export function performFind(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
query: string,
|
||||
options: FindInPageOptions | null | undefined
|
||||
): void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const opts = options && typeof options === 'object' ? options : {}
|
||||
|
||||
webContents.findInPage(String(query ?? ''), {
|
||||
forward: opts.forward !== false,
|
||||
findNext: Boolean(opts.findNext)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current find and clear highlights. The default `action` matches
|
||||
* what the renderer sends on Escape / close.
|
||||
*/
|
||||
export function stopFind(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
action: 'clearSelection' | 'keepSelection' | 'activateSelection' = 'clearSelection'
|
||||
): void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.stopFindInPage(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a `found-in-page` listener on the given sender `webContents` and
|
||||
* forward each result back to the SAME renderer (via `webContents.send`).
|
||||
*
|
||||
* Returns an uninstall function. Call it from `webContents.on('destroyed', …)`
|
||||
* to avoid leaking the listener when the window goes away — Electron does
|
||||
* not auto-detach webContents listeners on close.
|
||||
*
|
||||
* The forwarder is intentionally bound to a single sender rather than the
|
||||
* primary window: a Cmd+F pressed in a secondary session window must
|
||||
* highlight matches in THAT window, and the match counter must reflect
|
||||
* THAT window's DOM, not the primary's.
|
||||
*/
|
||||
export function installFoundInPageForwarder(webContents: Electron.WebContents | null | undefined): () => void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const handler = (_event: Electron.Event, result: Parameters<typeof formatFoundInPage>[0]) => {
|
||||
if (webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:found-in-page', formatFoundInPage(result))
|
||||
}
|
||||
|
||||
webContents.on('found-in-page', handler)
|
||||
|
||||
return () => {
|
||||
webContents.off('found-in-page', handler)
|
||||
}
|
||||
}
|
||||
+411
-56
@@ -14,6 +14,7 @@ import {
|
||||
clipboard,
|
||||
dialog,
|
||||
net as electronNet,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from 'electron'
|
||||
import nodePty from 'node-pty'
|
||||
|
||||
import { classifyActiveRuntime } from './active-runtime-state'
|
||||
import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
@@ -80,6 +82,7 @@ import {
|
||||
import { installEmbedReferer } from './embed-referer'
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
import { findGitBash as _findGitBash } from './find-git-bash'
|
||||
import { installFoundInPageForwarder, performFind, stopFind } from './find-in-page'
|
||||
import { createFirstRunSetupGate } from './first-run-setup-gate'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
@@ -139,6 +142,7 @@ import { createKeepAwake } from './power-save'
|
||||
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
|
||||
import { rehomePrimaryConnection } from './primary-connection-rehome'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry'
|
||||
import * as remoteLifecycle from './remote-lifecycle'
|
||||
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
|
||||
import {
|
||||
@@ -373,9 +377,9 @@ if (IS_WINDOWS) {
|
||||
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
|
||||
|
||||
// Keep the renderer running at full speed while the window is in the background
|
||||
// or occluded. The chat transcript streams to screen through a
|
||||
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
|
||||
// for backgrounded/occluded renderers, so without these the live answer stalls
|
||||
// or occluded. The chat transcript streams to screen through a bounded timer
|
||||
// flush; Chromium clamps timers for backgrounded/occluded renderers, so without
|
||||
// these the live answer stalls
|
||||
// whenever the window loses focus (switching to your editor mid-turn, detached
|
||||
// devtools, another window covering it) and only paints on refocus or refresh.
|
||||
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
|
||||
@@ -1032,6 +1036,12 @@ let remoteReauthFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
// Explicit "the user asked for a repair" flag. Repair used to signal intent by
|
||||
// deleting the bootstrap marker, which stranded healthy installs whose only
|
||||
// problem was a transient backend error (#72166). Intent now lives here, so
|
||||
// repair can force the installer without destroying provenance about how the
|
||||
// install was created. Cleared once the reinstall is under way.
|
||||
let bootstrapRepairRequested = false
|
||||
let connectionConfigCache = null
|
||||
let connectionConfigCacheMtime = null
|
||||
const hermesLog = []
|
||||
@@ -3383,11 +3393,11 @@ function readJson(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap-complete marker helpers. The marker is written ONCE by the
|
||||
// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed
|
||||
// AND the user has finished initial configuration. On every subsequent boot
|
||||
// we check `isBootstrapComplete()` and skip the bootstrap flow entirely if
|
||||
// the marker is present and current-schema.
|
||||
// Bootstrap-complete marker helpers. The marker is written by whichever
|
||||
// installer ran: install.ps1, install.sh, the Rust bootstrap installer, or the
|
||||
// first-launch bootstrap runner. It is provenance ("a bootstrap finished
|
||||
// here"), NOT the launch gate -- activeRuntimeState() decides that, because a
|
||||
// healthy runtime can predate the marker or outlive a repair that cleared it.
|
||||
//
|
||||
// Marker schema (version 1):
|
||||
// {
|
||||
@@ -3420,29 +3430,13 @@ function isActiveRuntimeUsable() {
|
||||
)
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
const marker = readBootstrapMarker()
|
||||
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
function activeRuntimeState() {
|
||||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||||
// pinned commit -- users update via the in-app update path or `hermes
|
||||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||||
// ran the bootstrap successfully at least once." We DO additionally require
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isActiveRuntimeUsable()
|
||||
// update`, which moves HEAD legitimately. The marker only attests "a
|
||||
// desktop-managed bootstrap ran here at least once"; runtime usability is
|
||||
// what decides whether we can actually launch.
|
||||
return classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable())
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
@@ -3702,16 +3696,30 @@ function resolveHermesBackend(backendArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at
|
||||
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
|
||||
// The bootstrap marker means install.ps1 stages finished and the user
|
||||
// completed initial configuration; we trust the install and go straight
|
||||
// to spawning hermes. Updates flow through the in-app update path
|
||||
// (applyUpdates -> git pull) or `hermes update` from the CLI.
|
||||
if (isBootstrapComplete()) {
|
||||
// 3. ACTIVE_HERMES_ROOT — the canonical install at
|
||||
// %LOCALAPPDATA%\\hermes\\hermes-agent (Windows) or ~/.hermes/hermes-agent.
|
||||
// A valid bootstrap marker proves Desktop finished the first-run install
|
||||
// flow, but marker provenance is NOT the same thing as runtime usability:
|
||||
// the CLI can create the exact same repo+venv layout, and older desktop
|
||||
// builds could leave a healthy install behind without the marker. If the
|
||||
// active runtime is usable, launch it directly; only fall through to
|
||||
// bootstrap when the runtime itself is unusable.
|
||||
const activeRuntime = activeRuntimeState()
|
||||
|
||||
if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) {
|
||||
if (!activeRuntime.hasValidMarker) {
|
||||
rememberLog(
|
||||
`[bootstrap] Active Hermes runtime at ${ACTIVE_HERMES_ROOT} is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.`
|
||||
)
|
||||
}
|
||||
|
||||
return createActiveBackend(backendArgs)
|
||||
}
|
||||
|
||||
if (bootstrapRepairRequested) {
|
||||
rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer')
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
// a previous tool-only setup, or pip-installed system-wide. Use it but
|
||||
// do NOT write a bootstrap marker; the user did this themselves and we
|
||||
@@ -3885,6 +3893,10 @@ async function ensureRuntime(backend) {
|
||||
|
||||
bootstrapAbortController = new AbortController()
|
||||
|
||||
// The repair request has been honoured by reaching the installer; clear it
|
||||
// so a later boot isn't forced through bootstrap again.
|
||||
bootstrapRepairRequested = false
|
||||
|
||||
const bootstrapResult = await runBootstrap({
|
||||
installStamp: backend.installStamp,
|
||||
activeRoot: backend.activeRoot,
|
||||
@@ -3979,10 +3991,10 @@ async function ensureRuntime(backend) {
|
||||
// No venv at the expected location AND no bootstrap-needed sentinel
|
||||
// means we have a half-installed checkout: .git exists, source files
|
||||
// exist, but venv is missing or broken. This shouldn't happen in
|
||||
// normal flow because isBootstrapComplete() requires
|
||||
// isHermesSourceRoot() and the bootstrap writes the marker only after
|
||||
// install.ps1 succeeds. If we hit this, the user (or a deleted venv)
|
||||
// broke the invariant; tell them to re-run the install.
|
||||
// normal flow because activeRuntimeState() requires isHermesSourceRoot()
|
||||
// plus an importable hermes_cli before it hands back the active runtime.
|
||||
// If we hit this, the user (or a deleted venv) broke the invariant; tell
|
||||
// them to re-run the install.
|
||||
throw new Error(
|
||||
`Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.'
|
||||
)
|
||||
@@ -4927,6 +4939,8 @@ function getNativeOverlayWidth() {
|
||||
function getWindowState(win = mainWindow) {
|
||||
return {
|
||||
isFullscreen: Boolean(win?.isFullScreen?.()),
|
||||
isMinimized: Boolean(win?.isMinimized?.()),
|
||||
isVisible: Boolean(win?.isVisible?.()),
|
||||
nativeOverlayWidth: getNativeOverlayWidth(),
|
||||
windowButtonPosition: getWindowButtonPosition()
|
||||
}
|
||||
@@ -8655,6 +8669,211 @@ function closePetOverlay() {
|
||||
petOverlayWindow = null
|
||||
}
|
||||
|
||||
// ── Quick Entry ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A global shortcut summons a small frameless always-on-top composer from
|
||||
// anywhere, so a prompt can be fired without raising the whole app. The window
|
||||
// carries NO gateway connection: it hands its text to us, we forward it to the
|
||||
// PRIMARY renderer, and that renderer submits through the same prompt path the
|
||||
// normal composer uses (see store/quick-entry + hooks/use-quick-entry-bridge).
|
||||
//
|
||||
// Main owns the OS registration and the persisted preference (it must restore
|
||||
// the shortcut on a cold launch without the renderer ever visiting Settings),
|
||||
// same authority split as keep-awake. Registration failure is surfaced, never
|
||||
// swallowed: a chord another app already owns comes back as `error: 'taken'`.
|
||||
const QUICK_ENTRY_CONFIG_PATH = path.join(app.getPath('userData'), 'quick-entry.json')
|
||||
|
||||
let quickEntryWindow = null
|
||||
|
||||
// Latest state push from the primary renderer (connection + recent sessions),
|
||||
// replayed to a quick window that spawns after the push happened.
|
||||
let quickEntryLastState = null
|
||||
|
||||
function readQuickEntrySettings() {
|
||||
try {
|
||||
return sanitizeQuickEntrySettings(JSON.parse(fs.readFileSync(QUICK_ENTRY_CONFIG_PATH, 'utf8')))
|
||||
} catch {
|
||||
// Missing / unreadable / malformed → shipped defaults (enabled, default chord).
|
||||
return sanitizeQuickEntrySettings(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function writeQuickEntrySettings(settings) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(QUICK_ENTRY_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(QUICK_ENTRY_CONFIG_PATH, JSON.stringify(settings, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[quick-entry] write failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function quickEntryUrl() {
|
||||
if (DEV_SERVER) {
|
||||
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=quick#/`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(resolveRendererIndex()).toString()}?win=quick#/`
|
||||
}
|
||||
|
||||
function spawnQuickEntryWindow() {
|
||||
const cursor = screen.getCursorScreenPoint()
|
||||
const display = screen.getDisplayNearestPoint(cursor)
|
||||
const bounds = quickEntryWindowBounds(display?.workArea)
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...bounds,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
// Same rationale as the pet overlay: on Windows/Linux keep the helper out
|
||||
// of the taskbar/alt-tab list; on macOS use an NSPanel so the frameless
|
||||
// capture window never becomes the app's cmd-tab anchor.
|
||||
skipTaskbar: !IS_MAC,
|
||||
hasShadow: true,
|
||||
alwaysOnTop: true,
|
||||
type: IS_MAC ? 'panel' : undefined,
|
||||
hiddenInMissionControl: IS_MAC,
|
||||
show: false,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: PRELOAD_PATH,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
|
||||
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
|
||||
win.setHiddenInMissionControl?.(true)
|
||||
|
||||
try {
|
||||
win.setVisibleOnAllWorkspaces(
|
||||
true,
|
||||
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
|
||||
)
|
||||
} catch {
|
||||
// Not supported everywhere — best effort.
|
||||
}
|
||||
|
||||
// Opts out of global UI zoom for the same reason as the pet overlay: it sizes
|
||||
// its own OS window and a zoomed composer would overflow it.
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('quickEntry'))
|
||||
|
||||
// Hide on blur. The window must never hold the user's focus captive — losing
|
||||
// focus is the cheapest, least surprising dismiss (matches Spotlight).
|
||||
win.on('blur', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.hide()
|
||||
}
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (quickEntryWindow === win) {
|
||||
quickEntryWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
// Replay the last known gateway state as soon as the page can hear it — a
|
||||
// freshly spawned quick window must not sit "disconnected" when the primary
|
||||
// renderer already reported a live gateway.
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
if (!win.isDestroyed() && quickEntryLastState) {
|
||||
win.webContents.send('hermes:quick-entry:state', quickEntryLastState)
|
||||
}
|
||||
})
|
||||
|
||||
win.loadURL(quickEntryUrl())
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// Move the (already-open) window to the display the cursor is on, so the chord
|
||||
// summons it where the user is looking rather than where they last were.
|
||||
function repositionQuickEntryWindow(win) {
|
||||
try {
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
win.setBounds(quickEntryWindowBounds(display?.workArea))
|
||||
} catch (error) {
|
||||
rememberLog(`[quick-entry] reposition failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function showQuickEntryWindow() {
|
||||
if (!quickEntryWindow || quickEntryWindow.isDestroyed()) {
|
||||
quickEntryWindow = spawnQuickEntryWindow()
|
||||
quickEntryWindow.once('ready-to-show', () => {
|
||||
if (!quickEntryWindow?.isDestroyed()) {
|
||||
quickEntryWindow.show()
|
||||
quickEntryWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
repositionQuickEntryWindow(quickEntryWindow)
|
||||
quickEntryWindow.show()
|
||||
quickEntryWindow.focus()
|
||||
// Re-summoned: tell the renderer to clear any stale draft and refocus.
|
||||
quickEntryWindow.webContents.send('hermes:quick-entry:shown')
|
||||
}
|
||||
|
||||
function hideQuickEntryWindow() {
|
||||
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
|
||||
quickEntryWindow.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// The chord toggles: pressing it while the composer is up puts it away, so one
|
||||
// gesture does exactly one thing in both directions.
|
||||
function toggleQuickEntryWindow() {
|
||||
if (quickEntryWindow && !quickEntryWindow.isDestroyed() && quickEntryWindow.isVisible()) {
|
||||
hideQuickEntryWindow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
showQuickEntryWindow()
|
||||
}
|
||||
|
||||
const quickEntryShortcut = createQuickEntryShortcut(globalShortcut, toggleQuickEntryWindow)
|
||||
|
||||
function applyQuickEntrySettings(settings) {
|
||||
const state = quickEntryShortcut.apply(settings)
|
||||
|
||||
if (!settings.enabled) {
|
||||
// Turning the feature off must not leave an orphan always-on-top window.
|
||||
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
|
||||
quickEntryWindow.close()
|
||||
}
|
||||
|
||||
quickEntryWindow = null
|
||||
}
|
||||
|
||||
if (state.error === 'taken') {
|
||||
rememberLog(`[quick-entry] shortcut ${state.shortcut} is already taken by another application`)
|
||||
} else if (state.error === 'invalid') {
|
||||
rememberLog(`[quick-entry] shortcut ${state.shortcut} is not a valid accelerator`)
|
||||
}
|
||||
|
||||
return { ...state, enabled: settings.enabled }
|
||||
}
|
||||
|
||||
function closeQuickEntryWindow() {
|
||||
quickEntryShortcut.dispose()
|
||||
|
||||
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
|
||||
quickEntryWindow.close()
|
||||
}
|
||||
|
||||
quickEntryWindow = null
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const icon = getAppIconPath()
|
||||
const savedWindowState = readWindowState()
|
||||
@@ -8681,9 +8900,9 @@ function createWindow() {
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
// Shared with the secondary session windows (chatWindowWebPreferences) so
|
||||
// both keep `backgroundThrottling: false` — the chat transcript streams via
|
||||
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
|
||||
// windows, stalling the live answer until refocus. See session-windows.ts.
|
||||
// both keep `backgroundThrottling: false` — the chat transcript uses a
|
||||
// bounded timer flush that Chromium clamps for blurred windows, stalling
|
||||
// the live answer until refocus. See session-windows.ts.
|
||||
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
|
||||
})
|
||||
|
||||
@@ -8751,6 +8970,10 @@ function createWindow() {
|
||||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
mainWindow.on('minimize', () => sendWindowStateChanged())
|
||||
mainWindow.on('restore', () => sendWindowStateChanged())
|
||||
mainWindow.on('hide', () => sendWindowStateChanged())
|
||||
mainWindow.on('show', () => sendWindowStateChanged())
|
||||
|
||||
// Reopen where the user left off. resized/moved settle once per drag; close is
|
||||
// the cross-platform backstop, flushed synchronously before the window is gone.
|
||||
@@ -9106,20 +9329,17 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
// Forceful repair: drop the bootstrap-complete marker so the next
|
||||
// startHermes() re-runs the full installer (refreshing a broken/partial
|
||||
// venv), and clear any latched failure + live connection. The renderer
|
||||
// reloads afterwards to re-drive the boot flow from scratch.
|
||||
rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure')
|
||||
|
||||
try {
|
||||
if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) {
|
||||
fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
// Forceful repair: force the next startHermes() through the full installer
|
||||
// (refreshing a broken/partial venv) and clear any latched failure + live
|
||||
// connection. The renderer reloads afterwards to re-drive the boot flow.
|
||||
//
|
||||
// We do NOT delete the bootstrap marker here. Repair is also reachable from
|
||||
// transient backend errors on a perfectly healthy install, and deleting the
|
||||
// marker in that case stranded the app in first-run setup with no way back
|
||||
// (#72166). The explicit flag carries the intent instead.
|
||||
rememberLog('[bootstrap] repair requested by renderer; forcing reinstall + clearing latched failure')
|
||||
|
||||
bootstrapRepairRequested = true
|
||||
bootstrapFailure = null
|
||||
backendStartFailure = null
|
||||
remoteReauthFailure = null
|
||||
@@ -9947,12 +10167,139 @@ ipcMain.on('hermes:keep-awake', (_event, on) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Quick Entry: the renderer reads the live registration state on settings mount
|
||||
// and writes the preference back. Main is authoritative — it owns the OS
|
||||
// accelerator — so both handlers return the state that ACTUALLY resulted,
|
||||
// including `registered: false` + `error: 'taken'` when another app owns the
|
||||
// chord. See electron/quick-entry.ts + store/quick-entry.
|
||||
ipcMain.handle('hermes:quick-entry:settings:get', async () => {
|
||||
const settings = readQuickEntrySettings()
|
||||
const state = quickEntryShortcut.current()
|
||||
|
||||
// Ground truth is what the last apply produced; the shortcut we report is the
|
||||
// live one (a saved-but-rejected chord still shows what the user asked for).
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
error: state.error,
|
||||
registered: state.registered,
|
||||
shortcut: settings.enabled ? state.shortcut : settings.shortcut
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
|
||||
const current = readQuickEntrySettings()
|
||||
|
||||
const next = sanitizeQuickEntrySettings({
|
||||
enabled: patch?.enabled === undefined ? current.enabled : patch.enabled === true,
|
||||
shortcut: typeof patch?.shortcut === 'string' && patch.shortcut.trim() ? patch.shortcut : current.shortcut
|
||||
})
|
||||
|
||||
writeQuickEntrySettings(next)
|
||||
|
||||
return applyQuickEntrySettings(next)
|
||||
})
|
||||
|
||||
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
|
||||
// owns the one prompt-submit path, and forwarding keeps it that way. The
|
||||
// payload is `{ target, text }` — target routing (current chat / a picked
|
||||
// session / new) is the renderer's job too.
|
||||
ipcMain.on('hermes:quick-entry:submit', (_event, payload) => {
|
||||
hideQuickEntryWindow()
|
||||
|
||||
const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
rememberLog('[quick-entry] dropped a submit: no primary window to route it to')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Deliberately does NOT raise/focus the main window — the user asked to fire
|
||||
// a prompt from wherever they were, not to be yanked into the app.
|
||||
mainWindow.webContents.send('hermes:quick-entry:submit', {
|
||||
target: typeof payload?.target === 'string' && payload.target ? payload.target : 'current',
|
||||
text
|
||||
})
|
||||
})
|
||||
|
||||
// Primary renderer → main → quick window: gateway connection state + the
|
||||
// recent-session list for the target picker. Cached so a quick window spawned
|
||||
// AFTER the last push still boots from truth instead of "disconnected".
|
||||
ipcMain.on('hermes:quick-entry:state', (_event, payload) => {
|
||||
quickEntryLastState = payload ?? null
|
||||
|
||||
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
|
||||
quickEntryWindow.webContents.send('hermes:quick-entry:state', payload)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:quick-entry:dismiss', () => hideQuickEntryWindow())
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
}
|
||||
})
|
||||
|
||||
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||
// Find must run against the requesting window, not a global — otherwise
|
||||
// Cmd+F pressed in a secondary session window would search the primary
|
||||
// and the match counter would report matches the user can't see. Resolve
|
||||
// the sender through `BrowserWindow.fromWebContents(event.sender)` and
|
||||
// forward `found-in-page` results back to that same sender.
|
||||
|
||||
// Lazily-installed forwarder per sender webContents. We track one
|
||||
// uninstall fn per webContents id and prune entries when the sender goes
|
||||
// away — Electron does not auto-detach webContents listeners on close,
|
||||
// so the map is the cleanup path.
|
||||
const foundInPageForwarders = new Map<number, () => void>()
|
||||
|
||||
function ensureFoundInPageForwarder(sender: Electron.WebContents): void {
|
||||
if (foundInPageForwarders.has(sender.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const uninstall = installFoundInPageForwarder(sender)
|
||||
foundInPageForwarders.set(sender.id, uninstall)
|
||||
|
||||
sender.once('destroyed', () => {
|
||||
foundInPageForwarders.get(sender.id)?.()
|
||||
foundInPageForwarders.delete(sender.id)
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:find-in-page', (event, query, options) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
if (!win || win.isDestroyed()) {
|
||||
return { count: 0 }
|
||||
}
|
||||
|
||||
ensureFoundInPageForwarder(event.sender)
|
||||
performFind(win.webContents, query, options)
|
||||
|
||||
// The match count arrives asynchronously via `found-in-page`; the
|
||||
// synchronous return value is intentionally `{ count: 0 }` to mirror
|
||||
// Electron's own `findInPage` return semantics (an opaque request id).
|
||||
return { count: 0 }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:stop-find-in-page', event => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
if (!win || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
stopFind(win.webContents)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => {
|
||||
if (!(await openPreviewInBrowser(url))) {
|
||||
throw new Error('Invalid preview URL')
|
||||
@@ -10963,6 +11310,10 @@ app.whenReady().then(() => {
|
||||
configureSpellChecker()
|
||||
registerPowerResumeListeners()
|
||||
keepAwake.set(readPersistedKeepAwake())
|
||||
// Quick Entry's global chord — registered on ready so a cold launch restores
|
||||
// it without the renderer visiting Settings. A failed registration is logged
|
||||
// here and surfaced in Settings via the IPC state (never silent).
|
||||
applyQuickEntrySettings(readQuickEntrySettings())
|
||||
createWindow()
|
||||
|
||||
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
||||
@@ -11041,6 +11392,10 @@ app.on('before-quit', event => {
|
||||
// pet can't keep the process alive or float over a quit app.
|
||||
closePetOverlay()
|
||||
|
||||
// Same for the Quick Entry composer — and release its global accelerator so a
|
||||
// quitting Hermes never keeps another app's chord hostage.
|
||||
closeQuickEntryWindow()
|
||||
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
|
||||
@@ -36,6 +36,41 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
|
||||
// shortcut + the persisted preference; the quick window only captures text
|
||||
// and hands it back, and the primary renderer submits it through the normal
|
||||
// prompt path.
|
||||
quickEntry: {
|
||||
getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'),
|
||||
setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch),
|
||||
submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload),
|
||||
dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'),
|
||||
// Primary renderer → main → quick window: gateway connection state + the
|
||||
// recent-session options the target picker offers. Main caches the latest
|
||||
// payload so a freshly spawned quick window starts from truth.
|
||||
pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload),
|
||||
// Quick window subscribes to those pushes.
|
||||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:quick-entry:state', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener)
|
||||
},
|
||||
// Main → primary renderer: a submit captured by the quick window.
|
||||
onSubmit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:quick-entry:submit', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener)
|
||||
},
|
||||
// Main → quick window: you were just summoned (reset draft + refocus).
|
||||
onShown: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:quick-entry:shown', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener)
|
||||
}
|
||||
},
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
@@ -268,5 +303,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
themes: {
|
||||
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
|
||||
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
|
||||
},
|
||||
// Find-in-page (Ctrl/Cmd+F): delegates to Electron's
|
||||
// webContents.findInPage on the IPC sender's window so a Cmd+F pressed
|
||||
// in a secondary session window searches THAT window, not the primary.
|
||||
// `onFoundInPage` returns the unsubscribe fn; the renderer wires it via
|
||||
// `initFindInPageListener` in store/find-in-page.ts and tears it down
|
||||
// when the FindBar unmounts.
|
||||
findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options),
|
||||
stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'),
|
||||
onFoundInPage: callback => {
|
||||
const listener = (_event, result) => callback(result)
|
||||
ipcRenderer.on('hermes:found-in-page', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:found-in-page', listener)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createQuickEntryShortcut,
|
||||
DEFAULT_QUICK_ENTRY_SHORTCUT,
|
||||
type GlobalShortcutLike,
|
||||
parseQuickEntryShortcut,
|
||||
quickEntryWindowBounds,
|
||||
sanitizeQuickEntrySettings
|
||||
} from './quick-entry'
|
||||
|
||||
function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) {
|
||||
const held = new Set(options.taken ?? [])
|
||||
|
||||
const globalShortcut: GlobalShortcutLike = {
|
||||
isRegistered: vi.fn((accelerator: string) => held.has(accelerator)),
|
||||
register: vi.fn((accelerator: string) => {
|
||||
if (options.register === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
held.add(accelerator)
|
||||
|
||||
return true
|
||||
}),
|
||||
unregister: vi.fn((accelerator: string) => void held.delete(accelerator))
|
||||
}
|
||||
|
||||
return { globalShortcut, held }
|
||||
}
|
||||
|
||||
describe('parseQuickEntryShortcut', () => {
|
||||
it('normalizes casing, aliases, and modifier order', () => {
|
||||
expect(parseQuickEntryShortcut('cmdorctrl+shift+space')).toEqual({
|
||||
accelerator: 'CommandOrControl+Shift+Space',
|
||||
ok: true
|
||||
})
|
||||
expect(parseQuickEntryShortcut(' Shift + CTRL + k ')).toEqual({ accelerator: 'Control+Shift+K', ok: true })
|
||||
expect(parseQuickEntryShortcut('Alt+f5')).toEqual({ accelerator: 'Alt+F5', ok: true })
|
||||
expect(parseQuickEntryShortcut('Meta+/')).toEqual({ accelerator: 'Super+/', ok: true })
|
||||
})
|
||||
|
||||
it('collapses duplicate modifiers', () => {
|
||||
expect(parseQuickEntryShortcut('Ctrl+Control+Shift+J')).toEqual({ accelerator: 'Control+Shift+J', ok: true })
|
||||
})
|
||||
|
||||
it('requires a modifier so a global bind cannot swallow a bare key', () => {
|
||||
expect(parseQuickEntryShortcut('K')).toEqual({ ok: false, reason: 'no-modifier' })
|
||||
expect(parseQuickEntryShortcut('Space')).toEqual({ ok: false, reason: 'no-modifier' })
|
||||
})
|
||||
|
||||
it('requires exactly one non-modifier key', () => {
|
||||
expect(parseQuickEntryShortcut('Shift+Control')).toEqual({ ok: false, reason: 'no-key' })
|
||||
expect(parseQuickEntryShortcut('Shift+A+B')).toEqual({ ok: false, reason: 'invalid-key' })
|
||||
expect(parseQuickEntryShortcut('A+Shift')).toEqual({ ok: false, reason: 'invalid-modifier' })
|
||||
})
|
||||
|
||||
it('rejects empty, junk, and the reserved Escape key', () => {
|
||||
expect(parseQuickEntryShortcut('')).toEqual({ ok: false, reason: 'empty' })
|
||||
expect(parseQuickEntryShortcut(' ')).toEqual({ ok: false, reason: 'empty' })
|
||||
expect(parseQuickEntryShortcut(null)).toEqual({ ok: false, reason: 'empty' })
|
||||
expect(parseQuickEntryShortcut('Ctrl+NotAKey')).toEqual({ ok: false, reason: 'invalid-key' })
|
||||
// Escape hides the window; binding it globally would make it un-toggleable.
|
||||
expect(parseQuickEntryShortcut('Ctrl+Escape')).toEqual({ ok: false, reason: 'reserved' })
|
||||
})
|
||||
|
||||
it('accepts the shipped default unchanged', () => {
|
||||
expect(parseQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)).toEqual({
|
||||
accelerator: DEFAULT_QUICK_ENTRY_SHORTCUT,
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeQuickEntrySettings', () => {
|
||||
it('defaults to enabled with the default shortcut', () => {
|
||||
expect(sanitizeQuickEntrySettings(undefined)).toEqual({ enabled: true, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
|
||||
expect(sanitizeQuickEntrySettings('not an object')).toEqual({
|
||||
enabled: true,
|
||||
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit disable and normalizes a stored shortcut', () => {
|
||||
expect(sanitizeQuickEntrySettings({ enabled: false, shortcut: 'alt+j' })).toEqual({
|
||||
enabled: false,
|
||||
shortcut: 'Alt+J'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the default when the stored shortcut is unusable', () => {
|
||||
expect(sanitizeQuickEntrySettings({ enabled: true, shortcut: 'Q' })).toEqual({
|
||||
enabled: true,
|
||||
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a non-boolean enabled as off (only `true` opts in once present)', () => {
|
||||
expect(sanitizeQuickEntrySettings({ enabled: 'yes' }).enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createQuickEntryShortcut', () => {
|
||||
it('registers the normalized accelerator when enabled', () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut()
|
||||
const onTrigger = vi.fn()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, onTrigger)
|
||||
|
||||
const state = controller.apply({ enabled: true, shortcut: 'cmdorctrl+shift+space' })
|
||||
|
||||
expect(state).toEqual({ error: null, registered: true, shortcut: 'CommandOrControl+Shift+Space' })
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('CommandOrControl+Shift+Space', onTrigger)
|
||||
expect(controller.current()).toEqual(state)
|
||||
})
|
||||
|
||||
it('never registers while the setting is disabled', () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
const state = controller.apply({ enabled: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
|
||||
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
expect(state).toEqual({ error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
|
||||
})
|
||||
|
||||
it('releases the old accelerator before registering a new one', () => {
|
||||
const { globalShortcut, held } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+J' })
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+K' })
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
|
||||
expect(held.has('Alt+J')).toBe(false)
|
||||
expect(held.has('Alt+K')).toBe(true)
|
||||
})
|
||||
|
||||
it('turning the feature off releases the live accelerator', () => {
|
||||
const { globalShortcut, held } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+J' })
|
||||
const off = controller.apply({ enabled: false, shortcut: 'Alt+J' })
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
|
||||
expect(held.size).toBe(0)
|
||||
expect(off.registered).toBe(false)
|
||||
expect(off.error).toBeNull()
|
||||
})
|
||||
|
||||
it("surfaces 'taken' when another app already owns the chord", () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut({ taken: ['Alt+J'] })
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
const state = controller.apply({ enabled: true, shortcut: 'alt+j' })
|
||||
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
expect(state).toEqual({ error: 'taken', registered: false, shortcut: 'Alt+J' })
|
||||
})
|
||||
|
||||
it("surfaces 'taken' when the OS refuses the registration", () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut({ register: false })
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' })).toEqual({
|
||||
error: 'taken',
|
||||
registered: false,
|
||||
shortcut: 'Alt+J'
|
||||
})
|
||||
})
|
||||
|
||||
it("surfaces 'invalid' for an unusable shortcut without asking the OS", () => {
|
||||
const { globalShortcut } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
expect(controller.apply({ enabled: true, shortcut: 'J' })).toEqual({
|
||||
error: 'invalid',
|
||||
registered: false,
|
||||
shortcut: 'J'
|
||||
})
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives a throwing globalShortcut', () => {
|
||||
const globalShortcut: GlobalShortcutLike = {
|
||||
isRegistered: () => false,
|
||||
register: () => {
|
||||
throw new Error('x11 grab failed')
|
||||
},
|
||||
unregister: () => {}
|
||||
}
|
||||
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' }).error).toBe('taken')
|
||||
})
|
||||
|
||||
it('dispose releases the accelerator and is idempotent', () => {
|
||||
const { globalShortcut, held } = fakeGlobalShortcut()
|
||||
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
|
||||
|
||||
controller.apply({ enabled: true, shortcut: 'Alt+J' })
|
||||
controller.dispose()
|
||||
controller.dispose()
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledTimes(1)
|
||||
expect(held.size).toBe(0)
|
||||
expect(controller.current().registered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quickEntryWindowBounds', () => {
|
||||
it('centers horizontally and sits below the top edge of the work area', () => {
|
||||
const bounds = quickEntryWindowBounds({ height: 1000, width: 1600, x: 0, y: 0 })
|
||||
|
||||
expect(bounds.width).toBe(640)
|
||||
expect(bounds.x).toBe((1600 - 640) / 2)
|
||||
expect(bounds.y).toBeGreaterThan(0)
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(1000)
|
||||
})
|
||||
|
||||
it('respects a display origin offset (second monitor)', () => {
|
||||
const bounds = quickEntryWindowBounds({ height: 900, width: 1440, x: 1600, y: -200 })
|
||||
|
||||
expect(bounds.x).toBe(1600 + (1440 - 640) / 2)
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(-200)
|
||||
})
|
||||
|
||||
it('stays inside a tiny work area', () => {
|
||||
const bounds = quickEntryWindowBounds({ height: 120, width: 320, x: 0, y: 0 })
|
||||
|
||||
expect(bounds.width).toBeLessThanOrEqual(320)
|
||||
expect(bounds.height).toBeLessThanOrEqual(120)
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(120)
|
||||
})
|
||||
|
||||
it('falls back to the origin without a work area', () => {
|
||||
expect(quickEntryWindowBounds()).toEqual({ height: 168, width: 640, x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Quick Entry — the global-hotkey mini composer.
|
||||
*
|
||||
* A small frameless always-on-top window that a global shortcut summons from
|
||||
* anywhere so the user can fire a prompt at Hermes without raising the whole
|
||||
* app. The window carries NO gateway connection of its own: it forwards the
|
||||
* text to the primary renderer, which sends it through the SAME prompt-submit
|
||||
* path the normal composer uses (see app/contrib/hooks/use-quick-entry-bridge).
|
||||
*
|
||||
* Everything Electron-free lives here so the parts that actually break a user —
|
||||
* accelerator validation, "disabled means never register", and surfacing a
|
||||
* shortcut another app already owns — are unit-testable without booting
|
||||
* Electron. main.ts owns the BrowserWindow, the file I/O, and the real
|
||||
* `globalShortcut`.
|
||||
*/
|
||||
|
||||
// Default matches the muscle memory of the apps this ports from (Claude
|
||||
// Desktop's quick entry / ChatGPT's Quick Chat sit on a Cmd+Shift chord).
|
||||
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space'
|
||||
|
||||
// Compact capture surface: wide enough for a sentence, short enough to read as
|
||||
// a HUD rather than a second app window. Height covers the composer row plus
|
||||
// the session-target picker row; the renderer never grows the OS window in v1.
|
||||
const QUICK_ENTRY_WINDOW_WIDTH = 640
|
||||
const QUICK_ENTRY_WINDOW_HEIGHT = 168
|
||||
|
||||
// Spotlight-ish placement: horizontally centered on the active display, a
|
||||
// comfortable fraction down from the top rather than dead center.
|
||||
const QUICK_ENTRY_TOP_FRACTION = 0.22
|
||||
|
||||
// Electron accelerator vocabulary (electronjs.org/docs/latest/api/accelerator).
|
||||
// Kept as data so validation and the settings UI agree on one list.
|
||||
const ACCELERATOR_MODIFIERS = new Set([
|
||||
'alt',
|
||||
'altgr',
|
||||
'cmd',
|
||||
'cmdorctrl',
|
||||
'command',
|
||||
'commandorcontrol',
|
||||
'control',
|
||||
'ctrl',
|
||||
'meta',
|
||||
'option',
|
||||
'shift',
|
||||
'super'
|
||||
])
|
||||
|
||||
const ACCELERATOR_KEYS = new Set([
|
||||
'backspace',
|
||||
'delete',
|
||||
'down',
|
||||
'end',
|
||||
'enter',
|
||||
'escape',
|
||||
'home',
|
||||
'insert',
|
||||
'left',
|
||||
'medianexttrack',
|
||||
'mediaplaypause',
|
||||
'mediaprevioustrack',
|
||||
'mediastop',
|
||||
'pagedown',
|
||||
'pageup',
|
||||
'plus',
|
||||
'printscreen',
|
||||
'return',
|
||||
'right',
|
||||
'space',
|
||||
'tab',
|
||||
'up',
|
||||
'volumedown',
|
||||
'volumemute',
|
||||
'volumeup'
|
||||
])
|
||||
|
||||
// Single printable characters Electron accepts verbatim, plus 0-9 / A-Z below.
|
||||
const ACCELERATOR_PUNCTUATION = new Set([
|
||||
'!',
|
||||
'"',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'&',
|
||||
"'",
|
||||
'(',
|
||||
')',
|
||||
'*',
|
||||
'+',
|
||||
',',
|
||||
'-',
|
||||
'.',
|
||||
'/',
|
||||
':',
|
||||
';',
|
||||
'<',
|
||||
'=',
|
||||
'>',
|
||||
'?',
|
||||
'@',
|
||||
'[',
|
||||
'\\',
|
||||
']',
|
||||
'^',
|
||||
'_',
|
||||
'`',
|
||||
'{',
|
||||
'|',
|
||||
'}',
|
||||
'~'
|
||||
])
|
||||
|
||||
/** Why a shortcut string was rejected. The renderer maps these to copy. */
|
||||
export type QuickEntryShortcutError =
|
||||
| 'empty'
|
||||
| 'invalid-key'
|
||||
| 'invalid-modifier'
|
||||
| 'no-key'
|
||||
| 'no-modifier'
|
||||
| 'reserved'
|
||||
|
||||
export type QuickEntryShortcutParse = { ok: false; reason: QuickEntryShortcutError } | { accelerator: string; ok: true }
|
||||
|
||||
function isAcceleratorKey(token: string): boolean {
|
||||
if (ACCELERATOR_KEYS.has(token)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(token)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^num(?:[0-9]|lock|dec|add|sub|mult|div)$/.test(token)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return token.length === 1 && (/^[a-z0-9]$/.test(token) || ACCELERATOR_PUNCTUATION.has(token))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize a user-typed accelerator.
|
||||
*
|
||||
* Rules beyond Electron's own grammar, both deliberate:
|
||||
* - At least one modifier. A bare global key steals that key from EVERY app.
|
||||
* - `Escape` can't be the key: inside the window Escape means "hide", so
|
||||
* binding it globally would make the shortcut un-toggleable.
|
||||
*/
|
||||
export function parseQuickEntryShortcut(raw: unknown): QuickEntryShortcutParse {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return { ok: false, reason: 'empty' }
|
||||
}
|
||||
|
||||
const parts = raw
|
||||
.split('+')
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { ok: false, reason: 'empty' }
|
||||
}
|
||||
|
||||
const modifiers: string[] = []
|
||||
let key: null | string = null
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase()
|
||||
|
||||
if (ACCELERATOR_MODIFIERS.has(lower)) {
|
||||
if (key) {
|
||||
// A modifier after the key ("A+Shift") is not a valid accelerator.
|
||||
return { ok: false, reason: 'invalid-modifier' }
|
||||
}
|
||||
|
||||
modifiers.push(lower)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (key) {
|
||||
// Two non-modifier keys ("Shift+A+B").
|
||||
return { ok: false, reason: 'invalid-key' }
|
||||
}
|
||||
|
||||
if (!isAcceleratorKey(lower)) {
|
||||
return { ok: false, reason: 'invalid-key' }
|
||||
}
|
||||
|
||||
key = lower
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return { ok: false, reason: 'no-key' }
|
||||
}
|
||||
|
||||
if (modifiers.length === 0) {
|
||||
return { ok: false, reason: 'no-modifier' }
|
||||
}
|
||||
|
||||
if (key === 'escape') {
|
||||
return { ok: false, reason: 'reserved' }
|
||||
}
|
||||
|
||||
// Canonical casing so a saved shortcut round-trips identically no matter how
|
||||
// the user typed it, and duplicate modifiers collapse.
|
||||
const seen = new Set<string>()
|
||||
|
||||
const normalizedModifiers = modifiers
|
||||
.map(modifier => CANONICAL_MODIFIER[modifier] ?? modifier)
|
||||
.filter(modifier => (seen.has(modifier) ? false : (seen.add(modifier), true)))
|
||||
// Stable display order (Electron itself is order-insensitive).
|
||||
.sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right))
|
||||
|
||||
return { accelerator: [...normalizedModifiers, canonicalKey(key)].join('+'), ok: true }
|
||||
}
|
||||
|
||||
const CANONICAL_MODIFIER: Record<string, string> = {
|
||||
alt: 'Alt',
|
||||
altgr: 'AltGr',
|
||||
cmd: 'Command',
|
||||
cmdorctrl: 'CommandOrControl',
|
||||
command: 'Command',
|
||||
commandorcontrol: 'CommandOrControl',
|
||||
control: 'Control',
|
||||
ctrl: 'Control',
|
||||
meta: 'Super',
|
||||
option: 'Option',
|
||||
shift: 'Shift',
|
||||
super: 'Super'
|
||||
}
|
||||
|
||||
const MODIFIER_ORDER = ['CommandOrControl', 'Command', 'Control', 'Super', 'Alt', 'Option', 'AltGr', 'Shift']
|
||||
|
||||
const CANONICAL_KEY: Record<string, string> = {
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
down: 'Down',
|
||||
end: 'End',
|
||||
enter: 'Enter',
|
||||
escape: 'Escape',
|
||||
home: 'Home',
|
||||
insert: 'Insert',
|
||||
medianexttrack: 'MediaNextTrack',
|
||||
mediaplaypause: 'MediaPlayPause',
|
||||
mediaprevioustrack: 'MediaPreviousTrack',
|
||||
mediastop: 'MediaStop',
|
||||
pagedown: 'PageDown',
|
||||
pageup: 'PageUp',
|
||||
plus: 'Plus',
|
||||
printscreen: 'PrintScreen',
|
||||
return: 'Return',
|
||||
right: 'Right',
|
||||
space: 'Space',
|
||||
tab: 'Tab',
|
||||
up: 'Up',
|
||||
volumedown: 'VolumeDown',
|
||||
volumemute: 'VolumeMute',
|
||||
volumeup: 'VolumeUp',
|
||||
left: 'Left'
|
||||
}
|
||||
|
||||
function canonicalKey(key: string): string {
|
||||
if (CANONICAL_KEY[key]) {
|
||||
return CANONICAL_KEY[key]
|
||||
}
|
||||
|
||||
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
if (key.length === 1 && /^[a-z]$/.test(key)) {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
/** The persisted shape of `quick-entry.json` (main-process owned). */
|
||||
export interface QuickEntrySettings {
|
||||
enabled: boolean
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw persisted JSON → usable settings. A malformed/absent file, or a shortcut
|
||||
* that no longer validates (hand-edited, or from a future build), falls back to
|
||||
* the default shortcut rather than leaving the feature un-summonable.
|
||||
*/
|
||||
export function sanitizeQuickEntrySettings(raw: unknown): QuickEntrySettings {
|
||||
const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
||||
const parsed = parseQuickEntryShortcut(record.shortcut)
|
||||
|
||||
return {
|
||||
// Default ON: the feature is inert until the shortcut is pressed.
|
||||
enabled: record.enabled === undefined ? true : record.enabled === true,
|
||||
shortcut: parsed.ok ? parsed.accelerator : DEFAULT_QUICK_ENTRY_SHORTCUT
|
||||
}
|
||||
}
|
||||
|
||||
/** The slice of Electron's `globalShortcut` we use (injected for testing). */
|
||||
export interface GlobalShortcutLike {
|
||||
isRegistered(accelerator: string): boolean
|
||||
register(accelerator: string, callback: () => void): boolean
|
||||
unregister(accelerator: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* What Settings shows. `registered` is the ground truth (we asked the OS);
|
||||
* `error` distinguishes "you turned it off" from "another app owns that chord",
|
||||
* which is the failure this feature must never swallow.
|
||||
*/
|
||||
export interface QuickEntryRegistration {
|
||||
error: null | QuickEntryRegistrationError
|
||||
registered: boolean
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
export type QuickEntryRegistrationError = 'invalid' | 'taken'
|
||||
|
||||
export interface QuickEntryShortcutController {
|
||||
/** Registration state as of the last apply. */
|
||||
current(): QuickEntryRegistration
|
||||
/** Release the shortcut (quit / feature off). Idempotent. */
|
||||
dispose(): void
|
||||
/** Re-register to match `settings`. Returns the resulting state. */
|
||||
apply(settings: QuickEntrySettings): QuickEntryRegistration
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the one live global accelerator. Single resolver so every caller — boot,
|
||||
* the settings write, quit — gets the same answer and we can never leak two
|
||||
* registrations for one feature.
|
||||
*
|
||||
* Disabled settings never touch `register()` at all: a user who turned Quick
|
||||
* Entry off must not have their chord silently held hostage.
|
||||
*/
|
||||
export function createQuickEntryShortcut(
|
||||
globalShortcut: GlobalShortcutLike,
|
||||
onTrigger: () => void
|
||||
): QuickEntryShortcutController {
|
||||
let active: null | string = null
|
||||
let state: QuickEntryRegistration = { error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }
|
||||
|
||||
const release = () => {
|
||||
if (active) {
|
||||
try {
|
||||
globalShortcut.unregister(active)
|
||||
} catch {
|
||||
// Best effort — a dead accelerator must not block a re-register.
|
||||
}
|
||||
|
||||
active = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apply(settings) {
|
||||
const parsed = parseQuickEntryShortcut(settings.shortcut)
|
||||
const shortcut = parsed.ok ? parsed.accelerator : settings.shortcut
|
||||
|
||||
release()
|
||||
|
||||
if (!settings.enabled) {
|
||||
state = { error: null, registered: false, shortcut }
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
state = { error: 'invalid', registered: false, shortcut }
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// `isRegistered` catches the common conflict before we ask, and
|
||||
// `register()` returning false catches the rest (another process owns it
|
||||
// OS-wide). Both land in the same surfaced 'taken' state.
|
||||
let ok = false
|
||||
|
||||
try {
|
||||
ok = globalShortcut.isRegistered(parsed.accelerator)
|
||||
? false
|
||||
: globalShortcut.register(parsed.accelerator, onTrigger)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
|
||||
active = ok ? parsed.accelerator : null
|
||||
state = { error: ok ? null : 'taken', registered: ok, shortcut: parsed.accelerator }
|
||||
|
||||
return state
|
||||
},
|
||||
current() {
|
||||
return state
|
||||
},
|
||||
dispose() {
|
||||
release()
|
||||
state = { ...state, error: null, registered: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the quick window opens on a given display work area. Centered
|
||||
* horizontally, a fraction down from the top, and clamped so it stays fully
|
||||
* inside the work area on small/odd displays.
|
||||
*/
|
||||
export function quickEntryWindowBounds(workArea?: { height: number; width: number; x: number; y: number }): {
|
||||
height: number
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
} {
|
||||
const width = Math.min(QUICK_ENTRY_WINDOW_WIDTH, workArea?.width ?? QUICK_ENTRY_WINDOW_WIDTH)
|
||||
const height = Math.min(QUICK_ENTRY_WINDOW_HEIGHT, workArea?.height ?? QUICK_ENTRY_WINDOW_HEIGHT)
|
||||
|
||||
if (!workArea) {
|
||||
return { height, width, x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const x = Math.round(workArea.x + (workArea.width - width) / 2)
|
||||
const maxY = workArea.y + workArea.height - height
|
||||
const y = Math.round(Math.min(Math.max(workArea.y, workArea.y + workArea.height * QUICK_ENTRY_TOP_FRACTION), maxY))
|
||||
|
||||
return { height, width, x, y }
|
||||
}
|
||||
|
||||
export { DEFAULT_QUICK_ENTRY_SHORTCUT, QUICK_ENTRY_TOP_FRACTION, QUICK_ENTRY_WINDOW_HEIGHT, QUICK_ENTRY_WINDOW_WIDTH }
|
||||
@@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {
|
||||
|
||||
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
|
||||
// Regression: secondary session windows used to omit this flag, so a streamed
|
||||
// answer stalled until the window regained focus (Chromium pauses the
|
||||
// requestAnimationFrame-gated transcript flush for backgrounded windows).
|
||||
// answer stalled until the window regained focus (Chromium clamps the
|
||||
// transcript flush timer for backgrounded windows).
|
||||
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||
|
||||
assert.equal(prefs.backgroundThrottling, false)
|
||||
|
||||
@@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
// false`, so a streamed answer stalled until the window regained focus.
|
||||
//
|
||||
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
|
||||
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
|
||||
// blurred/occluded windows. A streaming chat app must keep painting in the
|
||||
// screen through a bounded timer flush, which Chromium clamps for blurred/
|
||||
// occluded windows. A streaming chat app must keep painting in the
|
||||
// background, so every chat window opts out. The preload path is injected
|
||||
// because it depends on the Electron entry's __dirname.
|
||||
function chatWindowWebPreferences(preloadPath: string) {
|
||||
|
||||
@@ -90,15 +90,16 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
|
||||
|
||||
/**
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
|
||||
* global UI zoom; the pet overlay opts out because it sizes its own OS window
|
||||
* to the sprite and inheriting zoom would crop it.
|
||||
* global UI zoom; the pet overlay and the Quick Entry composer opt out because
|
||||
* they size their own OS window and inheriting zoom would crop/overflow them.
|
||||
*
|
||||
* Extracted so the "pet opts out, everything else opts in" contract is
|
||||
* Extracted so the "helper windows opt out, everything else opts in" contract is
|
||||
* unit-testable without booting a BrowserWindow or reading source.
|
||||
*/
|
||||
export const ZOOM_WINDOW_CONFIG = {
|
||||
chat: { zoom: true },
|
||||
petOverlay: { zoom: false }
|
||||
petOverlay: { zoom: false },
|
||||
quickEntry: { zoom: false }
|
||||
} as const
|
||||
|
||||
export function zoomWiringForWindowKind(kind) {
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"bippy": "0.5.43",
|
||||
"concurrently": "^10.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "40.10.2",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Who re-renders the transcript during a sash drag?
|
||||
//
|
||||
// Standalone probe, not a benchmark: seeds tiles, then drags the sash while
|
||||
// recording (a) render attribution and (b) every nanostores atom that notifies
|
||||
// during the gesture. The idle-cost scenario proved the transcript re-renders
|
||||
// ~18x above baseline during a drag but that the sash HANDLER is not the cause
|
||||
// (identical counts at 0px and 60px displacement) — so this names the store
|
||||
// that actually fires.
|
||||
//
|
||||
// node scripts/perf/diag-drag-churn.mjs [--port 9222]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const TILES = 5
|
||||
const TURNS = 20
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__D__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'diag-tile-' + n
|
||||
const rid = 'diag-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__D__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drag, recording renders AND atom notifications together.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
const ac = window.__ATOM_CHURN__
|
||||
rc.start(); ac.start()
|
||||
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) }
|
||||
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
|
||||
rc.stop(); ac.stop()
|
||||
const all = rc.report(400)
|
||||
const named = n => all.find(r => r.name === n) || null
|
||||
return JSON.stringify({
|
||||
moved: Math.round(x - x0),
|
||||
commits: rc.commits(),
|
||||
renders: rc.report(14),
|
||||
// The suspects: who at the TOP of the transcript tree re-rendered?
|
||||
chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat',
|
||||
'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell']
|
||||
.map(n => ({ name: n, hit: named(n) })).filter(x => x.hit),
|
||||
atoms: ac.report(30)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__D__) {
|
||||
for (const { sid, rid } of window.__D__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__D__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`diag-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(`moved ${data.moved}px, ${data.commits} commits\n`)
|
||||
console.log('RENDERS during drag:')
|
||||
|
||||
for (const r of data.renders) {
|
||||
console.log(
|
||||
` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` +
|
||||
`props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` +
|
||||
`ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):')
|
||||
|
||||
for (const { name, hit } of data.chain) {
|
||||
console.log(
|
||||
` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` +
|
||||
`props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nATOMS that notified during drag:')
|
||||
|
||||
for (const a of data.atoms) {
|
||||
console.log(
|
||||
` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` +
|
||||
`fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// What is the drag actually spending time on?
|
||||
//
|
||||
// The render counters proved React is no longer the cost (commits 83 -> 12
|
||||
// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That
|
||||
// pattern says a fixed per-frame floor outside React. This takes a real CDP
|
||||
// trace of one sash drag and prints the category split — Recalculate Style,
|
||||
// Layout, Paint, Scripting — so the next fix targets the actual cost instead
|
||||
// of the next plausible-looking thing.
|
||||
//
|
||||
// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__T__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'trace-tile-' + n
|
||||
const rid = 'trace-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__T__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced
|
||||
// loop and make the window itself a function of the slowness. Fixed wall-clock
|
||||
// pacing keeps the trace window comparable run to run.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) return 'none'
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
return 'dragged'
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__T__) {
|
||||
for (const { sid, rid } of window.__T__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__T__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`trace-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
// Collect trace events for the drag window only. `cdp.on` is the client's
|
||||
// only event API (no `once`), so completion is signalled through a flag.
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] }
|
||||
})
|
||||
|
||||
const dragged = await cdp.eval(DRAG)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let waited = 0; !complete && waited < 10000; waited += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
// Sum self-time per timeline category. Nested events would double-count, so
|
||||
// attribute each event's duration minus the duration of its direct children.
|
||||
const INTERESTING = new Set([
|
||||
'UpdateLayoutTree', // Recalculate Style
|
||||
'Layout',
|
||||
'Paint',
|
||||
'PaintImage',
|
||||
'Layerize',
|
||||
'UpdateLayer',
|
||||
'CompositeLayers',
|
||||
'FunctionCall',
|
||||
'EvaluateScript',
|
||||
'TimerFire',
|
||||
'EventDispatch',
|
||||
'HitTest',
|
||||
'ParseHTML',
|
||||
'CommitLoad'
|
||||
])
|
||||
|
||||
const totals = new Map()
|
||||
let traced = 0
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
traced += 1
|
||||
const name = e.name
|
||||
|
||||
if (!INTERESTING.has(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`)
|
||||
console.log('TIMELINE COST (ms, total duration by event):')
|
||||
|
||||
const rows = [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(' (no timeline events — category filter or tracing domain unavailable)')
|
||||
}
|
||||
|
||||
for (const [name, ms] of rows) {
|
||||
console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
|
||||
console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`)
|
||||
|
||||
// Script dominates -> name the functions. Timeline FunctionCall events carry
|
||||
// the callsite in args.data, so the top offenders can be attributed without
|
||||
// a separate CPU profile.
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log('\nTOP SCRIPT CALLSITES (ms):')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// How many ResizeObserver callbacks does one sash drag actually fire, and for
|
||||
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
|
||||
// but not whether that's a few expensive calls or a great many cheap ones —
|
||||
// and the fix differs completely between those.
|
||||
//
|
||||
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__R__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'ro-tile-' + n
|
||||
const rid = 'ro-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__R__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Patch ResizeObserver to count callbacks + distinct observed targets, then
|
||||
// drag and report. Counting happens in the page so nothing crosses CDP per call.
|
||||
//
|
||||
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
|
||||
// lazily on first use, so this patch must be installed BEFORE any surface
|
||||
// mounts — otherwise the shared instance is a native one this wrapper never
|
||||
// sees and every counter reads zero. `constructed` is the tell: a run showing
|
||||
// a handful of constructions and zero callbacks means the patch landed late,
|
||||
// not that the app stopped observing.
|
||||
const INSTRUMENT = `
|
||||
(() => {
|
||||
if (window.__ROSTATS__) return 'already'
|
||||
const Native = window.ResizeObserver
|
||||
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
|
||||
window.__ROSTATS__ = stats
|
||||
window.ResizeObserver = class extends Native {
|
||||
constructor(cb) {
|
||||
super((entries, obs) => {
|
||||
if (stats.on) {
|
||||
stats.callbacks += 1
|
||||
stats.entries += entries.length
|
||||
for (const e of entries) stats.targets.add(e.target)
|
||||
}
|
||||
return cb(entries, obs)
|
||||
})
|
||||
stats.constructed += 1
|
||||
}
|
||||
observe(...args) {
|
||||
stats.observed += 1
|
||||
return super.observe(...args)
|
||||
}
|
||||
}
|
||||
return 'patched'
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const s = window.__ROSTATS__
|
||||
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const t0 = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
s.on = false
|
||||
return JSON.stringify({
|
||||
ms: Math.round(performance.now() - t0),
|
||||
moves: 40,
|
||||
constructed: s.constructed,
|
||||
observed: s.observed,
|
||||
callbacks: s.callbacks,
|
||||
entries: s.entries,
|
||||
distinctTargets: s.targets.size,
|
||||
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__R__) {
|
||||
for (const { sid, rid } of window.__R__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__R__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.eval(INSTRUMENT)
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`ro-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const r = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(JSON.stringify(r, null, 2))
|
||||
|
||||
if (r.moves) {
|
||||
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
|
||||
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -51,6 +51,8 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
|
||||
| `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream |
|
||||
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
|
||||
| `transcript` | ci | large-transcript mount + paint cost | (new) |
|
||||
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
|
||||
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
|
||||
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
|
||||
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
||||
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"platform": "darwin-arm64",
|
||||
"node": "v24.11.0",
|
||||
"updated": "2026-07-19T23:16:01.227Z"
|
||||
"updated": "2026-07-27T00:30:11.290Z"
|
||||
},
|
||||
"scenarios": {
|
||||
"stream": {
|
||||
@@ -55,6 +55,25 @@
|
||||
"dom_content_loaded_ms": 574,
|
||||
"nav_to_read_ms": 721
|
||||
}
|
||||
},
|
||||
"multitab": {
|
||||
"metrics": {
|
||||
"longtasks_n": 0,
|
||||
"longtask_max_ms": 0,
|
||||
"frame_p95_ms": 29.1,
|
||||
"frame_p99_ms": 36.2,
|
||||
"slow_frames_33": 9
|
||||
}
|
||||
},
|
||||
"render-churn": {
|
||||
"metrics": {
|
||||
"sidebar_renders": 0,
|
||||
"sidebar_wasted": 0,
|
||||
"wasted_renders": 1704,
|
||||
"total_renders": 8221,
|
||||
"commits": 1352,
|
||||
"wasted_notifies": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// What does the app cost while a turn is running but NOTHING is arriving?
|
||||
//
|
||||
// The user-visible symptom: with a thread spinning, resizing the sidebar or
|
||||
// typing in the composer feels slow. That is not streaming cost — the stream
|
||||
// is idle. It is the app re-rendering on its own, competing with the
|
||||
// interaction for the main thread.
|
||||
//
|
||||
// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
|
||||
// - idle_commits_per_s the renderer's self-inflicted commit rate
|
||||
// - drag_fps fps while dragging the sidebar splitter
|
||||
// - type_fps fps while typing in the composer
|
||||
//
|
||||
// A perfectly idle app scores 0 idle commits and pins both interactions at the
|
||||
// display's refresh rate. Every idle commit is main-thread time stolen from an
|
||||
// interaction the user can feel.
|
||||
//
|
||||
// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
|
||||
* `render-churn`, but the driver never runs — the turn just stays open. */
|
||||
const setup = (tiles, seedTurns) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
|
||||
])
|
||||
|
||||
window.__IDLE__ = { ids: [] }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'idle-tile-' + n
|
||||
const rid = 'idle-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
// An OPEN assistant message: the turn is running, but no tokens arrive.
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working on it.' }] })
|
||||
|
||||
window.__IDLE__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Measure the app's self-inflicted commit rate with nothing happening. */
|
||||
const idleCost = seconds => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const t0 = performance.now()
|
||||
await new Promise(r => setTimeout(r, ${seconds} * 1000))
|
||||
const elapsed = (performance.now() - t0) / 1000
|
||||
rc.stop()
|
||||
return JSON.stringify({
|
||||
elapsed,
|
||||
commits: rc.commits(),
|
||||
top: rc.report(12),
|
||||
owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
/** Record frame pacing across an interaction driven from the page.
|
||||
*
|
||||
* The gesture body drives itself on requestAnimationFrame, so it IS the frame
|
||||
* clock — timing is taken from those same callbacks rather than a second,
|
||||
* independent rAF ticker. Running two rAF consumers made the observer's
|
||||
* deltas count the driver's frames as well as the app's and reported ~3fps
|
||||
* where the interaction actually ran at ~23fps. `frames` is filled by the
|
||||
* body via `__MARK__`.
|
||||
*
|
||||
* `record` MUST be false for any fps number you intend to believe: the render
|
||||
* counter walks the whole fiber tree on every commit, so recording during a
|
||||
* gesture measures the instrumentation as much as the app. Attribution and
|
||||
* timing therefore run as two separate passes. */
|
||||
const withFrames = (body, record = false) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
${record ? 'rc.start()' : ''}
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
// The body calls this once per frame it drives.
|
||||
const __MARK__ = () => {
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
}
|
||||
${body}
|
||||
${record ? 'rc.stop()' : ''}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
return JSON.stringify({
|
||||
fps: total ? (frames.length / total) * 1000 : 0,
|
||||
p95: pct(0.95),
|
||||
worst: sorted.length ? sorted[sorted.length - 1] : 0,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
n: frames.length,
|
||||
commits: ${record ? 'rc.commits()' : '0'},
|
||||
top: ${record ? 'rc.report(10)' : '[]'}
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
/** Drag the sidebar splitter — the resize symptom.
|
||||
* Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op),
|
||||
* and reports how far it actually moved so a drag that silently did nothing
|
||||
* shows up as `dragMoved: 0` instead of a confident wrong number. */
|
||||
const DRAG = withFrames(`
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
window.__DRAG_TARGET__ = handle ? 'separator' : 'none'
|
||||
window.__DRAG_MOVED__ = 0
|
||||
if (handle) {
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
// Out 60px then back — a real gesture, with a net displacement at the peak.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.__DRAG_MOVED__ = Math.round(x - x0)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x -= 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
`)
|
||||
|
||||
/** Type into the composer — the keystroke symptom. */
|
||||
const TYPE = withFrames(`
|
||||
const el = document.querySelector('[contenteditable="true"], textarea')
|
||||
window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none'
|
||||
if (el) {
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'performance testing '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
if (el.tagName === 'TEXTAREA') {
|
||||
el.value += ch
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
} else {
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
}
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
// Wait for the frame this keystroke produces, then mark it — same clock
|
||||
// discipline as DRAG, so typing fps is comparable to drag fps.
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
await new Promise(r => setTimeout(r, 25))
|
||||
}
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
`)
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__IDLE__) {
|
||||
for (const { sid, rid } of window.__IDLE__.ids) {
|
||||
const states = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__IDLE__ = null
|
||||
}
|
||||
window.__RENDER_COUNTS__.clear()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
|
||||
|
||||
export default {
|
||||
name: 'idle-cost',
|
||||
// NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a
|
||||
// direct single-clock probe of the same gesture on the same build (57fps),
|
||||
// and I could not reconcile the two — ruled out sash selection, tile setup,
|
||||
// counter residue, and a 20s soak. Its RENDER attribution and idle commit
|
||||
// rate are trustworthy and are what this scenario is for; the interaction
|
||||
// fps is reported for investigation, not gated on, until that is explained.
|
||||
tier: 'report',
|
||||
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const seconds = Number(opts.seconds ?? 6)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`idle-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
|
||||
const drag = JSON.parse(await cdp.eval(DRAG))
|
||||
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
|
||||
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
|
||||
const type = JSON.parse(await cdp.eval(TYPE))
|
||||
const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"')
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
if (dragTarget === 'none') {
|
||||
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
|
||||
}
|
||||
|
||||
if (typeTarget === 'none') {
|
||||
throw new Error('idle-cost: no composer found — the typing pass measured nothing.')
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// Commits per second with a turn open and nothing arriving. Should be 0.
|
||||
idle_commits_per_s: round(idle.commits / idle.elapsed),
|
||||
idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
|
||||
// Interaction smoothness while that churn competes for the main thread.
|
||||
// Reported as a deficit from 60fps so "lower is better" matches the
|
||||
// baseline gate's direction.
|
||||
drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
|
||||
drag_slow_frames: drag.slow33,
|
||||
type_fps_deficit: round(Math.max(0, 60 - type.fps)),
|
||||
type_slow_frames: type.slow33
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
dragTarget,
|
||||
dragMoved,
|
||||
idleSeconds: round(idle.elapsed),
|
||||
dragFps: round(drag.fps),
|
||||
dragP95: round(drag.p95),
|
||||
dragWorst: round(drag.worst),
|
||||
typeFps: round(type.fps),
|
||||
typeP95: round(type.p95),
|
||||
typeWorst: round(type.worst),
|
||||
// Components whose OWN state changed with no prop change: the roots.
|
||||
idleOwners: idle.owners,
|
||||
idleTop: idle.top,
|
||||
dragCommits: drag.commits,
|
||||
dragTop: drag.top,
|
||||
typeCommits: type.commits,
|
||||
typeTop: type.top
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
import coldStart from './cold-start.mjs'
|
||||
import firstToken from './first-token.mjs'
|
||||
import idleCost from './idle-cost.mjs'
|
||||
import keystroke from './keystroke.mjs'
|
||||
import multitab from './multitab.mjs'
|
||||
import profileSwitch from './profile-switch.mjs'
|
||||
import renderChurn from './render-churn.mjs'
|
||||
import sessionSwitch from './session-switch.mjs'
|
||||
import stream from './stream.mjs'
|
||||
import streamHistory from './stream-history.mjs'
|
||||
@@ -18,6 +20,8 @@ export const SCENARIOS = {
|
||||
[keystroke.name]: keystroke,
|
||||
[transcript.name]: transcript,
|
||||
[multitab.name]: multitab,
|
||||
[renderChurn.name]: renderChurn,
|
||||
[idleCost.name]: idleCost,
|
||||
[coldStart.name]: coldStart,
|
||||
[firstToken.name]: firstToken,
|
||||
[submit.name]: submit,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
|
||||
// store published the update. Frame pacing (see `multitab`) tells you the cost;
|
||||
// this tells you the cause.
|
||||
//
|
||||
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
|
||||
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
|
||||
// then reads the dev-only counters installed by `src/debug/`:
|
||||
//
|
||||
// window.__RENDER_COUNTS__ — per-component renders, attributed to
|
||||
// props / hook state / parent-only ("wasted")
|
||||
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
|
||||
// notifications whose value was deep-equal to the
|
||||
// previous one ("wasted")
|
||||
//
|
||||
// The headline metric is `sidebar_renders`: how many times the sidebar tree
|
||||
// re-rendered while agents were typing in other tabs. It should be 0.
|
||||
//
|
||||
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
/** Components that make up the sidebar tree. A render of any of these while
|
||||
* a background tab streams is work the user cannot see. */
|
||||
const SIDEBAR_COMPONENTS = [
|
||||
'ChatSidebar',
|
||||
'SidebarSurface',
|
||||
'SessionRow',
|
||||
'SessionsSection',
|
||||
'CronJobsSection',
|
||||
'ProfileSwitcher',
|
||||
'VirtualSessionList',
|
||||
'WorkspaceGroup',
|
||||
'OverviewRow',
|
||||
'SessionStatusDot'
|
||||
]
|
||||
|
||||
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
|
||||
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
|
||||
const setup = (tiles, seedTurns) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
||||
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
|
||||
])
|
||||
|
||||
const state = (sid) => {
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: '' }] })
|
||||
return {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
}
|
||||
}
|
||||
|
||||
window.__RC__ = { ids: [], timer: null }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'churn-tile-' + n
|
||||
const rid = 'churn-rt-' + n
|
||||
window.__RC__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, state(sid))
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
|
||||
* same publish path the gateway's delta flush uses. */
|
||||
const drive = (chunk, intervalMs, totalTokens) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
let pushed = 0
|
||||
const tick = () => {
|
||||
const states = hook.states()
|
||||
for (const { rid } of window.__RC__.ids) {
|
||||
const prev = states[rid]
|
||||
if (!prev) continue
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts[m.parts.length - 1]
|
||||
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
||||
})
|
||||
hook.publish(rid, { ...prev, messages })
|
||||
}
|
||||
pushed += 1
|
||||
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
||||
else window.__RC__.done = true
|
||||
}
|
||||
window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
||||
return 'driving'
|
||||
})()
|
||||
`
|
||||
|
||||
/** Wait until the renderer stops committing on its own, so the recording window
|
||||
* captures STREAMING cost and not whatever boot/hydration work happened to
|
||||
* still be in flight. Returns `quiet:N` once commits hold still for `quietMs`.
|
||||
*
|
||||
* If it returns `timeout:...` the app never went idle at all — with tiles
|
||||
* marked busy and NO driver running, that means something is ticking on its
|
||||
* own. The report of what rendered during the wait is attached so the culprit
|
||||
* is named rather than guessed at. */
|
||||
const quiesce = (quietMs, timeoutMs) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const deadline = Date.now() + ${timeoutMs}
|
||||
const startedAt = Date.now()
|
||||
let last = -1
|
||||
let stableSince = Date.now()
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
const n = rc.commits()
|
||||
if (n !== last) { last = n; stableSince = Date.now(); continue }
|
||||
if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n }
|
||||
}
|
||||
const idle = {
|
||||
commits: last,
|
||||
seconds: (Date.now() - startedAt) / 1000,
|
||||
top: rc.report(8),
|
||||
// Who OWNS the update? The component whose own hook state changed with
|
||||
// no changed props is the root of a churn cascade; everything under it
|
||||
// is collateral. Naming it is the difference between fixing the cause
|
||||
// and memoizing a symptom.
|
||||
owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8)
|
||||
}
|
||||
rc.stop()
|
||||
return 'timeout:' + JSON.stringify(idle)
|
||||
})()
|
||||
`
|
||||
|
||||
const START = `
|
||||
(() => {
|
||||
window.__RENDER_COUNTS__.start()
|
||||
window.__ATOM_CHURN__.start()
|
||||
return 'recording'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT = `
|
||||
(() => {
|
||||
window.__RENDER_COUNTS__.stop()
|
||||
window.__ATOM_CHURN__.stop()
|
||||
return JSON.stringify({
|
||||
commits: window.__RENDER_COUNTS__.commits(),
|
||||
renders: window.__RENDER_COUNTS__.report(200),
|
||||
atoms: window.__ATOM_CHURN__.report(200)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__RC__) {
|
||||
clearTimeout(window.__RC__.timer)
|
||||
for (const { sid, rid } of window.__RC__.ids) {
|
||||
const states = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__RC__ = null
|
||||
}
|
||||
window.__RENDER_COUNTS__.clear()
|
||||
window.__ATOM_CHURN__.clear()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'render-churn',
|
||||
tier: 'ci',
|
||||
description: 'N streaming tabs: per-component render attribution + store churn.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const tokens = Number(opts.tokens ?? 240)
|
||||
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
||||
const intervalMs = Number(opts.intervalMs ?? 33)
|
||||
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(
|
||||
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
|
||||
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
|
||||
)
|
||||
}
|
||||
|
||||
// Mount every tab (keep-alive mounts on first activation), then settle.
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`churn-tile-${n}`))
|
||||
await sleep(350)
|
||||
}
|
||||
|
||||
// Let the app go quiet before recording, so boot/hydration commits that
|
||||
// happen to still be in flight don't land in the streaming window. This is
|
||||
// what makes runs comparable — a fixed sleep let 2-4x of hydration churn
|
||||
// leak in depending on machine load.
|
||||
const settle = await cdp.eval(quiesce(600, 15000))
|
||||
await cdp.eval(START)
|
||||
await cdp.eval(drive(chunk, intervalMs, tokens))
|
||||
await sleep(tokens * intervalMs + 1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(COLLECT))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
const byName = new Map(data.renders.map(r => [r.name, r]))
|
||||
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
|
||||
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
|
||||
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
|
||||
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
|
||||
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
|
||||
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// The hypothesis, as a number: sidebar renders while background tabs
|
||||
// stream. Should be 0.
|
||||
sidebar_renders: sidebarRenders,
|
||||
sidebar_wasted: sidebarWasted,
|
||||
// Renders with no changed props and no changed hook state — pure
|
||||
// parent-driven work, across the whole tree.
|
||||
wasted_renders: totalWasted,
|
||||
total_renders: totalRenders,
|
||||
commits: data.commits,
|
||||
// Store notifications that published a value equal to the last one.
|
||||
wasted_notifies: atomWasted
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
tokens,
|
||||
// 'quiet:N' = the app went idle before recording (comparable run).
|
||||
// 'timeout:N' = it never did, so boot churn is mixed into the numbers.
|
||||
settle,
|
||||
sidebar: sidebarRows,
|
||||
topRenders: data.renders.slice(0, 15),
|
||||
topAtoms: data.atoms.slice(0, 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,7 @@ export function useComposerDraft({
|
||||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { createRef, type RefObject } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComposerUndo } from './use-composer-undo'
|
||||
|
||||
/** Mount the hook against a real contentEditable, exposing its API. */
|
||||
function mountUndo(editorRef: RefObject<HTMLDivElement | null>, onSync: () => string) {
|
||||
const api: { current: ReturnType<typeof useComposerUndo> | null } = { current: null }
|
||||
|
||||
const Harness = () => {
|
||||
// Assigned during render on purpose: the tests drive the API imperatively
|
||||
// right after mount, and this is a harness, not app state.
|
||||
api.current = useComposerUndo({ editorRef, syncDraftFromEditor: onSync })
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const view = render(<Harness />)
|
||||
|
||||
return { api, view }
|
||||
}
|
||||
|
||||
function makeEditor(text: string) {
|
||||
const editor = document.createElement('div')
|
||||
editor.contentEditable = 'true'
|
||||
// jsdom only focuses a contentEditable div when it's explicitly focusable;
|
||||
// the real editor is reachable via the composer's focus bus.
|
||||
editor.tabIndex = 0
|
||||
editor.append(document.createTextNode(text))
|
||||
document.body.append(editor)
|
||||
|
||||
const ref = createRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>
|
||||
ref.current = editor
|
||||
|
||||
return { editor, ref }
|
||||
}
|
||||
|
||||
const caretAtEnd = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
describe('useComposerUndo', () => {
|
||||
it('restores the pre-edit text, which is what a paste destroyed', () => {
|
||||
const { editor, ref } = makeEditor('before')
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
// Bank, then simulate the Range-based paste that Chromium never records.
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' PASTED'))
|
||||
expect(editor.textContent).toBe('before PASTED')
|
||||
|
||||
api.current!.undo()
|
||||
expect(editor.textContent).toBe('before')
|
||||
|
||||
api.current!.redo()
|
||||
expect(editor.textContent).toBe('before PASTED')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('withUndoPoint banks only when the edit actually ran', () => {
|
||||
const { editor, ref } = makeEditor('text')
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
// A guard that declines must not consume an undo slot.
|
||||
expect(api.current!.withUndoPoint(() => false)).toBe(false)
|
||||
expect(api.current!.undo()).toBe(false)
|
||||
|
||||
expect(
|
||||
api.current!.withUndoPoint(() => {
|
||||
editor.append(document.createTextNode('!'))
|
||||
|
||||
return true
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
api.current!.undo()
|
||||
expect(editor.textContent).toBe('text')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('claims a native historyUndo aimed at the focused editor', () => {
|
||||
const { editor, ref } = makeEditor('kept')
|
||||
editor.focus()
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' extra'))
|
||||
|
||||
// What Electron's Edit menu `{ role: 'undo' }` produces.
|
||||
const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' })
|
||||
editor.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(editor.textContent).toBe('kept')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('ignores a historyUndo while another editor holds focus', () => {
|
||||
const { editor, ref } = makeEditor('mine')
|
||||
const { editor: other } = makeEditor('theirs')
|
||||
|
||||
other.focus()
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' changed'))
|
||||
|
||||
const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' })
|
||||
other.dispatchEvent(event)
|
||||
|
||||
// Not ours to claim — the other surface keeps its native behavior.
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(editor.textContent).toBe('mine changed')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
other.remove()
|
||||
})
|
||||
|
||||
it('keeps two mounted composers independent', () => {
|
||||
const { editor: main, ref: mainRef } = makeEditor('main')
|
||||
const { editor: edit, ref: editRef } = makeEditor('edit')
|
||||
|
||||
const mainUndo = mountUndo(mainRef, () => main.textContent || '')
|
||||
const editUndo = mountUndo(editRef, () => edit.textContent || '')
|
||||
|
||||
mainUndo.api.current!.recordUndoPoint()
|
||||
main.append(document.createTextNode(' typed'))
|
||||
|
||||
// Undoing in the edit composer must not touch the main composer's text.
|
||||
editUndo.api.current!.undo()
|
||||
expect(main.textContent).toBe('main typed')
|
||||
|
||||
mainUndo.api.current!.undo()
|
||||
expect(main.textContent).toBe('main')
|
||||
expect(edit.textContent).toBe('edit')
|
||||
|
||||
mainUndo.view.unmount()
|
||||
editUndo.view.unmount()
|
||||
main.remove()
|
||||
edit.remove()
|
||||
})
|
||||
|
||||
it('reset drops history so undo cannot cross a draft swap', () => {
|
||||
const { editor, ref } = makeEditor('session A')
|
||||
caretAtEnd(editor)
|
||||
|
||||
const { api, view } = mountUndo(ref, () => editor.textContent || '')
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
editor.append(document.createTextNode(' edited'))
|
||||
api.current!.resetUndoHistory()
|
||||
|
||||
expect(api.current!.undo()).toBe(false)
|
||||
expect(editor.textContent).toBe('session A edited')
|
||||
|
||||
view.unmount()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('is inert when the editor ref is empty', () => {
|
||||
const ref = createRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>
|
||||
const sync = vi.fn(() => '')
|
||||
|
||||
const { api, view } = mountUndo(ref, sync)
|
||||
|
||||
api.current!.recordUndoPoint()
|
||||
|
||||
expect(api.current!.undo()).toBe(false)
|
||||
expect(sync).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { type RefObject, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { caretOffsetInEditor, composerPlainText, placeCaretAtOffset, renderComposerContents } from '../rich-editor'
|
||||
import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history'
|
||||
|
||||
interface UseComposerUndoArgs {
|
||||
editorRef: RefObject<HTMLDivElement | null>
|
||||
/** Push a restored snapshot back into draftRef + composer state. */
|
||||
syncDraftFromEditor: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo for the rich composer.
|
||||
*
|
||||
* The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing
|
||||
* pipeline (#45812), which also dodges Chromium's undo stack — so a paste was
|
||||
* invisible to ⌘Z and the keystroke undid whatever edit came before it instead.
|
||||
* We own the stack outright rather than half of it: every edit path records the
|
||||
* pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself.
|
||||
*/
|
||||
export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) {
|
||||
const history = useMemo(() => createComposerUndoHistory(), [])
|
||||
|
||||
const snapshot = useCallback((): ComposerSnapshot => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return { caret: 0, text: '' }
|
||||
}
|
||||
|
||||
return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) }
|
||||
}, [editorRef])
|
||||
|
||||
/** Bank the current state before mutating the editor. `coalesce` marks a
|
||||
* keystroke, so a run of typing collapses into one undo step. */
|
||||
const recordUndoPoint = useCallback(
|
||||
(options?: { coalesce?: boolean }) => {
|
||||
if (editorRef.current) {
|
||||
history.record(snapshot(), options)
|
||||
}
|
||||
},
|
||||
[editorRef, history, snapshot]
|
||||
)
|
||||
|
||||
const applySnapshot = useCallback(
|
||||
(next: ComposerSnapshot | null) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!next || !editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
renderComposerContents(editor, next.text)
|
||||
placeCaretAtOffset(editor, next.caret)
|
||||
syncDraftFromEditor()
|
||||
|
||||
return true
|
||||
},
|
||||
[editorRef, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
/** Run a conditional edit, banking its pre-edit state only if it actually
|
||||
* ran. The snapshot has to be taken first (the edit destroys the state we'd
|
||||
* be saving), but recording unconditionally would clear the redo stack on
|
||||
* every Backspace that falls through to the native path. */
|
||||
const withUndoPoint = useCallback(
|
||||
(edit: () => boolean) => {
|
||||
const before = snapshot()
|
||||
const ran = edit()
|
||||
|
||||
if (ran) {
|
||||
history.record(before)
|
||||
}
|
||||
|
||||
return ran
|
||||
},
|
||||
[history, snapshot]
|
||||
)
|
||||
|
||||
const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot])
|
||||
const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot])
|
||||
|
||||
// A session/draft swap makes prior history meaningless — undoing into another
|
||||
// conversation's text is worse than having no history at all.
|
||||
const resetUndoHistory = useCallback(() => history.reset(), [history])
|
||||
|
||||
// Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS
|
||||
// menu bar consumes before the web contents sees the keystroke (the same
|
||||
// hazard main.ts documents for ⌘W). It fires the native editing command,
|
||||
// which knows nothing about our stack. Claim it at the document level while
|
||||
// the composer holds focus, so the menu item and the keystroke agree.
|
||||
useEffect(() => {
|
||||
const onBeforeInput = (event: Event) => {
|
||||
const inputType = (event as InputEvent).inputType
|
||||
|
||||
if (inputType !== 'historyUndo' && inputType !== 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement !== editorRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (inputType === 'historyUndo') {
|
||||
undo()
|
||||
} else {
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('beforeinput', onBeforeInput, true)
|
||||
|
||||
return () => document.removeEventListener('beforeinput', onBeforeInput, true)
|
||||
}, [editorRef, redo, undo])
|
||||
|
||||
return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint }
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { useComposerPopout } from './hooks/use-composer-popout'
|
||||
import { useComposerQueue } from './hooks/use-composer-queue'
|
||||
import { useComposerSubmit } from './hooks/use-composer-submit'
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { useComposerUndo } from './hooks/use-composer-undo'
|
||||
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
|
||||
import { useComposerVoice } from './hooks/use-composer-voice'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
@@ -49,7 +50,7 @@ import {
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
insertComposerContentsAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
@@ -59,7 +60,9 @@ import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
export function ChatBar({
|
||||
@@ -172,9 +175,24 @@ export function ChatBar({
|
||||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
} = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId })
|
||||
|
||||
// Undo/redo. The rich editor bypasses Chromium's editing pipeline for speed,
|
||||
// which also bypasses its undo stack — so we own the stack and every edit
|
||||
// path below banks its pre-edit state through `recordUndoPoint`.
|
||||
const { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor
|
||||
})
|
||||
|
||||
// Prior history belongs to the draft that just left — undoing into another
|
||||
// conversation's text is worse than having none.
|
||||
useEffect(() => {
|
||||
resetUndoHistory()
|
||||
}, [activeQueueSessionKey, resetUndoHistory])
|
||||
|
||||
// "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or
|
||||
// an @url: directive into the draft).
|
||||
const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({
|
||||
@@ -356,6 +374,23 @@ export function ChatBar({
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
// Native typing/deleting mutates the DOM through Chromium's editing pipeline,
|
||||
// whose undo stack we've taken over — so bank the pre-edit state here, before
|
||||
// the change lands. `beforeinput` is the only hook that still sees the old
|
||||
// text. Consecutive keystrokes coalesce into one entry, so ⌘Z steps back by a
|
||||
// burst rather than a character.
|
||||
const handleEditorBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
// Undo/redo are ours (handled in useComposerUndo + keydown), and IME preedit
|
||||
// is not a committed edit — compositionend is where that text becomes real.
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo' || composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
@@ -402,7 +437,12 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
insertPlainTextAtCaret(event.currentTarget, pastedText)
|
||||
|
||||
// Links in the paste land as `@url:` chips rather than a wall of URL text —
|
||||
// the same reference the "Add URL" dialog inserts, parsed in place so a link
|
||||
// mid-sentence keeps its position.
|
||||
recordUndoPoint()
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
@@ -416,6 +456,23 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Undo/redo before anything else — we own the stack (see useComposerUndo),
|
||||
// so these never reach Chromium's native history, which has no record of
|
||||
// the Range-based edits the rich editor makes.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Plain Backspace right after a directive chip: remove the chip + its
|
||||
// auto-inserted trailing space as one unit, so deleting a directive never
|
||||
// leaves an orphaned space. (Modified backspaces stay native.)
|
||||
@@ -424,7 +481,7 @@ export function ChatBar({
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
deleteChipBeforeCaret(event.currentTarget)
|
||||
withUndoPoint(() => deleteChipBeforeCaret(event.currentTarget))
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
@@ -434,7 +491,19 @@ export function ChatBar({
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
withUndoPoint(() => deleteSelectionInEditor(event.currentTarget))
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A typed link finished with a space chips like a pasted one — the space
|
||||
// itself rides along inside the insert.
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
@@ -777,6 +846,7 @@ export function ChatBar({
|
||||
contentEditable={!inputDisabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleEditorBeforeInput}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
|
||||
@@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
@@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' |
|
||||
slice.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
// Carry the editor's slot marker: composerPlainText appends a trailing "\n"
|
||||
// to any other block element, so a bare <div> made `beforeText` always look
|
||||
// like it ended in whitespace and the separating space was never inserted —
|
||||
// a chip dropped after a word came out glued to it (`review@file:...`).
|
||||
const container = document.createElement('div')
|
||||
container.dataset.slot = RICH_INPUT_SLOT
|
||||
container.appendChild(slice.cloneContents())
|
||||
|
||||
return composerPlainText(container)
|
||||
|
||||
@@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
insertComposerContentsAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
replaceBeforeCaret,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
@@ -70,16 +71,40 @@ describe('insertInlineRefsIntoEditor', () => {
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
|
||||
it('separates a chip from the word the caret sits after', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(document.createTextNode('review'))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('does not double the space when one is already there', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(document.createTextNode('review '))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertPlainTextAtCaret', () => {
|
||||
describe('insertComposerContentsAtCaret', () => {
|
||||
it('inserts multiline text as text nodes + br', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
|
||||
insertComposerContentsAtCaret(editor, 'one\ntwo\nthree')
|
||||
|
||||
expect(editor.querySelectorAll('br').length).toBe(2)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
|
||||
@@ -102,12 +127,76 @@ describe('insertPlainTextAtCaret', () => {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'cd')
|
||||
insertComposerContentsAtCaret(editor, 'cd')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('abcdef')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('lands directives in the text as chips', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now')
|
||||
|
||||
expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1)
|
||||
expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceBeforeCaret', () => {
|
||||
it('swaps the token before the caret and leaves the caret after the insert', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'see foo'
|
||||
document.body.append(editor)
|
||||
|
||||
const text = editor.firstChild!
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(text, 7)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' '))
|
||||
|
||||
expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ')
|
||||
expect(selection.getRangeAt(0).collapsed).toBe(true)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('leaves the editor alone when the caret has no room for the token', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'hi'
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(editor.firstChild!, 2)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(document.createTextNode('x'))
|
||||
|
||||
expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false)
|
||||
expect(composerPlainText(editor)).toBe('hi')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelectionInEditor', () => {
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
directiveIconElement,
|
||||
directiveIconSvg,
|
||||
formatRefValue,
|
||||
refChipLabel,
|
||||
slashChipClass,
|
||||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
import { sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
@@ -35,10 +35,6 @@ export function unquoteRef(raw: string) {
|
||||
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
|
||||
}
|
||||
|
||||
export function refLabel(id: string) {
|
||||
return id.split(/[\\/]/).filter(Boolean).pop() || id
|
||||
}
|
||||
|
||||
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
|
||||
export function quoteRefValue(value: string) {
|
||||
if (!value.includes('`')) {
|
||||
@@ -60,9 +56,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
const label = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
|
||||
const label = displayLabel || refChipLabel(kind, id)
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
|
||||
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
|
||||
}
|
||||
|
||||
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
|
||||
@@ -72,12 +68,13 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
||||
const label = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.title = id
|
||||
chip.dataset.refText = text
|
||||
chip.dataset.refId = id
|
||||
chip.dataset.refKind = kind
|
||||
chip.className = DIRECTIVE_CHIP_CLASS
|
||||
label.className = 'truncate'
|
||||
label.textContent = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
|
||||
label.textContent = displayLabel || refChipLabel(kind, id)
|
||||
chip.append(directiveIconElement(kind), label)
|
||||
|
||||
return chip
|
||||
@@ -147,14 +144,15 @@ function composerSelectionRange(editor: HTMLElement) {
|
||||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert plain text at the caret (replacing any selection). Pastes use this
|
||||
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
|
||||
* ~O(n²) on large multiline blobs. */
|
||||
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
/** Insert text at the caret (replacing any selection), with any `@kind:value`
|
||||
* directives in it landing as chips. Pastes use this instead of
|
||||
* `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large
|
||||
* multiline blobs. */
|
||||
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendTextWithBreaks(fragment, text)
|
||||
appendComposerContents(fragment, text)
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
@@ -175,6 +173,41 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Swap the `length` characters immediately before a collapsed caret for
|
||||
* `fragment`, leaving the caret after it. Returns whether it ran — a caret that
|
||||
* isn't inside a text node holding the whole token is left alone. */
|
||||
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit?.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { startContainer, startOffset } = hit.range
|
||||
|
||||
if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = document.createRange()
|
||||
const tail = fragment.lastChild
|
||||
|
||||
range.setStart(startContainer, startOffset - length)
|
||||
range.setEnd(startContainer, startOffset)
|
||||
range.deleteContents()
|
||||
range.insertNode(fragment)
|
||||
|
||||
if (tail) {
|
||||
range.setStartAfter(tail)
|
||||
}
|
||||
|
||||
range.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(range)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
|
||||
* the single trailing space we auto-insert after it, atomically — so removing a
|
||||
* directive never strands an orphaned space (the contenteditable-driven cleanup
|
||||
@@ -299,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) {
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** The caret's offset in `composerPlainText` coordinates, so it can be restored
|
||||
* after the editor is re-rendered from text (undo/redo). A chip counts as its
|
||||
* whole `@kind:value` text — the same units the snapshot measures. */
|
||||
export function caretOffsetInEditor(editor: HTMLElement): number {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return composerPlainText(editor).length
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
// The scratch container must carry the editor's slot marker: composerPlainText
|
||||
// appends a trailing "\n" to any other block element, which would inflate
|
||||
// every offset by one and land the restored caret a character late.
|
||||
const container = document.createElement('div')
|
||||
container.dataset.slot = RICH_INPUT_SLOT
|
||||
container.append(before.cloneContents())
|
||||
|
||||
return composerPlainText(container).length
|
||||
}
|
||||
|
||||
/** Place the caret `offset` characters into the editor, in the same
|
||||
* `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a
|
||||
* chip it would otherwise split, since a chip is a single atomic unit. */
|
||||
export function placeCaretAtOffset(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = offset
|
||||
|
||||
const walk = (node: Node): Range | null => {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const length = (child.textContent || '').length
|
||||
|
||||
if (remaining <= length) {
|
||||
const range = document.createRange()
|
||||
range.setStart(child, remaining)
|
||||
range.collapse(true)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
continue
|
||||
}
|
||||
|
||||
const el = child as HTMLElement
|
||||
|
||||
// Chips and <br> are atomic: consume their serialized length whole.
|
||||
if (el.dataset.refText || el.tagName === 'BR') {
|
||||
const length = el.dataset.refText ? el.dataset.refText.length : 1
|
||||
|
||||
if (remaining < length) {
|
||||
const range = document.createRange()
|
||||
range.setStartBefore(el)
|
||||
range.collapse(true)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const hit = walk(el)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const range = walk(editor)
|
||||
|
||||
if (range) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */
|
||||
function isBlankNode(node: ChildNode | null): boolean {
|
||||
if (!node) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $goalsBySession, type SessionGoal } from '@/store/goals'
|
||||
|
||||
import { ComposerStatusStack } from './index'
|
||||
|
||||
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
|
||||
const SID = 'sess-goal-1'
|
||||
|
||||
const goal = (status: SessionGoal['status'], title = 'ship the feature', detail?: string): SessionGoal => ({
|
||||
detail,
|
||||
status,
|
||||
title,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
|
||||
function renderStack(sessionId: null | string = SID) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerStatusStack queue={null} sessionId={sessionId} />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ComposerStatusStack goal indicator', () => {
|
||||
beforeEach(() => {
|
||||
$goalsBySession.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$goalsBySession.set({})
|
||||
})
|
||||
|
||||
it('renders nothing when the session has no goal', () => {
|
||||
const view = renderStack()
|
||||
|
||||
expect(view.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an active goal with its title', () => {
|
||||
$goalsBySession.set({ [SID]: goal('active') })
|
||||
|
||||
renderStack()
|
||||
|
||||
expect(screen.getByText('Goal active')).toBeTruthy()
|
||||
expect(screen.getByText('ship the feature')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('labels a paused goal as paused', () => {
|
||||
$goalsBySession.set({ [SID]: goal('paused') })
|
||||
|
||||
renderStack()
|
||||
|
||||
expect(screen.getByText('Goal paused')).toBeTruthy()
|
||||
expect(screen.getByText('ship the feature')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the continuation detail line for an active goal', () => {
|
||||
$goalsBySession.set({ [SID]: goal('active', 'ship it', 'Continuing toward goal (3/20)') })
|
||||
|
||||
renderStack()
|
||||
|
||||
expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scopes the indicator to the goal-owning session', () => {
|
||||
$goalsBySession.set({ 'other-session': goal('active') })
|
||||
|
||||
const view = renderStack()
|
||||
|
||||
expect(view.container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $billingBlock } from '@/store/billing-block'
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { refreshSessionGoal } from '@/store/goals'
|
||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
@@ -42,12 +44,25 @@ const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.
|
||||
// Real codicons per group (no sparkles): a checklist for todos, the agent glyph
|
||||
// for subagents, a background process glyph for background tasks.
|
||||
const GROUP_ICON: Record<StatusGroup['type'], string> = {
|
||||
goal: 'target',
|
||||
todo: 'checklist',
|
||||
subagent: 'agent',
|
||||
background: 'server-process'
|
||||
}
|
||||
|
||||
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
|
||||
if (group.type === 'goal') {
|
||||
const status = group.items[0]?.goalStatus
|
||||
|
||||
return status === 'paused'
|
||||
? s.goalPaused
|
||||
: status === 'waiting'
|
||||
? s.goalWaiting
|
||||
: status === 'done'
|
||||
? s.goalDone
|
||||
: s.goalActive
|
||||
}
|
||||
|
||||
if (group.type === 'todo') {
|
||||
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
|
||||
}
|
||||
@@ -70,23 +85,25 @@ interface ComposerStatusStackProps {
|
||||
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const previewsBySession = useStore($previewStatusBySession)
|
||||
// Subscribe to THIS session's slice only. Both maps churn on other
|
||||
// sessions' activity (subagent ticks, background polls, preview updates in
|
||||
// any tile); a whole-map `useStore` re-rendered every mounted stack — one
|
||||
// per open tile — on all of it. The per-key arrays are referentially stable
|
||||
// across unrelated writes, so the slice hook bails out unless OUR session's
|
||||
// items actually changed.
|
||||
const items = useSessionSlice($statusItemsBySession, sessionId)
|
||||
const previews = useSessionSlice($previewStatusBySession, sessionId)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
[itemsBySession, sessionId]
|
||||
)
|
||||
|
||||
const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
|
||||
const groups = useMemo(() => groupStatusItems(items), [items])
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream.
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
void refreshSessionGoal(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
@@ -154,7 +171,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
</Tip>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'}
|
||||
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
|
||||
@@ -25,6 +25,24 @@ const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon
|
||||
// Left slot: braille spinner while running, otherwise a small status dot
|
||||
// (green = done, red = failed) so the slot is always filled and rows align.
|
||||
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
|
||||
if (item.type === 'goal') {
|
||||
if (item.goalStatus === 'paused') {
|
||||
return <Codicon className="text-muted-foreground/60" name="debug-pause" size="0.8rem" />
|
||||
}
|
||||
|
||||
if (item.goalStatus === 'done') {
|
||||
return <Codicon className="text-emerald-500/80" name="pass-filled" size="0.8rem" />
|
||||
}
|
||||
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.85rem] leading-none text-emerald-500/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.todoStatus === 'pending') {
|
||||
return (
|
||||
<span
|
||||
@@ -137,6 +155,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
|
||||
{toolLabel(item.currentTool)}
|
||||
</span>
|
||||
)}
|
||||
{item.type === 'goal' && item.currentTool && (
|
||||
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
|
||||
{item.currentTool}
|
||||
</span>
|
||||
)}
|
||||
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
|
||||
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
|
||||
{s.exit(item.exitCode)}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
caretOffsetInEditor,
|
||||
composerPlainText,
|
||||
placeCaretAtOffset,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { createComposerUndoHistory, isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
|
||||
const key = (over: Partial<KeyboardEvent> = {}) =>
|
||||
({ altKey: false, ctrlKey: false, key: 'z', metaKey: false, shiftKey: false, ...over }) as KeyboardEvent
|
||||
|
||||
describe('undo/redo shortcut recognition', () => {
|
||||
it('claims Cmd+Z and Ctrl+Z as undo, but not with Shift or Alt', () => {
|
||||
expect(isUndoShortcut(key({ metaKey: true }))).toBe(true)
|
||||
expect(isUndoShortcut(key({ ctrlKey: true }))).toBe(true)
|
||||
expect(isUndoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key({ altKey: true, metaKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key({ key: 'a', metaKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key())).toBe(false)
|
||||
})
|
||||
|
||||
it('claims Cmd+Shift+Z everywhere and Ctrl+Y as redo', () => {
|
||||
expect(isRedoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ ctrlKey: true, shiftKey: true }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ ctrlKey: true, key: 'y' }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ metaKey: true }))).toBe(false)
|
||||
expect(isRedoShortcut(key({ altKey: true, ctrlKey: true, key: 'y' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('never treats one keystroke as both undo and redo', () => {
|
||||
const chords = [
|
||||
key({ metaKey: true }),
|
||||
key({ ctrlKey: true }),
|
||||
key({ metaKey: true, shiftKey: true }),
|
||||
key({ ctrlKey: true, shiftKey: true }),
|
||||
key({ ctrlKey: true, key: 'y' })
|
||||
]
|
||||
|
||||
for (const chord of chords) {
|
||||
expect(isUndoShortcut(chord) && isRedoShortcut(chord)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('composer undo history', () => {
|
||||
const snap = (text: string, caret = text.length) => ({ caret, text })
|
||||
|
||||
it('steps back through discrete edits, newest first', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap(''))
|
||||
history.record(snap('a'))
|
||||
history.record(snap('ab'))
|
||||
|
||||
expect(history.undo(snap('abc'))?.text).toBe('ab')
|
||||
expect(history.undo(snap('ab'))?.text).toBe('a')
|
||||
expect(history.undo(snap('a'))?.text).toBe('')
|
||||
expect(history.undo(snap(''))).toBeNull()
|
||||
})
|
||||
|
||||
it('redoes back to the state undo left, and stops at the newest', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('before'))
|
||||
|
||||
const undone = history.undo(snap('before + pasted'))
|
||||
|
||||
expect(undone?.text).toBe('before')
|
||||
expect(history.redo(snap('before'))?.text).toBe('before + pasted')
|
||||
expect(history.redo(snap('before + pasted'))).toBeNull()
|
||||
})
|
||||
|
||||
it('restores the caret along with the text', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record({ caret: 3, text: 'hello world' })
|
||||
|
||||
expect(history.undo({ caret: 0, text: 'changed' })).toEqual({ caret: 3, text: 'hello world' })
|
||||
})
|
||||
|
||||
it('collapses a run of typing into one entry, so undo steps back by a burst', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 50
|
||||
history.record(snap('h'), { coalesce: true })
|
||||
now += 50
|
||||
history.record(snap('he'), { coalesce: true })
|
||||
|
||||
// One burst → one entry, back to the state before the burst started.
|
||||
expect(history.undo(snap('hel'))?.text).toBe('')
|
||||
expect(history.undo(snap(''))).toBeNull()
|
||||
})
|
||||
|
||||
it('starts a new entry once the typing pause exceeds the coalesce window', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 5_000
|
||||
history.record(snap('word one'), { coalesce: true })
|
||||
|
||||
expect(history.undo(snap('word one two'))?.text).toBe('word one')
|
||||
expect(history.undo(snap('word one'))?.text).toBe('')
|
||||
})
|
||||
|
||||
it('does not coalesce a paste into the typing burst that preceded it', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 20
|
||||
// A paste is a discrete edit — no coalesce flag.
|
||||
history.record(snap('typed '))
|
||||
|
||||
expect(history.undo(snap('typed PASTED'))?.text).toBe('typed ')
|
||||
expect(history.undo(snap('typed '))?.text).toBe('')
|
||||
})
|
||||
|
||||
it('drops the redo stack once a new edit lands', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('one'))
|
||||
history.undo(snap('one two'))
|
||||
history.record(snap('one'))
|
||||
|
||||
expect(history.redo(snap('one three'))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a no-op edit so undo never looks stuck for a press', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('same'))
|
||||
history.record(snap('same'))
|
||||
|
||||
expect(history.undo(snap('same'))?.text).toBe('same')
|
||||
expect(history.undo(snap('same'))).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds the stack, discarding the oldest entries', () => {
|
||||
const history = createComposerUndoHistory(3)
|
||||
|
||||
for (const text of ['a', 'b', 'c', 'd', 'e']) {
|
||||
history.record(snap(text))
|
||||
}
|
||||
|
||||
expect(history.undo(snap('f'))?.text).toBe('e')
|
||||
expect(history.undo(snap('e'))?.text).toBe('d')
|
||||
expect(history.undo(snap('d'))?.text).toBe('c')
|
||||
expect(history.undo(snap('c'))).toBeNull()
|
||||
})
|
||||
|
||||
it('reset clears both directions', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('a'))
|
||||
history.undo(snap('ab'))
|
||||
history.reset()
|
||||
|
||||
expect(history.undo(snap('ab'))).toBeNull()
|
||||
expect(history.redo(snap('ab'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('caret offsets in composerPlainText coordinates', () => {
|
||||
let editor: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
})
|
||||
|
||||
const caretAfter = (node: Node, offset: number) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
it('round-trips a caret in plain text', () => {
|
||||
renderComposerContents(editor, 'hello world')
|
||||
placeCaretAtOffset(editor, 5)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
})
|
||||
|
||||
it('counts a chip as its whole @kind:value text', () => {
|
||||
editor.append(
|
||||
document.createTextNode('see '),
|
||||
refChipElement('file', '`src/a.ts`'),
|
||||
document.createTextNode(' now')
|
||||
)
|
||||
|
||||
const chipText = '@file:`src/a.ts`'
|
||||
// Caret at the very end = everything before it.
|
||||
placeCaretAtOffset(editor, composerPlainText(editor).length)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(4 + chipText.length + 4)
|
||||
})
|
||||
|
||||
it('lands the caret before a chip rather than splitting it', () => {
|
||||
editor.append(document.createTextNode('a '), refChipElement('file', '`x.ts`'))
|
||||
|
||||
// An offset that falls midway through the chip's serialized text.
|
||||
placeCaretAtOffset(editor, 2 + 3)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts a line break as one character', () => {
|
||||
renderComposerContents(editor, 'one\ntwo')
|
||||
placeCaretAtOffset(editor, 5)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('clamps past-the-end offsets to the end instead of throwing', () => {
|
||||
renderComposerContents(editor, 'short')
|
||||
placeCaretAtOffset(editor, 999)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
})
|
||||
|
||||
it('reports the end when the selection is outside the editor', () => {
|
||||
renderComposerContents(editor, 'hello')
|
||||
|
||||
const outside = document.createElement('div')
|
||||
outside.textContent = 'elsewhere'
|
||||
document.body.append(outside)
|
||||
caretAfter(outside.firstChild!, 3)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
|
||||
outside.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The composer's own undo stack.
|
||||
*
|
||||
* The rich editor mutates its DOM through `Range` rather than the browser's
|
||||
* editing commands — `execCommand('insertText')` is ~O(n²) on large multiline
|
||||
* blobs and froze the composer for seconds on a big paste (#45812). The cost of
|
||||
* that bypass is that those mutations never reach Chromium's undo stack, so
|
||||
* ⌘Z skipped straight past a paste and undid whatever came *before* it, leaving
|
||||
* the pasted text stranded.
|
||||
*
|
||||
* Owning the whole stack is the only coherent fix: a half-owned one interleaves
|
||||
* our snapshots with Chromium's own typing entries and undoes them out of order.
|
||||
* So every composer edit — typed or programmatic — records here, and the editor
|
||||
* intercepts ⌘Z / ⌘⇧Z instead of letting the native command run.
|
||||
*
|
||||
* Snapshots are plain text + a caret offset, not DOM: the editor already
|
||||
* round-trips losslessly through `composerPlainText`/`renderComposerContents`,
|
||||
* so text is the smallest thing that fully restores a state.
|
||||
*/
|
||||
|
||||
export interface ComposerSnapshot {
|
||||
caret: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Consecutive typing inside this window collapses into one undo entry, so ⌘Z
|
||||
* steps back by a burst the way a native editor does — not one character. */
|
||||
const COALESCE_WINDOW_MS = 600
|
||||
|
||||
const DEFAULT_LIMIT = 200
|
||||
|
||||
export interface ComposerUndoHistory {
|
||||
/** Drop all history and start over from `snapshot`'s state (session swap). */
|
||||
reset: () => void
|
||||
/** Redo one step. `current` is the live state, banked for a subsequent undo. */
|
||||
redo: (current: ComposerSnapshot) => ComposerSnapshot | null
|
||||
/** Bank the state that existed *before* an edit. `coalesce` merges this into
|
||||
* the previous entry when it lands inside the typing window. */
|
||||
record: (previous: ComposerSnapshot, options?: { coalesce?: boolean }) => void
|
||||
/** Undo one step. `current` is the live state, banked for a subsequent redo. */
|
||||
undo: (current: ComposerSnapshot) => ComposerSnapshot | null
|
||||
}
|
||||
|
||||
export function createComposerUndoHistory(
|
||||
limit = DEFAULT_LIMIT,
|
||||
now: () => number = () => Date.now()
|
||||
): ComposerUndoHistory {
|
||||
let past: ComposerSnapshot[] = []
|
||||
let future: ComposerSnapshot[] = []
|
||||
let lastRecordedAt = 0
|
||||
let lastWasCoalescable = false
|
||||
|
||||
const record: ComposerUndoHistory['record'] = (previous, options) => {
|
||||
const coalesce = options?.coalesce ?? false
|
||||
const at = now()
|
||||
const merges = coalesce && lastWasCoalescable && past.length > 0 && at - lastRecordedAt < COALESCE_WINDOW_MS
|
||||
|
||||
lastRecordedAt = at
|
||||
lastWasCoalescable = coalesce
|
||||
// A fresh edit invalidates anything the user had redone past.
|
||||
future = []
|
||||
|
||||
// Merging keeps the OLDER snapshot — the entry already holds the state from
|
||||
// the start of the burst, which is what ⌘Z should step back to.
|
||||
if (merges) {
|
||||
return
|
||||
}
|
||||
|
||||
// A no-op edit (same text) would make ⌘Z look broken for one press.
|
||||
if (past[past.length - 1]?.text === previous.text) {
|
||||
return
|
||||
}
|
||||
|
||||
past.push(previous)
|
||||
|
||||
if (past.length > limit) {
|
||||
past = past.slice(past.length - limit)
|
||||
}
|
||||
}
|
||||
|
||||
const step = (from: ComposerSnapshot[], to: ComposerSnapshot[], current: ComposerSnapshot) => {
|
||||
const next = from.pop()
|
||||
|
||||
if (!next) {
|
||||
return null
|
||||
}
|
||||
|
||||
to.push(current)
|
||||
// Any traversal ends the typing burst, so the next keystroke opens a new entry.
|
||||
lastWasCoalescable = false
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
redo: current => step(future, past, current),
|
||||
reset: () => {
|
||||
past = []
|
||||
future = []
|
||||
lastRecordedAt = 0
|
||||
lastWasCoalescable = false
|
||||
},
|
||||
undo: current => step(past, future, current)
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the keystroke that means "undo" (⌘Z / Ctrl+Z, without Shift). */
|
||||
export function isUndoShortcut(event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>) {
|
||||
return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z'
|
||||
}
|
||||
|
||||
/** True for "redo" — ⌘⇧Z everywhere, plus Ctrl+Y on Windows/Linux. */
|
||||
export function isRedoShortcut(event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>) {
|
||||
if (event.altKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && key === 'z') {
|
||||
return true
|
||||
}
|
||||
|
||||
return event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'y'
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
|
||||
|
||||
/** An editor holding `text` with a collapsed caret at `caret`, plus the space
|
||||
* keydown the composer would hand `chipTypedUrlOnSpace`. */
|
||||
const spaceOn = (text: string, caret: number) => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = text
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(editor.firstChild!, caret)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent<HTMLDivElement> }
|
||||
}
|
||||
|
||||
describe('linkifyUrls', () => {
|
||||
it('rewrites a bare link as a url directive', () => {
|
||||
expect(linkifyUrls('https://example.dev/a/b')).toBe('@url:`https://example.dev/a/b`')
|
||||
})
|
||||
|
||||
it('keeps the link in place mid-sentence and leaves its punctuation behind', () => {
|
||||
expect(linkifyUrls('read https://example.dev/a. then stop')).toBe('read @url:`https://example.dev/a`. then stop')
|
||||
})
|
||||
|
||||
it('keeps balanced parens but drops the one that closed the sentence', () => {
|
||||
expect(linkifyUrls('(see https://en.wikipedia.org/wiki/A_(b))')).toBe(
|
||||
'(see @url:`https://en.wikipedia.org/wiki/A_(b)`)'
|
||||
)
|
||||
})
|
||||
|
||||
it('rewrites every link in a multi-link paste', () => {
|
||||
expect(linkifyUrls('http://a.dev and https://b.dev')).toBe('@url:`http://a.dev` and @url:`https://b.dev`')
|
||||
})
|
||||
|
||||
it('leaves a link that is already a directive alone', () => {
|
||||
expect(linkifyUrls('@url:`https://example.dev`')).toBe('@url:`https://example.dev`')
|
||||
})
|
||||
|
||||
it('leaves text without a scheme alone', () => {
|
||||
expect(linkifyUrls('example.dev/a and src/foo.ts')).toBe('example.dev/a and src/foo.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chipTypedUrlOnSpace', () => {
|
||||
it('chips a link typed right before the caret and adds the space', () => {
|
||||
const { editor, event } = spaceOn('see https://example.dev/a', 25)
|
||||
|
||||
expect(chipTypedUrlOnSpace(event)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('see @url:`https://example.dev/a` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('keeps sentence punctuation outside the chip', () => {
|
||||
const { editor, event } = spaceOn('https://example.dev.', 20)
|
||||
|
||||
expect(chipTypedUrlOnSpace(event)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('@url:`https://example.dev`. ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('ignores a caret that is not sitting on a link', () => {
|
||||
const { editor, event } = spaceOn('https://example.dev is nice', 27)
|
||||
|
||||
expect(chipTypedUrlOnSpace(event)).toBe(false)
|
||||
expect(composerPlainText(editor)).toBe('https://example.dev is nice')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('ignores a scheme with no host yet', () => {
|
||||
const { editor, event } = spaceOn('https://', 8)
|
||||
|
||||
expect(chipTypedUrlOnSpace(event)).toBe(false)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('leaves a modified space alone', () => {
|
||||
const { editor, event } = spaceOn('https://example.dev', 19)
|
||||
|
||||
expect(chipTypedUrlOnSpace({ ...event, altKey: true })).toBe(false)
|
||||
expect(composerPlainText(editor)).toBe('https://example.dev')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Bare-link recognition for the composer. A link the user pastes or types is the
|
||||
* same thing the "+ → Add URL" dialog inserts, so it becomes an `@url:`
|
||||
* directive: a chip that truncates instead of a wall of URL text, and a
|
||||
* reference the gateway resolves.
|
||||
*/
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor'
|
||||
import { textBeforeCaret } from './text-utils'
|
||||
|
||||
// An explicit scheme only — `example.com` bare is too easy to hit by accident
|
||||
// (a filename, a version, a sentence). Brackets and quotes fence a URL in prose;
|
||||
// parens don't, so they stay in and an unbalanced tail is trimmed below.
|
||||
const URL_RE = /https?:\/\/[^\s<>[\]{}"'`]+/gi
|
||||
const TYPED_URL_RE = /(?:^|\s)(https?:\/\/[^\s<>[\]{}"'`]+)$/i
|
||||
|
||||
/** A URL at the end of a sentence carries the punctuation that ended it. */
|
||||
function splitUrlTail(raw: string) {
|
||||
let url = raw.replace(/[,.;:!?]+$/, '')
|
||||
|
||||
while (url.endsWith(')') && url.split(')').length > url.split('(').length) {
|
||||
url = url.slice(0, -1)
|
||||
}
|
||||
|
||||
return { trailing: raw.slice(url.length), url }
|
||||
}
|
||||
|
||||
/** A URL needs a host past the scheme to be worth chipping. */
|
||||
const hasHost = (url: string) => /^https?:\/\/[^/\s]/i.test(url)
|
||||
|
||||
/** Rewrite bare links in `text` as `@url:` directives, leaving links that are
|
||||
* already part of a directive alone. Returns `text` unchanged when there are
|
||||
* none. */
|
||||
export function linkifyUrls(text: string) {
|
||||
REF_RE.lastIndex = 0
|
||||
|
||||
const fenced = Array.from(text.matchAll(REF_RE)).map(match => {
|
||||
const start = match.index ?? 0
|
||||
|
||||
return { end: start + match[0].length, start }
|
||||
})
|
||||
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
const start = match.index ?? 0
|
||||
const { url } = splitUrlTail(match[0])
|
||||
|
||||
if (!hasHost(url) || fenced.some(span => start >= span.start && start < span.end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out += `${text.slice(cursor, start)}@url:${quoteRefValue(url)}`
|
||||
cursor = start + url.length
|
||||
}
|
||||
|
||||
return out + text.slice(cursor)
|
||||
}
|
||||
|
||||
/** A plain space finishing a typed link commits it as a chip (followed by
|
||||
* whatever punctuation ended it, then the space). Returns whether it ran, so a
|
||||
* keydown handler can fall through on anything else. */
|
||||
export function chipTypedUrlOnSpace(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Runs on every space, so bail on the cheap native read before paying for the
|
||||
// caret range walk (same guard shape as the trigger detector).
|
||||
if (!editor.textContent?.includes('://')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const match = before ? TYPED_URL_RE.exec(before) : null
|
||||
const token = match?.[1]
|
||||
|
||||
if (!token) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { trailing, url } = splitUrlTail(token)
|
||||
|
||||
if (!hasHost(url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
fragment.append(refChipElement('url', quoteRefValue(url)))
|
||||
|
||||
if (trailing) {
|
||||
fragment.append(document.createTextNode(trailing))
|
||||
}
|
||||
|
||||
fragment.append(document.createTextNode(' '))
|
||||
|
||||
return replaceBeforeCaret(editor, token.length, fragment)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useModelControls } from '@/app/session/hooks/use-model-controls'
|
||||
@@ -424,6 +424,40 @@ export function stackSessionTilesIntoMain(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The three scalars the tab menu actually renders, derived from the stored
|
||||
* row. Subscribing to `$sessions` + `$projectTree` wholesale re-rendered
|
||||
* every tab's menu wrapper on ANY session-list or tree churn (polls, title
|
||||
* updates in other sessions) — for a context menu that's almost never open.
|
||||
* Same class as the TreeGroup fix (#72245): derive narrowly, bail out unless
|
||||
* the derived values change. */
|
||||
function useTileMenuRow(storedSessionId: string): { pinId: string; profile?: string; title: string } {
|
||||
const cache = useRef<{ key: string; value: { pinId: string; profile?: string; title: string } } | null>(null)
|
||||
|
||||
const subscribe = useCallback((onChange: () => void) => {
|
||||
const offSessions = $sessions.listen(onChange)
|
||||
const offTree = $projectTree.listen(onChange)
|
||||
|
||||
return () => {
|
||||
offSessions()
|
||||
offTree()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useSyncExternalStore(subscribe, () => {
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const title = tileTitle(storedSessionId)
|
||||
const profile = stored?.profile
|
||||
const key = `${pinId}\u0000${title}\u0000${profile ?? ''}`
|
||||
|
||||
if (cache.current?.key !== key) {
|
||||
cache.current = { key, value: { pinId, profile, title } }
|
||||
}
|
||||
|
||||
return cache.current.value
|
||||
})
|
||||
}
|
||||
|
||||
/** A session TAB's context menu: the full session verb set (pin, copy id, new
|
||||
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
|
||||
* gets, targeted through the tile delegate (whose verbs are generic over
|
||||
@@ -445,13 +479,8 @@ export function SessionTabMenu({
|
||||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
|
||||
// (which spans both sources), so the values themselves are unused here.
|
||||
useStore($sessions)
|
||||
useStore($projectTree)
|
||||
const { pinId, profile, title } = useTileMenuRow(storedSessionId)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
return (
|
||||
@@ -464,11 +493,11 @@ export function SessionTabMenu({
|
||||
onHideTabBar={onHideTabBar}
|
||||
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
||||
pinned={pinned}
|
||||
profile={stored?.profile}
|
||||
profile={profile}
|
||||
sessionId={storedSessionId}
|
||||
surface="tab"
|
||||
tabPaneId={tabPaneId}
|
||||
title={tileTitle(storedSessionId)}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</SessionContextMenu>
|
||||
|
||||
@@ -8,10 +8,6 @@ import { cn } from '@/lib/utils'
|
||||
// sections and the project/workspace tree, so it lives outside either to keep
|
||||
// imports one-directional (no index <-> projects cycle).
|
||||
|
||||
/** `loaded/total` when there's more on the server, else just the loaded count. */
|
||||
export const countLabel = (loaded: number, total: number): string =>
|
||||
total > loaded ? `${loaded}/${total}` : String(loaded)
|
||||
|
||||
/** The muted count chip next to a section/workspace label. */
|
||||
export function SidebarCount({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
|
||||
@@ -89,10 +89,9 @@ import {
|
||||
$messagingPlatformTotals,
|
||||
$messagingSessions,
|
||||
$messagingTruncated,
|
||||
$sessionProfileTotals,
|
||||
$sessionProfilesTruncated,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
sessionPinId,
|
||||
setCurrentCwd
|
||||
} from '@/store/session'
|
||||
@@ -108,7 +107,6 @@ import {
|
||||
} from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { countLabel } from './chrome'
|
||||
import { SidebarCronJobsSection } from './cron-jobs-section'
|
||||
import { SidebarLoadMoreRow } from './load-more-row'
|
||||
import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order'
|
||||
@@ -300,8 +298,7 @@ export function ChatSidebar({
|
||||
const messagingPlatformTotals = useStore($messagingPlatformTotals)
|
||||
const messagingTruncated = useStore($messagingTruncated)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||
const sessionProfilesTruncated = useStore($sessionProfilesTruncated)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const profiles = useStore($profiles)
|
||||
const profileScope = useStore($profileScope)
|
||||
@@ -937,7 +934,7 @@ export function ChatSidebar({
|
||||
...group,
|
||||
loadingMore: Boolean(profileLoadMorePending[group.id]),
|
||||
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
|
||||
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
|
||||
hasMore: Boolean(sessionProfilesTruncated[group.id])
|
||||
}))
|
||||
// default (root) first, then the rest alphabetically.
|
||||
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
|
||||
@@ -948,7 +945,7 @@ export function ChatSidebar({
|
||||
loadMoreForProfileGroup,
|
||||
onLoadMoreProfileSessions,
|
||||
profileLoadMorePending,
|
||||
sessionProfileTotals
|
||||
sessionProfilesTruncated
|
||||
])
|
||||
|
||||
// The flat Sessions list always shows ALL recent sessions; Projects is a
|
||||
@@ -956,22 +953,16 @@ export function ChatSidebar({
|
||||
const displayAgentSessions = agentSessions
|
||||
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
// keeps "Load more" stuck on while you browse a small one (the aggregator's
|
||||
// total sums every profile). Per-profile totals come from the aggregator
|
||||
// (children excluded); fall back to the global total / loaded count.
|
||||
// unified set; scoped to one profile it tracks that profile's own truncation
|
||||
// flag — otherwise a huge default profile keeps "Load more" stuck on while
|
||||
// you browse a small one. The backend reports whether its page was capped
|
||||
// rather than an exact count, so no COUNT(*) runs per refresh.
|
||||
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
|
||||
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
|
||||
|
||||
const knownSessionTotal = Math.max(
|
||||
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
|
||||
loadedSessionCount
|
||||
)
|
||||
const hasMoreSessions = showAllProfiles
|
||||
? Object.values(sessionProfilesTruncated).some(Boolean)
|
||||
: Boolean(sessionProfilesTruncated[profileScope])
|
||||
|
||||
const hasMoreSessions = knownSessionTotal > loadedSessionCount
|
||||
|
||||
const recentsMeta = countLabel(displayAgentSessions.length, knownSessionTotal)
|
||||
const displayRecentsCountRef = useRef(0)
|
||||
const loadedRecentsCountRef = useRef(0)
|
||||
displayRecentsCountRef.current = displayAgentSessions.length
|
||||
@@ -1390,9 +1381,7 @@ export function ChatSidebar({
|
||||
reposScanning && !projectsSkeletonVisible ? (
|
||||
<GlyphSpinner ariaLabel={s.loading} className="text-[0.6875rem] text-(--ui-text-quaternary)" />
|
||||
) : undefined
|
||||
) : (
|
||||
recentsMeta
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
liveSessions={inProject ? agentSessions : undefined}
|
||||
onArchiveSession={onArchiveSession}
|
||||
@@ -1458,7 +1447,7 @@ export function ChatSidebar({
|
||||
platformName={group.label}
|
||||
/>
|
||||
}
|
||||
labelMeta={countLabel(group.sessions.length, group.total)}
|
||||
labelMeta={String(shownSessions.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { notifyError } from '@/store/notifications'
|
||||
import { newSessionInProfile } from '@/store/profile'
|
||||
import { switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { countLabel, SidebarRowStack } from '../chrome'
|
||||
import { SidebarRowStack } from '../chrome'
|
||||
import { SidebarLoadMoreRow } from '../load-more-row'
|
||||
|
||||
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
|
||||
@@ -37,11 +37,13 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
|
||||
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
|
||||
|
||||
const loadedCount = group.sessions.length
|
||||
// Profile groups know their on-disk total (children excluded); workspace
|
||||
// groups only ever page within what's already loaded.
|
||||
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
|
||||
const visibleSessions = group.sessions.slice(0, visibleCount)
|
||||
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
|
||||
// Profile groups can have more rows on the server than are loaded — the
|
||||
// aggregator reports `hasMore` so the lane can offer another page without
|
||||
// pricing an exact total per refresh. Workspace groups only ever page within
|
||||
// what's already loaded.
|
||||
const hiddenLoaded = Math.max(0, loadedCount - visibleSessions.length)
|
||||
const hiddenCount = isProfileGroup && group.hasMore ? Math.max(hiddenLoaded, 1) : hiddenLoaded
|
||||
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
|
||||
|
||||
// Leading glyph: profile color dot, a home mark for the repo's primary
|
||||
@@ -63,7 +65,7 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
|
||||
|
||||
setVisibleCount(target)
|
||||
|
||||
if (target > loadedCount && loadedCount < totalCount) {
|
||||
if (target > loadedCount && group.hasMore) {
|
||||
group.onLoadMore?.()
|
||||
}
|
||||
}
|
||||
@@ -119,7 +121,7 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
|
||||
</div>
|
||||
)
|
||||
}
|
||||
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
|
||||
count={visibleSessions.length}
|
||||
icon={leadingIcon}
|
||||
label={group.label}
|
||||
onToggle={toggleOpen}
|
||||
|
||||
@@ -30,7 +30,10 @@ export interface SidebarSessionGroup {
|
||||
mode?: 'profile' | 'source' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
sourceId?: string
|
||||
totalCount?: number
|
||||
/** Profile lanes only: the backend page was capped, so more rows exist on
|
||||
* disk than were loaded. Replaces the old exact `totalCount`, which cost a
|
||||
* COUNT(*) per profile on every sidebar refresh just to render `n/total`. */
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $workingSessionIds, clearAllSessionStates } from '@/store/session-states'
|
||||
|
||||
import { rehydrateLiveSessionStatuses } from './use-background-sync'
|
||||
|
||||
/**
|
||||
* `session.active_list` is the authoritative snapshot of what is RUNNING in the
|
||||
* polled gateway process. A session that finished while Desktop was looking
|
||||
* elsewhere — or whose runtime id was recycled by a backend respawn — simply
|
||||
* stops appearing in the response. Absence is therefore a completion signal,
|
||||
* not "no news": if nothing reaps it, the row spins forever and the
|
||||
* busy→idle edge that paints the green "your turn" dot never fires.
|
||||
*/
|
||||
describe('rehydrateLiveSessionStatuses — reaping vanished runtimes', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
it('clears a working session that disappears from the live snapshot', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-a', session_key: 'stored-a', status: 'working' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['stored-a'])
|
||||
|
||||
// The turn finished and the gateway reaped the session between polls.
|
||||
rehydrateLiveSessionStatuses({ sessions: [] })
|
||||
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('fires the unread "your turn" marker for a vanished background session', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-b', session_key: 'stored-b', status: 'working' }]
|
||||
})
|
||||
|
||||
rehydrateLiveSessionStatuses({ sessions: [] })
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['stored-b'])
|
||||
})
|
||||
|
||||
it('clears a blocked session that disappears from the live snapshot', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-c', session_key: 'stored-c', status: 'waiting' }]
|
||||
})
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual(['stored-c'])
|
||||
|
||||
rehydrateLiveSessionStatuses({ sessions: [] })
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves runtimes this poll never seeded alone', () => {
|
||||
// A background PROFILE's sessions are served by a different gateway and
|
||||
// never appear in this profile's active_list. Reaping them would dark out
|
||||
// every other profile's running rows.
|
||||
rehydrateLiveSessionStatuses(
|
||||
{ sessions: [{ id: 'runtime-other', session_key: 'stored-other', status: 'working' }] },
|
||||
Date.now(),
|
||||
'other'
|
||||
)
|
||||
|
||||
rehydrateLiveSessionStatuses({ sessions: [] }, Date.now(), 'default')
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['stored-other'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $workingSessionIds, clearAllSessionStates } from '@/store/session-states'
|
||||
|
||||
import { rehydrateLiveSessionStatuses, resetLiveRuntimeTracking } from './use-background-sync'
|
||||
|
||||
/**
|
||||
* (C) The sidebar spinner is driven by `$workingSessionIds`, which is keyed by
|
||||
* STORED session id. A turn that STARTS while Desktop isn't receiving stream
|
||||
* events — a background profile, a degraded remote socket, a session opened on
|
||||
* another surface — is only ever learned about through the `session.active_list`
|
||||
* poll. If that poll can't seed a row the renderer has never seen, the thread
|
||||
* name never gets its arc even though the backend is plainly working.
|
||||
*/
|
||||
describe('rehydrateLiveSessionStatuses — seeding a turn the renderer never saw start', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
resetLiveRuntimeTracking()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
resetLiveRuntimeTracking()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
it('shows the spinner for a turn that started with no stream events', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toContain('stored-cold')
|
||||
})
|
||||
|
||||
it('keeps the spinner across polls while the turn is still running', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
|
||||
})
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toContain('stored-cold')
|
||||
})
|
||||
|
||||
it('shows the spinner when a runtime id is recycled onto a new stored session', () => {
|
||||
// A respawned backend can mint the same runtime id for a different stored
|
||||
// session. The row for the NEW stored id must light up, not the stale one.
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-1', session_key: 'stored-old', status: 'working' }]
|
||||
})
|
||||
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-1', session_key: 'stored-new', status: 'working' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toContain('stored-new')
|
||||
expect($workingSessionIds.get()).not.toContain('stored-old')
|
||||
})
|
||||
|
||||
it('leaves a starting session idle — the agent build is not proof of a turn', () => {
|
||||
// `starting` = `agent_build_started` without `agent_ready`. _start_agent_build
|
||||
// runs on the first prompt OR any incidental RPC that needs the agent, so it
|
||||
// is not proof of a turn — lighting the spinner here would fire on merely
|
||||
// opening a session. A real turn arrives as `working`.
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-boot', session_key: 'stored-boot', status: 'starting' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).not.toContain('stored-boot')
|
||||
})
|
||||
})
|
||||
@@ -37,11 +37,30 @@ interface LiveSessionStatusResponse {
|
||||
sessions?: LiveSessionStatusItem[]
|
||||
}
|
||||
|
||||
// Runtime ids this poll has seen live, per gateway profile. A profile only
|
||||
// ever reaps what its OWN snapshot previously reported: background profiles are
|
||||
// served by different gateways and never appear in this profile's active_list,
|
||||
// so an unscoped reap would dark out every other profile's running rows.
|
||||
const liveRuntimeIdsByProfile = new Map<string, Set<string>>()
|
||||
|
||||
/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
|
||||
* normally own these states, but events emitted while Desktop was disconnected
|
||||
* cannot be replayed. `session.active_list` is the authoritative in-memory
|
||||
* snapshot and does not resume, focus, or otherwise mutate a chat. */
|
||||
export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse, nowMs = Date.now()): void {
|
||||
* snapshot and does not resume, focus, or otherwise mutate a chat.
|
||||
*
|
||||
* The snapshot is authoritative about ABSENCE too. A turn that ends while the
|
||||
* websocket is degraded — a remote gateway over a flaky link, a reconnect, a
|
||||
* profile swap — drops out of `_sessions` without Desktop ever seeing the
|
||||
* `running: false` edge, so the row keeps spinning and the busy→idle transition
|
||||
* that paints the green "your turn" dot never fires. Reaping runtimes that
|
||||
* vanish between polls restores both. */
|
||||
export function rehydrateLiveSessionStatuses(
|
||||
response: LiveSessionStatusResponse,
|
||||
nowMs = Date.now(),
|
||||
profileKey = 'default'
|
||||
): void {
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const session of response.sessions ?? []) {
|
||||
const runtimeSessionId = session.id?.trim()
|
||||
const storedSessionId = session.session_key?.trim()
|
||||
@@ -52,6 +71,8 @@ export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(runtimeSessionId)
|
||||
|
||||
const existing = $sessionStates.get()[runtimeSessionId]
|
||||
|
||||
// Avoid re-arming the watchdog on every poll. Publish only when the
|
||||
@@ -87,6 +108,44 @@ export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse
|
||||
|
||||
setSessionStalled(storedSessionId, isQuiet)
|
||||
}
|
||||
|
||||
// A runtime this profile's snapshot reported live LAST poll but not this one
|
||||
// has ended: the gateway reaps a session out of `_sessions` when its turn
|
||||
// completes and its transport goes away. Settle it through the normal publish
|
||||
// path so the busy→idle transition fires — that edge is what clears the
|
||||
// spinner AND marks the row unread ("your turn"). Only ids this profile
|
||||
// previously saw are eligible, so another profile's live rows are untouched.
|
||||
const previouslyLive = liveRuntimeIdsByProfile.get(profileKey)
|
||||
|
||||
if (previouslyLive) {
|
||||
for (const runtimeSessionId of previouslyLive) {
|
||||
if (seen.has(runtimeSessionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = $sessionStates.get()[runtimeSessionId]
|
||||
|
||||
if (existing?.busy || existing?.needsInput) {
|
||||
publishSessionState(runtimeSessionId, {
|
||||
...existing,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false,
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
liveRuntimeIdsByProfile.set(profileKey, seen)
|
||||
}
|
||||
|
||||
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
|
||||
* drops the session states these ids point at, so a carried-over set would
|
||||
* only reap runtimes that no longer exist. */
|
||||
export function resetLiveRuntimeTracking(): void {
|
||||
liveRuntimeIdsByProfile.clear()
|
||||
}
|
||||
|
||||
interface BackgroundSyncParams {
|
||||
@@ -191,7 +250,7 @@ export function useBackgroundSync({
|
||||
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
|
||||
|
||||
if (!cancelled) {
|
||||
rehydrateLiveSessionStatuses(response)
|
||||
rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile)
|
||||
}
|
||||
} catch {
|
||||
// Older gateways may not expose session.active_list. Live stream events
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import {
|
||||
initQuickEntryBridge,
|
||||
QUICK_TARGET_CURRENT,
|
||||
QUICK_TARGET_NEW,
|
||||
type QuickEntrySessionOption,
|
||||
setQuickEntrySubmitHandler
|
||||
} from '@/store/quick-entry'
|
||||
import { $gatewayState, $sessions } from '@/store/session'
|
||||
import { sessionTileDelegate } from '@/store/session-states'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
interface QuickEntryBridgeParams {
|
||||
startFreshSessionDraft: () => void
|
||||
submitText: (text: string) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
// The picker is a capture aid, not a session browser — a handful of recent
|
||||
// rows is the whole point.
|
||||
const QUICK_ENTRY_SESSION_OPTIONS = 5
|
||||
|
||||
function sessionOptions(): QuickEntrySessionOption[] {
|
||||
return $sessions
|
||||
.get()
|
||||
.filter(session => !session.archived)
|
||||
.slice(0, QUICK_ENTRY_SESSION_OPTIONS)
|
||||
.map(session => ({
|
||||
id: session.id,
|
||||
title: session.title?.trim() || session.preview?.trim() || session.id
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the global-hotkey Quick Entry window back into the app, both ways:
|
||||
*
|
||||
* - **Inbound:** text captured there is routed by target and submitted through
|
||||
* THIS window's normal prompt machinery — current chat rides `submitText`, a
|
||||
* picked stored session rides the session-tile delegate (resume + submit,
|
||||
* background, without touching the primary view — the same path tiled
|
||||
* sessions use), and "new session" is a fresh draft + submit, exactly what
|
||||
* clicking New Chat and typing does. One submit pipeline, no bespoke RPC.
|
||||
* - **Outbound:** gateway connection state + the recent-session list are pushed
|
||||
* to the quick window (via main, which caches the latest push), so its input
|
||||
* disables with a reconnect hint whenever the backend is unreachable.
|
||||
*
|
||||
* Handlers register ONCE through refs tracking the latest callbacks —
|
||||
* re-registering on identity churn leaves a nulled-handler window that can drop
|
||||
* a submit (the same bug shape use-pet-bridge guards). Primary window only: a
|
||||
* secondary session window must not also claim the global capture channel, or
|
||||
* one keystroke would send N prompts.
|
||||
*/
|
||||
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
|
||||
const submitTextRef = useRef(submitText)
|
||||
submitTextRef.current = submitText
|
||||
const startFreshRef = useRef(startFreshSessionDraft)
|
||||
startFreshRef.current = startFreshSessionDraft
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
setQuickEntrySubmitHandler(({ target, text }) => {
|
||||
if (target === QUICK_TARGET_NEW) {
|
||||
// Same as the user clicking New Chat and typing: fresh draft, then the
|
||||
// normal submit creates the backend session.
|
||||
startFreshRef.current()
|
||||
void submitTextRef.current(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (target !== QUICK_TARGET_CURRENT) {
|
||||
// A picked stored session: resume + submit in the background through
|
||||
// the session-tile delegate so the primary view stays where it is.
|
||||
const delegate = sessionTileDelegate()
|
||||
|
||||
if (delegate) {
|
||||
void delegate
|
||||
.resumeTile(target)
|
||||
.then(runtimeId => delegate.submitToSession(runtimeId, text))
|
||||
// A dead/undeliverable target must not swallow the prompt.
|
||||
.catch(() => void submitTextRef.current(text))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
void submitTextRef.current(text)
|
||||
})
|
||||
|
||||
const dispose = initQuickEntryBridge()
|
||||
|
||||
return () => {
|
||||
setQuickEntrySubmitHandler(null)
|
||||
dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Push gateway truth into the quick window whenever it changes: connection
|
||||
// state gates its input; the recent-session list feeds its target picker.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
const api = window.hermesDesktop?.quickEntry
|
||||
|
||||
if (!api?.pushState) {
|
||||
return
|
||||
}
|
||||
|
||||
const push = () => {
|
||||
api.pushState({ connected: $gatewayState.get() === 'open', sessions: sessionOptions() })
|
||||
}
|
||||
|
||||
push()
|
||||
|
||||
const offGateway = $gatewayState.listen(push)
|
||||
const offSessions = $sessions.listen(push)
|
||||
|
||||
return () => {
|
||||
offGateway()
|
||||
offSessions()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { FindBar } from '@/components/find-bar'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
@@ -107,6 +108,7 @@ import { ContribWiringContext } from './context'
|
||||
import { useBackgroundSync } from './hooks/use-background-sync'
|
||||
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
|
||||
import { usePetBridge } from './hooks/use-pet-bridge'
|
||||
import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge'
|
||||
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
|
||||
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
|
||||
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
|
||||
@@ -607,6 +609,11 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
// The popped-out pet overlay's bridge back into the app.
|
||||
usePetBridge({ requestGateway, resumeSession, submitText })
|
||||
|
||||
// The global-hotkey Quick Entry window's bridge: its captured text rides the
|
||||
// SAME submit machinery the normal composer uses (current chat / picked
|
||||
// session / new session), and it hears gateway truth from this window.
|
||||
useQuickEntryBridge({ startFreshSessionDraft, submitText })
|
||||
|
||||
// Clear a failed turn's red error banner. Errors are renderer-local (never
|
||||
// persisted): a bare error placeholder is dropped entirely; a partial-output
|
||||
// failure keeps its content and sheds the error. Both the runtime cache AND
|
||||
@@ -978,6 +985,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
<RemoteFolderPicker />
|
||||
<FindBar />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -5,11 +5,18 @@ import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
|
||||
import { findBarClaimsCombo } from '@/lib/find-in-page'
|
||||
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { composerFocusKeysAllowed, isComposerFocusSoftCombo, typeToFocusChar } from '@/lib/keybinds/composer-focus-keys'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import {
|
||||
$findInPage,
|
||||
findNext as findNextMatch,
|
||||
findPrevious as findPreviousMatch,
|
||||
openFindBar
|
||||
} from '@/store/find-in-page'
|
||||
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
|
||||
import {
|
||||
requestSessionSearchFocus,
|
||||
@@ -188,6 +195,14 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
// the Win/Linux path where ⌘W reaches the renderer directly.
|
||||
'view.closeTab': () => void closeActiveTab(id => navigate(sessionRoute(id))),
|
||||
'view.reopenTab': reopenLastClosedTile,
|
||||
'view.findInPage': openFindBar,
|
||||
// ⌘G / ⌘⇧G are handled by the find bar's own capture-phase listener while
|
||||
// it is open (so they don't collide with `view.toggleReview`). These
|
||||
// registry handlers cover a user-assigned dedicated chord: stepping is a
|
||||
// no-op unless the bar is open with a query, so a bound key can't search
|
||||
// invisibly.
|
||||
'view.findNext': findNextMatch,
|
||||
'view.findPrevious': findPreviousMatch,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
@@ -243,6 +258,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
return
|
||||
}
|
||||
|
||||
// The open find bar owns ⌘G / ⌘⇧G / Escape. Its own capture-phase
|
||||
// listener runs those actions; bail here so the registry doesn't ALSO
|
||||
// fire the action bound to the same combo (⌘G = view.toggleReview,
|
||||
// Escape = composer.cancel, which would abort a live turn). Both
|
||||
// listeners are on `window`, so stopPropagation in the bar can't
|
||||
// suppress this one — the dispatcher has to yield explicitly.
|
||||
if ($findInPage.get().active && findBarClaimsCombo(combo)) {
|
||||
return
|
||||
}
|
||||
|
||||
const actionId = $comboIndex.get().get(combo)
|
||||
|
||||
// Unbound printable → type-to-focus. Bound chords (shift+n, …) win above.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
|
||||
import type { ModelSelection } from '@/app/shell/model-menu-panel'
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
@@ -24,15 +25,22 @@ export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOv
|
||||
const primaryModel = useStore($currentModel)
|
||||
const primaryProvider = useStore($currentProvider)
|
||||
const focusedRuntimeId = useStore($focusedRuntimeId)
|
||||
const focusedState = useStore($focusedSessionState)
|
||||
// `$focusedSessionState` is a projection of `$sessionStates`, republished on
|
||||
// EVERY message delta — and this overlay is mounted app-wide. Only two
|
||||
// fields are read off it, so subscribing to the whole object re-rendered
|
||||
// this component (and the un-memoized closed dialog below) per token while
|
||||
// the focused session streamed. Select each scalar so an unchanged
|
||||
// model/provider bails out instead — same fix as the statusbar (#72163).
|
||||
const focusedModel = useStoreSelector($focusedSessionState, state => state?.model ?? null)
|
||||
const focusedProvider = useStoreSelector($focusedSessionState, state => state?.provider ?? null)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const open = useStore($modelPickerOpen)
|
||||
|
||||
// Prefer the focused tile's runtime when the overlay opens from a tile that
|
||||
// lacked a live menu (gateway closed → fallback path).
|
||||
const sessionId = focusedRuntimeId ?? primarySessionId
|
||||
const currentModel = focusedRuntimeId && focusedState ? focusedState.model : primaryModel
|
||||
const currentProvider = focusedRuntimeId && focusedState ? focusedState.provider : primaryProvider
|
||||
const currentModel = focusedRuntimeId && focusedModel !== null ? focusedModel : primaryModel
|
||||
const currentProvider = focusedRuntimeId && focusedProvider !== null ? focusedProvider : primaryProvider
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
|
||||
@@ -433,7 +433,7 @@ export function PetOverlayApp() {
|
||||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
<PetSprite info={info} pauseWhenUnfocused={false} />
|
||||
|
||||
{/* Hearts on the popped-out pet — identical to in-window. */}
|
||||
<PetHeartField
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useReducer, useRef } from 'react'
|
||||
|
||||
import {
|
||||
initialQuickComposerState,
|
||||
QUICK_TARGET_CURRENT,
|
||||
QUICK_TARGET_NEW,
|
||||
type QuickComposerEvent,
|
||||
quickComposerReducer,
|
||||
type QuickComposerState
|
||||
} from '@/store/quick-entry'
|
||||
|
||||
/**
|
||||
* The Quick Entry composer — the whole renderer surface of the global-hotkey
|
||||
* mini window. Deliberately one input plus a session-target picker and nothing
|
||||
* else: this is a capture surface, not a second chat.
|
||||
*
|
||||
* All behavior rides `quickComposerReducer` (pure, unit-tested): submit sends
|
||||
* the trimmed text + target through the shell and asks to hide; an empty submit
|
||||
* does neither so a stray Enter can't make the window vanish; Escape and losing
|
||||
* focus dismiss without sending; a dead gateway disables the input entirely
|
||||
* (the reducer refuses the send AND the input paints the reconnect hint).
|
||||
*
|
||||
* The window itself has no gateway connection. Its view of backend truth — is
|
||||
* the gateway up, which recent sessions exist — is pushed in by the primary
|
||||
* renderer through main (`onState`), and its text goes back the same road to
|
||||
* the primary renderer's normal prompt-submit path.
|
||||
*/
|
||||
export function QuickEntryApp() {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// The reducer returns { send, state }; this wrapper performs the side effect
|
||||
// (hand the payload to the shell, ask to hide) and stores the next state, so
|
||||
// the decision stays pure and testable while the effects stay in one place.
|
||||
const [state, dispatch] = useReducer((current: QuickComposerState, event: QuickComposerEvent) => {
|
||||
const { send, state: next } = quickComposerReducer(current, event)
|
||||
const api = window.hermesDesktop?.quickEntry
|
||||
|
||||
if (send) {
|
||||
api?.submit(send)
|
||||
} else if (!next.visible && current.visible) {
|
||||
api?.dismiss()
|
||||
}
|
||||
|
||||
return next
|
||||
}, initialQuickComposerState)
|
||||
|
||||
// Re-summoned by the chord: the shell reuses the window, so reset the draft
|
||||
// and take the keyboard back for a fresh capture. Also adopt gateway-state
|
||||
// pushes (connection + recent sessions) relayed from the primary renderer.
|
||||
useEffect(() => {
|
||||
const api = window.hermesDesktop?.quickEntry
|
||||
|
||||
const offShown = api?.onShown(() => {
|
||||
dispatch({ type: 'shown' })
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
})
|
||||
|
||||
const offState = api?.onState(payload => {
|
||||
dispatch({
|
||||
connected: payload?.connected === true,
|
||||
sessions: Array.isArray(payload?.sessions) ? payload.sessions : [],
|
||||
type: 'state'
|
||||
})
|
||||
})
|
||||
|
||||
inputRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
offShown?.()
|
||||
offState?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
width: '100vw'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--ui-bg-elevated, var(--background))',
|
||||
border: '1px solid var(--ui-stroke-secondary, rgba(127,127,127,0.35))',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 18px 48px rgba(0,0,0,0.38)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '10px 14px',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<div style={{ alignItems: 'center', display: 'flex', gap: 10 }}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
color: 'var(--muted-foreground, #8a8a8a)',
|
||||
flexShrink: 0,
|
||||
fontSize: 15,
|
||||
lineHeight: 1,
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
›
|
||||
</span>
|
||||
<input
|
||||
aria-label="Quick Entry"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
disabled={!state.connected}
|
||||
onBlur={event => {
|
||||
// Moving focus to the target picker is not leaving the window.
|
||||
if (!event.relatedTarget) {
|
||||
dispatch({ type: 'blur' })
|
||||
}
|
||||
}}
|
||||
onChange={event => dispatch({ draft: event.target.value, type: 'edit' })}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'submit' })
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'dismiss' })
|
||||
}
|
||||
}}
|
||||
placeholder={state.connected ? 'Ask Hermes…' : 'Not connected — open Hermes to reconnect'}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--foreground, #eee)',
|
||||
flex: 1,
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 15,
|
||||
minWidth: 0,
|
||||
opacity: state.connected ? 1 : 0.55,
|
||||
outline: 'none'
|
||||
}}
|
||||
value={state.draft}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ alignItems: 'center', display: 'flex', gap: 8 }}>
|
||||
<label
|
||||
htmlFor="quick-entry-target"
|
||||
style={{
|
||||
color: 'var(--muted-foreground, #8a8a8a)',
|
||||
flexShrink: 0,
|
||||
fontSize: 11,
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
Send to
|
||||
</label>
|
||||
<select
|
||||
aria-label="Target session"
|
||||
disabled={!state.connected}
|
||||
id="quick-entry-target"
|
||||
onChange={event => dispatch({ target: event.target.value, type: 'target' })}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'dismiss' })
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--ui-stroke-secondary, rgba(127,127,127,0.35))',
|
||||
borderRadius: 6,
|
||||
color: 'var(--foreground, #eee)',
|
||||
fontSize: 11,
|
||||
maxWidth: 320,
|
||||
padding: '2px 6px'
|
||||
}}
|
||||
value={state.target}
|
||||
>
|
||||
<option value={QUICK_TARGET_CURRENT}>Current chat</option>
|
||||
<option value={QUICK_TARGET_NEW}>New session</option>
|
||||
{state.sessions.map(session => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { ThemeProvider } from '@/themes/context'
|
||||
|
||||
import { QuickEntryApp } from './quick-entry-app'
|
||||
|
||||
/**
|
||||
* Boot the Quick Entry window. Loaded by the same bundle as the main app but via
|
||||
* `?win=quick`, so it shares CSS/theme tokens while mounting a minimal capture
|
||||
* surface (no app shell, no gateway, no router).
|
||||
*
|
||||
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
||||
* in normal windows; this window is a floating card on a transparent backdrop,
|
||||
* so force the host layers see-through (same trick as the pet overlay).
|
||||
*/
|
||||
export function mountQuickEntry(): void {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="quick-entry">
|
||||
<ThemeProvider>
|
||||
<QuickEntryApp />
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PersistentTerminal, TerminalSlot } from './persistent'
|
||||
|
||||
vi.mock('./terminals', () => ({
|
||||
ensureTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./workspace', () => ({
|
||||
TerminalWorkspace: () => <div data-testid="terminal-workspace" />
|
||||
}))
|
||||
|
||||
let resizeObserverCallback: ResizeObserverCallback | null = null
|
||||
let mutationObserverCallback: MutationCallback | null = null
|
||||
let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = []
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rect(top: number, left: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function installRaf() {
|
||||
let nextId = 1
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
const request = vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
frames.set(id, callback)
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
const cancel = vi.fn((id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return {
|
||||
cancel,
|
||||
pending: () => frames.size,
|
||||
request,
|
||||
runNext: () => {
|
||||
const next = frames.entries().next().value
|
||||
|
||||
if (!next) {
|
||||
throw new Error('No pending RAF')
|
||||
}
|
||||
|
||||
const [id, callback] = next
|
||||
frames.delete(id)
|
||||
callback(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
return (
|
||||
<>
|
||||
<TerminalSlot className="slot" />
|
||||
<PersistentTerminal onAddSelectionToChat={() => undefined} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PersistentTerminal rect tracking', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
setVisibility(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
resizeObserverCallback = null
|
||||
mutationObserverCallback = null
|
||||
mutationObserveCalls = []
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCallback = callback
|
||||
}
|
||||
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
} as unknown as typeof ResizeObserver
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'MutationObserver',
|
||||
class {
|
||||
constructor(callback: MutationCallback) {
|
||||
mutationObserverCallback = callback
|
||||
}
|
||||
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn((target: Node, options?: MutationObserverInit) => {
|
||||
mutationObserveCalls.push({ options, target })
|
||||
})
|
||||
takeRecords = vi.fn(() => [])
|
||||
} as unknown as typeof MutationObserver
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('settles after rect changes instead of polling forever', () => {
|
||||
const raf = installRaf()
|
||||
let currentRect = rect(10, 20, 200, 100)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
currentRect = rect(12, 24, 220, 120)
|
||||
act(() => {
|
||||
resizeObserverCallback?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(3)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(3)
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('remeasures when layout moves the slot without resizing it', () => {
|
||||
const raf = installRaf()
|
||||
let currentRect = rect(10, 20, 200, 100)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(mutationObserveCalls.some(call => call.options?.subtree === true)).toBe(true)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
const overlay = container!.lastElementChild as HTMLElement
|
||||
expect(overlay.style.top).toBe('10px')
|
||||
expect(overlay.style.left).toBe('20px')
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
currentRect = rect(32, 48, 200, 100)
|
||||
act(() => {
|
||||
mutationObserverCallback?.([], {} as MutationObserver)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(overlay.style.top).toBe('32px')
|
||||
expect(overlay.style.left).toBe('48px')
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
})
|
||||
|
||||
expect(raf.cancel).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
resizeObserverCallback?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suspends while unfocused and cancels every callback on unmount', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
resizeObserverCallback?.([], {} as ResizeObserver)
|
||||
mutationObserverCallback?.([], {} as MutationObserver)
|
||||
})
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not schedule an initial frame when mounted while unfocused', () => {
|
||||
const raf = installRaf()
|
||||
vi.mocked(document.hasFocus).mockReturnValue(false)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
|
||||
import { $terminalTakeover } from '../store'
|
||||
|
||||
import { ensureTerminal } from './terminals'
|
||||
@@ -83,8 +85,23 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
|
||||
let prev: Rect | null = null
|
||||
let frame = 0
|
||||
let stopped = false
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
|
||||
const cancelFrame = () => {
|
||||
if (frame !== 0) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
}
|
||||
|
||||
const measure = (): boolean => {
|
||||
if (rendererPaused()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const r = slot.getBoundingClientRect()
|
||||
// floor top/left + ceil right/bottom: overlay always covers the slot's
|
||||
// full pixel footprint, so half-pixel rects can't leak page bg through.
|
||||
@@ -99,14 +116,80 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
||||
if (next.width > 0 && next.height > 0) {
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return false
|
||||
}
|
||||
|
||||
tick()
|
||||
const scheduleMeasure = () => {
|
||||
if (stopped || rendererPaused() || frame !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
|
||||
if (measure()) {
|
||||
scheduleMeasure()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (rendererPaused()) {
|
||||
cancelFrame()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMeasure()
|
||||
}
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
scheduleMeasure()
|
||||
})
|
||||
|
||||
const positionObserver =
|
||||
typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(() => {
|
||||
scheduleMeasure()
|
||||
})
|
||||
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||
|
||||
if (measure()) {
|
||||
scheduleMeasure()
|
||||
}
|
||||
|
||||
observer?.observe(slot)
|
||||
|
||||
for (let node: HTMLElement | null = slot; node; node = node.parentElement) {
|
||||
positionObserver?.observe(node, {
|
||||
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scheduleMeasure)
|
||||
window.addEventListener('scroll', scheduleMeasure, true)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
cancelFrame()
|
||||
observer?.disconnect()
|
||||
positionObserver?.disconnect()
|
||||
window.removeEventListener('resize', scheduleMeasure)
|
||||
window.removeEventListener('scroll', scheduleMeasure, true)
|
||||
pauseController?.dispose()
|
||||
}
|
||||
}, [slot])
|
||||
|
||||
const visible = Boolean(rect && rect.width > 0 && rect.height > 0)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'session-1'
|
||||
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
|
||||
let states: Map<string, ClientSessionState>
|
||||
type UpdateSessionState = (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
let updateSessionState: ReturnType<typeof vi.fn<UpdateSessionState>>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(states)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
appendAssistantDelta = stream.appendAssistantDelta
|
||||
}, [stream.appendAssistantDelta])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mountStream() {
|
||||
render(<Harness />)
|
||||
expect(appendAssistantDelta).not.toBeNull()
|
||||
}
|
||||
|
||||
function assistantText() {
|
||||
const message = states.get(SID)?.messages.at(-1)
|
||||
const part = message?.parts.at(-1)
|
||||
|
||||
return part?.type === 'text' ? part.text : ''
|
||||
}
|
||||
|
||||
describe('useMessageStream delta flush scheduling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appendAssistantDelta = null
|
||||
states = new Map()
|
||||
updateSessionState = vi.fn((sessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => {
|
||||
const next = updater(states.get(sessionId) ?? createClientSessionState())
|
||||
states.set(sessionId, next)
|
||||
|
||||
return next
|
||||
})
|
||||
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('flushes streaming text on a bounded timer while the window is unfocused', async () => {
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'still streaming'))
|
||||
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(assistantText()).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('still streaming')
|
||||
})
|
||||
|
||||
it('cancels the pending timer on unmount and flushes exactly once', async () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'final delta'))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(assistantText()).toBe('final delta')
|
||||
const updatesAfterUnmount = updateSessionState.mock.calls.length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@ import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedCh
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { applyGoalStatusText } from '@/store/goals'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
@@ -909,6 +910,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'goal') {
|
||||
applyGoalStatusText(sessionId, coerceGatewayText(payload?.text))
|
||||
}
|
||||
} else if (event.type === 'review.summary') {
|
||||
// Self-improvement background review saved something to memory/skills
|
||||
|
||||
@@ -18,6 +18,7 @@ import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import { setComposerDraft } from '@/store/composer'
|
||||
import { enqueueQueuedPrompt } from '@/store/composer-queue'
|
||||
import { applyGoalStatusText } from '@/store/goals'
|
||||
import { dismissNotification, notify, notifyError } from '@/store/notifications'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
@@ -232,6 +233,15 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
// `/goal <text>` looked like it did nothing.
|
||||
if ((dispatch.type === 'send' || dispatch.type === 'prefill') && dispatch.notice?.trim()) {
|
||||
renderSlashOutput(dispatch.notice.trim())
|
||||
|
||||
// `/goal <text>` returns its "⊙ Goal set …" notice here and kicks
|
||||
// off the first turn immediately; the backend only emits a
|
||||
// `status.update kind:"goal"` after that turn's post-turn judge
|
||||
// runs. Seed the goal store from the notice so the indicator shows
|
||||
// the active goal right away instead of after the first turn.
|
||||
if (name === 'goal') {
|
||||
applyGoalStatusText(sessionId, dispatch.notice.trim())
|
||||
}
|
||||
}
|
||||
|
||||
const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
|
||||
@@ -313,6 +323,15 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
|
||||
const output = result && typeof result === 'object' ? (result as SlashExecResponse) : null
|
||||
const body = output?.output || `/${name}: no output`
|
||||
|
||||
// `/goal status|pause|resume|clear` come back as plain exec output
|
||||
// ("⊙ Goal (active, 3/20 turns): …", "⏸ Goal paused: …", "✓ Goal
|
||||
// cleared." …). Mirror it into the goal store so the composer
|
||||
// indicator tracks pause/resume/clear immediately.
|
||||
if (name === 'goal' && output?.output) {
|
||||
applyGoalStatusText(sessionId, output.output)
|
||||
}
|
||||
|
||||
renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body)
|
||||
|
||||
return
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionStartedAt,
|
||||
setSessionsTotal,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
@@ -1293,9 +1292,6 @@ export function useSessionActions({
|
||||
// the delete RPC is in flight, so a racing refresh can't flash it back.
|
||||
tombstoneSessions(removedIds)
|
||||
beginSessionMutation(removedIds)
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== removedPinId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
@@ -1330,7 +1326,6 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions(removedIds)
|
||||
@@ -1393,10 +1388,6 @@ export function useSessionActions({
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
tombstoneSessions(archivedIds)
|
||||
beginSessionMutation(archivedIds)
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
|
||||
if (wasSelected) {
|
||||
@@ -1419,7 +1410,6 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions(archivedIds)
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
import { reconcileResumeMessages } from './utils'
|
||||
|
||||
const user = (id: string, text: string): ChatMessage => ({
|
||||
id,
|
||||
parts: [{ type: 'text', text }],
|
||||
role: 'user'
|
||||
})
|
||||
|
||||
/**
|
||||
* Switching away from a running session and back re-hydrates it from
|
||||
* `session.activate`, whose transcript is TEXT-ONLY for the live turn (the
|
||||
* gateway's `inflight` projection carries `user`/`assistant` strings, nothing
|
||||
* structural). The renderer's cached state is the only carrier of the turn's
|
||||
* tool calls and reasoning, so reconcile must not drop them.
|
||||
*/
|
||||
describe('reconcileResumeMessages — structural parts on a mid-turn switch', () => {
|
||||
it('keeps tool-call parts the authoritative text-only row cannot carry', () => {
|
||||
const cached: ChatMessage[] = [
|
||||
user('u1', 'read the config'),
|
||||
{
|
||||
id: 'a1',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'I should read the file first.' },
|
||||
{ type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', result: 'contents' },
|
||||
{ type: 'text', text: 'Reading it now' }
|
||||
],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
// What activate returns mid-turn: the same rows, but flattened to text and
|
||||
// one delta further along, so the text no longer matches the cached copy.
|
||||
const authoritative: ChatMessage[] = [
|
||||
user('u1', 'read the config'),
|
||||
{ id: 'a1', parts: [{ type: 'text', text: 'Reading it now — found the key' }], role: 'assistant' }
|
||||
]
|
||||
|
||||
const [, assistant] = reconcileResumeMessages(authoritative, cached)
|
||||
|
||||
expect(assistant.parts.filter(p => p.type === 'tool-call')).toHaveLength(1)
|
||||
expect(assistant.parts.filter(p => p.type === 'reasoning')).toHaveLength(1)
|
||||
// The newer authoritative text still wins.
|
||||
expect(assistant.parts.filter(p => p.type === 'text').at(-1)).toMatchObject({
|
||||
text: 'Reading it now — found the key'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not duplicate tool calls the authoritative row already carries', () => {
|
||||
const cached: ChatMessage[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', result: 'contents' }],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const authoritative: ChatMessage[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', result: 'contents' },
|
||||
{ type: 'text', text: 'done' }
|
||||
],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const [assistant] = reconcileResumeMessages(authoritative, cached)
|
||||
|
||||
expect(assistant.parts.filter(p => p.type === 'tool-call')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -435,6 +435,39 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
|
||||
})
|
||||
|
||||
// A mid-turn redirect inserts its correction as a SECOND optimistic user row
|
||||
// for the same turn. Keeping only the newest dropped the prompt that started
|
||||
// it, so a resume repainted the thread with the user's message missing.
|
||||
it('keeps every optimistic user row in the live run after a mid-turn redirect', () => {
|
||||
const previous = [
|
||||
msg('user-1000', 'user', 'remove the session counts'),
|
||||
msg('user-2000', 'user', 'hurry up'),
|
||||
msg('assistant-stream-1', 'assistant', 'Moving.', { pending: true })
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages([], previous).map(message => message.id)).toEqual([
|
||||
'user-1000',
|
||||
'user-2000',
|
||||
'assistant-stream-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('still drops optimistic rows separated from the live run by an assistant reply', () => {
|
||||
const previous = [
|
||||
msg('user-stale', 'user', 'compressed-away prompt'),
|
||||
msg('assistant-stale', 'assistant', 'compressed-away reply'),
|
||||
msg('user-1000', 'user', 'the live prompt'),
|
||||
msg('user-2000', 'user', 'the correction'),
|
||||
msg('assistant-stream-1', 'assistant', 'Moving.', { pending: true })
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages([], previous).map(message => message.id)).toEqual([
|
||||
'user-1000',
|
||||
'user-2000',
|
||||
'assistant-stream-1'
|
||||
])
|
||||
})
|
||||
|
||||
// #67603: the gateway persists model-switch / personality notices as role=user
|
||||
// ([System: …], tui_gateway/server.py). A single trailing marker is already
|
||||
// handled by the latestAuthoritativeUser guard above, but TWO switches around
|
||||
@@ -538,6 +571,46 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
})
|
||||
|
||||
describe('appendLiveSessionProjection', () => {
|
||||
// Corrections typed while a turn ran are their own user bubbles on the same
|
||||
// turn. Resume must rebuild the prompt AND every correction, in order.
|
||||
it('projects mid-turn redirect corrections after the prompt that started the turn', () => {
|
||||
const restored = appendLiveSessionProjection([], {
|
||||
session_id: 'runtime-1',
|
||||
inflight: {
|
||||
user: 'remove the session counts',
|
||||
corrections: ['hurry up', 'and the worktree ones'],
|
||||
assistant: 'Moving.',
|
||||
streaming: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([
|
||||
'remove the session counts',
|
||||
'hurry up',
|
||||
'and the worktree ones',
|
||||
'Moving.'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not re-project a correction the transcript already persisted', () => {
|
||||
const stored = [msg('stored-user', 'user', 'remove the session counts'), msg('stored-fix', 'user', 'hurry up')]
|
||||
|
||||
const restored = appendLiveSessionProjection(stored, {
|
||||
session_id: 'runtime-1',
|
||||
inflight: {
|
||||
user: 'remove the session counts',
|
||||
corrections: ['hurry up'],
|
||||
assistant: 'Moving.',
|
||||
streaming: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(restored.filter(message => message.role === 'user').map(message => message.id)).toEqual([
|
||||
'stored-user',
|
||||
'stored-fix'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not duplicate the inflight user when the persisted turn carries @image refs', () => {
|
||||
// By the time a stored transcript reaches appendLiveSessionProjection it
|
||||
// has already been run through toChatMessages, so the @image directive has
|
||||
|
||||
@@ -46,14 +46,40 @@ function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
|
||||
return appended ? { ...message, parts } : message
|
||||
}
|
||||
|
||||
function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage {
|
||||
if (message.parts.some(part => part.type === 'reasoning')) {
|
||||
/**
|
||||
* Carry structural parts an authoritative row cannot express.
|
||||
*
|
||||
* A live turn's authoritative projection is TEXT-ONLY: the gateway's `inflight`
|
||||
* snapshot carries `user`/`assistant` strings, and history is not committed
|
||||
* until the turn finishes. The renderer's cached state is therefore the sole
|
||||
* carrier of the running turn's reasoning and tool calls, so switching threads
|
||||
* mid-turn and back re-hydrated an assistant row stripped of both — the turn
|
||||
* looked inert, with no thinking trace and no tool activity.
|
||||
*
|
||||
* Preserved only when the rows are the SAME turn: identical text, or the
|
||||
* authoritative text extending the cached one (another delta landed). Anything
|
||||
* else may be a different turn at the same role ordinal — compression rewrites
|
||||
* history — and must not inherit foreign parts. Tool calls dedupe on
|
||||
* `toolCallId` so a row that already carries them is left alone.
|
||||
*/
|
||||
function preserveStructuralParts(message: ChatMessage, previous: ChatMessage): ChatMessage {
|
||||
const carried = previous.parts.filter(part => part.type === 'reasoning' || part.type === 'tool-call')
|
||||
|
||||
if (!carried.length) {
|
||||
return message
|
||||
}
|
||||
|
||||
const reasoningParts = previous.parts.filter(part => part.type === 'reasoning')
|
||||
const hasReasoning = message.parts.some(part => part.type === 'reasoning')
|
||||
|
||||
return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message
|
||||
const presentToolCallIds = new Set(
|
||||
message.parts.flatMap(part => (part.type === 'tool-call' ? [part.toolCallId] : []))
|
||||
)
|
||||
|
||||
const missing = carried.filter(part =>
|
||||
part.type === 'reasoning' ? !hasReasoning : !presentToolCallIds.has(part.toolCallId)
|
||||
)
|
||||
|
||||
return missing.length ? { ...message, parts: [...missing, ...message.parts] } : message
|
||||
}
|
||||
|
||||
// Compile-time exhaustiveness guards. If a new field is added to ChatMessage
|
||||
@@ -209,12 +235,29 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
const previousVisibleText = textWithoutEmbeddedImages(previousText)
|
||||
let preserved = message
|
||||
|
||||
if (nextText === previousVisibleText || nextText === previousText.trim()) {
|
||||
preserved = preserveReasoningParts(preserved, previous)
|
||||
const sameText = nextText === previousVisibleText || nextText === previousText.trim()
|
||||
|
||||
if (message.role === 'user' && preserved.attachmentRefs === undefined && previous.attachmentRefs?.length) {
|
||||
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
|
||||
}
|
||||
// Mid-turn, the authoritative text has advanced past the cached copy by one
|
||||
// or more deltas. That is still the same turn, and the cached row holds the
|
||||
// only copy of its reasoning / tool calls, so treat an extension as a match
|
||||
// for structural carry-over. Attachment refs and image re-appending stay on
|
||||
// the strict equality path — they reconcile a SETTLED row, and a growing
|
||||
// row is by definition not settled.
|
||||
const sameTurn =
|
||||
sameText ||
|
||||
(nextText.length > 0 && previousVisibleText.length > 0 && nextText.startsWith(previousVisibleText.trim()))
|
||||
|
||||
if (sameTurn) {
|
||||
preserved = preserveStructuralParts(preserved, previous)
|
||||
}
|
||||
|
||||
if (
|
||||
sameText &&
|
||||
message.role === 'user' &&
|
||||
preserved.attachmentRefs === undefined &&
|
||||
previous.attachmentRefs?.length
|
||||
) {
|
||||
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
|
||||
}
|
||||
|
||||
const previousImages = embeddedImageUrls(previousText)
|
||||
@@ -283,6 +326,26 @@ export function preserveLocalPendingTurnMessages(
|
||||
.reverse()
|
||||
.find(message => message.role === 'user' && message.id.startsWith('user-'))
|
||||
|
||||
// A mid-turn redirect inserts its correction as a second optimistic user row
|
||||
// directly before the live reply, so one turn can own a contiguous RUN of
|
||||
// them. Preserving only the newest keeps the correction and drops the prompt
|
||||
// that started the turn. Widen to the run — but only the contiguous one: any
|
||||
// `user-*` row separated by an assistant reply is stale post-compression
|
||||
// history, which is what the newest-only rule exists to discard.
|
||||
const liveOptimisticUsers = new Set<ChatMessage>()
|
||||
|
||||
if (newestOptimisticUser) {
|
||||
for (let index = previousMessages.indexOf(newestOptimisticUser); index >= 0; index -= 1) {
|
||||
const candidate = previousMessages[index]
|
||||
|
||||
if (candidate.role !== 'user' || !candidate.id.startsWith('user-')) {
|
||||
break
|
||||
}
|
||||
|
||||
liveOptimisticUsers.add(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
|
||||
const preserved: ChatMessage[] = []
|
||||
|
||||
@@ -303,7 +366,7 @@ export function preserveLocalPendingTurnMessages(
|
||||
continue
|
||||
}
|
||||
|
||||
if (isOptimisticUser && message !== newestOptimisticUser) {
|
||||
if (isOptimisticUser && !liveOptimisticUsers.has(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -349,13 +412,27 @@ export function appendLiveSessionProjection(
|
||||
const inflightUser = projection.inflight?.user?.trim() ?? ''
|
||||
const inflightAssistant = projection.inflight?.assistant ?? ''
|
||||
const inflightStreaming = Boolean(projection.inflight?.streaming)
|
||||
|
||||
// Mid-turn redirect corrections. They are additional user bubbles belonging
|
||||
// to this same turn, ordered after the prompt that started it.
|
||||
const inflightCorrections = (projection.inflight?.corrections ?? [])
|
||||
.map(correction => correction?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
|
||||
// A retained failed turn (the gateway keeps error snapshots replayable when
|
||||
// the terminal frame may have been lost to a disconnect) — surface the
|
||||
// failure on the projected row instead of rendering the partial as healthy.
|
||||
const inflightError = projection.inflight?.error?.trim() ?? ''
|
||||
const queuedUser = projection.queued?.user?.trim() ?? ''
|
||||
|
||||
if (!inflightUser && !inflightAssistant && !inflightStreaming && !inflightError && !queuedUser) {
|
||||
if (
|
||||
!inflightUser &&
|
||||
!inflightAssistant &&
|
||||
!inflightStreaming &&
|
||||
!inflightError &&
|
||||
!queuedUser &&
|
||||
!inflightCorrections.length
|
||||
) {
|
||||
return messages
|
||||
}
|
||||
|
||||
@@ -366,10 +443,20 @@ export function appendLiveSessionProjection(
|
||||
// both makes a backgrounded prompt appear twice when its session is reopened.
|
||||
// Only suppress the projection when the latest authoritative user row is the
|
||||
// same turn — older identical prompts must not hide a newly accepted repeat.
|
||||
const latestUser = [...messages].reverse().find(message => message.role === 'user')
|
||||
// A mid-turn redirect gives that turn a RUN of user rows (prompt +
|
||||
// corrections), so match the contiguous run ending at the latest user row
|
||||
// rather than the single last one.
|
||||
const latestUserIndex = messages.map(message => message.role).lastIndexOf('user')
|
||||
const latestUserRun: ChatMessage[] = []
|
||||
|
||||
const inflightUserAlreadyPersisted =
|
||||
latestUser && textWithoutImageRefs(chatMessageText(latestUser)) === textWithoutImageRefs(inflightUser)
|
||||
for (let index = latestUserIndex; index >= 0 && messages[index].role === 'user'; index -= 1) {
|
||||
latestUserRun.unshift(messages[index])
|
||||
}
|
||||
|
||||
const persistedInLatestRun = (text: string): boolean =>
|
||||
latestUserRun.some(message => textWithoutImageRefs(chatMessageText(message)) === textWithoutImageRefs(text))
|
||||
|
||||
const inflightUserAlreadyPersisted = Boolean(inflightUser) && persistedInLatestRun(inflightUser)
|
||||
|
||||
if (inflightUser && !inflightUserAlreadyPersisted) {
|
||||
projected.push({
|
||||
@@ -379,6 +466,22 @@ export function appendLiveSessionProjection(
|
||||
})
|
||||
}
|
||||
|
||||
// Corrections typed while the turn ran. Each is its own bubble, placed after
|
||||
// the original prompt and before the reply they redirected — the same order
|
||||
// the live transcript showed. Skip any the transcript already holds so a
|
||||
// resume doesn't double them.
|
||||
for (const [index, correction] of inflightCorrections.entries()) {
|
||||
if (persistedInLatestRun(correction)) {
|
||||
continue
|
||||
}
|
||||
|
||||
projected.push({
|
||||
id: `user-inflight-correction-${index}-${sessionId}`,
|
||||
role: 'user',
|
||||
parts: [textPart(correction)]
|
||||
})
|
||||
}
|
||||
|
||||
// Keep a pending assistant boundary even before the first delta when a
|
||||
// queued user turn follows it. This preserves the two distinct turns.
|
||||
if (inflightAssistant || inflightStreaming || inflightError || (inflightUser && queuedUser)) {
|
||||
|
||||
@@ -43,13 +43,13 @@ const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
|
||||
// separate listAllProfileSessions calls (each of which reopened every profile
|
||||
// DB) — #66377-adjacent perf work from the desktop audit canvas.
|
||||
const sidebar = (
|
||||
recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
|
||||
recents: { sessions: SessionInfo[]; profiles_truncated?: Record<string, boolean> },
|
||||
cron: SessionInfo[] = [],
|
||||
messaging: SessionInfo[] = []
|
||||
): SidebarSessionsResponse => ({
|
||||
recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
|
||||
recents: { sessions: recents.sessions, profiles_truncated: recents.profiles_truncated },
|
||||
cron: { sessions: cron },
|
||||
messaging: { sessions: messaging, total: messaging.length }
|
||||
messaging: { sessions: messaging }
|
||||
})
|
||||
|
||||
const listSidebarSessions = vi.fn()
|
||||
@@ -90,7 +90,7 @@ afterEach(() => {
|
||||
describe('refreshSessions identity + loading hygiene', () => {
|
||||
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
|
||||
const rows = [row('a'), row('b')]
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows }))
|
||||
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
@@ -102,9 +102,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
expect(first.map(s => s.id)).toEqual(['a', 'b'])
|
||||
|
||||
// Second refresh returns fresh (but equal) row objects, as the API does.
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
|
||||
)
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a'), row('b')] }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
@@ -114,7 +112,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
})
|
||||
|
||||
it('swaps the array when rows actually changed', async () => {
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')] }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
@@ -123,9 +121,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
|
||||
const first = $sessions.get()
|
||||
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
|
||||
)
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })] }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
@@ -136,7 +132,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
})
|
||||
|
||||
it('does not flicker the loading flag over a populated list', async () => {
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')] }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
@@ -162,9 +158,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
removed.ids = new Set(['b', 'root-c'])
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({
|
||||
sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial<SessionInfo>)],
|
||||
total: 3,
|
||||
profile_totals: {}
|
||||
sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial<SessionInfo>)]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -178,7 +172,7 @@ describe('refreshSessions identity + loading hygiene', () => {
|
||||
})
|
||||
|
||||
it('still shows loading for the initial (empty-list) fetch', async () => {
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')] }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
const loadingStates: boolean[] = []
|
||||
@@ -199,9 +193,7 @@ describe('refreshSessions batches slices into one request', () => {
|
||||
const cron = [row('c1', { source: 'cron', title: 'nightly' })]
|
||||
const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]
|
||||
|
||||
listSidebarSessions.mockResolvedValue(
|
||||
sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging)
|
||||
)
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: recents }, cron, messaging))
|
||||
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
@@ -220,7 +212,7 @@ describe('refreshSessions batches slices into one request', () => {
|
||||
})
|
||||
|
||||
it('forwards the active profile scope + section limits to the batched call', async () => {
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] }))
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))
|
||||
|
||||
await act(async () => {
|
||||
@@ -238,7 +230,7 @@ describe('refreshSessions batches slices into one request', () => {
|
||||
|
||||
it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => {
|
||||
const { getCronJobs } = await import('@/hermes')
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
|
||||
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] }))
|
||||
|
||||
const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' }))
|
||||
|
||||
|
||||
@@ -23,10 +23,9 @@ import {
|
||||
setMessagingPlatformTotals,
|
||||
setMessagingSessions,
|
||||
setMessagingTruncated,
|
||||
setSessionProfileTotals,
|
||||
setSessionProfilesTruncated,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
|
||||
|
||||
@@ -202,9 +201,13 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
||||
|
||||
return sameCronSignature(prev, next) ? prev : next
|
||||
})
|
||||
setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
|
||||
setSessionProfileTotals(prev => {
|
||||
const next = recents.profile_totals ?? {}
|
||||
// "Is there another page?" instead of an exact total: the backend
|
||||
// reports which profiles filled their window, which costs nothing on
|
||||
// top of the rows it already read (the old exact totals ran a COUNT(*)
|
||||
// per profile DB on every refresh). Reference-stable when unchanged so
|
||||
// the sidebar's group memos don't recompute per refresh.
|
||||
setSessionProfilesTruncated(prev => {
|
||||
const next = recents.profiles_truncated ?? {}
|
||||
const prevKeys = Object.keys(prev)
|
||||
|
||||
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
|
||||
@@ -258,8 +261,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
||||
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
|
||||
])
|
||||
|
||||
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||
// A full window back means the profile still has more on disk.
|
||||
const truncated = result.sessions.length >= loaded + SIDEBAR_SESSIONS_PAGE_SIZE
|
||||
setSessionProfilesTruncated(prev => ({ ...prev, [key]: truncated }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,8 +7,10 @@ import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { persistInFlightTurnState } from '@/lib/inflight-turn-journal'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$messages,
|
||||
setActiveSessionStoredIdRotation,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
@@ -108,10 +110,23 @@ export function useSessionStateCache({
|
||||
// rotates (e.g. auto-compression forks a continuation). Leaving the
|
||||
// stale key lets getRuntimeIdForStoredSession resolve the old stored id
|
||||
// to this runtime, which the compression route-follow logic relies on
|
||||
// being absent. The rotation signal itself is emitted centrally from
|
||||
// handleTransition (session-states.ts) off the published diff.
|
||||
// being absent. The rotation signal was previously emitted centrally
|
||||
// from handleTransition (session-states.ts), but updateSessionState
|
||||
// now skips publishSessionState (and thus handleTransition) when the
|
||||
// updater is a no-op — fire it here so the route-follow effect still
|
||||
// tracks compression without needing a dummy state write.
|
||||
if (existing.storedSessionId && existing.storedSessionId !== storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(existing.storedSessionId)
|
||||
|
||||
// A rotation event needs a real next id — a null/cleared stored id
|
||||
// is a detach, not a rotation the route-follow effect should chase.
|
||||
if (storedSessionId && sessionId === $activeSessionId.get()) {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: storedSessionId,
|
||||
previousStoredSessionId: existing.storedSessionId,
|
||||
runtimeSessionId: sessionId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (storedSessionId) {
|
||||
@@ -268,7 +283,22 @@ export function useSessionStateCache({
|
||||
storedSessionId?: string | null
|
||||
) => {
|
||||
const previous = ensureSessionState(sessionId, storedSessionId)
|
||||
const next = updater({ ...previous, messages: previous.messages })
|
||||
// Give the updater the raw previous state so it can return the same
|
||||
// reference when nothing changed (the caller sees a no-op). Previously
|
||||
// the param was always a fresh spread, so every call looked like a
|
||||
// change — including periodic ~1/s session.info heartbeats that churn
|
||||
// $sessionStates and its computed atoms on every tick.
|
||||
const next = updater(previous)
|
||||
|
||||
// If the updater returned the same reference, nothing changed for this
|
||||
// session — skip the store write, publishSessionState, and view sync.
|
||||
// The cache entry was already updated by ensureSessionState (if
|
||||
// storedSessionId rotated); the caller gets its return value from the
|
||||
// cache, so stale reads don't regress.
|
||||
if (next === previous) {
|
||||
return previous
|
||||
}
|
||||
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
// Crash-survivable turn progress: journal the running turn's visible
|
||||
// tail (throttled localStorage write; cleared the moment the turn
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
import { QuickEntrySettings } from './quick-entry-settings'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
@@ -290,10 +291,19 @@ export function ConfigSettings({
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{/* Device-local desktop pref (not config.yaml) — lives here since keeping
|
||||
the machine awake is a power-user knob. */}
|
||||
{/* Device-local desktop prefs (not config.yaml) — they live here since
|
||||
keeping the machine awake and the global Quick Entry chord are both
|
||||
power-user, this-computer-only knobs. */}
|
||||
{activeSectionId === 'advanced' && (
|
||||
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
|
||||
<>
|
||||
<ToggleRow
|
||||
checked={keepAwake}
|
||||
description={c.keepAwakeDesc}
|
||||
label={c.keepAwakeTitle}
|
||||
onChange={setKeepAwake}
|
||||
/>
|
||||
<QuickEntrySettings />
|
||||
</>
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
$quickEntry,
|
||||
canUseQuickEntry,
|
||||
loadQuickEntrySettings,
|
||||
QUICK_ENTRY_DEFAULT_SHORTCUT,
|
||||
saveQuickEntrySettings
|
||||
} from '@/store/quick-entry'
|
||||
|
||||
import { ListRow, ToggleRow } from './primitives'
|
||||
|
||||
/**
|
||||
* Quick Entry — the global-hotkey mini composer's settings rows.
|
||||
*
|
||||
* The MAIN process is authoritative (it owns the OS accelerator), so this reads
|
||||
* the live registration state on mount and surfaces the failure the feature must
|
||||
* never swallow: a chord another app already owns comes back `registered: false`
|
||||
* with `error: 'taken'` and says so, right under the field.
|
||||
*/
|
||||
export function QuickEntrySettings() {
|
||||
const { t } = useI18n()
|
||||
const q = t.settings.quickEntry
|
||||
const state = useStore($quickEntry)
|
||||
// The field is a local draft: the accelerator is only committed on blur/Enter,
|
||||
// so a half-typed chord ("Alt+") never tears down the live registration.
|
||||
const [draft, setDraft] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void loadQuickEntrySettings()
|
||||
}, [])
|
||||
|
||||
if (!canUseQuickEntry()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const next = (draft ?? '').trim()
|
||||
setDraft(null)
|
||||
|
||||
if (next && next !== state.shortcut) {
|
||||
void saveQuickEntrySettings({ shortcut: next })
|
||||
}
|
||||
}
|
||||
|
||||
const status =
|
||||
state.registered === null
|
||||
? null
|
||||
: state.error === 'taken'
|
||||
? q.takenBy
|
||||
: state.error === 'invalid'
|
||||
? q.invalidShortcut
|
||||
: state.enabled && state.registered
|
||||
? q.active
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleRow
|
||||
checked={state.enabled}
|
||||
description={q.enabledDesc}
|
||||
label={q.enabledTitle}
|
||||
onChange={enabled => void saveQuickEntrySettings({ enabled })}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
aria-label={q.shortcutTitle}
|
||||
disabled={!state.enabled}
|
||||
onBlur={commit}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commit()
|
||||
}
|
||||
}}
|
||||
placeholder={QUICK_ENTRY_DEFAULT_SHORTCUT}
|
||||
value={draft ?? state.shortcut}
|
||||
/>
|
||||
}
|
||||
below={
|
||||
status && (
|
||||
<div
|
||||
className={
|
||||
state.error
|
||||
? 'mt-1 text-[length:var(--conversation-caption-font-size)] text-amber-500/90'
|
||||
: 'mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={q.shortcutDesc}
|
||||
title={q.shortcutTitle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useI18n } from '@/i18n'
|
||||
import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||
import { revealFileInTree } from '@/store/layout'
|
||||
@@ -103,7 +104,20 @@ export function useStatusbarItems({
|
||||
const gatewayRestarting = useStore($gatewayRestarting)
|
||||
const primarySessionStartedAt = useStore($sessionStartedAt)
|
||||
const primaryTurnStartedAt = useStore($turnStartedAt)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
// The indicator must speak the same scope as the Spawn-tree panel it opens:
|
||||
// every session's subagents, never background system actions. Only two
|
||||
// COUNTS are read, so select scalars — a whole-map `useStore` re-ran this
|
||||
// hook (rebuilding all ~9 statusbar items) on every subagent progress tick
|
||||
// in ANY session, including background ones.
|
||||
const subagentsRunning = useStoreSelector($subagentsBySession, bySession =>
|
||||
Object.values(bySession).reduce((sum, items) => sum + activeSubagentCount(items), 0)
|
||||
)
|
||||
|
||||
const subagentsFailed = useStoreSelector($subagentsBySession, bySession =>
|
||||
Object.values(bySession).reduce((sum, items) => sum + failedSubagentCount(items), 0)
|
||||
)
|
||||
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const backendUpdateStatus = useStore($backendUpdateStatus)
|
||||
@@ -117,30 +131,44 @@ export function useStatusbarItems({
|
||||
// tile makes the statusbar describe THAT session.
|
||||
const focusedStoredSessionId = useStore($focusedStoredSessionId)
|
||||
const focusedRuntimeId = useStore($focusedRuntimeId)
|
||||
const focusedState = useStore($focusedSessionState)
|
||||
const sessions = useStore($sessions)
|
||||
// `$focusedSessionState` is a projection of `$sessionStates`, which is
|
||||
// republished on EVERY message delta — tens of times a second during a turn.
|
||||
// Only three fields are read off it here, so subscribing to the whole object
|
||||
// re-ran this hook (and re-created all ~9 statusbar items) per token. Select
|
||||
// each field individually so an unchanged readout bails out instead.
|
||||
const focusedBusy = useStoreSelector($focusedSessionState, state => Boolean(state?.busy))
|
||||
const focusedTurnStartedAt = useStoreSelector($focusedSessionState, state => state?.turnStartedAt ?? null)
|
||||
// `usage` is an object, so it can't be compared as a scalar. It IS however
|
||||
// replaced wholesale rather than mutated, and only changes when the backend
|
||||
// reports new usage — far rarer than a delta — so its reference is a valid
|
||||
// bail-out key on its own.
|
||||
const focusedUsage = useStoreSelector($focusedSessionState, state => state?.usage ?? null)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
|
||||
|
||||
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
|
||||
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
|
||||
const busy = primaryFocused ? primaryBusy : focusedBusy
|
||||
|
||||
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
|
||||
// a fresh `{...}` each render would bust the usage-label memos below.
|
||||
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
|
||||
const currentUsage = primaryFocused ? primaryUsage : (focusedUsage ?? EMPTY_USAGE)
|
||||
|
||||
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
|
||||
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : focusedTurnStartedAt
|
||||
|
||||
// A tile's session-start comes from its stored row (the cache only knows
|
||||
// runtime state); seconds → ms.
|
||||
const focusedRow = focusedStoredSessionId
|
||||
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
|
||||
: null
|
||||
// runtime state); seconds → ms. Only this ONE scalar is read off
|
||||
// `$sessions`, so select it — a whole-list `useStore` re-ran the hook on
|
||||
// every session-list write (title updates, poll refreshes, archives).
|
||||
const focusedRowStartedAt = useStoreSelector($sessions, sessions =>
|
||||
focusedStoredSessionId
|
||||
? (sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))?.started_at ?? null)
|
||||
: null
|
||||
)
|
||||
|
||||
const sessionStartedAt = primaryFocused
|
||||
? primarySessionStartedAt
|
||||
: focusedRow?.started_at
|
||||
? focusedRow.started_at * 1000
|
||||
: focusedRowStartedAt
|
||||
? focusedRowStartedAt * 1000
|
||||
: null
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
@@ -168,18 +196,6 @@ export function useStatusbarItems({
|
||||
[gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
|
||||
)
|
||||
|
||||
// The indicator must speak the same scope as the Spawn-tree panel it opens:
|
||||
// every session's subagents, never background system actions (gateway
|
||||
// restarts, toolset installs) which surface in their own panels.
|
||||
const { subagentsFailed, subagentsRunning } = useMemo(() => {
|
||||
const lists = Object.values(subagentsBySession)
|
||||
|
||||
return {
|
||||
subagentsFailed: lists.reduce((sum, items) => sum + failedSubagentCount(items), 0),
|
||||
subagentsRunning: lists.reduce((sum, items) => sum + activeSubagentCount(items), 0)
|
||||
}
|
||||
}, [subagentsBySession])
|
||||
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const gatewayConnecting = gatewayState === 'connecting'
|
||||
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
|
||||
@@ -233,8 +249,12 @@ export function useStatusbarItems({
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-client',
|
||||
label,
|
||||
// Update state is not a preference: hiding it is how a user misses that
|
||||
// their client is behind. Listed in the menu, but locked on.
|
||||
lockedVisible: true,
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
title: tooltip || undefined,
|
||||
toggleLabel: copy.toggleVersion,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
@@ -283,8 +303,10 @@ export function useStatusbarItems({
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
lockedVisible: true,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
toggleLabel: copy.toggleBackendVersion,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
@@ -334,8 +356,12 @@ export function useStatusbarItems({
|
||||
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
id: 'command-center',
|
||||
// The system icon: the way into every other surface, including the
|
||||
// settings that would bring a hidden item back. Never hideable.
|
||||
lockedVisible: true,
|
||||
onSelect: toggleCommandCenter,
|
||||
title: commandCenterOpen ? copy.closeCommandCenter : copy.openCommandCenter,
|
||||
toggleLabel: copy.toggleCommandCenter,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
@@ -353,6 +379,7 @@ export function useStatusbarItems({
|
||||
menuClassName: 'w-72',
|
||||
menuContent: gatewayMenuContent,
|
||||
title: inferenceStatus?.reason || copy.gatewayTitle,
|
||||
toggleLabel: copy.gateway,
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
@@ -386,6 +413,7 @@ export function useStatusbarItems({
|
||||
]
|
||||
: undefined,
|
||||
title: currentCwd || undefined,
|
||||
toggleLabel: copy.toggleWorkspace,
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
@@ -411,6 +439,7 @@ export function useStatusbarItems({
|
||||
label: copy.agents,
|
||||
onSelect: openAgents,
|
||||
title: agentsOpen ? copy.closeAgents : copy.openAgents,
|
||||
toggleLabel: copy.agents,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
@@ -419,6 +448,7 @@ export function useStatusbarItems({
|
||||
label: copy.cron,
|
||||
title: copy.openCron,
|
||||
to: CRON_ROUTE,
|
||||
toggleLabel: copy.cron,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
@@ -427,6 +457,7 @@ export function useStatusbarItems({
|
||||
label: copy.webhooks,
|
||||
title: copy.openWebhooks,
|
||||
to: WEBHOOKS_ROUTE,
|
||||
toggleLabel: copy.webhooks,
|
||||
variant: 'action'
|
||||
}
|
||||
],
|
||||
@@ -492,7 +523,8 @@ export function useStatusbarItems({
|
||||
},
|
||||
{
|
||||
...approvalModeItem,
|
||||
hidden: gatewayState !== 'open'
|
||||
hidden: gatewayState !== 'open',
|
||||
toggleLabel: copy.toggleApprovalMode
|
||||
},
|
||||
{
|
||||
actionId: 'view.showTerminal',
|
||||
@@ -502,6 +534,7 @@ export function useStatusbarItems({
|
||||
id: 'terminal',
|
||||
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
|
||||
toggleLabel: copy.toggleTerminal,
|
||||
variant: 'action'
|
||||
},
|
||||
clientVersionItem,
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { type ComponentProps, type ReactNode, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ComponentProps, memo, type ReactNode, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $statusbarHiddenIds, setStatusbarItemVisible } from '@/store/statusbar-prefs'
|
||||
|
||||
// Shared chrome styling for interactive statusbar items (button / link / menu
|
||||
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
|
||||
@@ -47,6 +58,14 @@ export interface StatusbarItem {
|
||||
title?: string
|
||||
to?: string
|
||||
variant?: 'action' | 'link' | 'menu' | 'text'
|
||||
/** Plain-text name for the bar's right-click show/hide menu. An item without
|
||||
* one is never listed there and always shows — the safe default for plugin
|
||||
* contributions that don't opt in. */
|
||||
toggleLabel?: string
|
||||
/** Listed in the menu but not switchable: the bar's own affordances (command
|
||||
* center, update/version pills) would strand the user if they could be
|
||||
* hidden from the surface that hides them. */
|
||||
lockedVisible?: boolean
|
||||
}
|
||||
|
||||
export interface StatusbarSelectModifiers {
|
||||
@@ -63,39 +82,114 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hiddenIds = useStore($statusbarHiddenIds)
|
||||
|
||||
const visible = (item: StatusbarItem) =>
|
||||
!item.hidden && (item.lockedVisible || !item.toggleLabel || !hiddenIds.includes(item.id))
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
data-slot="statusbar"
|
||||
{...props}
|
||||
>
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
data-slot="statusbar"
|
||||
{...props}
|
||||
>
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems.filter(visible).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items.filter(visible).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</ContextMenuTrigger>
|
||||
<StatusbarVisibilityMenu hiddenIds={hiddenIds} items={items} leftItems={leftItems} />
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
||||
/** Right-click the bar to choose what it shows. Lists every item that named
|
||||
* itself with `toggleLabel`, in bar order (left cluster then right), so the
|
||||
* menu reads like the surface it edits. */
|
||||
function StatusbarVisibilityMenu({
|
||||
hiddenIds,
|
||||
items,
|
||||
leftItems
|
||||
}: {
|
||||
hiddenIds: readonly string[]
|
||||
items: readonly StatusbarItem[]
|
||||
leftItems: readonly StatusbarItem[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar
|
||||
|
||||
// Deduped by id: an item can legitimately appear in both clusters across
|
||||
// renders (contributions move sides), and a repeated checkbox would let one
|
||||
// row's toggle silently contradict the other's.
|
||||
const toggles = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
return [...leftItems, ...items].filter(item => {
|
||||
if (!item.toggleLabel || seen.has(item.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seen.add(item.id)
|
||||
|
||||
return true
|
||||
})
|
||||
}, [items, leftItems])
|
||||
|
||||
if (toggles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuLabel>{copy.customizeTitle}</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
{toggles.map(item => (
|
||||
<ContextMenuCheckboxItem
|
||||
checked={item.lockedVisible || !hiddenIds.includes(item.id)}
|
||||
disabled={item.lockedVisible}
|
||||
key={item.id}
|
||||
onCheckedChange={checked => setStatusbarItemVisible(item.id, checked)}
|
||||
// Radix closes the menu on select; keep it open so several items can
|
||||
// be toggled in one pass (this is a preferences surface, not a
|
||||
// command list).
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">{item.toggleLabel}</span>
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
/** Memoized: `useStatusbarItems` rebuilds the item array whenever ANY of its
|
||||
* inputs change, but each individual item object is usually identical across
|
||||
* those rebuilds. Without this, one changed item (the running timer, say)
|
||||
* re-rendered every other item in the bar — measured at 1,446 wasted renders
|
||||
* of 2,174 during a five-tab streaming run. `navigate` is stable for the
|
||||
* router's lifetime, so item identity is the only real input. */
|
||||
const StatusbarItemView = memo(function StatusbarItemView({
|
||||
item,
|
||||
navigate
|
||||
}: {
|
||||
item: StatusbarItem
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
|
||||
@@ -230,4 +324,4 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { $statusbarHiddenIds, STATUSBAR_HIDDEN_BY_DEFAULT } from '@/store/statusbar-prefs'
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
HTMLElement.prototype.scrollIntoView ??= () => undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT])
|
||||
})
|
||||
|
||||
const item = (id: string, label: string, extra: Partial<StatusbarItem> = {}): StatusbarItem => ({
|
||||
id,
|
||||
label,
|
||||
toggleLabel: label,
|
||||
variant: 'action',
|
||||
...extra
|
||||
})
|
||||
|
||||
function bar(items: StatusbarItem[]) {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<StatusbarControls items={items} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
return screen.getByRole('contentinfo')
|
||||
}
|
||||
|
||||
/** Radix opens a ContextMenu on contextmenu after a pointerdown positions it. */
|
||||
function openContextMenu(target: HTMLElement) {
|
||||
fireEvent.pointerDown(target, { button: 2, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.contextMenu(target, { button: 2 })
|
||||
}
|
||||
|
||||
describe('statusbar item visibility', () => {
|
||||
it('hides the route/toggle items out of the box and keeps status items', () => {
|
||||
bar([
|
||||
item('cron', 'Cron'),
|
||||
item('webhooks', 'Webhooks'),
|
||||
item('agents', 'Agents'),
|
||||
item('terminal', 'Terminal'),
|
||||
item('approval-mode', 'Approvals'),
|
||||
item('gateway-health', 'Gateway')
|
||||
])
|
||||
|
||||
for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal', 'Approvals']) {
|
||||
expect(screen.queryByText(label)).toBeNull()
|
||||
}
|
||||
|
||||
expect(screen.getByText('Gateway')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows an item once the user enables it from the bar context menu', async () => {
|
||||
const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')])
|
||||
|
||||
expect(screen.queryByText('Cron')).toBeNull()
|
||||
|
||||
openContextMenu(statusbar)
|
||||
|
||||
const row = await screen.findByRole('menuitemcheckbox', { name: 'Cron' })
|
||||
fireEvent.click(row)
|
||||
|
||||
expect($statusbarHiddenIds.get()).not.toContain('cron')
|
||||
expect(within(statusbar).getByText('Cron')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('never lets the user hide a locked item (system icon / update pill)', async () => {
|
||||
const statusbar = bar([item('command-center', 'Command Center', { lockedVisible: true })])
|
||||
|
||||
openContextMenu(statusbar)
|
||||
|
||||
const row = await screen.findByRole('menuitemcheckbox', { name: 'Command Center' })
|
||||
expect(row.getAttribute('data-disabled')).not.toBeNull()
|
||||
expect(row.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('leaves items that never opted into the menu alone', () => {
|
||||
$statusbarHiddenIds.set(['plugin-thing'])
|
||||
bar([{ id: 'plugin-thing', label: 'Plugin thing', variant: 'action' }])
|
||||
|
||||
expect(screen.getByText('Plugin thing')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -277,7 +277,7 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
type: match[1] || 'file',
|
||||
label: shortLabel(match[1] as HermesRefType, id),
|
||||
label: refChipLabel(match[1] || 'file', id),
|
||||
id
|
||||
}
|
||||
}),
|
||||
@@ -320,25 +320,30 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
|
||||
return segments
|
||||
}
|
||||
|
||||
function shortLabel(type: HermesRefType, id: string): string {
|
||||
/** Display text for a `@kind:value` chip. Shared with the composer's
|
||||
* contenteditable chips so a link reads the same before and after send: the
|
||||
* host leads (scheme and `www.` are noise) and the path rides along for the
|
||||
* chip's `truncate` to cut — a bare hostname can't tell two links apart. */
|
||||
export function refChipLabel(type: string, id: string): string {
|
||||
if (type === 'terminal') {
|
||||
return id || 'terminal'
|
||||
}
|
||||
|
||||
if (type === 'session') {
|
||||
return sessionRefFallbackLabel(id)
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
try {
|
||||
const parsed = new URL(id)
|
||||
const { hostname, pathname, search } = new URL(id)
|
||||
const path = `${pathname}${search}`.replace(/\/$/, '')
|
||||
|
||||
return parsed.hostname || id
|
||||
return `${hostname.replace(/^www\./i, '')}${path}` || id
|
||||
} catch {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'session') {
|
||||
return sessionRefFallbackLabel(id)
|
||||
}
|
||||
|
||||
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||||
|
||||
return tail || id
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
Element.prototype.animate = function animate() {
|
||||
return {
|
||||
cancel: () => {},
|
||||
finished: Promise.resolve()
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; the virtualizer reads those to size its
|
||||
// viewport. Fall through to client* or a sane default so virtualized
|
||||
// items render (same stub as streaming.test.tsx).
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
const MESSAGES: ThreadMessage[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello from the user' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage,
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'stable assistant reply' }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
]
|
||||
|
||||
function Harness({
|
||||
onBranchInNewChat,
|
||||
onCancel
|
||||
}: {
|
||||
onBranchInNewChat: (messageId: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: MESSAGES,
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread onBranchInNewChat={onBranchInNewChat} onCancel={onCancel} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('thread message mount stability', () => {
|
||||
// Regression: the desktop controller re-renders every 15s (status
|
||||
// snapshot poll) and used to pass freshly-created callbacks down to
|
||||
// <Thread/>. Those callbacks were deps of the `messageComponents`
|
||||
// useMemo, so new component *types* were created each poll and React
|
||||
// unmounted/remounted every visible message — shiki re-highlighted
|
||||
// code blocks and the whole thread visibly jumped.
|
||||
it('keeps message DOM nodes mounted when callback props get new identities', async () => {
|
||||
const { rerender } = render(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('stable assistant reply')).toBeTruthy()
|
||||
expect(screen.getByText('hello from the user')).toBeTruthy()
|
||||
})
|
||||
|
||||
const assistantBefore = screen.getByText('stable assistant reply')
|
||||
const userBefore = screen.getByText('hello from the user')
|
||||
|
||||
// Same data, new callback identities — exactly what a parent
|
||||
// re-render driven by an unrelated state update produces.
|
||||
await act(async () => {
|
||||
rerender(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
|
||||
})
|
||||
|
||||
expect(screen.getByText('stable assistant reply')).toBe(assistantBefore)
|
||||
expect(screen.getByText('hello from the user')).toBe(userBefore)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, useCallback, useMemo, useState } from 'react'
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
|
||||
@@ -67,21 +67,53 @@ export const Thread: FC<{
|
||||
setRestoreConfirmTarget({ messageId, ...target })
|
||||
}, [])
|
||||
|
||||
// The values in this map are component *types*: when their identity
|
||||
// changes, React unmounts and remounts every visible message — async
|
||||
// re-rendered parts (shiki code blocks) collapse and re-expand, so the
|
||||
// whole thread visibly jumps. Parents re-render on unrelated state
|
||||
// (e.g. the 15s status-snapshot poll in the desktop controller) and
|
||||
// can't be trusted to keep callback identities stable (see #38333), so
|
||||
// route the callbacks through a ref instead of listing them as memo
|
||||
// deps. Only their definedness stays a dep — it gates UI (the user
|
||||
// Stop button, the restore-confirm affordance). Assigned during render
|
||||
// (the useStoreSelector pattern) so the ref never lags a render.
|
||||
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
|
||||
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
|
||||
|
||||
const hasBranchInNewChat = Boolean(onBranchInNewChat)
|
||||
const hasCancel = Boolean(onCancel)
|
||||
const hasDismissError = Boolean(onDismissError)
|
||||
const hasRestoreToMessage = Boolean(onRestoreToMessage)
|
||||
|
||||
const messageComponents = useMemo(
|
||||
() => ({
|
||||
AssistantMessage: () => (
|
||||
<AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />
|
||||
<AssistantMessage
|
||||
onBranchInNewChat={
|
||||
hasBranchInNewChat ? messageId => callbacksRef.current.onBranchInNewChat?.(messageId) : undefined
|
||||
}
|
||||
onDismissError={hasDismissError ? messageId => callbacksRef.current.onDismissError?.(messageId) : undefined}
|
||||
/>
|
||||
),
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => (
|
||||
<UserMessage
|
||||
onCancel={onCancel}
|
||||
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
|
||||
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
|
||||
onRequestRestoreConfirm={hasRestoreToMessage ? requestRestoreConfirm : undefined}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId]
|
||||
[
|
||||
cwd,
|
||||
gateway,
|
||||
hasBranchInNewChat,
|
||||
hasCancel,
|
||||
hasDismissError,
|
||||
hasRestoreToMessage,
|
||||
requestRestoreConfirm,
|
||||
sessionId
|
||||
]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
onComposerInsertRequest
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
@@ -38,6 +40,8 @@ import {
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
HERMES_PATHS_MIME,
|
||||
@@ -213,6 +217,22 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
[aui]
|
||||
)
|
||||
|
||||
// Same stack the main composer owns, for the same reason: the editor mutates
|
||||
// through `Range` to dodge Chromium's O(n²) editing pipeline, which also
|
||||
// dodges its undo stack, so a paste was invisible to Cmd+Z. `rememberInitialDraft`
|
||||
// already marks every mutation site (it's the dirty-edit guard), so the undo
|
||||
// points ride along with it.
|
||||
const syncFromEditorRef = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
return editor ? syncDraftFromEditor(editor) : draftRef.current
|
||||
}, [syncDraftFromEditor])
|
||||
|
||||
const { recordUndoPoint, redo, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor: syncFromEditorRef
|
||||
})
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -276,6 +296,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
@@ -325,7 +346,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
document.execCommand('insertText', false, text)
|
||||
finish()
|
||||
},
|
||||
[aui, closeTrigger, refreshTrigger, rememberInitialDraft, requestEditFocus, trigger]
|
||||
[aui, closeTrigger, recordUndoPoint, refreshTrigger, rememberInitialDraft, requestEditFocus, trigger]
|
||||
)
|
||||
|
||||
const insertRefStrings = useCallback(
|
||||
@@ -336,20 +357,23 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
return false
|
||||
}
|
||||
|
||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||
// Bank BEFORE the insert — insertInlineRefsIntoEditor mutates in place, so
|
||||
// recording after it would snapshot the state we're trying to undo to.
|
||||
const undone = withUndoPoint(() => insertInlineRefsIntoEditor(editor, refs) !== null)
|
||||
|
||||
if (nextDraft === null) {
|
||||
if (!undone) {
|
||||
return false
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestEditFocus()
|
||||
|
||||
return true
|
||||
},
|
||||
[aui, rememberInitialDraft, requestEditFocus]
|
||||
[aui, rememberInitialDraft, requestEditFocus, withUndoPoint]
|
||||
)
|
||||
|
||||
const insertDroppedRefs = useCallback(
|
||||
@@ -492,6 +516,19 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
// Native typing/deleting still goes through Chromium's editing pipeline, whose
|
||||
// undo stack we've taken over — bank the pre-edit state here, while
|
||||
// `beforeinput` can still see the old text.
|
||||
const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
|
||||
|
||||
@@ -503,7 +540,10 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
recordUndoPoint()
|
||||
|
||||
// Links land as `@url:` chips, same as the main composer.
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
@@ -595,6 +635,22 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
}
|
||||
}
|
||||
|
||||
// Undo/redo before Escape — we own the stack, and a stray Cmd+Z must never
|
||||
// fall through to something that cancels the edit outright.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aui.composer().cancel()
|
||||
@@ -602,6 +658,15 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
return
|
||||
}
|
||||
|
||||
// A typed link finished with a space chips like a pasted one.
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitEdit(event.currentTarget)
|
||||
@@ -669,6 +734,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
contentEditable
|
||||
data-placeholder={copy.editMessage}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -340,6 +340,28 @@ describe('buildToolView title actions', () => {
|
||||
expect(view.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' pnpm run lint' })
|
||||
})
|
||||
|
||||
it('never stutters the verb or echoes the command when the backend context is a phrased label', () => {
|
||||
// Older backends stamped tool.start with a *phrased* label
|
||||
// ("Running sleep 70 + 2 commands") rather than a raw arg preview, and the
|
||||
// desktop merges that into args.context. The row must still prepend its own
|
||||
// verb exactly once, show the real command in the `$` transcript, and not
|
||||
// repeat either string as detail.
|
||||
const command = 'sleep 70; echo "a"; echo "b"'
|
||||
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { command, context: 'Running sleep 70 + 2 commands' },
|
||||
result: { exit_code: 0 },
|
||||
toolName: 'terminal'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Ran sleep 70 + 2 commands')
|
||||
expect(view.terminalCommand).toBe(command)
|
||||
expect(view.detail).toBe('')
|
||||
})
|
||||
|
||||
it('uses the runtime locale for title text and action placement', () => {
|
||||
setRuntimeI18nLocale('ja')
|
||||
|
||||
|
||||
@@ -128,9 +128,14 @@ function readFileDisplayTarget(args: Record<string, unknown>, result: Record<str
|
||||
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// The real command, preferring the actual argument over the backend's
|
||||
// display preview. `context` is a *summarized* preview ("sleep 70 + 2
|
||||
// commands") the gateway sends on tool.start before real args arrive — fine
|
||||
// as a placeholder for the live title, wrong for the `$` transcript, which
|
||||
// must show what actually ran.
|
||||
function shellCommand(args: Record<string, unknown>): string {
|
||||
return (
|
||||
firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||
firstStringField(args, ['command', 'code']) || firstStringField(args, ['context', 'preview']) || contextValue(args)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1079,6 +1084,13 @@ function toolDetailText(
|
||||
if (output || lines) {
|
||||
return [output, lines].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
// A terminal row with no output already shows its command in the `$`
|
||||
// transcript above; the generic fallback would print the same string a
|
||||
// second time. `execute_code` has no transcript, so it keeps the fallback.
|
||||
if (part.toolName === 'terminal') {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'web_extract') {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isUnboundableTool, shouldBoundToolGroup, technicalTrace } from './fallback'
|
||||
import { isFileEditTool } from './fallback-model'
|
||||
|
||||
describe('shouldBoundToolGroup', () => {
|
||||
it('bounds long runs of ordinary tool calls', () => {
|
||||
@@ -23,25 +22,18 @@ describe('isUnboundableTool', () => {
|
||||
expect(isUnboundableTool('image_generate')).toBe(true)
|
||||
})
|
||||
|
||||
it('exempts tools whose body is a code block the user reads', () => {
|
||||
expect(isUnboundableTool('execute_code')).toBe(true)
|
||||
expect(isUnboundableTool('read_file')).toBe(true)
|
||||
})
|
||||
|
||||
// The window clips a diff to a ~2-row viewport, so every tool that renders
|
||||
// one has to be exempt. Derived from isFileEditTool rather than re-listed,
|
||||
// so a newly supported edit tool can't be exempt in one place and clipped
|
||||
// in the other.
|
||||
it('exempts every file-edit tool, so diffs are never clipped', () => {
|
||||
for (const toolName of ['edit_file', 'patch', 'write_file']) {
|
||||
expect(isFileEditTool(toolName)).toBe(true)
|
||||
expect(isUnboundableTool(toolName)).toBe(true)
|
||||
// Everything ToolEntry renders carries `data-tool-row`, so the
|
||||
// `:has([data-tool-row][data-tool-open])` rule in styles.css lifts the cap
|
||||
// on its own. A diff row mounts open and frees the group immediately; a
|
||||
// collapsed row has no body in the DOM to clip. Exempting these in JS
|
||||
// instead vetoed grouping for the whole run — and since reads and edits are
|
||||
// most of a coding session, runs of 19 calls never collapsed at all.
|
||||
it('bounds the rows the CSS break-out already covers', () => {
|
||||
for (const toolName of ['read_file', 'execute_code', 'edit_file', 'patch', 'write_file']) {
|
||||
expect(isUnboundableTool(toolName)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Console output is a log tail: the last lines are the ones that matter,
|
||||
// which is exactly what the bounded window pins. Keeping it boundable is
|
||||
// what stops the exemption from swallowing the feature whole.
|
||||
it('still bounds console output and other ordinary rows', () => {
|
||||
expect(isUnboundableTool('terminal')).toBe(false)
|
||||
expect(isUnboundableTool('web_search')).toBe(false)
|
||||
|
||||
@@ -700,29 +700,24 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
|
||||
// auto-scrolling window; fewer than this stays a plain inline stack.
|
||||
const TOOL_GROUP_SCROLL_THRESHOLD = 3
|
||||
|
||||
// Tools whose body (an interactive form, a full-size image, a syntax-
|
||||
// highlighted code/diff block) must never be trapped behind the window's
|
||||
// max-height + fade mask. A run holding any of them stays a plain, fully-
|
||||
// visible stack no matter how long it is.
|
||||
// Tools whose body must never be trapped behind the window's max-height +
|
||||
// fade mask. A run holding any of them stays a plain, fully-visible stack no
|
||||
// matter how long it is.
|
||||
//
|
||||
// A row rendered by ToolEntry carries `data-tool-row`, so once the user
|
||||
// expands it the `:has([data-tool-row][data-tool-open])` rule in styles.css
|
||||
// lifts the cap on its own. That escape hatch is why most tools are safe to
|
||||
// bound. These are the ones it cannot reach:
|
||||
// This list is deliberately tiny. A row rendered by ToolEntry carries
|
||||
// `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in
|
||||
// styles.css lifts the cap whenever one is open — so anything ToolEntry
|
||||
// renders takes care of itself. A code/diff row mounts open (see
|
||||
// `defaultOpen`), lifting the cap the moment it appears; a collapsed row is a
|
||||
// one-line status with no body in the DOM at all, so there is nothing to clip.
|
||||
//
|
||||
// - `clarify` / `image_generate` render their own components and never emit
|
||||
// `data-tool-row`, so no amount of expanding frees them.
|
||||
// - the code tools *do* emit it, but their body is a code block the user
|
||||
// reads rather than a one-line status — peering at a diff through a
|
||||
// ~2-row viewport until you think to expand it is the bug. Console output
|
||||
// (`terminal`) stays boundable: it's a log tail, and the last lines are
|
||||
// the ones that matter, which is exactly what the window pins.
|
||||
const CODE_BODY_TOOLS = ['execute_code', 'read_file']
|
||||
|
||||
const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate', ...CODE_BODY_TOOLS])
|
||||
// Only components that bypass ToolEntry need this opt-out: `clarify` and
|
||||
// `image_generate` render their own markup, never emit `data-tool-row`, and so
|
||||
// the CSS escape hatch can never reach them.
|
||||
const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate'])
|
||||
|
||||
export function isUnboundableTool(toolName: string): boolean {
|
||||
return UNBOUNDABLE_TOOLS.has(toolName) || isFileEditTool(toolName)
|
||||
return UNBOUNDABLE_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) {
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, useNavigate } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FindBar } from '@/components/find-bar'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { en } from '@/i18n/en'
|
||||
import { zh } from '@/i18n/zh'
|
||||
import { findBarClaimsCombo, findBarKeyAction, formatMatchLabel } from '@/lib/find-in-page'
|
||||
import { KEYBIND_ACTIONS } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput } from '@/lib/keybinds/combo'
|
||||
import {
|
||||
$findInPage,
|
||||
closeFindBar,
|
||||
findInPageListenerCount,
|
||||
findNext,
|
||||
findPrevious,
|
||||
initFindInPageListener,
|
||||
openFindBar,
|
||||
resetFindInPageListenerForTest,
|
||||
setFindQuery,
|
||||
updateFindResults
|
||||
} from '@/store/find-in-page'
|
||||
|
||||
// ── Bridge double ───────────────────────────────────────────────────────────
|
||||
// Stands in for the preload `hermesDesktop` surface. `onFoundInPage` records
|
||||
// its subscribers so the tests can assert the listener refcount and drive
|
||||
// results back into the store the way the main process would.
|
||||
|
||||
interface FoundResult {
|
||||
activeMatchOrdinal: number
|
||||
count: number
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
const findInPage = vi.fn().mockResolvedValue({ count: 0 })
|
||||
const stopFindInPage = vi.fn().mockResolvedValue(undefined)
|
||||
const subscribers = new Set<(result: FoundResult) => void>()
|
||||
|
||||
const onFoundInPage = vi.fn((callback: (result: FoundResult) => void) => {
|
||||
subscribers.add(callback)
|
||||
|
||||
return () => subscribers.delete(callback)
|
||||
})
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
findInPage,
|
||||
stopFindInPage,
|
||||
onFoundInPage
|
||||
}
|
||||
|
||||
return {
|
||||
findInPage,
|
||||
stopFindInPage,
|
||||
onFoundInPage,
|
||||
subscribers,
|
||||
emit(result: FoundResult) {
|
||||
for (const callback of [...subscribers]) {
|
||||
callback(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
$findInPage.set({ active: false, query: '', matchOrdinal: 0, matchCount: 0 })
|
||||
}
|
||||
|
||||
// Zero the bridge refcount so a leaked subscription can't bleed between tests.
|
||||
function drainListeners() {
|
||||
resetFindInPageListenerForTest()
|
||||
}
|
||||
|
||||
let bridge: ReturnType<typeof installBridge>
|
||||
|
||||
beforeEach(() => {
|
||||
bridge = installBridge()
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetStore()
|
||||
drainListeners()
|
||||
vi.restoreAllMocks()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
// ── Pure: match-count formatting ────────────────────────────────────────────
|
||||
|
||||
describe('formatMatchLabel', () => {
|
||||
it('hides the counter entirely when there is no query', () => {
|
||||
expect(formatMatchLabel('', 0, 0)).toBe('')
|
||||
// Even if stale counts linger from a previous search.
|
||||
expect(formatMatchLabel('', 3, 12)).toBe('')
|
||||
})
|
||||
|
||||
it('reports an explicit zero when a query matches nothing', () => {
|
||||
expect(formatMatchLabel('nope', 0, 0)).toBe('0/0')
|
||||
})
|
||||
|
||||
it('formats ordinal over count', () => {
|
||||
expect(formatMatchLabel('hit', 3, 12)).toBe('3/12')
|
||||
expect(formatMatchLabel('hit', 1, 1)).toBe('1/1')
|
||||
})
|
||||
|
||||
it('shows 0 ordinal for the frame before the first match is selected', () => {
|
||||
// Electron legitimately reports matches with activeMatchOrdinal 0 on the
|
||||
// first (non-final) update of a fresh search.
|
||||
expect(formatMatchLabel('hit', 0, 5)).toBe('0/5')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range ordinal into the count', () => {
|
||||
expect(formatMatchLabel('hit', 99, 5)).toBe('5/5')
|
||||
expect(formatMatchLabel('hit', -3, 5)).toBe('0/5')
|
||||
})
|
||||
|
||||
it('never emits NaN for non-finite input', () => {
|
||||
expect(formatMatchLabel('hit', Number.NaN, 4)).toBe('0/4')
|
||||
expect(formatMatchLabel('hit', 2, Number.NaN)).toBe('0/0')
|
||||
expect(formatMatchLabel('hit', 2, Number.POSITIVE_INFINITY)).toBe('0/0')
|
||||
})
|
||||
|
||||
it('floors fractional counts rather than rendering decimals', () => {
|
||||
expect(formatMatchLabel('hit', 2.7, 9.9)).toBe('2/9')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Pure: keybinding matcher ────────────────────────────────────────────────
|
||||
|
||||
describe('findBarKeyAction', () => {
|
||||
it('maps Escape to close', () => {
|
||||
expect(findBarKeyAction({ key: 'Escape' })).toBe('close')
|
||||
})
|
||||
|
||||
it('maps Cmd+G / Ctrl+G to next from anywhere', () => {
|
||||
expect(findBarKeyAction({ key: 'g', metaKey: true })).toBe('next')
|
||||
expect(findBarKeyAction({ key: 'g', ctrlKey: true })).toBe('next')
|
||||
})
|
||||
|
||||
it('maps Cmd+Shift+G / Ctrl+Shift+G to previous', () => {
|
||||
// event.key is uppercase 'G' when Shift is held — matched case-insensitively.
|
||||
expect(findBarKeyAction({ key: 'G', metaKey: true, shiftKey: true })).toBe('previous')
|
||||
expect(findBarKeyAction({ key: 'g', ctrlKey: true, shiftKey: true })).toBe('previous')
|
||||
})
|
||||
|
||||
it('steps with Enter / Shift+Enter only while focus is in the input', () => {
|
||||
expect(findBarKeyAction({ key: 'Enter' }, { inInput: true })).toBe('next')
|
||||
expect(findBarKeyAction({ key: 'Enter', shiftKey: true }, { inInput: true })).toBe('previous')
|
||||
// Bare Enter outside the input belongs to the composer, not the find bar.
|
||||
expect(findBarKeyAction({ key: 'Enter' })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a bare g so typing never triggers a step', () => {
|
||||
expect(findBarKeyAction({ key: 'g' })).toBeNull()
|
||||
expect(findBarKeyAction({ key: 'g' }, { inInput: true })).toBeNull()
|
||||
expect(findBarKeyAction({ key: 'G', shiftKey: true }, { inInput: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('falls through when Alt is held so ⌥⌘G is not swallowed', () => {
|
||||
expect(findBarKeyAction({ key: 'g', metaKey: true, altKey: true })).toBeNull()
|
||||
expect(findBarKeyAction({ key: 'Escape', altKey: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat Cmd+Escape as close', () => {
|
||||
expect(findBarKeyAction({ key: 'Escape', metaKey: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unrelated keys', () => {
|
||||
expect(findBarKeyAction({ key: 'f', metaKey: true })).toBeNull()
|
||||
expect(findBarKeyAction({ key: 'Tab' }, { inInput: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findBarClaimsCombo', () => {
|
||||
it('claims the step accelerators and Escape', () => {
|
||||
expect(findBarClaimsCombo('mod+g')).toBe(true)
|
||||
expect(findBarClaimsCombo('mod+shift+g')).toBe(true)
|
||||
expect(findBarClaimsCombo('escape')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves every other combo to the keybind registry', () => {
|
||||
// ⌘F must still reach view.findInPage even while the bar is open.
|
||||
expect(findBarClaimsCombo('mod+f')).toBe(false)
|
||||
expect(findBarClaimsCombo('mod+k')).toBe(false)
|
||||
expect(findBarClaimsCombo('mod+b')).toBe(false)
|
||||
expect(findBarClaimsCombo('enter')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Keybind registration ────────────────────────────────────────────────────
|
||||
|
||||
describe('find-in-page keybind registration', () => {
|
||||
const byId = new Map(KEYBIND_ACTIONS.map(action => [action.id, action]))
|
||||
|
||||
it('registers view.findInPage on mod+f in the view category', () => {
|
||||
const action = byId.get('view.findInPage')
|
||||
|
||||
expect(action).toBeTruthy()
|
||||
expect(action?.category).toBe('view')
|
||||
expect(action?.defaults).toEqual(['mod+f'])
|
||||
})
|
||||
|
||||
it('mod+f fires from inside a textarea (browser find behavior)', () => {
|
||||
// The runtime consults comboAllowedInInput before dispatching a combo
|
||||
// while an editable element owns focus; if mod combos ever stop
|
||||
// qualifying, ⌘F from the composer would type 'f' instead of opening find.
|
||||
expect(comboAllowedInInput('mod+f')).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the step pair unbound so it cannot conflict with view.toggleReview', () => {
|
||||
const next = byId.get('view.findNext')
|
||||
const previous = byId.get('view.findPrevious')
|
||||
|
||||
expect(next?.category).toBe('view')
|
||||
expect(previous?.category).toBe('view')
|
||||
// mod+g stays with view.toggleReview by default; the open find bar claims
|
||||
// it at dispatch time instead (findBarClaimsCombo above).
|
||||
expect(next?.defaults).toEqual([])
|
||||
expect(previous?.defaults).toEqual([])
|
||||
expect(byId.get('view.toggleReview')?.defaults).toEqual(['mod+g'])
|
||||
})
|
||||
|
||||
it('every registered find action has an i18n label (keybinds panel row)', () => {
|
||||
for (const id of ['view.findInPage', 'view.findNext', 'view.findPrevious']) {
|
||||
expect(en.keybinds.actions[id], id).toBeTruthy()
|
||||
expect(zh.keybinds.actions[id], id).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Store: open/close state + dispatch ──────────────────────────────────────
|
||||
|
||||
describe('find-in-page store', () => {
|
||||
it('opens with a cleared query and counters', () => {
|
||||
updateFindResults(3, 12)
|
||||
openFindBar()
|
||||
|
||||
expect($findInPage.get()).toEqual({ active: true, query: '', matchOrdinal: 0, matchCount: 0 })
|
||||
})
|
||||
|
||||
it('closing clears state and stops the native find (clears selection)', () => {
|
||||
openFindBar()
|
||||
setFindQuery('needle')
|
||||
updateFindResults(2, 7)
|
||||
|
||||
closeFindBar()
|
||||
|
||||
expect($findInPage.get().active).toBe(false)
|
||||
expect($findInPage.get().query).toBe('')
|
||||
expect($findInPage.get().matchCount).toBe(0)
|
||||
expect(bridge.stopFindInPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closing an already-closed bar does not re-issue stopFindInPage', () => {
|
||||
openFindBar()
|
||||
closeFindBar()
|
||||
closeFindBar()
|
||||
|
||||
expect(bridge.stopFindInPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a fresh query searches from scratch (findNext false)', () => {
|
||||
openFindBar()
|
||||
setFindQuery('needle')
|
||||
|
||||
expect(bridge.findInPage).toHaveBeenCalledWith('needle', { forward: true, findNext: false })
|
||||
})
|
||||
|
||||
it('clearing the query stops the find instead of searching for empty', () => {
|
||||
openFindBar()
|
||||
setFindQuery('needle')
|
||||
bridge.findInPage.mockClear()
|
||||
|
||||
setFindQuery('')
|
||||
|
||||
expect(bridge.findInPage).not.toHaveBeenCalled()
|
||||
expect(bridge.stopFindInPage).toHaveBeenCalled()
|
||||
expect($findInPage.get().matchCount).toBe(0)
|
||||
})
|
||||
|
||||
it('findNext steps forward and findPrevious steps backward on the same query', () => {
|
||||
openFindBar()
|
||||
setFindQuery('needle')
|
||||
bridge.findInPage.mockClear()
|
||||
|
||||
findNext()
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: true, findNext: true })
|
||||
|
||||
findPrevious()
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: false, findNext: true })
|
||||
})
|
||||
|
||||
it('stepping with no query is a no-op (never searches invisibly)', () => {
|
||||
openFindBar()
|
||||
|
||||
findNext()
|
||||
findPrevious()
|
||||
|
||||
expect(bridge.findInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setFindQuery on a closed bar never searches', () => {
|
||||
// A debounce timer that already fired, or any late caller, must not
|
||||
// re-highlight the page after the user dismissed the bar.
|
||||
setFindQuery('needle')
|
||||
|
||||
expect(bridge.findInPage).not.toHaveBeenCalled()
|
||||
expect($findInPage.get().query).toBe('')
|
||||
})
|
||||
|
||||
it('found-in-page results land on the store', () => {
|
||||
openFindBar()
|
||||
setFindQuery('needle')
|
||||
const release = initFindInPageListener()
|
||||
|
||||
bridge.emit({ activeMatchOrdinal: 4, count: 9 })
|
||||
|
||||
expect($findInPage.get().matchOrdinal).toBe(4)
|
||||
expect($findInPage.get().matchCount).toBe(9)
|
||||
release()
|
||||
})
|
||||
|
||||
it('refcounts the bridge listener so remounts cannot stack subscriptions', () => {
|
||||
const first = initFindInPageListener()
|
||||
const second = initFindInPageListener()
|
||||
|
||||
// One real bridge subscription regardless of subscriber count.
|
||||
expect(bridge.onFoundInPage).toHaveBeenCalledTimes(1)
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
expect(findInPageListenerCount()).toBe(2)
|
||||
|
||||
first()
|
||||
// Still one holder → the bridge listener stays installed.
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
|
||||
second()
|
||||
// Last holder released → the listener is detached, nothing leaks.
|
||||
expect(bridge.subscribers.size).toBe(0)
|
||||
expect(findInPageListenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('releasing the same subscription twice cannot drive the refcount negative', () => {
|
||||
const release = initFindInPageListener()
|
||||
release()
|
||||
release()
|
||||
|
||||
expect(findInPageListenerCount()).toBe(0)
|
||||
|
||||
// A fresh subscribe after a double-release still installs exactly one.
|
||||
const next = initFindInPageListener()
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
next()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Store mutations that a MOUNTED FindBar subscribes to must be act()-wrapped
|
||||
// so React flushes the resulting re-render inside the test.
|
||||
function actStore(mutate: () => void) {
|
||||
act(() => {
|
||||
mutate()
|
||||
})
|
||||
}
|
||||
|
||||
function renderFindBar(initialPath = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<FindBar />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/** Harness that can navigate the route the mounted FindBar observes. */
|
||||
function renderFindBarWithNavigation(initialPath = '/session/a') {
|
||||
let navigateRef: ReturnType<typeof useNavigate> | undefined
|
||||
|
||||
function CaptureNavigate() {
|
||||
navigateRef = useNavigate()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const view = render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<CaptureNavigate />
|
||||
<FindBar />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
return { ...view, navigate: (to: string) => act(() => navigateRef?.(to)) }
|
||||
}
|
||||
|
||||
describe('FindBar', () => {
|
||||
it('renders nothing while closed', () => {
|
||||
renderFindBar()
|
||||
|
||||
expect(screen.queryByRole('search')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders input, counter and close button when open with results', async () => {
|
||||
openFindBar()
|
||||
renderFindBar()
|
||||
|
||||
const input = await screen.findByRole('textbox', { name: /find in page/i })
|
||||
expect(input).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /next match/i })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /previous match/i })).toBeTruthy()
|
||||
|
||||
// Counter appears once a query + results exist.
|
||||
expect(screen.queryByText('3/12')).toBeNull()
|
||||
actStore(() => $findInPage.set({ active: true, query: 'needle', matchOrdinal: 3, matchCount: 12 }))
|
||||
await waitFor(() => expect(screen.getByText('3/12')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('focuses the input on open', async () => {
|
||||
openFindBar()
|
||||
renderFindBar()
|
||||
|
||||
const input = await screen.findByRole('textbox', { name: /find in page/i })
|
||||
// eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document
|
||||
await waitFor(() => expect(document.activeElement).toBe(input))
|
||||
})
|
||||
|
||||
it('debounces typing into a single findInPage call', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
openFindBar()
|
||||
renderFindBar()
|
||||
|
||||
const input = screen.getByRole('textbox', { name: /find in page/i })
|
||||
fireEvent.change(input, { target: { value: 'n' } })
|
||||
fireEvent.change(input, { target: { value: 'ne' } })
|
||||
fireEvent.change(input, { target: { value: 'nee' } })
|
||||
|
||||
expect(bridge.findInPage).not.toHaveBeenCalled()
|
||||
|
||||
// Flushing the debounce updates the store, which re-renders the bar.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(bridge.findInPage).toHaveBeenCalledTimes(1)
|
||||
expect(bridge.findInPage).toHaveBeenCalledWith('nee', { forward: true, findNext: false })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fire a pending search after the bar closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
openFindBar()
|
||||
renderFindBar()
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /find in page/i }), {
|
||||
target: { value: 'needle' }
|
||||
})
|
||||
|
||||
actStore(closeFindBar)
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(bridge.findInPage).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('Enter dispatches next and Shift+Enter dispatches previous', async () => {
|
||||
$findInPage.set({ active: true, query: 'needle', matchOrdinal: 1, matchCount: 4 })
|
||||
renderFindBar()
|
||||
|
||||
const input = screen.getByRole('textbox', { name: /find in page/i })
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: true, findNext: true })
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: false, findNext: true })
|
||||
})
|
||||
|
||||
it('Cmd+G / Cmd+Shift+G step from outside the input while the bar is open', async () => {
|
||||
$findInPage.set({ active: true, query: 'needle', matchOrdinal: 1, matchCount: 4 })
|
||||
renderFindBar()
|
||||
|
||||
// Fired on window (not the input) — the accelerator must not require focus.
|
||||
fireEvent.keyDown(window, { key: 'g', metaKey: true })
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: true, findNext: true })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'G', metaKey: true, shiftKey: true })
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: false, findNext: true })
|
||||
})
|
||||
|
||||
it('Escape closes the bar and clears the native selection', async () => {
|
||||
openFindBar()
|
||||
renderFindBar()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
|
||||
expect($findInPage.get().active).toBe(false)
|
||||
expect(bridge.stopFindInPage).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => expect(screen.queryByRole('search')).toBeNull())
|
||||
})
|
||||
|
||||
it('the close button clears state and stops the find', async () => {
|
||||
openFindBar()
|
||||
renderFindBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }))
|
||||
|
||||
expect($findInPage.get().active).toBe(false)
|
||||
expect(bridge.stopFindInPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the next / previous buttons dispatch a step', () => {
|
||||
$findInPage.set({ active: true, query: 'needle', matchOrdinal: 1, matchCount: 4 })
|
||||
renderFindBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next match/i }))
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: true, findNext: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous match/i }))
|
||||
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: false, findNext: true })
|
||||
})
|
||||
|
||||
it('keeps exactly one bridge subscription across an open/close cycle', async () => {
|
||||
renderFindBar()
|
||||
|
||||
// Mounted-but-closed already holds the subscription: results for an
|
||||
// in-flight search must still land after the bar hides.
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
|
||||
actStore(openFindBar)
|
||||
await waitFor(() => expect(screen.getByRole('search')).toBeTruthy())
|
||||
actStore(closeFindBar)
|
||||
await waitFor(() => expect(screen.queryByRole('search')).toBeNull())
|
||||
actStore(openFindBar)
|
||||
|
||||
// Toggling visibility must not stack listeners — the subscription is
|
||||
// mount-scoped, not active-scoped.
|
||||
expect(bridge.onFoundInPage).toHaveBeenCalledTimes(1)
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
})
|
||||
|
||||
it('unmount releases the bridge subscription (no leak across route changes)', () => {
|
||||
const { unmount } = renderFindBar()
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(bridge.subscribers.size).toBe(0)
|
||||
expect(findInPageListenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('a remount does not stack subscriptions', () => {
|
||||
const first = renderFindBar()
|
||||
first.unmount()
|
||||
|
||||
const second = renderFindBar()
|
||||
|
||||
expect(bridge.subscribers.size).toBe(1)
|
||||
second.unmount()
|
||||
expect(bridge.subscribers.size).toBe(0)
|
||||
})
|
||||
|
||||
it('the window key listener is removed on unmount', () => {
|
||||
openFindBar()
|
||||
const { unmount } = renderFindBar()
|
||||
|
||||
unmount()
|
||||
bridge.stopFindInPage.mockClear()
|
||||
|
||||
// Reopen the store WITHOUT a mounted bar; a leaked listener would still
|
||||
// handle Escape and call into the bridge.
|
||||
actStore(openFindBar)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
|
||||
expect(bridge.stopFindInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigating to another route closes the bar and clears the highlights', async () => {
|
||||
const { navigate } = renderFindBarWithNavigation('/session/a')
|
||||
|
||||
actStore(openFindBar)
|
||||
actStore(() => $findInPage.set({ active: true, query: 'needle', matchOrdinal: 2, matchCount: 7 }))
|
||||
await waitFor(() => expect(screen.getByRole('search')).toBeTruthy())
|
||||
|
||||
navigate('/session/b')
|
||||
|
||||
// Bar gone, state reset, and the native selection cleared — stale
|
||||
// highlights must not survive a session switch.
|
||||
await waitFor(() => expect(screen.queryByRole('search')).toBeNull())
|
||||
expect($findInPage.get()).toEqual({ active: false, query: '', matchOrdinal: 0, matchCount: 0 })
|
||||
expect(bridge.stopFindInPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('navigation while the bar is closed does not reach into the bridge', async () => {
|
||||
const { navigate } = renderFindBarWithNavigation('/session/a')
|
||||
|
||||
navigate('/session/b')
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('search')).toBeNull())
|
||||
expect(bridge.stopFindInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { findBarKeyAction, formatMatchLabel } from '@/lib/find-in-page'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$findInPage,
|
||||
closeFindBar,
|
||||
findNext,
|
||||
findPrevious,
|
||||
initFindInPageListener,
|
||||
setFindQuery
|
||||
} from '@/store/find-in-page'
|
||||
|
||||
/**
|
||||
* Find-in-page overlay (⌘F).
|
||||
*
|
||||
* Drives Electron's `webContents.findInPage` via the preload bridge so the
|
||||
* user gets the native browser-like incremental search (highlight, step,
|
||||
* Escape to clear) over the rendered chat transcript + editor panels. Multi-
|
||||
* window routing is handled in the main process — see
|
||||
* apps/desktop/electron/find-in-page.ts.
|
||||
*
|
||||
* Accelerators, matching the platform convention (and Claude Desktop's set):
|
||||
* ⌘F opens (via the `view.findInPage` keybind), ⌘G / ⌘⇧G step next/previous
|
||||
* from anywhere while the bar is open, Enter / ⇧Enter step from the input,
|
||||
* and Escape closes + clears the native selection.
|
||||
*
|
||||
* Key routing lives in `lib/find-in-page.ts` as a pure matcher so the
|
||||
* accelerator set is testable without a DOM.
|
||||
*/
|
||||
export function FindBar() {
|
||||
const { t } = useI18n()
|
||||
const { active, query, matchOrdinal, matchCount } = useStore($findInPage)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [localQuery, setLocalQuery] = useState('')
|
||||
const { pathname } = useLocation()
|
||||
|
||||
// Navigating away (opening another session, a settings page, …) closes the
|
||||
// bar and clears the native highlight. Electron's findInPage selection is
|
||||
// per-webContents, not per-route: without this, highlights (and a stale
|
||||
// match counter) from the previous chat would survive onto the next view.
|
||||
// Implemented as effect cleanup so the first render never fires it, a
|
||||
// pathname change tears down the previous route's search, and unmount
|
||||
// (session/profile switches that remount the shell) gets the same teardown.
|
||||
// closeFindBar is idempotent, so a closed bar never re-enters the bridge.
|
||||
useEffect(() => {
|
||||
void pathname
|
||||
|
||||
return () => closeFindBar()
|
||||
}, [pathname])
|
||||
|
||||
// Focus input when find bar opens.
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setLocalQuery('')
|
||||
// Small delay so the DOM paints the input before we focus.
|
||||
const id = requestAnimationFrame(() => inputRef.current?.focus())
|
||||
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [active])
|
||||
|
||||
// Subscribe to found-in-page results from the main process. Refcounted in
|
||||
// the store, so a remount (connection re-home) can't stack listeners; the
|
||||
// subscription is deliberately mount-scoped and NOT tied to `active` —
|
||||
// results for an in-flight search must still land if the bar just closed.
|
||||
useEffect(() => initFindInPageListener(), [])
|
||||
|
||||
// Debounce search — fire findInPage 200ms after the user stops typing.
|
||||
useEffect(() => {
|
||||
if (!active || !localQuery) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const id = setTimeout(() => setFindQuery(localQuery), 200)
|
||||
|
||||
// Cleanup covers every exit: another keystroke, the bar closing, and
|
||||
// unmount. Nothing can fire a find after the bar is gone.
|
||||
return () => clearTimeout(id)
|
||||
}, [active, localQuery])
|
||||
|
||||
// Global accelerators while the bar is open: Escape closes, ⌘G / ⌘⇧G step.
|
||||
// Capture-phase so they win regardless of which element inside the shell
|
||||
// owns focus (composer textarea, side panel button, …). ⌘G is also bound to
|
||||
// `view.toggleReview` in the keybinds registry — this listener runs in the
|
||||
// capture phase and stops propagation, so while the find bar is open ⌘G
|
||||
// means "find next" and the review toggle does not also fire. Closing the
|
||||
// bar hands ⌘G straight back to the review pane.
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const action = findBarKeyAction(event)
|
||||
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (action === 'close') {
|
||||
closeFindBar()
|
||||
} else if (action === 'next') {
|
||||
findNext()
|
||||
} else {
|
||||
findPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [active])
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onInput = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setLocalQuery(value)
|
||||
|
||||
// Empty query: clear highlights immediately rather than after the debounce.
|
||||
if (!value) {
|
||||
setFindQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
// Enter / ⇧Enter step while focus is in the input. Escape and ⌘G are handled
|
||||
// by the window listener above, so they are intentionally not duplicated
|
||||
// here — `inInput` only unlocks the bare-Enter family.
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
const action = findBarKeyAction(event, { inInput: true })
|
||||
|
||||
if (action !== 'next' && action !== 'previous') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (action === 'next') {
|
||||
findNext()
|
||||
} else {
|
||||
findPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
const matchLabel = formatMatchLabel(query, matchOrdinal, matchCount)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto fixed right-4 top-[calc(var(--titlebar-height,0px)+0.5rem)] z-50',
|
||||
'flex items-center gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-surface-background) px-2 py-1 shadow-md'
|
||||
)}
|
||||
role="search"
|
||||
>
|
||||
<input
|
||||
aria-label={t.keybinds.actions['view.findInPage'] ?? 'Find in page'}
|
||||
className="h-6 w-40 bg-transparent text-xs text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary)"
|
||||
onChange={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t.keybinds.actions['view.findInPage'] ?? 'Find in page'}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={localQuery}
|
||||
/>
|
||||
|
||||
{matchLabel && (
|
||||
<span aria-live="polite" className="min-w-[3rem] text-center text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
{matchLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tip label={t.findInPage.previous}>
|
||||
<button
|
||||
aria-label={t.findInPage.previous}
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-(--ui-text-secondary) hover:bg-(--ui-control-hover-background)"
|
||||
onClick={findPrevious}
|
||||
type="button"
|
||||
>
|
||||
<svg height="12" viewBox="0 0 16 16" width="12">
|
||||
<path d="M4 10l4-4 4 4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tip>
|
||||
|
||||
<Tip label={t.findInPage.next}>
|
||||
<button
|
||||
aria-label={t.findInPage.next}
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-(--ui-text-secondary) hover:bg-(--ui-control-hover-background)"
|
||||
onClick={findNext}
|
||||
type="button"
|
||||
>
|
||||
<svg height="12" viewBox="0 0 16 16" width="12">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tip>
|
||||
|
||||
<Tip label={t.common.close}>
|
||||
<button
|
||||
aria-label={t.common.close}
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-(--ui-text-secondary) hover:bg-(--ui-control-hover-background)"
|
||||
onClick={closeFindBar}
|
||||
type="button"
|
||||
>
|
||||
<svg height="10" viewBox="0 0 12 12" width="10">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -89,7 +89,11 @@ function ZoneMenu({
|
||||
/** False for the zone hosting the uncloseable workspace — collapsing the
|
||||
* MAIN pane strands the app behind a strip. */
|
||||
minimizable?: boolean
|
||||
directions: ZoneMenuDirection[]
|
||||
/** Called when the menu renders, not on every zone re-render: resolving the
|
||||
* neighbor zones has to read the layout tree, and subscribing every zone to
|
||||
* it made a sash drag re-render every mounted pane. Same lazy shape as
|
||||
* `closable`. */
|
||||
directions: () => ZoneMenuDirection[]
|
||||
headerHidden?: boolean
|
||||
minimized?: boolean
|
||||
nodeId: string
|
||||
@@ -100,7 +104,7 @@ function ZoneMenu({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{directions.map(direction => (
|
||||
{directions().map(direction => (
|
||||
<ContextMenuItem key={direction.side} onSelect={direction.run}>
|
||||
{direction.label}
|
||||
</ContextMenuItem>
|
||||
@@ -255,30 +259,43 @@ export function TreeGroup({
|
||||
// - a single pane -> "Move <dir>": join the zone visually adjacent on that
|
||||
// side (splitting here would only make an invisible empty zone). Sides
|
||||
// with no visible neighbor are omitted entirely.
|
||||
const tree = useStore($layoutTree)
|
||||
// NOT `useStore($layoutTree)`: this subscribes every zone — and therefore
|
||||
// every mounted pane and its whole transcript — to the entire layout tree.
|
||||
// A sash drag rewrites the tree once per frame, so dragging the sidebar
|
||||
// re-rendered all five tiles' message lists on every pointermove (measured:
|
||||
// TreeGroup 180 renders cascading into ChatView/Thread/TileChat at ~4.5s
|
||||
// each, holding the drag at ~3fps).
|
||||
//
|
||||
// The tree is only read to build the zone context menu's move/split
|
||||
// directions, which are consumed when the menu OPENS — so read it at that
|
||||
// moment with `.get()` instead of subscribing to every intermediate frame.
|
||||
const menuDirections = (): ZoneMenuDirection[] => {
|
||||
if (shown.length > 1) {
|
||||
return DIRECTION_ORDER.map(side => ({
|
||||
side,
|
||||
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
|
||||
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
|
||||
}))
|
||||
}
|
||||
|
||||
const menuDirections: ZoneMenuDirection[] =
|
||||
shown.length > 1
|
||||
? DIRECTION_ORDER.map(side => ({
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
return DIRECTION_ORDER.flatMap(side => {
|
||||
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
|
||||
|
||||
if (!neighbor || neighbor.id === node.id) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
side,
|
||||
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
|
||||
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
|
||||
}))
|
||||
: DIRECTION_ORDER.flatMap(side => {
|
||||
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
|
||||
|
||||
if (!neighbor || neighbor.id === node.id) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
side,
|
||||
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
|
||||
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
|
||||
}
|
||||
]
|
||||
})
|
||||
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
|
||||
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Close targets the right-clicked chip (falling back to the active pane);
|
||||
// only panes that declare `uncloseable` (the main workspace) are exempt.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/store/pet', () => {
|
||||
const listeners = new Set<(state: string) => void>()
|
||||
|
||||
return {
|
||||
$petState: {
|
||||
get: () => 'idle',
|
||||
listen: (callback: (state: string) => void) => {
|
||||
listeners.add(callback)
|
||||
|
||||
return () => {
|
||||
listeners.delete(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { PetSprite } from './pet-sprite'
|
||||
|
||||
const INFO = {
|
||||
enabled: true,
|
||||
frameH: 16,
|
||||
frameW: 16,
|
||||
framesPerState: 2,
|
||||
loopMs: 120,
|
||||
scale: 1,
|
||||
spritesheetBase64: 'stub',
|
||||
stateRows: ['idle']
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installRaf() {
|
||||
let nextId = 1
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
const request = vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
frames.set(id, callback)
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
const cancel = vi.fn((id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return {
|
||||
cancel,
|
||||
pending: () => frames.size,
|
||||
request,
|
||||
runNext: (now: number) => {
|
||||
const next = frames.entries().next().value
|
||||
|
||||
if (!next) {
|
||||
throw new Error('No pending RAF')
|
||||
}
|
||||
|
||||
const [id, callback] = next
|
||||
frames.delete(id)
|
||||
callback(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PetSprite RAF scheduling', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers()
|
||||
setVisibility(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class extends EventTarget {
|
||||
complete = true
|
||||
naturalWidth = 16
|
||||
src = ''
|
||||
} as unknown as typeof Image
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
imageSmoothingEnabled: false
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('sleeps between visible sprite frames instead of chaining RAFs', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
raf.runNext(0)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels pending RAF work while the Electron window is paused and resumes when visible', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
})
|
||||
|
||||
expect(raf.cancel).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suspends while unfocused, resumes on focus, and leaves no work after unmount', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} />)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
act(() => raf.runNext(0))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(raf.pending()).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the intentionally non-activating pop-out overlay animated while unfocused', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} pauseWhenUnfocused={false} />)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
import { $petState, type PetInfo, type PetState } from '@/store/pet'
|
||||
|
||||
const DEFAULT_FRAME_W = 192
|
||||
@@ -91,6 +92,8 @@ export function roamWalkRow(dir: -1 | 0 | 1, stateRows?: string[]): { row?: stri
|
||||
|
||||
interface PetSpriteProps {
|
||||
info: PetInfo
|
||||
/** Keep animating in a deliberately non-activating visible window, such as the pop-out pet overlay. */
|
||||
pauseWhenUnfocused?: boolean
|
||||
/** On-screen scale multiplier applied on top of the pet's native scale. */
|
||||
zoom?: number
|
||||
/**
|
||||
@@ -114,21 +117,24 @@ interface PetSpriteProps {
|
||||
* with `memo`, this component effectively never re-renders after mount until
|
||||
* the pet itself changes.
|
||||
*/
|
||||
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
|
||||
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUnfocused = true }: PetSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
const rowOverrideRef = useRef<string | undefined>(rowOverride)
|
||||
const kickAnimationRef = useRef<() => void>(() => undefined)
|
||||
|
||||
// Keep the override current without re-running the RAF setup effect.
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
overrideRef.current = stateOverride
|
||||
kickAnimationRef.current()
|
||||
}, [stateOverride])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
rowOverrideRef.current = rowOverride
|
||||
kickAnimationRef.current()
|
||||
}, [rowOverride])
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
@@ -173,17 +179,75 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
||||
// Track state via subscription, not a prop — no re-render on activity ticks.
|
||||
stateRef.current = $petState.get()
|
||||
|
||||
const unsubState = $petState.listen(next => {
|
||||
stateRef.current = next
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
let wakeTimer = 0
|
||||
let stopped = false
|
||||
let frame = 0
|
||||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
let activeRow = -1
|
||||
let activeCount = -1
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
|
||||
const cancelWakeTimer = () => {
|
||||
if (wakeTimer !== 0) {
|
||||
window.clearTimeout(wakeTimer)
|
||||
wakeTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRaf = () => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
}
|
||||
|
||||
const clearScheduled = () => {
|
||||
cancelWakeTimer()
|
||||
cancelRaf()
|
||||
}
|
||||
|
||||
const scheduleFrame = (delayMs = 0) => {
|
||||
if (stopped || rendererPaused() || raf !== 0 || wakeTimer !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (delayMs > 16) {
|
||||
wakeTimer = window.setTimeout(() => {
|
||||
wakeTimer = 0
|
||||
scheduleFrame()
|
||||
}, delayMs)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
const kickAnimation = () => {
|
||||
if (stopped || rendererPaused()) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelWakeTimer()
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
clearScheduled()
|
||||
|
||||
if (rendererPaused()) {
|
||||
return
|
||||
}
|
||||
|
||||
lastStep = performance.now()
|
||||
drawnFrame = -1
|
||||
kickAnimation()
|
||||
}
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
@@ -223,6 +287,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
raf = 0
|
||||
|
||||
if (stopped || rendererPaused()) {
|
||||
return
|
||||
}
|
||||
|
||||
const forcedRow = rowOverrideRef.current
|
||||
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
|
||||
|
||||
@@ -245,10 +315,13 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
||||
|
||||
frame %= count
|
||||
|
||||
if (!image.complete || image.naturalWidth <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only touch the canvas when the visible cell actually changes. The RAF
|
||||
// ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
|
||||
// the clear+draw work and keeps the main thread free.
|
||||
if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
|
||||
// wakes when a sprite cell is due, so the idle path avoids a 60Hz loop.
|
||||
if (frame !== drawnFrame || row !== drawnRow) {
|
||||
const sx = frame * frameW
|
||||
const sy = row * frameH
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
@@ -258,16 +331,29 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
||||
drawnRow = row
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
scheduleFrame(Math.max(0, stepMs - (now - lastStep)))
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
kickAnimationRef.current = kickAnimation
|
||||
|
||||
const unsubState = $petState.listen(next => {
|
||||
stateRef.current = next
|
||||
kickAnimation()
|
||||
})
|
||||
|
||||
image.addEventListener('load', kickAnimation)
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange, { pauseWhenUnfocused })
|
||||
scheduleFrame()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
stopped = true
|
||||
kickAnimationRef.current = () => undefined
|
||||
clearScheduled()
|
||||
image.removeEventListener('load', kickAnimation)
|
||||
pauseController?.dispose()
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { act, type ReactNode, type RefObject, useRef } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/store/pet', () => ({
|
||||
$petMotion: { set: () => undefined },
|
||||
$petRoamDir: { set: () => undefined }
|
||||
}))
|
||||
|
||||
import { usePetRoam } from './use-pet-roam'
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installRaf() {
|
||||
const request = vi.fn((_callback: FrameRequestCallback) => 1)
|
||||
const cancel = vi.fn()
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return { cancel, request }
|
||||
}
|
||||
|
||||
function RoamHarness({ isInteracting = () => false }: { isInteracting?: () => boolean }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
usePetRoam({
|
||||
commit: () => undefined,
|
||||
containerRef: ref as RefObject<HTMLDivElement | null>,
|
||||
enabled: true,
|
||||
isInteracting,
|
||||
loopMs: 1200,
|
||||
overlayOpen: false,
|
||||
petH: 64,
|
||||
petW: 64
|
||||
})
|
||||
|
||||
return <div ref={ref} />
|
||||
}
|
||||
|
||||
describe('usePetRoam RAF scheduling', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers()
|
||||
setVisibility(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 164,
|
||||
height: 64,
|
||||
left: 100,
|
||||
right: 164,
|
||||
top: 100,
|
||||
width: 64,
|
||||
x: 100,
|
||||
y: 100,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('uses a pause timer, not RAF, while dwelling at idle', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<RoamHarness />)
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('clears the pause wakeup while the Electron window is paused and restarts it when visible', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<RoamHarness />)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
|
||||
expect(raf.cancel).not.toHaveBeenCalled()
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('suspends idle movement while unfocused and cleans up its wake timer on unmount', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<RoamHarness />)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
|
||||
|
||||
import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior'
|
||||
@@ -33,6 +34,8 @@ const DROP_SETTLE_MS = 90
|
||||
const ARRIVE_EPS = 1.5
|
||||
// Cap dt so a backgrounded/throttled tab can't teleport the pet on resume.
|
||||
const MAX_DT_S = 0.05
|
||||
// While paused, wake rarely to notice drags/replans without burning a 60Hz RAF.
|
||||
const PAUSE_POLL_MS = 250
|
||||
|
||||
type Phase = 'pause' | 'walk' | 'fall' | 'jump'
|
||||
|
||||
@@ -114,6 +117,9 @@ export function usePetRoam({
|
||||
let pauseUntil = performance.now() + rand(400, 1200)
|
||||
let last = performance.now()
|
||||
let raf = 0
|
||||
let pauseTimer = 0
|
||||
let stopped = false
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
let walkTargetX = cur.x
|
||||
let curLedge: Ledge | null = null
|
||||
@@ -137,6 +143,64 @@ export function usePetRoam({
|
||||
$petRoamDir.set(dir)
|
||||
}
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
|
||||
const cancelRaf = () => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPauseTimer = () => {
|
||||
if (pauseTimer !== 0) {
|
||||
window.clearTimeout(pauseTimer)
|
||||
pauseTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
const clearScheduled = () => {
|
||||
cancelRaf()
|
||||
cancelPauseTimer()
|
||||
}
|
||||
|
||||
const schedule = (now = performance.now()) => {
|
||||
if (stopped || rendererPaused() || raf !== 0 || pauseTimer !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'pause') {
|
||||
const delay = Math.max(0, pauseUntil - now)
|
||||
|
||||
if (delay > 0) {
|
||||
pauseTimer = window.setTimeout(
|
||||
() => {
|
||||
pauseTimer = 0
|
||||
step(performance.now())
|
||||
},
|
||||
Math.min(delay, PAUSE_POLL_MS)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
clearScheduled()
|
||||
last = performance.now()
|
||||
|
||||
if (rendererPaused()) {
|
||||
signal(null, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
schedule(last)
|
||||
}
|
||||
|
||||
const beginPause = (now: number) => {
|
||||
phase = 'pause'
|
||||
pauseUntil = now + dwellMs(PAUSE_DWELL)
|
||||
@@ -209,6 +273,15 @@ export function usePetRoam({
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
raf = 0
|
||||
pauseTimer = 0
|
||||
|
||||
if (stopped || rendererPaused()) {
|
||||
signal(null, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const dt = Math.min(MAX_DT_S, (now - last) / 1000)
|
||||
last = now
|
||||
|
||||
@@ -223,7 +296,7 @@ export function usePetRoam({
|
||||
// Short settle so the pet falls right after you drop it, not seconds later.
|
||||
pauseUntil = now + DROP_SETTLE_MS
|
||||
signal(null, 0)
|
||||
raf = requestAnimationFrame(step)
|
||||
schedule(now)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -300,13 +373,16 @@ export function usePetRoam({
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step)
|
||||
schedule(now)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step)
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||
schedule()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
stopped = true
|
||||
clearScheduled()
|
||||
pauseController?.dispose()
|
||||
signal(null, 0)
|
||||
// Hand the final position back to React so its `style` matches the DOM once
|
||||
// the loop stops re-asserting it.
|
||||
|
||||
@@ -58,6 +58,30 @@ function ContextMenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="context-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ContextMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
@@ -142,6 +166,7 @@ function ContextMenuSubContent({
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useI18n } from '@/i18n'
|
||||
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** True inside `RootTooltipProvider`. `Tip` uses this to decide whether it
|
||||
* needs to supply its own provider — see the note on `Tip`. */
|
||||
const HasTooltipProvider = React.createContext(false)
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
// Tips are labels, not interactive surfaces. Hoverable content + Radix's
|
||||
@@ -105,21 +109,55 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con
|
||||
}
|
||||
|
||||
// Drop-in replacement for native `title=`: wrap any single element. Instant,
|
||||
// position-aware, themed. Self-contained (carries its own Provider) so it works
|
||||
// anywhere without a provider ancestor. Renders the child untouched when label
|
||||
// is falsy. Open state is trigger-hover only — never sticky, never click-blocking.
|
||||
// position-aware, themed. Renders the child untouched when label is falsy.
|
||||
// Open state is trigger-hover only — never sticky, never click-blocking.
|
||||
//
|
||||
// NO per-instance `TooltipProvider`. There are ~107 `Tip` call sites, and each
|
||||
// private provider is another subtree that re-renders whenever anything above
|
||||
// it does. Measured on a sash drag with five mounted tiles: 52,784
|
||||
// TooltipProvider renders and 18.3s of component time in a single gesture.
|
||||
//
|
||||
// Radix's provider holds only refs and stable callbacks (no reactive state), so
|
||||
// hoisting one to the app root is exactly what it is designed for — see
|
||||
// `RootTooltipProvider`, mounted in main.tsx. `Tooltip` still reads
|
||||
// `delayDuration`/`disableHoverableContent` from context, and the per-Tip
|
||||
// overrides below keep the previous behavior for anything that passed them.
|
||||
//
|
||||
// Deliberately NOT lazy-mounted: deferring the Radix subtree until hover was
|
||||
// tried and reverted. `asChild` puts `data-slot="tooltip-trigger"` on the
|
||||
// child element itself, so arming REPLACES that node — which broke 18 tests
|
||||
// encoding that contract, and risks focus/ref identity at every call site.
|
||||
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
|
||||
// A component rendered in isolation (every unit test, and any surface
|
||||
// mounted outside the app root) has no provider above it, and Radix throws
|
||||
// "`Tooltip` must be used within `TooltipProvider`". Fall back to a local
|
||||
// one there. Inside the app this is always false, so the common path is a
|
||||
// bare Tooltip and the ~107 providers collapse to one.
|
||||
const provided = React.useContext(HasTooltipProvider)
|
||||
|
||||
if (!label) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const tip = (
|
||||
<Tooltip delayDuration={delayDuration} disableHoverableContent>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent {...props}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return provided ? tip : <TooltipProvider delayDuration={delayDuration}>{tip}</TooltipProvider>
|
||||
}
|
||||
|
||||
/** The app's single tooltip provider. Mounted once at the root so no `Tip`
|
||||
* needs its own. Defaults match what `Tip` used to pass per instance. */
|
||||
function RootTooltipProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration} disableHoverableContent>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent {...props}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<HasTooltipProvider value>
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</HasTooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,4 +201,13 @@ function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
|
||||
return <TipHintLabel hint={hint ?? undefined} text={label} />
|
||||
}
|
||||
|
||||
export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
export {
|
||||
RootTooltipProvider,
|
||||
Tip,
|
||||
TipHintLabel,
|
||||
TipKeybindLabel,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Dev-only state diagnostics
|
||||
|
||||
Two counters that answer, for any interaction: **what re-rendered, why, and
|
||||
which store pushed it?**
|
||||
|
||||
```
|
||||
window.__RENDER_COUNTS__ what re-rendered, attributed to props / state / parent
|
||||
window.__ATOM_CHURN__ which store published it, and whether it mattered
|
||||
```
|
||||
|
||||
Both are inert until `start()`, so the idle cost is one no-op branch per commit
|
||||
and per notify. Neither ships: `vite.config.ts` aliases `@/debug/dev-only` to a
|
||||
no-op module for any build that isn't the dev server (or `VITE_PERF_PROBE=1`).
|
||||
|
||||
## Using it
|
||||
|
||||
From the devtools console, while an agent streams:
|
||||
|
||||
```js
|
||||
__RENDER_COUNTS__.start(); __ATOM_CHURN__.start()
|
||||
// ...let it run for a few seconds...
|
||||
__RENDER_COUNTS__.stop(); __ATOM_CHURN__.stop()
|
||||
console.table(__RENDER_COUNTS__.report())
|
||||
console.table(__ATOM_CHURN__.report())
|
||||
```
|
||||
|
||||
The `wasted` column in each is the fix list:
|
||||
|
||||
- **render `wasted`** — re-rendered with neither changed props nor changed hook
|
||||
state, i.e. purely because a parent did. A `memo()` or a narrower subscription
|
||||
removes it.
|
||||
- **atom `wasted`** — published a value deep-equal to the previous one.
|
||||
`@nanostores/react` bails out on *reference* equality only, so this
|
||||
re-renders every subscriber for nothing. This is the
|
||||
"preserve reference identity on no-ops" rule in `apps/desktop/AGENTS.md`.
|
||||
|
||||
Or as a gated measurement, driving 5 concurrent streaming tabs synthetically
|
||||
(no backend, no credits):
|
||||
|
||||
```bash
|
||||
node scripts/perf/run.mjs render-churn --spawn --tiles 5 --tokens 240
|
||||
```
|
||||
|
||||
## Why bippy, and not the obvious choices
|
||||
|
||||
**React 19.2 removed `injectProfilingHooks` from react-dom.** Verified:
|
||||
`grep -c injectProfilingHooks node_modules/react-dom/cjs/react-dom-client.development.js`
|
||||
→ `0`. Only `onCommitFiberRoot` / `onPostCommitFiberRoot` remain. The entire
|
||||
`mark*` profiling family (`component-render-start`, `state-update`,
|
||||
`render-scheduled`) is dead on this stack — anything built on it is out.
|
||||
|
||||
**`<Profiler>` cannot answer "did the sidebar re-render?"** React invokes
|
||||
`onRender` for *every* Profiler in a committed tree, including subtrees that
|
||||
bailed out. Counting those callbacks "proves" a re-render that never happened.
|
||||
`actualDuration` is not a discriminator either: a bailed-out subtree still
|
||||
reports a small nonzero duration, so there's no safe threshold. `didFiberRender`
|
||||
is the honest signal. (Note `app/chat/perf-probe.tsx` exports a `PerfProbe`
|
||||
Profiler wrapper that is used nowhere — that's why.)
|
||||
|
||||
**react-scan** ships the right idea in its undocumented `react-scan/lite`
|
||||
subpath, but `lite` is a thin wrapper whose only imports are `bippy` and
|
||||
`bippy/source`. The package pulls ~217 transitive deps (babel, preact) and
|
||||
floats `react-grab` / `react-doctor` on `latest`, so installs aren't
|
||||
reproducible, and its main entry currently breaks Vite with a JSON
|
||||
import-attribute error (upstream issues #448, #467, both open). We take bippy
|
||||
directly: MIT, zero dependencies.
|
||||
|
||||
## The import-order constraint
|
||||
|
||||
`main.tsx` imports `@/debug/dev-only` **statically, above `react-dom`**. This is
|
||||
load-bearing, not stylistic.
|
||||
|
||||
react-dom captures the devtools hook at **module init**, not at `createRoot`.
|
||||
Installing afterwards leaves a hook object in place but `bippy._renderers`
|
||||
empty, and every commit goes unseen. Verified both directions:
|
||||
|
||||
| order | `_renderers` | commits |
|
||||
|---|---|---|
|
||||
| bippy installs first | 1 | 1 |
|
||||
| react-dom evaluates first | 0 | 0 |
|
||||
|
||||
A dynamic `import()` behind an `import.meta.env.DEV` guard would resolve a
|
||||
microtask *after* main.tsx's static graph (react-dom included) had evaluated —
|
||||
too late. Hence a static import, with production exclusion handled by the
|
||||
build-time alias instead of tree-shaking.
|
||||
|
||||
If you add a test that imports these counters, note the `ui` vitest project's
|
||||
`setupFiles` pulls in `@testing-library/react` (and thus react-dom) before any
|
||||
test body runs, so the hook can never install there. Use a config without
|
||||
`setupFiles`.
|
||||
|
||||
## Baseline (5 tabs × 240 tokens, dev renderer, darwin-arm64)
|
||||
|
||||
```
|
||||
sidebar_renders 6
|
||||
sidebar_wasted 0
|
||||
wasted_renders 10,432
|
||||
total_renders 78,385
|
||||
commits 1,566
|
||||
wasted_notifies 0
|
||||
```
|
||||
|
||||
The sidebar hypothesis is **refuted**: 6 renders across the whole run, all
|
||||
attributable to hook state on genuine busy/needsInput edges, none wasted. The
|
||||
`stableArray` guards on `$workingSessionIds` / `$attentionSessionIds`
|
||||
(`store/session-states.ts:236-259`) are doing their job.
|
||||
|
||||
The real cost is elsewhere — see `topRenders` in the scenario's `detail`.
|
||||
`$sessionStates` notified 1,200 times with **10 listeners** (fan-out 12,000)
|
||||
and zero wasted notifies, so the publishing side is honest; the waste is in
|
||||
components that re-render on a parent commit without their own inputs changing.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user