storytellurx/reports/chat-composition.md

8.6 KiB
Raw Permalink Blame History

Chat composition: reference comparison and implementation

The implementation follows the user's latest REUI ai-chat-9 reference: headless MessageScroller with bottom following and no turn anchors. Persona fonts, tokens, bubbles, metrics, decorations and receipts remain StyleX-owned; no Tailwind or additional AI runtime is introduced.

Reference inspected Relevant composition Decision
shadcn MessageScroller and TanStack helper Default Provider, user-turn anchors, constrained transcript viewport, sibling composer; native spacer makes room for the answer Keep the headless parts and sibling footer; use the subsequently requested REUI ai-chat-9 scroll policy below.
AI Elements Conversation and Suggestions Separate conversation/input composition; Suggestions exposes ScrollArea props Reuse the horizontal ScrollArea + button composition with installed Base UI primitives and persona StyleX. Do not add a second conversation/transport stack.
REUI ai-chat-12 authenticated registry source RetrievalReceipt uses Base UI disclosure; AskThread enables auto-scroll and disables its row anchors Keep the receipt composition and adopt its bottom-following policy, also used by ai-chat-9.
Neon AgentChat, including its registry source MessageScroller plus separate ChatInput; anchors the latest user while enabling auto-scroll; typed to AI SDK messages Adopt its clear transcript/footer separation, retain native TanStack messages; use REUI ai-chat-9 for scroll policy.
TanStack React Chat UI and installed types Typed layout/message/part renderers and streaming context Keep existing createChatUI, ui.Message, ui.Part and structured-output rendering. Scroll and disclosure belong to their headless UI packages.

components/narrative-chat.tsx

- <MessageScroller.Provider>
+ <MessageScroller.Provider autoScroll>
    <MessageScroller.Root>
-     <MessageScroller.Viewport onScroll={customHeaderDirectionHandler}>
+     <MessageScroller.Viewport>
        <MessageScroller.Content aria-busy={chat.isLoading}>
          <Messages />
        </MessageScroller.Content>
      </MessageScroller.Viewport>
      <MessageScroller.Button direction="end" />
    </MessageScroller.Root>
  </MessageScroller.Provider>
  <persona.components.Composer />

  <MessageScroller.Item
    messageId={message.id}
-   scrollAnchor={message.role === "user"}
+   scrollAnchor={false}
  >

The provider follows the live edge with autoScroll; every row has scrollAnchor={false}, matching REUI ai-chat-9 and ai-chat-12. Manual wheel/touch/keyboard navigation releases following. The native jump control returns to the latest content. No user-turn spacer is requested. Rows use contentVisibility: visible so the primitive measures actual content. REUI ai-chat-9's fork-slot IDs and imperative ForkPin concern version switching; this app does not copy that unrelated branch runtime.

Viewport styles mirror the reference frame (min-height: 0, contained overflow, hidden while native pending-scroll positioning runs). Content is height: max-content; min-height: 100%; message rows do not shrink. The browser's independent overflow anchoring is disabled so the package owns turn positioning.

components/narrative-composer.tsx

<form>
  <ScrollArea.Root>
    <ScrollArea.Viewport role="region" aria-label={t("suggestionActions")}>
      <ScrollArea.Content>{/* actual persona suggestion buttons */}</ScrollArea.Content>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar orientation="horizontal">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
  </ScrollArea.Root>
  <div role="group" data-slot="input-group">
    <textarea readOnly={isLoading} aria-busy={isLoading} />
    <Button type="submit">{/* persona indicator */}</Button>
  </div>
</form>

The installed @shadcn/react package does not export InputGroup. Its registry counterpart is a styled composition, so this implementation uses the native textarea and Base UI button without importing Tailwind wrappers. ScrollArea handles overflow, keyboard focus and thumb measurement; the existing persona card effect observes its real viewport.

All persona composers now participate in normal footer flow. Custom composer ResizeObserver/inset variables, absolute/sticky positioning and compensating 120–160px transcript padding are removed. The header stays visible; its classical title is one truncating line with the complete accessible text and native title hint. It cannot change the viewport by wrapping from two lines to one as a response starts. No fixed-height empty title row is added.

The local MessageScroller patch and its marker-specific test are removed. Scroll behavior uses unmodified @shadcn/react@0.3.1; the unrelated Gemini wire-schema patch remains. A separate minimal TanStack core patch corrects successful structured-output event ordering; see below.

Offline acceptance

  • public-turn-anchor.spec.ts: growing prose, late suggestions, completion, manual reading and subsequent submission; multi-frame screen positions, not just normalized content coordinates.
  • public-status-scroll.spec.ts: static header geometry during wheel scrolling, normal and reduced motion.
  • public-suggestions-scroll.spec.ts: six long options, keyboard horizontal scrolling, offscreen focus/selection, input focus, contained overflow.
  • Existing streaming, receipt, lifecycle and saved-history regressions remain relevant.

Validation results are recorded after running the final implementation. No live model requests, deployment or push are part of this work.

Completion interruption corrected at the source

The initial scroll diagnosis was incomplete: a two-turn manual-reading test exposed a transient native error during completion. The MutationObserver saw streaming → interrupted → complete while aria-busy remained true. On Nelly the transcript shrank from 5917px to 3177px and restored, clamping the scroll position in between.

The latest published @tanstack/ai version was checked as 0.53.0. In its native combined path, RUN_FINISHED preceded structured-output.complete; the client correctly treated the still-unfinished part as an error. The bounded source+dist patch defers that existing successful terminal until after validated structured completion. It does not suppress real errors, modify tool waits, or introduce an app-level transport/event buffer.

- flushDeferredModelRunFinishedChunks() // before structured harvest
  harvestCombinedStructuredOutput()
+ flushDeferredModelRunFinishedChunks() // successful, noncancelled harvest only

Native regression tests observe every update, not just final data. Successful completion never enters error; invalid output still errors without a successful terminal; server cancellation never becomes completed data. Client-side cancellation guards remain because client stop() does not always finalize its structured part. Browser tests also observe every completion mutation and reject the transient interrupted state.