Stream Inspector
A developer feed of the raw frames of one streamed model response — per-frame offsets and gaps, a TTFT marker line, header stats, stall marking with jump-to-next, delta folding, tail-follow with break-away and a pretty/raw toggle per row.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stream-inspector.jsonPrompt
Build a React + TypeScript + Tailwind "StreamInspector" component with zod and
lucide-react: a developer-facing feed of the RAW frames of ONE streamed model
response, the panel you open when a stream "felt slow" and you need to know
which frame is to blame.
Contract
- A zod schema (`streamInspectorEventSchema` in a sibling contract file) is the
single source of truth for one frame: { id, kind, at, payload?: unknown,
gapMs?, raw?, text?, tokens?, label?, timeLabel? }.
- `kind` is the normalised PROTOCOL vocabulary, nine values: "start" |
"text-delta" | "reasoning-delta" | "tool-call" | "tool-args-delta" | "ping" |
"usage" | "finish" | "error". Map your provider onto it once, in an adapter,
and every downstream reading is comparable across providers. A keep-alive ping
is a first-class row — during a long tool call it is the only evidence the
socket is alive.
- `at` is a MONOTONIC OFFSET in ms from the moment the request was sent, never a
wall clock. Offsets are what you compare, they survive being pasted into a bug
report, and they render identically on the server and in the browser. A
wall-clock reading is allowed only as a PRE-FORMATTED `timeLabel`; the
component never calls Date.now() or toLocaleTimeString(), so it cannot hydrate
differently from what the server rendered.
- `gapMs` is optional and derived when absent: `at - previous.at`, and `at`
itself for the first frame (that one is the wait before the stream said
anything). Out-of-order or non-finite frames clamp to 0 rather than render a
negative gap.
- `payload` is `unknown` on purpose — a provider's frame shape is the provider's
business. Never ask the caller to pre-stringify it.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
panel's own render state. `status="error"` means the TRANSPORT failed (the
connection dropped, the proxy answered text/html); a provider error that
arrived as a frame is an ordinary `kind: "error"` row inside a `ready` feed,
and the two must read differently. `empty` is not `loading`: the stream opened
and closed without a single frame, which is its own bug.
- Props: events: Event[]; status; follow? + onFollowChange? (hybrid
controlled/uncontrolled, default on); defaultMergeDeltas? (true); defaultView?
("pretty"); defaultTimeMode? ("offset"); stallMs? (400); maxRows? (200);
maxHeight? (420); onStatsChange?(stats); onRetry?; emptyState?; errorMessage?;
label? ("Stream events"); skeletonRows? (6); formatOffset?; className plus the
rest of the div props, ref forwarded to the root.
- Every numeric prop is clamped (stallMs >= 1, maxRows >= 20, maxHeight >= 160):
a stallMs of 0 would mark every row as a stall, a maxRows of 0 would render an
empty feed that looks like a bug in the caller's transport.
Behavior
- HEADER STATS, all derived from the same events array, never passed in:
TTFT = the offset of the first TOKEN-BEARING frame (text-delta,
reasoning-delta, tool-call or tool-args-delta) — a response that opens with a
tool call has a real TTFT even though it never writes a character.
tokens/sec = tokens / (last token offset − TTFT), measured AFTER the first
token on purpose: folding TTFT into throughput turns one slow queue into a
permanently slow-looking model. It is null (rendered "—") when no frame
reported tokens or the window is zero — never a fabricated number. Tokens
reported by a `usage` frame are deliberately NOT summed: adding a running
total to the deltas that produced it doubles the reading.
Also: frame count (plus the folded row count), total duration, wire bytes, and
a stall counter that doubles as a control.
- A TTFT MARKER LINE is drawn as its own row immediately above the first
token-bearing row: everything above it is connection overhead, everything
below it is output. It is a line across the timeline, not a badge on a row.
- STALLS: any gap >= stallMs is marked on its row (left edge + emphasised gap
figure), counted in the header, and the counter is a BUTTON that walks the
stalls one by one, centring each in the viewport and showing "3/7". If the
next stall sits above the rendered window, the window budget is lifted first —
a jump to a row that is not in the DOM would silently do nothing.
- DELTA FOLDING: consecutive runs of the three fragment kinds (text-delta,
reasoning-delta, tool-args-delta) fold into one row carrying the concatenated
text and a ×N badge; 400 rows that each say " the" bury the six rows that
matter. The rule that makes folding safe: A STALL BREAKS THE RUN, so a gap you
were meant to see can never be swallowed by the fold. Pings never fold — two
keep-alives in a row are two separate facts about the connection.
- TAIL FOLLOW WITH BREAK-AWAY: while following, a layout effect pins scrollTop
to scrollHeight after every content change, so the newest row is in place on
the frame it appears instead of flashing half-clipped for one paint. Scrolling
away from the bottom (beyond an 8px epsilon) breaks away; scrolling back
resumes; the panel's own programmatic pins land at distance 0 and can only
ever resume. While detached, a pill counts the frames you have not seen and
jumps back.
- THE WINDOW FREEZES WHILE DETACHED. Only the last `maxRows` rows are kept in
the DOM, but the top of that window is frozen at the moment you took over —
otherwise rows would be unmounted above your cursor and the content under it
would jump every time a frame landed. The banner states exactly how many
earlier rows are not in the DOM and offers to render them all; a budget you
can see is a budget you can trust.
- PRETTY / RAW PER ROW. The toolbar sets the default view and CLEARS the
per-row overrides when it changes (a global switch that visibly does nothing
on the rows you touched earlier is worse than no switch), and every open row
can override it. Raw shows the exact `raw` frame; when a frame has none, the
panel reconstructs `event: <kind>\ndata: <json>` and SAYS SO — a
reconstruction passed off as the wire truth costs somebody an afternoon. A
folded run prints at most 40 frames in raw and states how many it withheld.
Copy writes exactly what the row is showing.
- Disclosure is a real <button aria-expanded> whose panel is UNMOUNTED when
closed — a zero-height collapse would leave the view toggles and copy buttons
of 200 rows reachable by Tab — and `aria-controls` is only set while open so
it never points at an id that is not in the document. Payload bodies are only
serialised for OPEN rows: stringifying 200 collapsed rows on every frame that
lands turns a live stream into a slideshow.
- Arrow Up / Down / Home / End move focus between row headers when focus is
already on one; 200 rows of Tab is not a keyboard path. Focusing a row scrolls
it into view, which breaks the follow — correct, you took over.
- Screen readers hear ONE throttled status line ("Tail following paused, 42 new
events below"), flushed at most once per ~1.5 s. A 60 frame/second stream in a
live region would flood assistive tech into uselessness.
- JSON is serialised with an ancestor STACK, not a visited set, so an object
used twice as a sibling still prints twice and only a true cycle becomes
"[Circular]"; BigInt and functions get placeholders instead of throwing.
- All timers, the throttle and the copy reset are cleared on unmount; the panel
starts no interval of its own — the stream belongs to the consumer.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary/10 + border-primary/50 for pressed toolbar chips and the TTFT line,
bg-primary + text-primary-foreground for the jump-to-latest pill,
text-destructive + bg-destructive/5 + a destructive left edge for error frames
and stall figures, bg-muted/40 for payload blocks. No hard-coded colours.
- Kind chips separate by BORDER STYLE, TINT AND WEIGHT rather than a hue ramp
(dashed for ping, filled for tool-call, destructive for error) plus a distinct
icon each, so a monochrome theme and a colour-blind reader still read the
feed.
- Rows are monospace with tabular-nums in the offset and gap columns so the
numbers form a scannable column; the summary uses `wrap-anywhere` with
`min-w-0` (NOT break-words — only overflow-wrap:anywhere lowers the
min-content width) so a 200-character JSON fragment cannot widen the panel.
- Invisible characters are made visible: a newline renders as ↵, a tab as ⇥, a
whitespace-only delta as ␣ glyphs. An empty-looking row is how you lose the
delta that mattered.
- Only decorative motion is used (colour transitions, the chevron rotation, the
skeleton pulse) and all of it is motion-reduce-guarded; scrolling is
instant scrollTop arithmetic, never smooth scrollIntoView, which would also
scroll every ancestor and yank the page the panel is embedded in.
- cn() merges the consumer's className into the root, which also spreads the
remaining div props and forwards its ref.
Customization levers
- Thresholds: `stallMs` is the whole stall story — raise it to 800 for a mobile
network, drop it to 150 when you are hunting micro-jitter; it re-marks rows,
re-counts the header and re-decides where folds break, in that order.
- Density budget: `maxRows` (DOM rows) and `maxHeight` (viewport) are the two
knobs for embedding — 120/300 in a side drawer, 500/640 in a full-page debug
view. Both stay announced; neither ever truncates silently.
- Defaults for the three switches: `defaultMergeDeltas` (off for protocol-level
fidelity, on for reading), `defaultView` ("raw" in a gateway inspector,
"pretty" in a playground), `defaultTimeMode` ("clock" only when your frames
carry `timeLabel`).
- Ownership: pass `follow` + `onFollowChange` to drive the tail from your own
toolbar or to force-follow while a request is in flight; pass `onStatsChange`
to feed TTFT / tok-s into a gauge elsewhere in your app instead of duplicating
the maths.
- Units: `formatOffset` swaps the wording for both the offset and the gap
columns (µs for a local model, "frame 12" for a video stream, percentages of
the total).
- Chrome: `label` names the region, `emptyState` replaces the empty body,
`errorMessage` + `onRetry` own the envelope failure, `skeletonRows` matches
the loading block to the height of the panel it will become.
- Kinds are data, not UI: add a provider kind by extending the contract enum and
giving it a chip class, an icon and a label — the folding, stall and TTFT
rules are keyed off the two exported kind lists, so a new kind opts into them
by joining a list rather than by touching the render.Concepts
- Offsets, not timestamps — every row is placed on a monotonic offset from the moment the request was sent, and the number you actually read is the GAP to the previous frame. A wall clock tells you when something happened; a gap column tells you where the stream stopped, which is the only question this panel exists to answer.
- TTFT is a line, not a badge — the marker is drawn across the feed above the first token-bearing frame, so the connection overhead above it and the output below it are two visually separate regions. A tool-only response still crosses that line: the first
tool-callframe is a token. - A stall breaks the fold — folding runs of deltas is what makes 1,200 frames readable, but a fold that could swallow a 900 ms gap would hide the one row you opened the panel for. Making the stall a hard boundary is what lets folding stay a reading preference instead of a fidelity trade.
- Follow, break away, freeze — the tail pins itself until you scroll up; from that moment the window's TOP is frozen too, so rows are never unmounted above your cursor and the content under it never jumps. Coming back to the bottom resumes both, and the panel's own pins land at distance zero so they can only resume, never break away.
- A budget you can see — only the last N rows live in the DOM, and the banner says exactly how many do not, with a control to render them all. The same discipline governs the raw view of a folded run: 40 frames, then the count of the rest. A silent cap makes you debug the wrong thing.
- Reconstruction is labelled — a frame without its raw bytes still gets a raw view, rebuilt from the decoded payload and marked as rebuilt. Presenting a guess as the wire truth is how a missing comma gets debugged for an afternoon.
- One throttled announcement — a live region on a 60 frame/second feed is a denial-of-service attack on a screen reader. The panel keeps a single status line and flushes it at most once per window, saying only what changed: following paused, and how many frames you have not seen.
API Error Card
An LLM API failure card whose recovery is chosen by error class — a countdown ring with one-shot opt-in auto-retry for 429s, a key-settings exit for auth, a status-page exit for overload, and trim / summarize exits when the context is too long.
Prompt Diff
A word-level diff of two prompt versions — split or unified columns, version chips with author, date and run count, side swap, per-version copy and a one-shot restore.