From dd04f5c5ce095df149dea7c98af4eb8f229e7454 Mon Sep 17 00:00:00 2001 From: Micah Nkrumah Ardayfio Date: Wed, 9 Sep 2026 03:01:08 +0000 Subject: [PATCH] Refactor message rows params --- .../src/pages/session/timeline/rows.test.ts | 173 ++++++++++++++++++ .../app/src/pages/session/timeline/rows.ts | 42 +++-- 2 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/pages/session/timeline/rows.test.ts diff --git a/packages/app/src/pages/session/timeline/rows.test.ts b/packages/app/src/pages/session/timeline/rows.test.ts new file mode 100644 index 00000000..50b35862 --- /dev/null +++ b/packages/app/src/pages/session/timeline/rows.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, mock, test } from "bun:test" +import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" +import { formatCommentNote } from "@/utils/comment-note" + +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") + +function fakeUserMessage(overrides: Partial = {}): UserMessage { + return { + id: "msg_user", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + ...overrides, + } +} + +function fakeCommentPart(userMessageID: string): Part { + return { + id: `${userMessageID}:comment:0`, + sessionID: "ses_1", + messageID: userMessageID, + type: "text", + synthetic: true, + text: formatCommentNote({ path: "src/index.ts", comment: "please fix this" }), + } +} + +describe("Timeline.constructMessageRows", () => { + test("adds a leading turn gap only when index is greater than zero", () => { + const user = fakeUserMessage() + + const firstTurn = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => [], + assistantMessages: [], + index: 0, + showReasoning: false, + status: "idle", + isActive: false, + inlineComments: true, + }) + expect(firstTurn).toEqual([new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true })]) + + const laterTurn = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => [], + assistantMessages: [], + index: 1, + showReasoning: false, + status: "idle", + isActive: false, + inlineComments: true, + }) + expect(laterTurn).toEqual([ + new TimelineRow.TurnGap({ userMessageID: user.id }), + new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true }), + ]) + }) + + test("shows a comment strip and clears the user-message anchor only when comments render inline", () => { + const user = fakeUserMessage() + const parts = [fakeCommentPart(user.id)] + + const strip = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => parts, + assistantMessages: [], + index: 0, + showReasoning: false, + status: "idle", + isActive: false, + inlineComments: false, + }) + expect(strip).toEqual([ + new TimelineRow.CommentStrip({ userMessageID: user.id }), + new TimelineRow.UserMessage({ userMessageID: user.id, anchor: false }), + ]) + + const inline = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => parts, + assistantMessages: [], + index: 0, + showReasoning: false, + status: "idle", + isActive: false, + inlineComments: true, + }) + expect(inline).toEqual([new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true })]) + }) + + test("renders a thinking row only while the active turn is busy with no reasoning parts yet", () => { + const user = fakeUserMessage() + + const active = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => [], + assistantMessages: [], + index: 0, + showReasoning: true, + status: "busy", + isActive: true, + inlineComments: true, + }) + expect(active).toEqual([ + new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true }), + new TimelineRow.Thinking({ userMessageID: user.id, reasoningHeading: undefined }), + ]) + + const inactive = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => [], + assistantMessages: [], + index: 0, + showReasoning: true, + status: "busy", + isActive: false, + inlineComments: true, + }) + expect(inactive).toEqual([new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true })]) + }) + + test("renders a retry row when the active turn is retrying", () => { + const user = fakeUserMessage() + + const rows = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => [], + assistantMessages: [] as AssistantMessage[], + index: 0, + showReasoning: false, + status: "retry", + isActive: true, + inlineComments: true, + }) + expect(rows).toEqual([ + new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true }), + new TimelineRow.Retry({ userMessageID: user.id }), + ]) + }) + + test("renders a diff summary row from the user message's summary diffs", () => { + const diffs = [{ file: "src/a.ts", additions: 1, deletions: 0 }] + const user = fakeUserMessage({ summary: { diffs } }) + + const rows = Timeline.constructMessageRows({ + userMessage: user, + getMessageParts: () => [], + assistantMessages: [], + index: 0, + showReasoning: false, + status: "idle", + isActive: false, + inlineComments: true, + }) + expect(rows).toEqual([ + new TimelineRow.UserMessage({ userMessageID: user.id, anchor: true }), + new TimelineRow.DiffSummary({ userMessageID: user.id, diffs }), + ]) + }) +}) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 1f9472a0..f2bc32ac 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -84,31 +84,43 @@ export namespace Timeline { return { activeMessageID, rows: turns.flatMap((turn, index) => - constructMessageRows( - turn.user, + constructMessageRows({ + userMessage: turn.user, getMessageParts, - turn.assistants, + assistantMessages: turn.assistants, index, showReasoning, status, - turn.user.id === activeMessageID, + isActive: turn.user.id === activeMessageID, inlineComments, - ), + }), ), } } - export function constructMessageRows( - userMessage: UserMessage, - getMessageParts: (messageID: string) => Part[], - assistantMessages: AssistantMessage[], - index: number, - showReasoning: boolean, - status: SessionStatus["type"], - isActive: boolean, + export type ConstructMessageRowsParams = { + userMessage: UserMessage + getMessageParts: (messageID: string) => Part[] + assistantMessages: AssistantMessage[] + index: number + showReasoning: boolean + status: SessionStatus["type"] + isActive: boolean // v2 renders comments inside the user message attachments row instead of a strip row - inlineComments: boolean, - ) { + inlineComments: boolean + } + + export function constructMessageRows(params: ConstructMessageRowsParams) { + const { + userMessage, + getMessageParts, + assistantMessages, + index, + showReasoning, + status, + isActive, + inlineComments, + } = params const rows: TimelineRow.TimelineRow[] = [] const previousUserMessage = index > 0