Skip to content

fix(media): close the viewer when its item leaves the list - #18

Closed
pratikbuilds wants to merge 130 commits into
mainfrom
cursor/media-viewer-close-8c4d
Closed

pratikbuilds wants to merge 130 commits into
mainfrom
cursor/media-viewer-close-8c4d

Conversation

@pratikbuilds

Copy link
Copy Markdown
Owner

Summary

When the open item falls off a shortened list, useMediaViewer commits the close. Uncontrolled state clears, and a controlled caller gets one onIndexChange(null). Growing the list back does not reopen the viewer.

Split out of corbitsdev#148 so that pull request only keeps script-capable sources out of links and frames.

Verification

  • bun test src/hooks/use-media-viewer.test.ts (8 pass), including both new cases

Closes corbitsdev#142

Open in Web Open in Cursor 

TheGreatAxios and others added 30 commits August 10, 2026 02:52
generate-exports walked every .ts/.tsx under src/, including *.test.*,
and re-exported them from the root barrel. Consumers that import from
@corbits/react-ui then pull bun:test into their vite/rollup build and
fail. Filter test modules out of both the barrel and the exports map.
* Add Dialog stories for center and sheet placements

* Lengthen the right-sheet story so DialogBody actually scrolls
* Allowlist URL schemes at every agent/artifact-supplied link sink

Citation, quote, and download links throughout the library render
agent- and artifact-supplied strings straight into href/src. Without a
scheme check, a javascript: or data: URL in a research citation, a
quote source, an artifact download, or an add-artifact link submission
becomes a live, clickable script.

Adds src/lib/url.ts: isSafeUrl/toSafeHref, a shared parse-based
allowlist (new URL(x).protocol against http:/https: by default). This
generalizes the https-only check embed-body.tsx already had for its
iframe src, rather than growing a second one. Relative and
protocol-relative URLs fail to parse without a base and are rejected,
since every sink here links to a third-party resource with no in-app
page a bare path could mean. Non-allowlisted values never become a
placeholder href like "#" - they fall back to plain text.

Applied to research-body.tsx (citation/quote/cluster-item links),
artifact-body.tsx (download links and image src), embed-body.tsx
(iframe src and its download link, generalized from the prior local
check), and add-artifact-dialog.tsx (link intake validation).

* Allowlist the artifact gallery card thumbnail src too

Missed in the first pass: ArtifactCard renders artifact.thumbnailUrl
straight into an img src, the same field already guarded in
artifact-body.tsx's image form.
…tip (corbitsdev#20)

TraceWaterfall now honors TraceSpan.timingSource: "ordinal" spans (positioned only by event order, no real duration) render as a point marker instead of a proportional bar. durationLabel is now string | null and renders "—" centrally in both the inline text and the accessible table. Adds InfoTooltip, a focusable Radix-based tooltip primitive replacing the inaccessible title-attribute pattern.
…sts, dead tooltip assertion (corbitsdev#22)

* Add ProfileCard href-safety and coverage tests

Covers the CL-6028 post-hoc review findings: unsafe schemes (data:,
file:, javascript:) on sharedChannels/pinnedSkills hrefs must never
reach a live <a href>, safe http(s) hrefs must pass through unchanged,
and the component's action-button wiring and conditional
header/status/footer branches previously had no coverage at all.

* ProfileCard: route hrefs through toSafeHref before rendering

sharedChannels[].href and pinnedSkills[].href rendered straight into
<a href> — React blocks bare javascript: but passes data: and file:
through unchanged, so an untrusted channel/skill name (agent- or
directory-supplied) could carry a live script or local-file link.
Matches the toSafeHref pattern already used by artifact-body,
embed-body, and research-body: safe http(s) hrefs pass through
unchanged, anything else falls back to plain text rather than a
disabled-looking but still-live link.

* tooltip.test.tsx: assert Escape actually dismisses the tooltip

The Escape-dismissal test's final assertion was inside
'if (describedBy !== null)', which never ran — Radix nulls
aria-describedby immediately on Escape, so the guard always skipped
the assertion and the test proved nothing about dismissal beyond the
open-state check above it. Now asserts unconditionally that
aria-describedby is cleared and the tooltip content element is gone
from the document.
* Port PulsingRing and add use-view-mode / use-resizable-rail (gtm-workbench)

CL-5624: reviewed the twelve gtm-workbench dashboard/chrome components and
three hooks for @workbench/ui. Ten of the twelve already have a direct
equivalent here (StatGrid, Sparkline, DashboardSection, TopBarBreadcrumbs,
PagePanel, LibraryPageHeader, RichEmptyState, Menu, CategoryBars) or
duplicate one (AppPageChromeRow vs LibraryPageHeader); Markdown is skipped
as a deliberate conflict with this package's no-markdown-dependency stance
(see research-body.tsx, artifact-body.tsx). Only PulsingRing was net new,
ported off the existing corbits-status-pulse keyframe instead of adding
framer-motion. use-theme already exists as ThemeProvider/useTheme; use-view-mode
and use-resizable-rail are genuinely new, adapted to be storage-key
configurable instead of hardcoded to one host's constants.

* PulsingRing: match the shared status-pulse timing

* use-resizable-rail: preserve current width on resize, SSR guard, pointer capture
…dev#23)

* Add stories for core primitives lacking workbench coverage

Avatar, Card, Switch, Table, Tabs, Tooltip, Pagination, Sparkline, Dial,
ToggleList, ThemeToggle, ConfirmButton and FileInput had no Ladle story.
Also points README at the component workbench and calls out stories as
the definition-of-done artifact for a new component.

* Fix story fixtures: fictional names, house time style

* Stories cover consequential states: armed, open, empty, overflow, sparse
* Add StepSidebar: collapsible workflow step rail

Adapted from gtm-workbench's StepSidebar — drops framer-motion for CSS
width/opacity transitions, drops the window.innerWidth collapse read for a
defaultCollapsed prop, and reuses WorkflowStep/WorkflowStepStatus from
workflow-run-progress.ts instead of a local status type. The source-label
and sign-out affordances become a single generic footer slot.

* StepSidebar: adopt the controlled collapse contract, reuse the shared toggle and status styling, cover edge states
…panel slot (corbitsdev#26)

* Add tests for sign-up mode, link button, dither background, and the auth panel slot

Covers the LoginForm mode prop, Button's new link variant, DitherBackground's reduced-motion and pause-on-hidden/offscreen contracts, and AuthLayout's panel slot.

* LoginForm sign-up mode, link Button variant, DitherBackground, and an auth panel slot

LoginForm gains a mode prop ("sign-in" default, "sign-up" adds a name field, new-password autocomplete, and a Signing up… busy label); Button gains a link variant matching the ghost/outline token pattern; DitherBackground is a new image-sourced sibling of DitherCanvas doing 8x8 Bayer dithering with an ambient + cursor warp, throttled to ~30fps and paused offscreen/hidden/reduced-motion; AuthLayout swaps its hardcoded DitherCanvas for an optional panel slot since no consumer in this repo relied on a default.

* Button link variant reads as a link: inline sizing + emphasis token; DitherBackground listener gating

- Button link variant: text-primary-emphasis (AA text-on-ground token) instead
  of text-primary, plus a compound variant stripping the fixed h-*/px-* box so
  it reads as inline text like every other link treatment. buttonVariants now
  wraps its cva output in tailwind-merge so the override actually wins.
- Guard tests: buttonVariants({variant:"link"}) never emits raw text-primary
  and never carries a fixed height/padding class, at every size.
- DitherBackground: skip the window pointermove listener entirely when warp
  is disabled, since the cursor displacement it drives is unused; corrected
  the throttling docstring (the rAF callback fires at native rate, only the
  paint work is gated to ~30fps).
- AuthLayout: reworded the aside JSDoc renaming artifact.
- CHANGELOG: documented the AuthLayout default-panel removal as a breaking
  change.
)

* Add Part union type for chat message content

Plain TS discriminated union covering text, reasoning, tool-trace,
block, file, event — no arktype, no host coupling. Mirrors the shape
a host's own validated part type would take; parsing untrusted data
into it is the host's job at its own trust boundary.

* Add ToolBlock: tool call lifecycle presentation

Discriminated-union state prop covering pending, running,
output-available, error, approval-requested, output-denied — a
denied approval and an execution error render distinctly. Collapsed
by default; error/approval-requested/output-denied open by default
since their detail is the point.

* Add PartsRenderer: render a Part[] in produced order

Text as prose, reasoning as a closed disclosure with duration, tool
calls through ToolBlock, files as an attachment chip, events as an
inline system line. An unregistered block part falls back to a
labeled card with its raw payload rather than going unrendered —
this package ships no generative-UI JSON-spec renderer.

* Add anchored-scroll mechanics for a growing message list

useAnchoredScroll: stick to bottom while the reader is there,
preserve scroll position once they've scrolled up into history.
MessageList wraps it as the ready-to-use scroll frame. Behavioral
only — no data fetching, no SSE.

* Regenerate barrel exports

Picks up the four new phase-1 chat-parts modules.

* Chat parts review fixes: streaming re-pin, unknown-part fallback, approval a11y, motion + tone discipline
* Add tests for CanvasHost

Covers the controlled-state contract: content===null renders the
empty state instead of the render-prop, focus is reflected purely
from the prop (re-render, not internal override), close/focus-toggle
call their callbacks exactly once with the right arguments, Escape
steps focused->split->closed and no-ops with nothing open, and the
transcript renders one parts-renderer block per message.

* CL-5654: Add CanvasHost, the dual-column generative-UI chat host

A chat column and a consumer-rendered canvas column sharing one
frame, fully controlled by content/focus props with onFocusChange/
onClose callbacks — no internal state beyond the derived layout mode.
Focus mode expands the canvas and collapses chat to a decorative rail
via visibility (not inert, so no JS media query is needed to know the
breakpoint); below lg the canvas takes over the same way regardless
of focus, since there's no room for both columns. Escape steps down
focused->split->closed, matching useChatDock's precedent.

lib/canvas-host-state.ts adds the optional, generic transition-
function helper (open/close/focus/unfocus/toggle) a host can wire
into its own state container, mirroring the workbench shell's
canvas-column-state.ts concept with one content-type parameter
instead of two content-specific ones.

* Add CanvasHost stories

Chat-only, even split, focus mode, empty canvas, and a long-content
scenario proving both columns scroll in their own box instead of the
page overflowing.

* CanvasHost review fixes: nested-overlay Escape, mobile chat-first split, composer nullish, morph easing

- Export isEscapeConsumedByNestedUI (and NESTED_OVERLAY_SELECTOR) from
  use-chat-dock.ts, generalized with an ownSlot parameter, and reuse it in
  CanvasHost's Escape handler so Escape inside a nested dialog/menu/select
  rendered by renderCanvas is consumed by that UI instead of closing the host.
- Rework GRID_CLASS/CHAT_CONTENT_VISIBILITY_CLASS so split and focus diverge
  below `lg`: split keeps the composer reachable (chat full-width, canvas
  off-screen), focus takes the canvas full-width — fixing the mobile
  composer trap and making the focus toggle a real affordance on small
  screens. Both tables are now exported and unit-tested directly.
- Treat composer={null} and an omitted composer prop identically — neither
  renders ChatPanelFooter.
- Add --ease-in-out to theme.css and switch the root grid's
  grid-template-columns transition to it (a morph, not an enter/exit),
  with a comment scoping the layout-property tradeoff shared with
  now-cards.tsx/live-status-line.tsx as out of scope for this pass.
…pattern (corbitsdev#29)

* Extract useDismissablePopover hook, adopt it in NotificationsBell

NotificationsBell, TenantSelector and ThreadSwitcher each hand-rolled the
same open-state-plus-outside-click-plus-Escape logic. Move it into one
headless hook so the behavior lives in one tested place instead of three,
and so a host can now lift the open state to a parent via optional
open/onOpenChange props (uncontrolled by default, matching prior behavior).

* Adopt useDismissablePopover in TenantSelector

Replaces the hand-rolled open state, root ref and outside-pointerdown
listener with the shared hook. Escape stays handled by the listbox's own
keydown handler (it closes on Tab too), so the hook's closeOnEscape is
turned off here. Adds optional open/onOpenChange props for callers that
want to control the popover from a parent.

* Adopt useDismissablePopover in ThreadSwitcher

Same hand-rolled open/outside-click/Escape logic as NotificationsBell,
now sharing the hook instead of a third copy. Adds optional
open/onOpenChange props for callers that want to control the popover
from a parent.

* Extract usePrefersReducedMotion hook, adopt it in AnimatedNumber

animated-number, dither-canvas and use-scroll-current-into-view each read
matchMedia("(prefers-reduced-motion: reduce)") by hand, and only as a
one-shot snapshot at effect-start — none of them notice the OS setting
flipping while mounted. The hook centralises the check and makes it live via
useSyncExternalStore, so AnimatedNumber (and the other two, in the following
commits) now react immediately to a live preference change instead of only
on their next remount or value change.

* Fix DitherCanvas's ink fallback to track the active theme

The fallback for when --primary can't be read (styles not yet attached)
was a literal copied from the light theme's orange, so it rendered wrong
if that codepath ever ran under dark mode. It now reads the canvas's own
computed color instead, which the theme's base layer already ties to
--foreground for whichever theme is active. Also adopts the new
usePrefersReducedMotion hook in place of the ad hoc matchMedia check, so a
live preference change is picked up without remounting.

* Adopt usePrefersReducedMotion in useScrollCurrentIntoView

Same ad hoc matchMedia read as AnimatedNumber and DitherCanvas, now
sharing the hook instead of a third copy.

* Add controlled open state to ToolBlock

Its expand/collapse was internal-only, with no way for a host to close
every tool call in a transcript at once, or open a specific one from
outside. Adds optional open/onOpenChange, uncontrolled by default so
existing callers (and defaultOpen) are unaffected.

* Add controlled open state to ToolNarrative

Same gap as ToolBlock, which this mirrors closely: no way to drive the
disclosure from a parent. Adds optional open/onOpenChange, uncontrolled
by default.

* Add architecture audit for small-stateless-components pass

* Document the standing pattern for future components

* Fix ARCHITECTURE.md claims that didn't match the tree

Re-verified every component, hook and file cited in the doc against the
actual source. Known limits claimed no tests and no rendering gallery;
both exist now (251 tests across 39 files, 52 Ladle stories) and are
partial, not absent. The hooks and blocks rows in the layout table listed
one hook and two block dirs when there are fourteen hooks and three block
dirs. The composition-pattern example cited StatusChip and ScopePill,
neither of which exists — workflow-registry.ts documents that StatusChip
was deliberately never built — replaced with the real LiveRunInspector/
WorkflowStatusBadge composition.

* Architecture doc verified against the tree; controllable-state helper shared; functional setters restored

Extracts the controlled/uncontrolled resolution that use-dismissable-popover,
ToolBlock and ToolNarrative each duplicated into one hook, useControllableState,
now the single owner of that ternary. It also warns in dev (mirroring React's
own controlled/uncontrolled <input> warning) if a caller switches between
controlled and uncontrolled after mount, instead of failing silently.

setOpen also regained the functional-updater form it lost in the earlier
refactor (setOpen((prev) => !prev)), matching useState's own setter shape;
NotificationsBell, ThreadSwitcher, ToolBlock and ToolNarrative all call it
that way again.
…sdev#30)

* Add tests for Select

* Select: native select wrapper matching Input's border/focus/disabled treatment

* Add tests for Textarea

* Textarea: multi-line Input with optional autoResize

* Add tests for Checkbox

* Checkbox: indeterminate-capable, label/description via Switch's aria-describedby convention

* Add tests for RadioGroup

* RadioGroup/RadioOption: compact single-select rows for agent/mode pickers

* Update docs: regenerate package exports for the new form primitives

* Textarea: cap autoResize growth and scroll past the cap

autoResize grew the textarea to fit content with no ceiling. Adopt
chat-input's capped pattern: an optional maxHeight prop (default 200,
matching chat-input's MAX_ROWS_PX) bounds the growth, and once content
exceeds it the control switches from overflow-hidden to overflow-y-auto
so the overflow is scrollable instead of clipped.

* Checkbox/RadioOption: bare-control mode, whole-row click target, matched glyph transition

Settle the composition model both primitives need before the workbench
cutover consumes them:

- Omitting `label` now renders just the control, with the same
  `id`/`describedBy` passthrough contract as Switch, so either can be
  composed into an externally labeled row the way ToggleList composes
  Switch.
- Checkbox's description was a sibling <p> outside the <label>, so
  clicking it did nothing. It now sits inside the <label>, mirroring
  RadioOption's existing structure, so the whole row — description
  included — is the click target in both.
- Checkbox's Check/Minus glyphs now reveal with the same scale
  transition as RadioOption's selected dot (scale-0 -> peer-*:scale-100
  via transition-transform) instead of an untransitioned opacity swap,
  so checking a box and selecting a radio read as the same motion.

Promotes tmp/critique-tests/checkbox-hit-area.test.tsx into
checkbox.test.tsx and radio-group.test.tsx, flipped to assert the new
standardized behavior, and removes the tmp/ scaffolding.

* Stories: cover disabled+description, invalid+description, disabled+invalid

Adds the combined-state stories the workbench cutover will actually
hit, across all four form primitives. Select and Textarea have no
description prop, so they demonstrate the same states via the
aria-describedby pattern their existing Invalid stories already use.

* contrast-test: check destructive border against background and card

destructive is now the invalid-state border color on Select, Textarea,
Checkbox, and RadioOption. Checks it against both surfaces at the 3:1
WCAG 1.4.11 UI-component threshold, not the 4.5:1 text threshold. Both
pairs already clear it in the current theme (light 7.43:1/6.74:1, dark
7.33:1/5.20:1) — no token change needed.

* StatGridItem: resolve border radius through the --radius token

rounded-[12px] was a literal that bypassed --radius overrides —
consumer apps that zero out radius (workbench does, app-wide) had no
way to reach this component. Switches to rounded-lg, matching the
token-based treatment its sibling StatTile already uses for the same
card shell.
active:scale-[0.97] under a dedicated transition-transform, gated by
motion-safe so reduced-motion users get none of it, and kept separate
from transition-colors so the brightness press-frame stays instant.
Adds --ease-spring alongside the existing --ease-out/--ease-in-out, plus
--duration-micro/--duration-standard/--duration-large, so the Workbench
design canon (DESIGN.md, PR corbitsdev#154) has named tokens to reach for instead
of literal ms/cubic-bezier values. Audited the semantic tokens the canon
references (--primary, --background, --card, --muted, --border, --ring,
--ok, --warn, --chart-1..5, sidebar row tokens) against theme.css — all
already present, none added.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
Tailwind v4's utility namespace for transition durations is
--transition-duration-*; --duration-* in @theme inline compiled to
nothing because an unreferenced inline-theme entry is dropped from the
build. Duration values now live as plain :root custom properties (so
var(--duration-standard) works for a consumer's hand-written CSS, same
as the raw hex tokens above them) and are re-exposed under
--transition-duration-* in @theme inline for the duration-micro/
-standard/-large utility classes. Verified against the built
dist/styles.css, not the source: with a class temporarily referencing
duration-micro/-standard/-large/ease-spring, all four utilities and
the --duration-* custom properties are present in the compiled sheet.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
Motion tokens: named easings and durations
Covers useListSelection's toggle/range/select-all/clear behavior
(including anchor re-anchoring and reversed ranges), SelectionCheckbox's
role/aria-checked/keyboard handling, and BulkActionBar's count-gated
render and Escape-to-clear.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
A shared, headless-first selection system every Workbench list page can
adopt in place of its own bespoke checkbox-column logic:

- useListSelection: toggle-by-id, cmd/ctrl-click add, shift-click range
  selection anchored on the last plain toggle (re-anchors on the next
  plain toggle, ranges correctly whichever direction the shift-click
  lands), select-all, clear, and a live selected count.
- SelectionCheckbox: a row's checkbox cell, hidden until the row is
  hovered/focused and always visible under `(hover: none)` for touch.
  Built as a role="checkbox" button rather than a native input so it can
  participate in the ancestor row's hover/focus-within state; the
  select pop uses a new `corbits-selection-pop` keyframe since no
  `--spring-*` motion token exists in the theme yet — it's a literal
  cubic-bezier overshoot to swap out once one lands.
- BulkActionBar: a floating pill, sticky to the bottom of its
  positioning context, that mounts once a selection exists and rises in
  via a new `corbits-bulk-action-bar-in` keyframe. Escape clears the
  selection from anywhere on the page while it's showing.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
Addresses review feedback on PR corbitsdev#33 before the subpath ships:

- BulkActionBar: the horizontal centering (-translate-x-1/2) is now a
  permanent class on an outer positioning wrapper instead of living
  only in the entrance keyframe's transform — under
  prefers-reduced-motion, where the motion-safe: animation never runs,
  the bar previously had no -50% correction at all and ran off-viewport.
  The keyframe now only animates opacity/translateY on an inner element.
- useListSelection: selectedIds/selectedCount/isSelected are reconciled
  against the current `ids` on every read, so a row dropped by a
  refetch stops counting without the caller pruning anything, and
  reappears automatically if the same id comes back. toggle/selectAll
  read `ids` through a ref instead of closing over the array directly,
  so their identity is stable even when a caller passes a fresh array
  each render. Documented the ids-must-be-unique assumption. Widened
  TId's bound from `string` to `PropertyKey`. Reducer's initial state is
  now built by a typed factory instead of an `as State<TId>` cast.
- SelectionCheckbox: checked now accepts `"indeterminate"`
  (aria-checked="mixed") for a select-all header checkbox, plus an
  ariaLabel override for the one hardcoded English string.
- BulkActionBar: role="group" instead of an unmanaged role="toolbar",
  a label override prop, aria-live="polite" on the count, and the
  Escape listener now defers to event.defaultPrevented so a dialog or
  input above the bar wins.
- Stories hoist their `ids` arrays to module scope instead of mapping
  them inline on every render, matching the hook's own guidance.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
List selection: useListSelection, SelectionCheckbox, BulkActionBar
The plain <a href> on every non-last crumb forced a full page reload in
SPA consumers, which blocked workbench PR corbitsdev#156 from adopting the
component. Adds an optional linkComponent prop — a React.ComponentType
receiving {href, children} — that non-last crumbs now render through,
defaulting to the existing anchor so nothing changes for consumers who
don't pass it.

The last crumb was already text with aria-current="page" and never a
link, even when it carries an href; that behavior is unchanged.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
Review of PR corbitsdev#34 found BreadcrumbLinkProps carried only {href, children},
so an injected linkComponent lost the truncate/muted-foreground/hover
classes that lived inside DefaultBreadcrumbLink — a router Link rendered
full-foreground, non-truncating, visually indistinguishable from the
current-page crumb and able to blow out the min-w-0 flex row.

BreadcrumbLinkProps now carries a required className, passed at the
call site; DefaultBreadcrumbLink consumes it like any other injected
component. Also: switched the crumb li key from label to index (labels
can repeat), and documented that linkComponent must be a stable
module-scope reference plus how to wrap a to-based router Link.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
…link-slot

TopBarBreadcrumbs: link-component slot for SPA navigation
A magnifier in a top bar is supposed to morph in place into the search
field it opens (Workbench DESIGN.md canon). A dialog cannot be morphed
into — it renders somewhere else, over an overlay, with the control it
came from behind that overlay — so consumers wanting the morph were
reduced to expanding a read-only span beside a dialog that had the real
input. This adds the same palette machine in an inline presentation:
the field is the real input, wherever the consumer puts it, and the
results hang anchored beneath it with no overlay and no focus trap.

Both surfaces now share one keyboard driver, one result renderer and one
set of ids (usePaletteMachine), so the dialog and the inline bar cannot
drift into two different contracts. The leading slot stays mounted while
collapsed, which is what lets focus return to the magnifier on Escape;
dismissal on an outside pointerdown replaces the overlay the inline
surface does not have. Sizing and the morph itself stay with the
consumer, reached through the data-slot hooks.
…nd-palette

CommandPaletteInline: the palette as anchored, non-modal chrome
TheGreatAxios and others added 28 commits October 26, 2026 23:13
…arch-and-collapse

feat: add a sidebar search control that opens the palette
…-commands-in-a-step-title

fix: clip a shell command to one line in a step title
…race-header-describe-the-whole-run

fix: describe the whole run in the trace header
feat: read a trace as the question, the answer, and its steps
Patch for the sidebar search, clipped step titles, trace header, and trace reading now on main. No export was removed or renamed.
…tsdev#113)

* feat(conversation): add the chat data contract and its helpers

Add src/lib/conversation.ts, the message model and part union every chat
component reads: a tool part carrying an approval record beside the
call, parallel tool batches, generated images, sources, a streaming
status, replies and reactions. It reuses the chat-parts.ts members
unchanged.

Add groupActivity and summarizeActivity to fold thinking and tool steps
into one run, mediaKindOf, and checkAttachments for count, size and
duplicate limits. Add the --attention-wash token, mixed from --primary.

* fix(conversation): format thinking past an hour in hours

summarizeActivity only knew seconds and minutes, so an hour of
thinking read "Thought for 60m 00s". Durations of an hour or more now
read as hours and minutes ("1h 30m"); shorter ones are unchanged.

* fix(conversation): read a .ts file sent as a transport stream as text

mediaKindOf let any video/* media type win over the extension. Servers
and desktop browsers report a TypeScript file as an MPEG transport
stream (video/mp2t, or video/vnd.dlna.mpeg-tts on Windows), since both
use .ts, so index.ts read as video and previewed as a broken player.

Those two types now defer to the extension, and stay video when the
name has no known extension.
…sdev#114)

* feat(conversation): add Conversation and its scroll anchoring

Conversation owns the scroll container so every chat surface scrolls
the same way. Scroll policy is a prop: settle lands a sent message at
the top and never moves while text streams; stick follows the end while
the reader is there. Layout shifts never scroll, and only the reader's
own input breaks a follow.

The anchor algorithm and settle geometry are pure functions, and an
anchor correction also applies mid-glide so a glide lands where it
meant to. useConversation() shares an item registry, scrollToItem,
scrollToEnd, captureAnchor/restoreAnchor, the scroll container and a
per-frame scroll snapshot that tells the reader's scrolls from the
engine's. The context value keeps its identity across scrolls.

* fix(conversation): release a ConversationItem when React 18 detaches it

React 18 ignores a cleanup returned from a ref and calls the ref with
null, so an unmounted item stayed registered and scroll anchoring
measured a detached node.

* fix(conversation): keep page scrolls and settle glide precedence

A scroll that came with no reader input was taken for a layout shift
while the view followed the end, and reversed. Moving focus to an
earlier item, or find in page, snapped straight back. Such a scroll
that leaves the end outside the moment after a resize is now kept
and breaks the follow; the engine's own writes are still told apart
by position.

A reader scroll during a glide now stops the glide, so momentum that
was already running wins and stays the reader's, instead of being
reported as theirs and then overwritten.

The reserve after a send is re-synced when the log remounts, so the
end is the content's end again, and when the settled item leaves the
reserve keeps what the view needs instead of collapsing and moving
it. A sentId with no rendered item no longer glides, collapses the
reserve or breaks the follow. Only scroll keys open the reader's
gesture, and the scroll button hands focus to the log when it leaves
while focused.

* fix(conversation): keep page scrolls mid-stream; let space activate

Any scroll within 100ms of a resize was taken for the resize's clamp.
Streaming resizes on every chunk, so while a reply wrote, a focus or
find-in-page scroll was undone: written back to the end while
following, or restored to the old anchor by the next chunk after a
send. With the browser's anchoring off, a resize moves the view only
by clamping it to the last reachable position, so only a scroll that
lands there is skipped now. Any other is kept and breaks a follow.

Space on a button or similar control inside the log activates it, not
scrolls, yet it cancelled a glide in flight. Its other scroll keys and
touch still do, since they really scroll the log.

scrollToItem takes an onEnd callback that reports whether the glide
arrived, so a caller can tell a jump cut short from one that landed.

* test(conversation): run the momentum-cap test on a set clock

The test slept in real time and read the wall clock, so on a loaded
machine a gap between two scrolls could outlast the gesture's quiet
window and end the reader's gesture before the cap. It now moves
performance.now itself, 100ms per scroll, and checks every scroll on
each side of the two-second cap.

* fix(conversation): forward refs to the log, items and scroll button

ConversationContent, ConversationItem and ConversationScrollButton
read ref from their props, which only React 19 passes to a function
component. Under React 18, inside the peer range, a host ref on any
of them stayed null and React warned that ref is not a prop. They are
forwardRef components now, so the ref reaches the scroll container,
the item and the button under both versions.

* fix(conversation): reopen a remounted log where the reader was

When ConversationContent mounted again inside the same Conversation,
attach wrote the end only while the view followed and otherwise kept
the new container's scrollTop of 0. After any send under settle, or
once the reader left the end under stick, a remount (a key change, a
panel that unmounts its log) opened at the oldest loaded message.
attach now restores the held anchor, which the new items answer to
under the same ids, and still writes the end while following.

* fix(conversation): leave the follow alone for an unmounted scrollToItem

scrollToItem dropped the follow before the glide found there was no
such item, and starting that glide cancelled any glide in flight. A
call for an id outside the mounted window moved nothing and reported
onEnd(false), yet under stick a streaming reply then ran off the
bottom of the view, and a jump already under way stopped short.
scrollToItem now reports onEnd(false) for an unmounted id and returns
before touching the follow or the glide in flight.

* fix(conversation): centre the empty state inside the log

ConversationEmptyState centres with flex-1 and justify-center, but
inside ConversationContent its parent is the content column, which is
only as tall as its content. With no free space to grow into, the
empty state sat at the top of the log. The column now fills the view
while it holds an empty state, so the empty state centres in it.

* fix(conversation): draw a bare divider's rule as an hr

Without a label, ConversationDivider was a div with role separator
around a decorative span. Its rule is an hr now, styled as before, so
the separator is a real element and the div carries no role. The
labelled form is unchanged: its label is read in place and its rules
stay decoration.

* fix(conversation): do not report arrival when the glide target is gone

scrollToItem kept calling onEnd(true) after the item unmounted and the
view never moved. End that glide as not arrived.

* test(conversation): satisfy the typecheck on the mid-glide case

The probe assigns the API from a child, so a null check narrowed it
to never under tsc.

---------

Co-authored-by: Sawyer Cutler <sawyer@dirtroad.dev>
…ev#115)

* feat(prompt-input): add PromptInput with token highlighting

PromptInput is a fully controlled form. PromptInputTextarea draws
@mention and /command tokens on an aria-hidden mirror beneath
transparent native text, grows through field-sizing with a measured
fallback, and never submits during IME composition. usePromptInput
drives PromptInputSuggestions from the keyboard, and PromptInputSubmit
turns Send into Stop while a reply is running.

* fix(prompt-input): keep send focusable and expose suggestion state

A focused Send that turned disabled dropped focus to the page. That
happened in the normal flow: a host that clears the draft on submit
makes Send unsendable when the reply ends or Stop is pressed. Send now
uses aria-disabled for "nothing to send", and the form's guarded submit
keeps it inert. A host's own disabled is still a real one.

The suggestion list's state was invisible to assistive tech, and a
textarea may not carry aria-expanded. The textarea now has
aria-haspopup="listbox", and aria-autocomplete is set only while a
suggestions part is mounted. A polite status announces how many
suggestions are open and empties when the list closes.

A value set by the host moved the caret without a select event, so
suggestions opened for a stale position. The value-sync effect now
reads the caret again while the textarea has focus.

Document that a suggestion render callback must return a
PromptInputSuggestion, warn in development when the current option's
id is missing, and cover Safari's keyCode 229 Enter after composition.

* refactor(prompt-input): match the suggestion rows to the toolbar popups

Drop the accent stripe from the active suggestion, keeping the neutral
fill alone, and tighten the rows to match the toolbar's popups.

* fix(prompt-input): keep the caret past a picked token

Picking a suggestion through the browser's editing left the caret after
the inserted text. When a following space was reused that is still
inside the token, so the list stayed open and Enter picked again instead
of sending. Move the caret to where insertPromptToken puts it.

insertPromptToken reused a following line break like a space, putting
the caret on the next line. Reuse only a space within the line; before a
line break, add one so the caret stays on its line and outside the
token.

* fix(prompt-input): keep Escape on an open list from closing a dialog

Radix layers such as Dialog dismiss on an Escape they see in the
document's capture phase, unless it is already prevented. That runs
before React's handlers, so the list's stopPropagation came too late
and Escape closed both the list and the dialog around the prompt.

While the list is open, prevent Escape from the window's capture phase,
which runs first. PromptInputTextarea skips the hook when a consumer's
onKeyDown prevents a key, so let an Escape the list claimed through.

* fix(prompt-input): forget Escape and arrow choices once a token ends

The key Escape dismissed and the row the arrows chose outlived their
token. Escape was keyed on the token's start and text, so retyping the
same token, or typing it again in the next draft, opened the list and
closed it at once. The arrow choice survived until a pick, so a later
token's list opened on that row and Enter picked it.

Clear both whenever the caret is in no token: on change, select and
blur, and when the host sets the value. Narrowing within one token keeps
the caret inside it, so the choice still holds there.

* fix(prompt-input): keep a press anywhere in the list off the textarea

Only each option cancelled mousedown. A press on the list's padding,
the gaps between rows or its scrollbar moved focus out of the textarea,
whose blur closed the list, so a long list could not be scrolled with
its scrollbar.

Cancel mousedown once on the listbox instead. Scrollbar dragging and
wheel scrolling still work, and custom option content is covered too.

* docs(prompt-input): say that an open list's Escape arrives prevented

usePromptInput prevents Escape from the window's capture phase while
its list is open, before any React handler runs. A host that builds its
own field and skips textareaProps.onKeyDown for prevented keys would
never close the list, and nothing said so. Document it on the hook and
on textareaProps.

* fix(prompt-input): forward the textarea's ref under React 18

PromptInputTextarea read `ref` from its props, which React 19 passes
but React 18 strips from a function component's props, so a host ref
never reached the textarea on React 18. Wrap it in forwardRef; the ref
still merges with the one the prompt keeps for itself.
…v#116)

* feat(panel): add a resizable Panel with keep-mounted panes

Panel resizes from PanelHandle by pointer without a React render while
dragging, by arrow keys, and resets on double-click or Enter. The main
area keeps a minimum width and a narrow container gets an overlay.
PanelContent with keepMounted keeps a hidden pane's scroll position and
typed text. useResizable holds the width logic.

* fix(panel): ignore handle clicks and flag pane value mistakes

A press on the resize handle that never moved still committed the
rendered width on release. When the container had clamped a stored
width, that click silently overwrote the user's choice. When the stored
width equalled the default, the click reset it. The pointer now has to
travel 4px before the press counts as a drag, and only a drag commits.

In development, PanelContent now logs a console error when two panes
share a value or when its value names no pane. Each pane registers its
value from an effect, so this check also catches a missing value
without keepMounted, where panes that are not shown never reach the DOM.

Adds tests for both, and for the right-to-left mirroring of keys and
drags, which had none.

* fix(panel): keep focus where a press, autofocus or resize leaves it

A press on the resize handle never focused it: preventing the
pointerdown default also cancelled the focus a press gives, so the
arrow keys did nothing until Tab reached the handle. The drag already
blocks text selection with user-select, so let the press focus it.

A pane that autofocuses a field takes focus before the panel's mount
effect runs, so the panel saved that field as its opener and focus
fell to the body on close. Record where focus came from when it moved
into the panel and use that as the opener in this case. Under
StrictMode the simulated remount would hand the field's focus to the
opener, so give it back when the effect sets up again.

When the row widened and the overlay went away, focus stayed on the
panel root. Give it back to what the overlay took it from, if it is
still on the panel itself.

* fix(panel): set the resize refs only from committed renders

useResizable wrote the width and the bounds into refs in the hook body.
A render React later discards, such as a transition that suspends,
still left its width there, so the next key press on the handle wrote
that uncommitted width onto the element and compared against it.

Set both refs in the layout effect that already runs when the width or
the bounds change, before it writes the width to the element.

* fix(panel): make a hidden pane inert on React 18 too

PanelPane passed `inert={!shown}`. React 18 does not know the attribute:
it dropped the boolean and logged a warning, so a hidden pane kept
mounted was never inert there, and the shown pane passed `inert={false}`
for nothing.

Pass `inert` only on a hidden pane, as the string "inert", which both
React 18 and 19 write to the DOM without a warning.

* fix(panel): forward the Panel ref on React 18

Panel read `ref` from its props, which only React 19 passes to a
function component. On React 18, which the peer range allows, the ref
was dropped with a warning, so a host could not reach the panel
element.

Wrap Panel in forwardRef so the ref reaches the aside on both.

* fix(stories): keep the panel stories' opener mounted

The panel stories showed "Open thread" only while the panel was closed,
so the button left the page in the same commit that mounted the panel.
The panel found no opener to record, and closing it sent focus to the
body instead of back to the button.

Keep a toggle in the main area in every panel story, with aria-expanded
and aria-controls, so the panel records it and returns focus to it.
…dev#117)

* test(stories): add sample conversations for the chat stories

Add deterministic samples built only from the conversation contract:
a short chat, a 300-message history, a tool storm with a batch, a
failure and every approval outcome, generated images, a thread and a
failed send. playTurn reveals a message's parts over time for streaming
stories. Images are inline SVG, timestamps are fixed, and nothing reads
the clock or Math.random.

* fix(stories): replay queued and stage-less tool calls faithfully

The sample replay gave a queued call (pending, no approval record) a
synthetic running frame, so it ran and then went back to pending. It
is now shown queued from the start, like a waiting or running call.

A tool batch also emitted one frame per call, so a call with no
earlier form produced a frame identical to the one before it. The
batch now finishes only the calls that change, one at a time, and
leaves the rest in their final form, so every frame differs from the
previous one.

* docs(stories): say what the samples frames column counts

The Samples table caption said Frames counts the steps a streaming
story plays for the last message, but no story replays a sample's last
message. It now says the column is how many steps turnFrames gives for
that message.
* feat(prompt-input): add the PromptInput toolbar parts

Add PromptInputToolbar with an add menu, icon buttons, model and mode
pickers, a float above the box, a context chip and a note. The pickers
are select-only comboboxes and the add menu is a menu button; both keep
or return focus to their trigger and need no optional peer. A toolbar
placed last in PromptInput takes its own row.

* fix(prompt-input): close host-opened popups on escape; follow disabled

A toolbar popup the host opened stayed open when Escape was pressed in
the textarea, because the popups opt out of the shared hook's Escape
handling to run their own. Listen for Escape on the document in the
bubble phase while open and close when focus is outside the popup,
leaving focus where it is. Handled Escapes are skipped, so the
suggestion list still closes first.

The toolbar parts each took their own disabled flag and ignored the
one on PromptInput, so a disabled prompt still offered a live add menu,
selects and buttons. Expose usePromptInputDisabled and default each
part's disabled to it; a part's own disabled still wins.

Also say that Home and End open a select on the first or last option,
and cover the toolbar, float and note parts with a test.

* refactor(prompt-input): tighten the toolbar popups

The select and add menu rows stacked a bold name over its description
and marked the active row with an accent stripe as well as a fill.
Draw each option as one row, the description inline and muted, mark
the active row with the neutral fill alone, as Menu does, and keep the
check for the chosen option. Rows sit flush with less padding.

* fix(prompt-input): keep toolbar escape out of enclosing dialogs

A dialog dismisses on Escape from a capture listener on the document,
so the popups' bubble-phase stopPropagation never kept it open: one
Escape closed both the popup and the dialog around the prompt. While
open, each popup now prevents Escape in the window's capture phase,
ahead of the dialog. Escape from outside the popup, such as the
textarea beside a popup the host opened, closes it there too, unless
the suggestion list is showing and takes that Escape first.

A disabled part now shows its popup closed, and turning disabled
while open closes it for the owner, so nothing can be chosen from a
popup in a disabled prompt, as the toolbar's docs already promised.

The add menu kept the focus request of an ArrowUp the owner declined,
so the owner's own later open moved focus into the menu. The request
now lasts only for the render that answers it.

* fix(prompt-input): claim only escapes from the prompt's own box

While a toolbar popup was open it took every Escape in the window
whose target lay outside the popup, closing itself and preventing the
event. A dialog opened elsewhere on the page then saw its Escape as
handled and stayed open while the popup behind it closed, and an
Escape inside the add menu also closed a model list the host had
opened beside it.

A popup now closes on an outside Escape only when it comes from the
same PromptInput, such as its textarea, and not from inside another
open toolbar popup, which handles its own. Each popup's root carries
data-state="open" while open, so that check reads the DOM. Escapes
from anywhere else are left alone for the layer they were pressed in.

* fix(prompt-input): keep focus home on a press inside an open popup

Only each select option prevented the mousedown that moves focus, so
a press on a group heading, the list's padding or the gap between
rows dropped focus to the page. The trigger, which holds the list's
keys, then got none: the arrows did nothing and Escape closed the
list without returning focus. The add menu had the same gap around
its items.

The listbox and the menu now prevent that mousedown as a whole, so
focus stays on the select's trigger or the focused menu item wherever
the press lands, and clicks still choose.

* fix(prompt-input): forward refs on the add menu and selects

PromptInputAddMenu and the model and mode selects read ref from their
props, which only React 19 passes to a function component; under
React 18, inside the peer range, the ref was dropped with a warning.
Wrap them, and the select they share, in forwardRef so a ref reaches
each part's root element on both versions.
* feat(panel): add tabs for the Panel

Add PanelTabs, PanelTab and PanelTabAdd. Tabs select by click, close
by the close button, middle-click or Delete, reorder by drag and by
Ctrl/Cmd+Shift+Arrow, and move by Arrow, Home and End; a long row keeps
the selected tab in view. useTabs owns the open tabs and which one is
selected, and useTabDrag reorders without rendering until release.
These are separate from the existing Tabs.

* fix(panel-tabs): keep focus and announce moves in the tab row

Closing the selected tab with its × or a middle-click while focus sat
in its pane dropped focus to the body: neither control takes focus,
and only focus in the row was put back. Keep the focused element
before the close renders and refocus the newly selected tab when that
element left the document with its pane.

Tab moves by drag or Ctrl/Cmd+Shift+Arrow were silent to screen
readers. A polite status region beside the row now says "Moved to
position N of M", worded by an optional movedMessage prop.

Export panelTabsHeaderClassName so hosts stop copying the header
classes by hand. The story and e2e fixture now show the header
"Close tab" button that touch screen-reader users need, since the
tab's × is hidden from them. The docs also say why touch does not
drag.

* refactor(panel-tabs): keep the drag's onMove current from an effect

useTabDrag wrote the latest onMove into a ref during render. Write it
from a layout effect instead, which runs before any pointer event can
reach the handler.

* fix(panel-tabs): return focus to the selected tab after a drag

The press that starts a drag focuses the pressed tab, and the drag
never selects it, so after a drop focus sat on an unselected tab
outside the row's tab stop: the arrows started from it and Delete
closed it while another tab was shown. useTabDrag now reports the end
of every drag, and PanelTabs sends focus in the row back to the
selected tab whether the tab moved or not.

Escape cancelled a drag while the button was still down, and the
click swallow was disarmed before the release, so letting go selected
the tab after all. Swallow the click of that press's own release
instead, and stop waiting if another press comes first.

* fix(panel-tabs): report a selection on close only when it moves

useTabs' close always passed an updater to the selection setter, which
reports whatever the updater returns. Closing a tab that was not
selected still called onValueChange with the unchanged selection. With
a controlled value the updater saw the value from the last render, so
several closes in one event did not see each other: closing every tab
reported a selection that had already closed instead of null.

Track the selection the current event has reached in a ref that every
commit sets back to the rendered value, and select on close only when
the closing tab is that selection. A controlled parent can turn down a
plain select without rendering, so that select leaves the ref alone.

* fix(stories): keep the panel tabs story's opener mounted

The panel tabs story showed "Open panel" only while the panel was
closed, so the button left the page in the same commit that mounted
the panel. The panel found no opener to record, and closing it, or
closing its last tab, sent focus to the body.

Keep a toggle in the main area, with aria-expanded and aria-controls,
so the panel records it and returns focus to it however it closes.

* fix(panel-tabs): forward the PanelTabs and PanelTab refs on React 18

PanelTabs and PanelTab read `ref` from their props, which only React 19
passes to a function component. On React 18, which the peer range
allows, the ref was dropped with a warning, so a host could not reach
the row or a tab's root.

Wrap both in forwardRef so the refs land on both versions.
corbitsdev#120)

* feat(agent): add Tool, ToolApproval, ToolGroup, Reasoning and Activity

Tool renders every ToolTraceStatus with an icon and a word, and its
input and output come through a render prop. ToolApproval asks a person
to approve once, approve always or deny, and renders a settled decision
from the approval record itself. ToolGroup shows calls that ran at the
same time, Reasoning shows thinking with its duration, and Activity
folds both into one summary line showing the latest ten steps.

The stories axe sweep now measures text contrast itself wherever axe
cannot: it hit-tests each text box to find the ground actually under
it, composites colour and opacity layers, reads gradient and fill
colours, and reports anything it cannot resolve as unmeasured rather
than passing it. Browser fixtures pin the cases that must never pass.

* fix(agent): keep focus and the outcome when a group's call is decided

A standalone ToolGroup was open only while a call waited, so deciding
the last one unmounted the list with the focused button: the outcome
never showed and focus fell to the page. With calls still waiting, the
decided call re-rendered as a Tool whose fresh ToolApproval never ran
its own recovery, so focus fell to the page there too.

The group now stays open once a waiting call opened it, until a person
closes it, and after a decision moves focus to the next waiting call's
first button, else the decided call's outcome, else its own row, the
same order the message renderer uses. A Tool opened by an error stays
open through a retry that succeeds, so focus in its output is kept.

The settled approval line, where focus lands, is now a group named by
its text, with a space so it reads "Approved once mail__send" rather
than running the outcome into the tool name. An e2e scenario checks a
standalone group never leaves focus on the page between frames.

* fix(agent): keep a stray Enter from deciding an approval

Approve, Approve always and Deny did not set a type, so inside a form
they were submit buttons: Enter in a text field activated Approve, and
aria-disabled did not stop it. They are now type="button".

After a decision in a group, focus moved to the next waiting call's
Approve button, so the repeat of a held Enter approved a call the
person never chose. Focus now moves to that call's card, which is named
by the question and the tool ("Needs your approval calendar__invite")
and does nothing on Enter. The e2e scenario holds Enter across the
decision to pin it.

* fix(agent): recover a group's focus whenever the focused call goes away

ToolGroup moved focus only when fewer calls waited after an update. A
decision that landed as another call started waiting left the count
unchanged, so the decided button unmounted and focus fell to the page.
The group now recovers after any update in which focus that was inside
it went to the page.

A decided call the host removed from the batch was no longer in the
list, so the search for the next waiting call started from the top and
focused the first card. The group now remembers the focused call by its
id and, if the host drops it, starts at the call that followed it in
the last commit, which holds even when other calls shift around it.

* fix(agent): show a live run's newest thought as still thinking

Activity kept `streaming` on its context but rendered every reasoning
step with Reasoning's default, finished. While the run's line said
"Thinking…", the step said "Thought for 2s", and a thought with no text
yet was left out. The default step renderer now passes `streaming` to
the newest step while the run streams; earlier thoughts stay finished.

* fix(e2e): leave text under an inset shadow unmeasured

The contrast fallback took an opaque background colour as the ground
before looking at an inset box-shadow, and its layer walk never recorded
one. #333 text on white under `inset 0 0 0 80px #000` was judged against
white and passed at 12.6:1, though black is what is under the glyphs.
An inset shadow now keeps a box from being a solid ground and adds an
unresolved layer, as a background image already does.

* fix(agent): keep a held Enter from folding a group after a decision

When a decided call had no outcome line to focus, because the host kept
no approval record and only changed the call's status, or dropped the
call with nothing else waiting, ToolGroup moved focus to its header
button. The next repeat of a held Enter clicked it and folded the group,
taking away the list the person had just acted in.

The group now falls back to the decided call's row in the list, or, if
the host dropped it, the row now in its place. Rows take focus from
script only and are not buttons, so a repeated Enter there does
nothing. The header remains the target only when no row is left.

* fix(agent): leave a finished thought with no text out of an opened run

Reasoning renders nothing for finished thinking with blank text, but
ActivityContent still wrapped it in its own focusable list item. An
opened run showed an empty item that screen readers counted, that took
one of the ten places before "Show earlier steps", and that received
focus from that control while reading nothing.

ActivityContent now leaves such a step out before it counts and lists
the steps. Each shown step keeps its index in the run, so keys and the
index passed to renderStep are unchanged.

* fix(stories): show a denied call in BatchWaiting as denied

The story set the call to output-available after either decision, so
after Deny the row showed a check and "Done" above a "Denied" line. It
now sets output-denied for a rejection, as a host should.

* fix(agent): forward refs on ToolApproval and ToolGroup under React 18

Both components read `ref` from their props. React 18 strips `ref` from
the props of a plain function component, so a host's ref never reached
the root there, though the peer range accepts React 18. Both are now
wrapped in forwardRef, which passes the ref on React 18 and 19 alike,
and still merge it with the root ref they use for focus recovery.
* feat(message): add the Message family

Message renders an agent, person or own message with an avatar, header,
content, footer and send status; own messages sit in a bubble, and
markdown comes only through MessageContent's render prop. MessageFold
folds a long answer and keeps the reader's place when folding from
inside it. MessageActions puts extras in an overflow menu without an
optional peer, and MessageReactions, MessageReplies and useCopy cover
reactions, reply summaries and copy feedback. A failed message offers a
retry.

* fix(message): merge a host ref into the action menu and keep the fold

A ref passed to MessageActionMenu replaced the root ref, so a click
outside no longer closed the menu and it no longer flipped above a
trigger near the bottom. The host's ref is now merged into the root.

Folding a long answer kept the reader's place only when the host
committed the close in the same frame as the click. The fold now reads
the toggle's position with getSnapshotBeforeUpdate, after React decides
to fold and before the DOM changes, so the place is kept however and
whenever the close lands.

* fix(message): keep copy and menu focus on the latest request

useCopy let whichever clipboard write settled last set `copied`, so a
slow earlier write could show "Copied" after a newer copy failed, or
clear the state after a newer one succeeded. Each copy now takes a
number, and only the latest one updates `copied` and the timer.

The action menu kept the "focus the last item" request from an ArrowUp
that a controlled host ignored, so a later open, by a click or by the
host, landed on the last item. The request is now state and is dropped
in the render that finds the menu still closed, as the prompt's add
menu does, so any later open starts on the first item.

Also note in the menu's docs that a click outside closes it without
taking focus back, as the other popovers do.

* fix(message): mark a pressed action apart from hover without colour

A pressed MessageAction in the row took `bg-muted` and
`text-foreground`, the same two classes hover applies, and both are
colour. Once the pointer left, the only difference between a set vote
and an unset one was a faint tint and a darker icon, so a reader could
not tell which vote was set.

A pressed action now also draws an inset outline in the control-boundary
colour, which clears 3:1 against the page and which hover does not draw.
The MessageAction docs say what a pressed action looks like, and the e2e
action row gains a pressed and an unpressed vote to check it.

* fix(message): forward the ref on MessageActionMenu under React 18

MessageActionMenu read `ref` from its props. React 18 strips `ref` from
the props of a plain function component, so a host's ref never reached
the root there, though the peer range accepts React 18. The menu is now
wrapped in forwardRef, which passes the ref on React 18 and 19 alike,
and still merges it with the root ref that outside-press dismissal and
the side flip measure.

* fix(message): forward the ref on MessageAction under React 18

MessageAction read `ref` from its props. React 18 strips `ref` from the
props of a plain function component, so the trigger ref MessageActionMenu
passes never reached the button, and closing the overflow menu did not
return focus to "More actions". The peer range still accepts React 18.
MessageAction is now wrapped in forwardRef, which passes the ref on
React 18 and 19 alike, and attaches it to the button it renders in the
row and in the menu.

* test(message): keep the MessageAction ref check to a few lines

Focus already returns to the trigger when an overflow item is chosen.
Assert that MessageAction is a forwardRef there, instead of a separate test.

* ci: retrigger checks against the updated stack

The previous run merged a stack commit whose mid-glide test failed
typecheck. That test is fixed on conversation/05-conversation.

---------

Co-authored-by: Sawyer Cutler <sawyer@dirtroad.dev>
…bitsdev#122)

* feat(conversation): add useHistoryWindow and ConversationEarlier

useHistoryWindow keeps a window of the latest items, adds earlier ones
as the reader nears the top, freezes while the reader has scrolled away
and trims back once they return to the end. It works with history the
host pages in asynchronously, and the item in view stays put within a
pixel as earlier items load. ConversationEarlier marks the top with a
loading state and a retry when a page brings nothing. The doc comment
records why this windows rather than virtualizes, and its ceiling.

* fix(conversation): hold the history window until the view is read

An item committed synchronously in the frame the reader scrolled up
in, before the conversation's scroll pass, slid or trimmed the window:
atBottom and the freeze were still a frame old, so the oldest item was
removed while the reader was scrolled up.

A new item now commits with nothing removed. A layout effect then trims
or slides the window as before, unless the view has moved towards the
top since the last scroll snapshot; the next pass then freezes it if
the scroll was the reader's. The snapshot subscription moves to a
layout effect so the one sent when the container mounts is not missed.

A loader that throws now stalls like one that rejects, and its error is
reported once instead of escaping the marker's observer callback. The
scroll helpers both conversation specs copied now live in one module.

* fix(conversation): keep the window start when the last id is swapped

The window remembers the last item it was trimmed through. When that
id vanished in the same update as an append (an optimistic id swapped
for the server's, plus the reply), the window slid by one during
render. If the reader had just scrolled up, the next frame froze the
slid start and the window's first item was gone.

A missing trim id now falls back as a missing pin does: to the first
item of the last window that still exists. At the bottom, the next
commit trims back, as it does after a plain append.

* fix(conversation): freeze the history window on any move up

Only a scroll the reader made froze the window's start. A move the page
made, such as a glide to an item, focus or find in page, left the start
following the latest items, so the next appended item slid the window
and unmounted the item the view had just been moved to.

The window now freezes on any scroll snapshot that moved the view
towards the top and ended away from the end. Content growing below a
view that stays put leaves scrollTop unchanged, so the window still
slides then.

* fix(conversation): keep the history window's last slice in state

The window start's fallback, used when the item it is kept by is gone,
read the previous window from a ref during render. The ref was written
by a layout effect, so it went stale while Activity hid the host: a
start grown while hidden whose item was then deleted fell back to the
window from before the host was hidden.

The last slice now lives in the hook's state, recorded during render as
atBottom already is, and the scroll listener reads the window's first
item in the handler rather than in a state updater.

* docs(conversation): say which failed loads the history window reports

The onLoadEarlier doc said the hook never reports a failure. A loader
that throws synchronously is reported through reportError, so that it
does not escape the marker's observer callback; only a rejection is
left to the host.

* fix(conversation): forward ConversationEarlier's ref under React 18

ConversationEarlier read ref from its props, which React 19 passes but
React 18 strips, so a ref never reached the marker on React 18, the
lower end of the peer range. It is now wrapped in forwardRef.
…tsdev#123)

* feat(conversation): add ConversationMap and useReadingPosition

ConversationMap shows one tick per turn, marked faint, on screen or
current, previews a turn on hover or focus and jumps to it with the
conversation's glide. The rail is one tab stop with arrow keys and
Enter, and keeping the current tick in view scrolls only the rail.
useReadingPosition reports the current and on-screen turns from one
IntersectionObserver over the item registry, measuring on the
conversation's per-frame scroll snapshots. A jumped-to turn stays
current until the reader scrolls.

* fix(conversation): end unseen map jumps and keep the tick in view

A tick clicked mid-glide pinned its turn as current until the turn had
been seen. If ConversationContent went away first, or the turn was
removed, the pin was never released and a stale tick stayed current.
The pin now ends with the view it was gliding, and with its item.

Keeping the current tick in view ran only when the current turn
changed, so a rail that shrank could clip it. A ResizeObserver on the
rail now places it again at once.

The tick's data-current attribute is dropped: nothing read it, and
aria-current already carries the state.

* fix(conversation): give map ticks a 24px target on touch screens

The map's ticks shrink so the whole conversation fits on the rail: 14px
tall at most, 6px on a long chat. That is fine for a mouse but below
the 24px target WCAG 2.5.8 asks for, and hard to hit with a finger. On
a coarse pointer each tick now keeps a 24px height and the rail
scrolls, which already keeps the current tick in view. A mouse still
gets the dense rail.

* fix(conversation): end a map jump cut short before its turn is seen

A jump pins its turn as current until the reader scrolls or the turn
leaves the view. When momentum, a tap or a wheel that moved nothing
stopped the glide before the turn came on screen, nothing released the
pin, and the map marked a turn that was not on screen as current until
the reader next scrolled. The jump now hears from the glide whether it
arrived, and drops a pin whose turn was never seen.

Document that the map's turns must be mounted: a tick for a turn that
is not mounted does not jump, even when onSelect mounts it.

* fix(conversation): keep focus on the map when a focused turn goes

When the turn of the focused tick left `turns`, as when a history
window trims it or the host deletes it, its button unmounted and focus
fell to the page body; the next Tab started from the top of the page.

A layout effect now notices the focused turn is gone and, if focus is
on the body, moves it to the rail's tab stop (the current tick, or the
first), or to the conversation's log when the rail itself is gone.

* fix(conversation): let Escape close a map preview the mouse opened

Escape was handled only by the map's own keydown, so a preview card
opened by resting the mouse on a tick stayed over the content while
focus was in the composer or the log. Content shown on hover has to be
dismissable without moving the pointer or focus.

While a preview is open, Escape anywhere in the document now closes it.

* refactor(conversation): measure the map preview outside its updater

When the rail scrolled, the preview card's position was measured inside
a setPreview updater. React runs a queued updater during the next
render, so the DOM was read during render, and StrictMode ran the reads
twice.

The scroll handler now measures the tick of the preview it sees and
passes a pure updater that applies the result only if that preview is
still open, so a close queued by a wheel in the same frame still wins.

* docs(conversation): record which target-size rule the mouse rail uses

With a mouse, map ticks are under 24px tall, and the comment said only
that this was fine for a mouse. It now names the WCAG 2.5.8 exception
the dense rail relies on: scrolling the conversation is an equivalent
way to reach every turn a tick jumps to. Touch keeps its 24px ticks.

* test(conversation): share the conversation e2e helpers with the map

The map spec declared its own container, settled, offsetOf and wheel,
copies of the helpers the other conversation specs import. It now
imports them from the shared module.

* fix(conversation): forward ConversationMap's ref under React 18

ConversationMap took its props as the nav's, ref included, and spread
them onto the nav. React 19 passes ref as a prop, but React 18 strips
it, so a ref never reached the nav on React 18, the lower end of the
peer range. It is now wrapped in forwardRef and the ref set on the nav.
…#124)

* feat(attachments): add Attachments and the file drop zone

Attachments renders files as a stack above the input, tiles in a
message, or a list, with preview, info, remove and more parts. The host
owns the list: useAttachments applies the count, size and duplicate
limits, builds previews and revokes every object URL it made once its
attachment leaves the list. useFileDrop and AttachmentDropZone accept
files and whole folders, and a stray drop elsewhere never replaces the
page.

* fix(attachments): rebuild revoked previews and cover the disabled option

A hook hidden by Activity for longer than a tick cannot tell the hide
from an unmount, so useAttachments revokes its previews while the host
still holds the items, and the previews came back blank. Attachment
now rebuilds the picture from the item's file when its url fails,
owning that url and revoking it on unmount or hide. The hook's docs
say the held url itself stays dead, so code that reads it should make
its own from the file or keep the hook above the Activity.

No test passed disabled to useFileDrop, and the test named for it
covered the stray-drop guard instead; both are now tested.

AttachmentPreview takes a label for its open button, as
AttachmentRemove already does. The docs say AttachmentMore renders
last and how to name the drop zone as a region, carriesFiles is no
longer public, and tests exercise the uncontrolled fold.

* fix(attachments): take a nested picker's drop once and drop late reads

A drop on a FileInput inside the drop zone reached both the picker and
the zone, so the file was attached and then refused as already there,
or attached twice by a host that does not de-duplicate. The zone now
leaves a drop a child already handled to that child.

A folder walk could also outlive the zone: it still delivered after
the zone was disabled, and after unmount it added files whose preview
URLs nothing would revoke. A read that finishes once the zone is
disabled or unmounted is now dropped, and useAttachments makes no
preview URL once it has unmounted.

A nameless file was checked for duplicates under its empty name but
held as "Untitled", so two of them could slip in and a real Untitled
was refused. The check now uses the name the file is held under.

* fix(attachments): walk a folder dropped on the picker inside the zone

The drop zone left any drop a child had already handled to that child,
and FileInput handles every drop. A folder dropped on the picker inside
the zone therefore reached only the picker, which passed on the single
unreadable entry the browser lists for a folder, instead of the files
inside it.

useFileDrop now reads drops in the capture phase, before any child
sees them, and marks them taken. FileInput leaves a drop that a target
around it already took, so every drop inside the zone, the picker
included, is walked and reaches the zone's onFiles once.

* fix(attachments): show a picture with no url from its file

Attachment rebuilt a picture from its file only after the url failed
to load, and a missing url never fails. useAttachments makes no
preview url once it has unmounted or been hidden for longer than a
tick, so an image added in that window showed as its type for as long
as it was held.

An image or video with no url is now shown from a url made from its
file, revoked on unmount or hide like a rebuilt one, and falls back to
its type if that fails. Other kinds get no url made.

* fix(attachments): check adds against the committed list only

useAttachments stored its list and limits for add during render, so a
render that never committed, such as a transition that suspends, left
its list behind. An add from an event in that window checked limits
and duplicates against a list that was not on screen.

The hook now stores them in a layout effect, so add always sees the
committed list plus whatever it accepted since.

* test(attachments): put the effect replay comment above its test

The comment on why the StrictMode replay comes after the previews are
made sat above the add-after-unmount test instead of the replay test
it explains.

* fix(attachments): forward refs to the list, item and preview on React 18

Attachments, Attachment and AttachmentPreview read ref from their
props, which only React 19 passes to a function component. On React
18, which the peer range still covers, a host's ref never reached the
list, the item or the preview.

The three are now wrapped in forwardRef, so the ref reaches the
element on both versions. Their props types are unchanged.
…v#125)

* feat(media): add generated images, sources and MediaViewer

GeneratedImages holds each tile at its final size under the dither
grain while it generates, with the elapsed time beside the label. An
image made while watched is revealed by the grain; one already there
loads plainly; a failed one shows a message and a retry. The grain reads
its colours from existing tokens and draws one still frame under reduced
motion. Sources lists citations, and MediaViewer shows images on the
native dialog with arrow keys between items and focus returned to the
opener. Media addresses from agent data are checked before use.

* fix(media): drop actions on unloadable tiles and reset on src swap

An image tile that could not be shown still offered Download, Copy
and Open, handing the host an index to an image it cannot load and
opening an empty viewer. Broken tiles now show only host-supplied
action children, and viewer items for such images carry no src
instead of an empty-string sentinel, so the viewer says there is no
preview and offers no download.

A tile whose address changed in place kept its failed or loaded
flag, so the new address was never tried and skipped its reveal.
The flags now reset when the source changes.

Citations shown as plain text no longer carry the link icon, the
sources list keeps list semantics without bullets, and an image
without alt text is named after the prompt's opening words.

Adds viewer tests for video, audio, PDF, text and unloadable items,
and the first tests for Sources.

* fix(media): return focus to the latest opener; regrain on src swap

MediaViewer took the opener when it mounted, so if a host opened
another item while the viewer was up, closing it focused the first
opener. The cleanup now reads the latest opener and falls back to
whatever had focus on open.

A watched image that had already revealed kept the browser's old
picture at full opacity after its source changed, under a loading
state, until the new image arrived. The src swap now puts a watched
tile back under the grain, which reveals the new image once loaded.

Also document that Escape inside a PDF reaches the PDF viewer rather
than the dialog, and that Tab leaves the PDF for Close.

* fix(media): name a plain image tile after the prompt when it has no alt

Without onOpen, a tile's picture took its alt from the image alone, so
an image with no alt was marked decorative and screen readers skipped
it, though the tile already had a name from the prompt's opening
words. A tile that failed to load likewise read "Couldn't load this
image". Both now use the tile's name, so a plain grid names every
picture and every failure.

* fix(media): spend the opener on close

The opener named in viewer.open stayed in the hook after the viewer
closed, and MediaViewer prefers it over what had focus on open. Once
any tile had opened the viewer, a later opening through the
controlled index sent focus back to that old tile instead of to the
control that opened it. MediaViewer now clears the opener when it
returns focus there, so an opening without open falls back to what
had focus at the time.

* fix(media): forward MediaViewer's ref under React 18

MediaViewer read ref from its props, which only React 19 passes to a
function component; under React 18, inside the peer range, a host's
ref never reached the dialog. MediaViewer is now a forwardRef
component that hands the ref to the <dialog>, and its props type
leaves ref to forwardRef.
* feat(conversation): add ConversationThread

ConversationThread renders a reply conversation headed by its parent
message, with a divider counting the replies, built from the same parts
as the main conversation through a render prop. Each thread has its own
Conversation, so a reply settles at the top of its own log, and in a
keep-mounted panel tab a thread keeps its place and the host's draft
while the reader switches tabs. The thread's PromptInput sits below the
log, and aria-label or aria-labelledby names the log.

* docs(conversation): correct ConversationThread docs and story count

The sentId doc said an id from another conversation moves nothing.
Say instead that an id which is not one of the thread's items is
ignored, matching the engine guard the Conversation change adds.

Also note that an aria-label, like aria-labelledby, overrides
labels.log, and document data-thread-parent and data-thread-divider
as styling hooks.

In the ChatWithThreads story the thread's replies lived in the tab,
so the count under the parent never moved after a send. Keep them in
the chat and derive the count from them, as the full-chat story does.
Since the replies now outlive the tab, reply ids come from the
thread's earlier sends, and stopping or closing the tab mid-answer
marks the answer sent rather than leaving it streaming.
* test(stories): add a story of the whole chat

Compose the new chat family from the shared samples into one story, in
both scroll policies: a conversation with agent steps, approvals and
generated images, a prompt input with its toolbar and attachments, the
conversation map and history window, and a panel with a thread tab. A
browser spec drives the story to check that sending settles, approvals
keep keyboard focus after they fold, and attachments move into the sent
message.

* test(stories): compose sources in the full chat; steady the Stop test

No mounted message carried a sources part, so the Sources family
never rendered in the whole-chat story. Start the recent stretch with
the short-chat sample, whose answer cites two sources, and check them
in the browser spec.

The Stop test had to hit a call that runs for one 120 ms replay frame,
clicking from inside a raf-polled waitForFunction. Under CPU load it
missed the frame, or clicked as the settled frame landed. Hold the
page clock once the story has loaded and step the replay a frame at a
time, so the running frame stays put while Stop is pressed.

Also name the GeneratedImages onOpen index for what it is, and derive
an approved call's output from its name instead of a fixed "Done".

* refactor(stories): stop a closed thread's answer without a render ref

The full-chat ThreadTab kept its stop function in a ref written during
render, so unmounting could call the latest one. Make the function
stable with useCallback instead and return it from the effect.

* test(stories): prove stop and thread replies in the full chat e2e

The Stop test resumed the page clock right after Stop. That lets the
clock run in real time, so a replay frame that Stop failed to cancel
could land after the asserts and the test still passed. Move the
paused clock on by a full run of frames first, so any leaked frame
lands before the asserts.

The thread reply test checked for "4 replies" as soon as the reply was
sent. The count reaches 4 with the answer's first, empty frame, so it
passed even if the answer never streamed. Wait for the answer's text
first, which also shows the final frame replaces the streaming one.

* fix(stories): keep the full chat's file notice in the accessibility tree

The composer's refusal line is a status region, but empty:hidden took
it out of the accessibility tree while it had no text. A refused file
then brought the region and its text in the same update, which screen
readers generally do not announce. The line now stays mounted with its
reserved height, as in the Attachments story, so the refusal is read
out as a change to it.

* fix(stories): date the full chat's recent messages before now

The recent samples were spaced 30 seconds apart after the long history,
whose last message sits 7 minutes before the story's now. Seventeen
steps ran past now, so the last answers read "in 30s" and "in 2m", and
a message sent from the box read as older than the one above it. The
step now divides the gap between the history and now, so every message
sits before now, in order.

* fix(stories): key the full chat's sent files by position

Sent file tiles were keyed by name, but two files of one name and
different sizes are both accepted, so a message could hold two tiles
with the same key. They are now keyed by position and name.
* feat(chat)!: remove the old chat components

There is now one way to build chat UI: the Conversation family. Remove
ChatThread, ChatComposer, ChatInput, ChatPanel, MessageBubble,
ApprovalCard, ToolBlock, ToolNarrative, ReasoningBlock and the modules
built only on them, with their stories, tests and browser specs. The
part types move into the conversation module unchanged, so their names
stay stable. ToolProviderMark takes a tool name instead of a part.

README gains "Upgrading from 0.3", which maps every removed export to
what replaced it.

* docs: note 0.4 removals in the 0.1 upgrade notes

The 0.1 upgrade notes still named ui/chat-dock-timing and
ReasoningBlock, both deleted in this release. Keep those lines as
history, but say they are removed in 0.4 and point ReasoningBlock
readers at Reasoning.

* ci: check one component per chat module in the pack smoke

The CI consumer smoke only checked Button, Conversation and Badge, so
dropping an export * line for most of the chat family from
src/index.ts would still pass. Check one component per chat module
(Message, Activity, Reasoning, Tool, ToolApproval, ToolGroup,
PromptInput, Panel, Attachments, MediaViewer, ConversationThread,
ConversationMap) and report every missing name at once.

* feat!: remove useDelayedAutofocus and ToolProviderMark

Nothing in the library, its stories or its known consumers uses
either export, and this release already breaks the chat surface. Drop
them rather than carry untested API: ProviderMark stays, and a host
reads the provider off a tool id itself. The 0.3 upgrade table lists
both with what to use instead.

* ci: check every chat module in the pack smoke

The consumer smoke claimed one component per chat module but named 13
of the 24 chat UI modules and none of the chat hooks or helpers, so
dropping the export * line for GeneratedImages, Sources, MessageActions
and others from src/index.ts still passed. It now names one runtime
export from each module the chat family puts in the barrel; the
types-only lib/conversation.js has none to name. It checks presence
rather than typeof function, since a forwardRef component is an
object.

* docs: drop ToolProviderMarkProps from the 0.3 upgrade table

ToolProviderMark took an inline props type; no ToolProviderMarkProps
was ever exported, so the removed-exports table no longer lists one.
)

* feat(chat): add MessageParts to render a message's parts

Every host mapped ConversationPart to components by hand and
re-implemented the focus handoff when a decided call folds into the run
before it. MessageParts groups parts with groupActivity and renders each
kind as its component (Activity, ToolApproval, ToolGroup,
MessageContent, Attachments tiles, GeneratedImages, Sources,
ConversationNotice), with a render prop per kind to replace any of them.
After a decision folds a call away, focus moves to the run's trigger, or
to the next waiting call in a batch, instead of falling to the page.

The full chat and activity stories and the e2e fixtures use it in place
of their own mappings; the e2e specs check that focus never sits on
<body> for a frame after a keyboard decision.

* fix(chat): keep focus when a waiting call lapses or is dropped

MessageParts moved focus only after a decision made through its own
decide. When the server resolved a call to timeout or expired while
focus was on its buttons, the call folded into its run, the buttons
unmounted, and focus fell to the page. The same happened when a host
recorded a decision by removing the call from parts.

Track which waiting call's card holds focus from the root's focus and
blur events, and give a call that stops waiting under focus the same
handoff as a decided one: the next waiting call in a batch, else the
run's trigger. For a call that is gone from parts, fall back to the
group it stood in, then the group before it, then the first control in
the message.

Document the markup custom renderBatch and renderActivity must keep
for the handoff, and that renderText takes precedence over
renderMarkdown. Point the 0.3 upgrade rows at MessageParts, and name
the image adapter's argument index.

* ci: check MessageParts in the pack smoke

The consumer smoke checks one export per chat module so a dropped
export * line fails CI. MessageParts arrived after that list was
written; add it.

* fix(chat): keep focus on the right call when parts are dropped

Dropping a waiting call from the middle of a batch moved focus to the
first waiting card, not the next one. The handoff now skips every call
up to and including the one that left, so focus lands on the call that
waited after it.

Removing a part before a waiting call shifted the index keys of every
group after it, so React remounted the waiting card and focus fell to
the page. A tool trace or batch is now keyed by its call id, and the
handoff finds a group by that key.

As in ToolGroup, the handoff focuses the next call's card rather than
its Approve, so a held Enter cannot decide a call nobody chose; a host
card without the card slot gets its first button. The batch e2e specs
expect the card.

* fix(chat): end each focus watch in MessageParts with its own call

MessageParts watches two calls whose buttons may go away under focus:
the one last decided and the waiting one whose card holds focus. It
merged them into one, preferring the decided call, and cleared both
once that one stopped waiting. In a batch, a decision moves focus to
the next card, which starts the watch on that call, and recording the
decision then ended it too: when the next call timed out or expired,
focus fell to the page. A decision the host never records kept the
merged watch stuck on that call, so a different call lapsing under
focus later also dropped focus to the page.

Each watch now ends only when its own call stops waiting. When both end
in one update, the handoff starts from the focused call, since that is
where focus was.

* fix(chat): hand focus to the run when a batch header times out

The leaving-focus watch armed only inside a pending approval card. A
tool-group header is a button outside that card, so focusing it left
the watch unset, and a timeout does not go through decide. When the
last waiting call folded the batch, ToolGroup unmounted and focus fell
to the page.

Focus inside a waiting batch, on its header or a row, is watched on
its own. When that batch is gone and focus would sit on the page, it
moves to the folded run's trigger, the same handoff a card lapse
already uses. A decision still keeps its own watch until parts shows it.

---------

Co-authored-by: Sawyer Cutler <sawyer@dirtroad.dev>
Breaking release for the new Conversation, Message, PromptInput, Panel,
attachments, media, activity, and MessageParts families. Removes the old
chat components and documents their replacements.
An index that falls off a shortened list now commits the close, so growing the list back does not reopen the viewer.

Co-authored-by: Pratik <pratikbuilds@users.noreply.github.com>
@cursor
cursor Bot deleted the cursor/media-viewer-close-8c4d branch October 30, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-ups from the conversation PR review

4 participants