Repository navigation
fix(media): close the viewer when its item leaves the list - #18
Closed
pratikbuilds wants to merge 130 commits into
Closed
pratikbuilds wants to merge 130 commits into
pratikbuilds wants to merge 130 commits into
Conversation
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.
Add press feedback to buttons
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
…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.
…act-ui-032 chore(release): 0.3.2
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When the open item falls off a shortened list,
useMediaViewercommits the close. Uncontrolled state clears, and a controlled caller gets oneonIndexChange(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 casesCloses corbitsdev#142