fix(app): stabilize session timeline layout continuity (#34533)
This commit is contained in:
parent
f266e829cf
commit
3cf71808c4
57 changed files with 6159 additions and 142 deletions
|
|
@ -26,6 +26,7 @@ import { FileProvider, selectionFromLines, useFile, type FileSelection, type Sel
|
|||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
|
|
@ -940,9 +941,11 @@ export default function Page() {
|
|||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
||||
markScrollGesture()
|
||||
const key = scrollKey(event)
|
||||
if (key) {
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
|
||||
markScrollGesture(scroller)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
|
|||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
|
|
@ -441,6 +441,16 @@ export function MessageTimeline(props: {
|
|||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture()) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
const item = virtualizer.measurementsCache[index]
|
||||
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
|
||||
|
|
@ -462,9 +472,13 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (props.shouldAnchorBottom()) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
||||
item.end <= instance.getLogicalScrollOffset()
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
|
|
@ -491,24 +505,13 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
})
|
||||
|
||||
let bottomAnchorSessionKey = ""
|
||||
let bottomAnchorFrame: number | undefined
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
const key = sessionKey()
|
||||
if (bottomAnchorSessionKey === key) return
|
||||
if (timelineRows().length === 0) return
|
||||
bottomAnchorSessionKey = key
|
||||
if (!props.shouldAnchorBottom()) return
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
bottomAnchorFrame = requestAnimationFrame(() => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (sessionKey() !== key) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = sessionKey()
|
||||
|
|
@ -527,7 +530,6 @@ export function MessageTimeline(props: {
|
|||
timelineCache.delete(ownerSessionKey)
|
||||
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
props.setRevealMessage?.(() => {})
|
||||
|
|
@ -600,6 +602,15 @@ export function MessageTimeline(props: {
|
|||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const key = scrollKey(event)
|
||||
if (!key) return
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
|
|
@ -976,10 +987,16 @@ export function MessageTimeline(props: {
|
|||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||
.filter((part): part is ToolPart => part?.type === "tool")
|
||||
})
|
||||
const contextOpenKey = () => `context:${row().group.key}`
|
||||
const open = createMemo(() => {
|
||||
return toolOpen[contextOpenKey()] === true
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextToolGroup
|
||||
parts={parts()}
|
||||
open={open()}
|
||||
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
|
||||
busy={
|
||||
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
||||
}
|
||||
|
|
@ -1339,6 +1356,7 @@ export function MessageTimeline(props: {
|
|||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
|
|
|
|||
96
packages/app/src/pages/session/timeline/projection.test.ts
Normal file
96
packages/app/src/pages/session/timeline/projection.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID,
|
||||
group: {
|
||||
key,
|
||||
type: "context",
|
||||
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
|
||||
} satisfies PartGroup,
|
||||
previousAssistantPart: false,
|
||||
})
|
||||
|
||||
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
|
||||
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
|
||||
|
||||
describe("reuseTimelineRows", () => {
|
||||
test.each([
|
||||
{
|
||||
name: "reuses an unchanged context group",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when a member is appended",
|
||||
previous: [context("context:a", ["a"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when the first member is removed",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "lets only the natural owner retain an old key after a split",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "chooses the earliest prior key when groups merge",
|
||||
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
rows: [context("context:b", ["b", "a"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reserves an old key for its natural owner when two new groups compete",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
|
||||
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "does not reuse context identity across user messages",
|
||||
previous: [context("context:a", ["a", "b"], "user-1")],
|
||||
rows: [context("context:b", ["b"], "user-2")],
|
||||
expected: ["assistant-part:user-2:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reuses an unaffected ordinary row",
|
||||
previous: [user()],
|
||||
rows: [user()],
|
||||
expected: ["user-message:user-1"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "does not create accidental key collisions",
|
||||
previous: [context("context:a", ["a", "b", "c"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
|
||||
expected: [
|
||||
"assistant-part:user-1:context:b",
|
||||
"assistant-part:user-1:context:a",
|
||||
"assistant-part:user-1:context:c",
|
||||
],
|
||||
reused: [],
|
||||
},
|
||||
])("$name", ({ previous, rows, expected, reused }) => {
|
||||
const result = reuseTimelineRows([...previous], [...rows])
|
||||
|
||||
expect(keys(result)).toEqual([...expected])
|
||||
expect(new Set(keys(result)).size).toBe(result.length)
|
||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { Timeline, TimelineRow } from "./rows"
|
||||
|
||||
export { reuseTimelineRows } from "./row-reconciliation"
|
||||
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
|
|
@ -102,15 +105,3 @@ export function createTimelineProjection(input: {
|
|||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
const next = rows.map((row) => {
|
||||
const existing = byKey.get(TimelineRow.key(row))
|
||||
if (!existing) return row
|
||||
return TimelineRow.equals(existing, row) ? existing : row
|
||||
})
|
||||
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
|
||||
return next
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
const contextByPart = new Map<string, PriorContext>()
|
||||
previous.forEach((row, index) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
|
||||
})
|
||||
const reserved = new Map<string, number>()
|
||||
rows.forEach((row, index) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
const key = TimelineRow.key(row)
|
||||
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
|
||||
})
|
||||
const claimed = new Set<string>()
|
||||
const next = rows.map((input, index) => {
|
||||
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
|
||||
const existing = byKey.get(TimelineRow.key(row))
|
||||
if (!existing) return row
|
||||
return TimelineRow.equals(existing, row) ? existing : row
|
||||
})
|
||||
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
|
||||
return next
|
||||
}
|
||||
|
||||
function stabilizeContextKey(
|
||||
contextByPart: Map<string, PriorContext>,
|
||||
reserved: Map<string, number>,
|
||||
row: TimelineRow.TimelineRow,
|
||||
rowIndex: number,
|
||||
claimed: Set<string>,
|
||||
) {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
|
||||
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
|
||||
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
|
||||
if (!candidate) return result
|
||||
const key = TimelineRow.key(candidate.row)
|
||||
if (claimed.has(key)) return result
|
||||
const owner = reserved.get(key)
|
||||
if (owner !== undefined && owner !== rowIndex) return result
|
||||
return !result || candidate.index < result.index ? candidate : result
|
||||
}, undefined)
|
||||
if (!existing) return row
|
||||
const key = TimelineRow.key(existing.row)
|
||||
claimed.add(key)
|
||||
if (row.group.key === existing.row.group.key) return row
|
||||
return new TimelineRow.AssistantPart({
|
||||
...row,
|
||||
group: { ...row.group, key: existing.row.group.key },
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
export type TimelineRowMap = {
|
||||
TurnGap: { userMessageID: string }
|
||||
|
|
@ -29,81 +29,6 @@ export type TimelineRowMap = {
|
|||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: 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 type TimelineRow =
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "TurnGap":
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
export function equals(a: TimelineRow, b: TimelineRow) {
|
||||
return Equal.equals(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Timeline {
|
||||
export function constructMessageRows(
|
||||
userMessage: UserMessage,
|
||||
|
|
|
|||
80
packages/app/src/pages/session/timeline/timeline-row.ts
Normal file
80
packages/app/src/pages/session/timeline/timeline-row.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: 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 type TimelineRow =
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "TurnGap":
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
export function equals(a: TimelineRow, b: TimelineRow) {
|
||||
return Equal.equals(a, b)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue