fix(app): align virtual timeline scrolling

This commit is contained in:
LukeParkerDev 2026-05-12 15:43:48 +10:00
commit 240201b139
2 changed files with 29 additions and 33 deletions

View file

@ -94,6 +94,9 @@ type TimelineRow =
| { key: string; type: "retry"; userMessageID: string } | { key: string; type: "retry"; userMessageID: string }
| { key: string; type: "diff-summary"; userMessageID: string; diffs: SummaryDiff[] } | { key: string; type: "diff-summary"; userMessageID: string; diffs: SummaryDiff[] }
| { key: string; type: "error"; userMessageID: string; text: string } | { key: string; type: "error"; userMessageID: string; text: string }
| { key: string; type: "bottom-spacer" }
type FramedTimelineRow = Exclude<TimelineRow, { type: "bottom-spacer" }>
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
if (a === b) return true if (a === b) return true
@ -129,6 +132,8 @@ function sameTimelineRow(a: TimelineRow, b: TimelineRow) {
if (a === b) return true if (a === b) return true
if (a.key !== b.key) return false if (a.key !== b.key) return false
if (a.type !== b.type) return false if (a.type !== b.type) return false
if (a.type === "bottom-spacer") return b.type === "bottom-spacer"
if (b.type === "bottom-spacer") return false
if (a.userMessageID !== b.userMessageID) return false if (a.userMessageID !== b.userMessageID) return false
switch (a.type) { switch (a.type) {
@ -371,25 +376,6 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
<For each={visible()}> <For each={visible()}>
{(diff) => { {(diff) => {
const view = normalize(diff) const view = normalize(diff)
const active = createMemo(() => expanded().includes(diff.file))
const [shown, setShown] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setShown(false)
return
}
requestAnimationFrame(() => {
if (active()) setShown(true)
})
},
{ defer: true },
),
)
return ( return (
<Accordion.Item value={diff.file}> <Accordion.Item value={diff.file}>
@ -414,11 +400,9 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
</Accordion.Trigger> </Accordion.Trigger>
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content> <Accordion.Content>
<Show when={shown()}> <div data-slot="session-turn-diff-view" data-scrollable>
<div data-slot="session-turn-diff-view" data-scrollable> <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} /> </div>
</div>
</Show>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
) )
@ -673,12 +657,17 @@ export function MessageTimeline(props: {
) )
) )
const timelineRows = createMemo(() => messageRowMemos().flatMap((memo) => memo())) const timelineRows = createMemo((previous: TimelineRow[] | undefined) => {
const rows = messageRowMemos().flatMap((memo) => memo())
if (rows.length === 0) return rows
return reuseTimelineRows(previous, [...rows, { key: "bottom-spacer", type: "bottom-spacer" }])
})
const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys }) const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys })
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const))) const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const)))
const messageRowIndex = createMemo(() => { const messageRowIndex = createMemo(() => {
const result = new Map<string, number>() const result = new Map<string, number>()
timelineRows().forEach((row, index) => { timelineRows().forEach((row, index) => {
if (!("userMessageID" in row)) return
if (result.has(row.userMessageID)) return if (result.has(row.userMessageID)) return
result.set(row.userMessageID, index) result.set(row.userMessageID, index)
}) })
@ -688,7 +677,7 @@ export function MessageTimeline(props: {
const id = activeMessageID() const id = activeMessageID()
if (!id) return if (!id) return
const rows = timelineRows() const rows = timelineRows()
const index = rows.findLastIndex((row) => row.userMessageID === id) const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
if (index < 0) return if (index < 0) return
return [index] return [index]
}) })
@ -1127,11 +1116,12 @@ export function MessageTimeline(props: {
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)} showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)} turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(part, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())} defaultOpen={partDefaultOpen(part, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())}
deferToolContent={false}
/> />
) )
} }
function TimelineRowFrame(input: { row: TimelineRow; children: JSX.Element }) { function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
const anchor = () => input.row.type === "comment-strip" || (input.row.type === "user-message" && input.row.anchor) const anchor = () => input.row.type === "comment-strip" || (input.row.type === "user-message" && input.row.anchor)
return ( return (
@ -1269,6 +1259,8 @@ export function MessageTimeline(props: {
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
) )
case "bottom-spacer":
return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
} }
} }
@ -1611,7 +1603,7 @@ export function MessageTimeline(props: {
ref={(handle) => { ref={(handle) => {
virtualizer = handle virtualizer = handle
}} }}
class="relative min-w-0 w-full h-full pb-16 no-scrollbar" class="relative min-w-0 w-full h-full"
onScroll={() => { onScroll={() => {
const root = listRoot const root = listRoot
if (!root) return if (!root) return

View file

@ -150,6 +150,7 @@ export interface MessagePartProps {
message: MessageType message: MessageType
hideDetails?: boolean hideDetails?: boolean
defaultOpen?: boolean defaultOpen?: boolean
deferToolContent?: boolean
showAssistantCopyPartID?: string | null showAssistantCopyPartID?: string | null
turnDurationMs?: number turnDurationMs?: number
} }
@ -1233,6 +1234,7 @@ export function Part(props: MessagePartProps) {
message={props.message} message={props.message}
hideDetails={props.hideDetails} hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen} defaultOpen={props.defaultOpen}
deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID} showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs} turnDurationMs={props.turnDurationMs}
/> />
@ -1249,6 +1251,7 @@ export interface ToolProps {
status?: string status?: string
hideDetails?: boolean hideDetails?: boolean
defaultOpen?: boolean defaultOpen?: boolean
deferContent?: boolean
forceOpen?: boolean forceOpen?: boolean
locked?: boolean locked?: boolean
} }
@ -1387,6 +1390,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status} status={part().state.status}
hideDetails={props.hideDetails} hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen} defaultOpen={props.defaultOpen}
deferContent={props.deferToolContent}
/> />
</Match> </Match>
</Switch> </Switch>
@ -1888,7 +1892,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={ trigger={
<div data-component="edit-trigger"> <div data-component="edit-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">
@ -1960,7 +1964,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={ trigger={
<div data-component="write-trigger"> <div data-component="write-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">
@ -2042,7 +2046,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={{ trigger={{
title: i18n.t("ui.tool.patch"), title: i18n.t("ui.tool.patch"),
subtitle: subtitle(), subtitle: subtitle(),
@ -2114,7 +2118,7 @@ ToolRegistry.register({
</Accordion.Trigger> </Accordion.Trigger>
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content> <Accordion.Content>
<Show when={visible()}> <Show when={props.deferContent === false || visible()}>
<div data-component="apply-patch-file-diff"> <div data-component="apply-patch-file-diff">
<Dynamic component={fileComponent} mode="diff" fileDiff={file.view.fileDiff} /> <Dynamic component={fileComponent} mode="diff" fileDiff={file.view.fileDiff} />
</div> </div>
@ -2134,7 +2138,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={ trigger={
<div data-component="edit-trigger"> <div data-component="edit-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">