fix(app): stabilize session timeline virtualization
This commit is contained in:
parent
bd14ab0174
commit
dcbe29c7c6
1 changed files with 180 additions and 90 deletions
|
|
@ -3,7 +3,7 @@ import { createStore, produce } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
import { VList, type VListHandle } from "virtua/solid"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Card } from "@opencode-ai/ui/card"
|
import { Card } from "@opencode-ai/ui/card"
|
||||||
|
|
@ -27,7 +27,6 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { SessionRetry } from "@opencode-ai/ui/session-retry"
|
import { SessionRetry } from "@opencode-ai/ui/session-retry"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||||
|
|
@ -103,6 +102,70 @@ function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undef
|
||||||
return a.every((key, index) => key === b[index])
|
return a.every((key, index) => key === b[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samePartGroup(a: PartGroup, b: PartGroup) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a.key !== b.key) return false
|
||||||
|
if (a.type !== b.type) return false
|
||||||
|
if (a.type === "part") {
|
||||||
|
if (b.type !== "part") return false
|
||||||
|
return a.ref.messageID === b.ref.messageID && a.ref.partID === b.ref.partID
|
||||||
|
}
|
||||||
|
if (b.type !== "context") return false
|
||||||
|
if (a.refs.length !== b.refs.length) return false
|
||||||
|
return a.refs.every((ref, index) => ref.messageID === b.refs[index]?.messageID && ref.partID === b.refs[index]?.partID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSummaryDiff(a: SummaryDiff, b: SummaryDiff) {
|
||||||
|
return a.file === b.file && a.patch === b.patch && a.additions === b.additions && a.deletions === b.deletions && a.status === b.status
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSummaryDiffs(a: readonly SummaryDiff[], b: readonly SummaryDiff[]) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((diff, index) => sameSummaryDiff(diff, b[index]!))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTimelineRow(a: TimelineRow, b: TimelineRow) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a.key !== b.key) return false
|
||||||
|
if (a.type !== b.type) return false
|
||||||
|
if (a.userMessageID !== b.userMessageID) return false
|
||||||
|
|
||||||
|
switch (a.type) {
|
||||||
|
case "comment-strip":
|
||||||
|
return b.type === "comment-strip" && a.previousUserMessage === b.previousUserMessage
|
||||||
|
case "user-message":
|
||||||
|
return b.type === "user-message" && a.anchor === b.anchor && a.previousUserMessage === b.previousUserMessage
|
||||||
|
case "turn-divider":
|
||||||
|
return b.type === "turn-divider" && a.label === b.label
|
||||||
|
case "assistant-part":
|
||||||
|
return (
|
||||||
|
b.type === "assistant-part" &&
|
||||||
|
a.previousAssistantPart === b.previousAssistantPart &&
|
||||||
|
a.lastAssistantPart === b.lastAssistantPart &&
|
||||||
|
samePartGroup(a.group, b.group)
|
||||||
|
)
|
||||||
|
case "thinking":
|
||||||
|
return b.type === "thinking" && a.reasoningHeading === b.reasoningHeading
|
||||||
|
case "retry":
|
||||||
|
return b.type === "retry"
|
||||||
|
case "diff-summary":
|
||||||
|
return b.type === "diff-summary" && sameSummaryDiffs(a.diffs, b.diffs)
|
||||||
|
case "error":
|
||||||
|
return b.type === "error" && a.text === b.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reuseTimelineRows(previous: TimelineRow[] | undefined, rows: TimelineRow[]) {
|
||||||
|
if (!previous?.length) return rows
|
||||||
|
const byKey = new Map(previous.map((row) => [row.key, row] as const))
|
||||||
|
return rows.map((row) => {
|
||||||
|
const existing = byKey.get(row.key)
|
||||||
|
if (!existing) return row
|
||||||
|
return sameTimelineRow(existing, row) ? existing : row
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function record(value: unknown): value is Record<string, unknown> {
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
@ -405,8 +468,7 @@ export function MessageTimeline(props: {
|
||||||
const { params, sessionKey } = useSessionKey()
|
const { params, sessionKey } = useSessionKey()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const [viewport, setViewport] = createSignal<HTMLDivElement>()
|
let virtualizer: VListHandle | undefined
|
||||||
let virtualizer: VirtualizerHandle | undefined
|
|
||||||
const sessionID = createMemo(() => params.id)
|
const sessionID = createMemo(() => params.id)
|
||||||
const sessionMessages = createMemo(() => {
|
const sessionMessages = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
|
|
@ -516,7 +578,7 @@ export function MessageTimeline(props: {
|
||||||
mapArray(
|
mapArray(
|
||||||
() => props.userMessages,
|
() => props.userMessages,
|
||||||
(userMessage, indexAccessor) => {
|
(userMessage, indexAccessor) => {
|
||||||
return createMemo(() => {
|
return createMemo((previous: TimelineRow[] | undefined) => {
|
||||||
const rows: TimelineRow[] = []
|
const rows: TimelineRow[] = []
|
||||||
const status = sessionStatus()
|
const status = sessionStatus()
|
||||||
const active = activeMessageID()
|
const active = activeMessageID()
|
||||||
|
|
@ -605,7 +667,7 @@ export function MessageTimeline(props: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
return reuseTimelineRows(previous, rows)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -662,15 +724,96 @@ export function MessageTimeline(props: {
|
||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
let head: HTMLDivElement | undefined
|
let head: HTMLDivElement | undefined
|
||||||
|
let listHost: HTMLDivElement | undefined
|
||||||
|
let listRoot: HTMLDivElement | undefined
|
||||||
|
let listCleanup = () => {}
|
||||||
|
let listFrame: number | undefined
|
||||||
|
|
||||||
|
const updateTitleMetrics = () => {
|
||||||
|
if (!head || head.clientWidth <= 0) return
|
||||||
|
setBar("ms", pace(head.clientWidth))
|
||||||
|
}
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => head,
|
() => head,
|
||||||
() => {
|
updateTitleMetrics,
|
||||||
if (!head || head.clientWidth <= 0) return
|
|
||||||
setBar("ms", pace(head.clientWidth))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const bindListRoot = () => {
|
||||||
|
const root = listHost?.firstElementChild
|
||||||
|
if (!(root instanceof HTMLDivElement)) return
|
||||||
|
if (root === listRoot) return
|
||||||
|
|
||||||
|
listCleanup()
|
||||||
|
listRoot = root
|
||||||
|
props.setScrollRef(root)
|
||||||
|
props.setContentRef(root.firstElementChild instanceof HTMLDivElement ? root.firstElementChild : root)
|
||||||
|
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
const delta = normalizeWheelDelta({
|
||||||
|
deltaY: event.deltaY,
|
||||||
|
deltaMode: event.deltaMode,
|
||||||
|
rootHeight: root.clientHeight,
|
||||||
|
})
|
||||||
|
if (!delta) return
|
||||||
|
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||||
|
}
|
||||||
|
const onTouchStart = (event: TouchEvent) => {
|
||||||
|
touchGesture = event.touches[0]?.clientY
|
||||||
|
}
|
||||||
|
const onTouchMove = (event: TouchEvent) => {
|
||||||
|
const next = event.touches[0]?.clientY
|
||||||
|
const prev = touchGesture
|
||||||
|
touchGesture = next
|
||||||
|
if (next === undefined || prev === undefined) return
|
||||||
|
|
||||||
|
const delta = prev - next
|
||||||
|
if (!delta) return
|
||||||
|
|
||||||
|
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||||
|
}
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
touchGesture = undefined
|
||||||
|
}
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.target !== root) return
|
||||||
|
props.onMarkScrollGesture(root)
|
||||||
|
}
|
||||||
|
const onClick = (event: MouseEvent) => props.onAutoScrollInteraction(event)
|
||||||
|
|
||||||
|
root.addEventListener("wheel", onWheel, { passive: true })
|
||||||
|
root.addEventListener("touchstart", onTouchStart, { passive: true })
|
||||||
|
root.addEventListener("touchmove", onTouchMove, { passive: true })
|
||||||
|
root.addEventListener("touchend", onTouchEnd)
|
||||||
|
root.addEventListener("touchcancel", onTouchEnd)
|
||||||
|
root.addEventListener("pointerdown", onPointerDown)
|
||||||
|
root.addEventListener("click", onClick)
|
||||||
|
listCleanup = () => {
|
||||||
|
root.removeEventListener("wheel", onWheel)
|
||||||
|
root.removeEventListener("touchstart", onTouchStart)
|
||||||
|
root.removeEventListener("touchmove", onTouchMove)
|
||||||
|
root.removeEventListener("touchend", onTouchEnd)
|
||||||
|
root.removeEventListener("touchcancel", onTouchEnd)
|
||||||
|
root.removeEventListener("pointerdown", onPointerDown)
|
||||||
|
root.removeEventListener("click", onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindListHost = (el: HTMLDivElement) => {
|
||||||
|
listHost = el
|
||||||
|
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||||
|
listFrame = requestAnimationFrame(() => {
|
||||||
|
listFrame = undefined
|
||||||
|
bindListRoot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||||
|
listCleanup()
|
||||||
|
props.setScrollRef(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
const viewShare = () => {
|
const viewShare = () => {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
|
@ -999,6 +1142,7 @@ export function MessageTimeline(props: {
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
|
"md:mx-auto": props.centered,
|
||||||
"pt-6":
|
"pt-6":
|
||||||
(input.row.type === "comment-strip" || input.row.type === "user-message") && input.row.previousUserMessage,
|
(input.row.type === "comment-strip" || input.row.type === "user-message") && input.row.previousUserMessage,
|
||||||
"pt-3": input.row.type === "assistant-part" && input.row.previousAssistantPart,
|
"pt-3": input.row.type === "assistant-part" && input.row.previousAssistantPart,
|
||||||
|
|
@ -1162,67 +1306,18 @@ export function MessageTimeline(props: {
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ScrollView
|
<div
|
||||||
viewportRef={(el) => {
|
class="relative min-w-0 w-full h-full flex flex-col"
|
||||||
setViewport(el)
|
|
||||||
props.setScrollRef(el)
|
|
||||||
}}
|
|
||||||
onWheel={(e) => {
|
|
||||||
const root = e.currentTarget
|
|
||||||
const delta = normalizeWheelDelta({
|
|
||||||
deltaY: e.deltaY,
|
|
||||||
deltaMode: e.deltaMode,
|
|
||||||
rootHeight: root.clientHeight,
|
|
||||||
})
|
|
||||||
if (!delta) return
|
|
||||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
touchGesture = e.touches[0]?.clientY
|
|
||||||
}}
|
|
||||||
onTouchMove={(e) => {
|
|
||||||
const next = e.touches[0]?.clientY
|
|
||||||
const prev = touchGesture
|
|
||||||
touchGesture = next
|
|
||||||
if (next === undefined || prev === undefined) return
|
|
||||||
|
|
||||||
const delta = prev - next
|
|
||||||
if (!delta) return
|
|
||||||
|
|
||||||
const root = e.currentTarget
|
|
||||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
|
||||||
}}
|
|
||||||
onTouchEnd={() => {
|
|
||||||
touchGesture = undefined
|
|
||||||
}}
|
|
||||||
onTouchCancel={() => {
|
|
||||||
touchGesture = undefined
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
if (e.target !== e.currentTarget) return
|
|
||||||
props.onMarkScrollGesture(e.currentTarget)
|
|
||||||
}}
|
|
||||||
onScroll={(e) => {
|
|
||||||
props.onScheduleScrollState(e.currentTarget)
|
|
||||||
props.onHistoryScroll()
|
|
||||||
if (!props.hasScrollGesture()) return
|
|
||||||
props.onUserScroll()
|
|
||||||
props.onAutoScrollHandleScroll()
|
|
||||||
props.onMarkScrollGesture(e.currentTarget)
|
|
||||||
}}
|
|
||||||
onClick={props.onAutoScrollInteraction}
|
|
||||||
class="relative min-w-0 w-full h-full"
|
|
||||||
style={{
|
style={{
|
||||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
<Show when={showHeader()}>
|
||||||
<Show when={showHeader()}>
|
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
head = el
|
head = el
|
||||||
setBar("ms", pace(el.clientWidth))
|
updateTitleMetrics()
|
||||||
}}
|
}}
|
||||||
data-session-title
|
data-session-title
|
||||||
classList={{
|
classList={{
|
||||||
|
|
@ -1507,36 +1602,31 @@ export function MessageTimeline(props: {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div ref={bindListHost} class="min-h-0 flex-1">
|
||||||
role="log"
|
<VList
|
||||||
data-slot="session-turn-list"
|
data={timelineRowKeys()}
|
||||||
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
shift={props.historyShift}
|
||||||
classList={{
|
keepMounted={keepMounted()}
|
||||||
"w-full": true,
|
ref={(handle) => {
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
virtualizer = handle
|
||||||
"mt-0.5": props.centered,
|
}}
|
||||||
"mt-0": !props.centered,
|
class="relative min-w-0 w-full h-full pb-16 no-scrollbar"
|
||||||
|
onScroll={() => {
|
||||||
|
const root = listRoot
|
||||||
|
if (!root) return
|
||||||
|
props.onScheduleScrollState(root)
|
||||||
|
props.onHistoryScroll()
|
||||||
|
if (!props.hasScrollGesture()) return
|
||||||
|
props.onUserScroll()
|
||||||
|
props.onAutoScrollHandleScroll()
|
||||||
|
props.onMarkScrollGesture(root)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={viewport()}>
|
{(key) => <TimelineRowView rowKey={key} />}
|
||||||
{(root) => (
|
</VList>
|
||||||
<Virtualizer
|
|
||||||
data={timelineRowKeys()}
|
|
||||||
scrollRef={root()}
|
|
||||||
shift={props.historyShift}
|
|
||||||
keepMounted={keepMounted()}
|
|
||||||
ref={(handle) => {
|
|
||||||
virtualizer = handle
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(key) => <TimelineRowView rowKey={key} />}
|
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue