diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 544bd09b..860b8ba3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -105,9 +105,12 @@ jobs: run: >- node --import tsx --test packages/ui/src/components/message-loading-visibility.test.ts + packages/ui/src/components/message-timeline-v2.test.ts packages/ui/src/components/provider-auth/provider-options.test.ts + packages/ui/src/components/session/session-bottom-pin-intent.test.ts packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/components/unified-picker-path.test.ts + packages/ui/src/components/virtual-follow-behavior.test.ts packages/ui/src/lib/filesystem-events.test.ts packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 31777bc4..9437dd94 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -7,6 +7,7 @@ import MessageBlock from "./message-block" import { getMessageAnchorId } from "./message-anchors" import { isInitialMessageLoad } from "./message-loading-visibility" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" +import { getTimelineRecordSignature } from "./message-timeline-projection" import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" import { isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing } from "./virtual-follow-behavior" import { useConfig } from "../stores/preferences" @@ -51,7 +52,7 @@ export interface MessageSectionProps { export default function MessageSection(props: MessageSectionProps) { const { preferences, updatePreferences } = useConfig() - const { t } = useI18n() + const { locale, t } = useI18n() const usageMetricsVisibility = () => preferences().showUsageMetrics ? preferences().usageMetricsExpansion : "hidden" const showMessageTimelinePreference = () => preferences().showMessageTimeline ?? true @@ -83,8 +84,8 @@ export default function MessageSection(props: MessageSectionProps) { return true } - const timeInfo = info.time as { created: number; end?: number } | undefined - return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0)) + const timeInfo = info.time as { created: number; completed?: number } | undefined + return Boolean(timeInfo && (timeInfo.completed === undefined || timeInfo.completed === 0)) }) }) @@ -156,7 +157,37 @@ export default function MessageSection(props: MessageSectionProps) { return messageIndexById().get(messageId) ?? -1 }) - const [timelineSegments, setTimelineSegments] = createSignal([]) + const timelineSegmentCache = new Map() + const timelineSegments = createMemo(() => { + sessionRevision() + const ids = messageIds() + const resolvedStore = store() + const activeLocale = locale() + + return untrack(() => { + const activeIds = new Set(ids) + const segments: TimelineSegment[] = [] + for (const messageId of ids) { + const record = resolvedStore.getMessage(messageId) + if (!record) continue + const cached = timelineSegmentCache.get(messageId) + if (cached?.revision === record.revision && cached.status === record.status && cached.locale === activeLocale) { + segments.push(...cached.segments) + continue + } + const signature = getTimelineRecordSignature(record) + const current = cached?.signature === signature && cached.locale === activeLocale + ? cached.segments + : buildTimelineSegments(props.instanceId, record, t) + timelineSegmentCache.set(messageId, { revision: record.revision, status: record.status, locale: activeLocale, signature, segments: current }) + segments.push(...current) + } + for (const messageId of timelineSegmentCache.keys()) { + if (!activeIds.has(messageId)) timelineSegmentCache.delete(messageId) + } + return segments + }) + }) const hasTimelineSegments = () => timelineSegments().length > 0 function segmentMatchesSearch(segment: TimelineSegment, match: { messageId: string; partId?: string; partType?: string }): boolean { @@ -186,56 +217,6 @@ export default function MessageSection(props: MessageSectionProps) { return timelineSegments().find((segment) => segmentMatchesSearch(segment, match))?.id ?? null }) - const seenTimelineMessageIds = new Set() - const seenTimelineSegmentKeys = new Set() - const timelinePartCountsByMessageId = new Map() - let pendingTimelineMessagePartUpdates = new Set() - let pendingTimelinePartUpdateFrame: number | null = null - - function makeTimelineKey(segment: TimelineSegment) { - return `${segment.messageId}:${segment.id}:${segment.type}` - } - - function seedTimeline() { - seenTimelineMessageIds.clear() - seenTimelineSegmentKeys.clear() - timelinePartCountsByMessageId.clear() - const ids = untrack(messageIds) - const resolvedStore = untrack(store) - const segments: TimelineSegment[] = [] - ids.forEach((messageId) => { - const record = resolvedStore.getMessage(messageId) - if (!record) return - seenTimelineMessageIds.add(messageId) - timelinePartCountsByMessageId.set(messageId, record.partIds.length) - const built = buildTimelineSegments(props.instanceId, record, t) - built.forEach((segment) => { - const key = makeTimelineKey(segment) - if (seenTimelineSegmentKeys.has(key)) return - seenTimelineSegmentKeys.add(key) - segments.push(segment) - }) - }) - setTimelineSegments(segments) - } - - function appendTimelineForMessage(messageId: string) { - const record = untrack(() => store().getMessage(messageId)) - if (!record) return - timelinePartCountsByMessageId.set(messageId, record.partIds.length) - const built = buildTimelineSegments(props.instanceId, record, t) - if (built.length === 0) return - const newSegments: TimelineSegment[] = [] - built.forEach((segment) => { - const key = makeTimelineKey(segment) - if (seenTimelineSegmentKeys.has(key)) return - seenTimelineSegmentKeys.add(key) - newSegments.push(segment) - }) - if (newSegments.length > 0) { - setTimelineSegments((prev) => [...prev, ...newSegments]) - } - } const [activeSegmentId, setActiveSegmentId] = createSignal(null) const isActive = createMemo(() => props.isActive !== false) @@ -407,8 +388,8 @@ export default function MessageSection(props: MessageSectionProps) { if (record.status !== "streaming") return false const info = resolvedStore.getMessageInfo(messageId) - const timeInfo = info?.time as { end?: number } | undefined - if (typeof timeInfo?.end === "number" && timeInfo.end > 0) return false + const timeInfo = info?.time as { completed?: number } | undefined + if (typeof timeInfo?.completed === "number" && timeInfo.completed > 0) return false const { orderedParts } = buildRecordDisplayData(props.instanceId, record) return orderedParts.some((part) => { @@ -603,205 +584,6 @@ export default function MessageSection(props: MessageSectionProps) { listApi()?.notifyContentRendered() } - let previousTimelineIds: string[] = [] - - createEffect(() => { - const loading = Boolean(props.loading) - const ids = messageIds() - - // Wrap all iteration of the store-proxied `ids` array in untrack() - // to prevent O(n) per-element reactive subscriptions. The effect - // only needs to re-run when `messageIds` (memo) changes. - untrack(() => { - if (isInitialMessageLoad(loading, ids.length)) { - previousTimelineIds = [] - setTimelineSegments([]) - seenTimelineMessageIds.clear() - seenTimelineSegmentKeys.clear() - timelinePartCountsByMessageId.clear() - pendingTimelineMessagePartUpdates.clear() - if (pendingTimelinePartUpdateFrame !== null) { - cancelAnimationFrame(pendingTimelinePartUpdateFrame) - pendingTimelinePartUpdateFrame = null - } - return - } - - if (previousTimelineIds.length === 0 && ids.length > 0) { - seedTimeline() - previousTimelineIds = [...ids] - return - } - - if (ids.length < previousTimelineIds.length) { - seedTimeline() - previousTimelineIds = [...ids] - return - } - - if (ids.length === previousTimelineIds.length) { - let changedIndex = -1 - let changeCount = 0 - for (let index = 0; index < ids.length; index++) { - if (ids[index] !== previousTimelineIds[index]) { - changedIndex = index - changeCount += 1 - if (changeCount > 1) break - } - } - if (changeCount === 1 && changedIndex >= 0) { - const oldId = previousTimelineIds[changedIndex] - const newId = ids[changedIndex] - if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) { - seenTimelineMessageIds.delete(oldId) - seenTimelineMessageIds.add(newId) - setTimelineSegments((prev) => { - const next = prev.map((segment) => { - if (segment.messageId !== oldId) return segment - const updatedId = segment.id.replace(oldId, newId) - return { ...segment, messageId: newId, id: updatedId } - }) - seenTimelineSegmentKeys.clear() - next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) - return next - }) - - // Keep part count tracking in sync with id replacement. - const existingPartCount = timelinePartCountsByMessageId.get(oldId) - if (existingPartCount !== undefined) { - timelinePartCountsByMessageId.delete(oldId) - timelinePartCountsByMessageId.set(newId, existingPartCount) - } - - previousTimelineIds = [...ids] - return - } - } - } - - const newIds: string[] = [] - ids.forEach((id) => { - if (!seenTimelineMessageIds.has(id)) { - newIds.push(id) - } - }) - - if (newIds.length > 0) { - newIds.forEach((id) => { - seenTimelineMessageIds.add(id) - appendTimelineForMessage(id) - }) - } - - previousTimelineIds = [...ids] - }) - }) - - function clearPendingTimelinePartUpdateFrame() { - if (pendingTimelinePartUpdateFrame !== null) { - cancelAnimationFrame(pendingTimelinePartUpdateFrame) - pendingTimelinePartUpdateFrame = null - } - } - - function scheduleTimelinePartUpdateFlush() { - if (pendingTimelinePartUpdateFrame !== null) return - pendingTimelinePartUpdateFrame = requestAnimationFrame(() => { - pendingTimelinePartUpdateFrame = null - if (pendingTimelineMessagePartUpdates.size === 0) return - const changedIds = Array.from(pendingTimelineMessagePartUpdates) - pendingTimelineMessagePartUpdates = new Set() - - const ids = messageIds() - const resolvedStore = store() - - setTimelineSegments((prev) => { - let next = prev - - for (const changedId of changedIds) { - // Remove old segments for this message. - next = next.filter((segment) => segment.messageId !== changedId) - - const record = resolvedStore.getMessage(changedId) - const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : [] - - // Insert rebuilt segments in the correct place based on session message order. - if (rebuilt.length > 0) { - let insertAt = next.length - const changedIndex = ids.indexOf(changedId) - if (changedIndex >= 0) { - for (let i = changedIndex + 1; i < ids.length; i++) { - const followingId = ids[i] - const existingIndex = next.findIndex((segment) => segment.messageId === followingId) - if (existingIndex >= 0) { - insertAt = existingIndex - break - } - } - } - next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)] - } - } - - // Rebuild the segment key set since we may have removed/replaced segments. - seenTimelineSegmentKeys.clear() - next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) - return next - }) - - }) - } - - // Keep timeline segments in sync when message parts are added/removed. - // Explicitly replace segments for messages whose part count changed. - createEffect(() => { - if (props.loading) return - const ids = messageIds() - // Also re-run when sessionRevision bumps (covers part additions within - // existing messages) but read individual records inside untrack() to - // avoid creating O(n) fine-grained subscriptions. - sessionRevision() - - // Wrap the iteration in untrack() so that accessing individual elements - // of the store-proxied `ids` array does not create O(n) per-element - // reactive subscriptions. We only need to re-run when the memo - // (messageIds) or sessionRevision changes — not per-element. - untrack(() => { - const resolvedStore = store() - const idsSet = new Set(ids) - let hasChanges = false - - for (const messageId of ids) { - const record = resolvedStore.getMessage(messageId) - const partCount = record?.partIds.length ?? 0 - const previousCount = timelinePartCountsByMessageId.get(messageId) - - if (previousCount === undefined) { - timelinePartCountsByMessageId.set(messageId, partCount) - continue - } - - if (previousCount !== partCount) { - timelinePartCountsByMessageId.set(messageId, partCount) - pendingTimelineMessagePartUpdates.add(messageId) - hasChanges = true - } - } - - // Drop tracking for ids that are no longer present. - // Use the Set for O(1) lookups instead of ids.includes() which is O(n). - for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) { - if (!idsSet.has(trackedId)) { - timelinePartCountsByMessageId.delete(trackedId) - } - } - - if (hasChanges) { - scheduleTimelinePartUpdateFlush() - } - }) - }) - createEffect(() => { if (!props.onQuoteSelection) { clearQuoteSelection() @@ -932,7 +714,7 @@ export default function MessageSection(props: MessageSectionProps) { }) onCleanup(() => { - clearPendingTimelinePartUpdateFrame() + timelineSegmentCache.clear() clearQuoteSelection() }) diff --git a/packages/ui/src/components/message-timeline-projection.ts b/packages/ui/src/components/message-timeline-projection.ts new file mode 100644 index 00000000..4530e048 --- /dev/null +++ b/packages/ui/src/components/message-timeline-projection.ts @@ -0,0 +1,13 @@ +import { partHasRenderableText } from "../types/message" +import type { MessageRecord } from "../stores/message-v2/types" + +export function getTimelineRecordSignature(record: MessageRecord): string { + const parts = record.partIds.map((partId) => { + const part = record.parts[partId] + const data = part?.data + const structuralRevision = data?.type === "tool" || data?.type === "compaction" ? part.revision : 0 + const fileName = data?.type === "file" && typeof data.filename === "string" ? data.filename : "" + return `${partId}:${data?.type ?? "unknown"}:${data && partHasRenderableText(data) ? 1 : 0}:${structuralRevision}:${fileName}` + }).join("|") + return `${record.status}|${parts}` +} diff --git a/packages/ui/src/components/message-timeline-v2.test.ts b/packages/ui/src/components/message-timeline-v2.test.ts new file mode 100644 index 00000000..e8fbe9a5 --- /dev/null +++ b/packages/ui/src/components/message-timeline-v2.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { getTimelineRecordSignature } from "./message-timeline-projection.ts" +import type { MessageRecord } from "../stores/message-v2/types.ts" + +function record(parts: Array<{ id: string; type: string; text?: string; revision?: number }>): MessageRecord { + return { + id: "message", + sessionId: "session", + role: "assistant", + status: "streaming", + createdAt: 1, + updatedAt: 1, + revision: 1, + partIds: parts.map((part) => part.id), + parts: Object.fromEntries(parts.map((part) => [part.id, { + id: part.id, + revision: part.revision ?? 0, + data: part as any, + }])), + } +} + +describe("V2 timeline projection", () => { + it("changes its structural signature when a provisional part is replaced at the same cardinality", () => { + const provisional = record([{ id: "message-text-native-0", type: "text", text: "hello" }]) + const authoritative = record([{ id: "message-text-0", type: "text", text: "hello" }]) + + assert.notEqual(getTimelineRecordSignature(provisional), getTimelineRecordSignature(authoritative)) + }) + + it("changes its structural signature when a same-id part changes type or renderability", () => { + const text = record([{ id: "part", type: "text", text: "hello" }]) + const empty = record([{ id: "part", type: "text", text: "" }]) + const tool = record([{ id: "part", type: "tool", revision: 1 }]) + + assert.notEqual(getTimelineRecordSignature(text), getTimelineRecordSignature(empty)) + assert.notEqual(getTimelineRecordSignature(text), getTimelineRecordSignature(tool)) + }) + + it("keeps text streaming out of the structural signature but tracks tool revisions", () => { + const shortText = record([{ id: "text", type: "text", text: "a" }]) + const longText = record([{ id: "text", type: "text", text: "a longer streamed value", revision: 20 }]) + const firstTool = record([{ id: "tool", type: "tool", revision: 1 }]) + const updatedTool = record([{ id: "tool", type: "tool", revision: 2 }]) + + assert.equal(getTimelineRecordSignature(shortText), getTimelineRecordSignature(longText)) + assert.notEqual(getTimelineRecordSignature(firstTool), getTimelineRecordSignature(updatedTool)) + }) + + it("invalidates the projection once when streaming reaches a terminal status", () => { + const streaming = record([{ id: "text", type: "text", text: "partial" }]) + const complete = { ...record([{ id: "text", type: "text", text: "final response" }]), status: "complete" as const } + + assert.notEqual(getTimelineRecordSignature(streaming), getTimelineRecordSignature(complete)) + }) +}) diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index 8c4ba262..affd124d 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -164,6 +164,37 @@ describe("virtual follow behavior", () => { assert.deepEqual(result.effect, { type: "none" }) }) + it("repins after an unowned virtualizer measurement correction", () => { + const controller = new VirtualScrollController(true) + controller.recordProgrammaticOffset(2400, true) + + const result = controller.observeViewport(metrics(2200), 1000, false) + + assert.deepEqual(result.state.mode, { type: "following" }) + assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true }) + }) + + it("does not rejoin escaped mode from measurement-only downward movement", () => { + const controller = new VirtualScrollController(false) + controller.recordProgrammaticOffset(2200, false) + + const result = controller.observeViewport(metrics(2400), 1000, false) + + assert.deepEqual(result.state.mode, { type: "escaped" }) + assert.deepEqual(result.effect, { type: "none" }) + }) + + it("keeps hold-driven escape stable across later viewport measurements", () => { + const controller = new VirtualScrollController(true) + controller.setFollow(false) + controller.recordProgrammaticOffset(2200, false) + + const result = controller.observeViewport(metrics(2400), 1000, false) + + assert.deepEqual(result.state.mode, { type: "escaped" }) + assert.deepEqual(result.effect, { type: "none" }) + }) + it("blocks content pinning while restoring", () => { const controller = new VirtualScrollController(true) controller.setRestoring(true) diff --git a/packages/ui/src/components/virtual-follow-behavior.ts b/packages/ui/src/components/virtual-follow-behavior.ts index 3d604f44..c9014841 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -337,16 +337,22 @@ export class VirtualScrollController { return this.setFollow(false) } - const direction = actualDirection ?? this.state.userIntentDirection - - if (!hasFreshIntent && (!actualDirection || programmatic)) { + if (!hasFreshIntent) { + if (!programmatic && this.isAutoFollowing() && !atBottom && !this.state.restoring) { + return this.result({ type: "scroll-bottom", immediate: true }) + } return this.result(noFollowEffect) } - if (direction === "up" && (!programmatic || hasFreshIntent)) { + const direction = this.state.userIntentDirection ?? actualDirection + if (direction === "up") { return this.setFollow(false) } + // Explicit bottom commands enter follow mode through jumpBottom. A + // measured programmatic move alone must never rejoin an escaped viewport. + if (programmatic && this.state.mode.type === "escaped") return this.result(noFollowEffect) + const next = transitionFollowMode(this.state.mode, { type: "user-scroll", direction, atBottom }) this.state.mode = next.mode this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 8a7f17ad..cda29be1 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -277,7 +277,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const index = props.items().findIndex((item, i) => props.getKey(item, i) === key) if (index === -1) return markProgrammaticScroll() - virtuaHandle()?.scrollToIndex(index, { align: opts.block, smooth: opts.smooth }) + // Large smooth jumps over dynamically measured items can leave Virtua's + // mounted range behind the viewport. Semantic navigation must land first. + virtuaHandle()?.scrollToIndex(index, { align: opts.block, smooth: false }) } function updateScrollStateFromDom() { @@ -296,9 +298,6 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const now = performance.now() const programmatic = hasProgrammaticScrollIntent() const result = scrollController.observeViewport(metrics, now, programmatic) - if (result.state.mode.type === "escaped" && explicitBottomPinIntent()) { - cancelExplicitBottomPinFromUser() - } syncControllerResult(result) } @@ -587,6 +586,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const handleWheelIntent = (event: WheelEvent) => markUserScrollIntent(event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null) const handlePointerIntent = (event: PointerEvent) => { if ((event.target as HTMLElement | null)?.closest(INTERACTIVE_KEY_TARGET_SELECTOR)) return + if (event.target !== element) return markUserScrollIntent(null) } let lastTouchY: number | null = null @@ -651,11 +651,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function scrollToTop(immediate = true) { cancelActiveScrollRestore() + if (hasActiveExplicitBottomPin() || explicitBottomPinIntent()) cancelExplicitBottomPinFromUser() dispatchFollowEvent({ type: "jump-top", immediate }) } function scrollToKey(key: string, opts?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition }) { cancelActiveScrollRestore() + if (hasActiveExplicitBottomPin() || explicitBottomPinIntent()) cancelExplicitBottomPinFromUser() dispatchFollowEvent({ type: "jump-key", key, block: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) } diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 8df577d0..8774f4e1 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -87,9 +87,9 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; completed?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined + const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined store.upsertMessage({ id: info.id, @@ -97,7 +97,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null role: info.role === "user" ? "user" : "assistant", status: options?.status ?? "complete", createdAt, - updatedAt: endAt ?? createdAt, + updatedAt: completedAt ?? createdAt, bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 9de7e8d0..7809e12d 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -211,6 +211,26 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => { assert.deepEqual(store.getSessionMessageIds("session-1"), ["msg-real-1"]) }) + it("dedupes repeated part ids while keeping the newest part payload", () => { + const store = createInstanceMessageStore("instance-1") + store.addOrUpdateSession({ id: "session-1" }) + + store.upsertMessage({ + id: "msg-1", + sessionId: "session-1", + role: "assistant", + status: "complete", + parts: [ + { id: "part-1", type: "text", text: "stale" } as any, + { id: "part-1", type: "text", text: "current" } as any, + ], + }) + + const message = store.getMessage("msg-1") + assert.deepEqual(message?.partIds, ["part-1"]) + assert.equal((message?.parts["part-1"]?.data as any)?.text, "current") + }) + it("drops a definitively failed send on the next authoritative snapshot", () => { // promptAsync rejection retires the in-flight marker and marks the bubble. // The failed bubble stays visible until the next authoritative snapshot, diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 377e80a9..bb0a2b01 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -876,6 +876,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } const map: MessageRecord["parts"] = {} const ids: string[] = [] + const seenIds = new Set() parts.forEach((part, index) => { const id = ensurePartId(messageId, part, index) @@ -885,7 +886,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt data: cloned, revision: 0, } - ids.push(id) + if (!seenIds.has(id)) { + seenIds.add(id) + ids.push(id) + } }) return { map, ids }