import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part" import { Data, Equal } from "effect" export type SummaryDiff = SnapshotFileDiff & { file: string } export type TimelineRowMap = { CommentStrip: { userMessageID: string previousUserMessage: boolean } UserMessage: { userMessageID: string anchor: boolean previousUserMessage: boolean } TurnDivider: { userMessageID: string label: "compaction" | "interrupted" } AssistantPart: { userMessageID: string group: PartGroup previousAssistantPart: boolean lastAssistantPart: boolean } Thinking: { userMessageID: string; reasoningHeading?: string } Retry: { userMessageID: string } DiffSummary: { userMessageID: string; diffs: SummaryDiff[] } Error: { userMessageID: string; text: string } BottomSpacer: {} } export namespace TimelineRow { export class CommentStrip extends Data.TaggedClass("CommentStrip")<{ userMessageID: string previousUserMessage: boolean }> {} export class UserMessage extends Data.TaggedClass("UserMessage")<{ userMessageID: string anchor: boolean previousUserMessage: boolean }> {} export class TurnDivider extends Data.TaggedClass("TurnDivider")<{ userMessageID: string label: "compaction" | "interrupted" }> {} export class AssistantPart extends Data.TaggedClass("AssistantPart")<{ userMessageID: string group: PartGroup previousAssistantPart: boolean lastAssistantPart: boolean }> {} export class Thinking extends Data.TaggedClass("Thinking")<{ userMessageID: string reasoningHeading?: string }> {} export class DiffSummary extends Data.TaggedClass("DiffSummary")<{ userMessageID: string diffs: SummaryDiff[] }> {} export class Error extends Data.TaggedClass("Error")<{ userMessageID: string text: string }> {} export class Retry extends Data.TaggedClass("Retry")<{ userMessageID: string }> {} export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {} export type TimelineRow = | CommentStrip | UserMessage | TurnDivider | AssistantPart | Thinking | DiffSummary | Error | Retry | BottomSpacer export const key = (row: TimelineRow) => { switch (row._tag) { case "CommentStrip": return `comment-strip:${row.userMessageID}` case "UserMessage": return `user-message:${row.userMessageID}` case "TurnDivider": return `turn-divider:${row.userMessageID}:${row.label}` case "AssistantPart": return `assistant-part:${row.userMessageID}:${row.group.key}` case "Thinking": return `thinking:${row.userMessageID}` case "DiffSummary": return `diff-summary:${row.userMessageID}` case "Error": return `error:${row.userMessageID}` case "Retry": return `retry:${row.userMessageID}` case "BottomSpacer": return "bottom-spacer" } } export function equals(a: TimelineRow, b: TimelineRow) { return Equal.equals(a, b) } } export namespace Timeline { export function constructMessageRows( userMessage: UserMessage, getMessageParts: (messageID: string) => Part[], assistantMessages: AssistantMessage[], index: number, showReasoning: boolean, status: SessionStatus["type"], isActive: boolean, ) { const rows: TimelineRow.TimelineRow[] = [] const previousUserMessage = index > 0 const userParts = getMessageParts(userMessage.id) const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? []) const compaction = userParts.some((p) => p.type === "compaction") const errorMsg = assistantMessages.find((m) => m.error?.name === "MessageAbortedError") const interrupted = !!errorMsg const assistantPartRefs = assistantMessages.flatMap((message) => getMessageParts(message.id) .filter((part) => renderable(part, showReasoning)) .map((part) => ({ messageID: message.id, part })), ) const assistantGroups = groupParts(assistantPartRefs) if (comments.length > 0) rows.push( new TimelineRow.CommentStrip({ userMessageID: userMessage.id, previousUserMessage, }), ) rows.push( new TimelineRow.UserMessage({ userMessageID: userMessage.id, anchor: comments.length === 0, previousUserMessage: comments.length === 0 && previousUserMessage, }), ) if (compaction || interrupted) { rows.push( new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: compaction ? "compaction" : "interrupted", }), ) } assistantGroups.forEach((group, index) => rows.push( new TimelineRow.AssistantPart({ userMessageID: userMessage.id, group, previousAssistantPart: index > 0, lastAssistantPart: index === assistantGroups.length - 1, }), ), ) if (isActive && status === "busy" && !errorMsg?.error && (showReasoning ? assistantPartRefs.length === 0 : true)) { const heading = assistantMessages .flatMap((message) => getMessageParts(message.id)) .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined)) .find((value): value is string => !!value) rows.push( new TimelineRow.Thinking({ userMessageID: userMessage.id, reasoningHeading: heading, }), ) } if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id })) const diffs = (userMessage.summary?.diffs ?? []) .reduceRight((result, diff) => { if (!isSummaryDiff(diff)) return result if (result.some((item) => item.file === diff.file)) return result result.push(diff) return result }, []) .reverse() if (diffs.length > 0 && (status === "idle" || !isActive)) { rows.push( new TimelineRow.DiffSummary({ userMessageID: userMessage.id, diffs, }), ) } if (errorMsg?.error) { const data = errorMsg.error.data?.message rows.push( new TimelineRow.Error({ userMessageID: userMessage.id, text: unwrapErrorMessage( typeof data === "string" ? data : data === undefined || data === null ? "" : String(data), ), }), ) } return rows } function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff { return typeof value.file === "string" } function reasoningHeading(text: string) { const markdown = text.replace(/\r\n?/g, "\n") const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) if (html?.[1]) { const value = cleanHeading(html[1].replace(/<[^>]+>/g, " ")) if (value) return value } const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) if (atx?.[1]) { const value = cleanHeading(atx[1]) if (value) return value } const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) if (setext?.[1]) { const value = cleanHeading(setext[1]) if (value) return value } const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) if (strong?.[1]) { const value = cleanHeading(strong[1]) if (value) return value } } function cleanHeading(value: string) { return value .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .replace(/[*_~]+/g, "") .trim() } function unwrapErrorMessage(message: string) { const text = message.replace(/^Error:\s*/, "").trim() const parse = (value: string) => { try { return JSON.parse(value) as unknown } catch { return undefined } } const read = (value: string) => { const first = parse(value) if (typeof first !== "string") return first return parse(first.trim()) } let json = read(text) if (json === undefined) { const start = text.indexOf("{") const end = text.lastIndexOf("}") if (start !== -1 && end > start) json = read(text.slice(start, end + 1)) } if (!record(json)) return message const err = record(json.error) ? json.error : undefined if (err) { const type = typeof err.type === "string" ? err.type : undefined const msg = typeof err.message === "string" ? err.message : undefined if (type && msg) return `${type}: ${msg}` if (msg) return msg if (type) return type const code = typeof err.code === "string" ? err.code : undefined if (code) return code } const msg = typeof json.message === "string" ? json.message : undefined if (msg) return msg const reason = typeof json.error === "string" ? json.error : undefined if (reason) return reason return message } function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } } export namespace MessageComment { export type MessageComment = { path: string comment: string selection?: { startLine: number endLine: number } } export const fromPart = (part: Part): MessageComment | undefined => { if (part.type !== "text" || !part.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, } } }