From 27a53969d6a84fa5d8c1f608b23e5b7bd7786943 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:06:50 +0800 Subject: [PATCH] fix(app): stabilize initial timeline rendering (#44333) --- .../app/src/session/timeline/interaction.ts | 14 ++++-- packages/app/src/session/timeline/model.ts | 11 ++++- .../app/src/session/timeline/virtualizer.tsx | 49 ++++++++++++++++++- .../session-ui/src/components/markdown.tsx | 35 +++++++++---- .../src/message/message-content.tsx | 2 +- 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index d1ef49b9a26..afcf4661743 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -1,7 +1,7 @@ import type { SessionMessageUser } from "@opencode-ai/client/promise" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLocation } from "@solidjs/router" -import { createEffect, createSignal, on, onCleanup } from "solid-js" +import { createEffect, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useLayout } from "@/shell/state/layout" import type { SessionModel } from "../model" @@ -19,6 +19,10 @@ export function createSessionTimelineInteraction(session: SessionModel) { overflow: false, jump: false, }, + follow: { + sessionKey: session.identity.sessionKey(), + pinned: true, + }, refs: { content: undefined as HTMLDivElement | undefined, dock: undefined as HTMLDivElement | undefined, @@ -26,11 +30,11 @@ export function createSessionTimelineInteraction(session: SessionModel) { }) // The single source of truth for "follow the newest content". The virtualizer pins and unpins // it from scroll geometry; everything else only expresses explicit intent. - const [pinned, setPinned] = createSignal(true) - const pin = () => setPinned(true) + const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned + const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true }) const unpin = () => { if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return - setPinned(false) + setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false }) } let scroller: HTMLDivElement | undefined let dockHeight = 0 @@ -209,8 +213,10 @@ export function createSessionTimelineInteraction(session: SessionModel) { on( session.identity.sessionKey, () => { + pin() setState("messageID", undefined) setState("pendingMessage", undefined) + setState("scroll", { overflow: false, jump: false }) }, { defer: true }, ), diff --git a/packages/app/src/session/timeline/model.ts b/packages/app/src/session/timeline/model.ts index e4069696857..c9cd84c2faf 100644 --- a/packages/app/src/session/timeline/model.ts +++ b/packages/app/src/session/timeline/model.ts @@ -13,6 +13,7 @@ export { export function createTimelineModel(input: { session: Pick }) { const data = useData() + const prepared = new Set() const [resource] = createResource( () => input.session.identity.sessionID(), @@ -20,7 +21,7 @@ export function createTimelineModel(input: { session: Pick input.session.identity.sessionKey() === key, messages: () => data.session.message.list(id), more: () => data.session.message.more(id), @@ -29,9 +30,15 @@ export function createTimelineModel(input: { session: Pick new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)), maxPages: leadingTurnPageLimit, }).catch(() => undefined) + if (input.session.identity.sessionKey() === key) prepared.add(key) }, ) - const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading) + const ready = createMemo(() => { + const id = input.session.identity.sessionID() + if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true + const messages = data.session.message.list(id) + return messages.length > 0 && !leadingTurnNeedsParent(messages) + }) const more = () => { const id = input.session.identity.sessionID() return id ? data.session.message.more(id) : false diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index afbd01f776a..244136f06a1 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -20,6 +20,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { filterVirtualIndexes } from "./virtual-items" const fallbackItemSize = 60 +const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])' // Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps // exactly to the end, while a one-pixel nudge upward is a deliberate move away from it. const endEpsilon = 0.5 @@ -177,10 +178,48 @@ export function createTimelineVirtualizer(input: Input) { }) let overscanFrame: number | undefined + const pendingMeasurements = () => + virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key)) + const settleColdBottom = () => { + if (input.pinned()) virtualizer.scrollToEnd() + if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) { + overscanFrame = requestAnimationFrame(settleColdBottom) + return + } + overscanFrame = requestAnimationFrame(() => { + if (input.pinned()) virtualizer.scrollToEnd() + if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) { + settleColdBottom() + return + } + overscanFrame = undefined + const content = virtualContent + if (!content) return + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + content.style.removeProperty("visibility") + return + } + const animation = ["animate-in", "fade-in", "duration-150"] + const clearAnimation = (event: AnimationEvent) => { + if (event.target !== content) return + content.removeEventListener("animationend", clearAnimation) + content.removeEventListener("animationcancel", clearAnimation) + content.classList.remove(...animation) + } + content.addEventListener("animationend", clearAnimation) + content.addEventListener("animationcancel", clearAnimation) + content.classList.add(...animation) + content.style.removeProperty("visibility") + }) + } onMount(() => { overscanFrame = requestAnimationFrame(() => { - overscanFrame = undefined if (renderOverscan() < 20) setRenderOverscan(20) + if (!coldBottomMount) { + overscanFrame = undefined + return + } + settleColdBottom() }) }) @@ -367,11 +406,17 @@ export function createTimelineVirtualizer(input: Input) { {props.header}
{ virtualContent = element input.setContentRef(element) }} - style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }} + style={{ + height: `${virtualizer.getTotalSize()}px`, + position: "relative", + width: "100%", + visibility: coldBottomMount ? "hidden" : undefined, + }} > {(rowKey) => } 0}> diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index dd7850351cc..2f2f5141638 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -50,6 +50,7 @@ type RenderedBlock = type RenderResult = { text: string blocks: RenderedBlock[] + ready: boolean } const renderedCodeTokens = new WeakMap() @@ -367,8 +368,14 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { } } -function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult { - if (!text) return { text, blocks: [] } +function initialResult( + text: string, + key: string | undefined, + projection: Projection, + owner: string, + deferUntilReady: boolean | undefined, +): RenderResult { + if (!text) return { text, blocks: [], ready: true } const base = key ?? checksum(text) if (base) { const blocks = projection.blocks.flatMap((block, index) => { @@ -378,10 +385,12 @@ function initialResult(text: string, key: string | undefined, projection: Projec if (cached?.raw !== block.raw) return [] return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }] }) - if (blocks.length === projection.blocks.length) return { text, blocks } + if (blocks.length === projection.blocks.length) return { text, blocks, ready: true } } + if (deferUntilReady) return { text, blocks: [], ready: false } return { text, + ready: false, blocks: [ { key: "initial", @@ -403,11 +412,12 @@ export function Markdown( text: string cacheKey?: string streaming?: boolean + deferUntilReady?: boolean class?: string classList?: Record }, ) { - const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"]) + const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"]) const i18n = useI18n() const [root, setRoot] = createSignal() const owner = createUniqueId() @@ -448,10 +458,11 @@ export function Markdown( projection: value, } }, - async (src) => { + async (src): Promise => { if (isServer) return { text: src.text, + ready: true, blocks: [ { key: "server", @@ -462,7 +473,7 @@ export function Markdown( }, ], } satisfies RenderResult - if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult + if (!src.text) return { text: src.text, blocks: [], ready: true } satisfies RenderResult const base = src.key ?? checksum(src.text) return Promise.all( @@ -500,11 +511,12 @@ export function Markdown( return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe } }), ) - .then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult) + .then((blocks) => ({ text: src.text, blocks, ready: true }) satisfies RenderResult) .catch( () => ({ text: src.text, + ready: true, blocks: [ { key: base ?? "fallback", @@ -523,6 +535,7 @@ export function Markdown( local.cacheKey, local.streaming ? pendingProjection(local.text) : completedProjection(local.text), owner, + local.deferUntilReady, ), }, ) @@ -533,12 +546,14 @@ export function Markdown( const container = root() const result = html.latest ?? html() const projected = currentProjection() - const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : [] + const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner, local.deferUntilReady) : [] if (!container) return if (isServer) return + delete container.dataset.markdownReady if (content.length === 0) { disposeCopyButtons(container) container.innerHTML = "" + if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" return } @@ -567,6 +582,7 @@ export function Markdown( copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), })) + if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" }) onCleanup(() => { @@ -579,7 +595,6 @@ export function Markdown( return (
{ const current = initial ? undefined : result.blocks[index] diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 3bc7820bf8d..fee19a8173e 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -159,7 +159,7 @@ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boole return ( - + ) }