Apply PR #26949: perf(app): virtualize session timeline rows

This commit is contained in:
opencode-agent[bot]
2026-05-13 15:26:04 +00:00
8 changed files with 1028 additions and 527 deletions

View File

@@ -764,7 +764,7 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
"virtua": "0.42.3",
"virtua": "0.49.1",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.10",
"zod": "4.1.8",
@@ -4904,7 +4904,7 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="],
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],

View File

@@ -74,7 +74,7 @@
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"virtua": "0.49.1",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",

View File

@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useSearchParams } from "@solidjs/router"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
@@ -85,205 +84,78 @@ type SessionHistoryWindowInput = {
scroller: () => HTMLDivElement | undefined
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
const historyScrollThreshold = 200
let shiftFrame: number | undefined
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
shift: false,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
const userMessages = createMemo(
() => input.visibleUserMessages(),
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
const cancelShiftReset = () => {
if (shiftFrame === undefined) return
cancelAnimationFrame(shiftFrame)
shiftFrame = undefined
}
const scheduleShiftReset = () => {
cancelShiftReset()
shiftFrame = requestAnimationFrame(() => {
shiftFrame = undefined
setState("shift", false)
})
}
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
let afterVisible = beforeVisible
let added = 0
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
if (afterVisible > beforeVisible) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (added <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, beforeVisible + turnBatch)
setTurnStart(Math.max(0, afterVisible - target))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const fetchOlderMessages = async () => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded()
let added = 0
let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (opts?.prefetch) break
if (!input.historyMore()) break
}
const afterVisible = input.visibleUserMessages().length
if (opts?.prefetch) {
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (added > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
if (growth > 0) {
scheduleShiftReset()
return
}
if (turnStart() !== start) return
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
setState("shift", false)
}
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
@@ -292,27 +164,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
cancelShiftReset()
setState({ shift: false })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
onCleanup(cancelShiftReset)
return {
turnStart,
setTurnStart,
renderedUserMessages,
userMessages,
shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
@@ -333,6 +196,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
@@ -737,6 +601,7 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {}
let scrollMark = 0
let messageMark = 0
@@ -1403,9 +1268,8 @@ export default function Page() {
},
)
const historyWindow = createSessionHistoryWindow({
const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
@@ -1427,9 +1291,9 @@ export default function Page() {
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
if (!historyMore()) return
void historyWindow.loadAndReveal()
void historyLoader.loadAndReveal()
})
}
@@ -1439,15 +1303,14 @@ export default function Page() {
[
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
([id, ready, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
if (!more) return
fill()
},
{ defer: true },
@@ -1749,15 +1612,14 @@ export default function Page() {
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
revealMessage: (id) => revealMessage(id),
scheduleScrollState,
consumePendingMessage: layout.pendingMessage.consume,
})
@@ -1831,7 +1693,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={messagesReady()}>
<Show when={!store.deferRender && messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
@@ -1853,8 +1715,11 @@ export default function Page() {
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1863,14 +1728,12 @@ export default function Page() {
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>

File diff suppressed because it is too large Load Diff

View File

@@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
revealMessage?: (id: string) => void
scheduleScrollState: (el: HTMLDivElement) => void
consumePendingMessage: (key: string) => string | undefined
}) => {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
let clearing = false
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
queue(() => {
seek(message.id, behavior)
})
updateHash(message.id)
return
}
input.revealMessage?.(message.id)
if (seek(message.id, behavior)) {
updateHash(message.id)
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
if (!input.sessionID() || !input.messagesReady()) return
visibleUserMessages()
input.turnStart()
let targetId = input.pendingMessage()
if (!targetId) {

View File

@@ -6,6 +6,7 @@
"exports": {
"./package.json": "./package.json",
"./*": "./src/components/*.tsx",
"./session-diff": "./src/components/session-diff.ts",
"./i18n/*": "./src/i18n/*.ts",
"./pierre": "./src/pierre/index.ts",
"./pierre/*": "./src/pierre/*.ts",

View File

@@ -1,4 +1,4 @@
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store"
@@ -40,26 +40,76 @@ export interface BasicToolProps {
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
let deferredFrame: number | undefined
function flushDeferredMounts() {
while (deferredMounts.length > 0) {
// Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
// Pop from the end so heavy default-open bodies near the bottom become interactive first.
const item = deferredMounts.pop()!
if (item.active) {
deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
item.fn()
return
}
}
deferredFrame = undefined
}
function scheduleDeferredFlush() {
if (deferredFrame !== undefined) return
deferredFrame = requestAnimationFrame(() => {
deferredFrame = requestAnimationFrame(flushDeferredMounts)
})
}
function scheduleDeferredMount(fn: () => void) {
const item = { active: true, fn }
deferredMounts.push(item)
scheduleDeferredFlush()
return () => {
item.active = false
}
}
function scheduleFrameMount(fn: () => void) {
const frame = requestAnimationFrame(fn)
return () => cancelAnimationFrame(frame)
}
export function BasicTool(props: BasicToolProps) {
const [state, setState] = createStore({
open: props.defaultOpen ?? false,
ready: props.defaultOpen ?? false,
ready: !props.defer && (props.defaultOpen ?? false),
})
const open = () => state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
let frame: number | undefined
let cancelReady: (() => void) | undefined
const cancel = () => {
if (frame === undefined) return
cancelAnimationFrame(frame)
frame = undefined
cancelReady?.()
cancelReady = undefined
}
const scheduleReady = (initial = false) => {
cancel()
cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
cancelReady = undefined
if (!open()) return
setState("ready", true)
})
}
onCleanup(cancel)
onMount(() => {
if (props.defer && open()) scheduleReady(true)
})
createEffect(() => {
if (props.forceOpen) setState("open", true)
})
@@ -75,12 +125,7 @@ export function BasicTool(props: BasicToolProps) {
return
}
cancel()
frame = requestAnimationFrame(() => {
frame = undefined
if (!open()) return
setState("ready", true)
})
scheduleReady()
},
{ defer: true },
),
@@ -189,7 +234,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
@@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) {
</Collapsible.Trigger>
)}
</Show>
<Show when={props.animated && props.children && !props.hideDetails}>
<Show when={props.animated && hasChildren() && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
@@ -229,10 +274,10 @@ export function BasicTool(props: BasicToolProps) {
overflow: initialOpen ? "visible" : "hidden",
}}
>
{props.children}
<Show when={!props.defer || ready()}>{props.children}</Show>
</div>
</Show>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>

View File

@@ -150,6 +150,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
deferToolContent?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
@@ -486,12 +487,12 @@ function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
return a.every((x, i) => x === b[i])
}
type PartRef = {
export type PartRef = {
messageID: string
partID: string
}
type PartGroup =
export type PartGroup =
| {
key: string
type: "part"
@@ -520,14 +521,14 @@ function sameGroup(a: PartGroup, b: PartGroup) {
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
}
function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((item, i) => sameGroup(item, b[i]!))
}
function groupParts(parts: { messageID: string; part: PartType }[]) {
export function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = []
let start = -1
@@ -575,7 +576,7 @@ function index<T extends { id: string }>(items: readonly T[]) {
return new Map(items.map((item) => [item.id, item] as const))
}
function renderable(part: PartType, showReasoningSummaries = true) {
export function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
@@ -591,7 +592,7 @@ function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
}
function partDefaultOpen(part: PartType, shell = false, edit = false) {
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
return toolDefaultOpen(part.tool, shell, edit)
}
@@ -904,7 +905,7 @@ export function AssistantMessageDisplay(props: {
)
}
function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(
@@ -914,7 +915,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost" class="tool-collapsible">
<Collapsible
open={open()}
onOpenChange={setOpen}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
>
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<span
@@ -1077,7 +1084,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}
return (
<div data-component="user-message">
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -1228,6 +1235,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
@@ -1244,6 +1252,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
deferContent?: boolean
forceOpen?: boolean
locked?: boolean
}
@@ -1344,7 +1353,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
return (
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper">
<div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
<Switch>
<Match when={part().state.status === "error" && (part().state as any).error}>
{(error) => {
@@ -1382,6 +1391,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
deferContent={props.deferToolContent}
/>
</Match>
</Switch>
@@ -1487,7 +1497,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return (
<Show when={text()}>
<div data-component="text-part">
<div data-component="text-part" data-timeline-part-id={part().id}>
<div data-slot="text-part-body">
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
@@ -1531,7 +1541,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
<Show when={text()}>
<div data-component="reasoning-part">
<div data-component="reasoning-part" data-timeline-part-id={part().id}>
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</Show>
@@ -1909,7 +1919,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
@@ -1970,7 +1980,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
@@ -2052,7 +2062,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
@@ -2124,7 +2134,7 @@ ToolRegistry.register({
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<Show when={props.deferContent === false || visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
@@ -2149,7 +2159,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">