From aecfd056ae5076cdf2ccc49fd5fc9098ba881f79 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 14:11:38 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20TDD=20foundation=20=E2=80=94=20?= =?UTF-8?q?chunk=20boundaries,=20smooth=20scroll,=20dot=20offsets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure logic modules for #628 chat UI polish: - chunk-boundaries.ts: markdown text segmentation with label heuristic (<40 chars + colon suppresses split), list-to-list guard, heading coherence - smooth-scroll.ts: custom RAF-based 180ms ease-out scroller with cancel(), instant fallback for reduced-motion - dot-offsets.ts: deterministic vertical centre by row group type (prose=21, tool-group=11, single-tool=16, thinking=11) All three modules are DOM-free and fully tested (34 tests, 0 failures). --- .../session/timeline/dot-offsets.test.ts | 33 ++++ .../src/pages/session/timeline/dot-offsets.ts | 33 ++++ .../session/timeline/smooth-scroll.test.ts | 142 ++++++++++++++++ .../pages/session/timeline/smooth-scroll.ts | 124 ++++++++++++++ .../src/components/chunk-boundaries.test.ts | 156 ++++++++++++++++++ .../src/components/chunk-boundaries.ts | 136 +++++++++++++++ 6 files changed, 624 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.ts create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.test.ts create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.ts diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.test.ts new file mode 100644 index 00000000..1d59f41b --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { dotCentreForGroup, type TimelineGroupType } from "./dot-offsets" + +describe("dotCentreForGroup", () => { + test("prose group returns 21px", () => { + expect(dotCentreForGroup("prose")).toBe(21) + }) + + test("tool-group returns 11px", () => { + expect(dotCentreForGroup("tool-group")).toBe(11) + }) + + test("single-tool returns 16px", () => { + expect(dotCentreForGroup("single-tool")).toBe(16) + }) + + test("thinking returns 11px", () => { + expect(dotCentreForGroup("thinking")).toBe(11) + }) + + test("unknown type falls back to prose offset", () => { + expect(dotCentreForGroup("unknown" as TimelineGroupType)).toBe(21) + }) + + test("all offsets are positive numbers", () => { + const types: TimelineGroupType[] = ["prose", "tool-group", "single-tool", "thinking"] + for (const t of types) { + const offset = dotCentreForGroup(t) + expect(offset).toBeGreaterThan(0) + expect(typeof offset).toBe("number") + } + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.ts new file mode 100644 index 00000000..73228f36 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/dot-offsets.ts @@ -0,0 +1,33 @@ +/** + * Deterministic dot-centre offsets by timeline group type. + * + * Replaces the TreeWalker-based measurement that suffered from race conditions + * when virtualizer rows mount/unmount during streaming. Each offset is the + * vertical centre (in px from the row's top edge) where the rail dot should + * sit for that row type. + * + * If CSS padding changes, update the constants here — one file, one source. + */ + +export type TimelineGroupType = "prose" | "tool-group" | "single-tool" | "thinking" + +const OFFSETS: Record = { + /** Text content: accounts for card padding-top (12px) + half line-height (~18px) */ + prose: 21, + /** Collapsed tool group accordion: the header's vertical centre */ + "tool-group": 11, + /** A single tool card (not in a group): header centre */ + "single-tool": 16, + /** Thinking row: wave indicator's vertical centre */ + thinking: 11, +} + +const DEFAULT_OFFSET = OFFSETS.prose + +/** + * Returns the vertical centre offset (px from row top) for the rail dot + * given the type of timeline row group. + */ +export function dotCentreForGroup(type: TimelineGroupType): number { + return OFFSETS[type] ?? DEFAULT_OFFSET +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.test.ts new file mode 100644 index 00000000..f9c7b6e5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test" +import { createSmoothScroller, type SmoothScrollerOptions } from "./smooth-scroll" + +// Mock RAF for deterministic testing +let rafCallbacks: ((time: number) => void)[] = [] +let rafId = 0 +const mockRaf = (cb: (time: number) => void) => { + rafCallbacks.push(cb) + return ++rafId +} +const mockCancelRaf = (_id: number) => { + // In tests we just clear all — sufficient for our use +} + +function flushRaf(time: number) { + const cbs = [...rafCallbacks] + rafCallbacks = [] + for (const cb of cbs) cb(time) +} + +describe("createSmoothScroller", () => { + let element: { scrollTop: number; scrollHeight: number; clientHeight: number } + + beforeEach(() => { + rafCallbacks = [] + rafId = 0 + element = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 } + }) + + test("scrolls to bottom in ~180ms using ease-out", () => { + const scroller = createSmoothScroller({ + getElement: () => element as any, + duration: 180, + requestAnimationFrame: mockRaf as any, + cancelAnimationFrame: mockCancelRaf, + }) + + element.scrollTop = 500 + scroller.scrollToEnd() + + // First frame establishes startTime (no movement yet) + flushRaf(0) + expect(element.scrollTop).toBe(500) + + // Mid-animation — should have moved + flushRaf(90) + expect(element.scrollTop).toBeGreaterThan(500) + expect(element.scrollTop).toBeLessThan(800) + + // After full duration — should be at the end + flushRaf(180) + expect(element.scrollTop).toBe(800) // scrollHeight - clientHeight + }) + + test("cancel() stops the animation", () => { + const scroller = createSmoothScroller({ + getElement: () => element as any, + duration: 180, + requestAnimationFrame: mockRaf as any, + cancelAnimationFrame: mockCancelRaf, + }) + + element.scrollTop = 0 + scroller.scrollToEnd() + flushRaf(0) // start + const posAfterStart = element.scrollTop + + scroller.cancel() + flushRaf(90) // should do nothing — cancelled + + expect(element.scrollTop).toBe(posAfterStart) + }) + + test("isAnimating() returns true while scrolling", () => { + const scroller = createSmoothScroller({ + getElement: () => element as any, + duration: 180, + requestAnimationFrame: mockRaf as any, + cancelAnimationFrame: mockCancelRaf, + }) + + expect(scroller.isAnimating()).toBe(false) + scroller.scrollToEnd() + expect(scroller.isAnimating()).toBe(true) + + // First frame — still animating + flushRaf(0) + expect(scroller.isAnimating()).toBe(true) + + // Complete + flushRaf(180) + expect(scroller.isAnimating()).toBe(false) + }) + + test("calling scrollToEnd during animation updates target without restarting", () => { + const scroller = createSmoothScroller({ + getElement: () => element as any, + duration: 180, + requestAnimationFrame: mockRaf as any, + cancelAnimationFrame: mockCancelRaf, + }) + + element.scrollTop = 0 + scroller.scrollToEnd() + flushRaf(0) + + // Content grows — target changes + element.scrollHeight = 1200 + scroller.scrollToEnd() + + flushRaf(180) // complete + expect(element.scrollTop).toBe(1000) // new scrollHeight - clientHeight + }) + + test("does nothing if already at bottom", () => { + const scroller = createSmoothScroller({ + getElement: () => element as any, + duration: 180, + requestAnimationFrame: mockRaf as any, + cancelAnimationFrame: mockCancelRaf, + }) + + element.scrollTop = 800 // already at bottom + scroller.scrollToEnd() + expect(scroller.isAnimating()).toBe(false) + }) + + test("instant mode sets scrollTop directly (for reduced-motion)", () => { + const scroller = createSmoothScroller({ + getElement: () => element as any, + duration: 180, + requestAnimationFrame: mockRaf as any, + cancelAnimationFrame: mockCancelRaf, + reducedMotion: true, + }) + + element.scrollTop = 0 + scroller.scrollToEnd() + expect(element.scrollTop).toBe(800) // instant + expect(scroller.isAnimating()).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.ts new file mode 100644 index 00000000..c782bebc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/smooth-scroll.ts @@ -0,0 +1,124 @@ +/** + * Custom RAF-based smooth scroll — 180ms ease-out, cancels on user gesture. + * + * Replaces browser-native `scrollBehavior: 'smooth'` which is 500ms+ and + * browser-dependent. This gives us snappy, consistent control. + * + * Contract: + * - scrollToEnd() starts or retargets an animation toward scrollHeight - clientHeight + * - cancel() stops mid-animation (called on user scroll gesture) + * - isAnimating() — true while a scroll animation is in flight + * - Under prefers-reduced-motion, scrollToEnd() is instant (no RAF) + */ + +export interface SmoothScrollerOptions { + getElement: () => HTMLElement | null | undefined + /** Animation duration in ms (default 180) */ + duration?: number + /** Override for testability */ + requestAnimationFrame?: typeof globalThis.requestAnimationFrame + cancelAnimationFrame?: typeof globalThis.cancelAnimationFrame + /** When true, all scrolls are instant (prefers-reduced-motion) */ + reducedMotion?: boolean +} + +export interface SmoothScroller { + scrollToEnd(): void + cancel(): void + isAnimating(): boolean +} + +/** Ease-out cubic: decelerating to zero velocity */ +function easeOut(t: number): number { + return 1 - Math.pow(1 - t, 3) +} + +export function createSmoothScroller(options: SmoothScrollerOptions): SmoothScroller { + const { + getElement, + duration = 180, + requestAnimationFrame: raf = globalThis.requestAnimationFrame, + cancelAnimationFrame: cancelRaf = globalThis.cancelAnimationFrame, + reducedMotion = false, + } = options + + let animating = false + let frameId: number | undefined + let startTime: number | undefined + let startScroll: number | undefined + + function getTarget(el: HTMLElement): number { + return el.scrollHeight - el.clientHeight + } + + function cancel() { + if (frameId !== undefined) { + cancelRaf(frameId) + frameId = undefined + } + animating = false + startTime = undefined + startScroll = undefined + } + + function scrollToEnd() { + const el = getElement() + if (!el) return + + const target = getTarget(el) + const current = el.scrollTop + + // Already at bottom (within 1px tolerance) + if (Math.abs(target - current) < 1) { + cancel() + return + } + + // Instant mode for reduced motion + if (reducedMotion) { + el.scrollTop = target + return + } + + // If already animating, just update target (retarget) — the loop reads + // getTarget() each frame, so we don't need to restart + if (animating) return + + animating = true + startScroll = current + startTime = undefined + + function step(time: number) { + if (!animating) return + const el = getElement() + if (!el) { cancel(); return } + + if (startTime === undefined) startTime = time + const elapsed = time - startTime + const progress = Math.min(elapsed / duration, 1) + const easedProgress = easeOut(progress) + + const target = getTarget(el) + const distance = target - startScroll! + el.scrollTop = startScroll! + distance * easedProgress + + if (progress >= 1) { + el.scrollTop = target + animating = false + frameId = undefined + startTime = undefined + startScroll = undefined + } else { + frameId = raf(step) + } + } + + frameId = raf(step) + } + + function isAnimating() { + return animating + } + + return { scrollToEnd, cancel, isAnimating } +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.test.ts new file mode 100644 index 00000000..ade3d151 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" +import { chunkBoundaries } from "./chunk-boundaries" + +describe("chunkBoundaries", () => { + describe("basic splitting", () => { + test("returns no boundaries for a single paragraph", () => { + const text = "Hello world, this is a simple paragraph." + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("splits on double newline between paragraphs", () => { + const text = "First paragraph.\n\nSecond paragraph." + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBe(1) + // boundary points to start of second chunk; slice before it includes trailing whitespace + expect(text.slice(0, boundaries[0]).trim()).toBe("First paragraph.") + expect(text.slice(boundaries[0])).toBe("Second paragraph.") + }) + + test("returns empty for empty text", () => { + expect(chunkBoundaries("")).toEqual([]) + }) + + test("returns empty for whitespace-only text", () => { + expect(chunkBoundaries(" \n\n ")).toEqual([]) + }) + }) + + describe("list coherence (existing guard)", () => { + test("consecutive list items stay in one chunk", () => { + const text = "- item one\n- item two\n- item three" + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("consecutive numbered list items stay in one chunk", () => { + const text = "1. first\n2. second\n3. third" + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("list block preceded by blank line splits from prior prose", () => { + const text = "Some intro text.\n\n- item one\n- item two" + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBe(1) + expect(text.slice(0, boundaries[0]).trim()).toBe("Some intro text.") + }) + + test("two separate lists split from each other", () => { + const text = "- item a\n- item b\n\n- item c\n- item d" + // list-to-list guard: do NOT split between two list blocks + expect(chunkBoundaries(text)).toEqual([]) + }) + }) + + describe("label heuristic (<40 chars ending with colon)", () => { + test("short label followed by list stays in one card", () => { + const text = "Key Decisions:\n\n- Use sticky overlay\n- Track per-row" + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("short label followed by paragraph stays in one card", () => { + const text = "Summary:\n\nThe optimization converged in 137 iterations." + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("long line ending with colon DOES split (>=40 chars)", () => { + const text = "This is a long sentence that explains things in detail:\n\n- item one" + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBe(1) + }) + + test("short label with content in between", () => { + const text = "Files:\n\n| File | Changes |\n|------|---------|" + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("label heuristic doesn't trigger on mid-sentence colons", () => { + // "Note: this is important" is 24 chars and ends with a colon... + // but it's a complete sentence, not a label. The length check + colon + // at END of line catches it. The text after the blank line should split. + const text = "Note: this is important.\n\nA separate thought here." + // "Note: this is important." is < 40 chars and ends with "." not ":" + // so this SHOULD split normally + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBe(1) + }) + + test("actual label pattern — text ending in colon at line end", () => { + const text = "Acceptance Criteria:\n\n- [ ] First criterion\n- [ ] Second" + expect(chunkBoundaries(text)).toEqual([]) + }) + }) + + describe("blockquote coherence", () => { + test("consecutive blockquotes stay in one chunk", () => { + const text = "> line one\n> line two\n> line three" + expect(chunkBoundaries(text)).toEqual([]) + }) + + test("blockquote followed by paragraph splits", () => { + const text = "> a quote\n\nSome prose after." + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBe(1) + }) + }) + + describe("no empty chunks", () => { + test("multiple blank lines don't produce empty chunks", () => { + const text = "Hello.\n\n\n\nWorld." + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBe(1) + expect(text.slice(0, boundaries[0]).trim()).toBe("Hello.") + expect(text.slice(boundaries[0]).trim()).toBe("World.") + }) + + test("trailing blank lines don't produce an empty tail chunk", () => { + const text = "Content here.\n\n" + expect(chunkBoundaries(text)).toEqual([]) + }) + }) + + describe("monotonicity (streaming contract)", () => { + test("boundaries are monotonically increasing", () => { + const text = "First.\n\nSecond.\n\nThird." + const boundaries = chunkBoundaries(text) + for (let i = 1; i < boundaries.length; i++) { + expect(boundaries[i]).toBeGreaterThan(boundaries[i - 1]) + } + }) + + test("extending text only appends boundaries, never removes", () => { + const partial = "First.\n\nSecond." + const full = "First.\n\nSecond.\n\nThird." + const partialBoundaries = chunkBoundaries(partial) + const fullBoundaries = chunkBoundaries(full) + // All boundaries from partial must appear in full + for (const b of partialBoundaries) { + expect(fullBoundaries).toContain(b) + } + }) + }) + + describe("heading splits", () => { + test("heading after prose creates a boundary", () => { + const text = "Some intro.\n\n## Section One\n\nContent here." + const boundaries = chunkBoundaries(text) + expect(boundaries.length).toBeGreaterThanOrEqual(1) + expect(text.slice(0, boundaries[0]).trim()).toBe("Some intro.") + }) + + test("heading as first element creates no leading boundary", () => { + const text = "## Title\n\nSome content." + // No split needed — heading + content is one chunk + expect(chunkBoundaries(text)).toEqual([]) + }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.ts new file mode 100644 index 00000000..fa4ecd4f --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/chunk-boundaries.ts @@ -0,0 +1,136 @@ +/** + * chunkBoundaries — split settled markdown text into visual card boundaries. + * + * Returns an array of character offsets where the text should be split into + * separate visual chunks (bordered cards). The boundaries are monotonically + * increasing and represent positions AFTER which a new chunk begins. + * + * Rules: + * 1. Split on blank lines (double newline) between content blocks + * 2. Never split inside a list (consecutive list items stay together) + * 3. Never split between two adjacent list blocks (list-to-list guard) + * 4. Label heuristic: if the last non-blank line before a split is <40 chars + * and ends with ":", suppress the split (keeps labels with their content) + * 5. Never produce empty chunks (skip splits that would leave only whitespace) + * 6. Monotonicity: once a boundary is emitted, it never un-emits (streaming safe) + */ +export function chunkBoundaries(text: string): number[] { + if (!text.trim()) return [] + + const boundaries: number[] = [] + + // Collapse runs of blank lines into distinct split candidates. + // A "split region" is one or more consecutive blank lines between content. + // We emit at most one boundary per region: at the afterOffset of the LAST + // blank line in the run (so the offset points to the first content char). + const splitRegions = findSplitRegions(text) + + if (splitRegions.length === 0) return [] + + for (const split of splitRegions) { + const before = text.slice(0, split.contentEnd) + const after = text.slice(split.afterOffset) + + // Rule 5: no empty chunks — skip if either side would be whitespace-only + if (!before.trim() || !after.trim()) continue + + // Rule 7: heading-at-start — if all content before the split is a single heading, + // keep it together with its body (headings introduce, they don't stand alone) + if (isHeadingOnly(before)) continue + + // Rule 2 & 3: list coherence + const beforeLines = before.split("\n") + const afterLines = after.split("\n") + const lastNonBlankBefore = findLastNonBlank(beforeLines) + const firstNonBlankAfter = findFirstNonBlank(afterLines) + + if (lastNonBlankBefore === undefined || firstNonBlankAfter === undefined) continue + + const beforeIsList = isListLine(lastNonBlankBefore) + const afterIsList = isListLine(firstNonBlankAfter) + + // List-to-list guard: don't split between two list blocks + if (beforeIsList && afterIsList) continue + + // Rule 4: label heuristic — short line ending with ":" suppresses the split + if (isLabelLine(lastNonBlankBefore)) continue + + boundaries.push(split.afterOffset) + } + + return boundaries +} + +/** Find distinct split regions: collapsed runs of blank lines */ +function findSplitRegions(text: string): { contentEnd: number; afterOffset: number }[] { + const regions: { contentEnd: number; afterOffset: number }[] = [] + // Match one or more blank lines (sequences of \n with only whitespace between) + const blankRunPattern = /(\n[ \t]*){2,}/g + let match: RegExpExecArray | null + while ((match = blankRunPattern.exec(text)) !== null) { + regions.push({ + contentEnd: match.index, // end of content before the blank run + afterOffset: match.index + match[0].length, // first content char after + }) + } + return regions +} + +/** True if the text is ONLY a heading line (possibly with leading whitespace) */ +function isHeadingOnly(text: string): boolean { + const trimmed = text.trim() + // Must be a single line that's a markdown heading + if (trimmed.includes("\n")) return false + return /^#{1,6}\s/.test(trimmed) +} + +/** Check if a line is a list item (unordered or ordered) */ +function isListLine(line: string): boolean { + const trimmed = line.trimStart() + // Unordered: starts with -, *, + + if (/^[-*+]\s/.test(trimmed)) return true + // Ordered: starts with number followed by . or ) + if (/^\d+[.)]\s/.test(trimmed)) return true + // Checkbox list + if (/^[-*+]\s\[[ x]\]/i.test(trimmed)) return true + return false +} + +/** Check if a line is a label (< 40 chars and ends with ":") */ +function isLabelLine(line: string): boolean { + const trimmed = line.trim() + return trimmed.length < 40 && /:\s*$/.test(trimmed) +} + +/** Find the last non-blank line in an array */ +function findLastNonBlank(lines: string[]): string | undefined { + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim()) return lines[i] + } + return undefined +} + +/** Find the first non-blank line in an array */ +function findFirstNonBlank(lines: string[]): string | undefined { + for (const line of lines) { + if (line.trim()) return line + } + return undefined +} + +/** + * Split text at the given boundaries into chunks. + * Convenience function for consumers that want the string[] result. + */ +export function splitAtBoundaries(text: string, boundaries: number[]): string[] { + if (boundaries.length === 0) return [text] + const chunks: string[] = [] + let start = 0 + for (const b of boundaries) { + chunks.push(text.slice(start, b)) + start = b + } + chunks.push(text.slice(start)) + // Filter out empty/whitespace-only chunks + return chunks.filter((c) => c.trim()) +} From bbd9bc9414c9cc8661d436f559a80bc40e11704d Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 14:15:49 -0400 Subject: [PATCH 2/3] feat(ui): wire chat UI polish into components (#628) - ChunkedMarkdown: settled text renders as multiple bordered cards using chunkBoundaries (streaming stays single-block for stability) - Smooth scroll: anchorResizedBottom uses 180ms ease-out RAF scroller; cancelled immediately on user scroll gesture - Rail alignment: md:pl-3 on TimelineRowFrame pushes content inside the composer's visible left boundary - ThoughtRailOverlay: persistent AmicoWave dot in a sticky container, spanning the active turn; 250ms spring animation on step transitions - CSS: thought-rail spring bezier, crossfade on settle, reduced-motion fallbacks disable all animations - Re-export AmicoWave from @opencode-ai/ui/amicode-thinking --- .../packages/app/src/design-polish.css | 47 ++++++++ .../session/timeline/message-timeline.tsx | 15 ++- .../pages/session/timeline/thought-rail.tsx | 107 ++++++++++++++++++ .../src/components/chunked-markdown.tsx | 51 +++++++++ .../src/components/message-part.tsx | 3 +- .../ui/src/components/amicode-thinking.tsx | 1 + 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/timeline/thought-rail.tsx create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/chunked-markdown.tsx diff --git a/packages/app-bundle/overlay/packages/app/src/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index d927050f..298c3e54 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -301,3 +301,50 @@ span[data-component="tag"][data-variant="accent"] { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } + +/* ============================================================ + THOUGHT RAIL — persistent dot overlay (#628) + ============================================================ */ + +/* The continuous rail line running alongside the active turn */ +.thought-rail-line { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 35%, transparent); + border-radius: var(--radius-full); + transition: height 250ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* The sticky dot container — spring animation on position change */ +.thought-rail-dot { + transition: top 250ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* The wave inside the dot — accent color, sized for the rail */ +.thought-rail-wave { + width: 14px; + height: 14px; +} + +/* Crossfade on turn completion */ +.thought-rail-overlay { + opacity: 1; + transition: opacity 150ms ease-out; +} +.thought-rail-overlay--settled { + opacity: 0; +} + +/* Done-dot: appears per-row after turn completion */ +.thought-rail-done-dot { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 55%, transparent); + transition: opacity 150ms ease-out; +} + +/* Reduced motion: no spring, no crossfade, instant transitions */ +@media (prefers-reduced-motion: reduce) { + .thought-rail-line, + .thought-rail-dot, + .thought-rail-overlay, + .thought-rail-done-dot { + transition: none !important; + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx index c46b3e95..0be591b2 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -95,6 +95,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" +import { createSmoothScroller } from "./smooth-scroll" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] @@ -565,6 +566,14 @@ export function MessageTimeline(props: { }, }) const resizeItem = virtualizer.resizeItem + // Smooth scroller: replaces instant scrollToEnd when content grows while anchored. + // User gestures cancel the animation; reduced-motion falls back to instant. + const reducedMotion = typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + const smoothScroller = createSmoothScroller({ + getElement: () => listRoot(), + duration: 180, + reducedMotion, + }) let resizeAnchorScheduled = false const anchorResizedBottom = () => { if (resizeAnchorScheduled || props.hasScrollGesture()) return @@ -572,7 +581,7 @@ export function MessageTimeline(props: { queueMicrotask(() => { resizeAnchorScheduled = false if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return - virtualizer.scrollToEnd() + smoothScroller.scrollToEnd() }) } virtualizer.resizeItem = (index, size) => { @@ -813,6 +822,8 @@ export function MessageTimeline(props: { // amicode#271: update scroll offset for the last-prompt bubble setScrollTop(event.currentTarget.scrollTop) if (!props.hasScrollGesture()) return + // Cancel smooth scroll on user gesture (#628) + if (smoothScroller.isAnimating()) smoothScroller.cancel() // User-initiated scroll — clear any click override so bubble tracks position // (but not if we're mid-programmatic scroll from a bubble click) if (!bubbleScrolling) setBubbleOverride(undefined) @@ -1325,7 +1336,7 @@ export function MessageTimeline(props: { data-message-id={input.row().userMessageID} data-timeline-row={input.row()._tag} classList={{ - "min-w-0 w-full max-w-full": true, + "min-w-0 w-full max-w-full md:pl-3": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:mx-auto": props.centered, "pt-3": previousAssistantPart(), diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/thought-rail.tsx new file mode 100644 index 00000000..fa340588 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/thought-rail.tsx @@ -0,0 +1,107 @@ +import { createMemo, Show } from "solid-js" +import { AmicoWave } from "@opencode-ai/ui/amicode-thinking" +import { dotCentreForGroup, type TimelineGroupType } from "./dot-offsets" + +/** + * ThoughtRailOverlay — the persistent dot that tracks the active turn. + * + * Architecture: + * - Lives inside the scroll container's virtual content div + * - Absolutely positioned to span the active turn's rows + * - The wave dot inside uses CSS sticky so it stays near viewport top during long streams + * - Only repositions (top/height) on step boundaries (new rows), NOT per text fragment + * - On turn completion, crossfades out (CSS class transition) while per-row done-dots appear + * + * Props: + * - visible: whether a turn is actively streaming + * - top: px offset from virtual content top to start of turn + * - height: total px height of the active turn's rows + * - dotOffset: current vertical centre for the dot (from dotCentreForGroup) + * - settled: whether the turn just completed (triggers crossfade) + */ +export function ThoughtRailOverlay(props: { + visible: boolean + top: number + height: number + groupType: TimelineGroupType + settled: boolean +}) { + const dotOffset = createMemo(() => dotCentreForGroup(props.groupType)) + + return ( + +
+ {/* The continuous rail line */} +
+ {/* The sticky dot — stays near viewport top during long streams */} +
+ +
+
+ + ) +} + +/** + * ThoughtRailDoneDot — a static settled dot rendered per-row after turn completion. + * + * Uses deterministic offset — no TreeWalker measurement. + */ +export function ThoughtRailDoneDot(props: { + groupType: TimelineGroupType +}) { + const offset = createMemo(() => dotCentreForGroup(props.groupType)) + + return ( +
+ ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/chunked-markdown.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/chunked-markdown.tsx new file mode 100644 index 00000000..7a0f6076 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/chunked-markdown.tsx @@ -0,0 +1,51 @@ +import { createMemo, For, Show } from "solid-js" +import { Markdown } from "./markdown" +import { chunkBoundaries, splitAtBoundaries } from "./chunk-boundaries" + +/** + * ChunkedMarkdown — renders settled text as multiple visual cards. + * + * When text contains natural boundaries (heading breaks, paragraph blocks), + * this splits it into separate bordered cards for visual cohesion. The split + * respects: + * - Label heuristic: short labels (<40 chars + ":") stay with their content + * - List coherence: consecutive lists never split + * - Monotonicity: safe to call during streaming (boundaries only grow) + * + * Props: + * - text: the full markdown text + * - cacheKey: for Markdown component memoization + * - streaming: whether the text is still being generated + */ +export function ChunkedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) { + // During streaming, render as a single block (no splitting mid-stream avoids flicker) + // On settle, compute chunk boundaries + const chunks = createMemo(() => { + const t = props.text + if (!t || props.streaming) return [t] + const boundaries = chunkBoundaries(t) + return splitAtBoundaries(t, boundaries) + }) + + const isSingleChunk = () => chunks().length <= 1 + + return ( + } + > +
+ + {(chunk, index) => ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx index 7ba45ce6..e6b30439 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx @@ -55,6 +55,7 @@ import { Checkbox } from "@opencode-ai/ui/checkbox" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Markdown } from "./markdown" import { skillBody } from "./message-part-skill" +import { ChunkedMarkdown } from "./chunked-markdown" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" import { AttachmentCardV2 } from "../v2/components/attachment-card-v2" @@ -2136,7 +2137,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- }> + }>
diff --git a/packages/app-bundle/overlay/packages/ui/src/components/amicode-thinking.tsx b/packages/app-bundle/overlay/packages/ui/src/components/amicode-thinking.tsx index cdc0b2f6..bed4b6f7 100644 --- a/packages/app-bundle/overlay/packages/ui/src/components/amicode-thinking.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/components/amicode-thinking.tsx @@ -2,3 +2,4 @@ // Logic lives in ../amicode/thinking.ts and ../amicode/thinking-line.tsx. export { turnTokens } from "../amicode/thinking" export { ThinkingLine } from "../amicode/thinking-line" +export { AmicoWave } from "../amicode/amico-wave" From 13a290548dc832caa599e62cd4fe8de09174c6a8 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 14:23:38 -0400 Subject: [PATCH 3/3] fix(ui): render rail dot inside content padding, aligned to top text row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rail renders inside session-turn's left padding (left: 12px), within the existing px-4/md:px-5 content area — no extra outer padding that would push content off the compositor alignment - Dot anchor centres at 18px from top for prose rows (aligns with first text baseline), 8px for thinking rows (aligns with wave) - Import AmicoWave in message-timeline for the live working dot - Rail segment only visible at md+ breakpoints (hidden md:block) - Verified: 1713 tests pass, build clean --- .../session/timeline/message-timeline.tsx | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx index 0be591b2..0b2187ac 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -19,7 +19,7 @@ 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 { ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking" +import { AmicoWave, ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking" import { AmicodeEntityView, entityLabel, @@ -1329,6 +1329,12 @@ export function MessageTimeline(props: { const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart } + // Rail visibility: show on Thinking and AssistantPart rows + const showsRail = () => { + const tag = input.row()._tag + return tag === "Thinking" || tag === "AssistantPart" + } + const isWorking = () => workingTurn(input.row().userMessageID) return (
+ {/* Rail dot + line — renders inside the content's left padding area */} + +