diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a06e7c17d60..1f49d3d03b9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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 const emptyFollowups: FollowupItem[] = [] @@ -79,70 +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 - userScrolled: () => boolean - scroller: () => HTMLDivElement | undefined - onBeforeLoad?: () => void - onAfterLoad?: () => void -} - -function createSessionHistoryLoader(input: SessionHistoryWindowInput) { - const historyScrollThreshold = 200 - - const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, { - equals: same, - }) - - 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() - input.onBeforeLoad?.() - - while (true) { - await input.loadMore(id) - input.onAfterLoad?.() - if (input.sessionID() !== id) return - - const nextLoaded = input.loaded() - const raw = nextLoaded - loaded - loaded = nextLoaded - const growth = input.visibleUserMessages().length - beforeVisible - - if (growth > 0) break - if (raw <= 0) break - if (!input.historyMore()) break - } - } - - const loadAndReveal = () => fetchOlderMessages() - - const onScrollerScroll = () => { - if (!input.userScrolled()) return - const el = input.scroller() - if (!el) return - if (el.scrollTop >= historyScrollThreshold) return - - void fetchOlderMessages() - } - - return { - userMessages, - loadAndReveal, - onScrollerScroll, - } -} - export default function Page() { const serverSync = useServerSync() const layout = useLayout() @@ -283,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() @@ -383,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 @@ -593,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( () => { @@ -1256,18 +1130,12 @@ export default function Page() { let captureHistoryAnchor = () => {} let restoreHistoryAnchor = () => {} - const historyLoader = createSessionHistoryLoader({ - sessionID: () => params.id, - loaded: () => messages().length, - visibleUserMessages, - historyMore, - historyLoading, - loadMore: (sessionID) => sync().session.history.loadMore(sessionID), - userScrolled: autoScroll.userScrolled, - scroller: () => scroller, - onBeforeLoad: () => captureHistoryAnchor(), - onAfterLoad: () => restoreHistoryAnchor(), - }) + 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 @@ -1283,7 +1151,7 @@ export default function Page() { if (el.scrollHeight > el.clientHeight + 1) return if (!historyMore()) return - void historyLoader.loadAndReveal() + void loadOlder() }) } @@ -1639,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) @@ -1776,40 +1642,40 @@ export default function Page() { {(_id) => ( - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() - } - centered={centered()} - setContentRef={(el) => { - content = el - autoScroll.contentRef(el) + 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) - }} - userMessages={historyLoader.userMessages()} - setHistoryAnchor={(handlers) => { - captureHistoryAnchor = handlers.capture - restoreHistoryAnchor = handlers.restore - }} - anchor={anchor} - setRevealMessage={(fn) => { - revealMessage = fn - }} - setScrollToEnd={(fn) => { - scrollToEnd = 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 + }} /> )} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx similarity index 93% rename from packages/app/src/pages/session/message-timeline.tsx rename to packages/app/src/pages/session/timeline/message-timeline.tsx index f5ebbcb2c2b..424fe2adc05 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -8,7 +8,6 @@ import { onCleanup, onMount, Show, - mapArray, type Accessor, type JSX, } from "solid-js" @@ -50,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" @@ -70,7 +68,8 @@ 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 { createTimelineProjection } from "./projection" +import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] @@ -87,18 +86,6 @@ const timelineCache = new Map< { measurements: VirtualItem[]; toolOpen: Record } >() -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 -} - const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return const metadata = "metadata" in part.state ? part.state.metadata : undefined @@ -286,36 +273,13 @@ export function MessageTimeline(props: { const [listRoot, setListRoot] = createSignal() 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() - 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) @@ -333,25 +297,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 @@ -390,27 +335,20 @@ export function MessageTimeline(props: { return language.t("command.session.new") }) const showHeader = createMemo(() => !!(titleValue() || parentID())) - - 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, - ) - - return reuseTimelineRows(previous, rows) - }) - }, - ), - ) + 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 messageRowIndex = projection.messageRowIndex + const timelineRowByKey = projection.rowByKey + const timelineRows = projection.rows let prependAnchor: { key: string; offset: number } | undefined let prependAnchorFrame: number | undefined @@ -471,10 +409,6 @@ export function MessageTimeline(props: { prependAnchorFrame = requestAnimationFrame(apply) } - const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { - const rows = messageRowMemos().flatMap((memo) => memo()) - return reuseTimelineRows(previous, rows) - }) const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 50) const prepareScrollOverscan = () => { @@ -517,28 +451,10 @@ export function MessageTimeline(props: { }) virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => item.end <= (instance.scrollOffset ?? 0) - const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string)) - const messageRowIndex = createMemo(() => { - const result = new Map() - 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() - timelineRows().forEach((row) => { - if (row._tag !== "AssistantPart") return - result.set(row.userMessageID, row.group.key) - }) - return result - }) createEffect(() => { props.setRevealMessage?.((id) => { const index = messageRowIndex().get(id) diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts new file mode 100644 index 00000000000..5a96224ca80 --- /dev/null +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -0,0 +1,58 @@ +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: string[] = [] + + 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: () => anchors.push("after"), + }) + + expect(calls).toBe(2) + expect(anchors).toEqual(["before", "after", "after"]) + }) + + 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) + }) +}) diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts new file mode 100644 index 00000000000..35741c05843 --- /dev/null +++ b/packages/app/src/pages/session/timeline/model.ts @@ -0,0 +1,148 @@ +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 + revertMessageID: Accessor +}) { + 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?: () => 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 + loaded: Accessor + visible: Accessor + more: Accessor + loading: Accessor + loadMore: (sessionID: string) => Promise + before?: () => void + after?: () => 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) + input.after?.() + if (input.sessionID() !== id) return + + const nextLoaded = input.loaded() + const growth = input.visible() - beforeVisible + const raw = nextLoaded - loaded + loaded = nextLoaded + if (growth > 0 || raw <= 0 || !input.more()) return + } +} diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts new file mode 100644 index 00000000000..54195e0845d --- /dev/null +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -0,0 +1,105 @@ +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 + userMessages: Accessor + parts: (messageID: string) => Part[] + status: Accessor + showReasoningSummaries: Accessor +}) { + const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) + const assistantMessagesByParent = createMemo(() => { + const result = new Map() + 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() + rows().forEach((row, index) => { + if (!("userMessageID" in row) || result.has(row.userMessageID)) return + result.set(row.userMessageID, index) + }) + return result + }) + const lastAssistantGroupKey = createMemo(() => { + const result = new Map() + rows().forEach((row) => { + if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key) + }) + return result + }) + + return { + activeMessageID, + assistantMessagesByParent, + lastAssistantGroupKey, + messageByID, + messageRowIndex, + 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 +} diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/timeline/rows.ts similarity index 100% rename from packages/app/src/pages/session/message-timeline.data.ts rename to packages/app/src/pages/session/timeline/rows.ts