diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 08baf080b..e74ec5936 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -209,23 +209,7 @@ [data-part-enter] { animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) backwards; } -/* Prose-fragment cards get a snappier, blur-free entrance (#265): 150ms - ease-out, 10px rise, no blur. Faster than the general timeline-enter because - these settle during active streaming — the eye is already watching the bottom - edge, so the entrance can be crisp without startling. */ -[data-prose-fragment][data-part-enter] { - animation: prose-fragment-enter 150ms ease-out backwards; -} -@keyframes prose-fragment-enter { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} + /* The open cascade holds at frame 0 (opacity 0, risen, blurred) until the timeline has settled at the bottom — released by removing [data-entrance-pending] (message-timeline.tsx entranceReady). Also keeps @@ -279,9 +263,6 @@ --motion-enter-rise: 0px; --motion-enter-blur: 0px; } - [data-prose-fragment][data-part-enter] { - animation: none; - } } /* ── inline code: readable in both schemes ── */ diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4b23fe85c..a9b3b592a 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -378,44 +378,14 @@ /* The thought rail's live node: a spherical-harmonic morphing dot that pulses through Y_l^m silhouettes via SMIL path animation (sphere → shape → sphere). - The grow animation fires on mount (7→13px). Honours reduced motion. */ + display:block kills the inline SVG baseline gap (~2-3px descender space) + that otherwise pushes the dot above its intended centre. */ .thought-rail-dot--harmonic { - animation: thought-rail-grow 150ms ease-out both; -} -/* Travelling dot (#265): after the first measurement settles, the dot - transitions smoothly to track the latest prose-fragment card. The settled - 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; -} -/* 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; -} -@keyframes thought-rail-grow { - from { - transform: scale(0.538); /* 7/13 — starts at done-dot size */ - opacity: 0.7; - } - to { - transform: scale(1); - opacity: 1; - } + display: block; } @media (prefers-reduced-motion: reduce) { - .thought-rail-dot--harmonic { - animation: none; - } .thought-rail-dot--harmonic .harmonic-dot-shape animate { /* SMIL respects this; browsers also pause SMIL under reduced-motion */ display: none; } - .thought-rail-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..5913c7a4d 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/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 { 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 { AmicodeEntityView, @@ -98,6 +98,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" @@ -702,6 +703,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 = () => { @@ -710,7 +769,7 @@ export function MessageTimeline(props: { queueMicrotask(() => { resizeAnchorScheduled = false if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return - virtualizer.scrollToEnd() + smoothScrollToEnd() }) } virtualizer.resizeItem = (index, size) => { @@ -819,7 +878,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 }) }) @@ -843,7 +902,7 @@ export function MessageTimeline(props: { if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) clearPrependAnchor() if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) - virtualizer.scrollToEnd() + smoothScrollToEnd() } let measuredSessionKey = sessionKey() @@ -1479,38 +1538,38 @@ export function MessageTimeline(props: { if (row._tag === "Thinking") { const hasOutput = hasAssistantParts(row.userMessageID) // Dot stays on Thinking only while no output exists - return { first: true, last: !hasOutput, running: row.turnRunning && !hasOutput } + return { first: true, last: !hasOutput, running: row.turnRunning && !hasOutput, prose: false } } if (row._tag !== "AssistantPart") return undefined if (!shouldRenderRail(row)) return undefined - // Last AssistantPart gets the dot when the turn is still running - return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart } + // Last AssistantPart gets the dot when the turn is still running. + // prose = text content that grows → dot bottom-anchored. + // !prose = tool/status row → dot at dotCentre. + // A "part" group can be either text or a single tool — check the actual part. + let isProse = false + if (row.group.type === "part") { + const part = getMsgPart(row.group.ref.messageID, row.group.ref.partID) + isProse = part?.type === "text" + } + return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart, prose: isProse } } - // 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 aligns with the vertical centre of the row's first text line. + // Initialized from dotCentreForGroup (deterministic per row type) so the + // dot starts in the right place without waiting for DOM measurement. + // ResizeObserver refines the value once the content has rendered. let turnEl: HTMLDivElement | undefined - const [dotCentre, setDotCentre] = createSignal(DEFAULT_DOT_CENTRE) - const [dotSettled, setDotSettled] = createSignal(false) + const initialDotCentre = () => { + const row = input.row() + if (row._tag === "Thinking") return dotCentreForGroup("thinking") + if (row._tag === "AssistantPart") return dotCentreForGroup(row.group.type) + return DEFAULT_DOT_CENTRE + } + const [dotCentre, setDotCentre] = createSignal(initialDotCentre()) 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) + const walker = document.createTreeWalker(turnEl, NodeFilter.SHOW_TEXT) let node: Node | null while ((node = walker.nextNode())) { if (!node.textContent?.trim()) continue @@ -1519,13 +1578,10 @@ export function MessageTimeline(props: { 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 + if (centre > 0 && centre < 80) { + setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2)) + return + } } } onMount(() => { @@ -1546,6 +1602,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(), }} >
diff --git a/packages/app/src/pages/session/timeline/model-pure.ts b/packages/app/src/pages/session/timeline/model-pure.ts new file mode 100644 index 000000000..5edbf8b9b --- /dev/null +++ b/packages/app/src/pages/session/timeline/model-pure.ts @@ -0,0 +1,35 @@ +import type { Message, UserMessage } from "@opencode-ai/sdk/v2" +import type { Accessor } from "solid-js" + +export function selectUserMessages(messages: Message[]) { + return messages.filter((message): message is UserMessage => message.role === "user") +} + +export function isTimelineReady(messages: Message[] | undefined, loading: boolean) { + return messages !== undefined && (messages.some((message) => message.role === "user") || !loading) +} + +export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) { + if (!revertMessageID) return messages + return messages.filter((message) => message.id < revertMessageID) +} + +export async function loadOlderTimeline(input: { + sessionID: Accessor + more: Accessor + loading: Accessor + loadMore: (sessionID: string) => Promise + before?: () => void + after?: (done: boolean) => void +}) { + const id = input.sessionID() + if (!id || !input.more() || input.loading()) return + + input.before?.() + await input.loadMore(id).catch((error) => { + if (input.sessionID() === id) input.after?.(true) + throw error + }) + if (input.sessionID() !== id) return + input.after?.(true) +} diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts index 24612072c..b19a393e5 100644 --- a/packages/app/src/pages/session/timeline/model.test.ts +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2" -import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model" +import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure" const user = (id: string) => ({ id, role: "user" }) as UserMessage const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 7eebee608..b254f0692 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -3,6 +3,8 @@ import { createMemo, createResource, onCleanup, untrack, type Accessor } from "s import { useServerSync } from "@/context/server-sync" import { useSync } from "@/context/sync" import { same } from "@/utils/same" +import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure" +export { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure" const emptyUserMessages: UserMessage[] = [] const sessionFreshness = 15_000 @@ -93,36 +95,3 @@ export function createTimelineModel(input: { refreshTimer = undefined } } - -export function selectUserMessages(messages: Message[]) { - return messages.filter((message): message is UserMessage => message.role === "user") -} - -export function isTimelineReady(messages: Message[] | undefined, loading: boolean) { - return messages !== undefined && (messages.some((message) => message.role === "user") || !loading) -} - -export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) { - if (!revertMessageID) return messages - return messages.filter((message) => message.id < revertMessageID) -} - -export async function loadOlderTimeline(input: { - sessionID: Accessor - more: Accessor - loading: Accessor - loadMore: (sessionID: string) => Promise - before?: () => void - after?: (done: boolean) => void -}) { - const id = input.sessionID() - if (!id || !input.more() || input.loading()) return - - input.before?.() - await input.loadMore(id).catch((error) => { - if (input.sessionID() === id) input.after?.(true) - throw error - }) - if (input.sessionID() !== id) return - input.after?.(true) -} diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 0dc5f86f3..202c3e42c 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -241,6 +241,17 @@ export namespace Timeline { assistantItems.forEach((item, itemIndex) => { if (item.type === "interrupted") { + // ThinkingMeta attaches to the last output BEFORE the interruption — + // it summarises the work that was interrupted, not the post-interrupt tail. + if (assistantPartRefs.length > 0 && !turnIsRunning) { + rows.push( + new TimelineRow.ThinkingMeta({ + userMessageID: userMessage.id, + turnRunning: false, + turnDurationMs: computeTurnDuration(userMessage, assistantMessages), + }), + ) + } rows.push( new TimelineRow.TurnDivider({ userMessageID: userMessage.id, @@ -266,8 +277,8 @@ export namespace Timeline { // ThinkingMeta row renders LAST — duration + tokens as a historical record. // Hidden while streaming (the harmonic dot signals "working"); appears only - // after the turn completes. - if (assistantPartRefs.length > 0 && !turnIsRunning) { + // after the turn completes. Skipped when interrupted — already emitted above. + if (assistantPartRefs.length > 0 && !turnIsRunning && !interrupted) { rows.push( new TimelineRow.ThinkingMeta({ userMessageID: userMessage.id, 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 diff --git a/packages/app/src/pages/session/timeline/thought-rail-pure.ts b/packages/app/src/pages/session/timeline/thought-rail-pure.ts new file mode 100644 index 000000000..b9d5e3894 --- /dev/null +++ b/packages/app/src/pages/session/timeline/thought-rail-pure.ts @@ -0,0 +1,37 @@ +// Pure (no DOM / no Kobalte) exports from the thought-rail module. +// Extracted so tests can import these without triggering the SSR error that +// Kobalte's client-only tooltip causes in a test environment. + +/** Where a row's dot centre sits when nothing measures it: 11px — the centre + * of a 22px first text line starting at the row's top, which is what prose + * and rail-label rows produce. */ +export const DEFAULT_DOT_CENTRE = 11 + +/** Deterministic dot centre per group type — the vertical centre of the first + * text line for each content species, measured once and tabulated. Used as the + * initial value for dotCentre (the ResizeObserver measurement refines it once + * the DOM settles, but the initial value prevents a frame of misalignment). */ +export function dotCentreForGroup(groupType: string): number { + if (groupType === "prose" || groupType === "part") return DEFAULT_DOT_CENTRE + if (groupType === "single_tool" || groupType === "shell" || groupType === "edit" || groupType === "context") return DEFAULT_DOT_CENTRE + if (groupType === "tool_group") return DEFAULT_DOT_CENTRE + if (groupType === "thinking") return DEFAULT_DOT_CENTRE + 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 + * timeline's only "working" mark. The lone live dot draws no line — every line + * must end at a dot at both ends, so a single dot has no dangling half — and a + * one-step completion still reads as "dot fills". + */ +export function shouldRenderRail(_input: { + previousAssistantPart: boolean + lastAssistantPart: boolean + turnRunning: boolean +}) { + // The Thinking row is always the first rail node, so every AssistantPart + // always has at least one other node above it — the rail always renders. + return true +} diff --git a/packages/app/src/pages/session/timeline/thought-rail.test.ts b/packages/app/src/pages/session/timeline/thought-rail.test.ts index 19c784904..fd7ac9299 100644 --- a/packages/app/src/pages/session/timeline/thought-rail.test.ts +++ b/packages/app/src/pages/session/timeline/thought-rail.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { shouldRenderRail } from "./thought-rail" +import { shouldRenderRail, dotCentreForGroup } from "./thought-rail-pure" // The rail's grammar, stated as tests. A step is "running" only when it is the // TAIL of a turn that is still working; everything above it has by definition @@ -63,3 +63,23 @@ describe("thought rail", () => { expect(steps.map((s) => s.last)).toEqual([false, false, true]) }) }) + +describe("dotCentreForGroup", () => { + test("returns DEFAULT_DOT_CENTRE for all group types (measurement is source of truth)", () => { + // All types return DEFAULT_DOT_CENTRE as the initial value; + // the ResizeObserver measurement refines it once DOM settles. + expect(dotCentreForGroup("prose")).toBe(11) + expect(dotCentreForGroup("tool_group")).toBe(11) + expect(dotCentreForGroup("single_tool")).toBe(11) + expect(dotCentreForGroup("thinking")).toBe(11) + expect(dotCentreForGroup("part")).toBe(11) + expect(dotCentreForGroup("shell")).toBe(11) + expect(dotCentreForGroup("edit")).toBe(11) + expect(dotCentreForGroup("context")).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..b3521472c 100644 --- a/packages/app/src/pages/session/timeline/thought-rail.tsx +++ b/packages/app/src/pages/session/timeline/thought-rail.tsx @@ -84,22 +84,23 @@ // // tail: { top: first ? "0px" : NEG, height: first ? "0px" : `calc(${STEP_GAP} + ${dotCentre}px)` } -import { createSignal, onCleanup, Show } from "solid-js" +import { createSignal, onCleanup } from "solid-js" import { HarmonicDot, HARMONIC_SIZE } from "@opencode-ai/ui/amicode-harmonic-dot" import { formatElapsed, formatTokens } from "@opencode-ai/ui/amicode-thinking" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +export { DEFAULT_DOT_CENTRE, dotCentreForGroup, shouldRenderRail } from "./thought-rail-pure" const NODE = 7 // dot diameter, px — matches the site's Step -/** Where a row's dot centre sits when nothing measures it: 11px — the centre - * of a 22px first text line starting at the row's top, which is what prose - * and rail-label rows produce. Rows whose content opens with a CARD (a tool - * chip, a group header, a widget preview) start their first text line lower - * — measured 16px for a chip row, 26.5px for a widget preview — so - * TimelineRowFrame measures the actual first line and passes `dotCentre` - * (Kate 2026-08-24: dots must line up with the text they coincide with). - * Segment caps derive from the same value, so alignment can never detach - * the spine from its dots. */ -export const DEFAULT_DOT_CENTRE = 11 +// The bottom-anchored dot on prose rows sits with its centre at the last +// text line's vertical centre — approximately: card bottom-padding (10px) + +// half body line-height (~11px) = 21px from the row's bottom edge. This is +// a fixed offset, so the dot never jumps — it just rides down as the row grows. +const PROSE_DOT_BOTTOM_INSET = 21 + +// DEFAULT_DOT_CENTRE, dotCentreForGroup, shouldRenderRail are re-exported +// from ./thought-rail-pure (extracted for test isolation from Kobalte SSR). +import { DEFAULT_DOT_CENTRE } from "./thought-rail-pure" // The rail sits in a gutter carved out of the row's own left inset, NOT flush // against the row edge. Flush was wrong: below the md breakpoint the message @@ -127,12 +128,12 @@ export function ThoughtRail(props: { last: boolean /** the turn is still working, so this tail step is in flight */ running: boolean + /** true for prose/text rows (growing content) — dot bottom-anchors. + * false for Thinking/tool/shell rows (status cards) — dot at dotCentre. */ + prose: boolean /** measured centre of the row's first text line (px from the row's top); - * defaults to DEFAULT_DOT_CENTRE for unmeasured/prose rows */ + * used for done-dot alignment and status-row running dot */ dotCentre?: number - /** true once the first measurement has landed — gates the CSS transition - * so the initial mount uses the grow animation alone (#265) */ - settled?: boolean /** epoch-ms when the user message was created — anchors the tooltip timer */ turnStartedAt?: number /** streamed token count for this turn — shown in the tooltip */ @@ -152,8 +153,7 @@ export function ThoughtRail(props: { return ( <> {/* Rail line BEHIND the dot — the SVG's internal background circle - masks the line within the ring's interior. The line reaches dotCentre - geometrically to connect with harmonic shapes that extend inward. */} + masks the line within the ring's interior. */}