Make message list bottom-first with append-only timeline
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import { Index, type Accessor } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -10,12 +10,10 @@ export function getMessageAnchorId(messageId: string) {
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
|
||||
interface MessageBlockListProps {
|
||||
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIds: () => string[]
|
||||
messageIndexMap: () => Map<string, number>
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
@@ -27,62 +25,38 @@ interface MessageBlockListProps {
|
||||
onContentRendered?: () => void
|
||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||
suspendMeasurements?: () => boolean
|
||||
onInitialRenderComplete?: () => void
|
||||
}
|
||||
|
||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
const totalMessages = () => props.messageIds().length
|
||||
let renderedCount = 0
|
||||
let initialRenderReported = false
|
||||
const handleBlockRendered = () => {
|
||||
if (initialRenderReported) return
|
||||
renderedCount += 1
|
||||
if (renderedCount >= totalMessages() && totalMessages() > 0) {
|
||||
initialRenderReported = true
|
||||
renderedCount = 0
|
||||
props.onInitialRenderComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) {
|
||||
renderedCount = 0
|
||||
initialRenderReported = false
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Index each={props.messageIds()}>
|
||||
{(messageId) => {
|
||||
return (
|
||||
<VirtualItem
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
suspendMeasurements={props.suspendMeasurements}
|
||||
onMeasured={handleBlockRendered}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndexMap={props.messageIndexMap}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)
|
||||
}}
|
||||
{(messageId, index) => (
|
||||
<VirtualItem
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
suspendMeasurements={props.suspendMeasurements}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndex={index}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)}
|
||||
</Index>
|
||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</>
|
||||
|
||||
@@ -204,7 +204,7 @@ interface MessageBlockProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIndexMap: () => Map<string, number>
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
@@ -223,7 +223,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const current = record()
|
||||
if (!current) return null
|
||||
|
||||
const index = props.messageIndexMap().get(current.id) ?? 0
|
||||
const index = props.messageIndex
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
const info = messageInfo()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import type { Component } from "solid-js"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
@@ -10,8 +10,7 @@ interface MessagePreviewProps {
|
||||
}
|
||||
|
||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
const indexMap = createMemo(() => new Map([[props.messageId, 0]]))
|
||||
const lastAssistantIndex = createMemo(() => 0)
|
||||
const lastAssistantIndex = () => 0
|
||||
|
||||
return (
|
||||
<div class="message-preview message-stream">
|
||||
@@ -20,7 +19,7 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndexMap={indexMap}
|
||||
messageIndex={0}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => false}
|
||||
thinkingDefaultExpanded={() => false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageListHeader from "./message-list-header"
|
||||
@@ -88,14 +88,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
}
|
||||
|
||||
const messageIndexMap = createMemo(() => {
|
||||
|
||||
const map = new Map<string, number>()
|
||||
const ids = messageIds()
|
||||
ids.forEach((id, index) => map.set(id, index))
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
@@ -108,20 +100,53 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
return -1
|
||||
})
|
||||
|
||||
const timelineSegments = createMemo<TimelineSegment[]>(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
}
|
||||
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.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)
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
segments.push(...built)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
segments.push(segment)
|
||||
})
|
||||
})
|
||||
return segments
|
||||
})
|
||||
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
setTimelineSegments(segments)
|
||||
}
|
||||
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
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 [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
@@ -165,8 +190,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
let scrollToBottomDelayedFrame: number | null = null
|
||||
let pendingInitialScroll = true
|
||||
|
||||
const [initialRenderComplete, setInitialRenderComplete] = createSignal(false)
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
@@ -390,10 +413,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
scheduleAnchorScroll()
|
||||
}
|
||||
|
||||
function handleInitialRenderComplete() {
|
||||
setInitialRenderComplete(true)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
|
||||
if (!containerRef) return
|
||||
@@ -444,12 +463,123 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const loading = Boolean(props.loading)
|
||||
if (loading) {
|
||||
pendingInitialScroll = true
|
||||
setInitialRenderComplete(false)
|
||||
return
|
||||
}
|
||||
if (pendingInitialScroll && initialRenderComplete()) {
|
||||
pendingInitialScroll = false
|
||||
requestScrollToBottom(false)
|
||||
if (!pendingInitialScroll) {
|
||||
return
|
||||
}
|
||||
const container = scrollElement()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel || messageIds().length === 0) {
|
||||
return
|
||||
}
|
||||
pendingInitialScroll = false
|
||||
requestScrollToBottom(true)
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
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
|
||||
})
|
||||
previousTimelineIds = ids.slice()
|
||||
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.slice()
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
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])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -677,7 +807,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
messageIndexMap={messageIndexMap}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
@@ -689,7 +818,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
onInitialRenderComplete={handleInitialRenderComplete}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user