fix(app): complete leading paginated turns

This commit is contained in:
Brendan Allan
2026-08-23 14:07:42 +08:00
committed by Brendan Allan
parent 6cd3da1d4c
commit 45b236237f
8 changed files with 152 additions and 16 deletions
@@ -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) => {
@@ -355,7 +355,7 @@ export function createSessionTimelineRowRenderer(input: {
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
class="shrink-0 text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
@@ -363,7 +363,7 @@ export function createSessionTimelineRowRenderer(input: {
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
class="min-w-0 truncate text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
@@ -380,7 +380,7 @@ export function createSessionTimelineRowRenderer(input: {
<div
data-slot="session-timeline-notice"
data-type="location-switched"
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
>
<Tooltip
appearance="compact"