Improve message stream caching and virtualization for large sessions
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Develops Web UI components.
|
||||
mode: all
|
||||
model: zai-coding-plan/glm-4.6
|
||||
---
|
||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, Show, onMount, createEffect } from "solid-js"
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
|
||||
interface ToolCallDiffViewerProps {
|
||||
@@ -13,7 +14,7 @@ interface ToolCallDiffViewerProps {
|
||||
mode: DiffViewMode
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheKey?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
type DiffData = {
|
||||
@@ -22,6 +23,13 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const diffData = createMemo<DiffData | null>(() => {
|
||||
const normalized = normalizeDiffText(props.diffText)
|
||||
@@ -46,30 +54,93 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
})
|
||||
|
||||
let diffContainerRef: HTMLDivElement | undefined
|
||||
let pendingCapture: number | undefined
|
||||
let pendingContext: CaptureContext | undefined
|
||||
let lastRenderedMarkup: string | undefined
|
||||
let lastCachedHtml: string | undefined
|
||||
|
||||
const captureAndCacheHtml = () => {
|
||||
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
|
||||
// Extract the rendered HTML from DiffView container
|
||||
const renderedHtml = diffContainerRef.innerHTML
|
||||
if (renderedHtml) {
|
||||
setToolRenderCache(props.cacheKey, {
|
||||
text: props.diffText,
|
||||
html: renderedHtml,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
const clearPendingCapture = () => {
|
||||
if (pendingCapture !== undefined) {
|
||||
cancelAnimationFrame(pendingCapture)
|
||||
pendingCapture = undefined
|
||||
}
|
||||
pendingContext = undefined
|
||||
}
|
||||
|
||||
const runCapture = (context: CaptureContext) => {
|
||||
if (!diffContainerRef) {
|
||||
props.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) {
|
||||
props.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
const hasChanged = markup !== lastRenderedMarkup
|
||||
if (hasChanged) {
|
||||
lastRenderedMarkup = markup
|
||||
if (context.cacheEntryParams) {
|
||||
setCacheEntry(context.cacheEntryParams, {
|
||||
text: context.diffText,
|
||||
html: markup,
|
||||
theme: context.theme,
|
||||
mode: context.mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
props.onRendered?.()
|
||||
}
|
||||
|
||||
// Also capture HTML when diff data changes
|
||||
const scheduleCapture = (context: CaptureContext) => {
|
||||
clearPendingCapture()
|
||||
pendingContext = context
|
||||
pendingCapture = requestAnimationFrame(() => {
|
||||
const activeContext = pendingContext
|
||||
pendingContext = undefined
|
||||
pendingCapture = undefined
|
||||
if (activeContext) {
|
||||
runCapture(activeContext)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const data = diffData()
|
||||
if (data && !props.cachedHtml) {
|
||||
// Delay to allow DiffView to re-render with new data
|
||||
setTimeout(captureAndCacheHtml, 100)
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
clearPendingCapture()
|
||||
if (cachedHtml !== lastCachedHtml) {
|
||||
lastCachedHtml = cachedHtml
|
||||
lastRenderedMarkup = cachedHtml
|
||||
props.onRendered?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastCachedHtml = undefined
|
||||
|
||||
const data = diffData()
|
||||
const theme = props.theme
|
||||
const mode = props.mode
|
||||
|
||||
if (!data) {
|
||||
clearPendingCapture()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleCapture({
|
||||
theme,
|
||||
mode,
|
||||
diffText: props.diffText,
|
||||
cacheEntryParams: props.cacheEntryParams,
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearPendingCapture()
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
record: MessageRecord
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isQueued?: boolean
|
||||
parts?: ClientPart[]
|
||||
combinedParts: ClientPart[]
|
||||
orderedParts: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
const isUser = () => props.record.role === "user"
|
||||
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
|
||||
const timestamp = () => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
const date = new Date(createdTime)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
@@ -30,10 +33,10 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const displayParts = () => props.parts ?? props.message.parts
|
||||
const combinedParts = () => props.combinedParts
|
||||
|
||||
const fileAttachments = () =>
|
||||
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
props.orderedParts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
|
||||
const getAttachmentName = (part: FilePart) => {
|
||||
if (part.filename && part.filename.trim().length > 0) {
|
||||
@@ -123,7 +126,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return displayParts().some((part) => partHasRenderableText(part))
|
||||
return combinedParts().some((part) => partHasRenderableText(part))
|
||||
}
|
||||
|
||||
const isGenerating = () => {
|
||||
@@ -133,7 +136,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
const handleRevert = () => {
|
||||
if (props.onRevert && isUser()) {
|
||||
props.onRevert(props.message.id)
|
||||
props.onRevert(props.record.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +230,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Show when={isUser() && props.onFork}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
onClick={() => props.onFork?.(props.message.id)}
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
>
|
||||
@@ -254,11 +257,11 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayParts()}>
|
||||
<For each={combinedParts()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.message.type}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
@@ -341,7 +344,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<Show when={props.record.status === "sending"}>
|
||||
|
||||
|
||||
<div class="message-sending">
|
||||
@@ -349,7 +352,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<Show when={props.record.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,50 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup } from "so
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import Kbd from "./kbd"
|
||||
import type { Message, MessageInfo, ClientPart, MessageDisplayParts } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
import { showCommandPalette } from "../stores/command-palette"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance, type ToolCallPart } from "../stores/message-v2/record-display-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getScrollCache, setScrollCache } from "../lib/scroll-cache"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const TOOL_ICON = "🔧"
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
const INITIAL_BATCH_COUNT = 150
|
||||
const PREPEND_CHUNK_COUNT = 50
|
||||
const LOAD_MORE_THRESHOLD_PX = 320
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 120
|
||||
|
||||
const messageItemCache = new Map<string, MessageDisplayItem>()
|
||||
const toolItemCache = new Map<string, ToolDisplayItem>()
|
||||
|
||||
function makeInstanceCacheKey(instanceId: string, id: string) {
|
||||
return `${instanceId}:${id}`
|
||||
}
|
||||
|
||||
function clearInstanceCaches(instanceId: string) {
|
||||
clearRecordDisplayCacheForInstance(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of messageItemCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
messageItemCache.delete(key)
|
||||
}
|
||||
}
|
||||
for (const key of toolItemCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
toolItemCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
return formatTokenTotal(tokens)
|
||||
}
|
||||
@@ -32,8 +61,9 @@ interface MessageStreamV2Props {
|
||||
|
||||
interface MessageDisplayItem {
|
||||
type: "message"
|
||||
message: Message
|
||||
record: MessageRecord
|
||||
combinedParts: ClientPart[]
|
||||
orderedParts: ClientPart[]
|
||||
messageInfo?: MessageInfo
|
||||
isQueued: boolean
|
||||
}
|
||||
@@ -48,61 +78,28 @@ interface ToolDisplayItem {
|
||||
partVersion: number
|
||||
}
|
||||
|
||||
type DisplayItem = MessageDisplayItem | ToolDisplayItem
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
function isToolPart(part: ClientPart): part is ToolCallPart {
|
||||
return part.type === "tool"
|
||||
interface MessageDisplayBlock {
|
||||
record: MessageRecord
|
||||
messageItem: MessageDisplayItem | null
|
||||
toolItems: ToolDisplayItem[]
|
||||
}
|
||||
|
||||
function recordToMessage(record: MessageRecord): Message {
|
||||
const parts = record.partIds
|
||||
.map((partId) => record.parts[partId]?.data)
|
||||
.filter((part): part is ClientPart => Boolean(part))
|
||||
return {
|
||||
id: record.id,
|
||||
sessionId: record.sessionId,
|
||||
type: record.role,
|
||||
parts,
|
||||
timestamp: record.createdAt,
|
||||
status: record.status,
|
||||
version: record.revision,
|
||||
}
|
||||
interface MeasurementEntry {
|
||||
revision: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function computeDisplayPartsForMessage(message: Message, showThinking: boolean): MessageDisplayParts {
|
||||
const text: ClientPart[] = []
|
||||
const tool: ClientPart[] = []
|
||||
const reasoning: ClientPart[] = []
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
|
||||
text.push(part)
|
||||
} else if (part.type === "tool") {
|
||||
tool.push(part)
|
||||
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
|
||||
reasoning.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
|
||||
const version = typeof message.version === "number" ? message.version : 0
|
||||
|
||||
return { text, tool, reasoning, combined, showThinking, version }
|
||||
}
|
||||
|
||||
function hasRenderableContent(message: Message, combinedParts: ClientPart[], info?: MessageInfo): boolean {
|
||||
if (message.type !== "assistant" && message.type !== "user") {
|
||||
function hasRenderableContent(record: MessageRecord, combinedParts: ClientPart[], info?: MessageInfo): boolean {
|
||||
if (record.role !== "assistant" && record.role !== "user") {
|
||||
return false
|
||||
}
|
||||
if (message.type !== "assistant" || combinedParts.length > 0) {
|
||||
if (record.role !== "assistant" || combinedParts.length > 0) {
|
||||
return true
|
||||
}
|
||||
if (info && info.role === "assistant" && info.error) {
|
||||
return true
|
||||
}
|
||||
return message.status === "error"
|
||||
return record.status === "error"
|
||||
}
|
||||
|
||||
export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
@@ -115,6 +112,63 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
.filter((record): record is MessageRecord => Boolean(record)),
|
||||
)
|
||||
|
||||
const [visibleRange, setVisibleRange] = createSignal({ start: 0, end: 0 })
|
||||
const [rangeInitialized, setRangeInitialized] = createSignal(false)
|
||||
const [forceFullHistory, setForceFullHistory] = createSignal(false)
|
||||
const messageMeasurements = new Map<string, MeasurementEntry>()
|
||||
const [measurementVersion, setMeasurementVersion] = createSignal(0)
|
||||
const [virtualPadding, setVirtualPadding] = createSignal(0)
|
||||
const [reachedAbsoluteTop, setReachedAbsoluteTop] = createSignal(false)
|
||||
const showLoadOlderButton = createMemo(() => visibleRange().start > 0 && reachedAbsoluteTop())
|
||||
|
||||
function updateMeasurementCache(messageId: string, revision: number, height: number) {
|
||||
const safeHeight = Math.max(0, height)
|
||||
const existing = messageMeasurements.get(messageId)
|
||||
if (existing && existing.revision === revision && Math.abs(existing.height - safeHeight) < 1) {
|
||||
return
|
||||
}
|
||||
messageMeasurements.set(messageId, { revision, height: safeHeight })
|
||||
setMeasurementVersion((value) => value + 1)
|
||||
}
|
||||
|
||||
function getAverageMeasuredHeight() {
|
||||
if (messageMeasurements.size === 0) {
|
||||
return ESTIMATED_MESSAGE_HEIGHT
|
||||
}
|
||||
let total = 0
|
||||
for (const entry of messageMeasurements.values()) {
|
||||
total += entry.height
|
||||
}
|
||||
return total / messageMeasurements.size
|
||||
}
|
||||
|
||||
const messageIndexMap = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
const records = messageRecords()
|
||||
records.forEach((record, index) => map.set(record.id, index))
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const records = messageRecords()
|
||||
for (let index = records.length - 1; index >= 0; index--) {
|
||||
if (records[index].role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
const visibleRecords = createMemo(() => {
|
||||
const records = messageRecords()
|
||||
const range = visibleRange()
|
||||
if (range.end === 0) {
|
||||
return records
|
||||
}
|
||||
return records.slice(range.start, range.end)
|
||||
})
|
||||
|
||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
@@ -145,30 +199,50 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
|
||||
const messageInfoMap = createMemo(() => {
|
||||
const map = new Map<string, MessageInfo>()
|
||||
messageIds().forEach((id) => {
|
||||
const info = store().getMessageInfo(id)
|
||||
const records = visibleRecords()
|
||||
records.forEach((record) => {
|
||||
const info = store().getMessageInfo(record.id)
|
||||
if (info) {
|
||||
map.set(id, info)
|
||||
map.set(record.id, info)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId))
|
||||
|
||||
const displayItems = createMemo<DisplayItem[]>(() => {
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: SCROLL_SCOPE,
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const sessionId = props.sessionId
|
||||
store()
|
||||
messageMeasurements.clear()
|
||||
setMeasurementVersion((value) => value + 1)
|
||||
setVirtualPadding(0)
|
||||
setVisibleRange({ start: 0, end: 0 })
|
||||
setRangeInitialized(false)
|
||||
setReachedAbsoluteTop(false)
|
||||
const snapshot = store().getScrollSnapshot(sessionId, SCROLL_SCOPE)
|
||||
setForceFullHistory(Boolean(snapshot && !snapshot.atBottom))
|
||||
previousToken = undefined
|
||||
})
|
||||
|
||||
const displayBlocks = createMemo<MessageDisplayBlock[]>(() => {
|
||||
const infoMap = messageInfoMap()
|
||||
const showThinking = preferences().showThinkingBlocks
|
||||
const revert = revertTarget()
|
||||
const items: DisplayItem[] = []
|
||||
|
||||
const records = messageRecords()
|
||||
let lastAssistantIndex = -1
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
if (records[i].role === "assistant") {
|
||||
lastAssistantIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const instanceId = props.instanceId
|
||||
const blocks: MessageDisplayBlock[] = []
|
||||
const usedMessageKeys = new Set<string>()
|
||||
const usedToolKeys = new Set<string>()
|
||||
const records = visibleRecords()
|
||||
const globalAssistantIndex = lastAssistantIndex()
|
||||
const indexMap = messageIndexMap()
|
||||
|
||||
for (let index = 0; index < records.length; index++) {
|
||||
const record = records[index]
|
||||
@@ -176,61 +250,197 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
break
|
||||
}
|
||||
|
||||
const baseMessage = recordToMessage(record)
|
||||
const displayParts = computeDisplayPartsForMessage(baseMessage, showThinking)
|
||||
baseMessage.displayParts = displayParts
|
||||
const combinedParts = displayParts.combined
|
||||
const { orderedParts, textAndReasoningParts, toolParts } = buildRecordDisplayData(instanceId, record, showThinking)
|
||||
const messageInfo = infoMap.get(record.id)
|
||||
const isQueued =
|
||||
baseMessage.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex)
|
||||
const recordCacheKey = makeInstanceCacheKey(instanceId, record.id)
|
||||
const recordIndex = indexMap.get(record.id) ?? 0
|
||||
const isQueued = record.role === "user" && (globalAssistantIndex === -1 || recordIndex > globalAssistantIndex)
|
||||
|
||||
if (hasRenderableContent(baseMessage, combinedParts, messageInfo)) {
|
||||
items.push({
|
||||
type: "message",
|
||||
message: baseMessage,
|
||||
combinedParts,
|
||||
messageInfo,
|
||||
isQueued,
|
||||
})
|
||||
let messageItem: MessageDisplayItem | null = null
|
||||
if (hasRenderableContent(record, textAndReasoningParts, messageInfo)) {
|
||||
let cached = messageItemCache.get(recordCacheKey)
|
||||
if (!cached) {
|
||||
cached = {
|
||||
type: "message",
|
||||
record,
|
||||
combinedParts: textAndReasoningParts,
|
||||
orderedParts,
|
||||
messageInfo,
|
||||
isQueued,
|
||||
}
|
||||
messageItemCache.set(recordCacheKey, cached)
|
||||
} else {
|
||||
cached.record = record
|
||||
cached.combinedParts = textAndReasoningParts
|
||||
cached.orderedParts = orderedParts
|
||||
cached.messageInfo = messageInfo
|
||||
cached.isQueued = isQueued
|
||||
}
|
||||
messageItem = cached
|
||||
usedMessageKeys.add(recordCacheKey)
|
||||
}
|
||||
|
||||
const toolParts: ToolCallPart[] = displayParts.tool.filter(isToolPart)
|
||||
const toolItems: ToolDisplayItem[] = []
|
||||
toolParts.forEach((toolPart, toolIndex) => {
|
||||
const partVersion = typeof toolPart.version === "number" ? toolPart.version : 0
|
||||
const messageVersion = typeof baseMessage.version === "number" ? baseMessage.version : 0
|
||||
const key = toolPart.id || `${record.id}-tool-${toolIndex}`
|
||||
items.push({
|
||||
type: "tool",
|
||||
key,
|
||||
toolPart,
|
||||
messageInfo,
|
||||
messageId: record.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
})
|
||||
const messageVersion = record.revision
|
||||
const key = `${record.id}:${toolPart.id ?? toolIndex}`
|
||||
const toolCacheKey = makeInstanceCacheKey(instanceId, key)
|
||||
let toolItem = toolItemCache.get(toolCacheKey)
|
||||
if (!toolItem) {
|
||||
toolItem = {
|
||||
type: "tool",
|
||||
key,
|
||||
toolPart,
|
||||
messageInfo,
|
||||
messageId: record.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
toolItemCache.set(toolCacheKey, toolItem)
|
||||
} else {
|
||||
toolItem.key = key
|
||||
toolItem.toolPart = toolPart
|
||||
toolItem.messageInfo = messageInfo
|
||||
toolItem.messageId = record.id
|
||||
toolItem.messageVersion = messageVersion
|
||||
toolItem.partVersion = partVersion
|
||||
}
|
||||
toolItems.push(toolItem)
|
||||
usedToolKeys.add(toolCacheKey)
|
||||
})
|
||||
|
||||
if (!messageItem && toolItems.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push({ record, messageItem, toolItems })
|
||||
}
|
||||
|
||||
return items
|
||||
for (const key of messageItemCache.keys()) {
|
||||
if (!usedMessageKeys.has(key)) {
|
||||
messageItemCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of toolItemCache.keys()) {
|
||||
if (!usedToolKeys.has(key)) {
|
||||
toolItemCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const records = messageRecords()
|
||||
const total = records.length
|
||||
const requireFullHistory = forceFullHistory()
|
||||
if (total === 0) {
|
||||
setVisibleRange({ start: 0, end: 0 })
|
||||
setRangeInitialized(false)
|
||||
return
|
||||
}
|
||||
|
||||
setVisibleRange((current) => {
|
||||
if (!rangeInitialized() || requireFullHistory) {
|
||||
const start = requireFullHistory ? 0 : Math.max(0, total - INITIAL_BATCH_COUNT)
|
||||
if (!rangeInitialized()) {
|
||||
setRangeInitialized(true)
|
||||
}
|
||||
if (requireFullHistory) {
|
||||
setForceFullHistory(false)
|
||||
}
|
||||
return { start, end: total }
|
||||
}
|
||||
const nextEnd = total
|
||||
let nextStart = current.start
|
||||
if (nextStart > nextEnd) {
|
||||
nextStart = Math.max(0, nextEnd - INITIAL_BATCH_COUNT)
|
||||
}
|
||||
return { start: nextStart, end: nextEnd }
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
measurementVersion()
|
||||
const range = visibleRange()
|
||||
if (range.start <= 0) {
|
||||
setVirtualPadding(0)
|
||||
return
|
||||
}
|
||||
const records = messageRecords()
|
||||
const trimmed = records.slice(0, range.start)
|
||||
if (trimmed.length === 0) {
|
||||
setVirtualPadding(0)
|
||||
return
|
||||
}
|
||||
const fallback = getAverageMeasuredHeight()
|
||||
let total = 0
|
||||
for (const record of trimmed) {
|
||||
const entry = messageMeasurements.get(record.id)
|
||||
total += entry?.height ?? fallback
|
||||
}
|
||||
setVirtualPadding(total)
|
||||
})
|
||||
|
||||
const changeToken = createMemo(() => {
|
||||
const entries = displayItems()
|
||||
return entries
|
||||
.map((item) => {
|
||||
if (item.type === "message") {
|
||||
return `${item.message.id}:${item.message.version}:${item.combinedParts.length}`
|
||||
}
|
||||
const status = item.toolPart.state?.status || "unknown"
|
||||
return `tool:${item.key}:${item.partVersion}:${status}`
|
||||
})
|
||||
.join("|")
|
||||
const revisionValue = sessionRevision()
|
||||
const range = visibleRange()
|
||||
const blocks = displayBlocks()
|
||||
if (blocks.length === 0) {
|
||||
return `${revisionValue}:${range.start}:${range.end}:empty`
|
||||
}
|
||||
const lastBlock = blocks[blocks.length - 1]
|
||||
const lastTool = lastBlock.toolItems[lastBlock.toolItems.length - 1]
|
||||
const tailSignature = lastTool
|
||||
? `tool:${lastTool.key}:${lastTool.partVersion}`
|
||||
: `msg:${lastBlock.record.id}:${lastBlock.record.revision}`
|
||||
return `${revisionValue}:${range.start}:${range.end}:${tailSignature}`
|
||||
})
|
||||
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
function captureScrollSnapshot() {
|
||||
if (!containerRef) return { height: 0, top: 0 }
|
||||
return { height: containerRef.scrollHeight, top: containerRef.scrollTop }
|
||||
}
|
||||
|
||||
function restoreScrollSnapshot(snapshot?: { height: number; top: number }) {
|
||||
if (!containerRef || !snapshot) return
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
const delta = containerRef.scrollHeight - snapshot.height
|
||||
containerRef.scrollTop = snapshot.top + delta
|
||||
})
|
||||
}
|
||||
|
||||
function prependChunk(amount = PREPEND_CHUNK_COUNT) {
|
||||
if (visibleRange().start === 0) {
|
||||
return
|
||||
}
|
||||
const snapshot = captureScrollSnapshot()
|
||||
setVisibleRange((range) => {
|
||||
if (range.start === 0) {
|
||||
return range
|
||||
}
|
||||
const nextStart = Math.max(0, range.start - amount)
|
||||
return { start: nextStart, end: range.end }
|
||||
})
|
||||
restoreScrollSnapshot(snapshot)
|
||||
}
|
||||
|
||||
function loadAllOlderMessages() {
|
||||
if (visibleRange().start === 0) {
|
||||
return
|
||||
}
|
||||
const snapshot = captureScrollSnapshot()
|
||||
setVisibleRange((range) => ({ start: 0, end: range.end }))
|
||||
restoreScrollSnapshot(snapshot)
|
||||
}
|
||||
|
||||
function isNearBottom(element: HTMLDivElement, offset = 48) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
return scrollHeight - (scrollTop + clientHeight) <= offset
|
||||
@@ -246,41 +456,45 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
|
||||
function persistScrollState() {
|
||||
if (!containerRef) return
|
||||
setScrollCache(
|
||||
{ instanceId: props.instanceId, sessionId: props.sessionId, scope: SCROLL_SCOPE },
|
||||
{
|
||||
scrollTop: containerRef.scrollTop,
|
||||
atBottom: isNearBottom(containerRef),
|
||||
},
|
||||
)
|
||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||
}
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (!containerRef) return
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
setShowScrollButton(!atBottom)
|
||||
const atAbsoluteTop = containerRef.scrollTop <= 4
|
||||
setReachedAbsoluteTop(atAbsoluteTop)
|
||||
if (event.isTrusted) {
|
||||
setAutoScroll(atBottom)
|
||||
if (containerRef.scrollTop <= LOAD_MORE_THRESHOLD_PX && visibleRange().start > 0) {
|
||||
prependChunk()
|
||||
}
|
||||
}
|
||||
persistScrollState()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const scrollSnapshot = getScrollCache({ instanceId: props.instanceId, sessionId: props.sessionId, scope: SCROLL_SCOPE })
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
if (scrollSnapshot) {
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
containerRef.scrollTop = Math.min(scrollSnapshot.scrollTop, maxScrollTop)
|
||||
setAutoScroll(scrollSnapshot.atBottom)
|
||||
setShowScrollButton(!scrollSnapshot.atBottom)
|
||||
} else {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
const sessionId = props.sessionId
|
||||
store()
|
||||
const target = containerRef
|
||||
if (!target) return
|
||||
scrollCache.restore(target, {
|
||||
fallback: () => scrollToBottom(true),
|
||||
onApplied: (snapshot) => {
|
||||
if (snapshot) {
|
||||
setAutoScroll(snapshot.atBottom)
|
||||
setShowScrollButton(!snapshot.atBottom)
|
||||
} else {
|
||||
const atBottom = isNearBottom(target)
|
||||
setAutoScroll(atBottom)
|
||||
setShowScrollButton(!atBottom)
|
||||
}
|
||||
},
|
||||
})
|
||||
void sessionId
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
if (!token || token === previousToken) {
|
||||
@@ -293,7 +507,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (displayItems().length === 0) {
|
||||
if (messageRecords().length === 0) {
|
||||
setShowScrollButton(false)
|
||||
setAutoScroll(true)
|
||||
}
|
||||
@@ -358,7 +572,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Show when={!props.loading && displayItems().length === 0}>
|
||||
<Show when={!props.loading && messageRecords().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
@@ -388,41 +602,84 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayItems()}>
|
||||
{(item) => {
|
||||
if (item.type === "message") {
|
||||
return (
|
||||
<MessageItem
|
||||
message={item.message}
|
||||
messageInfo={item.messageInfo}
|
||||
parts={item.combinedParts}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={item.isQueued}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
/>
|
||||
)
|
||||
<Show when={virtualPadding() > 0}>
|
||||
<div class="message-stream-virtual-padding" style={{ height: `${virtualPadding()}px` }} aria-hidden="true" />
|
||||
</Show>
|
||||
|
||||
<Show when={showLoadOlderButton()}>
|
||||
<div class="message-stream-load-older">
|
||||
<button type="button" class="message-stream-load-older-button" onClick={loadAllOlderMessages}>
|
||||
Load older messages
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayBlocks()}>
|
||||
{(block) => {
|
||||
let blockRef: HTMLDivElement | undefined
|
||||
|
||||
const scheduleMeasurement = () => {
|
||||
if (!blockRef) return
|
||||
requestAnimationFrame(() => {
|
||||
if (!blockRef) return
|
||||
updateMeasurementCache(block.record.id, block.record.revision, blockRef.clientHeight)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void block.record.revision
|
||||
scheduleMeasurement()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tool-call-message" data-key={item.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={item.toolPart}
|
||||
toolCallId={item.key}
|
||||
messageId={item.messageId}
|
||||
messageVersion={item.messageVersion}
|
||||
partVersion={item.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
<div
|
||||
class="message-stream-block"
|
||||
data-message-id={block.record.id}
|
||||
ref={(element) => {
|
||||
blockRef = element || undefined
|
||||
if (element) {
|
||||
scheduleMeasurement()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={block.messageItem} keyed>
|
||||
{(message) => (
|
||||
<MessageItem
|
||||
record={message.record}
|
||||
messageInfo={message.messageInfo}
|
||||
combinedParts={message.combinedParts}
|
||||
orderedParts={message.orderedParts}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={message.isQueued}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={block.toolItems}>
|
||||
{(item) => (
|
||||
<div class="tool-call-message" data-key={item.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={item.toolPart}
|
||||
toolCallId={item.key}
|
||||
messageId={item.messageId}
|
||||
messageVersion={item.messageVersion}
|
||||
partVersion={item.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -6,11 +6,12 @@ import { ToolCallDiffViewer } from "./diff-viewer"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { isRenderableDiffText } from "../lib/diff-utils"
|
||||
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { sendPermissionResponse } from "../stores/instances"
|
||||
import type { TextPart, SDKPart, ClientPart } from "../types/message"
|
||||
import type { TextPart, SDKPart, ClientPart, RenderCache } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -34,46 +35,19 @@ function isToolStateError(state: ToolState): state is ToolStateError {
|
||||
}
|
||||
|
||||
|
||||
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
messageId?: string,
|
||||
messageVersion?: number,
|
||||
partVersion?: number,
|
||||
variant = "default",
|
||||
) {
|
||||
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}`
|
||||
const keyBase = `${messageId}:${toolCallId}`
|
||||
return `${keyBase}::${suffix}`
|
||||
}
|
||||
|
||||
function updateScrollState(id: string, element: HTMLElement) {
|
||||
if (!id) return
|
||||
const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight)
|
||||
const atBottom = distanceFromBottom <= 2
|
||||
toolScrollState.set(id, { scrollTop: element.scrollTop, atBottom })
|
||||
}
|
||||
|
||||
function restoreScrollState(id: string, element: HTMLElement) {
|
||||
if (!id) return
|
||||
const state = toolScrollState.get(id)
|
||||
if (!state) {
|
||||
requestAnimationFrame(() => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
updateScrollState(id, element)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (state.atBottom) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
} else {
|
||||
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
|
||||
element.scrollTop = Math.min(state.scrollTop, maxScrollTop)
|
||||
}
|
||||
updateScrollState(id, element)
|
||||
})
|
||||
const messageComponent = messageId ?? "unknown-message"
|
||||
const toolCallComponent = toolCallId ?? "unknown-tool-call"
|
||||
const versionComponent = `${messageVersion ?? 0}:${partVersion ?? 0}`
|
||||
return `${messageComponent}:${toolCallComponent}:${versionComponent}:${variant}`
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +322,34 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const { isDark } = useTheme()
|
||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
|
||||
const cacheContext = createMemo(() => ({
|
||||
toolCallId: toolCallId(),
|
||||
messageId: props.messageId,
|
||||
messageVersion: props.messageVersion ?? 0,
|
||||
partVersion: props.partVersion ?? 0,
|
||||
}))
|
||||
|
||||
const createVariantCache = (variant: string) =>
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: TOOL_CALL_CACHE_SCOPE,
|
||||
key: () => {
|
||||
const context = cacheContext()
|
||||
return makeRenderCacheKey(
|
||||
context.toolCallId || undefined,
|
||||
context.messageId,
|
||||
context.messageVersion,
|
||||
context.partVersion,
|
||||
variant,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const diffCache = createVariantCache("diff")
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const markdownCache = createVariantCache("markdown")
|
||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallId() || props.toolCall?.id))
|
||||
const pendingPermission = createMemo(() => {
|
||||
const state = permissionState()
|
||||
@@ -383,30 +385,49 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let toolCallRootRef: HTMLDivElement | undefined
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
|
||||
const id = toolCallId()
|
||||
|
||||
if (!id || !scrollContainerRef) return
|
||||
restoreScrollState(id, scrollContainerRef)
|
||||
const scrollScopeId = createMemo(() => {
|
||||
const id = toolCallId()
|
||||
if (id) return id
|
||||
const messageKey = props.messageId || "unknown"
|
||||
const partKey = typeof props.partVersion === "number" ? props.partVersion : 0
|
||||
return `${messageKey}:${partKey}`
|
||||
})
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: () => `${TOOL_CALL_CACHE_SCOPE}:scroll:${scrollScopeId()}`,
|
||||
})
|
||||
|
||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
scrollCache.persist(element, { atBottomOffset: 2 })
|
||||
}
|
||||
|
||||
const restoreScrollSnapshot = (element?: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
scrollCache.restore(element, {
|
||||
fallback: () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!element || !element.isConnected) return
|
||||
element.scrollTop = element.scrollHeight
|
||||
persistScrollSnapshot(element)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
if (!scrollContainerRef) return
|
||||
restoreScrollSnapshot(scrollContainerRef)
|
||||
}
|
||||
|
||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||
const resolvedElement = element || undefined
|
||||
scrollContainerRef = resolvedElement
|
||||
const id = toolCallId()
|
||||
if (!resolvedElement || !id) return
|
||||
|
||||
if (!toolScrollState.has(id)) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef || toolCallId() !== id) return
|
||||
scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight
|
||||
updateScrollState(id, scrollContainerRef)
|
||||
})
|
||||
} else {
|
||||
restoreScrollState(id, resolvedElement)
|
||||
}
|
||||
if (!resolvedElement) return
|
||||
restoreScrollSnapshot(resolvedElement)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -435,16 +456,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup cache entry when component unmounts or toolCallId changes
|
||||
createEffect(() => {
|
||||
const id = toolCallId()
|
||||
if (!id) return
|
||||
|
||||
onCleanup(() => {
|
||||
toolScrollState.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.toolCall?.tool !== "task") return
|
||||
const state = props.toolCall?.state
|
||||
@@ -734,25 +745,20 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return renderMarkdownTool(toolName, state)
|
||||
}
|
||||
|
||||
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
|
||||
function renderDiffTool(payload: DiffPayload, options?: { variant?: string; disableScrollTracking?: boolean; label?: string }) {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = isDark() ? "dark" : "light"
|
||||
|
||||
// Check if we have valid cache
|
||||
let cachedHtml: string | undefined
|
||||
if (cacheKey) {
|
||||
const cached = getToolRenderCache(cacheKey)
|
||||
const currentMode = diffMode()
|
||||
if (cached &&
|
||||
cached.text === payload.diffText &&
|
||||
cached.theme === themeKey &&
|
||||
cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
const cached = cacheHandle.get<RenderCache>()
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
@@ -760,10 +766,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (cacheKey && !cachedHtml) {
|
||||
// Cache will be updated by the diff viewer component itself
|
||||
// We'll capture HTML from the rendered component
|
||||
}
|
||||
if (!options?.disableScrollTracking) {
|
||||
handleScrollRendered()
|
||||
}
|
||||
@@ -776,7 +778,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
if (options?.disableScrollTracking) return
|
||||
initializeScrollContainer(element)
|
||||
}}
|
||||
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)}
|
||||
>
|
||||
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
@@ -806,7 +808,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheKey={cacheKey}
|
||||
cacheEntryParams={cacheHandle.params()}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
</div>
|
||||
@@ -822,20 +824,15 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
|
||||
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
|
||||
const disableHighlight = state?.status === "running"
|
||||
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||
|
||||
const markdownPart: TextPart = { type: "text", text: content }
|
||||
if (cacheKey) {
|
||||
const cached = getToolRenderCache(cacheKey)
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
}
|
||||
const cached = markdownCache.get<RenderCache>()
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
}
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
if (cacheKey) {
|
||||
setToolRenderCache(cacheKey, markdownPart.renderCache)
|
||||
}
|
||||
markdownCache.set(markdownPart.renderCache)
|
||||
handleScrollRendered()
|
||||
}
|
||||
|
||||
@@ -843,7 +840,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
|
||||
>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
@@ -1053,7 +1050,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
onScroll={(event) => persistScrollSnapshot(event.currentTarget)}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={summary}>
|
||||
@@ -1131,7 +1128,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{renderDiffTool(payload(), {
|
||||
cacheKeySuffix: "::permission",
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||
})}
|
||||
|
||||
126
packages/ui/src/lib/global-cache.ts
Normal file
126
packages/ui/src/lib/global-cache.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export interface CacheEntryBaseParams {
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export interface CacheEntryParams extends CacheEntryBaseParams {
|
||||
key: string
|
||||
}
|
||||
|
||||
type CacheValueMap = Map<string, unknown>
|
||||
type CacheScopeMap = Map<string, CacheValueMap>
|
||||
type CacheSessionMap = Map<string, CacheScopeMap>
|
||||
|
||||
const GLOBAL_KEY = "GLOBAL"
|
||||
const cacheStore = new Map<string, CacheSessionMap>()
|
||||
|
||||
function resolveKey(value?: string) {
|
||||
return value && value.length > 0 ? value : GLOBAL_KEY
|
||||
}
|
||||
|
||||
function getScopeValueMap(params: CacheEntryParams, create: boolean): CacheValueMap | undefined {
|
||||
const instanceKey = resolveKey(params.instanceId)
|
||||
const sessionKey = resolveKey(params.sessionId)
|
||||
|
||||
let sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) {
|
||||
if (!create) return undefined
|
||||
sessionMap = new Map()
|
||||
cacheStore.set(instanceKey, sessionMap)
|
||||
}
|
||||
|
||||
let scopeMap = sessionMap.get(sessionKey)
|
||||
if (!scopeMap) {
|
||||
if (!create) return undefined
|
||||
scopeMap = new Map()
|
||||
sessionMap.set(sessionKey, scopeMap)
|
||||
}
|
||||
|
||||
let valueMap = scopeMap.get(params.scope)
|
||||
if (!valueMap) {
|
||||
if (!create) return undefined
|
||||
valueMap = new Map()
|
||||
scopeMap.set(params.scope, valueMap)
|
||||
}
|
||||
|
||||
return valueMap
|
||||
}
|
||||
|
||||
function cleanupHierarchy(instanceKey: string, sessionKey: string, scopeKey?: string) {
|
||||
const sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) {
|
||||
return
|
||||
}
|
||||
|
||||
const scopeMap = sessionMap.get(sessionKey)
|
||||
if (!scopeMap) {
|
||||
if (sessionMap.size === 0) {
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (scopeKey) {
|
||||
const valueMap = scopeMap.get(scopeKey)
|
||||
if (valueMap && valueMap.size === 0) {
|
||||
scopeMap.delete(scopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
if (scopeMap.size === 0) {
|
||||
sessionMap.delete(sessionKey)
|
||||
}
|
||||
|
||||
if (sessionMap.size === 0) {
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined): void {
|
||||
const instanceKey = resolveKey(params.instanceId)
|
||||
const sessionKey = resolveKey(params.sessionId)
|
||||
|
||||
if (value === undefined) {
|
||||
const existingMap = getScopeValueMap(params, false)
|
||||
existingMap?.delete(params.key)
|
||||
cleanupHierarchy(instanceKey, sessionKey, params.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const scopeEntries = getScopeValueMap(params, true)
|
||||
scopeEntries?.set(params.key, value)
|
||||
}
|
||||
|
||||
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
|
||||
const scopeEntries = getScopeValueMap(params, false)
|
||||
return scopeEntries?.get(params.key) as T | undefined
|
||||
}
|
||||
|
||||
export function clearCacheScope(params: CacheEntryBaseParams): void {
|
||||
const instanceKey = resolveKey(params.instanceId)
|
||||
const sessionKey = resolveKey(params.sessionId)
|
||||
const sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) return
|
||||
const scopeMap = sessionMap.get(sessionKey)
|
||||
if (!scopeMap) return
|
||||
scopeMap.delete(params.scope)
|
||||
cleanupHierarchy(instanceKey, sessionKey)
|
||||
}
|
||||
|
||||
export function clearCacheForSession(instanceId?: string, sessionId?: string): void {
|
||||
const instanceKey = resolveKey(instanceId)
|
||||
const sessionKey = resolveKey(sessionId)
|
||||
const sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) return
|
||||
sessionMap.delete(sessionKey)
|
||||
if (sessionMap.size === 0) {
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCacheForInstance(instanceId?: string): void {
|
||||
const instanceKey = resolveKey(instanceId)
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
|
||||
86
packages/ui/src/lib/hooks/use-global-cache.ts
Normal file
86
packages/ui/src/lib/hooks/use-global-cache.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import {
|
||||
type CacheEntryParams,
|
||||
getCacheEntry,
|
||||
setCacheEntry,
|
||||
clearCacheScope,
|
||||
clearCacheForSession,
|
||||
clearCacheForInstance,
|
||||
} from "../global-cache"
|
||||
|
||||
/**
|
||||
* `useGlobalCache` exposes a tiny typed facade over the shared cache helpers.
|
||||
* Callers can pass raw values or accessors for the cache keys; empty identifiers
|
||||
* automatically fall back to the global buckets.
|
||||
*/
|
||||
export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle {
|
||||
const resolvedEntry = createMemo<CacheEntryParams>(() => {
|
||||
const instanceId = normalizeId(resolveValue(params.instanceId))
|
||||
const sessionId = normalizeId(resolveValue(params.sessionId))
|
||||
const scope = resolveValue(params.scope)
|
||||
const key = resolveValue(params.key)
|
||||
return { instanceId, sessionId, scope, key }
|
||||
})
|
||||
|
||||
const scopeParams = createMemo(() => {
|
||||
const entry = resolvedEntry()
|
||||
return { instanceId: entry.instanceId, sessionId: entry.sessionId, scope: entry.scope }
|
||||
})
|
||||
|
||||
const sessionParams = createMemo(() => {
|
||||
const entry = resolvedEntry()
|
||||
return { instanceId: entry.instanceId, sessionId: entry.sessionId }
|
||||
})
|
||||
|
||||
return {
|
||||
get<T>() {
|
||||
return getCacheEntry<T>(resolvedEntry())
|
||||
},
|
||||
set<T>(value: T | undefined) {
|
||||
setCacheEntry(resolvedEntry(), value)
|
||||
},
|
||||
clearScope() {
|
||||
clearCacheScope(scopeParams())
|
||||
},
|
||||
clearSession() {
|
||||
const params = sessionParams()
|
||||
clearCacheForSession(params.instanceId, params.sessionId)
|
||||
},
|
||||
clearInstance() {
|
||||
const params = sessionParams()
|
||||
clearCacheForInstance(params.instanceId)
|
||||
},
|
||||
params() {
|
||||
return resolvedEntry()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeId(value?: string): string | undefined {
|
||||
return value && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function resolveValue<T>(value: MaybeAccessor<T> | undefined): T {
|
||||
if (typeof value === "function") {
|
||||
return (value as Accessor<T>)()
|
||||
}
|
||||
return value as T
|
||||
}
|
||||
|
||||
type MaybeAccessor<T> = T | Accessor<T>
|
||||
|
||||
interface UseGlobalCacheParams {
|
||||
instanceId?: MaybeAccessor<string | undefined>
|
||||
sessionId?: MaybeAccessor<string | undefined>
|
||||
scope: MaybeAccessor<string>
|
||||
key: MaybeAccessor<string>
|
||||
}
|
||||
|
||||
interface GlobalCacheHandle {
|
||||
get<T>(): T | undefined
|
||||
set<T>(value: T | undefined): void
|
||||
clearScope(): void
|
||||
clearSession(): void
|
||||
clearInstance(): void
|
||||
params(): CacheEntryParams
|
||||
}
|
||||
102
packages/ui/src/lib/hooks/use-scroll-cache.ts
Normal file
102
packages/ui/src/lib/hooks/use-scroll-cache.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import type { ScrollSnapshot } from "../../stores/message-v2/types"
|
||||
|
||||
interface UseScrollCacheParams {
|
||||
instanceId: MaybeAccessor<string>
|
||||
sessionId: MaybeAccessor<string>
|
||||
scope: MaybeAccessor<string>
|
||||
}
|
||||
|
||||
interface PersistScrollOptions {
|
||||
atBottomOffset?: number
|
||||
}
|
||||
|
||||
interface RestoreScrollOptions {
|
||||
behavior?: ScrollBehavior
|
||||
fallback?: () => void
|
||||
onApplied?: (snapshot: ScrollSnapshot | undefined) => void
|
||||
}
|
||||
|
||||
interface ScrollCacheHandle {
|
||||
persist: (element: HTMLElement | null | undefined, options?: PersistScrollOptions) => ScrollSnapshot | undefined
|
||||
restore: (element: HTMLElement | null | undefined, options?: RestoreScrollOptions) => void
|
||||
}
|
||||
|
||||
const DEFAULT_BOTTOM_OFFSET = 48
|
||||
|
||||
/**
|
||||
* Wraps the message-store scroll snapshot helpers so components can
|
||||
* persist/restore scroll positions without duplicating requestAnimationFrame
|
||||
* boilerplate.
|
||||
*/
|
||||
export function useScrollCache(params: UseScrollCacheParams): ScrollCacheHandle {
|
||||
const resolved = createMemo(() => ({
|
||||
instanceId: resolveValue(params.instanceId),
|
||||
sessionId: resolveValue(params.sessionId),
|
||||
scope: resolveValue(params.scope),
|
||||
}))
|
||||
|
||||
const store = createMemo(() => {
|
||||
const { instanceId } = resolved()
|
||||
return messageStoreBus.getOrCreate(instanceId)
|
||||
})
|
||||
|
||||
function persist(element: HTMLElement | null | undefined, options?: PersistScrollOptions) {
|
||||
if (!element) {
|
||||
return undefined
|
||||
}
|
||||
const target = resolved()
|
||||
if (!target.sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const snapshot: Omit<ScrollSnapshot, "updatedAt"> = {
|
||||
scrollTop: element.scrollTop,
|
||||
atBottom: isNearBottom(element, options?.atBottomOffset ?? DEFAULT_BOTTOM_OFFSET),
|
||||
}
|
||||
store().setScrollSnapshot(target.sessionId, target.scope, snapshot)
|
||||
return { ...snapshot, updatedAt: Date.now() }
|
||||
}
|
||||
|
||||
function restore(element: HTMLElement | null | undefined, options?: RestoreScrollOptions) {
|
||||
const target = resolved()
|
||||
if (!element || !target.sessionId) {
|
||||
options?.fallback?.()
|
||||
options?.onApplied?.(undefined)
|
||||
return
|
||||
}
|
||||
const snapshot = store().getScrollSnapshot(target.sessionId, target.scope)
|
||||
requestAnimationFrame(() => {
|
||||
if (!element) {
|
||||
options?.onApplied?.(snapshot)
|
||||
return
|
||||
}
|
||||
if (!snapshot) {
|
||||
options?.fallback?.()
|
||||
options?.onApplied?.(undefined)
|
||||
return
|
||||
}
|
||||
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
|
||||
const nextTop = snapshot.atBottom ? maxScrollTop : Math.min(snapshot.scrollTop, maxScrollTop)
|
||||
const behavior = options?.behavior ?? "auto"
|
||||
element.scrollTo({ top: nextTop, behavior })
|
||||
options?.onApplied?.(snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
return { persist, restore }
|
||||
}
|
||||
|
||||
function isNearBottom(element: HTMLElement, offset: number) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
return scrollHeight - (scrollTop + clientHeight) <= offset
|
||||
}
|
||||
|
||||
function resolveValue<T>(value: MaybeAccessor<T>): T {
|
||||
if (typeof value === "function") {
|
||||
return (value as Accessor<T>)()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type MaybeAccessor<T> = T | Accessor<T>
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ScrollSnapshot } from "../stores/message-v2/types"
|
||||
|
||||
interface ScrollCacheParams {
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
const scrollCache = new Map<string, ScrollSnapshot>()
|
||||
const DEFAULT_SCOPE = "session"
|
||||
|
||||
function resolve(value?: string) {
|
||||
return value && value.length > 0 ? value : "GLOBAL"
|
||||
}
|
||||
|
||||
function makeKey(params: ScrollCacheParams) {
|
||||
return `${resolve(params.instanceId)}:${resolve(params.sessionId)}:${params.scope ?? DEFAULT_SCOPE}`
|
||||
}
|
||||
|
||||
export function setScrollCache(params: ScrollCacheParams, snapshot: Omit<ScrollSnapshot, "updatedAt">) {
|
||||
scrollCache.set(makeKey(params), { ...snapshot, updatedAt: Date.now() })
|
||||
}
|
||||
|
||||
export function getScrollCache(params: ScrollCacheParams): ScrollSnapshot | undefined {
|
||||
return scrollCache.get(makeKey(params))
|
||||
}
|
||||
|
||||
export function clearScrollCacheScope(params: ScrollCacheParams) {
|
||||
const key = makeKey(params)
|
||||
scrollCache.delete(key)
|
||||
}
|
||||
|
||||
export function clearScrollCacheForSession(instanceId?: string, sessionId?: string) {
|
||||
const match = `${resolve(instanceId)}:${resolve(sessionId)}:`
|
||||
for (const key of scrollCache.keys()) {
|
||||
if (key.startsWith(match)) {
|
||||
scrollCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearScrollCacheForInstance(instanceId?: string) {
|
||||
const match = `${resolve(instanceId)}:`
|
||||
for (const key of scrollCache.keys()) {
|
||||
if (key.startsWith(match)) {
|
||||
scrollCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllScrollCache() {
|
||||
scrollCache.clear()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { RenderCache } from "../types/message"
|
||||
|
||||
const toolRenderCache = new Map<string, RenderCache>()
|
||||
|
||||
export function getToolRenderCache(key?: string | null): RenderCache | undefined {
|
||||
if (!key) return undefined
|
||||
return toolRenderCache.get(key)
|
||||
}
|
||||
|
||||
export function setToolRenderCache(key: string | undefined | null, cache?: RenderCache): void {
|
||||
if (!key) return
|
||||
if (cache) {
|
||||
toolRenderCache.set(key, cache)
|
||||
} else {
|
||||
toolRenderCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToolRenderCache(key?: string | null): void {
|
||||
if (!key) return
|
||||
toolRenderCache.delete(key)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearScrollCacheForInstance } from "../lib/scroll-cache"
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ function removeInstance(id: string) {
|
||||
}
|
||||
|
||||
// Clean up session indexes and drafts for removed instance
|
||||
clearScrollCacheForInstance(id)
|
||||
clearCacheForInstance(id)
|
||||
messageStoreBus.unregisterInstance(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createInstanceMessageStore } from "./instance-store"
|
||||
import type { InstanceMessageStore } from "./instance-store"
|
||||
import { clearCacheForInstance } from "../../lib/global-cache"
|
||||
|
||||
class MessageStoreBus {
|
||||
private stores = new Map<string, InstanceMessageStore>()
|
||||
private teardownHandlers = new Set<(instanceId: string) => void>()
|
||||
|
||||
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
|
||||
if (this.stores.has(instanceId)) {
|
||||
@@ -22,20 +24,41 @@ class MessageStoreBus {
|
||||
return this.registerInstance(instanceId)
|
||||
}
|
||||
|
||||
onInstanceDestroyed(handler: (instanceId: string) => void): () => void {
|
||||
this.teardownHandlers.add(handler)
|
||||
return () => {
|
||||
this.teardownHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
unregisterInstance(instanceId: string) {
|
||||
const store = this.stores.get(instanceId)
|
||||
if (store) {
|
||||
store.clearInstance()
|
||||
}
|
||||
clearCacheForInstance(instanceId)
|
||||
this.notifyInstanceDestroyed(instanceId)
|
||||
this.stores.delete(instanceId)
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
for (const [instanceId, store] of this.stores.entries()) {
|
||||
store.clearInstance()
|
||||
clearCacheForInstance(instanceId)
|
||||
this.notifyInstanceDestroyed(instanceId)
|
||||
this.stores.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyInstanceDestroyed(instanceId: string) {
|
||||
for (const handler of this.teardownHandlers) {
|
||||
try {
|
||||
handler(instanceId)
|
||||
} catch (error) {
|
||||
console.error("Failed to run message store teardown handler", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const messageStoreBus = new MessageStoreBus()
|
||||
|
||||
@@ -24,6 +24,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
||||
messages: {},
|
||||
messageInfoVersion: {},
|
||||
pendingParts: {},
|
||||
sessionRevisions: {},
|
||||
permissions: {
|
||||
queue: [],
|
||||
active: null,
|
||||
@@ -41,8 +42,52 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
|
||||
return `${messageId}-part-${index}`
|
||||
}
|
||||
|
||||
const PENDING_PART_MAX_AGE_MS = 30_000
|
||||
|
||||
function clonePart(part: ClientPart): ClientPart {
|
||||
return JSON.parse(JSON.stringify(part)) as ClientPart
|
||||
if (!part || typeof part !== "object") {
|
||||
return part
|
||||
}
|
||||
const cloned: Record<string, any> = { ...part }
|
||||
if ("renderCache" in cloned) {
|
||||
cloned.renderCache = undefined
|
||||
}
|
||||
if ("text" in cloned) {
|
||||
cloned.text = cloneStructuredValue(cloned.text)
|
||||
}
|
||||
if ("thinking" in cloned && typeof cloned.thinking === "object") {
|
||||
cloned.thinking = cloneStructuredValue(cloned.thinking)
|
||||
}
|
||||
if ("content" in cloned && Array.isArray(cloned.content)) {
|
||||
cloned.content = cloneStructuredValue(cloned.content)
|
||||
}
|
||||
return cloned as ClientPart
|
||||
}
|
||||
|
||||
function cloneStructuredValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneStructuredValue(item)) as T
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const next: Record<string, any> = {}
|
||||
Object.entries(value as Record<string, any>).forEach(([key, nested]) => {
|
||||
next[key] = cloneStructuredValue(nested)
|
||||
})
|
||||
return next as T
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function areMessageIdListsEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < a.length; index++) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function createEmptyUsageState(): SessionUsageState {
|
||||
@@ -158,6 +203,7 @@ export interface InstanceMessageStore {
|
||||
getSessionUsage: (sessionId: string) => SessionUsageState | undefined
|
||||
setScrollSnapshot: (sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) => void
|
||||
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
||||
getSessionRevision: (sessionId: string) => number
|
||||
getSessionMessageIds: (sessionId: string) => string[]
|
||||
getMessage: (messageId: string) => MessageRecord | undefined
|
||||
clearInstance: () => void
|
||||
@@ -167,6 +213,15 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
||||
const messageInfoCache = new Map<string, MessageInfo>()
|
||||
|
||||
function bumpSessionRevision(sessionId: string) {
|
||||
if (!sessionId) return
|
||||
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
|
||||
}
|
||||
|
||||
function getSessionRevisionValue(sessionId: string) {
|
||||
return state.sessionRevisions[sessionId] ?? 0
|
||||
}
|
||||
|
||||
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
||||
setState("usage", sessionId, (current) => {
|
||||
const draft = current
|
||||
@@ -223,6 +278,7 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
|
||||
function addOrUpdateSession(input: SessionUpsertInput) {
|
||||
const session = ensureSessionEntry(input.id)
|
||||
const previousIds = [...session.messageIds]
|
||||
const nextMessageIds = Array.isArray(input.messageIds) ? input.messageIds : session.messageIds
|
||||
|
||||
setState("sessions", input.id, {
|
||||
@@ -233,6 +289,10 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
messageIds: nextMessageIds,
|
||||
revert: input.revert ?? session.revert ?? null,
|
||||
})
|
||||
|
||||
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
|
||||
bumpSessionRevision(input.id)
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) {
|
||||
@@ -303,7 +363,7 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
|
||||
setState("messages", (prev) => ({ ...prev, ...nextMessages }))
|
||||
setState("messageInfoVersion", (prev) => ({ ...prev, ...nextMessageInfoVersion }))
|
||||
setState("pendingParts", (prev) => ({ ...prev, ...nextPendingParts }))
|
||||
setState("pendingParts", () => nextPendingParts)
|
||||
setState("permissions", "byMessage", (prev) => ({ ...prev, ...nextPermissionsByMessage }))
|
||||
|
||||
if (usageState) {
|
||||
@@ -315,6 +375,8 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
messageIds: incomingIds,
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
|
||||
bumpSessionRevision(sessionId)
|
||||
}
|
||||
|
||||
function insertMessageIntoSession(sessionId: string, messageId: string) {
|
||||
@@ -374,12 +436,24 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
|
||||
insertMessageIntoSession(input.sessionId, input.id)
|
||||
flushPendingParts(input.id)
|
||||
bumpSessionRevision(input.sessionId)
|
||||
}
|
||||
|
||||
function bufferPendingPart(entry: PendingPartEntry) {
|
||||
setState("pendingParts", entry.messageId, (list = []) => [...list, entry])
|
||||
}
|
||||
|
||||
function clearPendingPartsForMessage(messageId: string) {
|
||||
setState("pendingParts", (prev) => {
|
||||
if (!prev[messageId]) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function applyPartUpdate(input: PartUpdateInput) {
|
||||
const message = state.messages[input.messageId]
|
||||
if (!message) {
|
||||
@@ -417,12 +491,14 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
if (!pending || pending.length === 0) {
|
||||
return
|
||||
}
|
||||
pending.forEach((entry) => applyPartUpdate({ messageId, part: entry.part }))
|
||||
setState("pendingParts", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
const now = Date.now()
|
||||
const validEntries = pending.filter((entry) => now - entry.receivedAt <= PENDING_PART_MAX_AGE_MS)
|
||||
if (validEntries.length === 0) {
|
||||
clearPendingPartsForMessage(messageId)
|
||||
return
|
||||
}
|
||||
validEntries.forEach((entry) => applyPartUpdate({ messageId, part: entry.part }))
|
||||
clearPendingPartsForMessage(messageId)
|
||||
}
|
||||
|
||||
function replaceMessageId(options: ReplaceMessageIdOptions) {
|
||||
@@ -444,6 +520,8 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
return next
|
||||
})
|
||||
|
||||
const affectedSessions = new Set<string>()
|
||||
|
||||
Object.values(state.sessions).forEach((session) => {
|
||||
const index = session.messageIds.indexOf(options.oldId)
|
||||
if (index === -1) return
|
||||
@@ -452,8 +530,11 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
next[index] = options.newId
|
||||
return next
|
||||
})
|
||||
affectedSessions.add(session.id)
|
||||
})
|
||||
|
||||
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
|
||||
|
||||
const infoEntry = messageInfoCache.get(options.oldId)
|
||||
if (infoEntry) {
|
||||
messageInfoCache.set(options.newId, infoEntry)
|
||||
@@ -482,12 +563,8 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
const pending = state.pendingParts[options.oldId]
|
||||
if (pending) {
|
||||
setState("pendingParts", options.newId, pending)
|
||||
setState("pendingParts", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[options.oldId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
clearPendingPartsForMessage(options.oldId)
|
||||
}
|
||||
|
||||
function setMessageInfo(messageId: string, info: MessageInfo) {
|
||||
@@ -608,6 +685,7 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
getSessionUsage,
|
||||
setScrollSnapshot,
|
||||
getScrollSnapshot,
|
||||
getSessionRevision: getSessionRevisionValue,
|
||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||
getMessage: (messageId: string) => state.messages[messageId],
|
||||
clearInstance,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { decodeHtmlEntities } from "../../lib/markdown"
|
||||
import { partHasRenderableText } from "../../types/message"
|
||||
import type { MessageDisplayParts, Message } from "../../types/message"
|
||||
|
||||
function decodeTextSegment(segment: any): any {
|
||||
if (typeof segment === "string") {
|
||||
@@ -74,23 +72,3 @@ export function normalizeMessagePart(part: any): any {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts {
|
||||
const text: any[] = []
|
||||
const tool: any[] = []
|
||||
const reasoning: any[] = []
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
|
||||
text.push(part)
|
||||
} else if (part.type === "tool") {
|
||||
tool.push(part)
|
||||
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
|
||||
reasoning.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
|
||||
const version = typeof message.version === "number" ? message.version : 0
|
||||
|
||||
return { text, tool, reasoning, combined, showThinking, version }
|
||||
}
|
||||
|
||||
72
packages/ui/src/stores/message-v2/record-display-cache.ts
Normal file
72
packages/ui/src/stores/message-v2/record-display-cache.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import { partHasRenderableText } from "../../types/message"
|
||||
import type { MessageRecord } from "./types"
|
||||
|
||||
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
export interface RecordDisplayData {
|
||||
orderedParts: ClientPart[]
|
||||
textAndReasoningParts: ClientPart[]
|
||||
toolParts: ToolCallPart[]
|
||||
}
|
||||
|
||||
interface RecordDisplayCacheEntry {
|
||||
revision: number
|
||||
data: RecordDisplayData
|
||||
}
|
||||
|
||||
const recordDisplayCache = new Map<string, RecordDisplayCacheEntry>()
|
||||
|
||||
function makeCacheKey(instanceId: string, messageId: string, showThinking: boolean) {
|
||||
return `${instanceId}:${messageId}:${showThinking ? 1 : 0}`
|
||||
}
|
||||
|
||||
function isToolPart(part: ClientPart): part is ToolCallPart {
|
||||
return part.type === "tool"
|
||||
}
|
||||
|
||||
export function buildRecordDisplayData(instanceId: string, record: MessageRecord, showThinking: boolean): RecordDisplayData {
|
||||
const cacheKey = makeCacheKey(instanceId, record.id, showThinking)
|
||||
const cached = recordDisplayCache.get(cacheKey)
|
||||
if (cached && cached.revision === record.revision) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
const orderedParts: ClientPart[] = []
|
||||
const textAndReasoningParts: ClientPart[] = []
|
||||
const toolParts: ToolCallPart[] = []
|
||||
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts[partId]
|
||||
if (!entry?.data) continue
|
||||
const part = entry.data
|
||||
orderedParts.push(part)
|
||||
|
||||
if (isToolPart(part)) {
|
||||
toolParts.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
|
||||
textAndReasoningParts.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
|
||||
textAndReasoningParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const data = { orderedParts, textAndReasoningParts, toolParts }
|
||||
recordDisplayCache.set(cacheKey, { revision: record.revision, data })
|
||||
return data
|
||||
}
|
||||
|
||||
export function clearRecordDisplayCacheForInstance(instanceId: string) {
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of recordDisplayCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
recordDisplayCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,7 @@ export interface InstanceMessageState {
|
||||
messages: Record<string, MessageRecord>
|
||||
messageInfoVersion: Record<string, number>
|
||||
pendingParts: Record<string, PendingPartEntry[]>
|
||||
|
||||
|
||||
sessionRevisions: Record<string, number>
|
||||
permissions: InstancePermissionState
|
||||
usage: Record<string, SessionUsageState>
|
||||
scrollState: Record<string, ScrollSnapshot>
|
||||
|
||||
@@ -70,6 +70,40 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-stream-virtual-padding {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-stream-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message-stream-load-older {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.message-stream-load-older-button {
|
||||
@apply inline-flex items-center justify-center rounded-md border text-sm font-medium px-3 py-1.5 transition-colors;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-stream-load-older-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-stream-load-older-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
|
||||
@@ -41,15 +41,6 @@ export type ClientPart = SDKPart & {
|
||||
pendingPermission?: PendingPermissionState
|
||||
}
|
||||
|
||||
export interface MessageDisplayParts {
|
||||
text: ClientPart[]
|
||||
tool: ClientPart[]
|
||||
reasoning: ClientPart[]
|
||||
combined: ClientPart[]
|
||||
showThinking: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
sessionId: string
|
||||
@@ -58,7 +49,6 @@ export interface Message {
|
||||
timestamp: number
|
||||
status: "sending" | "sent" | "streaming" | "complete" | "error"
|
||||
version: number
|
||||
displayParts?: MessageDisplayParts
|
||||
}
|
||||
|
||||
export interface TextPart {
|
||||
|
||||
Reference in New Issue
Block a user