<script lang="ts">
import { onMount, tick } from "svelte";
import { createVirtualizer } from "@tanstack/svelte-virtual";
import ScratchpadAccordion from "./ScratchpadAccordion.svelte";
import CellTerminal from "./CellTerminal.svelte";
import SpeechBubble from "./SpeechBubble.svelte";
import type { StreamingClientV2, TimelineItem } from "../lib/streaming-v2";
import { Activity, ChevronDown, ChevronRight, CircleAlert, History, ShieldAlert, Terminal } from "lucide-svelte";
import {
buildRows,
computeInputOutcomes,
filterItems,
groupTurns,
isCellRun,
isLifecycleRun,
type CellRun,
type LaneSelection,
type LifecycleRun,
type Row,
type TurnChild,
} from "../lib/turns";
export let client: StreamingClientV2;
/** Which lanes this view shows: a set, not a single mode. */
export let selection: LaneSelection = ["conversation"];
export let atLive = true;
/** Timestamp of the oldest record the operator can see; the horizon reports this, not a cursor. */
export let viewingAt: number | undefined = undefined;
export let behind = 0;
let scroller: HTMLDivElement;
let autoScroll = true;
/**
* Whether the console has put the reader at the live end at least once, and where it last put them.
* A position means nothing until the console has placed the reader, and the scroll event that follows
* the console's own write reports the position it asked for: that is its echo, not the reader's hand,
* and one echo misread as a decision is enough to stop following the session for good.
*/
let placed = false;
let wroteAt = -1;
let newestTurnId: string | undefined = undefined;
let knownCount = -1;
let openRuns: Record<string, boolean> = {};
/** Expanded output streams, keyed by cell id. Owned here because a row is unmounted off screen. */
let streamState: Record<string, Record<number, boolean>> = {};
/**
* `?overscan=all` renders every row — the same markup with the window removed. It exists so the
* cost of the window can be measured against the cost of not having one, on the same data and the
* same code, rather than being asserted.
*/
const OVERSCAN = new URLSearchParams(window.location.search).get("overscan") === "all" ? 1_000_000 : 6;
/**
* How close to the end still counts as standing at the live end. One number, used by the scroll
* handler, the "jump to live" state and the virtualizer's own end-anchoring, because three copies of
* this threshold is three ways for the console to disagree with itself about where the reader is.
*/
const AT_END = 48;
const timeline = client.timelineStore;
const workItemsStore = client.workItemsStore;
const conn = client.connectionStore;
$: timelineItems = $timeline ?? [];
$: workItems = $workItemsStore;
$: visible = filterItems(timelineItems, selection);
$: turns = groupTurns(visible);
/**
* The top row is the loaded window's own edge, and it is only there when the daemon says there is
* something behind it. It stays while a page is in flight, so the control a reader just pressed is
* the one that reports the answer.
*/
$: earlier = $conn.hasEarlier === true || $conn.loadingEarlier === true;
/**
* A window that opens at a fold is not obviously the start of anything: the preset hides lifecycle
* rows, so the record that folded the prefix is not in the transcript to explain it. The daemon says
* which record the fold drained through, and the edge mentions it while the loaded window is above it
* — a reader who has paged back past the fold is looking at the prefix itself, and there is nothing
* summarized about what they are reading.
*/
$: floorSeq = timelineItems[0]?.seq;
$: foldAtWindow =
floorSeq !== undefined && $conn.foldSeq !== undefined && floorSeq > $conn.foldSeq
? $conn.foldSeq
: undefined;
$: rows = buildRows(turns, openRuns, earlier);
/** The newest turn is the one that is still being written to. */
$: liveTurnId = turns.length > 0 ? turns[turns.length - 1].id : undefined;
/**
* What the virtualizer is told. It is a function rather than an object because the adapter merges
* whatever is passed to `setOptions` over the options it already holds — and it overwrites `onChange`
* even when the new call does not mention it. So every sync passes the whole contract, and the
* layout callback below cannot be silently dropped by the next append.
*/
function options(list: Row[]) {
return {
count: list.length,
getScrollElement: () => scroller,
estimateSize: (i: number) => list[i]?.estimate ?? 64,
getItemKey: (i: number) => list[i]?.key ?? i,
/**
* Rows are measured to the pixel rather than rounded to one. The default rounds every height,
* and the errors add up: by the third turn the same records sit a couple of pixels lower than
* they would in an ordinary flow. A transform takes a fraction happily, so the row goes
* exactly where its text put it.
*/
measureElement: (element: HTMLElement, entry?: ResizeObserverEntry) =>
entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height,
/** the rows either side of the fold, so a flick does not land on a blank band */
overscan: OVERSCAN,
/**
* A session is read from its live end, and the list owns that end: it keeps the bottom pinned
* through appends and through measurement, and its own bookkeeping of how far a measurement
* moved the content is the only one that adds up. It is told where the reader is, not moved
* behind its back.
*/
anchorTo: "end" as const,
followOnAppend: true,
scrollEndThreshold: AT_END,
/**
* Measurement is the list's own business: it compensates for a row above the fold that turns
* out to be taller than its estimate, and it does that without being told. Re-pinning from
* this callback instead made the two fight — the pin writes a scroll offset, the write changes
* the window, the change calls back, and the transcript pinned itself hundreds of times a
* second. The app's own hold is a statement (below), not a callback from here.
*/
};
}
// constructed empty: the first `sync` below is what gives it the real list
const virtualizer = createVirtualizer(options([]));
/** The list is the virtualizer's input, and it can only learn about a new one by being told. */
function sync(list: Row[]) {
$virtualizer.setOptions(options(list));
}
$: sync(rows);
// bind:this lands after the first pass, so the scroll element is only real once we are mounted
onMount(() => {
tick().then(() => sync(rows));
});
$: virtualItems = $virtualizer.getVirtualItems();
$: totalSize = $virtualizer.getTotalSize();
$: {
if (knownCount >= 0 && timelineItems.length > knownCount && visible.length > 0) {
newestTurnId = turns[turns.length - 1]?.id;
}
knownCount = timelineItems.length;
}
/** Measures a row into the virtualizer. The row's own box carries the padding, so the whole row counts. */
function measure(node: HTMLElement) {
$virtualizer.measureElement(node);
return {
destroy: () => $virtualizer.measureElement(null),
};
}
/**
* Getting the reader to the live end, and keeping them there. The list holds the bottom while the
* reader is standing there — through appends, through measurement, through rows above growing — and
* lets go the moment they are not. Two things it cannot do alone, and both are the app's, gated on
* `autoScroll`, which only the reader's own scroll can take away: the first placement, and the write
* that answers its own measurement adjustment.
*/
function place() {
tick().then(() => {
if (!scroller || !autoScroll) return;
$virtualizer.scrollToEnd();
if (!$virtualizer.scrollElement) return; // nothing to move yet: the next batch tries again
placed = true;
// where the console just put the reader, so the event it causes can be told from a hand
wroteAt = scroller.scrollTop;
});
}
/**
* First: arriving. The transcript opens at the live end, and the hand-over is what has to be waited
* for — a replay arrives faster than one write can keep up with, and the height being scrolled to is
* still made of estimates. So the placement repeats with every batch of it, and keeps repeating after
* the hand-over ends if it has still not landed: the first records arrive before the virtualizer has
* found a scroll element to move, and a placement that silently did nothing would leave the session
* opening at its top forever.
*/
$: if (autoScroll && rows.length > 0 && (!placed || $conn.isSyncing)) place();
/**
* Second: staying. The follow lands on the size the transcript had when an append arrived, and a row
* that then measures taller than its estimate leaves the newest lines under the fold. So while the
* reader is standing at the live end the console holds the end itself — a plain write of the size it
* already knows, not a scroll command. Going through `scrollToEnd` here instead made the console and
* the virtualizer correct each other in a loop, sixty times a second; a write of the position the
* scroller is already at costs nothing.
*
* It is written twice per size, and the second write is the point. The list adjusts the scroll itself
* when a row measures, after this statement has run and from the size the row had *before* it
* measured; left there, the reader is a row short of the end, and the scroll event that correction
* causes looks exactly like a reader leaving. The follow disarms and the transcript never catches up
* with itself. So the end is held once now and once after the list has had its turn in the frame.
*/
let holdFrame = 0;
function holdAgain(end: number) {
if (holdFrame) cancelAnimationFrame(holdFrame);
holdFrame = requestAnimationFrame(() => {
holdFrame = 0;
if (!autoScroll || !scroller) return;
if (Math.abs(end - scroller.scrollTop) > 0.5) {
scroller.scrollTop = end;
wroteAt = scroller.scrollTop;
}
});
}
$: if (autoScroll && placed && scroller && totalSize > 0) {
const end = totalSize - ($virtualizer.scrollRect?.height ?? 0);
if (end > 0) {
if (Math.abs(end - scroller.scrollTop) > 0.5) {
scroller.scrollTop = end;
wroteAt = scroller.scrollTop;
}
holdAgain(end);
}
}
/**
* A view change replaces the transcript underneath the reader, so their pixel position means nothing
* afterwards. Their intent does: if they were standing at the live end when they asked for the
* change, they are standing at the live end when it lands.
*/
let appliedSelection: LaneSelection = selection;
$: if (selection !== appliedSelection) {
const wasAtLive = autoScroll;
appliedSelection = selection;
tick().then(() => {
autoScroll = wasAtLive;
if (wasAtLive) place();
});
}
$: atLive = autoScroll;
$: behind = Math.max(0, timelineItems.length - visible.length);
/** The horizon is the row at the top edge of the viewport: one binary search, not a DOM sweep. */
function updateViewingAt() {
if (!scroller) return;
const item = $virtualizer.getVirtualItemForOffset(scroller.scrollTop);
const row = item ? rows[item.index] : undefined;
// The window's own edge is a control, not a record: it has no moment to report.
viewingAt = row && row.kind !== "history" ? row.at : undefined;
}
function handleScroll() {
if (!scroller) return;
// nothing has placed the reader yet, so their position is not yet a decision they made
if (!placed) return;
const { scrollTop, scrollHeight, clientHeight } = scroller;
// the echo of the console's own write, or a position the reader has already left: not a move
if (Math.abs(scrollTop - wroteAt) < 1) return;
wroteAt = -1;
autoScroll = scrollHeight - (scrollTop + clientHeight) < AT_END;
if (autoScroll) viewingAt = undefined;
else updateViewingAt();
}
export function jumpToLive() {
autoScroll = true;
viewingAt = undefined;
placed = false;
place();
}
function toggleRun(id: string) {
openRuns = { ...openRuns, [id]: !openRuns[id] };
}
/**
* Loading earlier history, which is the one scroll this console makes that is not the live end.
*
* Keeping the reader still would hide the thing they asked for: the records arrive above a viewport
* that does not move, so a press would look like nothing happened. They land on the top of the page
* that just arrived instead, and read downward from there — back to where they were.
*/
let waitingForHistory = false;
let historyRequested = false;
function loadEarlier() {
if (!scroller) return;
// Asking for the past is a decision about where to stand: the follow must not fight the landing.
autoScroll = false;
waitingForHistory = true;
historyRequested = client.loadEarlier();
if (!historyRequested) waitingForHistory = false;
}
$: if (waitingForHistory && !$conn.loadingEarlier) {
waitingForHistory = false;
landOnHistory();
}
function landOnHistory() {
if (!historyRequested) return;
historyRequested = false;
tick().then(() => {
if (!scroller || rows.length === 0) return;
// Row 0 is the window's edge; row 1 is the first record the daemon sent for it.
$virtualizer.scrollToIndex(Math.min(1, rows.length - 1), { align: "start" });
wroteAt = scroller.scrollTop;
updateViewingAt();
});
}
function toggleStream(cellId: string, index: number) {
const cell = streamState[cellId] ?? {};
streamState = { ...streamState, [cellId]: { ...cell, [index]: !cell[index] } };
}
/**
* Built once. An `Intl.DateTimeFormat` is a locale lookup plus a compiled pattern, and constructing one
* per rendered row per render was, at 4800 records, the largest single cost left in the console.
*/
const clockFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
function clock(ms: number): string {
return clockFormat.format(ms);
}
function duration(ms?: number): string {
if (!ms) return "";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
$: inputOutcomes = computeInputOutcomes(timelineItems, workItems);
/** `hook.decision` is a machine identifier, not a phrase: the console says "hook decision". */
function humanKind(kind: string): string {
return kind.replace(/[._]/g, " ");
}
/** One row for a run of bookkeeping records: how many, and which kinds, in arrival order. */
function lifecycleRunLabel(run: LifecycleRun): string {
const counts = new Map<string, number>();
for (const item of run.items) {
const kind = humanKind(String((item.data as { kind?: string }).kind ?? "event"));
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
return [...counts].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(" · ");
}
function runLabel(run: CellRun): string {
const statuses = run.items.map((it) => it.data.status);
const running = statuses.filter((s) => s === "running").length;
const failed = statuses.filter((s) => s === "error").length;
const total = run.items.reduce((a, it) => a + (it.data.duration_ms ?? 0), 0);
const parts = [`${run.items.length} cells`];
if (running) parts.push(`${running} running`);
if (failed) parts.push(`${failed} failed`);
if (!running && !failed) parts.push("all ok");
if (total) parts.push(duration(total));
return parts.join(" · ");
}
</script>
<div class="stream-root">
<div class="stream-scroll" bind:this={scroller} on:scroll={handleScroll}>
{#if rows.length === 0}
<div class="empty">
{#if $conn.state !== "connected"}
<span class="mute">no daemon · retrying on its own</span>
{:else if selection.length === 0}
<span class="mute">no lanes selected</span>
{:else}
<span class="mute">nothing here yet</span>
{/if}
</div>
{:else}
<div class="sizer" style="height: {Math.round(totalSize)}px">
{#each virtualItems as v (v.key)}
{@const row = rows[v.index]}
{#if row.kind === "history"}
<div class="row history-row" data-index={v.index} use:measure
class:first={v.index === 0} style="transform: translateY({v.start}px)">
<div class="col">
<button class="btn history-btn" type="button" on:click={loadEarlier}
disabled={$conn.loadingEarlier}
title="ask the daemon for the records behind this window">
<History size={13} stroke-width={1.5} />
{#if $conn.loadingEarlier}loading earlier history{:else}load earlier history{/if}
</button>
{#if foldAtWindow !== undefined}
<span class="mute hist-note"
title="the context summary carries every record through {foldAtWindow}; this transcript is what came after it">
everything before this point is carried by the context summary
</span>
{/if}
</div>
</div>
{:else if row.kind === "head"}
<div class="row head-row" data-index={v.index} use:measure
class:first={v.index === 0} class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<header class="turn-head">
{#if row.turn.id === liveTurnId && atLive}
<span class="live-dot" aria-hidden="true" title="this turn is still being written"></span>
{/if}
<span class="turn-no num faint">#{row.no}</span>
<span class="turn-when reg-mono faint">{clock(row.turn.startedAt)}</span>
{#if row.turn.input}
<span class="turn-who">{row.turn.input.data.source || "operator"}</span>
{:else}
<span class="turn-who faint">session</span>
{/if}
{#if row.partial}
<span class="mute partial-note">starts above the window</span>
{/if}
<span class="spacer"></span>
{#if row.partial}
<!-- a count of the records loaded is not a count for the turn -->
{:else if row.turn.summary.cells > 0}
<span class="chip lane-cell" title="{row.turn.summary.cells} cells in this turn">
<Terminal size={12} stroke-width={1.5} /><span class="num">{row.turn.summary.cells}</span>
</span>
{/if}
{#if row.turn.summary.failed > 0}
<span class="chip chip-err" title="{row.turn.summary.failed} failed">
<CircleAlert size={12} stroke-width={1.5} /><span class="num">{row.turn.summary.failed}</span>
</span>
{/if}
{#if row.turn.summary.held > 0}
<span class="chip chip-agent" title="a delivery is held for review">
<ShieldAlert size={12} stroke-width={1.5} />
</span>
{/if}
</header>
</div>
</div>
{:else if row.kind === "input"}
<div class="row record" data-index={v.index} use:measure
class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<SpeechBubble
kind="input"
text={row.item.data.text}
provenance={row.item.data.provenance}
outcome={inputOutcomes.get(row.item.id)}
/>
</div>
</div>
{:else if row.kind === "cell"}
<div class="row cell-row" data-index={v.index} use:measure
class:end-of-run={row.endOfRun} class:last={row.last}
style="transform: translateY({v.start}px)">
<div class="col">
<CellTerminal
item={row.cell.data}
expanded={true}
streamState={streamState[row.cell.id] ?? {}}
onToggleStream={(i) => toggleStream(row.cell.id, i)}
/>
</div>
</div>
{:else if isCellRun(row.child)}
{@const open = Boolean(openRuns[row.child.id])}
<div class="row record" data-index={v.index} use:measure
class:run-head={open} class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<div class="record-head">
<button class="btn btn-icon disclosure" type="button" aria-expanded={open}
title={open ? "collapse this run" : "expand this run"}
on:click={() => toggleRun(row.child.id)}>
{#if open}<ChevronDown size={13} stroke-width={1.5} />{:else}<ChevronRight size={13} stroke-width={1.5} />{/if}
</button>
<span class="lbl">{runLabel(row.child)}</span>
</div>
</div>
</div>
{:else if isLifecycleRun(row.child)}
<div class="row record" data-index={v.index} use:measure
class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<div class="lifecycle icon-word">
<Activity size={12} stroke-width={1.5} class="lane-lifecycle" aria-hidden="true" />
<span class="reg-mono">{lifecycleRunLabel(row.child)}</span>
</div>
</div>
</div>
{:else if row.child.lane === "scratchpad"}
<div class="row record" data-index={v.index} use:measure
class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<ScratchpadAccordion entry={row.child.data} expanded={row.child.data.active} />
</div>
</div>
{:else if row.child.lane === "cell"}
<div class="row record" data-index={v.index} use:measure
class:last={row.last} style="transform: translateY({v.start}px)">
<div class="col">
<CellTerminal
item={row.child.data}
expanded={true}
streamState={streamState[row.child.id] ?? {}}
onToggleStream={(i) => toggleStream(row.child.id, i)}
/>
</div>
</div>
{:else if row.child.lane === "speech"}
<div class="row record" data-index={v.index} use:measure
class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<SpeechBubble
kind="speech"
receipt={row.child.data.receipt}
text={row.child.data.receipt.text}
provenance={row.child.data.receipt?.provenance}
/>
</div>
</div>
{:else}
<div class="row record" data-index={v.index} use:measure
class:last={row.last} class:arrive={row.turnId === newestTurnId}
style="transform: translateY({v.start}px)">
<div class="col">
<div class="lifecycle icon-word">
<Activity size={12} stroke-width={1.5} class="lane-lifecycle" aria-hidden="true" />
<span class="reg-mono">{humanKind(String((row.child.data as { kind?: string }).kind ?? "event"))}</span>
{#if (row.child.data as { reason?: string }).reason}
<span class="dim">{(row.child.data as { reason?: string }).reason}</span>
{/if}
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
<style>
.stream-root { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; min-height: 0; }
.stream-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; }
/* the sizer is the whole conversation in scroll height; only the rows near the fold exist in it. */
.sizer { position: relative; width: 100%; }
/* a row spans the scroller and insets its own column inside that, so the scrollbar stays at the
window edge and no row is ever as wide as the viewport. */
.row { position: absolute; top: 0; left: 0; width: 100%; }
.col { max-width: var(--col); margin: 0 auto; padding: 0 var(--s-4); }
/* rhythm. the vertical padding lives on the row, not the column, so the measured box includes it. */
.row.head-row { padding-top: var(--s-4); padding-bottom: var(--s-3); }
.row.head-row:not(.first) > .col { border-top: 1px solid var(--line); }
.row.record { padding-top: var(--s-3); padding-bottom: var(--s-3); }
/* when a run is open its head hands the gap below it to the stack, which owns it */
.row.run-head { padding-bottom: 0; }
/* a cell inside an open run: the gap between cells is the run's own stack gap, above each one */
.row.cell-row { padding-top: var(--s-4); }
.row.cell-row.end-of-run { padding-bottom: var(--s-3); }
/* the last row of a turn carries the turn's own bottom gap, on top of its own */
.row.last { padding-bottom: var(--s-5); }
.row.record.last { padding-bottom: calc(var(--s-3) + var(--s-5)); }
.row.cell-row.last { padding-bottom: calc(var(--s-3) + var(--s-5)); }
/* the top of the loaded window: a control, drawn as one, at the edge of the transcript */
.row.history-row { padding-top: var(--s-5); padding-bottom: var(--s-3); }
.row.history-row > .col { display: flex; align-items: center; gap: var(--s-4); }
.history-btn { font-size: var(--fs-2); }
.hist-note { font-size: var(--fs-2); }
.partial-note { font-size: var(--fs-1); }
.turn-head { display: flex; align-items: center; gap: var(--s-4); }
.turn-when { font-size: var(--fs-2); }
.turn-no { font-size: var(--fs-1); color: var(--fg-mute); }
.turn-who { font-family: var(--sans); font-size: var(--fs-2); font-weight: 500; color: var(--fg-mute); }
.spacer { flex: 1 1 auto; }
.record-head { display: flex; align-items: center; gap: var(--s-4); }
.lifecycle { display: flex; align-items: center; gap: var(--s-4); font-size: var(--fs-2); }
/* the newest turn is still being written. a static mark, because the state is not an event. */
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--lane-lifecycle); flex: 0 0 auto; }
/* the collapsed run's own summary line, in the machine's voice because the numbers are its own */
.lbl { font-family: var(--mono); font-size: var(--fs-2); color: var(--fg-mute); }
.dim { color: var(--fg-mute); }
.empty {
max-width: var(--col);
margin: 0 auto;
padding: var(--s-8) var(--s-4);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
</style>