Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`

benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
Expand Down Expand Up @@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
Expand Down Expand Up @@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
19 changes: 13 additions & 6 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,14 @@ export function MessageTimeline(props: {
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
Expand Down Expand Up @@ -324,7 +332,7 @@ export function MessageTimeline(props: {
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
Expand Down Expand Up @@ -664,8 +672,7 @@ export function MessageTimeline(props: {
}))

const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().client.session.update({ sessionID: input.id, title: input.title }),
mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
Expand Down Expand Up @@ -809,7 +816,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])

await sdk()
.client.session.update({ sessionID, time: { archived: Date.now() } })
.api.session.archive({ sessionID })
.then(() => {
sync().set(
produce((draft) => {
Expand Down Expand Up @@ -838,8 +845,8 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])

const result = await sdk()
.client.session.delete({ sessionID })
.then((x) => x.data)
.api.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
Expand Down
56 changes: 13 additions & 43 deletions packages/app/src/pages/session/timeline/projection.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"

export { reuseTimelineRows } from "./row-reconciliation"

const emptyAssistantMessages: AssistantMessage[] = []

export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
sessionMessages: Accessor<SessionMessageInfo[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
Expand All @@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
})
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,
input.inlineComments(),
),
),
),
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.inlineComments(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
reuseTimelineRows(previous, projection().rows),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
Expand Down
122 changes: 122 additions & 0 deletions packages/app/src/pages/session/timeline/rows-current.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, mock, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"

mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))

const { Timeline, TimelineRow } = await import("./rows")

describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
const source = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_3", type: "user", text: "second", time: { created: 4 } },
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "reasoning", text: "working" }],
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))

const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
)

expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
])
})

test("renders a current shell message as a standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))

const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
)

expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
])
})

test("associates assistants with a projected parent missing from the source page", () => {
const source = [
{ id: "msg_user", type: "user", text: "question", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))

const result = Timeline.constructSessionMessageRows(
[source[1]!],
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
)

expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"assistant-part:msg_user:msg_assistant:text:0",
])
})
})
50 changes: 50 additions & 0 deletions packages/app/src/pages/session/timeline/rows.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
Expand Down Expand Up @@ -31,6 +32,55 @@ export type TimelineRowMap = {
}

export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
inlineComments: boolean,
) {
const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => {
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }]
}
return projected?.role === "user" ? [{ user: projected, assistants: [] }] : []
})
const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn]))
messages.forEach((message) => {
const projected = getMessage(message.id)
if (projected?.role !== "assistant") return
const existing = turnByUserID.get(projected.parentID)
if (existing) {
existing.assistants.push(projected)
return
}
const user = getMessage(projected.parentID)
if (user?.role !== "user") return
const turn = { user, assistants: [projected] }
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
}
}

export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
Expand Down
Loading
Loading