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 seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = 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) { function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}` return `${segment.messageId}:${segment.id}:${segment.type}`
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
function seedTimeline() { function seedTimeline() {
seenTimelineMessageIds.clear() seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear() seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
const ids = untrack(messageIds) const ids = untrack(messageIds)
const resolvedStore = untrack(store) const resolvedStore = untrack(store)
const segments: TimelineSegment[] = [] const segments: TimelineSegment[] = []
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
if (!record) return if (!record) return
seenTimelineMessageIds.add(messageId) seenTimelineMessageIds.add(messageId)
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t) const built = buildTimelineSegments(props.instanceId, record, t)
built.forEach((segment) => { built.forEach((segment) => {
const key = makeTimelineKey(segment) const key = makeTimelineKey(segment)
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
function appendTimelineForMessage(messageId: string) { function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId)) const record = untrack(() => store().getMessage(messageId))
if (!record) return if (!record) return
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t) const built = buildTimelineSegments(props.instanceId, record, t)
if (built.length === 0) return if (built.length === 0) return
const newSegments: TimelineSegment[] = [] const newSegments: TimelineSegment[] = []
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
let previousTimelineIds: string[] = [] let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => { createEffect(() => {
const loading = Boolean(props.loading) const loading = Boolean(props.loading)
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
if (loading) { if (loading) {
previousTimelineIds = [] previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([]) setTimelineSegments([])
seenTimelineMessageIds.clear() seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear() seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return return
} }
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next 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() previousTimelineIds = ids.slice()
return return
} }
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
previousTimelineIds = ids.slice() 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(() => { createEffect(() => {
if (props.loading) return if (props.loading) return
const ids = messageIds() const ids = messageIds()
if (ids.length === 0) return const resolvedStore = store()
const lastId = ids[ids.length - 1]
if (!lastId) return let hasChanges = false
const record = store().getMessage(lastId) for (const messageId of ids) {
if (!record) return const record = resolvedStore.getMessage(messageId)
const partCount = record.partIds.length const partCount = record?.partIds.length ?? 0
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) { const previousCount = timelinePartCountsByMessageId.get(messageId)
return
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
} }
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount // Drop tracking for ids that are no longer present.
const built = buildTimelineSegments(props.instanceId, record, t) for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
const newSegments: TimelineSegment[] = [] if (!ids.includes(trackedId)) {
built.forEach((segment) => { timelinePartCountsByMessageId.delete(trackedId)
const key = makeTimelineKey(segment) }
if (seenTimelineSegmentKeys.has(key)) return }
seenTimelineSegmentKeys.add(key)
newSegments.push(segment) if (hasChanges) {
}) scheduleTimelinePartUpdateFlush()
if (newSegments.length > 0) {
setTimelineSegments((prev) => [...prev, ...newSegments])
} }
}) })
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
cancelAnimationFrame(pendingAnchorScroll) cancelAnimationFrame(pendingAnchorScroll)
} }
clearScrollToBottomFrames() clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
} }