Addresses @helix4u's review on #91493:
- conversation_loop now stamps failure_retryable (the real ClassifiedError
verdict) next to failure_reason; error_surface prefers it and only falls
back to the reason set for older results. Fallback set corrected to match
classify_api_error (auth, format_error, billing_unverified now
non-retryable).
- The descriptor carries the failing session's provider/model captured at
classification time; Copy error details prefers them over the foreground
composer atoms.
- Open logs is labeled 'Open Desktop logs' on remote/cloud connections —
the local folder holds transport logs, not the remote runtime's.
- API-exception module allowlist widened to botocore/boto3/google/grpc/
requests/aiohttp so other adapter SDKs don't misclassify as gateway.
Sessions running on provider 'nous' get a 'Nous support' action on the
failed-turn card, opening the portal help hub
(https://portal.nousresearch.com/help — docs, Discord, GitHub) in the
external browser. All five locales + docs updated.
useNavigate() throws outside a <Router>; streaming.test.tsx renders the
thread bare. Move the Settings deep-link into a SwitchProviderAction child
gated on useInRouterContext(), which is safe in any tree.
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.
Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
Free ($0/$0) Nous Portal models sat with a blank discount column and no
sale star (stealth/ox-alpha, upstage/solar-pro4:free), reading as missing
data next to the -20% sale rows. compute_sale_discount now returns a flat
100% for free models; was_* raws pass through only when the gateway served
a pricing.original, so natively-free models render bare '-100%' with no
fabricated 'was ?/?'. CLI picker star follows on_sale automatically;
inventory feed carries discount_percent=100 to Desktop, whose FREE badge
row now renders the amber -100% pill beside it.
PaneTab gated its hover close button on two independent inputs: the
onClose verb, and a showCloseButton prop that TreeGroup fed from a
showCloseButton flag on the pane contribution. The middle-click and
Meta-click gestures read only onClose. A tab could therefore close on a
pointer gesture and advertise no control for it.
The flag had no user that hideOnly did not already cover. Both setters
also set hideOnly: true, which removes every close gesture:
- the sessions pane (app/contrib/controller.tsx),
- the Bots pane (plugins/hermes-bots/plugin.js).
The flag was an opt-out marker with no reachable effect, so this change
deletes it instead of teaching it to track the gestures. onClose alone
now decides both shapes. A tab that closes shows the button. A tab
without the verb shows nothing. To make a tab uncloseable, give it no
close verb.
hideOnly and uncloseable keep their meaning. They gate the verb, and
both shapes follow the verb together.
The DialogContent and SheetContent prop of the same name is a different
prop and stays. It has no close verb to derive from, and one caller
changes it while the dialog is open.
Tests: the new tab-close-affordance test renders the real TreeGroup and
asserts that button presence equals middle-click closure. It covers
hideOnly chrome, a plain side pane, the uncloseable workspace, and a
session tile. It reads closure from the layout tree, not from a spy, so
a wired-up mock cannot pass it. A regression that hides the button on a
closeable tab fails two of the four cases. The compiler rejects the
deleted prop, so the test carries no fixture for it. The pane-tab unit
test moves off the deleted prop.
Verified with the full apps/desktop vitest suite, npm run typecheck, and
npm run lint. Two electron process-spawn tests fail on this machine.
They also fail on a clean tree, and they do not touch the pane shell.
`headerHidden` carried two meanings at once. `true` was either "the user hid
this" or "a double-tap nobody meant hid this"; `false` was either "the user
wants a strip" or "insert / tab-cycling / dock-enforce / adoption pinned one to
escape a dead end". Because the layout wrote the same field the user did, a
repair silently overwrote a preference and neither could be read back — and
since hiding also unmounted the tab, the ✕ and the menu offering "Show header",
a zone that got hidden by accident stayed that way across restarts.
Replaces it with `tabStrip?: 'always' | 'never'`, where absent is auto and only
the user ever writes it, and moves the decision into one resolver that TreeGroup
and the store both call, so the strip on screen and the toggle command cannot
disagree. Reachability moves into that resolver as an invariant that outranks an
explicit `never`: a closeable tile keeps its ✕ and a lone tool panel keeps its
chip, because "hide the chrome" is never a request to make a surface
unreachable. With that guarantee held centrally, the four repair writes are
gone. Persisted `headerHidden` is dropped rather than translated — nothing on
disk distinguishes a deliberate hide from an accidental one, and carrying the
accidents forward would re-strand exactly the people who reported being stuck.
The double-tap hide goes with it, along with the synthesized double-tap detector
it was the only consumer of. It fired from ordinary double-clicks on a tab,
nothing announced it, and its undo lived behind the chrome it had just removed.
`data-zone-no-header` goes too: it marked full-page views for a body
double-click toggle that no longer exists, and nothing has read it since.
Supersedes the tab-side half of the fix from abundantbeing and yoniebans, whose
commits this builds on.
Narrows #86278 to exactly the defect. Tabs pass no double-tap context on any press path (generic pane drag, multi-tab selection drag, chrome.tabDrag), so a double-click on a tab can no longer hide the strip; the strip background keeps its documented hide gesture unchanged.
The body double-tap reveal from #86278 is dropped: the zone body deliberately carries no double-click gesture (virtualized content recreates its nodes between clicks, per the standing ruling in tree-group.tsx), and recovery surfaces for a deliberately hidden header are being decided separately across #84458 / #81638 / #89225. The DOUBLE_TAP_MS export is reverted since no consumer remains outside drag-session.
Test file trimmed to the two assertions that pin the grammar: a tab double-tap must not hide the strip (red on main), the strip background double-tap still hides. Taps release on window between presses so the drag-session synthesized double-tap path is the one exercised.
The synthesized double-tap that hides a zone's tab strip rode every tab's
pointerdown (generic pane drag and each pane's tabDrag), so a routine
double-click on a tab (select a title, retry a click) vanished the whole
bar and stranded the zone with no tab, no close X, and no way back but a
right-click. Keep the documented hide gesture on the strip background
only, and add its inverse as recovery: double-tap a hidden zone's body
restores the strip. Regression tests pin both sides of the grammar.
The full-screen boot surfaces (connecting, onboarding, boot failure, root
crash fallback) paint their backdrop with --ui-chat-surface-background,
which the glass field turns transparent so <body> can be the one painter
(0483133842). That was harmless while glass shipped off; once it shipped
on by default (be3166607e) every boot overlay became a window onto the
shell behind it.
These overlays mask the whole app, so they declare data-glass-opaque —
the existing contract for surfaces that paint over siblings — which pins
the token back to opaque chrome under glass and changes nothing when
glass is off.
Review-round residuals: the spinner's user-select guard now beats
[data-selectable-text] regardless of stylesheet order; the will-change
layer hint clears under the global renderer pause and reduced motion so
parked spinners hold no compositor layer; the e2e travel assertion reads
the engine's keyframes (a computed transform always serializes to a
matrix, so the old '%' check could never fail); inline import() type
hoisted for the lint gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the deleted stylesheet-text assertions with tests that run the
thing they claim to cover.
e2e/glyph-spinner.spec.ts drives a real browser, where the CSS actually
executes: the strip's animation resolves to steps(N) for N frames, runs
infinitely, and travels a resolved length rather than a percentage (a
percentage translate is layout-dependent and Chromium refuses to
composite it). Both pause gates are covered — the per-spinner
`data-paused` attribute and the global renderer-pause attribute that
window blur / minimize / document-hidden arm — along with the layer
promotion being scoped to running spinners. A sampling test confirms the
transform visits a bounded number of distinct values across one cycle
(steps, not a linear sweep) and that nothing mutates the DOM while it
animates, which is the property the whole change exists to deliver.
status-invalidation-scope.test.tsx pins the scoping itself as a render
count. `useTapbackDoubleClick` is called by AssistantMessageBody and by
nothing else in the tree, which makes it an exact render counter for the
message root without exporting internals. A settle and a delta flush must
both leave that count untouched while the leaves update. Verified by
mutation: reinstating a root-level status subscription fails the settle
test (2 renders where 1 is required).
It also pins node identity across the settle transition, so the
inter-agent collapse cannot go back to swapping element types at the
message-root position and remounting the row under the scroll anchor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
Review follow-ups on the compositor spinner and the invalidation scoping.
Spinner CSS:
- Clip each frame to its own box. Braille renders from a system fallback
face (JetBrains Mono has no U+2800 block), whose metrics are not
guaranteed to fit the 1em frame, so neighbouring ink could bleed into
the viewport.
- Name descendants explicitly in the selection guard. The competing
`[data-selectable-text='true'] *` rule has the same (0,1,0)
specificity, so relying on inheritance made the winner depend on
stylesheet order.
- Scope the compositor promotion to spinners that are actually running.
A permanently promoted layer per parked spinner is pure memory at
fan-out breadth, where many sit mounted and paused at once.
- Give every var() the braille default as its fallback, so a missing
custom property degrades to a working spinner rather than an invalid
declaration.
Spinner component: replace the bare `as CSSProperties` cast on the inline
style with an exported GlyphSpinnerVars contract, so a typo in a custom
property name is a compile error rather than a silently dead declaration.
Assistant message:
- Render the inter-agent collapse as a CHILD of the normal body instead
of a competing root. The settled case previously returned a different
element type than the running case, so settling unmounted the whole row
and mounted a fresh one — discarding the DOM the scroll anchor held.
One component, one root, children vary; the truth table is unchanged,
including the collapsed row carrying no tapback listener.
- Collapse AssistantStatusSlot's separate subscriptions into one selector
returning a stable string. The inputs always move together on a status
flip, so reading them separately just multiplied the wake-ups.
- Give StreamingMarker a stable `data-slot` and assert on that rather
than on `span.hidden`.
Repro script: count settled rows by subtracting streaming markers from
message roots instead of `:not(:has(...))`. The selector walked every
row's subtree on each evaluation, inside the very latency window the
probe measures.
Comments: drop the stale translateY(-100%) description, name both pause
triggers, replace hard-coded line-number citations with selector/symbol
ones, note that only the primary window arms the renderer-pause
attribute, and move the forensic trace numbers out of source comments
into the PR.
Delete the three tests that asserted on stylesheet TEXT. AGENTS.md bans
reading source in tests outright, and they demonstrated exactly why: a
var()-fallback edit that changed no rendered pixel broke one of them.
Replacements that exercise the CSS in a real browser follow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
Three items from the adversarial review of the compositor-only spinner.
1. COMPOSITOR PROMOTION. The keyframes travelled translateY(-100%), which
resolves against the strip's own box and so makes the animation
layout-dependent: instrumentation recorded a non-zero compositeFailed on
184/184 records (131072 / 131104) while a sibling transform animation using an
absolute length composited clean. The travel is now
calc(frames * -1 * frame-height), an absolute length for the same distance, and
the strip gets will-change: transform. steps(var(--glyph-spinner-frames)) and
the 1em frame metric are unchanged.
The frame height is now a custom property on .glyph-spinner, used by the clip
viewport, each frame box and the keyframe travel, so those three cannot drift.
On the em-resolution question: the keyframes apply to .glyph-spinner__strip and
nothing below .glyph-spinner declares a font-size, so the strip's em and the
frame's em are the same length -- the property makes that a single declaration
rather than a coincidence to re-verify.
2. SELECTION. .glyph-spinner takes user-select: none (plus -webkit-). These sit
inside [data-selectable-text] subtrees, where the strip contributed all N
glyphs to a transcript copy against the old implementation's one. None is right
for a decorative aria-hidden element.
3. SAME-CLASS SWEEP. chat-swap-overlay.tsx ran its own 80ms setInterval +
setState braille ticker -- the exact mechanism this fix removes. Its setFrame
drove only the glyph (setLabel is independent), and its frame set and cadence
are exactly the `braille` variant, so it now renders GlyphSpinner.
`justify-start` (tailwind-merge lets the caller win) keeps the glyph
left-aligned in its w-3 box as the bare span was. GlyphSpinner gains a `paused`
prop for it: the overlay stays mounted through its fade-out, and the old
cleared-interval behaviour was to stop animating once the swap was done.
Tests: guards for the two invisible-in-jsdom regressions -- keyframes must not
return to a percentage translate, and the selection guard must stay -- plus the
paused prop, and a new chat-swap-overlay.test.tsx pinning that no timer comes
back, the label still survives the fade-out, and the glyph freezes with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scheduler attribution on the incident trace (FINDINGS.md round-5-Opus receipt)
puts 133 of 138 wide document-scale recalcs on GlyphSpinner's ticker, and 0 of
254 cheap ones. Replacing glyph.textContent every interval is a structural
text-node mutation, so each tick scheduled a style recalculation that resolved
against the whole document -- with N spinners mounted in a streaming
transcript, that is the incident.
Every frame is now in the DOM from mount as a vertical strip, scrolled by a
transform translateY keyframes animation. Transform animations run on the
compositor: no JS timer, no text mutation, no per-frame style recalc, layout or
schedule.
The strip is N frames tall and each frame is exactly 1em, so translating -100%
travels N frames; steps(N) (jump-end) samples that at 0, 1/N .. (N-1)/N, i.e.
it parks on frame 0..N-1 for one interval each and wraps -- the same sequence
and cadence the setInterval produced. Frame count and duration (N x interval)
arrive as inline custom properties, so all 18 spinner names / 16 distinct
frame-interval shapes share one keyframes rule with no generated or colliding
per-variant CSS.
Sizing, colour and alignment are unchanged: the outer cell keeps its exact
classes, and the 1em clipping viewport is centred by the same items-center that
used to centre the single glyph -- so consumer classNames that set a box
(size-3, size-3.5) or a font-size still land the way they did.
Gating semantics preserved, per the original "N mounted tabs each ticking burns
CPU for pixels nobody can see":
- kept-alive hidden tab -> data-paused -> animation-play-state: paused. Kept
explicit rather than relying on the pane's content-visibility:hidden, since
that containment has a runtime kill switch and older pane layers only set
visibility:hidden, which does not stop an animation.
- window blur / minimize / document hidden -> the strip joins the existing
:root[data-renderer-animations-paused] allowlist in styles.css, driven by
main.tsx's installRendererAnimationPauseState(). That is the mechanism every
other continuous decorative animation here already uses, so the per-spinner
createRendererLoopPauseController goes away.
- reduced motion is now honoured, which the ticker never did: the blanket
@media (prefers-reduced-motion: reduce) rule freezes the animation. This
also makes E2E screenshots deterministic, which that rule exists for.
The frames are marked aria-hidden. role="status" is a live region and the old
implementation rewrote its text ~12x/second, which announced a new glyph on
every tick.
Tests rewritten, deliberately: all five previous cases asserted the ticker
itself (vi.getTimerCount(), per-tick textContent), which no longer exists. The
replacements pin what jsdom can see -- frame order, the custom properties
feeding steps()/duration per variant, zero timers ever created, the hidden-tab
gate, aria-hidden, and that the strip is still named in the global pause rule.
The blur/minimize/document-hidden contracts now belong to that global mechanism
and are covered by lib/renderer-loop-pause.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last status-dependent read at the message root, and the most expensive
one: the completedText selector flipped between '' while running and a full
messageContentText(content) join once settled, so every running <-> settled
transition re-ran the join for the whole message AND re-rendered the root. At
stream breadth N that is N joins plus N root re-renders per flip.
completedText and the previewTargets memo it feeds now live in a new
AssistantPreviewEmbeds leaf, mounted at the same position inside
[data-slot='aui_assistant-message-content']. Verified before moving that
previewTargets fed nothing else at the root -- its only consumer was its own
render block. The leaf renders the same wrapper div with the same classes, or
null when there are no targets, so the DOM is byte-identical; a component
boundary adds no node, so unlike StreamingMarker this needed no placement care
around the :first-child/:last-child rules.
The '' branch is preserved deliberately: it is the streaming-side optimization
that keeps the selector referentially stable so per-token flushes skip the
regex scan.
AssistantMessageBody now holds no status-dependent subscription at all -- what
remains is messageId, hasVisibleText, isInterim and turnDurationS, none of
which move on a pending flip.
Adds preview-embeds.test.tsx. The embed had no coverage, and the two cases are
written as a matched pair on the same selector -- present once settled, absent
while running -- so neither can pass vacuously.
Behavior-identical; invalidation scope only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the two residual invalidators left by the previous commit.
data-streaming: gone from the message root. The flag is not dead -- it is the
settled-row signal for scripts/run-short-session-hang-repro.mjs -- so it moved
to a permanently-mounted, display:none leaf that is a ROOT-LEVEL sibling, and
the repro now matches on the descendant. Placement is load-bearing three ways:
a node inside [data-slot='aui_assistant-message-content'] would steal
:last-child from the stall indicator and change inter-bubble margins mid-stream
(styles.css:1995-2003); keeping it mounted and toggling only the attribute
keeps the per-flip write on a childless node instead of making it a DOM
structure change; display:none costs no layout or paint while querySelectorAll
and :has() still match it.
Renamed to data-message-streaming rather than reusing data-streaming: shiki
puts that exact attribute on deferred code cards, which are descendants of the
message root, so a descendant-matching selector sharing the name would report
any message holding a deferred code card as still streaming.
root isRunning: gone from the standard path. AssistantMessage now dispatches on
interAgentSender, so the collapse gate's live status subscription lives in
InterAgentAssistantMessage and only the rare inter-agent case pays it. The
enter animation captures its enabled flag once off the runtime, non-reactively,
because use-enter-animation.ts parks the value in a ref behind a useCallback([])
identity and consults it only when the callback ref fires at mount -- a live
subscription fed a value the hook already ignores.
Adds inter-agent-collapse.test.tsx: the collapse gate and the marker contract
both had zero coverage, and nothing in the app reads the marker, so a delete
would otherwise look free and silently regress the repro's response gate.
Behavior-identical; invalidation scope only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prong A of the wide-recalc fix (FINDINGS.md round-4 protocol): read status in
leaf components inside message content, hoist MessagePrimitive.Parts so status
flips cannot re-render the parts subtree. Behavior-identical; invalidation
scope only.
The third primary edit -- dropping the data-streaming root attribute -- is NOT
in this commit. The design doc calls it dead based on a CSS grep, and that grep
is correct (every [data-streaming='true'] rule targets [data-slot='code-card']).
But it has a live non-CSS consumer: scripts/run-short-session-hang-repro.mjs
:928 and :1023 count settled assistant rows via
[data-slot="aui_assistant-message-root"]:not([data-streaming="true"]) and gate
the assistant-response wait on that count growing. Deleting the attribute makes
that selector match every row, so the gate would pass at stream start instead of
completion. Held pending a decision rather than silently weakening the repro.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The visual half of asking someone to connect a thing: the mark, the card,
the settled scaffold line it collapses to, and the trust badge.
It renders a subject, a state, and an outcome, and calls back. What a
connector IS, how one connects, and where the strings come from all stay
with the caller — the prop types are structural, so a richer type satisfies
them without this layer importing it. That boundary is the point: any
surface that has to ask "connect this?" should look identical without
sharing a data layer.
MarkdownLinkText comes along because setup steps need inline links.
LinkifiedText can't serve them — it finds bare URLs and guesses a label,
and here the label carries the meaning while the URL is a console page
whose own title is useless or, behind a login wall, actively wrong.
Messaging platforms, the MCP servers tab, and the connector surfaces each
drew their own square brand chip, and the three copies had already drifted
on radius, glyph size, and what an unknown name falls back to.
They now share one AvatarChip. Size and radius stay the caller's call;
everything else is fixed in one place, so a Slack row and a Linear card
wear the same mark. brandFor also stops caring how a name is spelled —
catalog slug, registry id, or display name all reach the same glyph.
Mermaid emits width="100%". Inside the zoom viewer's shrink-to-fit grid
that percentage can collapse, and svgSize's parseFloat("100%") made a
100px PNG so copy fell back to raw SVG text.
Co-authored-by: Robert Mohid <rmohid@gmail.com>
The Dialog shell is a fixed-height flex column, but the body had no
flex-1. The toolbar is absolutely positioned, so the in-flow stage had
nothing to resolve against and clipped the SVG.
Co-authored-by: Anuvrat Rastogi <anuvrat.rastogi@sap.com>
Handlers that need the answer inline had no way to reach the shared dialog
without hoisting state and a JSX mount into their component, so they all
reached for window.confirm instead. This mirrors notify(): a store action
carries the question, one host at the shell renders the real ConfirmDialog.
The worktree removal prompt offers a third way out — hide the lane but leave
the worktree on disk — which is why it was still hand-rolled. One optional
slot between Cancel and Confirm covers it, and it keeps Confirm as the
focused button so Enter still means the destructive action.
The delete-session dialog opted out of Radix's autofocus, which left focus
on the sidebar row that opened it — Enter re-activated the row instead of
confirming, and ConfirmDialog's Enter handler never saw the key.
ConfirmDialog now focuses its own Confirm button on open. The existing Enter
test fired the key at the dialog node, so it passed over the bug; it now
fires at whatever actually holds focus.
The app-wide context-menu coordinator recognizes surfaces that own a Radix
menu by `[data-slot="context-menu-trigger"]`. Radix `asChild` merges as
mergeProps(slotProps, childProps), so a child that sets its own `data-slot`
wins and the marker never reaches the DOM. The status bar footer is
`data-slot="statusbar"`, so the coordinator swallowed its right-click and
showed the window-verbs fallback instead — leaving every default-hidden
status bar item, the context meter included, unreachable from the UI.
Stamp a dedicated `data-hermes-context-menu-trigger` after `{...props}` on
ContextMenuTrigger and bail on that marker. Any asChild surface with its own
`data-slot` is covered, not just the status bar.
A colgroup of percentages is the only state, so widths never touch the
cells: one <col> per column, table-layout fixed, and the browser does the
rest. A drag moves one seam and the pair either side trade width, so the
table box never changes size mid-drag — no reflow of the message around
it, no scrollbar appearing under the pointer.
Handles are markup inside each <th>; the table listens once and resolves
the grabbed seam from the DOM, so there is no context, no per-column
component, and no index threading. Tables stay in auto layout until they
are resized, and double-clicking a seam hands them back to it — the same
reset gesture the pane sashes use.
On a 43-row table a 40-step drag mutates 78 col[style] attributes and
touches no cell.
The ship button always targeted `main`, so a tile Review still prompted
the workspace session. Remember the originating composer target with the
pane's cwd, capture the live surface at click, and toast if that chat
isn't on screen instead of dropping the click.
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
The same AST sweep over specs found fixtures maintained in parallel
across suites that have no reason to know about each other.
Twenty-one specs each mounted useMessageStream themselves and ten of the
harnesses were byte-identical; twenty now take renderMessageStream, with
overrides for the seams that genuinely vary. The SessionInfo builder was
spelled out field-by-field in seven specs, so a new backend field broke
seven files instead of one. Twenty specs carried their own inert
ResizeObserver and eleven repeated the animation-frame, CSS.escape,
scrollTo and WAAPI stubs the transcript needs to mount at all — split by
scope into src/test/jsdom for what any component might need and the
assistant-ui folder's own kit for the transcript. Plus the window-state
bridge, deferred, the external-store thread runtime, the manual
createRoot harness, and the per-folder caret, env-var, provider and
session fixtures.
Left alone on purpose: the store suites' makePrimary, where the vi.mock
harness around it is the actual duplication and cannot be hoisted out of
a hoisted factory; electron's deferred, where reaching into src/ from
the main process would invert the layering for eight lines; and the two
suites that compose another hook alongside the stream.
Splitting the god files made a pile of copy-paste helpers visible and,
for the first time, fixable — sharing them previously meant importing a
god file. Hashing function bodies through the TypeScript AST found
twelve groups desktop-wide; production code is now at zero duplicates.
Each helper went to the module that already owns its concern:
firstStringField to lib/text, the two REST 404 predicates to
lib/gateway-rpc beside isMissingRpcMethod, useDebounced and
prefersReducedMotion to their hooks, the superseded-bootstrap guard to
electron/ssh-connection, the composer keyup handler to the trigger hook
that owns the rest of that state machine, and clampDataUrlReadMaxMb to
apps/shared, replacing a "keep these in sync" comment between two
copies.
Only helpers with no existing owner got a new file: lib/mcp-servers,
lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command
palette's status row. Error-shape predicates are the worst thing to
copy — when the backend changes how it reports a missing route, every
copy has to be found.
AlertTitle clamps to one line, so Desktop error toasts hide the rest of the message behind an ellipsis. Override that clamp, let the title wrap, and cap height so a huge error scrolls instead of covering the chat.
The transcript's activity row went silent for most of an agentic turn. Two
gates were too narrow. It mounted only while the tail bubble's own status was
`running`, so a turn that seals a bubble mid-flight (message.interim) or
finishes one while the agent keeps going left a settled message at the tail and
unmounted the row entirely. And its activity signature was part count plus text
length, which a landing tool result does not change — a result mutates the part
that was already there — so the pause after a call ended was read as more of
the same silence and dated from whenever the call started.
Between them, the app spent whole stretches with the composer's arc border lit
and Stop armed while the thread showed nothing and counted nothing.
The row now follows the same busy signal the composer does, is mounted by the
tail unconditionally, and decides for itself whether the turn owes the user a
line. A named wait (compaction, provider wait, a tool being drafted) says so
immediately; an unnamed gap earns two seconds of quiet first, so a run of quick
calls doesn't strobe. It still defers to the two waits already accounted for
elsewhere: a prompt the user is answering, and a tool call in flight carrying
its own row and timer — anywhere in the message now, not just the last part,
and excluding the silent tools that render nothing to defer to.
Renamed to TurnActivityIndicator / `aui_turn-activity`: it hasn't measured a
stall since it started measuring the whole turn.
`turnStartedAt` was only reachable through the global atom, which mirrors
whichever session is currently viewed — the same trap `$busy` and `$messages`
were moved out of. Anything in a tile that measures a turn was therefore
timing the primary chat's.
Put it on SessionView beside the other per-surface signals: the primary falls
back to the draft atom while a new chat has no slice yet, a tile reads its own.
Tours address elements by selector, and a positional nth-child path breaks on
the next re-render. These are the durable handles, applied at the shared
primitive rather than per screen: every route overlay's nav and its rows, every
settings field (keyed by its config schema key, so new fields are named for
free), the filter tabs on any search shell page, and artifact cards.
One edit per primitive covers every screen built from it, which keeps the tour
vocabulary small enough to stay accurate.
One renderer coordinator owns every right-click and replaces the
native Electron menus. Menus are assembled from what the click
landed on:
- Links and images get open/copy/save sections; chat links add the
reach-aware resolved-URL copy on remote gateways.
- Editables get spell-check suggestions (async-appended when
Chromium's facts arrive from main), cut/copy/paste, and select
all. Cut and copy need a selection; paste needs a non-empty
clipboard; select all needs field content. The verbs show their
accelerators instead of icons and dispatch a frame after the menu
closes, so the radix focus trap cannot steal the target. Select
all runs renderer-side, scoped to the field, because main's
selectAll acts on the focused frame and could grab the transcript.
- Terminals answer through registered xterm handles; the read-only
agent terminal hides paste.
- The in-app browser guest builds the same menus from the webview
tag's context-menu event: Chromium's editFlags gate the edit
verbs, spell-check rides the event, and Inspect element closes
every menu. Coordinates arrive as window-relative device pixels,
so the handler divides by the window zoom factor; guest edit
commands focus the webview first, because they act on the focused
webContents.
- Bare app chrome falls back to the window verbs.
Labels come from the locale files in all five languages. Main keeps
thin IPC verbs: edit commands, copy-image-at-gesture, spell-check
actions, and dictionary-add for guests (the tag has no session API).
The e2e spec exercises the real focus trap; it is blocked today by
the gateway-checking stall that also fails e2e/chat.spec.ts.
The batch card previously locked each answer with its own Continue
press. Now picks and typed answers stage locally, and one Confirm and
continue button (enabled when every question has an answer) submits
the whole batch. Staged answers stay editable until that confirm.
The wire protocol is unchanged. The confirm sends the per-question
locks in sequence, because the last lock resolves the blocked tool and
each earlier lock must already be accepted when it lands. Replayed
locked answers from a reconnect pre-stage their questions so restored
progress stays visible. The TUI and CLI keep incremental per-question
locks, so a timeout there still returns partial answers.
The clarify card renders every batch question at once. Answers stage
locally per question; the footer button locks the staged answer with a
clarify.respond keyed by question_id. Locked answers stay editable — a
new pick un-locks the row and a re-lock overwrites server-side. When
exactly one question is unanswered the button relabels to Confirm and
continue, and that final lock completes the batch. Skip cancels the
whole batch (no question_id). Reconnect replay seeds the locked map so
a reattached window restores its earlier state.
The settled card lists every question with its answer; blank answers
render as Skipped. Single-question cards are untouched.