fix(app): complete leading paginated turns (#44322)

This commit is contained in:
Brendan Allan
2026-08-23 15:27:33 +08:00
committed by GitHub
parent 7420903859
commit 1e3d3fcaca
9 changed files with 153 additions and 15 deletions
@@ -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 }) => {
@@ -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
+47 -2
View File
@@ -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<SessionModel, "identi
const [resource] = createResource(
() => 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<SessionModel, "identi
sessionID: input.session.identity.sessionID,
more,
loading,
loadMore: data.session.message.loadMore,
loadMore: (id) => data.session.message.loadMore(id),
before: options?.before,
after: options?.after,
})
@@ -44,6 +61,34 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
}
}
export async function enrichLeadingTurn(input: {
current: Accessor<boolean>
messages: Accessor<SessionMessageInfo[]>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: () => Promise<void>
pause: () => Promise<void>
maxPages: number
}) {
const load = async (pages: number): Promise<void> => {
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<string | undefined>
more: Accessor<boolean>
@@ -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)
@@ -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
@@ -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) })
@@ -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",
])
})
})
@@ -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)
@@ -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) => {