Skip to content

feat(ai-chat): add AIChat component migrating the UI Catalogue AI chat pattern - #497

Open
interacsean wants to merge 13 commits into
mainfrom
claude/issue-1748-implementation-05f7a4
Open

feat(ai-chat): add AIChat component migrating the UI Catalogue AI chat pattern#497
interacsean wants to merge 13 commits into
mainfrom
claude/issue-1748-implementation-05f7a4

Conversation

@interacsean

@interacsean interacsean commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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 way Layout places Layout.Header and Layout.Column: AIChat.Header (optional) over AIChat.Conversation over AIChat.Composer (optional). Each region carries its own props; the root carries the chat's status and provides it via context. Callers compose the transcript inside AIChat.Conversation from attached parts against their own useAIChat() state.

const { messages, status, sendMessage, stop } = useAIChat({ client, model: "gpt-5" });

<Card.Root className="astw:flex astw:h-full astw:flex-col astw:overflow-hidden">
  <AIChat status={status}>
    <AIChat.Header title="Assistant" />
    <AIChat.Conversation>
      {messages.map((message) => (
        <AIChat.Message key={message.id} from={message.role}>
          <AIChat.Response>{message.content}</AIChat.Response>
        </AIChat.Message>
      ))}
    </AIChat.Conversation>
    <AIChat.Composer onSubmit={sendMessage} onStop={stop} />
  </AIChat>
</Card.Root>

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.Composer for 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.role from useAIChat lines up directly with AIChat.Message's from — no mapping step.

Streaming is already real

Worth stating because the catalogue's demo is misleading on this point. createAIGatewayClient calls chat.completions.create({ stream: true }) and yields a text-delta per SSE chunk; useAIChat appends each one as it arrives. That is genuine token-by-token streaming and it already existed — AIChat adds no client-side typewriter effect. The catalogue's word-by-word sleep() is demo scaffolding, since nothing is behind ui.tailor.tech.

Open question for the team: the Gemini route (streamJSONResponse) waits for the full completion and yields it as one text-delta, so the streaming states on Reasoning/Tool won'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 AIChat is now canonical and the history isn't useful to a consumer.

  • Message + MessageContent collapsed into one AIChat.Message, branching on from. Removes the group-[.is-user] arbitrary-variant styling; data-from is the external styling hook.
  • Response only linkifies http(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.
  • Composer body is AppShell's bordered Textarea following the form/composer layout, 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 against field-sizing-content.
  • Labelled Send / Sending… / Stop button rather than an icon-only round one — matches form/composer's "no spinner-only submit" rule, and removes the need to port a separate Loader (the Loader2 + animate-spin idiom is already used by CommandPalette, CsvImporter, DataTable and ActionPanel).
  • Suggestion.onSelect instead of onClick — the catalogue name shadows both the DOM event and a native onSelect inherited from ComponentProps<typeof Button>.
  • No assembled panel bound to useAIChat — the catalogue files its own AssistantPanel under Pages.
  • Regions instead of one flat prop bag. The catalogue's AssistantPanel wires everything through one component. AIChat started that way too (19 props on the root) and was split into Header/Conversation/Composer regions during review, mirroring Layout, so header props sit on the header and composer props on the composer. Suggestion, Action and ChainOfThoughtSearchResult also went from inheriting the whole Button/Badge surface to Picking what each part needs.
  • One abstraction added. Reasoning, ChainOfThought, Tool and Sources each hand-rolled the same context + useState + trigger/chevron collapsible. They now share one internal wrapper over Base UI's Collapsible, the same primitive SidebarGroup already wraps, so open state and ARIA come from Base UI.
  • Attachments use a light buffer in the composer sharing AttachmentItem's field vocabulary. Attachment/useAttachment is built for a persisted record's file list (initial items, buffered ops flushed via applyChanges) — 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() calls twMerge with no astw: prefix configured, so tailwind-merge cannot dedupe prefixed utilities. Any component overriding a base utility from cva/buttonVariants is relying on stylesheet order, not merge resolution. It bit the suggestion chips here (whitespace-normal couldn't wrap because Button's base shrink-0 won), worked around with max-w-full, which collides with nothing. Not this PR's to fix, but a reviewer should know it's there.

Still open, deliberately

  • Stop is variant="ghost". The catalogue put it in the primary slot. Ghost reads quiet for the only action available mid-stream; outline may be the better middle.
  • Blob URLs are handed to the consumer on submit and never revoked — same as the catalogue. Revoking on submit would break rendering sent attachments in the transcript.

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.ts pins the five props whose names collide with inherited DOM handlers (onSubmit, two onSelects, two titles) — confirmed to fail when an Omit is removed, rather than assumed to.
  • Exercised in the Next.js example on a scripted client that satisfies the real AIGatewayClient interface, so useAIChat and AIChat run 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

interacsean and others added 5 commits September 3, 2026 15:15
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>
Comment thread packages/core/src/components/ai-chat/response.tsx Fixed
Comment thread packages/core/src/components/ai-chat/response.tsx Fixed
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Code Metrics Report

main (b624df2) #497 (56fb354) +/-
Coverage 87.1% 87.5% +0.4%
Test Execution Time 1m45s 2m18s +33s
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%)

Files Coverage +/- Patch Coverage Status
packages/core/src/components/ai-chat/actions.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/ai-chat-context.ts 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/ai-chat.tsx 93.3% +93.3% 93.3% added
packages/core/src/components/ai-chat/attachment-chip.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/chain-of-thought.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/chat-history.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/composer.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/conversation.tsx 93.6% +93.6% 93.6% added
packages/core/src/components/ai-chat/disclosure.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/header.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/index.ts 0.0% 0.0% - added
packages/core/src/components/ai-chat/message.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/reasoning.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/response.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/sources.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/suggestion.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/ai-chat/tool.tsx 100.0% +100.0% 100.0% added
packages/core/src/i18n-labels.ts 60.0% -40.0% 50.0% modified
packages/core/src/index.ts 0.0% 0.0% - modified

Reported by octocov

interacsean and others added 6 commits September 4, 2026 09:19
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>
@interacsean

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Code Review failed. Please review the logs for details.

interacsean and others added 2 commits September 4, 2026 13:53
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
interacsean marked this pull request as ready for review September 4, 2026 04:50
@interacsean
interacsean requested a review from a team as a code owner September 4, 2026 04:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants