diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5abf573bc..9b96407bc 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,9 +1,8 @@ -import { For, createEffect, createMemo, on, onCleanup, onMount, Show, Index, type JSX, createSignal } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, onMount, Show, type JSX, createSignal } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" -import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -11,10 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message as MessageType, Part, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" -import { getFilename } from "@opencode-ai/util/path" import { collectTimelineScrollMetrics } from "@/pages/session/session-timeline-scroll-anchors" import { type TimelineScrollControllerResult, @@ -29,6 +27,11 @@ import { shouldMarkTimelineBoundaryGesture, } from "@/pages/session/session-timeline-scroll-intents" import { createTimelineStaging } from "@/pages/session/session-timeline-staging" +import { + areMessageCommentsEqual, + extractMessageComments, + SessionMessageComments, +} from "@/pages/session/session-message-comments" import { taskDescription } from "@/pages/session/task-description" import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages" import { @@ -51,19 +54,9 @@ import { useShellSurface } from "@/context/shell-surface" import { useSync } from "@/context/sync" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" -import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { makeTimer } from "@solid-primitives/timer" import { webSearchRecoveryToast } from "./websearch-toasts" -type MessageComment = { - path: string - comment: string - selection?: { - startLine: number - endLine: number - } -} - function isWebSearchToolPart(part: Part): part is Extract { return part.type === "tool" && part.tool === "websearch" } @@ -103,25 +96,6 @@ type TurnChangeDisplay = { }> } -const messageComments = (parts: Part[]): MessageComment[] => - parts.flatMap((part) => { - if (part.type !== "text" || !(part as TextPart).synthetic) return [] - const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) - if (!next) return [] - return [ - { - path: next.path, - comment: next.comment, - selection: next.selection - ? { - startLine: next.selection.startLine, - endLine: next.selection.endLine, - } - : undefined, - }, - ] - }) - export { taskDescription } export function MessageTimeline(props: { @@ -929,18 +903,9 @@ export function MessageTimeline(props: { {(messageID, index) => { const userMessage = createMemo(() => props.renderedUserMessages[index()]) const active = createMemo(() => activeMessageID() === messageID) - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => - a.length === b.length && - a.every( - (c, i) => - c.path === b[i].path && - c.comment === b[i].comment && - c.selection?.startLine === b[i].selection?.startLine && - c.selection?.endLine === b[i].selection?.endLine, - ), + const comments = createMemo(() => extractMessageComments(sync.data.part[messageID] ?? []), [], { + equals: areMessageCommentsEqual, }) - const commentCount = createMemo(() => comments().length) return (
- 0}> -
-
-
- - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( - - {(c) => ( -
-
- - {getFilename(c().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {c().comment} -
-
- )} -
- ) - }} -
-
-
-
-
+ + ({ + id: "prt_test", + type: "text", + text: input.text, + synthetic: input.synthetic, + metadata: input.metadata, + }) as Part + +describe("extractMessageComments", () => { + test("extracts synthetic comment metadata", () => { + expect( + extractMessageComments([ + textPart({ + text: "ignored fallback", + synthetic: true, + metadata: createCommentMetadata({ + path: "src/app.ts", + comment: "check this branch", + selection: { startLine: 4, startChar: 0, endLine: 6, endChar: 0 }, + }), + }), + ]), + ).toEqual([ + { + path: "src/app.ts", + comment: "check this branch", + selection: { startLine: 4, endLine: 6 }, + }, + ]) + }) + + test("falls back to formatted synthetic comment notes", () => { + expect( + extractMessageComments([ + textPart({ + text: formatCommentNote({ + path: "src/view.tsx", + comment: "missing accessible label", + selection: { startLine: 12, startChar: 0, endLine: 12, endChar: 0 }, + }), + synthetic: true, + }), + ]), + ).toEqual([ + { + path: "src/view.tsx", + comment: "missing accessible label", + selection: { startLine: 12, endLine: 12 }, + }, + ]) + }) + + test("ignores non-synthetic text parts and non-comment text", () => { + expect( + extractMessageComments([ + textPart({ text: formatCommentNote({ path: "src/app.ts", comment: "visible", selection: undefined }) }), + textPart({ text: "regular assistant text", synthetic: true }), + { id: "prt_tool", type: "tool", tool: "bash", state: { status: "pending" } } as Part, + ]), + ).toEqual([]) + }) +}) + +describe("areMessageCommentsEqual", () => { + test("compares displayed comment fields", () => { + const comments = [ + { + path: "src/app.ts", + comment: "same", + selection: { startLine: 1, endLine: 2 }, + }, + ] + + expect(areMessageCommentsEqual(comments, comments.map((comment) => ({ ...comment })))).toBe(true) + expect(areMessageCommentsEqual(comments, [{ ...comments[0], selection: { startLine: 1, endLine: 3 } }])).toBe( + false, + ) + }) +}) diff --git a/packages/app/src/pages/session/session-message-comments.tsx b/packages/app/src/pages/session/session-message-comments.tsx new file mode 100644 index 000000000..e7ef1f5ca --- /dev/null +++ b/packages/app/src/pages/session/session-message-comments.tsx @@ -0,0 +1,88 @@ +import { createMemo, Index, Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import type { Part, TextPart } from "@opencode-ai/sdk/v2" +import { getFilename } from "@opencode-ai/util/path" +import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" + +export type MessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } +} + +export const extractMessageComments = (parts: Part[]): MessageComment[] => + parts.flatMap((part) => { + if (part.type !== "text" || !(part as TextPart).synthetic) return [] + const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) + if (!next) return [] + return [ + { + path: next.path, + comment: next.comment, + selection: next.selection + ? { + startLine: next.selection.startLine, + endLine: next.selection.endLine, + } + : undefined, + }, + ] + }) + +export function areMessageCommentsEqual(a: MessageComment[], b: MessageComment[]) { + return ( + a.length === b.length && + a.every( + (comment, index) => + comment.path === b[index].path && + comment.comment === b[index].comment && + comment.selection?.startLine === b[index].selection?.startLine && + comment.selection?.endLine === b[index].selection?.endLine, + ) + ) +} + +export function SessionMessageComments(props: { comments: MessageComment[] }) { + return ( + 0}> +
+
+
+ + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( + + {(c) => ( +
+
+ + {getFilename(c().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {c().comment} +
+
+ )} +
+ ) + }} +
+
+
+
+
+ ) +}