mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 06:13:26 +00:00
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:
parent
e4595fb977
commit
20de5d2615
10 changed files with 187 additions and 268 deletions
3
.github/workflows/pr-build.yml
vendored
3
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
13
packages/ui/src/components/message-timeline-projection.ts
Normal file
13
packages/ui/src/components/message-timeline-projection.ts
Normal 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}`
|
||||
}
|
||||
58
packages/ui/src/components/message-timeline-v2.test.ts
Normal file
58
packages/ui/src/components/message-timeline-v2.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue