feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331)
This commit is contained in:
parent
e772664389
commit
3b811bd019
49 changed files with 2824 additions and 845 deletions
|
|
@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => {
|
|||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("initializes text delta accumulation from the current part text", () => {
|
||||
const part = { ...textPart("part", "session", "message"), text: "existing" }
|
||||
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.part_text_accum_delta.part).toBe("existing appended")
|
||||
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
|
||||
})
|
||||
|
||||
test("preserves a Home-specific retained session limit", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
|
|
|
|||
|
|
@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: {
|
|||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[result.index]?.[field]
|
||||
input.setStore(
|
||||
"part_text_accum_delta",
|
||||
props.partID,
|
||||
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
|
||||
)
|
||||
input.setStore(
|
||||
"part",
|
||||
props.messageID,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
|
|
@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => {
|
|||
expect(starts).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text") => ({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
||||
} as Event,
|
||||
})
|
||||
|
||||
test("merges adjacent deltas for the same field", () => {
|
||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
|
||||
}
|
||||
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
|
||||
|
||||
expect(result.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.delta",
|
||||
"message.part.delta",
|
||||
"session.status",
|
||||
"message.part.delta",
|
||||
])
|
||||
})
|
||||
|
||||
test("drops stale deltas", () => {
|
||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,39 @@ const isAbortError = (error: unknown) =>
|
|||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
const deltas = new Map<string, number>()
|
||||
events.forEach((event) => {
|
||||
if (stale && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
||||
}
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
deltas.clear()
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
const props = event.payload.properties
|
||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
||||
const index = deltas.get(id)
|
||||
const existing = index === undefined ? undefined : output[index]
|
||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
||||
deltas.set(id, output.length)
|
||||
output.push({
|
||||
directory: event.directory,
|
||||
payload: { ...event.payload, properties: { ...props } },
|
||||
})
|
||||
return
|
||||
}
|
||||
existing.payload.properties.delta += props.delta
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||
if (!event.persisted) return
|
||||
|
|
@ -45,7 +78,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = { directory: string; payload: Event }
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const RECONNECT_DELAY_MS = 250
|
||||
|
|
@ -57,8 +90,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
|
|
@ -83,14 +114,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events, skip)
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (skip && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||
}
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
|
|
|
|||
|
|
@ -2366,7 +2366,7 @@ export default function Layout(props: ParentProps) {
|
|||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
|
|
@ -2519,7 +2519,7 @@ export default function Layout(props: ParentProps) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
on,
|
||||
onMount,
|
||||
untrack,
|
||||
createResource,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
|
@ -33,7 +32,6 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
|||
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"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
|
|
@ -54,7 +52,8 @@ import {
|
|||
shouldFocusTerminalOnKeyDown,
|
||||
shouldShowFileTree,
|
||||
} from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useServer } from "@/context/server"
|
||||
|
|
@ -67,11 +66,9 @@ import { Identifier } from "@/utils/id"
|
|||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
|
|
@ -79,110 +76,6 @@ const emptyFollowups: FollowupItem[] = []
|
|||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
loaded: () => number
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
userScrolled: () => boolean
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
||||
const historyScrollThreshold = 200
|
||||
let shiftFrame: number | undefined
|
||||
|
||||
const [state, setState] = createStore({
|
||||
shift: false,
|
||||
})
|
||||
|
||||
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
|
||||
equals: same,
|
||||
})
|
||||
|
||||
const cancelShiftReset = () => {
|
||||
if (shiftFrame === undefined) return
|
||||
cancelAnimationFrame(shiftFrame)
|
||||
shiftFrame = undefined
|
||||
}
|
||||
|
||||
const scheduleShiftReset = () => {
|
||||
cancelShiftReset()
|
||||
shiftFrame = requestAnimationFrame(() => {
|
||||
shiftFrame = undefined
|
||||
setState("shift", false)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchOlderMessages = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
let loaded = input.loaded()
|
||||
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
|
||||
loaded = nextLoaded
|
||||
growth = input.visibleUserMessages().length - beforeVisible
|
||||
|
||||
if (growth > 0) break
|
||||
if (raw <= 0) break
|
||||
if (!input.historyMore()) break
|
||||
}
|
||||
|
||||
if (growth > 0) {
|
||||
scheduleShiftReset()
|
||||
return
|
||||
}
|
||||
|
||||
setState("shift", false)
|
||||
}
|
||||
|
||||
const loadAndReveal = () => fetchOlderMessages()
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= historyScrollThreshold) return
|
||||
|
||||
void fetchOlderMessages()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
cancelShiftReset()
|
||||
setState({ shift: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(cancelShiftReset)
|
||||
|
||||
return {
|
||||
userMessages,
|
||||
shift: () => state.shift,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
|
|
@ -323,39 +216,15 @@ export default function Page() {
|
|||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const messagesReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
return sync().data.message[id] !== undefined
|
||||
})
|
||||
const historyMore = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync().session.history.more(id)
|
||||
})
|
||||
const historyLoading = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync().session.history.loading(id)
|
||||
})
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
|
||||
const historyLoading = timeline.history.loading
|
||||
const historyMore = timeline.history.more
|
||||
const lastUserMessage = timeline.lastUserMessage
|
||||
const messages = timeline.messages
|
||||
const messagesReady = timeline.ready
|
||||
const sessionSync = timeline.resource
|
||||
const userMessages = timeline.userMessages
|
||||
const visibleUserMessages = timeline.visibleUserMessages
|
||||
|
||||
createEffect(() => {
|
||||
const tab = activeFileTab()
|
||||
|
|
@ -423,8 +292,6 @@ export default function Page() {
|
|||
}, sessionKey())
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let refreshFrame: number | undefined
|
||||
let refreshTimer: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
|
|
@ -614,6 +481,7 @@ export default function Page() {
|
|||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let revealMessage = (_id: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
|
|
@ -632,39 +500,6 @@ export default function Page() {
|
|||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
const [sessionSync] = createResource(
|
||||
() => [sdk().directory, params.id] as const,
|
||||
([directory, id]) => {
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
refreshFrame = undefined
|
||||
refreshTimer = undefined
|
||||
if (!id) return
|
||||
|
||||
const cached = untrack(() => sync().data.message[id] !== undefined)
|
||||
const stale = !cached
|
||||
? false
|
||||
: (() => {
|
||||
const info = getSessionPrefetch(serverSDK().scope, directory, id)
|
||||
if (!info) return true
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
|
||||
refreshFrame = requestAnimationFrame(() => {
|
||||
refreshFrame = undefined
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined
|
||||
if (params.id !== id) return
|
||||
untrack(() => {
|
||||
if (stale) void sync().session.sync(id, { force: true })
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
return sync().session.sync(id)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
|
|
@ -1202,8 +1037,18 @@ export default function Page() {
|
|||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: () => true,
|
||||
overflowAnchor: "dynamic",
|
||||
overflowAnchor: "none",
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id, previous) => {
|
||||
if (!id || !previous || id === previous) return
|
||||
if (location.hash || store.messageId || ui.pendingMessage) return
|
||||
autoScroll.resume()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
|
|
@ -1239,7 +1084,8 @@ export default function Page() {
|
|||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
|
|
@ -1282,16 +1128,14 @@ export default function Page() {
|
|||
},
|
||||
)
|
||||
|
||||
const historyLoader = createSessionHistoryLoader({
|
||||
sessionID: () => params.id,
|
||||
loaded: () => messages().length,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
userScrolled: autoScroll.userScrolled,
|
||||
scroller: () => scroller,
|
||||
})
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
const loadOlder = () =>
|
||||
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
const onHistoryScroll = () => {
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
|
||||
void loadOlder()
|
||||
}
|
||||
|
||||
fill = () => {
|
||||
if (fillFrame !== undefined) return
|
||||
|
|
@ -1307,7 +1151,7 @@ export default function Page() {
|
|||
if (el.scrollHeight > el.clientHeight + 1) return
|
||||
if (!historyMore()) return
|
||||
|
||||
void historyLoader.loadAndReveal()
|
||||
void loadOlder()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1615,7 +1459,7 @@ export default function Page() {
|
|||
|
||||
dockHeight = next
|
||||
|
||||
if (stick) autoScroll.forceScrollToBottom()
|
||||
if (stick) scrollToEnd()
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
fill()
|
||||
|
|
@ -1634,7 +1478,13 @@ export default function Page() {
|
|||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
autoScroll,
|
||||
autoScroll: {
|
||||
pause: autoScroll.pause,
|
||||
forceScrollToBottom: () => {
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
},
|
||||
},
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
revealMessage: (id) => revealMessage(id),
|
||||
|
|
@ -1657,8 +1507,6 @@ export default function Page() {
|
|||
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
|
|
@ -1791,37 +1639,45 @@ export default function Page() {
|
|||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
/>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
|
|
|
|||
30
packages/app/src/pages/session/timeline/measure.test.ts
Normal file
30
packages/app/src/pages/session/timeline/measure.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
|
||||
test("does not measure an element detached before the frame", async () => {
|
||||
const element = document.createElement("div")
|
||||
document.body.append(element)
|
||||
let calls = 0
|
||||
|
||||
scheduleConnectedMeasure(element, () => {
|
||||
calls += 1
|
||||
})
|
||||
element.remove()
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
test("measures a connected element on the next frame", async () => {
|
||||
const element = document.createElement("div")
|
||||
document.body.append(element)
|
||||
let calls = 0
|
||||
|
||||
scheduleConnectedMeasure(element, () => {
|
||||
calls += 1
|
||||
})
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
element.remove()
|
||||
})
|
||||
5
packages/app/src/pages/session/timeline/measure.ts
Normal file
5
packages/app/src/pages/session/timeline/measure.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
|
||||
return requestAnimationFrame(() => {
|
||||
if (element.isConnected) measure(element)
|
||||
})
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@ import {
|
|||
Index,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
mapArray,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
|
|
@ -15,7 +15,7 @@ import { createStore, produce } from "solid-js/store"
|
|||
import { Dynamic } from "solid-js/web"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
|
|
@ -49,7 +49,6 @@ import type {
|
|||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/ui/session-diff"
|
||||
|
|
@ -69,7 +68,9 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
|||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
|
|
@ -77,43 +78,14 @@ const emptyTools: ToolPart[] = []
|
|||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "BottomSpacer" }>
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
|
||||
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
||||
|
||||
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((key, index) => key === b[index])
|
||||
}
|
||||
|
||||
const timelineCacheLimit = 16
|
||||
const timelineFallbackItemSize = 60
|
||||
const timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>()
|
||||
|
||||
function readTimelineCache(id: string, keys: readonly string[]) {
|
||||
const entry = timelineCache.get(id)
|
||||
if (!entry) return
|
||||
if (sameKeys(entry.keys, keys)) return entry.cache
|
||||
timelineCache.delete(id)
|
||||
}
|
||||
|
||||
function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
|
||||
if (!handle || keys.length === 0) return
|
||||
timelineCache.delete(id)
|
||||
timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
|
||||
while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
|
||||
}
|
||||
|
||||
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))
|
||||
return rows.map((row) => {
|
||||
const existing = byKey.get(TimelineRow.key(row))
|
||||
if (!existing) return row
|
||||
return TimelineRow.equals(existing, row) ? existing : row
|
||||
})
|
||||
}
|
||||
const timelineCache = new Map<
|
||||
string,
|
||||
{ measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }
|
||||
>()
|
||||
|
||||
const taskDescription = (part: PartType, sessionID: string) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return
|
||||
|
|
@ -278,10 +250,11 @@ export function MessageTimeline(props: {
|
|||
shouldAnchorBottom: () => boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
historyShift: boolean
|
||||
userMessages: UserMessage[]
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
|
|
@ -293,40 +266,21 @@ export function MessageTimeline(props: {
|
|||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const ownerSessionKey = sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
|
||||
const platform = usePlatform()
|
||||
|
||||
let virtualizer: VirtualizerHandle | undefined
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
const result = new Map<string, AssistantMessage[]>()
|
||||
for (const message of sessionMessages()) {
|
||||
if (message.role !== "assistant") continue
|
||||
const messages = result.get(message.parentID)
|
||||
if (messages) {
|
||||
messages.push(message)
|
||||
continue
|
||||
}
|
||||
result.set(message.parentID, [message])
|
||||
}
|
||||
return result
|
||||
})
|
||||
const pending = createMemo(() =>
|
||||
sessionMessages().findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
),
|
||||
)
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => sessionStatus().type !== "idle")
|
||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
|
||||
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
|
@ -344,25 +298,6 @@ export function MessageTimeline(props: {
|
|||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
||||
})
|
||||
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
const messages = sessionMessages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||
if (message && message.role === "user") return message.id
|
||||
}
|
||||
|
||||
const status = sessionStatus()
|
||||
if (status.type !== "idle") {
|
||||
const messages = sessionMessages()
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
|
|
@ -385,6 +320,7 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
|
|
@ -401,147 +337,217 @@ export function MessageTimeline(props: {
|
|||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
parts: getMsgParts,
|
||||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
})
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||
const messageByID = projection.messageByID
|
||||
const messageLastRowIndex = projection.messageLastRowIndex
|
||||
const messageRowIndex = projection.messageRowIndex
|
||||
const timelineRowByKey = projection.rowByKey
|
||||
const timelineRows = projection.rows
|
||||
|
||||
const messageRowMemos = createMemo(
|
||||
mapArray(
|
||||
() => props.userMessages,
|
||||
(userMessage, indexAccessor) => {
|
||||
return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
|
||||
const rows = Timeline.constructMessageRows(
|
||||
userMessage,
|
||||
getMsgParts,
|
||||
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
|
||||
indexAccessor(),
|
||||
settings.general.showReasoningSummaries(),
|
||||
sessionStatus().type,
|
||||
activeMessageID() === userMessage.id,
|
||||
)
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
let prependLoading = false
|
||||
const clearPrependAnchor = () => {
|
||||
prependLoading = false
|
||||
prependAnchor = undefined
|
||||
if (prependAnchorFrame === undefined) return
|
||||
cancelAnimationFrame(prependAnchorFrame)
|
||||
prependAnchorFrame = undefined
|
||||
}
|
||||
const capturePrependAnchor = () => {
|
||||
prependLoading = true
|
||||
updatePrependAnchor()
|
||||
}
|
||||
const updatePrependAnchor = () => {
|
||||
const root = listRoot()
|
||||
if (!root) return
|
||||
const view = root.getBoundingClientRect()
|
||||
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
||||
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
||||
if (!anchor) return
|
||||
if (!anchor.element.dataset.timelineKey) return
|
||||
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
|
||||
}
|
||||
const restorePrependAnchor = (done: boolean) => {
|
||||
if (done) prependLoading = false
|
||||
applyPrependAnchor()
|
||||
}
|
||||
const applyPrependAnchor = () => {
|
||||
const root = listRoot()
|
||||
if (!root || !prependAnchor) return
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
let frames = 0
|
||||
let stable = 0
|
||||
const apply = () => {
|
||||
prependAnchorFrame = undefined
|
||||
const anchor = prependAnchor
|
||||
if (!anchor) return
|
||||
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
|
||||
const delta = element
|
||||
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
|
||||
: undefined
|
||||
if (delta !== undefined && Math.abs(delta) > 0.5) {
|
||||
root.scrollTop += delta
|
||||
stable = 0
|
||||
} else {
|
||||
stable += 1
|
||||
}
|
||||
frames += 1
|
||||
if (stable >= 30 || frames >= 180) {
|
||||
if (!prependLoading) prependAnchor = undefined
|
||||
return
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
|
||||
return reuseTimelineRows(previous, rows)
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
|
||||
let resizePinnedIndexes: number[] = []
|
||||
let resizePinFrame: number | undefined
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return timelineRows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
// Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height.
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
},
|
||||
get getItemKey() {
|
||||
const rows = timelineRows()
|
||||
return (index: number) => {
|
||||
const row = rows[index]
|
||||
// ResizeObserver can report a removed element after its row has left the projection.
|
||||
if (!row) return `removed:${index}`
|
||||
return TimelineRow.key(row)
|
||||
}
|
||||
},
|
||||
anchorTo: "end",
|
||||
followOnAppend: true,
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return showHeader() ? 64 : 0
|
||||
},
|
||||
overscan: 50,
|
||||
paddingEnd: 64,
|
||||
rangeExtractor: (range) => {
|
||||
const id = activeMessageID()
|
||||
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
|
||||
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
const item = virtualizer.measurementsCache[index]
|
||||
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
|
||||
const root = listRoot()
|
||||
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
|
||||
const view = root.getBoundingClientRect()
|
||||
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
},
|
||||
),
|
||||
.map((element) => Number(element.dataset.index))
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
resizePinFrame = requestAnimationFrame(() => {
|
||||
resizePinFrame = requestAnimationFrame(() => {
|
||||
resizePinFrame = undefined
|
||||
resizePinnedIndexes = []
|
||||
})
|
||||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
||||
item.end <= instance.getLogicalScrollOffset()
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
|
||||
const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
|
||||
const rows = messageRowMemos().flatMap((memo) => memo())
|
||||
if (rows.length === 0) return rows
|
||||
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
||||
})
|
||||
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
|
||||
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
if (!("userMessageID" in row)) return
|
||||
if (result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const lastAssistantGroupKey = createMemo(() => {
|
||||
const result = new Map<string, string>()
|
||||
timelineRows().forEach((row) => {
|
||||
if (row._tag !== "AssistantPart") return
|
||||
result.set(row.userMessageID, row.group.key)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const keepMounted = createMemo(() => {
|
||||
const id = activeMessageID()
|
||||
if (!id) return
|
||||
const rows = timelineRows()
|
||||
const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
|
||||
if (index < 0) return
|
||||
return [index]
|
||||
})
|
||||
const activeAssistantMessages = createMemo(() => {
|
||||
const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
|
||||
if (!id) return emptyAssistantMessages
|
||||
return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
|
||||
})
|
||||
const activeAssistantContentVersion = createMemo(() =>
|
||||
activeAssistantMessages()
|
||||
.flatMap((message) => [
|
||||
`${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
|
||||
...getMsgParts(message.id).map((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
|
||||
if (part.type === "tool") {
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
const output =
|
||||
"output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
|
||||
const metadataOutput =
|
||||
metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
|
||||
? metadata.output.length
|
||||
: 0
|
||||
return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
|
||||
}
|
||||
return `${part.id}:${part.type}`
|
||||
}),
|
||||
])
|
||||
.join("|"),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
|
||||
() => {
|
||||
if (!virtualizer) return
|
||||
if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
|
||||
const keys = timelineRowKeys()
|
||||
if (keys.length === 0) return
|
||||
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
||||
scheduleMeasuredBottomAnchor()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string))
|
||||
createEffect(() => {
|
||||
props.setRevealMessage?.((id) => {
|
||||
const index = messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
virtualizer?.scrollToIndex(index, { align: "center" })
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
props.setScrollToEnd?.(() => virtualizer.scrollToEnd())
|
||||
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
|
||||
})
|
||||
|
||||
let overscanFrame: number | undefined
|
||||
onMount(() => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
let cacheSessionKey = sessionKey()
|
||||
let cacheRowKeys = timelineRowKeys()
|
||||
let virtualizerSessionKey = cacheSessionKey
|
||||
let virtualizerRowKeys = cacheRowKeys
|
||||
let bottomAnchorSessionKey = ""
|
||||
let bottomAnchorFrame: number | undefined
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
const key = sessionKey()
|
||||
if (bottomAnchorSessionKey === key) return
|
||||
if (!virtualizer) return
|
||||
const keys = timelineRowKeys()
|
||||
if (keys.length === 0) return
|
||||
if (timelineRows().length === 0) return
|
||||
bottomAnchorSessionKey = key
|
||||
if (!props.shouldAnchorBottom()) return
|
||||
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
bottomAnchorFrame = requestAnimationFrame(() => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (sessionKey() !== key) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), timelineRowKeys()] as const,
|
||||
(next, prev) => {
|
||||
if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
|
||||
cacheSessionKey = next[0]
|
||||
cacheRowKeys = next[1]
|
||||
if (virtualizer) {
|
||||
virtualizerSessionKey = cacheSessionKey
|
||||
virtualizerRowKeys = cacheRowKeys
|
||||
maybeAnchorBottom()
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
let measuredSessionKey = sessionKey()
|
||||
createEffect(() => {
|
||||
const key = sessionKey()
|
||||
timelineRows().length
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
virtualizer.measure()
|
||||
}
|
||||
maybeAnchorBottom()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
||||
clearPrependAnchor()
|
||||
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?.(() => {})
|
||||
props.setScrollToEnd?.(() => {})
|
||||
props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
|
||||
})
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
|
|
@ -560,17 +566,8 @@ export function MessageTimeline(props: {
|
|||
const [bar, setBar] = createStore({
|
||||
ms: pace(640),
|
||||
})
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||
|
||||
let more: HTMLButtonElement | undefined
|
||||
let head: HTMLDivElement | undefined
|
||||
let listRoot: HTMLDivElement | undefined
|
||||
let listFrame: number | undefined
|
||||
let contentFrame: number | undefined
|
||||
let bottomAnchorFrame: number | undefined
|
||||
let bottomAnchorFrames = 0
|
||||
let measuredBottomAnchored = true
|
||||
const [scrollRoot, setScrollRoot] = createSignal<HTMLDivElement>()
|
||||
|
||||
const updateTitleMetrics = () => {
|
||||
if (!head || head.clientWidth <= 0) return
|
||||
|
|
@ -579,83 +576,14 @@ export function MessageTimeline(props: {
|
|||
|
||||
createResizeObserver(() => head, updateTitleMetrics)
|
||||
|
||||
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
|
||||
|
||||
const measureTimeline = () => {
|
||||
virtualizer?.measure()
|
||||
anchorMeasuredBottom()
|
||||
}
|
||||
|
||||
function anchorMeasuredBottom() {
|
||||
if (!listRoot) return false
|
||||
if (!measuredBottomAnchored) return false
|
||||
listRoot.scrollTop = listRoot.scrollHeight
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleMeasuredBottomAnchor() {
|
||||
// Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
|
||||
// "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
|
||||
// height, so keep the scroll container bottom-locked for a few frames while measurement settles.
|
||||
bottomAnchorFrames = 90
|
||||
if (bottomAnchorFrame !== undefined) return
|
||||
|
||||
const tick = () => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (!anchorMeasuredBottom()) {
|
||||
bottomAnchorFrames = 0
|
||||
return
|
||||
}
|
||||
|
||||
bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
|
||||
if (bottomAnchorFrames <= 0) return
|
||||
bottomAnchorFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
bottomAnchorFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const bindContentRoot = (root: HTMLDivElement) => {
|
||||
const child = root.firstElementChild
|
||||
props.setContentRef(child instanceof HTMLDivElement ? child : root)
|
||||
}
|
||||
|
||||
const scheduleContentRoot = (root: HTMLDivElement) => {
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
contentFrame = requestAnimationFrame(() => {
|
||||
contentFrame = undefined
|
||||
if (listRoot !== root) return
|
||||
bindContentRoot(root)
|
||||
})
|
||||
}
|
||||
|
||||
const connectListRoot = (root: HTMLDivElement) => {
|
||||
if (listRoot !== root) return
|
||||
if (!root.isConnected || !root.ownerDocument.defaultView) {
|
||||
listFrame = requestAnimationFrame(() => {
|
||||
listFrame = undefined
|
||||
connectListRoot(root)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
props.setScrollRef(root)
|
||||
measuredBottomAnchored = isMeasuredBottom(root)
|
||||
setScrollRoot(root)
|
||||
scheduleContentRoot(root)
|
||||
}
|
||||
|
||||
const bindListRoot = (root: HTMLDivElement) => {
|
||||
if (root === listRoot) return
|
||||
|
||||
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
listRoot = root
|
||||
setScrollRoot(undefined)
|
||||
connectListRoot(root)
|
||||
if (root === listRoot()) return
|
||||
setListRoot(root)
|
||||
props.setScrollRef(root)
|
||||
}
|
||||
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
|
|
@ -667,6 +595,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
}
|
||||
|
||||
|
|
@ -692,12 +621,13 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
if (event.target !== event.currentTarget) return
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
props.onHistoryScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
|
|
@ -707,10 +637,6 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
setScrollRoot(undefined)
|
||||
props.setScrollRef(undefined)
|
||||
})
|
||||
|
||||
|
|
@ -1010,9 +936,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
}
|
||||
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
|
||||
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
|
||||
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
|
||||
if (row().group.type === "context") {
|
||||
const parts = createMemo(() => {
|
||||
const group = row().group
|
||||
|
|
@ -1028,7 +952,7 @@ export function MessageTimeline(props: {
|
|||
busy={
|
||||
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
||||
}
|
||||
onSizeChange={measureTimeline}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1062,8 +986,9 @@ export function MessageTimeline(props: {
|
|||
defaultOpen={defaultOpen()}
|
||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
deferToolContent={false}
|
||||
deferToolContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -1077,10 +1002,6 @@ export function MessageTimeline(props: {
|
|||
const row = input.row()
|
||||
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
||||
}
|
||||
const previousUserMessage = () => {
|
||||
const row = input.row()
|
||||
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
||||
}
|
||||
const previousAssistantPart = () => {
|
||||
const row = input.row()
|
||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||
|
|
@ -1095,7 +1016,6 @@ export function MessageTimeline(props: {
|
|||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
"md:mx-auto": props.centered,
|
||||
"pt-6": previousUserMessage(),
|
||||
"pt-3": previousAssistantPart(),
|
||||
}}
|
||||
>
|
||||
|
|
@ -1106,8 +1026,10 @@ export function MessageTimeline(props: {
|
|||
)
|
||||
}
|
||||
|
||||
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
|
||||
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
|
||||
switch (row()._tag) {
|
||||
case "TurnGap":
|
||||
return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
|
||||
case "CommentStrip": {
|
||||
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
|
||||
const comments = createMemo(() =>
|
||||
|
|
@ -1195,7 +1117,7 @@ export function MessageTimeline(props: {
|
|||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||
>
|
||||
{renderAssistantPartGroup(assistantPartRow)}
|
||||
{renderAssistantPartGroup(assistantPartRow, onSizeChange)}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
|
|
@ -1246,13 +1168,74 @@ export function MessageTimeline(props: {
|
|||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "BottomSpacer":
|
||||
return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
|
||||
}
|
||||
}
|
||||
|
||||
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
|
||||
return renderTimelineRow(() => props.row)
|
||||
function TimelineRowView(props: { row: TimelineRow.TimelineRow; onSizeChange?: () => void }) {
|
||||
return renderTimelineRow(() => props.row, props.onSizeChange)
|
||||
}
|
||||
|
||||
function VirtualTimelineRow(props: { rowKey: string }) {
|
||||
let element: HTMLDivElement
|
||||
const initialItem = virtualItemByKey().get(props.rowKey)!
|
||||
const initialRow = timelineRowByKey().get(props.rowKey)!
|
||||
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
|
||||
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
|
||||
const asyncFile = () => {
|
||||
const value = row()
|
||||
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
|
||||
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
|
||||
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
|
||||
}
|
||||
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
|
||||
let contentMeasureFrame: number | undefined
|
||||
|
||||
onMount(() => virtualizer.measureElement(element))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => item().index,
|
||||
() => {
|
||||
virtualizer.measureElement(element)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-timeline-key={props.rowKey}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${item().start - (showHeader() ? 64 : 0)}px`,
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
overflow: "clip",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
}}
|
||||
data-index={item().index}
|
||||
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
||||
>
|
||||
<TimelineRowView
|
||||
row={row()}
|
||||
onSizeChange={() => {
|
||||
setReady(true)
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -1581,33 +1564,28 @@ export function MessageTimeline(props: {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={scrollRoot()}>
|
||||
{(root) => (
|
||||
<Virtualizer
|
||||
data={timelineRows()}
|
||||
cache={virtualCache()}
|
||||
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
||||
scrollRef={root()}
|
||||
shift={props.historyShift}
|
||||
keepMounted={keepMounted()}
|
||||
startMargin={64}
|
||||
ref={(handle) => {
|
||||
if (!handle) {
|
||||
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
||||
virtualizer = undefined
|
||||
return
|
||||
}
|
||||
virtualizer = handle
|
||||
virtualizerSessionKey = cacheSessionKey
|
||||
virtualizerRowKeys = cacheRowKeys
|
||||
maybeAnchorBottom()
|
||||
scheduleContentRoot(root())
|
||||
}}
|
||||
>
|
||||
{(row) => <TimelineRowView row={row} />}
|
||||
</Virtualizer>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
virtualContent = element
|
||||
props.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<For each={virtualRowKeys()}>{(rowKey) => <VirtualTimelineRow rowKey={rowKey} />}</For>
|
||||
<Show when={timelineRows().length > 0}>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
aria-hidden="true"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
101
packages/app/src/pages/session/timeline/model.test.ts
Normal file
101
packages/app/src/pages/session/timeline/model.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||
|
||||
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
||||
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
|
||||
|
||||
describe("timeline model", () => {
|
||||
test("selects users and applies the revert boundary", () => {
|
||||
const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")]
|
||||
const users = selectUserMessages(messages)
|
||||
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
|
||||
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"])
|
||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
test("loads pages until a visible user turn is added", async () => {
|
||||
let loaded = 10
|
||||
let visible = 2
|
||||
let calls = 0
|
||||
const anchors: Array<string | boolean> = []
|
||||
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => loaded,
|
||||
visible: () => visible,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
calls += 1
|
||||
loaded += 3
|
||||
if (calls === 2) visible += 1
|
||||
},
|
||||
before: () => anchors.push("before"),
|
||||
after: (done) => anchors.push("after", done),
|
||||
})
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(anchors).toEqual(["before", "after", false, "after", true])
|
||||
})
|
||||
|
||||
test("stops when a page adds no raw messages", async () => {
|
||||
let calls = 0
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
calls += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("does not restore an anchor after the session changes", async () => {
|
||||
let sessionID = "ses_old"
|
||||
let restore = 0
|
||||
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => sessionID,
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
sessionID = "ses_new"
|
||||
},
|
||||
after: () => {
|
||||
restore += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(restore).toBe(0)
|
||||
})
|
||||
|
||||
test("releases the anchor when loading history fails", async () => {
|
||||
let restore = 0
|
||||
|
||||
await expect(
|
||||
loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
throw new Error("history failed")
|
||||
},
|
||||
after: () => {
|
||||
restore += 1
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("history failed")
|
||||
|
||||
expect(restore).toBe(1)
|
||||
})
|
||||
})
|
||||
152
packages/app/src/pages/session/timeline/model.ts
Normal file
152
packages/app/src/pages/session/timeline/model.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
export function createTimelineModel(input: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
revertMessageID: Accessor<string | undefined>
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const sync = useSync()
|
||||
let refreshFrame: number | undefined
|
||||
let refreshTimer: number | undefined
|
||||
|
||||
const [resource] = createResource(
|
||||
() => [sdk().directory, input.sessionID()] as const,
|
||||
([directory, id]) => {
|
||||
clearRefresh()
|
||||
if (!id) return
|
||||
|
||||
const cached = untrack(() => sync().data.message[id] !== undefined)
|
||||
const stale = cached
|
||||
? (() => {
|
||||
const info = getSessionPrefetch(serverSDK().scope, directory, id)
|
||||
if (!info) return true
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
: false
|
||||
|
||||
refreshFrame = requestAnimationFrame(() => {
|
||||
refreshFrame = undefined
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined
|
||||
if (input.sessionID() !== id) return
|
||||
untrack(() => {
|
||||
if (stale) void sync().session.sync(id, { force: true })
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
return sync().session.sync(id)
|
||||
},
|
||||
)
|
||||
const messages = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? (sync().data.message[id] ?? []) : []
|
||||
})
|
||||
const ready = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return !id || sync().data.message[id] !== undefined
|
||||
})
|
||||
const userMessages = createMemo(
|
||||
() => selectUserMessages(messages()),
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
|
||||
},
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const more = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.history.more(id) : false
|
||||
})
|
||||
const loading = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.history.loading(id) : false
|
||||
})
|
||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||
return loadOlderTimeline({
|
||||
sessionID: input.sessionID,
|
||||
loaded: () => messages().length,
|
||||
visible: () => visibleUserMessages().length,
|
||||
more,
|
||||
loading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
before: options?.before,
|
||||
after: options?.after,
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(clearRefresh)
|
||||
|
||||
return {
|
||||
history: { loadOlder, loading, more },
|
||||
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
|
||||
messages,
|
||||
ready,
|
||||
resource,
|
||||
userMessages,
|
||||
visibleUserMessages,
|
||||
}
|
||||
|
||||
function clearRefresh() {
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
refreshFrame = undefined
|
||||
refreshTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function selectUserMessages(messages: Message[]) {
|
||||
return messages.filter((message): message is UserMessage => message.role === "user")
|
||||
}
|
||||
|
||||
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
|
||||
if (!revertMessageID) return messages
|
||||
return messages.filter((message) => message.id < revertMessageID)
|
||||
}
|
||||
|
||||
export async function loadOlderTimeline(input: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
loaded: Accessor<number>
|
||||
visible: Accessor<number>
|
||||
more: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
before?: () => void
|
||||
after?: (done: boolean) => void
|
||||
}) {
|
||||
const id = input.sessionID()
|
||||
if (!id || !input.more() || input.loading()) return
|
||||
|
||||
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
|
||||
const beforeVisible = input.visible()
|
||||
let loaded = input.loaded()
|
||||
input.before?.()
|
||||
while (true) {
|
||||
await input.loadMore(id).catch((error) => {
|
||||
if (input.sessionID() === id) input.after?.(true)
|
||||
throw error
|
||||
})
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const growth = input.visible() - beforeVisible
|
||||
const raw = nextLoaded - loaded
|
||||
loaded = nextLoaded
|
||||
const done = growth > 0 || raw <= 0 || !input.more()
|
||||
input.after?.(done)
|
||||
if (done) return
|
||||
}
|
||||
}
|
||||
113
packages/app/src/pages/session/timeline/projection.ts
Normal file
113
packages/app/src/pages/session/timeline/projection.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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 { Timeline, TimelineRow } from "./rows"
|
||||
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
messages: Accessor<Message[]>
|
||||
userMessages: Accessor<UserMessage[]>
|
||||
parts: (messageID: string) => Part[]
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
const result = new Map<string, AssistantMessage[]>()
|
||||
input.messages().forEach((message) => {
|
||||
if (message.role !== "assistant") return
|
||||
const messages = result.get(message.parentID)
|
||||
if (messages) {
|
||||
messages.push(message)
|
||||
return
|
||||
}
|
||||
result.set(message.parentID, [message])
|
||||
})
|
||||
return result
|
||||
})
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = input.messages().findLast(
|
||||
(message): message is AssistantMessage => message.role === "assistant" && typeof message.time.completed !== "number",
|
||||
)?.parentID
|
||||
if (parentID) {
|
||||
const messages = input.messages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||
if (message?.role === "user") return message.id
|
||||
}
|
||||
|
||||
if (input.status().type === "idle") return
|
||||
return input.messages().findLast((message) => message.role === "user")?.id
|
||||
})
|
||||
const messageRowMemos = createMemo(
|
||||
mapArray(input.userMessages, (userMessage, indexAccessor) =>
|
||||
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(
|
||||
previous,
|
||||
Timeline.constructMessageRows(
|
||||
userMessage,
|
||||
input.parts,
|
||||
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
|
||||
indexAccessor(),
|
||||
input.showReasoningSummaries(),
|
||||
input.status().type,
|
||||
activeMessageID() === userMessage.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(
|
||||
previous,
|
||||
messageRowMemos().flatMap((memo) => memo()),
|
||||
),
|
||||
)
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
rows().forEach((row, index) => {
|
||||
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const messageLastRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
rows().forEach((row, index) => {
|
||||
if ("userMessageID" in row) result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const lastAssistantGroupKey = createMemo(() => {
|
||||
const result = new Map<string, string>()
|
||||
rows().forEach((row) => {
|
||||
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
activeMessageID,
|
||||
assistantMessagesByParent,
|
||||
lastAssistantGroupKey,
|
||||
messageByID,
|
||||
messageRowIndex,
|
||||
messageLastRowIndex,
|
||||
rowByKey,
|
||||
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
|
||||
}
|
||||
|
|
@ -6,14 +6,13 @@ import { Data, Equal } from "effect"
|
|||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
export type TimelineRowMap = {
|
||||
TurnGap: { userMessageID: string }
|
||||
CommentStrip: {
|
||||
userMessageID: string
|
||||
previousUserMessage: boolean
|
||||
}
|
||||
UserMessage: {
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
previousUserMessage: boolean
|
||||
}
|
||||
TurnDivider: {
|
||||
userMessageID: string
|
||||
|
|
@ -28,18 +27,18 @@ export type TimelineRowMap = {
|
|||
Retry: { userMessageID: string }
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
BottomSpacer: {}
|
||||
}
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
previousUserMessage: boolean
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
previousUserMessage: boolean
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
|
|
@ -65,9 +64,9 @@ export namespace TimelineRow {
|
|||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
|
|
@ -76,10 +75,11 @@ export namespace TimelineRow {
|
|||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| BottomSpacer
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "TurnGap":
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
case "CommentStrip":
|
||||
return `comment-strip:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
|
|
@ -96,8 +96,6 @@ export namespace TimelineRow {
|
|||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
case "BottomSpacer":
|
||||
return "bottom-spacer"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,11 +147,12 @@ export namespace Timeline {
|
|||
),
|
||||
]
|
||||
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
|
||||
|
||||
if (comments.length > 0)
|
||||
rows.push(
|
||||
new TimelineRow.CommentStrip({
|
||||
userMessageID: userMessage.id,
|
||||
previousUserMessage,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -161,7 +160,6 @@ export namespace Timeline {
|
|||
new TimelineRow.UserMessage({
|
||||
userMessageID: userMessage.id,
|
||||
anchor: comments.length === 0,
|
||||
previousUserMessage: comments.length === 0 && previousUserMessage,
|
||||
}),
|
||||
)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue