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