diff --git a/packages/app/e2e/regression/timeline-history.spec.ts b/packages/app/e2e/regression/timeline-history.spec.ts index 11ad65eb187..38d861e3811 100644 --- a/packages/app/e2e/regression/timeline-history.spec.ts +++ b/packages/app/e2e/regression/timeline-history.spec.ts @@ -217,6 +217,52 @@ test.describe("timeline history", () => { expect(loads.length).toBeGreaterThan(1) }) + test("keeps the turn rail in timeline order, with one preview of each turn's own reply", async ({ page }) => { + // Other clients mint IDs that do not sort by time, like the iOS app's uppercase UUIDs. + const turnID = (index: number) => + [5, 30].includes(index) ? `msg_F${index}_ios_user` : `msg_0${String(index).padStart(4, "0")}_turn_a_user` + + const messages = Array.from({ length: 40 }, (_, index) => [ + userMessage([userText(`Prompt number ${index}`, { id: `prt_${index}_prompt` })], { + id: turnID(index), + created: 1690000000000 + index * 10_000, + }), + assistantMessage([{ id: `prt_${index}_reply`, type: "text", text: `Response number ${index}` }], { + id: `msg_0${String(index).padStart(4, "0")}_turn_b_assistant`, + parentID: turnID(index), + created: 1690000001000 + index * 10_000, + }), + ]).flat() + + await setupTimeline(page, { sessionMessages: messages, pageMessages: pageMessagesFrom({ [sessionID]: messages }) }) + const navigator = page.locator('[data-component="session-turn-navigator"]') + const turns = navigator.locator("[data-turn]") + const preview = navigator.locator('[data-slot="session-turn-navigator-preview"]') + await expect(turns).toHaveCount(40) + + // An unloaded turn previews its prompt, then its own reply, once each. + await turns.nth(5).hover() + await expect(preview.locator("p")).toHaveText(["Prompt number 5", "Response number 5"]) + await turns.nth(5).click() + await expect(page.locator(`#message-${turnID(5)}`)).toBeInViewport() + + // Scrolling closes the preview; moving on opens a single one for the turn under the pointer. + await turns.nth(20).hover() + await expect(preview).toBeVisible() + await page.mouse.wheel(0, -300) + await expect(preview).toBeHidden() + await turns.nth(30).hover() + await expect(preview.locator("p")).toHaveText(["Prompt number 30", "Response number 30"]) + await expect(page.locator('[data-slot="session-turn-navigator-preview"]')).toHaveCount(1) + + // The last tick is the latest turn. + await turns.nth(39).hover() + await expect(preview.locator("p")).toHaveText(["Prompt number 39", "Response number 39"]) + await turns.nth(39).click() + await expect(page.locator(`#message-${turnID(39)}`)).toBeInViewport() + await expect(page.locator(`#message-${turnID(5)}`)).not.toBeInViewport() + }) + test("mounts every part once and in order while paging to the start", async ({ page }) => { test.setTimeout(240_000) const errors = trackPageErrors(page) @@ -303,6 +349,8 @@ test.describe("timeline history", () => { const timeline = await setupTimeline(page, { sessionMessages: messages, sessionStatus: { [sessionID]: { type: "busy" } }, + // The turn rail reads the transcript through its own index, which would land in the exact page log below. + settings: { showTurnNavigator: false }, viewport: { width: 646, height: 1385 }, beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()), onMessages: (request) => { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index a399528cca8..21c47d8a940 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1424,8 +1424,9 @@ function mockHandlers( const token = ctx.query.cursor const type = ctx.query.type - // Filter before pagination, matching the server's message index without paging the transcript. - if (type) { + // Filter before pagination, matching the server's message index without paging the transcript. A `before` + // anchor reads back from a message the same way. + if (type || ctx.query.before) { const [order, offset] = token?.startsWith("typed:") ? [token.split(":")[1], Number(token.split(":")[2])] : [ctx.query.order ?? "desc", 0] @@ -1439,7 +1440,7 @@ function mockHandlers( ) : transcript.length - const typed = transcript.slice(0, end).filter((item) => item.type === type) + const typed = transcript.slice(0, end).filter((item) => !type || item.type === type) const ordered = order === "asc" ? typed : typed.toReversed() const limit = ctx.query.limit ?? 50 diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 618a7f247d6..bc6cc3188f0 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -3,7 +3,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLocation } from "@solidjs/router" import { createEffect, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { frameLoop } from "@opencode/ui/motion" +import { frameLoop, prefersReducedMotion } from "@opencode/ui/motion" import { useLayout } from "@/shell/state/layout" import { useData } from "@/runtime/server/current" import type { SessionModel } from "../model" @@ -224,8 +224,12 @@ export function createSessionTimelineInteraction(session: SessionModel) { const message = loaded() if (!message || !owner.current()) return + const behavior = prefersReducedMotion() ? "auto" : "smooth" + + // The latest turn is the bottom of the timeline, reached the way the jump-to-latest button reaches it. + if (message.id === visibleUserMessages().at(-1)?.id) return resume(behavior) unpin() - scrollToMessage(message, "auto") + scrollToMessage(message, behavior) } // A gesture inside a nested scrollable region scrolls that region, not the timeline. diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 6d6866ee3af..1226a3e837b 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -476,6 +476,7 @@ function MessageTimelineView( sessionID={id} messages={turnMessages} complete={() => !data.session.message.more(id)} + historyLoading={() => data.session.message.loading(id)} assistantMessagesByParent={projection.assistantMessagesByParent} revertMessageID={() => props.session.data.info()?.revert?.messageID} visibleUserMessageIDs={virtualized.visibleUserMessageIDs} diff --git a/packages/app/src/session/timeline/turn-navigator.tsx b/packages/app/src/session/timeline/turn-navigator.tsx index 4f686c8aeae..db376724568 100644 --- a/packages/app/src/session/timeline/turn-navigator.tsx +++ b/packages/app/src/session/timeline/turn-navigator.tsx @@ -1,27 +1,39 @@ import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode/client/promise" -import { createEffect, createMemo, createSelector, For, onCleanup, onMount, Show, type Accessor } from "solid-js" +import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion" +import { createEffect, createMemo, createSelector, For, onCleanup, Show, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { readPromptPresentation } from "@/composer/comment-note" import { useServerSDK } from "@/runtime/server/client" const turnPageLimit = 200 -// Previews clamp to a few lines; keep only enough text to fill them. +// Previews fade out after a few lines; keep only enough text to fill them. const previewLength = 400 -// Keep index loading clear of session entry and tab switch rendering. +// Keep index loading clear of session entry, tab switch rendering and the timeline's own history requests. const railDelay = 1000 +// The rail lists at most this many of the newest turns. +const indexLimit = 1000 + // Resting on an unloaded turn fetches its reply; sweeping across the rail fetches nothing. const replyHoverDelay = 150 const previewHoverDelay = 100 -const hoverRadius = 6 +// Messages read back from the next turn to find an unloaded turn's reply. +const replyScanLimit = 20 -const tickWidths = { default: [7, 12], active: [12, 20], hovered: [14, 22] } +// Ticks within this many pixels of the pointer swell towards it, like a dock. +const waveRadius = 48 -const tickSpanWidth = Math.max(...Object.values(tickWidths).flat()) +const tickWidths = { default: 7, active: 12, peak: 22 } + +// The prompt's first line sits level with its tick: card padding, bubble padding, then half a 16px line. +const previewAnchor = 20 + +// The preview at its tallest, with both texts clamped. Clamping to it keeps the card still while a reply loads. +const previewMaxHeight = 142 type TurnNavigatorProps = { sessionID: string @@ -29,6 +41,8 @@ type TurnNavigatorProps = { messages: Accessor /** Whether the loaded messages reach the start of the session, making the index redundant. */ complete: Accessor + /** Whether the timeline is loading its own history; the index waits until it has been idle for a while. */ + historyLoading: Accessor assistantMessagesByParent: Accessor> revertMessageID: Accessor visibleUserMessageIDs: Accessor> @@ -46,24 +60,28 @@ export function TurnNavigator(props: TurnNavigatorProps) { const [state, setState] = createStore<{ engaged: boolean; index?: Map }>({ engaged: false }) let root: HTMLDivElement | undefined - const loadIndex = async (cursor?: string): Promise<[string, string][]> => { - const page = await sdk.api.message.list({ - sessionID: props.sessionID, - type: "user", - limit: turnPageLimit, - ...(cursor ? { cursor } : { order: "asc" as const }), - }) + const abort = new AbortController() + onCleanup(() => abort.abort()) + + // Newest first, so a capped index still meets the loaded tail. + const loadIndex = async (cursor?: string, count = 0): Promise<[string, string][]> => { + const page = await sdk.api.message.list( + { sessionID: props.sessionID, type: "user", limit: turnPageLimit, cursor }, + { signal: abort.signal }, + ) const turns = page.data.flatMap((message): [string, string][] => message.type === "user" ? [[message.id, promptPreview(message)]] : [], ) - if (page.data.length < turnPageLimit || !page.cursor.next) return turns + if (page.data.length < turnPageLimit || !page.cursor.next || count + turns.length >= indexLimit) return turns - return [...turns, ...(await loadIndex(page.cursor.next))] + return [...turns, ...(await loadIndex(page.cursor.next, count + turns.length))] } - onMount(() => { + createEffect(() => { + if (state.engaged || props.historyLoading()) return + const timer = setTimeout(() => { // Touch-only devices never load the rail. if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return @@ -74,7 +92,7 @@ export function TurnNavigator(props: TurnNavigatorProps) { // On failure the rail lists the loaded turns. void loadIndex() .catch(() => []) - .then((turns) => setState("index", new Map(turns))) + .then((turns) => setState("index", new Map(turns.toReversed()))) }, railDelay) onCleanup(() => clearTimeout(timer)) @@ -85,7 +103,7 @@ export function TurnNavigator(props: TurnNavigatorProps) { ref={root} data-component="session-turn-navigator" aria-hidden="true" - class="absolute start-0 top-[calc(var(--session-timeline-header-height,48px)+8px)] bottom-[calc(var(--session-timeline-inset,0px)+80px)] z-[50] flex w-4 flex-col justify-center pointer-events-none" + class="absolute start-0 top-[calc(var(--session-timeline-header-height,48px)+8px)] bottom-[calc(var(--session-timeline-inset,0px)+80px)] z-[50] flex w-8 flex-col justify-center pointer-events-none" > @@ -98,46 +116,69 @@ function TurnRail(props: TurnNavigatorProps & { index?: Map; roo const sdk = useServerSDK() const [state, setState] = createStore<{ - hover?: { id: string; top: number; position: number } - showPreview: boolean + hover?: string + // The previewed turn, kept while the preview fades out. + shown?: string + open: boolean // Fetched replies by user message ID; null while loading or when there is no reply text. replies: Record - }>({ showPreview: false, replies: {} }) + }>({ open: false, replies: {} }) const loaded = createMemo( () => new Map(props.messages().flatMap((message) => (message.type === "user" ? [[message.id, message] as const] : []))), ) - const turns = createMemo(() => { - const revert = props.revertMessageID() + // Timeline order, as the server lists it. IDs from other clients do not sort chronologically, so neither the order + // nor the reverted boundary may compare them. Streaming replaces the loaded messages on every delta, so the list keeps + // its identity until the turns themselves change. + const turns = createMemo( + () => { + const tail = [...loaded().keys()] + // Turns paged in from beyond a capped index are older than all of it. + const join = tail.findIndex((id) => props.index?.has(id)) + const ids = [...new Set([...tail.slice(0, Math.max(0, join)), ...(props.index?.keys() ?? []), ...tail])] + const revert = props.revertMessageID() + const reverted = revert ? ids.indexOf(revert) : -1 - return [...new Set([...(props.index?.keys() ?? []), ...loaded().keys()])] - .filter((id) => !revert || id < revert) - .toSorted() - }) + return reverted === -1 ? ids : ids.slice(0, reverted) + }, + [], + { + equals: (previous, next) => previous.length === next.length && previous.every((id, index) => id === next[index]), + }, + ) - // The turn's latest reply is the newest assistant message before the next turn. + const abort = new AbortController() + onCleanup(() => abort.abort()) + + // The turn's latest reply is the newest assistant text between its prompt and the next turn. const loadReply = async (id: string) => { setState("replies", id, null) const list = turns() - const next = list[list.indexOf(id) + 1] ?? props.revertMessageID() - const page = await sdk.api.message.list({ - sessionID: props.sessionID, - type: "assistant", - limit: 1, - before: next, - }) + const page = await sdk.api.message.list( + { + sessionID: props.sessionID, + limit: replyScanLimit, + before: list[list.indexOf(id) + 1] ?? props.revertMessageID(), + }, + { signal: abort.signal }, + ) - const message = page.data[0] + const end = page.data.findIndex((message) => message.type === "user") - if (message?.type !== "assistant" || message.id < id) return - setState("replies", id, replyText([message]) ?? null) + if (end !== -1 && page.data[end]!.id !== id) return + + const replies = (end === -1 ? page.data : page.data.slice(0, end)).filter( + (message): message is SessionMessageAssistant => message.type === "assistant", + ) + + setState("replies", id, replyText(replies.toReversed()) ?? null) } createEffect(() => { - const id = state.hover?.id + const id = state.hover if (!id || loaded().has(id) || state.replies[id] !== undefined) return const timer = setTimeout(() => void loadReply(id).catch(() => undefined), replyHoverDelay) @@ -145,131 +186,225 @@ function TurnRail(props: TurnNavigatorProps & { index?: Map; roo }) const active = createSelector(props.visibleUserMessageIDs, (id: string, visible) => visible.has(id)) - const hovered = createSelector(() => state.hover?.id) + const hovered = createSelector(() => state.hover) const hovering = createMemo(() => !!state.hover) // Only the first preview waits; moving along an engaged rail switches it immediately. createEffect(() => { if (!hovering()) { - setState("showPreview", false) + setState("open", false) return } - const timer = setTimeout(() => setState("showPreview", true), previewHoverDelay) + const timer = setTimeout(() => setState("open", true), previewHoverDelay) onCleanup(() => clearTimeout(timer)) }) - const tickScale = (id: string, proximity: number) => { - const widths = hovered(id) ? tickWidths.hovered : active(id) ? tickWidths.active : tickWidths.default + // A closed preview keeps its last content for the fade-out without following a streaming reply. + const preview = createMemo((previous?: { prompt?: string; response?: string }) => { + const id = state.shown - return (widths[0] + (widths[1] - widths[0]) * (hovered(id) ? 1 : proximity)) / tickSpanWidth - } - - const preview = createMemo(() => { - const hover = state.hover - - if (!hover) return - const message = loaded().get(hover.id) + if (!id || !state.open) return previous + const message = loaded().get(id) return { - top: hover.top, - prompt: message ? promptPreview(message) : props.index?.get(hover.id), - response: message - ? replyText(props.assistantMessagesByParent().get(hover.id) ?? []) - : (state.replies[hover.id] ?? undefined), + prompt: message ? promptPreview(message) : props.index?.get(id), + response: message ? replyText(props.assistantMessagesByParent().get(id) ?? []) : (state.replies[id] ?? undefined), } }) - const turnAt = (event: MouseEvent) => - event.target instanceof Element ? event.target.closest("[data-turn]") : null + let list: HTMLDivElement | undefined + let card: HTMLDivElement | undefined + // Each tick's element and the scale last written to it. + const ticks = new Map() + // The pointer's clientY over the rail, and the rail's geometry from the last frame's read phase. + let pointer: number | undefined - const hover = (event: PointerEvent) => { - const tick = turnAt(event) - const id = tick?.dataset.turn + const rail = { top: 0, height: 0, root: 0, rootHeight: 0 } - if (!tick || !id) return - const box = tick.getBoundingClientRect() - const position = Number(tick.dataset.index) + (event.clientY - box.top) / box.height - 0.5 - - if (state.hover?.id === id && Math.abs(state.hover.position - position) < 0.05) return - setState("hover", { - id, - top: box.top + box.height / 2 - props.root.getBoundingClientRect().top, - position, - }) + // `position` follows the pointer in tick units and `wave` swells the ticks around it, so one pair drives every tick. + const motion = { + wave: Spring.fromDuration(0, { duration: 0.2, bounce: 0 }), + position: Spring.fromDuration(0, { duration: 0.12, bounce: 0 }), + top: Spring.fromDuration(0, { duration: 0.25, bounce: 0 }), + opacity: Spring.fromDuration(0, { duration: 0.18, bounce: 0 }), } + let frame: (() => void) | undefined + + // Reduced motion drops travel but keeps the preview's fade. + const step = (spring: Spring, dt: number) => + spring === motion.opacity || !prefersReducedMotion() ? spring.step(dt) : spring.snap(spring.target).value + + const paint = (dt: number) => { + const ids = turns() + const pitch = rail.height / Math.max(1, ids.length) + + if (pointer !== undefined && pitch > 0) { + const hit = railHit(pointer - rail.top, rail.height, ids.length) + const id = ids[hit.index] + const tick = rail.top - rail.root + (hit.index + 0.5) * pitch + + // Entering the rail starts the wave under the pointer rather than sweeping in from where it left. + if (motion.wave.value === 0) motion.position.snap(hit.position) + motion.position.set(hit.position) + motion.wave.set(1) + motion.top.set(Math.max(0, Math.min(rail.rootHeight - previewMaxHeight, tick - previewAnchor))) + + // Store writes re-render, so they wait until the frame's style writes are done. + if (id && id !== state.hover) queueMicrotask(() => pointer !== undefined && setState({ hover: id, shown: id })) + } + + const position = step(motion.position, dt) + const wave = step(motion.wave, dt) + + ids.forEach((id, index) => { + const distance = Math.abs(index - position) * pitch + const swell = distance < waveRadius ? (wave * (1 + Math.cos((distance / waveRadius) * Math.PI))) / 2 : 0 + const rest = active(id) ? tickWidths.active : tickWidths.default + const scale = (rest + (tickWidths.peak - rest) * swell) / tickWidths.peak + const tick = ticks.get(id) + + if (!tick || tick.scale === scale) return + tick.scale = scale + tick.element.style.transform = `scaleX(${scale})` + }) + + motion.opacity.set(state.open ? 1 : 0) + + // A preview that opens from hidden appears in place; one already showing glides to the next turn. + if (motion.opacity.value === 0) motion.top.snap(motion.top.target) + const opacity = step(motion.opacity, dt) + + if (card) { + card.style.transform = `translateY(${step(motion.top, dt)}px)` + card.style.opacity = String(opacity) + card.style.visibility = opacity === 0 ? "hidden" : "visible" + } + + return Object.values(motion).some((spring) => !spring.resting) + } + + const wake = () => { + frame ??= frameLoop.addMeasured( + () => { + // Only the pointer needs geometry; reading it otherwise would force a layout on every streamed frame. + if (!list || pointer === undefined) return + const box = list.getBoundingClientRect() + const root = props.root.getBoundingClientRect() + rail.top = box.top + rail.height = box.height + rail.root = root.top + rail.rootHeight = root.height + }, + (dt) => { + if (paint(dt)) return true + frame = undefined + + return false + }, + ) + } + + const leave = () => { + pointer = undefined + motion.wave.set(0) + setState("hover", undefined) + wake() + } + + // Resting widths follow the visible turns, and the preview follows its open state. + createEffect(() => { + turns().forEach((id) => active(id)) + void state.open + wake() + }) + + onCleanup(() => frame?.()) + return ( 1}>
setState("hover", undefined)} + class="flex max-h-full min-h-0 w-full cursor-pointer flex-col py-6 pointer-events-auto animate-in fade-in [animation-duration:180ms] [animation-timing-function:ease-out] motion-reduce:animate-none" + onPointerMove={(event) => { + pointer = event.clientY + wake() + }} + onPointerLeave={leave} + // Scrolling over the rail closes the preview; the next pointer move reopens it on the turn below the pointer. + onWheel={leave} onClick={(event) => { - const id = turnAt(event)?.dataset.turn + if (!list) return + const box = list.getBoundingClientRect() + const ids = turns() + const id = ids[railHit(event.clientY - box.top, box.height, ids.length).index] if (id) props.onSelect(id) }} > - - {(id, index) => { - const proximity = createMemo(() => { - if (!state.hover) return 0 - const distance = Math.abs(index() - state.hover.position) - - if (distance >= hoverRadius) return 0 - - return (1 + Math.cos((distance / hoverRadius) * Math.PI)) / 2 - }) - - return ( +
+ + {(id, index) => (
{ + const scale = (active(id) ? tickWidths.active : tickWidths.default) / tickWidths.peak + element.style.transform = `scaleX(${scale})` + ticks.set(id, { element, scale }) + onCleanup(() => ticks.delete(id)) + }} + class="block h-0.5 shrink-0 origin-left bg-(--tick-color) rtl:origin-right" style={{ - width: `${tickSpanWidth}px`, - transform: `scaleX(${tickScale(id, proximity())})`, - opacity: "var(--tick-opacity)", + width: `${tickWidths.peak}px`, + opacity: hovered(id) ? 1 : "var(--tick-opacity)", + "background-color": hovered(id) ? "var(--v2-icon-icon-base)" : undefined, + transition: hovered(id) ? "none" : "background-color 180ms ease-out, opacity 180ms ease-out", }} />
- ) - }} -
+ )} + +
- - {(item) => ( -
-
-

- {item().prompt} -

+ -
- )} - + + )} + +
) } @@ -289,3 +424,13 @@ function replyText(messages: SessionMessageAssistant[]) { function previewText(text: string) { return text.replace(/\s+/g, " ").trim().slice(0, previewLength) } + +/** + * The tick nearest a pointer `offset` px down a rail of `count` evenly spaced ticks `height` px tall, and the pointer's + * position in tick units (0 is the first tick's centre). Offsets beyond either end clamp to that end's tick. + */ +function railHit(offset: number, height: number, count: number) { + const position = Math.min(count - 1, Math.max(0, (offset / height) * count - 0.5)) + + return { index: Math.round(position), position } +} diff --git a/packages/app/src/session/use-session-hash-scroll.ts b/packages/app/src/session/use-session-hash-scroll.ts index c133c63e655..aa6ed09f686 100644 --- a/packages/app/src/session/use-session-hash-scroll.ts +++ b/packages/app/src/session/use-session-hash-scroll.ts @@ -100,12 +100,14 @@ export const useSessionHashScroll = (input: { cancel() if (input.currentMessageId() !== message.id) input.setActiveMessage(message) - input.revealMessage?.(message.id) + // A mounted prompt is close enough to glide to; any other is first brought in by the virtualizer. + const mounted = behavior === "smooth" ? document.getElementById(input.anchor(message.id)) : null - if (seek(message.id, behavior)) { - updateHash(message.id) + if (mounted) scrollToElement(mounted, behavior) - return + if (!mounted) { + input.revealMessage?.(message.id) + seek(message.id, behavior) } updateHash(message.id)