fix(app): stabilize initial timeline rendering (#44333)

This commit is contained in:
Brendan Allan 2026-08-23 16:06:50 +08:00 committed by GitHub
parent 1e3d3fcaca
commit 27a53969d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 93 additions and 18 deletions

View file

@ -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 },
),

View file

@ -13,6 +13,7 @@ export {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const prepared = new Set<string>()
const [resource] = createResource(
() => input.session.identity.sessionID(),
@ -20,7 +21,7 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
if (!id) return
const key = input.session.identity.sessionKey()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
void enrichLeadingTurn({
await enrichLeadingTurn({
current: () => 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<SessionModel, "identi
pause: () => 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

View file

@ -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) {
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
class="motion-reduce:animate-none"
ref={(element) => {
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,
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<Show when={rows().length > 0}>

View file

@ -50,6 +50,7 @@ type RenderedBlock =
type RenderResult = {
text: string
blocks: RenderedBlock[]
ready: boolean
}
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
@ -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<string, boolean>
},
) {
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<HTMLDivElement>()
const owner = createUniqueId()
@ -448,10 +458,11 @@ export function Markdown(
projection: value,
}
},
async (src) => {
async (src): Promise<RenderResult> => {
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 (
<div
data-component="markdown"
data-markdown-ready={html.loading ? undefined : ""}
dir="auto"
classList={{
...local.classList,
@ -596,9 +611,11 @@ function pendingBlocks(
projection: Projection | undefined,
cacheKey: string | undefined,
owner: string,
deferUntilReady: boolean | undefined,
) {
if (!result) return []
if (!projection || result.text === projection.text) return result.blocks
if (deferUntilReady) return result.blocks
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
return projection.blocks.map((block, index) => {
const current = initial ? undefined : result.blocks[index]

View file

@ -159,7 +159,7 @@ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boole
return (
<Show when={value()}>
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} />
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} deferUntilReady />
</Show>
)
}