From 0d4359ce89d122c06e9f5709c962231dd3ed354c Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 24 Aug 2026 21:01:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(timeline):=20blocks=20land=20whole,=20with?= =?UTF-8?q?=20a=20crisp=20entrance=20=E2=80=94=20no=20typing=20reveal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one grammar (the site demo's): each step of a turn appears as a COMPLETE block, and its appearance is animated. Withholding: a text/reasoning part that is still streaming — the tail of the last assistant message with no time.end — is kept out of the timeline; the Thinking row is the working signal while it composes, and the finished block enters whole. A part with a successor is complete by definition, so only the tail is ever withheld; a done turn withholds nothing, so history renders exactly as before. In showReasoning mode Thinking now also stands in while the tail is withheld (previously it hid once any part existed). Entrance: a row that JOINS the projection after the session's first paint animates in once — 4px rise + fade, 0.18s ease-out (--motion-enter-* tokens in design-polish.css). Virtual rows remount on every scroll-back, so mount alone never triggers it: a per-session key set, seeded synchronously with the whole history by the first mount after a session switch, decides each key exactly once. Animation is opacity+transform on the measured inner div only — the virtualizer owns row position and height. The row clip-margin grows 4px→8px to cover ring + rise. Reduced motion keeps the fade, drops the rise. rows-current.test.ts updated to the new spec: a streaming tail yields Thinking, not a half-streamed part row (the error-removal test's intent — no stale Error row once the turn resumes — is unchanged). Verified live on :3004 against :4096, dark, with a real streamed turn: 36/48 poll samples show Thinking with the prose withheld; the block's first DOM appearance already carries animation timeline-enter; turn-gap, bubble, Thinking, and prose each animated exactly once; zero pre-existing rows animated on load or after a scroll roundtrip. tsgo -b clean, oxlint baseline, timeline tests 37/37. --- packages/app/src/design-polish.css | 30 ++++++++++++++++ .../session/timeline/message-timeline.tsx | 36 ++++++++++++++++--- .../session/timeline/rows-current.test.ts | 10 ++++-- .../app/src/pages/session/timeline/rows.ts | 28 ++++++++++++--- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 681e37026..d4c1a77ab 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -150,6 +150,36 @@ } } +/* ── entrance motion — timeline blocks land whole ── + New timeline rows (Kate 2026-08-24) enter as complete blocks: a small rise + and fade, fast and crisp, decided per row-key exactly once (the timeline + guards against virtual-row remount replays). Opacity+transform only — the + virtualizer owns row position and measured height, so entrance motion must + never touch layout. Reduced motion keeps the fade, drops the rise. */ +:root { + --motion-enter-duration: 0.18s; + --motion-enter-rise: 4px; + --motion-enter-ease: cubic-bezier(0.215, 0.61, 0.355, 1); +} +[data-timeline-enter] { + animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) both; +} +@keyframes timeline-enter { + from { + opacity: 0; + transform: translateY(var(--motion-enter-rise)); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@media (prefers-reduced-motion: reduce) { + :root { + --motion-enter-rise: 0px; + } +} + /* ── inline code: readable in both schemes ── */ :not(pre) > code { color: #3d4451; diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2d5fc0378..92c84fd3f 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -458,6 +458,29 @@ export function MessageTimeline(props: { const timelineRowByKey = projection.rowByKey const timelineRows = projection.rows + // Entrance bookkeeping (Kate 2026-08-24: blocks animate in whole, crisply). + // A row animates only when it JOINS the projection after the session's first + // paint, and only once — virtual rows unmount and remount on every + // scroll-back, so mount alone must never trigger the entrance. The first + // mounted row after a session switch seeds the set with the whole history + // (synchronously, so no render of stale rows can slip in between); a key + // never seen before animates and is recorded. Rows that join off-window + // animate on their first scroll into view — still exactly once. + let enteredFor: string | undefined + const enteredKeys = new Set() + const shouldAnimateEnter = (rowKey: string) => { + const sid = sessionID() + if (enteredFor !== sid) { + enteredFor = sid + enteredKeys.clear() + for (const row of timelineRows()) enteredKeys.add(TimelineRow.key(row)) + return false + } + if (enteredKeys.has(rowKey)) return false + enteredKeys.add(rowKey) + return true + } + let prependAnchor: { key: string; offset: number } | undefined let prependAnchorFrame: number | undefined let prependLoading = false @@ -1583,6 +1606,8 @@ export function MessageTimeline(props: { let element: HTMLDivElement const initialItem = virtualItemByKey().get(props.rowKey)! const initialRow = timelineRowByKey().get(props.rowKey)! + // Decided once at creation — remounts of an already-entered row get false. + const animateEnter = shouldAnimateEnter(props.rowKey) const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) const tool = () => { @@ -1622,11 +1647,11 @@ export function MessageTimeline(props: { height: `${item().size}px`, overflow: "clip", // Rounded virtual measurements can otherwise clip a framed row's outer paint. - // 4px, not 0.5px: the live rail dot breathes by a 0→4px ring - // (index.css thought-rail-breathe) and the old margin clipped the - // whole animation away — found in PR #246's testing. Keep in step - // with the ring size there. - "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "4px", + // 8px, not 0.5px: the live rail dot breathes by a 0→4px ring + // (index.css thought-rail-breathe; found clipped in PR #246's + // testing), and an entering row rides a --motion-enter-rise + // translate on top of it — the margin must cover ring + rise. + "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "8px", }} >
{ ) expect(result.activeMessageID).toBe("msg_3") + // msg_4's reasoning is the busy turn's streaming tail (no time.end) — it + // is withheld until it completes (blocks land whole), so Thinking stands + // in as the working signal. expect(result.rows.map(TimelineRow.key)).toEqual([ "user-message:msg_1", "assistant-part:msg_1:msg_2:text:0", "turn-gap:msg_3", "user-message:msg_3", - "assistant-part:msg_3:msg_4:reasoning:0", + "thinking:msg_3", ]) }) @@ -202,6 +205,9 @@ describe("current session timeline rows", () => { normalized.messages.filter((message) => message.role === "user"), ) - expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) + // The stale error row must not appear once the turn resumes. The resumed + // text is the streaming tail (no time.end) so it is withheld until it + // completes — Thinking, not the half-streamed part, is what renders. + expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking"]) }) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 0a59e3cec..a39f983b4 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -127,24 +127,41 @@ export namespace Timeline { .filter((part) => renderable(part, showReasoning)) .map((part) => ({ messageID: message.id, messageIndex, part })), ) + // Steps land as FULL blocks (Kate 2026-08-24: no typing reveal — the site + // demo's grammar, each step appears complete). A text/reasoning part that + // is still streaming — the tail of the last assistant message with no + // time.end yet — is withheld from the timeline; the Thinking row is the + // working signal while it composes, and the finished block enters whole. + // A part with a successor is complete by definition, so only the tail can + // ever be withheld, and a done turn (status not busy) withholds nothing. + const tail = assistantPartRefs.at(-1) + const tailStreaming = + isActive && + status === "busy" && + !error && + tail !== undefined && + tail.messageIndex === assistantMessages.length - 1 && + (tail.part.type === "text" || tail.part.type === "reasoning") && + !tail.part.time?.end + const settledPartRefs = tailStreaming ? assistantPartRefs.slice(0, -1) : assistantPartRefs const assistantItems = interrupted && !compaction ? [ - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( + ...groupParts(settledPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( (group) => ({ type: "part" as const, group, }), ), { type: "interrupted" as const }, - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( + ...groupParts(settledPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( (group) => ({ type: "part" as const, group, }), ), ] - : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) + : groupParts(settledPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) if (comments.length > 0 && !inlineComments) @@ -217,7 +234,10 @@ export namespace Timeline { assistantGroupIndex += 1 }) - if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { + // In showReasoning mode the reasoning rows themselves carry the working + // signal — except while the tail is withheld (streaming), when Thinking + // must stand in for it or the turn would show nothing at all. + if (isActive && status === "busy" && !error && (showReasoning ? settledPartRefs.length === 0 || tailStreaming : true)) { const heading = assistantMessages .flatMap((message) => getMessageParts(message.id)) .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))