From 4d7dbc07ba6731d272665d9061bfda75f4d0f54b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 16:51:25 -0400 Subject: [PATCH 01/27] =?UTF-8?q?fix(session-ui):=20cohesive=20segments=20?= =?UTF-8?q?=E2=80=94=20label=20heuristic=20+=20blank-card=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A short label (<40 chars ending with ':') no longer splits from its content — the blank line after it is not treated as a chunk boundary. This prevents orphaned single-line cards like 'Results:' appearing alone above their list. The whitespace-only chunk guard was already in splitSettledChunks; tests now explicitly verify it. Closes #632 --- .../src/components/message-part-text.ts | 9 +++++ .../src/components/message-part.test.ts | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/session-ui/src/components/message-part-text.ts b/packages/session-ui/src/components/message-part-text.ts index e2a52a366..f46462c76 100644 --- a/packages/session-ui/src/components/message-part-text.ts +++ b/packages/session-ui/src/components/message-part-text.ts @@ -14,6 +14,7 @@ export function readPartText(accum: Record | undefined, part: { const FENCE = /^\s{0,3}(```|~~~)/ const CONTINUATION = /^\s{0,3}([-*+]\s|\d{1,3}[.)]\s|>)|^\s{4,}\S/ +const LABEL_MAX = 40 /** Every settled chunk boundary of a streaming text, in order. Each value is * the char index where the NEXT chunk begins. Monotonic as the text grows. @@ -47,6 +48,14 @@ function chunkBoundaries(text: string): number[] { CONTINUATION.test(prevNonBlank) ) continue + // A short label (< 40 chars ending with `:`) stays glued to the content + // that follows — splitting here orphans the label in a tiny card. + if ( + prevNonBlank !== undefined && + prevNonBlank.length < LABEL_MAX && + prevNonBlank.trimEnd().endsWith(":") + ) + continue boundaries.push(candidate) offset = candidate i = j - 1 diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index 651d860ae..43b703903 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -56,10 +56,11 @@ describe("splitSettledChunks", () => { expect(tail).toBe("After the fence starts") }) - test("splits before a list's first item, never between its items", () => { + test("a label followed by a list keeps everything in one chunk, never splits between items", () => { const text = "Steps:\n\n1. one\n\n2. two\n\n> quoted\n\nNext paragraph beg" const { chunks, tail } = splitSettledChunks(text) - expect(chunks).toEqual(["Steps:\n\n", "1. one\n\n2. two\n\n> quoted\n\n"]) + // "Steps:" is a short label — stays glued; list items stay together via continuation guard + expect(chunks).toEqual(["Steps:\n\n1. one\n\n2. two\n\n> quoted\n\n"]) expect(tail).toBe("Next paragraph beg") }) @@ -67,4 +68,34 @@ describe("splitSettledChunks", () => { const text = "Done paragraph.\n\n" expect(settledChunkBoundary(text)).toBe(0) }) + + test("a short label (<40 chars ending with colon) stays with the following content", () => { + const text = "Results:\n\n- item 1\n- item 2\n\nNext paragraph" + const { chunks, tail } = splitSettledChunks(text) + // "Results:" is a label — stays glued to its content, no split after it + expect(chunks).toEqual(["Results:\n\n- item 1\n- item 2\n\n"]) + expect(tail).toBe("Next paragraph") + }) + + test("a short label followed by a paragraph stays in one chunk", () => { + const text = "Summary:\n\nThis is the summary text.\n\nAnother section begins" + const { chunks, tail } = splitSettledChunks(text) + expect(chunks).toEqual(["Summary:\n\nThis is the summary text.\n\n"]) + expect(tail).toBe("Another section begins") + }) + + test("a long line (>40 chars) ending with colon still splits normally", () => { + const text = "This is a complete sentence that happens to end with a colon:\n\nNext content" + const { chunks, tail } = splitSettledChunks(text) + // 62 chars — too long to be a label, splits normally + expect(chunks).toEqual(["This is a complete sentence that happens to end with a colon:\n\n"]) + expect(tail).toBe("Next content") + }) + + test("whitespace-only chunks are never emitted", () => { + // Even if boundaries produce a whitespace segment, it should be filtered + const text = "Before.\n\n \n\nAfter begins" + const { chunks, tail } = splitSettledChunks(text) + expect(chunks.every(c => c.trim() !== "")).toBe(true) + }) }) From f04b736c7a40c80f5a471e9895b5b83f1f9f1dd8 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 16:59:43 -0400 Subject: [PATCH 02/27] =?UTF-8?q?fix(app):=20persistent=20dot=20overlay=20?= =?UTF-8?q?=E2=80=94=20turn-spanning,=20step-boundary=20travel,=20determin?= =?UTF-8?q?istic=20offsets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist the running HarmonicDot from per-row inline rendering into a single persistent overlay element inside the virtual content container. The dot never remounts while a turn is running — one DOM element throughout. Key changes: - TurnOverlay in message-timeline.tsx: positioned absolutely in virtual space, top computed from virtualizer measurements of the last running row. Updates only on step boundaries (row count changes for the active turn), not per prose-fragment chunk. - dotCentreForGroup(): deterministic offsets (prose=21, tool_group=11, single_tool=16, thinking=11) replace the old ResizeObserver + TreeWalker measurement in TimelineRowFrame. - ThoughtRail renders null for the running dot path; done-dots remain per-row with deterministic centres. - CSS transitions updated to 250ms spring cubic-bezier(0.34, 1.56, 0.64, 1) with slight overshoot. prefers-reduced-motion disables the spring. Closes #630 --- packages/app/src/index.css | 12 +- .../session/timeline/message-timeline.tsx | 154 ++++++++++++------ .../session/timeline/thought-rail.test.ts | 16 +- .../pages/session/timeline/thought-rail.tsx | 28 ++-- 4 files changed, 147 insertions(+), 63 deletions(-) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4b23fe85c..c083b8577 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -387,12 +387,17 @@ class gates the transition so the initial mount uses the grow animation alone — no slide from top:0 to the first measured position. */ .thought-rail-dot--settled { - transition: top 150ms ease-out; + transition: top 250ms cubic-bezier(0.34, 1.56, 0.64, 1); +} +/* Turn overlay dot (#630): the persistent overlay uses the same spring timing + for its top transition on step boundaries. */ +.turn-overlay-dot--settled { + transition: top 250ms cubic-bezier(0.34, 1.56, 0.64, 1); } /* The rail line extends in sync with the dot — same timing so the spine grows as the dot descends. */ [data-slot="thought-rail-line"] { - transition: height 150ms ease-out; + transition: height 250ms cubic-bezier(0.34, 1.56, 0.64, 1); } @keyframes thought-rail-grow { from { @@ -415,6 +420,9 @@ .thought-rail-dot--settled { transition: none; } + .turn-overlay-dot--settled { + transition: none; + } [data-slot="thought-rail-line"] { transition: none; } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 4c7b8c095..53dcaac39 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -19,8 +19,9 @@ import { useMutation } from "@tanstack/solid-query" import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" import { AmicodeEntityRail } from "@opencode-ai/ui/amicode-entity-rail" -import { DEFAULT_DOT_CENTRE, ThoughtRail, ThoughtRailLabel, THOUGHT_RAIL_INSET, shouldRenderRail } from "./thought-rail" +import { DEFAULT_DOT_CENTRE, ThoughtRail, ThoughtRailLabel, THOUGHT_RAIL_INSET, shouldRenderRail, dotCentreForGroup } from "./thought-rail" import { formatElapsed, formatTokens, turnTokens } from "@opencode-ai/ui/amicode-thinking" +import { HarmonicDot, HARMONIC_SIZE } from "@opencode-ai/ui/amicode-harmonic-dot" import { AmicodeEntityView, entityLabel, @@ -1487,54 +1488,20 @@ export function MessageTimeline(props: { return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart } } - // The dot sits on the row's FIRST TEXT LINE, wherever the content puts it - // (Kate 2026-08-24: dots must line up with the text they coincide with). - // Prose and rail-label rows put it at the default 11px; rows that open - // with a card (a tool chip, a group header, a widget preview) start their - // first line lower by that card's own padding — measured, not tabulated, - // because the set of card species is open-ended. The ResizeObserver - // re-measures when async card content mounts (deferToolContent) or - // streaming reflows the row; observers exist only on rendered rows, so - // the count is bounded by the virtualizer's window. + // The dot sits on a deterministic centre per group type — no measurement + // needed. Prose=21px, tool groups=11px, single tools=16px, thinking=11px. + // Done-dots pass this to ThoughtRail; the running dot is in the overlay. let turnEl: HTMLDivElement | undefined - const [dotCentre, setDotCentre] = createSignal(DEFAULT_DOT_CENTRE) - const [dotSettled, setDotSettled] = createSignal(false) - const measureDotCentre = () => { - if (!turnEl || !rail()) return - const hostTop = turnEl.getBoundingClientRect().top - // Travelling dot (#265): ONLY the running dot tracks the last - // prose-fragment card. The done-dot stays at the first text line - // (top of the row) so the rail reads as a sequence of origin marks. - const r = rail() - const isRunning = r && r.last && r.running - const fragments = isRunning ? turnEl.querySelectorAll("[data-prose-fragment]") : undefined - const lastFragment = fragments && fragments.length > 0 ? (fragments[fragments.length - 1] as HTMLElement) : null - const target = lastFragment ?? turnEl - const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT) - let node: Node | null - while ((node = walker.nextNode())) { - if (!node.textContent?.trim()) continue - const range = document.createRange() - range.selectNodeContents(node) - const rect = range.getClientRects()[0] - if (!rect || rect.height === 0) continue - const centre = rect.top + rect.height / 2 - hostTop - // When targeting a fragment, the dot can be anywhere down the row - // (no ceiling). For non-fragment rows the 80px ceiling guards against - // mid-virtualisation nonsense measurements. - const maxCentre = lastFragment ? Infinity : 80 - if (centre > 0 && centre < maxCentre) setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2)) - if (!dotSettled()) setDotSettled(true) - return - } + const dotCentre = () => { + const row = input.row() + if (row._tag === "Thinking") return DEFAULT_DOT_CENTRE + if (row._tag !== "AssistantPart") return DEFAULT_DOT_CENTRE + if (row.group.type !== "part") return DEFAULT_DOT_CENTRE // context/shell/edit group → 11 + // Single part: check if it's a tool (16) or prose (21) + const part = getMsgPart(row.group.ref.messageID, row.group.ref.partID) + if (part?.type === "tool") return 16 + return 21 // text, reasoning } - onMount(() => { - if (!rail()) return - measureDotCentre() - const observer = new ResizeObserver(() => measureDotCentre()) - if (turnEl) observer.observe(turnEl) - onCleanup(() => observer.disconnect()) - }) return (
@@ -1580,6 +1546,80 @@ export function MessageTimeline(props: { ) } + // ── Turn Overlay (#630) ────────────────────────────────────────────── + // A single persistent HarmonicDot rendered once per running turn, outside + // the per-row virtualizer loop. Positioned absolutely in virtual content + // space; uses CSS sticky to stay visible near the viewport top during long + // responses. Repositions only on step boundaries (row count changes). + const overlayDotPosition = createMemo(() => { + const id = activeMessageID() + if (!id || sessionStatus().type === "idle") return undefined + // Find the last AssistantPart row for the running turn + const rows = timelineRows() + let lastIndex = -1 + let lastRow: TimelineRow.TimelineRow | undefined + for (let i = rows.length - 1; i >= 0; i--) { + const row = rows[i] + if (row.userMessageID !== id) continue + if (row._tag === "AssistantPart" && row.lastAssistantPart) { + lastIndex = i + lastRow = row + break + } + // If no assistant parts yet, target the Thinking row + if (row._tag === "Thinking" && lastIndex === -1) { + lastIndex = i + lastRow = row + } + } + if (lastIndex === -1 || !lastRow) return undefined + // Get the virtual measurement for this row + const measurement = virtualizer.measurementsCache[lastIndex] + if (!measurement) return undefined + // Deterministic dot centre based on group type + let centre = DEFAULT_DOT_CENTRE + if (lastRow._tag === "AssistantPart") { + if (lastRow.group.type !== "part") centre = DEFAULT_DOT_CENTRE + else { + const part = getMsgPart(lastRow.group.ref.messageID, lastRow.group.ref.partID) + centre = part?.type === "tool" ? 16 : 21 + } + } + return { + top: measurement.start - (showHeader() ? 64 : 0) + centre - HARMONIC_SIZE / 2, + turnStartedAt: "turnStartedAt" in lastRow ? (lastRow as any).turnStartedAt as number | undefined : undefined, + tokens: assistantTokensForTurn(id) || undefined, + } + }) + + // Track row count per active turn — dot moves only on step boundaries + const overlayStepKey = createMemo(() => { + const id = activeMessageID() + if (!id || sessionStatus().type === "idle") return "" + return `${id}:${timelineRows().filter(r => r.userMessageID === id).length}` + }) + const [overlayTop, setOverlayTop] = createSignal(undefined) + const [overlaySettled, setOverlaySettled] = createSignal(false) + createEffect( + on(overlayStepKey, () => { + const pos = overlayDotPosition() + if (pos) { + setOverlayTop(pos.top) + // After first position, enable the spring transition + if (!overlaySettled()) requestAnimationFrame(() => setOverlaySettled(true)) + } + }), + ) + // Reset settled state when the running turn changes + createEffect( + on(activeMessageID, () => { + setOverlaySettled(false) + }, { defer: true }), + ) + + // The line x position (must match thought-rail.tsx constants) + const OVERLAY_LINE_X = 8 + 7 / 2 - 0.5 // GUTTER + NODE/2 - 0.5 = 11 + const renderTimelineRow = (row: Accessor, onSizeChange?: () => void) => { switch (row()._tag) { case "TurnGap": @@ -2550,6 +2590,24 @@ export function MessageTimeline(props: { }} > {(rowKey) => } + {/* Turn overlay: single persistent dot for the running turn (#630) */} + + + 0}>
{ expect(steps.map((s) => s.last)).toEqual([false, false, true]) }) }) + +describe("dotCentreForGroup", () => { + test("returns deterministic offsets per group type", () => { + expect(dotCentreForGroup("prose")).toBe(21) + expect(dotCentreForGroup("tool_group")).toBe(11) + expect(dotCentreForGroup("single_tool")).toBe(16) + expect(dotCentreForGroup("thinking")).toBe(11) + }) + + test("returns default for unknown group types", () => { + expect(dotCentreForGroup("unknown")).toBe(11) + expect(dotCentreForGroup("")).toBe(11) + }) +}) diff --git a/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app/src/pages/session/timeline/thought-rail.tsx index 7213789d7..2903802ea 100644 --- a/packages/app/src/pages/session/timeline/thought-rail.tsx +++ b/packages/app/src/pages/session/timeline/thought-rail.tsx @@ -178,18 +178,10 @@ export function ThoughtRail(props: { }} /> {isRunning() ? ( - // RUNNING: spherical-harmonic morphing dot — 13px SVG centred on LINE_X. - // The grow animation (7→13px) is a CSS @keyframes on mount; the morph - // cycles Y_l^m silhouettes via SMIL; slow rotation via CSS on the . - // The settled class gates the top transition (#265): after the first - // measurement, subsequent dotCentre changes slide smoothly. - // Tooltip on hover shows elapsed time + token count (#625). - + // RUNNING: the overlay handles the live dot — render nothing here. + // The overlay is a single persistent element at the MessageTimeline + // level that transitions between rows on step boundaries (#630). + null ) : ( // DONE: 7px ink circle — the rail is one ink stroke (Rule 5). // text-base is the fg: literal black on light, near-white on dark, @@ -293,6 +285,18 @@ export function ThoughtRailLabel(props: { label: string }) { * Step has — pl-4 left a 1px gap and labels read as glued to their dots. (Rule 5) */ export const THOUGHT_RAIL_INSET = "pl-6" +/** Deterministic dot centre per group type — replaces the old ResizeObserver + * measurement. The offsets are the vertical centre of the first text line for + * each content species, measured once and tabulated. If CSS padding changes, + * update the one constant here. */ +export function dotCentreForGroup(groupType: string): number { + if (groupType === "prose") return 21 + if (groupType === "single_tool") return 16 + if (groupType === "tool_group") return 11 + if (groupType === "thinking") return 11 + return DEFAULT_DOT_CENTRE +} + /** * Rule 6 — lone COMPLETED steps render nothing (one dot is decoration). A * RUNNING turn rails from its very first step, though — the live dot is the From f8726a11eacb60d3f9ac0acbcc0a86000af48bbe Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 17:02:03 -0400 Subject: [PATCH 03/27] =?UTF-8?q?fix(app):=20smooth=20scroll=20=E2=80=94?= =?UTF-8?q?=20custom=20180ms=20RAF=20ease-out=20replacing=20instant=20tele?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New content arriving while anchored to bottom now animates with a custom 180ms ease-out cubic RAF loop instead of instant scrollToEnd(). Cancel on user scroll gesture (wheel, touchstart, pointerdown). prefers-reduced-motion disables smooth scroll (instant fallback). The smoothScrollToEnd helper is used for: - maybeAnchorBottom (new rows append while following) - anchorResizedBottom (row grows while following) - Jump to latest button Instant scroll preserved for: initial mount, scrollToIndex (reveal/history), and prepend-anchor (direct scrollTop writes). The pure interpolation function (smoothScrollInterpolate) is unit-tested. Closes #631 --- .../session/timeline/message-timeline.tsx | 65 ++++++++++++++++++- .../session/timeline/smooth-scroll.test.ts | 40 ++++++++++++ .../pages/session/timeline/smooth-scroll.ts | 15 +++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/pages/session/timeline/smooth-scroll.test.ts create mode 100644 packages/app/src/pages/session/timeline/smooth-scroll.ts diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 53dcaac39..4de331a6a 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -99,6 +99,7 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { sessionTitle } from "@/utils/session-title" import { scheduleConnectedMeasure } from "./measure" import { observeElementOffsetReconnectAware } from "./observe-element-offset" +import { smoothScrollInterpolate, SMOOTH_SCROLL_DURATION } from "./smooth-scroll" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" @@ -703,6 +704,64 @@ export function MessageTimeline(props: { ) }, }) + + // ── Smooth scroll (#631) ─────────────────────────────────────────────── + // Custom RAF loop for bottom-follow scrolls (180ms ease-out cubic). Instant + // scrollToEnd is used for mount, reveal, and prepend-anchor; smooth only for + // the "new content arrived while anchored" path. Cancels on user gesture. + let smoothFrame: number | undefined + let smoothStartY = 0 + let smoothStartTime = 0 + const prefersReducedMotion = typeof window !== "undefined" + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : undefined + + const cancelSmoothScroll = () => { + if (smoothFrame !== undefined) { + cancelAnimationFrame(smoothFrame) + smoothFrame = undefined + } + } + + const smoothScrollToEnd = () => { + const el = listRoot() + if (!el) { virtualizer.scrollToEnd(); return } + // Reduced motion: instant + if (prefersReducedMotion?.matches) { virtualizer.scrollToEnd(); return } + // Compute target (max scroll position = scrollHeight - clientHeight) + const target = el.scrollHeight - el.clientHeight + const current = el.scrollTop + if (Math.abs(target - current) < 2) return // already there + // If an animation is in flight, restart from current position + cancelSmoothScroll() + smoothStartY = current + smoothStartTime = performance.now() + const tick = (now: number) => { + const elapsed = now - smoothStartTime + const y = smoothScrollInterpolate(smoothStartY, target, elapsed, SMOOTH_SCROLL_DURATION) + el.scrollTop = y + if (elapsed < SMOOTH_SCROLL_DURATION) smoothFrame = requestAnimationFrame(tick) + else smoothFrame = undefined + } + smoothFrame = requestAnimationFrame(tick) + } + + // Cancel smooth scroll on user gesture (wheel, touch, pointer) + onMount(() => { + const el = listRoot() + if (!el) return + const cancel = () => cancelSmoothScroll() + el.addEventListener("wheel", cancel, { passive: true }) + el.addEventListener("touchstart", cancel, { passive: true }) + el.addEventListener("pointerdown", cancel) + onCleanup(() => { + el.removeEventListener("wheel", cancel) + el.removeEventListener("touchstart", cancel) + el.removeEventListener("pointerdown", cancel) + cancelSmoothScroll() + }) + }) + const resizeItem = virtualizer.resizeItem let resizeAnchorScheduled = false const anchorResizedBottom = () => { @@ -711,7 +770,7 @@ export function MessageTimeline(props: { queueMicrotask(() => { resizeAnchorScheduled = false if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return - virtualizer.scrollToEnd() + smoothScrollToEnd() }) } virtualizer.resizeItem = (index, size) => { @@ -820,7 +879,7 @@ export function MessageTimeline(props: { if (index === undefined) return virtualizer.scrollToIndex(index, { align: "center" }) }) - props.setScrollToEnd?.(() => virtualizer.scrollToEnd()) + props.setScrollToEnd?.(() => smoothScrollToEnd()) props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) }) @@ -844,7 +903,7 @@ export function MessageTimeline(props: { if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) clearPrependAnchor() if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) - virtualizer.scrollToEnd() + smoothScrollToEnd() } let measuredSessionKey = sessionKey() diff --git a/packages/app/src/pages/session/timeline/smooth-scroll.test.ts b/packages/app/src/pages/session/timeline/smooth-scroll.test.ts new file mode 100644 index 000000000..f59abc126 --- /dev/null +++ b/packages/app/src/pages/session/timeline/smooth-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { smoothScrollInterpolate } from "./smooth-scroll" + +describe("smoothScrollInterpolate", () => { + test("returns startY at elapsed=0", () => { + expect(smoothScrollInterpolate(100, 500, 0, 180)).toBe(100) + }) + + test("returns targetY when elapsed >= duration", () => { + expect(smoothScrollInterpolate(100, 500, 180, 180)).toBe(500) + expect(smoothScrollInterpolate(100, 500, 300, 180)).toBe(500) + }) + + test("returns intermediate value at 50% elapsed", () => { + const mid = smoothScrollInterpolate(0, 400, 90, 180) + // Ease-out cubic at t=0.5: 1 - (1 - 0.5)^3 = 1 - 0.125 = 0.875 + expect(mid).toBe(400 * 0.875) + }) + + test("is monotonically increasing for scrolling down", () => { + let prev = 0 + for (let t = 0; t <= 180; t += 10) { + const current = smoothScrollInterpolate(0, 1000, t, 180) + expect(current).toBeGreaterThanOrEqual(prev) + prev = current + } + }) + + test("handles scrolling up (target < start)", () => { + const result = smoothScrollInterpolate(500, 100, 180, 180) + expect(result).toBe(100) + const mid = smoothScrollInterpolate(500, 100, 90, 180) + expect(mid).toBeLessThan(500) + expect(mid).toBeGreaterThan(100) + }) + + test("clamps to targetY for very large elapsed values", () => { + expect(smoothScrollInterpolate(0, 1000, 99999, 180)).toBe(1000) + }) +}) diff --git a/packages/app/src/pages/session/timeline/smooth-scroll.ts b/packages/app/src/pages/session/timeline/smooth-scroll.ts new file mode 100644 index 000000000..5a8301370 --- /dev/null +++ b/packages/app/src/pages/session/timeline/smooth-scroll.ts @@ -0,0 +1,15 @@ +// Smooth scroll helper for the timeline (#631). +// Pure easing function — testable without DOM. The RAF loop that drives it +// lives in message-timeline.tsx; this module is the math. + +/** Ease-out cubic: fast start, gentle settle. `1 - (1 - t)^3` */ +export function smoothScrollInterpolate(startY: number, targetY: number, elapsed: number, duration: number): number { + if (elapsed >= duration) return targetY + const t = Math.min(elapsed / duration, 1) + const eased = 1 - (1 - t) ** 3 + return startY + (targetY - startY) * eased +} + +/** Duration for smooth-follow scrolls (ms). Kept short so the timeline feels + * responsive — native smooth-behavior is 500ms+ and browser-dependent. */ +export const SMOOTH_SCROLL_DURATION = 180 From df85128e1e195cc50379eb6a69388b3488abb03a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 17:04:40 -0400 Subject: [PATCH 04/27] fix(app): rail continuity + inset + crossfade Adds the visual polish layer on top of the persistent dot overlay: - Continuous rail line: a single absolutely-positioned in the overlay spans from the turn's first row (Thinking) to the dot position. No per-row seams during streaming. - Rail inset: md:pl-3 on the outer TimelineRowFrame wrapper for assistant rows, shifting the rail inside the composer's visible left boundary at md+ breakpoints. - Crossfade on turn completion: when session status transitions to idle, the overlay and its rail line fade to opacity:0 over 150ms (CSS transition on data-state='completed'). After the transition, the overlay DOM is removed and per-row done-dots (always rendered underneath) become visible. Rapid turn succession cancels any pending fade. - prefers-reduced-motion disables crossfade (instant removal). Closes #633 --- packages/app/src/index.css | 19 ++++ .../session/timeline/message-timeline.tsx | 91 +++++++++++++++---- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index c083b8577..82e6b833b 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -394,6 +394,19 @@ .turn-overlay-dot--settled { transition: top 250ms cubic-bezier(0.34, 1.56, 0.64, 1); } +/* Turn overlay crossfade (#633): on turn completion the overlay fades out over + 150ms and the per-row done-dots appear underneath. */ +[data-turn-overlay] { + opacity: 1; + transition: opacity 150ms ease-out; +} +[data-turn-overlay][data-state="completed"] { + opacity: 0; +} +/* The overlay's rail line transitions height in sync with the dot. */ +[data-turn-overlay] [data-slot="overlay-rail-line"] { + transition: height 250ms cubic-bezier(0.34, 1.56, 0.64, 1); +} /* The rail line extends in sync with the dot — same timing so the spine grows as the dot descends. */ [data-slot="thought-rail-line"] { @@ -423,6 +436,12 @@ .turn-overlay-dot--settled { transition: none; } + [data-turn-overlay] { + transition: none; + } + [data-turn-overlay] [data-slot="overlay-rail-line"] { + transition: none; + } [data-slot="thought-rail-line"] { transition: none; } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 4de331a6a..9263893f5 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1572,6 +1572,7 @@ export function MessageTimeline(props: { "md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:mx-auto": props.centered, "pt-3": previousAssistantPart(), + "md:pl-3": assistantPart(), }} >
{ const id = activeMessageID() if (!id || sessionStatus().type === "idle") return undefined - // Find the last AssistantPart row for the running turn + // Find the first and last rows for the running turn const rows = timelineRows() + let firstIndex = -1 let lastIndex = -1 let lastRow: TimelineRow.TimelineRow | undefined - for (let i = rows.length - 1; i >= 0; i--) { + for (let i = 0; i < rows.length; i++) { const row = rows[i] if (row.userMessageID !== id) continue - if (row._tag === "AssistantPart" && row.lastAssistantPart) { - lastIndex = i - lastRow = row - break - } - // If no assistant parts yet, target the Thinking row - if (row._tag === "Thinking" && lastIndex === -1) { - lastIndex = i - lastRow = row + if (row._tag === "Thinking" || row._tag === "AssistantPart") { + if (firstIndex === -1) firstIndex = i + if (row._tag === "Thinking") { + if (lastIndex === -1) { lastIndex = i; lastRow = row } + } + if (row._tag === "AssistantPart" && row.lastAssistantPart) { + lastIndex = i; lastRow = row + } } } if (lastIndex === -1 || !lastRow) return undefined - // Get the virtual measurement for this row - const measurement = virtualizer.measurementsCache[lastIndex] - if (!measurement) return undefined + // Get virtual measurements + const lastMeasurement = virtualizer.measurementsCache[lastIndex] + const firstMeasurement = firstIndex >= 0 ? virtualizer.measurementsCache[firstIndex] : undefined + if (!lastMeasurement) return undefined // Deterministic dot centre based on group type let centre = DEFAULT_DOT_CENTRE if (lastRow._tag === "AssistantPart") { @@ -1644,8 +1646,17 @@ export function MessageTimeline(props: { centre = part?.type === "tool" ? 16 : 21 } } + const headerOffset = showHeader() ? 64 : 0 + const dotTop = lastMeasurement.start - headerOffset + centre - HARMONIC_SIZE / 2 + // Rail line: from first row's dot centre to the current dot position + const firstTop = firstMeasurement + ? firstMeasurement.start - headerOffset + DEFAULT_DOT_CENTRE + : dotTop + HARMONIC_SIZE / 2 + const railHeight = Math.max(0, lastMeasurement.start - headerOffset + centre - firstTop) return { - top: measurement.start - (showHeader() ? 64 : 0) + centre - HARMONIC_SIZE / 2, + top: dotTop, + railTop: firstTop, + railHeight, turnStartedAt: "turnStartedAt" in lastRow ? (lastRow as any).turnStartedAt as number | undefined : undefined, tokens: assistantTokensForTurn(id) || undefined, } @@ -1658,21 +1669,48 @@ export function MessageTimeline(props: { return `${id}:${timelineRows().filter(r => r.userMessageID === id).length}` }) const [overlayTop, setOverlayTop] = createSignal(undefined) + const [overlayRailTop, setOverlayRailTop] = createSignal(undefined) + const [overlayRailHeight, setOverlayRailHeight] = createSignal(0) const [overlaySettled, setOverlaySettled] = createSignal(false) + const [overlayFading, setOverlayFading] = createSignal(false) + let overlayFadeTimer: ReturnType | undefined createEffect( on(overlayStepKey, () => { const pos = overlayDotPosition() if (pos) { setOverlayTop(pos.top) + setOverlayRailTop(pos.railTop) + setOverlayRailHeight(pos.railHeight) // After first position, enable the spring transition if (!overlaySettled()) requestAnimationFrame(() => setOverlaySettled(true)) } }), ) - // Reset settled state when the running turn changes + // Crossfade (#633): when the turn completes, fade the overlay out over 150ms + // then remove it. Done-dots are always rendered per-row underneath — they + // become visible as the overlay's opacity drops. + createEffect( + on(() => sessionStatus().type, (status, prev) => { + if (prev !== "idle" && status === "idle" && overlayTop() !== undefined) { + // Turn just completed — start fade + setOverlayFading(true) + if (overlayFadeTimer) clearTimeout(overlayFadeTimer) + overlayFadeTimer = setTimeout(() => { + setOverlayTop(undefined) + setOverlayRailTop(undefined) + setOverlayRailHeight(0) + setOverlayFading(false) + overlayFadeTimer = undefined + }, 150) + } + }, { defer: true }), + ) + // Reset settled state and cancel any pending fade when a new turn starts createEffect( on(activeMessageID, () => { setOverlaySettled(false) + if (overlayFadeTimer) { clearTimeout(overlayFadeTimer); overlayFadeTimer = undefined } + setOverlayFading(false) }, { defer: true }), ) @@ -2649,10 +2687,29 @@ export function MessageTimeline(props: { }} > {(rowKey) => } - {/* Turn overlay: single persistent dot for the running turn (#630) */} + {/* Turn overlay: persistent dot + continuous rail line (#630, #633) */} + {/* Continuous rail line for the active turn — one element, no per-row seams */} + 0}> + + {/* The dot */}