From 1e3d3fcaca6aeaae8d90f419e2a9a820d13d1252 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:27:33 +0800 Subject: [PATCH] fix(app): complete leading paginated turns (#44322) --- .../session-timeline-tool-projection.spec.ts | 6 +- .../app/src/session/timeline/model.test.ts | 58 ++++++++++++++++++- packages/app/src/session/timeline/model.ts | 49 +++++++++++++++- .../app/src/session/timeline/projection.ts | 3 +- .../src/message/current-message.tsx | 2 +- .../src/message/message-content.tsx | 14 +++-- .../src/timeline/projection.test.ts | 31 ++++++++++ .../session-ui/src/timeline/projection.ts | 3 +- .../src/timeline/session-timeline-row.tsx | 2 +- 9 files changed, 153 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index ab8af77c726..24e04b4147e 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -98,7 +98,7 @@ test("labels completed searches with result counts", async ({ page }) => { const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`) await group.locator('[data-slot="collapsible-trigger"]').click() - const rows = group.locator('[data-component="tool-trigger"]') + const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') await expect(rows.nth(0)).toContainText("(1 match)") await expect(rows.nth(1)).toContainText("(12 matches)") }) @@ -111,7 +111,9 @@ test("labels read tools from their path input", async ({ page }) => { const group = page.locator(`[data-timeline-part-ids="${id}"]`) await group.locator('[data-slot="collapsible-trigger"]').click() - await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts") + await expect( + group.locator('[data-component="context-tool-group-list"] [data-slot="basic-tool-tool-subtitle"]'), + ).toHaveText("a.ts") }) test("labels skill tools from IDs and result metadata", async ({ page }) => { diff --git a/packages/app/src/session/timeline/model.test.ts b/packages/app/src/session/timeline/model.test.ts index 8fe4a9abeb4..064fdd6cb8f 100644 --- a/packages/app/src/session/timeline/model.test.ts +++ b/packages/app/src/session/timeline/model.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test" import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise" -import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model" +import { + enrichLeadingTurn, + leadingTurnNeedsParent, + loadOlderTimeline, + selectUserMessages, + selectVisibleUserMessages, +} from "./model" const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } }) const assistant = (id: string): SessionMessageAssistant => ({ @@ -42,6 +48,56 @@ describe("timeline model", () => { expect(anchors).toEqual(["before", "after", true]) }) + test("recognizes a leading partial assistant turn", () => { + expect(leadingTurnNeedsParent([assistant("msg_assistant"), user("msg_next")])).toBe(true) + expect(leadingTurnNeedsParent([user("msg_user"), assistant("msg_assistant")])).toBe(false) + expect(leadingTurnNeedsParent([user("msg_user")])).toBe(false) + }) + + test("pauses between bounded history pages until the leading turn has its parent", async () => { + const pages: SessionMessageInfo[][] = [[assistant("msg_older")], [user("msg_parent")]] + const messages: SessionMessageInfo[] = [assistant("msg_latest"), user("msg_next")] + let pauses = 0 + let loads = 0 + + await enrichLeadingTurn({ + current: () => true, + messages: () => messages, + more: () => pages.length > 0, + loading: () => false, + loadMore: async () => { + messages.unshift(...pages.shift()!) + loads += 1 + }, + pause: async () => { + pauses += 1 + }, + maxPages: 3, + }) + + expect(loads).toBe(2) + expect(pauses).toBe(2) + expect(leadingTurnNeedsParent(messages)).toBe(false) + }) + + test("caps background pages when the parent remains outside the window", async () => { + let loads = 0 + + await enrichLeadingTurn({ + current: () => true, + messages: () => [assistant("msg_latest")], + more: () => true, + loading: () => false, + loadMore: async () => { + loads += 1 + }, + pause: async () => undefined, + maxPages: 3, + }) + + expect(loads).toBe(3) + }) + test("does not restore an anchor after the session changes", async () => { let sessionID = "ses_old" let restore = 0 diff --git a/packages/app/src/session/timeline/model.ts b/packages/app/src/session/timeline/model.ts index 325fd9c1aca..e4069696857 100644 --- a/packages/app/src/session/timeline/model.ts +++ b/packages/app/src/session/timeline/model.ts @@ -1,7 +1,11 @@ import { createMemo, createResource, type Accessor } from "solid-js" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { useData } from "@/runtime/server/current" import type { SessionModel } from "../model" +const leadingTurnPageDelay = 200 +const leadingTurnPageLimit = 3 + export { selectSessionUserMessages as selectUserMessages, selectVisibleSessionUserMessages as selectVisibleUserMessages, @@ -12,7 +16,20 @@ export function createTimelineModel(input: { session: Pick input.session.identity.sessionID(), - (id) => (id ? Promise.all([data.session.message.sync(id), data.session.pending.sync(id)]) : undefined), + async (id) => { + if (!id) return + const key = input.session.identity.sessionKey() + await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)]) + void enrichLeadingTurn({ + current: () => input.session.identity.sessionKey() === key, + messages: () => data.session.message.list(id), + more: () => data.session.message.more(id), + loading: () => data.session.message.loading(id), + loadMore: () => data.session.message.loadMore(id), + pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)), + maxPages: leadingTurnPageLimit, + }).catch(() => undefined) + }, ) const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading) const more = () => { @@ -28,7 +45,7 @@ export function createTimelineModel(input: { session: Pick data.session.message.loadMore(id), before: options?.before, after: options?.after, }) @@ -44,6 +61,34 @@ export function createTimelineModel(input: { session: Pick + messages: Accessor + more: Accessor + loading: Accessor + loadMore: () => Promise + pause: () => Promise + maxPages: number +}) { + const load = async (pages: number): Promise => { + if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more()) + return + await input.pause() + if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return + if (input.loading()) return load(pages) + await input.loadMore() + return load(pages + 1) + } + return load(0) +} + +export function leadingTurnNeedsParent(messages: SessionMessageInfo[]) { + const assistant = messages.findIndex((message) => message.type === "assistant") + if (assistant === -1) return false + const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell") + return boundary === -1 || assistant < boundary +} + export async function loadOlderTimeline(input: { sessionID: Accessor more: Accessor diff --git a/packages/app/src/session/timeline/projection.ts b/packages/app/src/session/timeline/projection.ts index da94e130f6f..411df468954 100644 --- a/packages/app/src/session/timeline/projection.ts +++ b/packages/app/src/session/timeline/projection.ts @@ -63,7 +63,8 @@ export function createTimelineProjection(input: { input.sessionMessages().forEach((message) => { if (message.type === "user") userID = message.id if (message.type === "shell") userID = undefined - if (message.type !== "assistant" || !userID) return + if (message.type !== "assistant") return + if (!userID) userID = message.id const messages = result.get(userID) if (messages) { messages.push(message) diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index 53e8cda0a37..38aedd38ced 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -41,7 +41,7 @@ export function SessionAssistantContent(props: { content: SessionMessageAssistant["content"][number] contentID: string showAssistantCopyPartID?: string | null - turnDurationMs?: number + turnDurationMs?: number | null defaultOpen?: boolean toolOpen?: boolean onToolOpenChange?: (open: boolean) => void diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index b3acbb435ed..3bc7820bf8d 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -388,7 +388,7 @@ export function AssistantTextContent(props: { text: string message: SessionMessageAssistant showCopy: boolean - turnDurationMs?: number + turnDurationMs?: number | null }) { const data = useData() const i18n = useI18n() @@ -404,11 +404,13 @@ export function AssistantTextContent(props: { const duration = createMemo(() => { const completed = props.message.time.completed const ms = - typeof props.turnDurationMs === "number" - ? props.turnDurationMs - : typeof completed === "number" - ? completed - props.message.time.created - : -1 + props.turnDurationMs === null + ? -1 + : typeof props.turnDurationMs === "number" + ? props.turnDurationMs + : typeof completed === "number" + ? completed - props.message.time.created + : -1 if (!(ms >= 0)) return "" const total = Math.round(ms / 1000) if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) }) diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 550d1984546..ceb49cfbcb9 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -196,4 +196,35 @@ describe("createTimelineProjection", () => { expect(second.rows[1]).toBe(first.rows[1]) }) + test("indexes a leading partial assistant turn under its projected turn ID", () => { + const messages = [ + { + id: "assistant-1", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "partial answer" }], + time: { created: 2, completed: 3 }, + }, + { + id: "assistant-2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "final answer" }], + time: { created: 4, completed: 5 }, + }, + ] satisfies SessionMessageInfo[] + + const result = createTimelineProjection({ + sessionMessages: messages, + status: { type: "idle" }, + showReasoningSummaries: true, + }) + + expect(result.assistantMessagesByParent.get("assistant-1")?.map((message) => message.id)).toEqual([ + "assistant-1", + "assistant-2", + ]) + }) }) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 3412de5bbed..1574572efb3 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -392,7 +392,8 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) { messages.forEach((message) => { if (message.type === "user") userID = message.id if (message.type === "shell") userID = undefined - if (message.type !== "assistant" || !userID) return + if (message.type !== "assistant") return + if (!userID) userID = message.id const existing = result.get(userID) if (existing) { existing.push(message) diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 7133c811451..fa844999150 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -55,7 +55,7 @@ export function createSessionTimelineRowRenderer(input: { input.status().type !== "idle" && input.projection.activeMessageID() === messageID const duration = (messageID: string) => { const user = input.projection.messageByID().get(messageID) - if (user?.type !== "user") return undefined + if (user?.type !== "user") return null const completed = (input.projection.assistantMessagesByParent().get(messageID) ?? emptyAssistantMessages).reduce< number | undefined >((latest, message) => {