storytellurx/reports/streaming-layout-review.md

142 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Streaming layout review and implementation plan
Status: original measurements retained below. The fixed-height implementation was rejected in UX review and removed. The replacement uses a compact, closed-by-default REUI-style receipt with natural-height expanded content. No push or deployment.
## Evidence and offline test boundary
- [Recorded response](../src/fixtures/recorded-neutral-gemini.json): one separately authorised, neutral `gemini-3.5-flash-lite` capture, 13 original text deltas, timestamps and exact prompt. It is a provider recording, not a hand-written answer. The capture was outside the test runner; no credentials, headers or private reasoning are stored.
- [DOM audit](../src/tests/public-layout-audit.spec.ts): 39 unique scenarios pass: seven personas × three widths; image, rewrite, rejection, gateway handoff and normal-motion cases × three widths; manual scroll-away × three widths. One additional replay verifies input focus. All tests use local fixtures; zero unexpected network requests.
- [Measurements](streaming-layout-audit.json): changed settled samples with node identity, positions, heights, scroll position, focus, busy state and errors. Full frame/settled samples are also retained locally in `.cache/streaming-layout-audit-full.json`.
- [Unit network guard](../src/tests/offline.setup.ts): fetch fails by default; provider tests must replace it with their fixture. All 303 existing unit tests passed with this guard; the additional recorded-response regression also passes.
- Browser audit uses an isolated server without provider keys, aborts unexpected external/API requests, and redirects chat to its local fixture server. Native TanStack owns JSON parsing, message assembly and SSE. The fixture server only paces recorded deltas.
```diff
// vite.config.ts — applied test safety change
- test: { include: ["src/**/*.test.ts"] },
+ test: {
+ include: ["src/**/*.test.ts"],
+ setupFiles: ["./src/tests/offline.setup.ts"],
+ },
```
```ts
// src/tests/offline.setup.ts — applied
beforeEach(() => {
vi.stubGlobal("fetch", () => {
throw new Error("Network disabled in tests. Replay a recorded provider fixture instead.");
});
});
```
Replay captures geometry after two animation frames and after 250ms at every provider barrier, then after 650ms at completion. This deliberately slowed replay isolates causality; it is not a reproduction of provider timing or a CLS benchmark. Font loading settles before measurement. Phone is 390×844, tablet 768×1024, desktop 1440×900. Wider shells render at the existing 0.95 scale; `contentY` is normalised back to transcript coordinates. Other positions are rendered CSS pixels.
Images, rewrites, handoff, invalid output and long scroll-away responses are labelled synthetic mutations of the neutral recording. Their altered JSON is re-chunked deterministically. They are not presented as actual provider answers. Default-motion coverage is Nelly only. French, pause-specific presentation, non-square images, six long suggestions, IME and browser zoom remain planned regressions below.
## Measured movements
Phone results for the exact recording. “Fragment displacement” measures insertion above the same paragraph within the transcript, independently of scroll compensation.
| Persona | Composer initial → submitted → complete | First fragment displacement |
| ---------- | --------------------------------------- | --------------------------- |
| Samantha69 | 164.66 → 80 → 164.66px | +128px |
| Nelly | 152 → 80 → 152px | +144px |
| Xixi | 132 → 80 → 100px | +136px |
| Hisoka | 158 → 68 → 110px | +160px |
| Cybelle | 118 → 80 → 86px | +128px |
| Galatée | 96 → 50 → 72px | +152px |
| Polaire | 117 → 51 → 85px | +144px |
| Stage | Observed delta | Cause and owner |
| ------------------------ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Submit | Suggestion row removed; flow composers expand viewport, overlay composers change reserved inset | `narrative-chat.tsx` derives suggestions only from the last assistant; `narrative-composer.tsx` conditionally mounts the row |
| First assistant chunk | Existing user bubble moves up 16px; Hisoka 32px, before any new prose | `persona-ui-defaults.tsx` clamps the previous explanation when `isLatestAssistant` changes |
| Chunk 3 | Classical header 108 → 80px on phone, 102.6 → 76px wider | `conversation-status.stylex.ts` gives the title natural height; the replacement title wraps onto fewer lines |
| Chunks 5–10 | Header can collapse 80 → 8px; later expand 8 → 112px | `narrative-chat.tsx` treats native follow-bottom scroll as reader input; status height changes trigger another resize/scroll cycle |
| Chunks 6–9 | Explanations insert 128–160px above the existing first fragment | `ResponseContent` renders explanations before fragments; `rationale.order = -2` reinforces that order |
| Third suggestion arrives | First suggestion moves left about 60px; some personas also change its scale | Shared first/last auto margins centre incomplete rows; persona emphasis changes which card is nearest the centre |
| Completion | Nelly’s first paragraph moves down 48px on screen; header also expands 104px | The typing paragraph and transcript gap disappear while the native scroller preserves the end position; automatic chrome changes compound this |
| Image completion | Nelly adds 220px above existing text when pending UI becomes the 252px image | Pending and final images occupy different nodes with different natural heights; `image.order = -1` |
| Rewrite arrival | Nelly’s first fragment gains 96.5px in the synthetic rewrite case | The previous user bubble is replaced with longer text and an intervention explanation is inserted beneath it |
| Invalid final result | Visible provisional turn disappears; Nelly scrollTop 128 → 0, composer 152 → 120px | Correct validation rollback currently removes geometry as well as content, then adds the error in the composer |
| Gateway handoff | Composer 49 → 80 → 152px; terminal status 89px becomes classical status | A streamed valid persona ID changes the shared persona registry, fonts, metrics and chrome. This is a semantic transition, not an accidental remount |
| Submission focus | `TEXTAREA` → `BODY`, still `BODY` after completion | The focused textarea is disabled while loading |
The first fragment retains the same DOM node throughout every exact-recording run. This rules out fragment remounting as the cause in these cases. The inspector trigger is absolutely positioned and does not add transcript height.
The scroll-away case isolates the reading defect: on phone, after a manual wheel event, native scrollTop stays at 131 while the first paragraph moves from y=434.5 to y=578.5 as explanations arrive: **144px of movement under a reader who has stopped following**. Tablet and desktop show the equivalent displacement at 0.95 scale. Native manual-scroll handling is working; replacing the scroller would not fix the insertion above the paragraph.
## Corrected implementation — natural content, explicit disclosure
The original plan traded movement for empty space and nested scrollers. That was the wrong UX. The metadata, rewrite, pending-image, failed-turn and suggestion reservations are removed. The original measurements above are historical diagnostics, not approval of the discarded solution.
### `components/turn-receipt.tsx`
Adapted from the authenticated **REUI `ai-chat-12` / `retrieval-trace.tsx` `RetrievalReceipt`** source. The block uses the shadcn Base UI variant: `Collapsible` + a composed button trigger + content panel. We reuse those installed Base UI primitives directly, with StyleX and persona tokens instead of the block's Tailwind wrapper classes. This is an adaptation of the receipt, not an installation of the entire demo or its scripted retrieval runtime. The license credential is not stored in source or browser code.
```tsx
<Collapsible.Root data-turn-receipt="">
<Collapsible.Trigger render={<Button />}>
{t("turnNotes")} {count} · {t("turnState")} {stateCount}
</Collapsible.Trigger>
<Collapsible.Panel>{children}</Collapsible.Panel>
</Collapsible.Root>
```
`count` is the number of nonempty public explanations; `stateCount` is the number of fields supplied by the native partial state patch. These are real available data counts, not simulated work or private model reasoning. Base UI owns keyboard interaction, open state, accessibility attributes and panel lifecycle. The receipt remains mounted through the native structured-output part's streaming/completion lifecycle.
### `components/narrative-chat.tsx` and `styles/narrative-chat.stylex.ts`
```diff
- rationaleSlot: { blockSize: 144, overflowY: "auto" }
- stateChangesSlot: { blockSize: 64, overflowY: "auto" }
- userRewriteSlot: { blockSize: 176, overflowY: "auto" }
- interruptedTurn: { minBlockSize: "var(--turn-block-size, 0px)" }
+ receiptTrigger: { fontSize: 12, lineHeight: "20px", paddingBlock: 4 }
+ receiptPanel: { display: "flex", flexDirection: "column", gap: 8 }
+ userRewriteSlot: { display: "flex", flexDirection: "column", gap: 4 }
```
The trigger stays one compact row as its counts change. Expanded state indicators and public explanations use existing persona components at their natural height. There is no additional per-note disclosure or nested scroll region. Images render after the prose at their natural aspect ratio when available, without a preallocated empty square. Rewrites and errors size naturally; invalid prose is still unpublished, with no retained blank turn height. The typing announcement remains accessible but has no flow height.
```tsx
<TurnReceipt count={explanationCount} stateCount={patchFieldCount} streaming={streaming}>
<turnPersona.components.StateChanges {...stateProps} />
<TurnExplanations data={turn} />
</TurnReceipt>
<ui.Message message={message}>{/* native typed parts */}</ui.Message>
```
Native TanStack `createChatUI`, `ui.Message`, `ui.Part`, structured partials, validation, persistence and transport remain authoritative. No second message model or fabricated reasoning stream is introduced. See [TanStack React Chat UI](https://tanstack.com/ai/latest/docs/ui/react) and [REUI registry setup](https://reui.io/docs/get-started).
### Composer and header
```diff
- invisible suggestion placeholders and fixed per-persona rail blockSize
+ only actual suggestion cards, naturally sized
- absolute error overlay within a reserved rail
+ error and native retry in normal flow
- textarea disabled while streaming
+ textarea readOnly while streaming, with aria-busy
```
Duplicate-send guards, IME handling and native retry remain. The subsequent [chat composition correction](chat-composition.md) replaces auto-follow with native user-turn anchoring, adopts a static single-line header and flow footer, and removes the local scroller patch. This supersedes the earlier scroll-marker workaround.
### Offline public regressions
`public-rationale-receipt.spec.ts` proves on phone/tablet/desktop:
```ts
expect(closedReceiptHeight).toBeLessThanOrEqual(44);
expect(Math.abs(proseYAfterNotes - proseYBeforeNotes)).toBeLessThanOrEqual(1);
await trigger.press("Enter");
await expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(expandedPanelHeight).toBeGreaterThan(viewportHeight);
expect(internalScrollers).toBe(0);
expect(cappedNodes).toBe(0);
await trigger.press("Space");
await expect(trigger).toHaveAttribute("aria-expanded", "false");
```
These tests pass using a neutral, deliberately reordered native stream (prose, state, then four notes including a 90-sentence note). Streaming/lifecycle regressions keep testing partial prose, metric updates, focus, retry and images. The broader DOM audit now measures natural geometry and checks compact closed receipts instead of demanding invariant composer height or preserving invalid content's empty space.
Intentional layout changes remain: expanding a receipt, receiving an image, rewriting text, wrapping real suggestion labels or changing persona can change content height. The acceptance criterion is a compact, understandable interface and stable closed metadata—not zero movement bought with large gaps. No claim of total visual parity or complete coverage of zoom and every persona transition is made.