diff --git a/packages/app/src/pages/session/timeline/timeline-row-equality.test.ts b/packages/app/src/pages/session/timeline/timeline-row-equality.test.ts new file mode 100644 index 000000000000..a9a9f6c37e37 --- /dev/null +++ b/packages/app/src/pages/session/timeline/timeline-row-equality.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import { Equal } from "effect" +import { TimelineRow, type SummaryDiff } from "./timeline-row" + +const diff = (file: string, patch: string): SummaryDiff => ({ + file, + patch, + additions: 1, + deletions: 0, + status: "modified", +}) + +const rows: TimelineRow.TimelineRow[] = [ + new TimelineRow.TurnGap({ userMessageID: "msg_1" }), + new TimelineRow.TurnGap({ userMessageID: "msg_2" }), + new TimelineRow.CommentStrip({ userMessageID: "msg_1" }), + new TimelineRow.UserMessage({ userMessageID: "msg_1", anchor: true }), + new TimelineRow.UserMessage({ userMessageID: "msg_1", anchor: false }), + new TimelineRow.UserMessage({ userMessageID: "msg_2", anchor: true }), + new TimelineRow.TurnDivider({ userMessageID: "msg_1", label: "compaction" }), + new TimelineRow.TurnDivider({ userMessageID: "msg_1", label: "interrupted" }), + new TimelineRow.AssistantPart({ + userMessageID: "msg_1", + previousAssistantPart: false, + group: { key: "grp_1", type: "part", ref: { messageID: "msg_1", partID: "part_1" } }, + }), + new TimelineRow.AssistantPart({ + userMessageID: "msg_1", + previousAssistantPart: false, + group: { key: "grp_1", type: "part", ref: { messageID: "msg_1", partID: "part_2" } }, + }), + new TimelineRow.AssistantPart({ + userMessageID: "msg_1", + previousAssistantPart: true, + group: { key: "grp_1", type: "part", ref: { messageID: "msg_1", partID: "part_1" } }, + }), + new TimelineRow.AssistantPart({ + userMessageID: "msg_1", + previousAssistantPart: false, + group: { + key: "grp_2", + type: "context", + refs: [ + { messageID: "msg_1", partID: "part_1" }, + { messageID: "msg_1", partID: "part_2" }, + ], + }, + }), + new TimelineRow.AssistantPart({ + userMessageID: "msg_1", + previousAssistantPart: false, + group: { + key: "grp_2", + type: "context", + refs: [ + { messageID: "msg_1", partID: "part_2" }, + { messageID: "msg_1", partID: "part_1" }, + ], + }, + }), + new TimelineRow.AssistantPart({ + userMessageID: "msg_1", + previousAssistantPart: false, + group: { key: "grp_2", type: "context", refs: [{ messageID: "msg_1", partID: "part_1" }] }, + }), + new TimelineRow.Thinking({ userMessageID: "msg_1", reasoningHeading: "Planning" }), + new TimelineRow.Thinking({ userMessageID: "msg_1" }), + new TimelineRow.Thinking({ userMessageID: "msg_2", reasoningHeading: "Planning" }), + new TimelineRow.DiffSummary({ userMessageID: "msg_1", diffs: [diff("a.ts", "one")] }), + new TimelineRow.DiffSummary({ userMessageID: "msg_1", diffs: [diff("a.ts", "one")] }), + new TimelineRow.DiffSummary({ userMessageID: "msg_1", diffs: [diff("a.ts", "two")] }), + new TimelineRow.Error({ userMessageID: "msg_1", text: "boom" }), + new TimelineRow.Error({ userMessageID: "msg_1", text: "bang" }), + new TimelineRow.Retry({ userMessageID: "msg_1" }), + new TimelineRow.Retry({ userMessageID: "msg_2" }), +] + +describe("TimelineRow.equals", () => { + test("agrees with Equal.equals for every pair", () => { + for (const a of rows) { + for (const b of rows) { + expect(TimelineRow.equals(a, b)).toBe(Equal.equals(a, b)) + } + } + }) + + test("is reflexive and key-stable", () => { + for (const row of rows) { + expect(TimelineRow.equals(row, row)).toBe(true) + expect(TimelineRow.key(row)).toBe(TimelineRow.key(row)) + } + }) +}) diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 3905254b29b5..7a99e21cee9f 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -1,5 +1,5 @@ import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import type { PartGroup } from "@opencode-ai/session-ui/message-part" +import { sameGroup, type PartGroup } from "@opencode-ai/session-ui/message-part-groups" import { Data, Equal } from "effect" export type SummaryDiff = SnapshotFileDiff & { file: string } @@ -75,6 +75,42 @@ export namespace TimelineRow { } export function equals(a: TimelineRow, b: TimelineRow) { - return Equal.equals(a, b) + if (a === b) return true + if (a._tag !== b._tag) return false + switch (a._tag) { + case "TurnGap": + case "CommentStrip": + return a.userMessageID === (b as TurnGap | CommentStrip).userMessageID + case "UserMessage": { + const other = b as UserMessage + return a.userMessageID === other.userMessageID && a.anchor === other.anchor + } + case "TurnDivider": { + const other = b as TurnDivider + return a.userMessageID === other.userMessageID && a.label === other.label + } + case "AssistantPart": { + const other = b as AssistantPart + return ( + a.userMessageID === other.userMessageID && + a.previousAssistantPart === other.previousAssistantPart && + sameGroup(a.group, other.group) + ) + } + case "Thinking": { + const other = b as Thinking + return a.userMessageID === other.userMessageID && a.reasoningHeading === other.reasoningHeading + } + case "DiffSummary": { + const other = b as DiffSummary + return a.userMessageID === other.userMessageID && Equal.equals(a.diffs, other.diffs) + } + case "Error": { + const other = b as Error + return a.userMessageID === other.userMessageID && a.text === other.text + } + case "Retry": + return a.userMessageID === (b as Retry).userMessageID + } } } diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 7db27630da53..d658344c8200 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -6,6 +6,7 @@ "license": "MIT", "exports": { "./*": "./src/components/*.tsx", + "./message-part-groups": "./src/components/message-part-groups.ts", "./session-diff": "./src/components/session-diff.ts", "./message-file": "./src/components/message-file.ts", "./message-part-text": "./src/components/message-part-text.ts", diff --git a/packages/session-ui/src/components/message-part-groups.ts b/packages/session-ui/src/components/message-part-groups.ts new file mode 100644 index 000000000000..092d9fe82888 --- /dev/null +++ b/packages/session-ui/src/components/message-part-groups.ts @@ -0,0 +1,40 @@ +export type PartRef = { + messageID: string + partID: string +} + +export type PartGroup = + | { + key: string + type: "part" + ref: PartRef + } + | { + key: string + type: "context" + refs: PartRef[] + } + +function sameRef(a: PartRef, b: PartRef) { + return a.messageID === b.messageID && a.partID === b.partID +} + +export function sameGroup(a: PartGroup, b: PartGroup) { + if (a === b) return true + if (a.key !== b.key) return false + if (a.type !== b.type) return false + if (a.type === "part") { + if (b.type !== "part") return false + return sameRef(a.ref, b.ref) + } + if (b.type !== "context") return false + if (a.refs.length !== b.refs.length) return false + return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) +} + +export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((item, i) => sameGroup(item, b[i]!)) +} diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index d2a00b9d44c0..8a4bb1e83dc2 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -62,6 +62,7 @@ import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" import { partDefaultOpen } from "./part-default-open" +import { sameGroup, sameGroups, type PartGroup, type PartRef } from "./message-part-groups" import { animate } from "motion" import { attached, inline, kind, typeLabel } from "./message-file" import { readPartText } from "./message-part-text" @@ -619,46 +620,8 @@ function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { return a.every((x, i) => x === b[i]) } -export type PartRef = { - messageID: string - partID: string -} - -export type PartGroup = - | { - key: string - type: "part" - ref: PartRef - } - | { - key: string - type: "context" - refs: PartRef[] - } - -function sameRef(a: PartRef, b: PartRef) { - return a.messageID === b.messageID && a.partID === b.partID -} - -function sameGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return sameRef(a.ref, b.ref) - } - if (b.type !== "context") return false - if (a.refs.length !== b.refs.length) return false - return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) -} - -export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((item, i) => sameGroup(item, b[i]!)) -} +export { sameGroup, sameGroups } +export type { PartGroup, PartRef } export function groupParts(parts: { messageID: string; part: PartType }[]) { const result: PartGroup[] = []