fix(v2): preserve virtual timeline navigation

Keep follow ownership tied to explicit user intent so Virtua measurement corrections cannot silently escape an actively followed response. Large key jumps now land immediately, while explicit timeline and top navigation cancel submit pinning without confusing programmatic movement for user scrolling.

Rebuild timeline segments from cached V2 structural signatures instead of part-count and same-length replacement heuristics. This handles provisional-to-authoritative part replacement, type and renderability changes, terminal status updates, locale changes, duplicate part IDs, and the V2 time.completed contract without rebuilding streamed text on every token.

Add follow-state, timeline projection, and duplicate-part regression coverage to the PR workflow. Validated with 264 standard UI tests, 72 browser/integration tests, full UI and Electron typechecks, production UI build, and live Tauri WebView navigation/follow scenarios.
This commit is contained in:
Pascal André 2026-08-16 13:12:36 +02:00
parent e4595fb977
commit 20de5d2615
No known key found for this signature in database
10 changed files with 187 additions and 268 deletions

View file

@ -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

View file

@ -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<TimelineSegment[]>([])
const timelineSegmentCache = new Map<string, { revision: number; status: string; locale: string; signature: string; segments: TimelineSegment[] }>()
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<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}`
}
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<string | null>(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<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.
// 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()
})

View file

@ -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}`
}

View file

@ -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))
})
})

View file

@ -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)

View file

@ -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

View file

@ -277,7 +277,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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" })
}

View file

@ -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)

View file

@ -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,

View file

@ -876,6 +876,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}
const map: MessageRecord["parts"] = {}
const ids: string[] = []
const seenIds = new Set<string>()
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 }