mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 10:21:55 +00:00
fix(app): stabilize initial timeline rendering (#44333)
This commit is contained in:
parent
1e3d3fcaca
commit
27a53969d6
5 changed files with 93 additions and 18 deletions
|
|
@ -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 },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue