diff --git a/.opencode/agent/web_developer.md b/.opencode/agent/web_developer.md index ec903f36..97e7f809 100644 --- a/.opencode/agent/web_developer.md +++ b/.opencode/agent/web_developer.md @@ -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. diff --git a/packages/ui/src/components/diff-viewer.tsx b/packages/ui/src/components/diff-viewer.tsx index d986b3f4..ab871fef 100644 --- a/packages/ui/src/components/diff-viewer.tsx +++ b/packages/ui/src/components/diff-viewer.tsx @@ -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(() => { 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 ( diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index ae2c42c0..648ddc5f 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -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) { + + + + + {(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 ( -
-
-
- {TOOL_ICON} - Tool Call - {item.toolPart.tool || "unknown"} -
-
- +
{ + blockRef = element || undefined + if (element) { + scheduleMeasurement() + } + }} + > + + {(message) => ( + + )} + + + + {(item) => ( +
+
+
+ {TOOL_ICON} + Tool Call + {item.toolPart.tool || "unknown"} +
+
+ +
+ )} +
) }} diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 5a8a01c0..be968ba1 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -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 @@ -34,46 +35,19 @@ function isToolStateError(state: ToolState): state is ToolStateError { } -const toolScrollState = new Map() +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() + 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)} >
@@ -806,7 +808,7 @@ export default function ToolCall(props: ToolCallProps) { theme={themeKey} mode={diffMode()} cachedHtml={cachedHtml} - cacheKey={cacheKey} + cacheEntryParams={cacheHandle.params()} onRendered={handleDiffRendered} />
@@ -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() + 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) {
initializeScrollContainer(element)} - onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} + onScroll={(event) => persistScrollSnapshot(event.currentTarget)} > initializeScrollContainer(element)} - onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} + onScroll={(event) => persistScrollSnapshot(event.currentTarget)} >
@@ -1131,7 +1128,7 @@ export default function ToolCall(props: ToolCallProps) { {(payload) => (
{renderDiffTool(payload(), { - cacheKeySuffix: "::permission", + variant: "permission-diff", disableScrollTracking: true, label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff", })} diff --git a/packages/ui/src/lib/global-cache.ts b/packages/ui/src/lib/global-cache.ts new file mode 100644 index 00000000..04f48b21 --- /dev/null +++ b/packages/ui/src/lib/global-cache.ts @@ -0,0 +1,126 @@ +export interface CacheEntryBaseParams { + instanceId?: string + sessionId?: string + scope: string +} + +export interface CacheEntryParams extends CacheEntryBaseParams { + key: string +} + +type CacheValueMap = Map +type CacheScopeMap = Map +type CacheSessionMap = Map + +const GLOBAL_KEY = "GLOBAL" +const cacheStore = new Map() + +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(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(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) +} + diff --git a/packages/ui/src/lib/hooks/use-global-cache.ts b/packages/ui/src/lib/hooks/use-global-cache.ts new file mode 100644 index 00000000..e5c81056 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-global-cache.ts @@ -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(() => { + 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() { + return getCacheEntry(resolvedEntry()) + }, + set(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(value: MaybeAccessor | undefined): T { + if (typeof value === "function") { + return (value as Accessor)() + } + return value as T +} + +type MaybeAccessor = T | Accessor + +interface UseGlobalCacheParams { + instanceId?: MaybeAccessor + sessionId?: MaybeAccessor + scope: MaybeAccessor + key: MaybeAccessor +} + +interface GlobalCacheHandle { + get(): T | undefined + set(value: T | undefined): void + clearScope(): void + clearSession(): void + clearInstance(): void + params(): CacheEntryParams +} diff --git a/packages/ui/src/lib/hooks/use-scroll-cache.ts b/packages/ui/src/lib/hooks/use-scroll-cache.ts new file mode 100644 index 00000000..1bbe956c --- /dev/null +++ b/packages/ui/src/lib/hooks/use-scroll-cache.ts @@ -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 + sessionId: MaybeAccessor + scope: MaybeAccessor +} + +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 = { + 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(value: MaybeAccessor): T { + if (typeof value === "function") { + return (value as Accessor)() + } + return value +} + +type MaybeAccessor = T | Accessor diff --git a/packages/ui/src/lib/scroll-cache.ts b/packages/ui/src/lib/scroll-cache.ts deleted file mode 100644 index ff3f29c7..00000000 --- a/packages/ui/src/lib/scroll-cache.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ScrollSnapshot } from "../stores/message-v2/types" - -interface ScrollCacheParams { - instanceId?: string - sessionId?: string - scope?: string -} - -const scrollCache = new Map() -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) { - 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() -} diff --git a/packages/ui/src/lib/tool-render-cache.ts b/packages/ui/src/lib/tool-render-cache.ts deleted file mode 100644 index a98e5270..00000000 --- a/packages/ui/src/lib/tool-render-cache.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RenderCache } from "../types/message" - -const toolRenderCache = new Map() - -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) -} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 911453ee..a7f20064 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -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) } diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index ff027818..0ccd98eb 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -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() + 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() diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 459e02ec..163d711b 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -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 = { ...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(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneStructuredValue(item)) as T + } + if (value && typeof value === "object") { + const next: Record = {} + Object.entries(value as Record).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) => 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(createInitialState(instanceId)) const messageInfoCache = new Map() + 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) { @@ -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() + 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, diff --git a/packages/ui/src/stores/message-v2/normalizers.ts b/packages/ui/src/stores/message-v2/normalizers.ts index 109cef8f..c88296d6 100644 --- a/packages/ui/src/stores/message-v2/normalizers.ts +++ b/packages/ui/src/stores/message-v2/normalizers.ts @@ -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 } -} diff --git a/packages/ui/src/stores/message-v2/record-display-cache.ts b/packages/ui/src/stores/message-v2/record-display-cache.ts new file mode 100644 index 00000000..d568e0cd --- /dev/null +++ b/packages/ui/src/stores/message-v2/record-display-cache.ts @@ -0,0 +1,72 @@ +import type { ClientPart } from "../../types/message" +import { partHasRenderableText } from "../../types/message" +import type { MessageRecord } from "./types" + +export type ToolCallPart = Extract + +export interface RecordDisplayData { + orderedParts: ClientPart[] + textAndReasoningParts: ClientPart[] + toolParts: ToolCallPart[] +} + +interface RecordDisplayCacheEntry { + revision: number + data: RecordDisplayData +} + +const recordDisplayCache = new Map() + +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) + } + } +} diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 1b06f4ed..e46b66b8 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -95,8 +95,7 @@ export interface InstanceMessageState { messages: Record messageInfoVersion: Record pendingParts: Record - - + sessionRevisions: Record permissions: InstancePermissionState usage: Record scrollState: Record diff --git a/packages/ui/src/styles/messaging/message-stream.css b/packages/ui/src/styles/messaging/message-stream.css index 74a9503b..62c3b548 100644 --- a/packages/ui/src/styles/messaging/message-stream.css +++ b/packages/ui/src/styles/messaging/message-stream.css @@ -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; diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index 31c66201..6c899c49 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -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 {