fix(app): stabilize session timeline layout continuity (#34533)

This commit is contained in:
Luke Parker 2026-07-02 12:36:47 +10:00 committed by GitHub
commit 3cf71808c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 6159 additions and 142 deletions

View file

@ -26,6 +26,7 @@ import { FileProvider, selectionFromLines, useFile, type FileSelection, type Sel
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
@ -940,9 +941,11 @@ export default function Page() {
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
markScrollGesture()
const key = scrollKey(event)
if (key) {
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
markScrollGesture(scroller)
return
}

View file

@ -40,7 +40,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
@ -441,6 +441,16 @@ export function MessageTimeline(props: {
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || props.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
@ -462,9 +472,13 @@ export function MessageTimeline(props: {
})
}
resizeItem(index, size)
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (props.shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
item.end <= instance.getLogicalScrollOffset()
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
@ -491,24 +505,13 @@ export function MessageTimeline(props: {
})
})
let bottomAnchorSessionKey = ""
let bottomAnchorFrame: number | undefined
const maybeAnchorBottom = () => {
const key = sessionKey()
if (bottomAnchorSessionKey === key) return
if (timelineRows().length === 0) return
bottomAnchorSessionKey = key
if (!props.shouldAnchorBottom()) return
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
bottomAnchorFrame = requestAnimationFrame(() => {
bottomAnchorFrame = undefined
if (sessionKey() !== key) return
virtualizer.scrollToEnd()
})
virtualizer.scrollToEnd()
}
let measuredSessionKey = sessionKey()
@ -527,7 +530,6 @@ export function MessageTimeline(props: {
timelineCache.delete(ownerSessionKey)
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
props.setRevealMessage?.(() => {})
@ -600,6 +602,15 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(event.currentTarget)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
if (!prependLoading) clearPrependAnchor()
props.onMarkScrollGesture(event.currentTarget)
}
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (prependLoading) updatePrependAnchor()
props.onScheduleScrollState(event.currentTarget)
@ -976,10 +987,16 @@ export function MessageTimeline(props: {
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
})
const contextOpenKey = () => `context:${row().group.key}`
const open = createMemo(() => {
return toolOpen[contextOpenKey()] === true
})
return (
<ContextToolGroup
parts={parts()}
open={open()}
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
busy={
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
@ -1339,6 +1356,7 @@ export function MessageTimeline(props: {
onTouchEnd={handleListTouchEnd}
onTouchCancel={handleListTouchEnd}
onPointerDown={handleListPointerDown}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"

View file

@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { reuseTimelineRows } from "./row-reconciliation"
import { TimelineRow } from "./timeline-row"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({
userMessageID,
group: {
key,
type: "context",
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
})
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
describe("reuseTimelineRows", () => {
test.each([
{
name: "reuses an unchanged context group",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
reused: [[0, 0]],
},
{
name: "preserves the group key when a member is appended",
previous: [context("context:a", ["a"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "lets only the natural owner retain an old key after a split",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
reused: [],
},
{
name: "chooses the earliest prior key when groups merge",
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
rows: [context("context:b", ["b", "a"])],
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "reserves an old key for its natural owner when two new groups compete",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
reused: [],
},
{
name: "does not reuse context identity across user messages",
previous: [context("context:a", ["a", "b"], "user-1")],
rows: [context("context:b", ["b"], "user-2")],
expected: ["assistant-part:user-2:context:b"],
reused: [],
},
{
name: "reuses an unaffected ordinary row",
previous: [user()],
rows: [user()],
expected: ["user-message:user-1"],
reused: [[0, 0]],
},
{
name: "does not create accidental key collisions",
previous: [context("context:a", ["a", "b", "c"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
expected: [
"assistant-part:user-1:context:b",
"assistant-part:user-1:context:a",
"assistant-part:user-1:context:c",
],
reused: [],
},
])("$name", ({ previous, rows, expected, reused }) => {
const result = reuseTimelineRows([...previous], [...rows])
expect(keys(result)).toEqual([...expected])
expect(new Set(keys(result)).size).toBe(result.length)
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
})
})

View file

@ -1,8 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
@ -102,15 +105,3 @@ export function createTimelineProjection(input: {
rows,
}
}
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const next = rows.map((row) => {
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}

View file

@ -0,0 +1,56 @@
import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
const key = TimelineRow.key(row)
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
})
const claimed = new Set<string>()
const next = rows.map((input, index) => {
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
const owner = reserved.get(key)
if (owner !== undefined && owner !== rowIndex) return result
return !result || candidate.index < result.index ? candidate : result
}, undefined)
if (!existing) return row
const key = TimelineRow.key(existing.row)
claimed.add(key)
if (row.group.key === existing.row.group.key) return row
return new TimelineRow.AssistantPart({
...row,
group: { ...row.group, key: existing.row.group.key },
})
}

View file

@ -1,9 +1,9 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export { TimelineRow, type SummaryDiff } from "./timeline-row"
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
@ -29,81 +29,6 @@ export type TimelineRowMap = {
Error: { userMessageID: string; text: string }
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export namespace Timeline {
export function constructMessageRows(
userMessage: UserMessage,

View file

@ -0,0 +1,80 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}