feat(ai-chat): add AIChat component migrating the UI Catalogue AI chat pattern - #497
Open
interacsean wants to merge 13 commits into
Open
feat(ai-chat): add AIChat component migrating the UI Catalogue AI chat pattern#497interacsean wants to merge 13 commits into
interacsean wants to merge 13 commits into
Conversation
Migrates the UI Catalogue "AI chat" pattern into AppShell as a component,
per the disposition on platform-planning#1748 ("As a component, not a
page or pattern").
Adds `AIChat`: a standalone root that owns the frame (scroll area +
composer) and takes the transcript as `children`, so callers compose each
turn from attached parts against their own `useAIChat()` state —
`AIChat.Message`, `.Response`, `.EmptyState`, `.Suggestions`/`.Suggestion`,
`.Actions`/`.Action`, `.Reasoning`/`.ReasoningTrigger`/`.ReasoningContent`,
`.ChainOfThought*`, `.Tool*`, `.Sources*`, and `.History`. The composer's
body is `Textarea` following the `form/composer` pattern's action-row
shape (no Discard — that pattern's own carve-out for a chat composer);
`status` from `useAIChat()` plugs directly into the composer's busy/Stop
state. Streaming is real end to end: `useAIChat`/`createAIGatewayClient`
already stream token-by-token over SSE, `AIChat` adds no client-side
typewriter effect.
Reasoning, ChainOfThought, Tool, and Sources each hand-roll the same
trigger/chevron/panel collapsible shape in the catalogue source; here
they share one internal wrapper (disclosure.tsx) over Base UI's
Collapsible, the same primitive `SidebarGroup` already wraps.
Attachments: the composer stages files with its own light buffer sharing
`AttachmentItem`'s field vocabulary, rather than reusing
`Attachment`/`useAttachment` — that hook's initial-items/buffered-ops/
`applyChanges` model is built for a persisted record's file list, a
different lifecycle from a composer's ephemeral attach-then-clear-on-send.
Deviations from the catalogue source (for team review, not because the
catalogue was wrong for its own context):
- Message + MessageContent collapsed into one `AIChat.Message`.
- Response only renders http(s)/mailto/relative links as clickable;
other schemes (e.g. `javascript:`) render as plain text, since the
text passing through this renderer is untrusted model output.
- Composer body is AppShell's bordered `Textarea` (per form/composer),
not the catalogue's borderless auto-growing textarea inside its own
bordered card — avoids a double border since `AIChat` itself renders
no chrome.
- Submit is a labelled Send/Sending…/Stop button, not an icon-only round
one — matches form/composer's "no spinner-only submit" rule and drops
the need to port a separate Loader component (reuses the
`Loader2 + animate-spin` idiom already used by CommandPalette,
CsvImporter, DataTable, and ActionPanel).
- `Suggestion.onSelect(suggestion)` instead of `onClick` — the catalogue
name shadows both the DOM event and, on this component, a native
`onSelect` handler already inherited from `ComponentProps<typeof Button>`.
- No assembled panel bound to `useAIChat` — the catalogue's own
`AssistantPanel` is filed under Pages, not this component.
Open question for the team: `useAIChat`'s Gemini route
(`streamJSONResponse`) waits for the full completion and yields it as one
`text-delta`, so `AIChat.Reasoning`/`.Tool` streaming states won't animate
incrementally against a Gemini model today — worth deciding whether that
matters before AIChat sees production traffic against non-OpenAI-compatible
models.
Refs tailor-inc/platform-planning#1748
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes from review of the AIChat migration.
Correctness:
- `Response` rejected `javascript:` links but still accepted
protocol-relative `//host` ones, which are off-site links wearing a
same-site shape. The whole point of that check is untrusted model
output, so the gap mattered. Covered by a test.
- Fixed `docs/components/ai-chat.md` links to `useAIChat` and
`createAIGatewayClient` — both live under `docs/api/`, not
`docs/components/`, so all three links 404'd.
- Dropped a stale reference to an `AIChat` header `actions` slot in
`ChatHistory`'s docblock; the root has no header.
Consistency:
- Finished the i18n pass. Composer strings already went through `useT`,
but the reasoning/tool/sources/chain-of-thought labels were hardcoded
English with no override, so a Japanese app got 送信 on the button and
"Thinking…" above it. All of them now resolve through `i18n-labels`
(en + ja).
- `Source` now `Omit`s the native `title` attribute it repurposes, matching
what `ToolHeader` already did.
Tidying:
- Extracted `DisclosureChevron`. The marker class is an internal contract
between the trigger's selector and the icon, and was being retyped in
four files. It has to stay a literal in the selector — Tailwind only
compiles utilities it can read statically — so the constant documents
that rather than interpolating.
- `Reasoning` mirrored a prop into state through `useEffect`, the exact
anti-pattern in `.agents/references/react-use-effect.md`. Now derived:
`manualOpen ?? (isStreaming || defaultOpen)`. Drops the effect and the
ref, and fixes `defaultOpen`, which the effect used to clobber on mount.
- Removed the composer's `rows` prop: nothing passed it, and its default
sat under `Textarea`'s own `min-h-16` floor, so it could never apply.
- Moved the remaining deviation-from-catalogue rationale out of code and
docs and into the PR/commit record, where this kind of history belongs.
Tests:
- Added `ai-chat.test-d.ts` pinning the four props whose names collide
with inherited DOM handlers (`onSubmit`, two `onSelect`s, `title`).
Verified it fails when the `Omit` is removed.
- Covered `disabled`, the `submitted` status, and Backspace-removes-newest
-attachment. Rewrote the busy-state tests to seed the draft via
`defaultValue` instead of typing it, removing a timing-sensitive
`user.type` from an assertion that is about Enter, not typing.
Demo: wired the Retry action to resend the last user turn, and gave the
scripted client's abort listener `{ once: true }`.
Refs tailor-inc/platform-planning#1748
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two visual details from the UI Catalogue's assistant panel that should have come across in the first port. The header strip: a 48px row above the transcript carrying a leading graphic (a sparkle by default), a `text-sm font-semibold` title, and an open action slot on the right, closed by a rule that runs the full width of the surface — bisecting the rounded card it sits in. Exposed as `title`, `icon`, and `actions`; the strip renders only when `title` or `actions` is set, so a bare transcript-and-composer surface is still available. `icon` takes the leading position, which is where a docked right panel wants its collapse control, so that case needs no prop of its own. `title` had to join `onSubmit` in the root's `Omit` — a `<div>` has a native `title` attribute, so without it the two signatures intersect and the prop stops accepting a `ReactNode`. This is the fourth prop in this component to hit that, and the type test now pins it. The composer had a `border-t` the catalogue does not have; its own form is `m-3 mt-0` with no rule, so the composer and transcript read as one surface. Removed, and the padding moved to `p-3 pt-0` to keep the spacing. Demo now uses AIChat's own header instead of `Card.Header`, with `overflow-hidden` on the card so the header rule stays inside the rounded corners, and its scene-setting copy moved above the card. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he recipe The demo pinned the card to a fixed `h-[640px]`, so it left dead space on a tall viewport and overflowed a short one. Inside `<Layout fill>` the column is `flex flex-col` with `min-h-0`, so `flex-1 min-h-0` on the card makes it take the leftover height instead. No component change was needed — `AIChat` is already `h-full`. Verified at 1280x800 (card 615px, page does not scroll) and 1280x560 (card shrinks to 375px, transcript scrolls internally at 388/205 while the header and composer stay pinned). Added a "Filling the page" section to the docs, since the `min-h-0` half of this is the part that is easy to miss: without it the card cannot shrink below its content and the composer gets pushed off-screen. Also dropped the demo paragraph's `mb-4` — `Layout.Column` already has `gap-4`. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Suggestion` is `whitespace-normal` so a long starter prompt wraps inside the pill, but `Button`'s base classes set `shrink-0`, so the chip never shrank and a long prompt overflowed the transcript horizontally instead. Caught in the example app at a ~290px panel: a 253px chip inside a 156px container, with the scroll container 326px wide against a 244px viewport. Capped with `max-w-full` rather than a competing `shrink` utility: `cn()` calls `twMerge` without the `astw:` prefix configured, so it cannot dedupe `astw:shrink` against `astw:shrink-0` and the winner would come down to stylesheet order. `max-w-full` collides with nothing in `Button`'s base, so there is no race. Verified at ~290px (chip caps to the container and wraps to two lines, no horizontal overflow) and at 1100px (chips single-line, composer action row still one row with no overlap). Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Code Metrics Report
Details | | main (b624df2) | #497 (56fb354) | +/- |
|---------------------|----------------|----------------|-------|
+ | Coverage | 87.1% | 87.5% | +0.4% |
| Files | 178 | 195 | +17 |
| Lines | 5470 | 5720 | +250 |
+ | Covered | 4767 | 5008 | +241 |
- | Test Execution Time | 1m45s | 2m18s | +33s |Code coverage of files in pull request scope (100.0% → 96.4%, patch 96.3%)Reported by octocov |
CodeQL flagged js/polynomial-redos on both patterns in `Response`. The renderer parses model output, so the input is genuinely uncontrolled. The inline pattern was the real one. `[^*]+` / `` [^`]+ `` / `[^\]]+` / `[^)]+` each scan to the end of the string and fail, and the engine retries from every start position — quadratic. Measured on unclosed `[`: n= 5000 15.2ms -> 5.6ms n=20000 222.4ms -> 24.7ms n=40000 866.2ms -> 48.8ms 4x the input took ~57x the time before, and 2x after. Bounding each span (500 chars, 2000 for an href) caps the work per start position. A longer span now renders as literal text, which is the right trade for content no real markdown produces. The list-marker alert was conservative: `^\s*([-*]|\d+\.)\s+` is anchored, so it only ever had one start position, and both old and new run in ~0.05ms at n=40000. Bounded it anyway and switched `\s` to `[ \t]` so a marker can no longer match across a newline, and pulled the marker into a shared constant — `isListLine` and the strip-marker `replace` were duplicating the same literal, so they could drift apart. Regression tests cover 20k-char runs of `[`, `*`, `` ` `` and leading spaces; a quadratic path would blow the test timeout. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The metrics bot put `conversation.tsx` at 57% with no test file of its own, which was a fair catch — it holds the subtlest logic in the component and I had left it entirely untested. Adds `conversation.test.tsx` covering the pin/unpin rules, including the one the logic exists for: while a response streams the bottom moves away from a reader who has not moved, and treating that as "scrolled away" would pop the scroll button up mid-answer. happy-dom does no layout, so the scroll metrics are driven by hand. Confirmed the tests fail when the `scrolledUp` guard is removed, rather than assuming they would. Also covers the interactions the report showed unexercised: removing a staged attachment from its chip, the image-thumbnail branch, opening the file picker from the attach button, `onDelete` on a history row, the `Actions` row wrapper, `Reasoning`'s context guard, and the markdown heading branch. ai-chat coverage 81.6% -> 92.2% statements, 83.5% -> 93.6% lines; `conversation.tsx` 53.7% -> 88.9%. What remains is styling-variant branches and the ResizeObserver callback body, which needs real layout to fire. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit fixed the two patterns CodeQL had flagged; re-analysis
then flagged a third of the same shape, which the first report had not
reached. CodeQL surfaces these incrementally, so this sweeps the whole file
rather than waiting to be told about the next one.
- Heading `^(#{1,3})\s+(.*)$`: `\s+` and `(.*)` both match a space, so a run
of them can be split either way and the engine can backtrack across it.
Now `^(#{1,3})[ \t]+(\S.*)$` — requiring the text to start with a non-space
forces `[ \t]+` to take the whole run, leaving one way to match. A heading
marker followed only by whitespace is no longer a heading, which is the
better reading anyway.
- Link destructure `^\[([^\]]+)\]\(([^)]+)\)$`: not flagged, because it only
ever sees a token the bounded inline pattern already matched. Bounded to
the same caps regardless — leaving one unbounded twin next to a fixed one
is what invites the next report.
Checked every regex the renderer applies to model output at 10k/20k/40k
chars: all now grow linearly or better (4x input, at most 4.3x time).
Refs tailor-inc/platform-planning#1748
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ough The page linked "Live preview in the UI Catalogue →", copying the phrasing every other component doc uses. For those it is accurate — they link to /components/<name>, a preview of that exact component. AIChat links to /patterns/ai-chat, which is a design reference built on its own primitives with a different API. Same wording, different kind of artefact, so it read as "here is this component running" when it is not. Replaced with a callout that says what the page is and what to use each source for. Adds a "Prop pass-through" section. Most props stay inside AIChat, but four land on another AppShell component and so carry its contract rather than one AIChat defines: `value`, `placeholder` and `disabled` reach `Textarea` (`disabled` also reaches the attach `Button`), and `onStop` becomes a `Button` onClick. Also records that three attached parts — `Suggestion`, `Action`, `ChainOfThoughtSearchResult` — are wrappers that forward their whole surface to `Button` or `Badge`, which means they accept variants AIChat was never designed around, and that a `className` passed to them cannot reliably override a base utility because `cn()` is not configured for the `astw:` prefix. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le surface `Suggestion`, `Action` and `ChainOfThoughtSearchResult` inherited `ComponentProps<typeof Button>` / `typeof Badge` wholesale, so every prop of the wrapped component became AIChat's public contract by accident — including variants the part was never designed around (`<AIChat.Suggestion variant="destructive" />` type-checked and rendered). Each now Picks what its job needs and fixes the rest: - `Suggestion`: `suggestion`, `onSelect`, `className`, `disabled`, `children`; `variant="secondary"` / `size="sm"` are now internal. - `Action`: `label`, `onClick`, `className`, `disabled`, `children`; `variant="ghost"` / `size="icon"` are now internal. - `ChainOfThoughtSearchResult`: `className`, `children`, `variant` — `variant` stays open, since conveying status is a Badge's whole purpose. Type tests pin each key set, so widening the surface again has to be deliberate rather than a side effect of a `ComponentProps` edit. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ser regions
Mirrors `Layout`: the root inspects its children by type, renders
`AIChat.Header` → `AIChat.Conversation` → `AIChat.Composer` in that order
regardless of source order, warns on anything else, and provides the chat's
`status` through a context that `Composer` reads.
The flat root had 19 props from three unrelated concerns side by side —
`title` next to `accept`, `actions` next to `submitOnEnter`. Each region now
owns its own: Header takes `title`/`icon`/`actions`; Conversation takes
`autoScroll` and the transcript as children; Composer takes `onSubmit`,
`onStop`, the draft (`value`/`defaultValue`/`onValueChange`), `placeholder`,
`disabled`, `submitOnEnter`, `attachments`/`accept`/`multiple`, and `actions`.
`composerActions` is gone — it is just `actions` on the region it belongs to.
The root keeps `status` and `className`.
Composer now owns the draft state it previously received from the root, and
Conversation renders its own scroll-to-latest button, so each region is
self-contained behind its props.
Two things fall out of this. Composer is optional, so a read-only transcript
is `<AIChat><AIChat.Conversation>…</AIChat.Conversation></AIChat>` — not
possible before, when `onSubmit` was required on the root. And the root now
carries `flex-1` alongside `h-full`, so it fills a flex-column parent (a Card,
a Layout.Column) without the consumer passing sizing classes.
Unknown children warn and are dropped rather than rendering somewhere
unexpected; a transcript passed as a loose child is the case that matters,
and it is now loud. Duplicate regions warn and keep the first. A missing
Conversation warns.
Tests: the root suite is rewritten around the regions and adds placement
coverage — source-order independence, read-only rendering, the unknown /
duplicate / missing warnings, and Composer-outside-AIChat. The type tests pin
region ownership so a prop cannot drift back onto the root unnoticed. One
assertion I first wrote was wrong: `not.toHaveProperty("onSubmit")` on the
root fails because a div legitimately has the DOM `onSubmit`; the test now
checks the unambiguous names and pins where the AIChat versions live instead.
Exports `AIChatHeaderProps`, `AIChatConversationProps`, `AIChatComposerProps`
alongside `AIChatProps`.
Refs tailor-inc/platform-planning#1748
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Author
|
/review |
Contributor
|
❌ Code Review failed. Please review the logs for details. |
The wrapper-parts table still described the pre-`3fe57a6` behaviour: that `Suggestion`, `Action` and `ChainOfThoughtSearchResult` "forward their whole prop surface", and that `<AIChat.Suggestion variant="destructive" />` "is legal and will render". Since the narrowing that example is a type error, so the docs were actively instructing something that no longer compiles. An earlier attempt to update this block did not apply — the replacement was written against the pre-`oxfmt` column widths and silently failed to match, and I did not re-read the file to confirm. Replaced now and verified each documented prop against the `Pick<>` in source. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er render Three findings from reviewing this branch. Attachment object URLs leaked. `handleSubmit` snapshotted the staged files, cleared state, and handed the snapshot — with live `blob:` URLs — to `onSubmit`; the unmount cleanup then iterated an array that was empty by then, so every image ever sent kept its blob resident for the life of the page. The composer now revokes what it created, which makes the default safe and gives `previewUrl` an honest contract: valid while staged, and a caller who wants a sent image in the transcript makes its own URL from `attachment.file`. That is better than transferring the obligation, since the caller already holds the `File` and nothing has to be remembered. Region warnings fired during render, so the root re-rendering per streamed token repeated the same line hundreds of times in one response (twice more per render under StrictMode). They now emit from an effect, once per distinct message per mounted root. Deliberately *not* gated on `process.env.NODE_ENV`, which my own review suggested: this package has no other reference to `process`, and the library build has no `define` to replace it, so adding one risks `process is not defined` for consumers whose bundler does not shim it. Emitting once solves the actual problem without that dependency. Regions are matched by component identity, so wrapping one in your own component silently drops it. Inherent to the pattern, so the fix is diagnosis: the warning now names the direct-child requirement, and the docs say so and show passing props instead of the element. Region-splitting moved to a module-level `splitRegions`, which also clears the shadowing the inline version introduced. Both behaviours are covered, and both new tests were confirmed to fail against the previous code. Revocation additionally verified in the example app: the chip's blob URL is revoked on send and the caller still receives the `File`. Refs tailor-inc/platform-planning#1748 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
interacsean
marked this pull request as ready for review
September 4, 2026 04:50
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.
Migrates the UI Catalogue AI chat pattern into AppShell as a component, per the disposition on the issue ("As a component, not a page or pattern").
Refs tailor-inc/platform-planning#1748
What this adds
AIChat— a root that places three regions in a fixed order, the wayLayoutplacesLayout.HeaderandLayout.Column:AIChat.Header(optional) overAIChat.ConversationoverAIChat.Composer(optional). Each region carries its own props; the root carries the chat'sstatusand provides it via context. Callers compose the transcript insideAIChat.Conversationfrom attached parts against their ownuseAIChat()state.Regions can appear in any source order — the root renders them in the fixed order and warns in dev on anything that isn't one of the three. Omit
AIChat.Composerfor a read-only transcript.All 12 catalogue blocks are here:
AIChat.Message,.Response,.EmptyState,.Suggestions/.Suggestion,.Actions/.Action,.Reasoning*,.ChainOfThought*,.Tool*,.Sources*,.History, plus the composer and attachments.message.rolefromuseAIChatlines up directly withAIChat.Message'sfrom— no mapping step.Streaming is already real
Worth stating because the catalogue's demo is misleading on this point.
createAIGatewayClientcallschat.completions.create({ stream: true })and yields atext-deltaper SSE chunk;useAIChatappends each one as it arrives. That is genuine token-by-token streaming and it already existed —AIChatadds no client-side typewriter effect. The catalogue's word-by-wordsleep()is demo scaffolding, since nothing is behindui.tailor.tech.Open question for the team: the Gemini route (
streamJSONResponse) waits for the full completion and yields it as onetext-delta, so the streaming states onReasoning/Toolwon't animate incrementally against a Gemini model. Worth deciding whether that matters before this sees production traffic on non-OpenAI-compatible models.Deliberate divergences from the catalogue
Recorded here rather than in code comments, since
AIChatis now canonical and the history isn't useful to a consumer.Message+MessageContentcollapsed into oneAIChat.Message, branching onfrom. Removes thegroup-[.is-user]arbitrary-variant styling;data-fromis the external styling hook.Responseonly linkifieshttp(s):,mailto:and same-site relative targets. Everything else —javascript:, protocol-relative//host— renders as plain text. The text passing through this renderer is untrusted model output.Textareafollowing theform/composerlayout, not the catalogue's borderless auto-growing box inside its own bordered card. Avoids a double border, and honours feat(textarea): add standalone Textarea and the form/composer pattern #479's decision againstfield-sizing-content.form/composer's "no spinner-only submit" rule, and removes the need to port a separateLoader(theLoader2 + animate-spinidiom is already used by CommandPalette, CsvImporter, DataTable and ActionPanel).Suggestion.onSelectinstead ofonClick— the catalogue name shadows both the DOM event and a nativeonSelectinherited fromComponentProps<typeof Button>.useAIChat— the catalogue files its ownAssistantPanelunder Pages.AssistantPanelwires everything through one component.AIChatstarted that way too (19 props on the root) and was split intoHeader/Conversation/Composerregions during review, mirroringLayout, so header props sit on the header and composer props on the composer.Suggestion,ActionandChainOfThoughtSearchResultalso went from inheriting the wholeButton/Badgesurface toPicking what each part needs.Reasoning,ChainOfThought,ToolandSourceseach hand-rolled the same context +useState+ trigger/chevron collapsible. They now share one internal wrapper over Base UI'sCollapsible, the same primitiveSidebarGroupalready wraps, so open state and ARIA come from Base UI.AttachmentItem's field vocabulary.Attachment/useAttachmentis built for a persisted record's file list (initial items, buffered ops flushed viaapplyChanges) — a different lifecycle from attach-then-clear-on-send. Flagging as a design question: worth deciding whether these two should converge.A package-wide issue this surfaced
cn()callstwMergewith noastw:prefix configured, so tailwind-merge cannot dedupe prefixed utilities. Any component overriding a base utility fromcva/buttonVariantsis relying on stylesheet order, not merge resolution. It bit the suggestion chips here (whitespace-normalcouldn't wrap becauseButton's baseshrink-0won), worked around withmax-w-full, which collides with nothing. Not this PR's to fix, but a reviewer should know it's there.Still open, deliberately
variant="ghost". The catalogue put it in the primary slot. Ghost reads quiet for the only action available mid-stream;outlinemay be the better middle.Verification
type-check,lint, 94 test files / 1709 tests,build,check-dts(all three published entry points), plus the example app's own type-check and lint.ai-chat.test-d.tspins the five props whose names collide with inherited DOM handlers (onSubmit, twoonSelects, twotitles) — confirmed to fail when anOmitis removed, rather than assumed to.AIGatewayClientinterface, souseAIChatandAIChatrun their production paths: streaming, Stop mid-stream, Enter-to-submit gating while busy, attachments, message actions, and the transcript scrolling internally at 1280x560 while the header and composer stay pinned.🤖 Generated with Claude Code