fix(ui): refresh timeline when parts change

Track per-message part count changes and rebuild timeline segments so deletions or streaming updates don't leave stale entries in the message timeline.
This commit is contained in:
Shantur Rathore
2026-02-08 21:32:35 +00:00
parent 2a5bb6304d
commit 56a0e8aa6e

View File

@@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
const seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = new Set<string>()
const timelinePartCountsByMessageId = new Map<string, number>()
let pendingTimelineMessagePartUpdates = new Set<string>()
let pendingTimelinePartUpdateFrame: number | null = null
function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}`
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
function seedTimeline() {
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
const ids = untrack(messageIds)
const resolvedStore = untrack(store)
const segments: TimelineSegment[] = []
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
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)
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
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[] = []
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
})
let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => {
const loading = Boolean(props.loading)
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
if (loading) {
previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
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.slice()
return
}
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
previousTimelineIds = ids.slice()
})
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<string>()
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.
// Part deletion does not remove message ids from the session, so we must
// explicitly replace segments for messages whose part count changed.
createEffect(() => {
if (props.loading) return
const ids = messageIds()
if (ids.length === 0) return
const lastId = ids[ids.length - 1]
if (!lastId) return
const record = store().getMessage(lastId)
if (!record) return
const partCount = record.partIds.length
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
return
const resolvedStore = store()
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
}
}
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount
const built = buildTimelineSegments(props.instanceId, record, t)
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])
// Drop tracking for ids that are no longer present.
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
})
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
cancelAnimationFrame(pendingAnchorScroll)
}
clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}