Skip to content

Commit 4a33249

Browse files
CyberSecDefclaude
andcommitted
feat(M13 chunk 10b): ChatBubble + WebSocket events wiring
Closes chunk 10. ChatBubble (bottom-right) now renders the live token stream from the WebSocket `token.received` events, with PipelineState.generatedTokens as a fallback. Chunk-10 checkbox flips to [x]; all four persistent UI surfaces are now wired. ChatBubble component: - New props: tokens (string[]) + isFinal (boolean). - Empty → "Tokens stream in here as the model generates." - Populated → "Response" header + token count + concatenated text with whitespace preserved. - " · complete" appended to the count when isFinal=true. - Blinking cursor (1px-wide animated bar) trails the text while streaming; hidden when isFinal. - data-final attribute mirrors the prop for test assertions. CinematicViz wiring: - events prop now consumed (was inert since chunk-1 type slot). Default = []; pages pass through from useRunStream. - Filter events for token.received via discriminated-union type narrowing; map to tokenEvents. - chatTokens = events-derived when any present, else state.pipelineState.generatedTokens.map(t => t.string). - chatIsFinal = last event's is_final, OR (state fallback) Scene 20 flourish phase (sceneId === 'detokenize' && t >= 0.9). - ChatBubble mounts with the derived props. 11 new Vitest tests in ChatBubble.test.tsx: - Empty placeholder gated to no tokens. - Concatenated text renders. - Token count visible. - "complete" suffix gated to isFinal=true. - Blinking cursor gated to !isFinal. - data-final attribute mirrors prop. - Whitespace + newlines preserved in concatenated text. Full suite: 86 files / 867 tests pass; tsc --noEmit clean. phase1.md: chunk-10 checkbox [x], status row "chunks 1-10 done", chunk-10b decisions block (10 bullets) landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee956da commit 4a33249

4 files changed

Lines changed: 181 additions & 18 deletions

File tree

docs/phase1.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ This document breaks Phase 1 into 15 milestones (M1–M15). Each milestone lists
2727
| M10 | GIF Export | M8 | 6 days | ✅ Complete | |
2828
| M11 | Thread Sharing | M9 | 3 days | ✅ Done | |
2929
| M12 | Accessibility + Polish | M11 | 5 days | ✅ Done | |
30-
| M13 | Cinematic Inference Visualization | M12 | 12 days | 🟡 In progress (chunks 1–9 done) | Replaces the M8 live viz with a 20-scene narrative |
30+
| M13 | Cinematic Inference Visualization | M12 | 12 days | 🟡 In progress (chunks 1–10 done) | Replaces the M8 live viz with a 20-scene narrative |
3131
| M14 | Deployment | M13 | 5 days | ⚪ Not started | |
3232
| M15 | Launch Prep | M14 | 5 days | ⚪ Not started | |
3333
| | **Total** | | **~80 engineer-days** | | |
@@ -1027,7 +1027,7 @@ Bonus M12 work beyond the SPEC's two exit criteria:
10271027

10281028
- [x] **Chunk 9 — Scenes 18-20 (autoregressive loop + KV cache + detokenization).** **Scene 18 (variable)**: meta-loop. Full sequence (input + generated so far) becomes new input. Compress Scenes 5-17 into ~2s for token #2, ~1.5s for #3, accelerating to ~200ms/token (5/sec) by token #10+. The real run drives token timing via the WebSocket; if the run finishes before the viz catches up, viz keeps playing at its accelerated pace while the chat bubble already has the full text. **Scene 19 (2s, one-time reveal during first loop iteration)**: KV cache. Pause briefly; show K + V matrices from previous tokens appearing in a dimmed "cache drawer" with a small lock/disk icon. For the new token, only its row of K + V is fresh-computed + added. Attention matrix gains a single new bottom row per step instead of recomputing the whole grid. Subsequent loop iterations skip the explanatory beat; cache drawer just fills. **Scene 20 (continuous during Scene 18)**: detokenization — each chosen token ID briefly transforms back to its string fragment via reverse-lookup highlight in the vocab sidebar, then flies into the chat bubble + concatenates. End-of-sequence token: chat bubble glows, full pipeline canvas dims, completion flourish.
10291029

1030-
- [ ] **Chunk 10 — Persistent UI completion.** Wire the four sections to live data:
1030+
- [x] **Chunk 10 — Persistent UI completion.** Wire the four sections to live data:
10311031
- **Vocabulary sidebar (left)**: scrolling list of `(id, string)` populated during Scene 3's tokenization. Persists through all 20 scenes. Highlight + scroll-into-view on lookup events from Scene 3 (forward) + Scene 20 (reverse).
10321032
- **Chat bubble (bottom-right)**: mock chat-message UI. Starts empty; grows as Scene 17 appends each token's string. Driven directly by the real `token.received` WebSocket event so the user sees the response coming in even if the visualization hasn't reached Scene 17 yet for that token.
10331033
- **Layer counter HUD (top-right)**: integer counter + tower progress mini-bar. Visible during scenes 5-12. Auto-hides during scenes 0-4 + 13-20.
@@ -1066,7 +1066,19 @@ Bonus M12 work beyond the SPEC's two exit criteria:
10661066
- **Real run continues independently.** The M6 streaming pipeline + the M8 `useRunStream` hook keep doing exactly what they do. The chat bubble subscribes to the same WebSocket events and displays tokens as they arrive. Scenes 18-20 use the same event timeline to drive the autoregressive-loop pacing, but the viz lagging behind is expected + acceptable.
10671067
- **WebSocket → Scene coupling.** Scenes 5-17 fire once per *generated* token. The current `RunEvent` schema (M6) emits one `token.received` per token; the SceneRunner subscribes + advances. For the input tokens (the prompt), the runner enters Scenes 0-4 at submit time without waiting for the WebSocket.
10681068

1069-
**Decisions (chunk 10a):** (LayerCounterHud + VocabSidebar reverse-lookup — chunk 10 is mid-flight; checkbox stays unchecked until 10b lands ChatBubble + events wiring.)
1069+
**Decisions (chunk 10b):** (ChatBubble + WebSocket events wiring — completing chunk 10.)
1070+
- **ChatBubble accepts `tokens: string[]` + `isFinal: boolean`** — the simplest contract that matches both data sources. CinematicViz does the events-vs-state selection upstream; ChatBubble itself doesn't know which source produced the array.
1071+
- **Events-primary, state-fallback selection in CinematicViz.** When the `events` prop contains any `token.received` entries, the bubble reads from those. When empty (chunk-10 isolated testing, replay without a stream), it falls back to `state.pipelineState.generatedTokens.map(t => t.string)`. This delivers the spec's "ahead-of-viz" property automatically: live events stream from the real run independent of which scene the viz is on; chunk-10 tests + replays without a stream still show something.
1072+
- **`is_final` derived from the last token event's payload, or scene-based fallback.** When events stream, `is_final` is the `tokenEvents[N-1].payload.is_final` flag. When no events (state fallback), the bubble enters "complete" mode when Scene 20's flourish is active (`sceneId === 'detokenize' && t >= 0.9`). Mirrors the in-canvas "Inference complete" badge timing.
1073+
- **Blinking cursor only when `!isFinal`.** Subtle pulse on a 1px-wide bar at the end of the text. CSS `animate-pulse` (Tailwind's keyframes). Hidden once the run is final — matches the "stop talking" beat. Aria-hidden because the cursor is purely decorative.
1074+
- **Token count rendered alongside "Response" header.** "12 tokens" / "12 tokens · complete" — quick orientation for the viewer about how much output has streamed in. The chunk-9 in-canvas tray uses the same count format.
1075+
- **`whitespace-pre-wrap break-words` on the text.** Real tokens include leading spaces (`' the'`, `' a'`); preserving them gives natural word-boundary rendering. `break-words` keeps unusually long single tokens from horizontally overflowing the 288px bubble.
1076+
- **CinematicViz's events prop defaults to `[]`.** Existing pages (Threads/Show, Runs/Replay, Share/Replay) already pass an events array from the M6 streaming wiring; the default is for chunk-10 isolated testing only. The page mounts continue to work without changes.
1077+
- **Type narrowing via `Extract<RunEvent, { event: 'token.received' }>`.** TypeScript's discriminated-union narrowing inside the `filter` predicate gives us typed access to `e.payload.token` + `e.payload.is_final` without a cast. Documented in the inline comment.
1078+
- **No `useRunStream` import in `CinematicViz`.** The hook stays at the page level (Threads/Show passes its events through to CinematicViz). Keeps CinematicViz a pure render component — no live data subscription, easier to test in isolation. Chunk 11 will keep the same separation when adding playback controls.
1079+
- **Chunk 10 complete with all four surfaces wired.** PipelineProgressBar (chunk 1), VocabSidebar (chunks 3c + 10a), LayerCounterHud (chunk 10a), ChatBubble (chunk 10b). The off-canvas persistent UI now reflects scene + run state continuously.
1080+
1081+
**Decisions (chunk 10a):** (LayerCounterHud + VocabSidebar reverse-lookup.)
10701082
- **PipelineProgressBar was already wired in chunk 1.** The 21-segment control + `onSelectScene` click-to-jump existed since the chunk-1 stub plus the chunk-3a integration. No work needed in chunk 10. Status table line in the chunk-10 task list now reads "✅ already wired (chunk 1)".
10711083
- **LayerCounterHud derives `currentLayer` from viz state, not real `layer.advanced` telemetry.** Spec line says "visible during scenes 5-12" — the HUD reflects *where the viz is*, not raw run progress. During scenes 5-11 we show the representative layer (= 1) since those scenes visualize a single layer's worth of computation. During Scene 12 (tower view) the HUD's value matches Scene 12's in-canvas counter exactly via the same five-phase math. Real `layer.advanced` events stay out of scope; chunk 12 (perf) or chunk 14 may add a "real layer" indicator if needed.
10721084
- **Phase math for Scene 12 inlined in `CinematicViz`, not re-imported from `lib/towerCamera`.** Considered importing `counterValue` from chunk 7's lib. Rejected because the HUD already mirrors the in-canvas counter exactly; re-importing would tightly couple the persistent HUD to the scene-internal math. The 10-line inline equivalent is its own contract — chunk 7's `towerCamera` is testable + the HUD is testable; both can evolve independently without circular dependency.
Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,79 @@
11
/*
2-
* ChatBubble (M13) — bottom-right persistent UI section that
3-
* mirrors the live token stream from the WebSocket.
2+
* ChatBubble (M13 chunk 1 + chunk 10) — bottom-right persistent
3+
* UI section that mirrors the live token stream.
44
*
5-
* Chunk 1 stub: empty bubble outline. Chunk 10 wires it to the
6-
* real `useRunStream` events so tokens accumulate in real time
7-
* regardless of where the visualization currently is in the
8-
* scene sequence.
5+
* Chunk 1 stubbed the empty bubble outline. Chunk 10 wires it to
6+
* accept tokens + finality flag from the parent (CinematicViz
7+
* derives tokens from the WebSocket event stream when available
8+
* and falls back to PipelineState.generatedTokens otherwise).
9+
*
10+
* Per `phase1.md:1032`: "Starts empty; grows as Scene 17 appends
11+
* each token's string. Driven directly by the real `token.received`
12+
* WebSocket event so the user sees the response coming in even if
13+
* the visualization hasn't reached Scene 17 yet for that token."
14+
*
15+
* The "ahead-of-viz" property is a key spec beat: the chat bubble
16+
* can show tokens that the visualization hasn't reached yet. This
17+
* happens automatically when CinematicViz prefers the events array
18+
* over the slower scene-transform-driven generatedTokens.
919
*/
10-
export default function ChatBubble() {
20+
21+
export interface ChatBubbleProps {
22+
/** Token strings to render, in order. Empty array → placeholder. */
23+
tokens?: readonly string[];
24+
/** Whether the run is complete (the final token was the EOS). */
25+
isFinal?: boolean;
26+
}
27+
28+
export default function ChatBubble({ tokens = [], isFinal = false }: ChatBubbleProps) {
29+
const concatenated = tokens.join('');
30+
const hasContent = tokens.length > 0;
31+
1132
return (
1233
<div
1334
className="rounded-lg border border-border bg-card/60 p-3 text-xs"
1435
role="region"
1536
aria-label="Streaming output"
1637
data-testid="viz-chat-bubble"
38+
data-final={isFinal ? 'true' : 'false'}
1739
>
18-
<p className="font-medium uppercase tracking-wider text-muted-foreground text-[10px]">
19-
Response
20-
</p>
21-
<p className="mt-1 text-muted-foreground/70 italic">
22-
Tokens stream in here as the model generates.
23-
</p>
40+
<div className="flex items-center justify-between gap-2">
41+
<p className="font-medium uppercase tracking-wider text-muted-foreground text-[10px]">
42+
Response
43+
</p>
44+
{hasContent && (
45+
<p
46+
className="text-[9px] font-mono tabular-nums text-muted-foreground/60"
47+
data-testid="viz-chat-bubble-count"
48+
>
49+
{tokens.length} tokens
50+
{isFinal && ' · complete'}
51+
</p>
52+
)}
53+
</div>
54+
55+
{hasContent ? (
56+
<p
57+
className="mt-1 whitespace-pre-wrap break-words text-foreground/90"
58+
data-testid="viz-chat-bubble-text"
59+
>
60+
{concatenated}
61+
{!isFinal && (
62+
<span
63+
className="ml-0.5 inline-block h-3 w-1 animate-pulse bg-foreground/60 align-middle"
64+
aria-hidden="true"
65+
data-testid="viz-chat-bubble-cursor"
66+
/>
67+
)}
68+
</p>
69+
) : (
70+
<p
71+
className="mt-1 italic text-muted-foreground/70"
72+
data-testid="viz-chat-bubble-placeholder"
73+
>
74+
Tokens stream in here as the model generates.
75+
</p>
76+
)}
2477
</div>
2578
);
2679
}

resources/js/Components/Viz/CinematicViz.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ interface CinematicVizProps {
5858
scenes?: ReadonlyArray<Scene<PipelineState, PipelineState>>;
5959
}
6060

61-
export default function CinematicViz({ model, prompt, scenes = ALL_SCENES }: CinematicVizProps) {
61+
export default function CinematicViz({
62+
events = [],
63+
model,
64+
prompt,
65+
scenes = ALL_SCENES,
66+
}: CinematicVizProps) {
6267
const reducedMotion = useReducedMotion();
6368
const webgl2Supported = useWebGL2Support();
6469

@@ -148,6 +153,29 @@ export default function CinematicViz({ model, prompt, scenes = ALL_SCENES }: Cin
148153
return row >= 0 ? row : null;
149154
})();
150155

156+
// M13 chunk 10b: derive chat-bubble tokens from the WebSocket
157+
// event stream when present, falling back to the scene-driven
158+
// generatedTokens. Spec literal: "driven directly by the real
159+
// token.received WebSocket event so the user sees the response
160+
// coming in even if the visualization hasn't reached Scene 17
161+
// yet." When events stream live, the bubble can be ahead of the
162+
// viz; when no events arrive (isolated chunk testing, replay
163+
// without a stream), the chunks 8b/9a-populated generatedTokens
164+
// fill in.
165+
const tokenEvents = events.filter(
166+
(e): e is Extract<RunEvent, { event: 'token.received' }> => e.event === 'token.received',
167+
);
168+
const chatTokens = (() => {
169+
if (tokenEvents.length > 0) {
170+
return tokenEvents.map((e) => e.payload.token);
171+
}
172+
return (state.pipelineState.generatedTokens ?? []).map((t) => t.string);
173+
})();
174+
const chatIsFinal =
175+
tokenEvents.length > 0
176+
? tokenEvents[tokenEvents.length - 1]?.payload.is_final === true
177+
: state.sceneId === 'detokenize' && state.t >= 0.9;
178+
151179
// M13 chunk 10: LayerCounterHud visibility + values. Visible
152180
// during scenes 5-12 (the per-layer + tower scenes); hidden on
153181
// tokenization (0-4) + output (13-20). currentLayer defaults to
@@ -273,7 +301,7 @@ export default function CinematicViz({ model, prompt, scenes = ALL_SCENES }: Cin
273301
)}
274302

275303
<div className="absolute bottom-2 right-2 z-10 w-72">
276-
<ChatBubble />
304+
<ChatBubble tokens={chatTokens} isFinal={chatIsFinal} />
277305
</div>
278306
</div>
279307
</div>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
import ChatBubble from '@/Components/Viz/ChatBubble';
4+
5+
describe('<ChatBubble />', () => {
6+
it('renders the placeholder when no tokens are provided', () => {
7+
render(<ChatBubble />);
8+
expect(screen.getByTestId('viz-chat-bubble-placeholder')).toBeInTheDocument();
9+
expect(screen.queryByTestId('viz-chat-bubble-text')).not.toBeInTheDocument();
10+
});
11+
12+
it('renders the placeholder when tokens is empty', () => {
13+
render(<ChatBubble tokens={[]} />);
14+
expect(screen.getByTestId('viz-chat-bubble-placeholder')).toBeInTheDocument();
15+
});
16+
17+
it('renders the concatenated text when tokens are present', () => {
18+
render(<ChatBubble tokens={[' The', ' quick', ' brown', ' fox']} />);
19+
const text = screen.getByTestId('viz-chat-bubble-text');
20+
expect(text.textContent).toContain(' The quick brown fox');
21+
});
22+
23+
it('shows the token count', () => {
24+
render(<ChatBubble tokens={[' a', ' b', ' c']} />);
25+
const count = screen.getByTestId('viz-chat-bubble-count');
26+
expect(count.textContent).toContain('3 tokens');
27+
});
28+
29+
it('shows " · complete" when isFinal=true', () => {
30+
render(<ChatBubble tokens={[' done']} isFinal={true} />);
31+
const count = screen.getByTestId('viz-chat-bubble-count');
32+
expect(count.textContent).toMatch(/complete/i);
33+
});
34+
35+
it('omits " · complete" when isFinal=false', () => {
36+
render(<ChatBubble tokens={[' streaming']} isFinal={false} />);
37+
const count = screen.getByTestId('viz-chat-bubble-count');
38+
expect(count.textContent).not.toMatch(/complete/i);
39+
});
40+
41+
it('renders the blinking cursor while streaming (isFinal=false)', () => {
42+
render(<ChatBubble tokens={[' a']} isFinal={false} />);
43+
expect(screen.getByTestId('viz-chat-bubble-cursor')).toBeInTheDocument();
44+
});
45+
46+
it('hides the cursor when isFinal=true', () => {
47+
render(<ChatBubble tokens={[' a']} isFinal={true} />);
48+
expect(screen.queryByTestId('viz-chat-bubble-cursor')).not.toBeInTheDocument();
49+
});
50+
51+
it('reports data-final attribute matching the prop', () => {
52+
const { container } = render(<ChatBubble tokens={[' a']} isFinal={true} />);
53+
const bubble = container.querySelector('[data-testid="viz-chat-bubble"]') as HTMLElement;
54+
expect(bubble.getAttribute('data-final')).toBe('true');
55+
});
56+
57+
it('preserves whitespace in tokens (leading spaces)', () => {
58+
render(<ChatBubble tokens={[' a', ' b']} />);
59+
const text = screen.getByTestId('viz-chat-bubble-text');
60+
// " a b" with leading space preserved.
61+
expect(text.textContent?.startsWith(' a b')).toBe(true);
62+
});
63+
64+
it('handles a single newline token', () => {
65+
render(<ChatBubble tokens={['first', '\n', 'second']} />);
66+
const text = screen.getByTestId('viz-chat-bubble-text');
67+
expect(text.textContent).toContain('first');
68+
expect(text.textContent).toContain('second');
69+
});
70+
});

0 commit comments

Comments
 (0)