Merge branch 'main' into feat/hermes-relay-shared-metrics

This commit is contained in:
Jeffrey Quesnelle
2026-07-26 23:14:26 -04:00
committed by GitHub
388 changed files with 30656 additions and 1491 deletions
+5
View File
@@ -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
+78
View File
@@ -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![slug](https://<provider-url>)\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"
+8
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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,
+22
View File
@@ -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)
+53
View File
@@ -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,
+204
View File
@@ -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
+37 -1
View File
@@ -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 -2
View File
@@ -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.
+1
View File
@@ -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(
+310
View File
@@ -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(&not_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(&not_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'
}
}
+222
View File
@@ -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')
})
})
+120
View File
@@ -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
View File
@@ -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 {
+49
View File
@@ -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)
}
})
+240
View File
@@ -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 })
})
})
+426
View File
@@ -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)
+2 -2
View File
@@ -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) {
+5 -4
View File
@@ -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) {
+1
View File
@@ -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",
+158
View File
@@ -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?.()
}
+217
View File
@@ -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?.()
}
+170
View File
@@ -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?.()
}
+2
View File
@@ -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 |
+21 -2
View File
@@ -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 }
}
+75 -5
View File
@@ -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', () => {
+146 -13
View File
@@ -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)
}
+38 -9
View File
@@ -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>
+13 -24
View File
@@ -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()
}
}, [])
}
+8
View File
@@ -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.
+11 -3
View File
@@ -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)
@@ -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,
+124 -30
View File
@@ -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()
})
})
+224
View File
@@ -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)
})
})
+98 -12
View File
@@ -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,
+57 -10
View File
@@ -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
}
+111
View File
@@ -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