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
94 changes: 10 additions & 84 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
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"
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,
Expand All @@ -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 {
Expand All @@ -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<Part, { type: "tool" }> {
return part.type === "tool" && part.tool === "websearch"
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 (
<div
id={props.anchor(messageID)}
Expand All @@ -954,46 +919,7 @@ export function MessageTimeline(props: {
"contain-intrinsic-size": active() ? undefined : "auto 500px",
}}
>
<Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
{(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor())
return (
<Show when={comment()}>
{(c) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak bg-bg-base px-2.5 py-2">
<div class="flex items-center gap-1.5 min-w-0 text-h3 text-fg-strong">
<FileIcon
node={{ path: c().path, type: "file" }}
class="size-3.5 shrink-0"
/>
<span class="truncate">{getFilename(c().path)}</span>
<Show when={c().selection}>
{(selection) => (
<span class="shrink-0 text-fg-weak">
{selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`}
</span>
)}
</Show>
</div>
<div class="pt-1 text-body text-fg-strong whitespace-pre-wrap break-words">
{c().comment}
</div>
</div>
)}
</Show>
)
}}
</Index>
</div>
</div>
</div>
</Show>
<SessionMessageComments comments={comments()} />
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={messageID}
Expand Down
89 changes: 89 additions & 0 deletions packages/app/src/pages/session/session-message-comments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
import { areMessageCommentsEqual, extractMessageComments } from "./session-message-comments"

const textPart = (input: {
text: string
synthetic?: boolean
metadata?: unknown
}): Part =>
({
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,
)
})
})
88 changes: 88 additions & 0 deletions packages/app/src/pages/session/session-message-comments.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Show when={props.comments.length > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={props.comments}>
{(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor())
return (
<Show when={comment()}>
{(c) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak bg-surface-base px-2.5 py-2">
<div class="flex items-center gap-1.5 min-w-0 text-body font-emphasis text-fg-strong">
<FileIcon node={{ path: c().path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(c().path)}</span>
<Show when={c().selection}>
{(selection) => (
<span class="shrink-0 text-fg-weak">
{selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`}
</span>
)}
</Show>
</div>
<div class="pt-1 text-body text-fg-strong whitespace-pre-wrap break-words">
{c().comment}
</div>
</div>
)}
</Show>
)
}}
</Index>
</div>
</div>
</div>
</Show>
)
}
Loading