364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
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
|
|
}
|
|
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
|
|
}> {}
|
|
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 interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
|
|
const interrupted = interruptedMessageIndex !== -1
|
|
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
|
|
|
|
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
|
|
getMessageParts(message.id)
|
|
.filter((part) => renderable(part, showReasoning))
|
|
.map((part) => ({ messageID: message.id, messageIndex, part })),
|
|
)
|
|
const assistantItems =
|
|
interrupted && !compaction
|
|
? [
|
|
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
|
|
(group) => ({
|
|
type: "part" as const,
|
|
group,
|
|
}),
|
|
),
|
|
{ type: "interrupted" as const },
|
|
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
|
|
(group) => ({
|
|
type: "part" as const,
|
|
group,
|
|
}),
|
|
),
|
|
]
|
|
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
|
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) {
|
|
rows.push(
|
|
new TimelineRow.TurnDivider({
|
|
userMessageID: userMessage.id,
|
|
label: "compaction",
|
|
}),
|
|
)
|
|
}
|
|
|
|
let assistantGroupIndex = 0
|
|
assistantItems.forEach((item) => {
|
|
if (item.type === "interrupted") {
|
|
rows.push(
|
|
new TimelineRow.TurnDivider({
|
|
userMessageID: userMessage.id,
|
|
label: "interrupted",
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
|
|
rows.push(
|
|
new TimelineRow.AssistantPart({
|
|
userMessageID: userMessage.id,
|
|
group: item.group,
|
|
previousAssistantPart: assistantGroupIndex > 0,
|
|
}),
|
|
)
|
|
assistantGroupIndex += 1
|
|
})
|
|
|
|
if (isActive && status === "busy" && !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<SummaryDiff[]>((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 (error) {
|
|
const data = 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(/<h[1-6][^>]*>([\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<string, unknown> {
|
|
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,
|
|
}
|
|
}
|
|
}
|