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))