diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 85c0f4959..48c62c307 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1268,15 +1268,27 @@ export function MessageTimeline(props: { const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart } - const assistantPart = () => input.row()._tag === "AssistantPart" + const assistantPart = () => { + const tag = input.row()._tag + return tag === "AssistantPart" || tag === "Thinking" + } const railLabel = () => { const row = input.row() return row._tag === "AssistantPart" ? row.railLabel : undefined } // The thought rail: a spine down a turn's assistant steps. Drawn per-row // because the timeline is virtualised and consecutive rows share no ancestor. + // Thinking is the lone running step before the first assistant part arrives + // — it gets a breathing dot so the turn never shows a line with no dot. const rail = () => { const row = input.row() + if (row._tag === "Thinking") { + // Thinking only exists while the turn is busy (rows.ts), so a dot + // here is always the live tail. Lone + running => zero-height line, + // just the breathing dot (Rule 6). + if (!workingTurn(row.userMessageID)) return undefined + return { first: true, last: true, running: true } + } if (row._tag !== "AssistantPart") return undefined if (!shouldRenderRail(row)) return undefined return { first: !row.previousAssistantPart, last: row.lastAssistantPart, running: row.turnRunning } diff --git a/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app/src/pages/session/timeline/thought-rail.tsx index 2adcc99be..ef4d0a93e 100644 --- a/packages/app/src/pages/session/timeline/thought-rail.tsx +++ b/packages/app/src/pages/session/timeline/thought-rail.tsx @@ -2,29 +2,75 @@ // ported from the website's /amicode animation (harmoniqs-ai // app/components/demo/parts.jsx, `Step`). // -// TWO THINGS THAT LOOK LIKE MISTAKES AND ARE NOT: +// Six geometry rules — keep them together, they are the whole rail. A +// standalone, framework-agnostic snippet is at the bottom of this header. // -// 1. The rail is drawn as a PER-ROW SEGMENT, not as a border-left on a shared -// container. It has to be. The timeline is virtualised (@tanstack/solid-virtual): -// every row is an absolutely-positioned box and consecutive rows share no -// ancestor except the total-height spacer, so a container spine is structurally -// impossible here. The site independently arrived at the same per-row approach, -// which is why the port is cheap. Segments meet because each one reaches UP -// through the 12px `pt-3` the frame puts above its row — see STEP_GAP below. -// Anchoring at top:0 alone leaves a 12px break in the line, because the rail's -// positioning ancestor sits inside that padding rather than around it. +// 1. Per-row segments, not a container border. Forced by virtualization: +// @tanstack/solid-virtual renders every row as an absolutely-positioned +// box; consecutive rows share no ancestor except the height spacer, so +// border-left on a wrapper is structurally impossible. Each row draws +// its own 1px span and they meet. The site arrived at the same per-row +// approach independently, which is why the port is cheap. // -// 2. "done" is decided by ADJACENCY — a step is finished once a successor exists — -// not by that step's own tool lifecycle. Tools can complete out of order or run -// in parallel, so asking each row "are you finished?" would let a filled dot sit -// above a hollow one and destroy the rail's grammar. Adjacency makes the -// sequence monotonic by construction. It is also exactly what the website does: -// a step flips filled no later than the moment the next one appears. +// 2. Segments reach up through the gap above them. TimelineRowFrame +// (message-timeline.tsx) puts `pt-3` on every step after the first, and +// the rail's positioning ancestor — the inner `session-turn` div — sits +// INSIDE that padding. A segment anchored at `top: 0` therefore starts +// 12px below where the previous one ended and you get a dashed column. +// Fix is `top: calc(var(--space-3) * -1)` (NEG_STEP_GAP). Most "rounding" +// reports are this gap, not rounding. // -// Hollow therefore means RUNNING (the tail of a turn still in flight), never -// "planned". The website does not preview future steps either — its scenes gate -// every entry on `t >= from`, so an unstarted step is never in the DOM. Showing -// the path ahead needs a real plan source (score stages), which is a later step. +// 3. Caps land on the dot centre. dotCentre = DOT_TOP + NODE/2 (7.5 + 3.5 +// = 11px). First segment starts there, last stops there: the tail is +// `height = STEP_GAP + dotCentre` from NEG_STEP_GAP (so its bottom is +// dotCentre), and the first mid segment is `top: dotCentre`. PR #242 +// fixed the 1–2px stub that peeked past the dot from the old +// `DOT_TOP + NODE/2` inline math plus the 0.5px centring in +// LINE_X = GUTTER + NODE/2 - 0.5. Lone first+last is zero-height by +// intent — see shouldRenderRail. +// +// 4. "Done" is adjacency, not lifecycle. A step is finished once a +// successor exists. Tools complete out of order and run in parallel, so +// asking each row "am I done?" lets a filled dot sit above a hollow one +// and destroys the rail's grammar. Adjacency makes the sequence monotonic +// by construction — exactly what the website does (a step flips no later +// than when the next one appears). Hollow therefore means RUNNING — the +// tail of a turn still in flight — never "planned". The site never +// previews future steps either (gated on `t >= from`); a plan source +// would be score stages, a later step. +// +// 5. Two details worth keeping: +// - Gutter, not flush. GUTTER=8, NODE=7, content clears at pl-6 (24px). +// GUTTER+NODE=15px stays inside the inset and leaves ~9px dot-to-content +// breath (pl-4 left a 1px glue). Flush at left:0 clips the live ring +// below the md breakpoint where the message column has no padding and +// begins at the viewport edge. +// - Contrast is the spec. Done dots use icon-muted (4.58:1 dark, +// 5.74:1 light) not border-strong (≈ white@20% → #4C4C4C = 1.92:1, +// fails the 3:1 a UI mark needs). Live uses the brand yellow +// (--accent / --accent-edge, ink-ring defines the dot on light where +// #FFE614 is ~1.2:1). Line colour is uniform per turn — accent while +// running, icon-muted when done — not border-strong, so a running +// spine never reads half-yellow/half-grey; segments within a turn +// share one token. +// +// 6. shouldRenderRail is polish: a lone COMPLETED step renders nothing (one +// dot is decoration, not a sequence). A lone RUNNING step DOES rail — +// its dot is the turn's only "working" signal (card sig / Livedot are +// gone) and first steps are often the longest, so waiting for step two +// left the opening with no status. The lone live dot itself carries no +// line (first && last → 0px) so completion is just "dot fills" with no +// retraction. +// +// Other implementation: the harness StepFrame rail on PR #216 slices by SDK +// step-start/step-finish markers instead of assistant-part turns — different +// segmentation model, same geometry problem. Portable math if you need it: +// +// const NODE = 7, DOT_TOP = 7.5, GUTTER = 8, LINE_X = GUTTER + NODE/2 - 0.5 +// const STEP_GAP = "var(--space-3)", NEG = `calc(${STEP_GAP} * -1)` +// const dotCentre = DOT_TOP + NODE/2 +// // mid: { top: first ? dotCentre : NEG, bottom: 0 } +// // tail: { top: first ? "0px" : NEG, height: first ? "0px" : `calc(${STEP_GAP} + ${dotCentre}px)` } const NODE = 7 // dot diameter, px — matches the site's Step @@ -58,26 +104,28 @@ export function ThoughtRail(props: { running: boolean }) { // Only the tail of a still-running turn is hollow. Everything above it has, - // by definition, been succeeded. + // by definition, been succeeded. (Rule 4 — adjacency.) const isRunning = () => props.last && props.running + // Rule 3 — cap at dot centre; Rule 2 — NEG_STEP_GAP bridges the pt-3 gap. + const dotCentre = DOT_TOP + NODE / 2 return ( <>