diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1d4ab8dbc..bf4e58796 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -103,21 +103,25 @@ jobs: - name: Test changed runnable UI behavior run: >- - node --import tsx --test + node --import tsx --test --test-force-exit packages/ui/src/components/message-timeline-v2.test.ts packages/ui/src/components/provider-auth/provider-options.test.ts packages/ui/src/components/session/session-bottom-pin-intent.test.ts packages/ui/src/components/session-list-visibility.test.ts + packages/ui/src/components/tool-call/render-memory.test.ts packages/ui/src/components/unified-picker-path.test.ts packages/ui/src/components/virtual-follow-behavior.test.ts packages/ui/src/lib/filesystem-events.test.ts + packages/ui/src/lib/global-cache.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts + packages/ui/src/lib/retained-size.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/session-transcript-lru.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts @@ -129,6 +133,7 @@ jobs: packages/ui/src/stores/client-state.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts + packages/ui/src/stores/message-v2/message-window.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/shell-store.test.ts @@ -144,6 +149,7 @@ jobs: node --conditions=browser --import tsx --test --test-force-exit packages/ui/src/components/form-request-tool-target.test.ts packages/ui/src/components/form-request.test.ts + packages/ui/src/components/tool-call/renderer-copy.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 79e29ecdf..24f8a9df7 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -589,6 +589,7 @@ const InstanceShell2: Component = (props) => { instanceId: () => props.instance.id, instanceSessions: allInstanceSessions, activeSessionId: activeSessionIdForInstance, + isActiveInstance: () => Boolean(props.isActiveInstance), }) const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts index 35ffe5c78..2c9cfd4fe 100644 --- a/packages/ui/src/components/instance/shell/useSessionCache.ts +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -1,17 +1,15 @@ -import { createEffect, createSignal, type Accessor } from "solid-js" -import { messageStoreBus } from "../../../stores/message-v2/bus" -import { clearSessionRenderCache } from "../../message-block" -import { getLogger } from "../../../lib/logger" -import { invalidateSessionMessageLoad } from "../../../stores/session-state" - -const log = getLogger("session") - -const SESSION_CACHE_LIMIT = 5 +import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import { + reconcileSessionTranscriptBudget, + setSessionTranscriptVisible, + touchSessionTranscript, +} from "../../../stores/session-transcript-memory" type SessionCacheOptions = { instanceId: Accessor instanceSessions: Accessor> activeSessionId: Accessor + isActiveInstance: Accessor } type SessionCacheState = { @@ -19,80 +17,25 @@ type SessionCacheState = { } export function useSessionCache(options: SessionCacheOptions): SessionCacheState { - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - const [pendingEvictions, setPendingEvictions] = createSignal([]) - - const evictSession = (sessionId: string) => { - if (!sessionId) return - const instanceId = options.instanceId() - log.info("Evicting cached session", { instanceId, sessionId }) - const store = messageStoreBus.getInstance(instanceId) - invalidateSessionMessageLoad(instanceId, sessionId) - store?.clearSession(sessionId, { preserveScroll: true, notify: false }) - clearSessionRenderCache(instanceId, sessionId) - } - - const scheduleEvictions = (ids: string[]) => { - if (!ids.length) return - setPendingEvictions((current) => { - const existing = new Set(current) - const next = [...current] - ids.forEach((id) => { - if (!existing.has(id)) { - next.push(id) - existing.add(id) - } - }) - return next - }) - } - - createEffect(() => { - const pending = pendingEvictions() - if (!pending.length) return - const cached = new Set(cachedSessionIds()) - const remaining: string[] = [] - pending.forEach((id) => { - if (cached.has(id)) { - remaining.push(id) - } else { - evictSession(id) - } - }) - if (remaining.length !== pending.length) { - setPendingEvictions(remaining) - } - }) - - createEffect(() => { + const cachedSessionIds = createMemo(() => { const instanceSessions = options.instanceSessions() const activeId = options.activeSessionId() + if (!options.isActiveInstance() || !activeId || activeId === "info" || !instanceSessions.has(activeId)) return [] + return [activeId] + }) - setCachedSessionIds((current) => { - const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) - - const touch = (id: string | null) => { - if (!id || id === "info") return - if (!instanceSessions.has(id)) return - - const index = next.indexOf(id) - if (index !== -1) { - next.splice(index, 1) - } - next.unshift(id) - } - - touch(activeId) - - const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next + createEffect(() => { + const instanceId = options.instanceId() + const [sessionId] = cachedSessionIds() + if (!sessionId) return + setSessionTranscriptVisible(instanceId, sessionId, true) + touchSessionTranscript(instanceId, sessionId) + reconcileSessionTranscriptBudget() + onCleanup(() => setSessionTranscriptVisible(instanceId, sessionId, false)) + }) - const trimmedSet = new Set(trimmed) - const removed = current.filter((id) => !trimmedSet.has(id)) - if (removed.length) { - scheduleEvictions(removed) - } - return trimmed - }) + onCleanup(() => { + reconcileSessionTranscriptBudget() }) return { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 91d40793d..ca2af1514 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,9 +1,10 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { useGlobalCache } from "../lib/hooks/use-global-cache" import type { TextPart, RenderCache } from "../types/message" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" +import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils" const log = getLogger("session") @@ -89,6 +90,10 @@ function renderFallbackHtml(content: string): string { return escapeHtml(content).replace(/\n/g, "
") } +export function getMarkdownTextForRender(content: string): string { + return limitToolOutputForRender(content) +} + interface MarkdownProps { part: TextPart instanceId?: string @@ -158,7 +163,7 @@ export function Markdown(props: MarkdownProps) { const resolved = createMemo(() => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" - const text = decodeHtmlEntitiesLocally(rawText) + const text = decodeHtmlEntitiesLocally(getMarkdownTextForRender(rawText)) const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight const escapeRawHtml = Boolean(props.escapeRawHtml) @@ -346,15 +351,31 @@ export function Markdown(props: MarkdownProps) { }) return ( -
+ <> +
+ TOOL_OUTPUT_RENDER_CHARACTER_LIMIT}> + + + ) } diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 96de64eaa..357a58361 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -20,11 +20,20 @@ import { copyToClipboard } from "../lib/clipboard" import SpeechActionButton from "./speech-action-button" import type { VisibilityPreference } from "../stores/preferences" import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } from "../types/tool-state" +import { + clearInstanceMessageRenderCaches, + clearSessionMessageRenderCache, + getSessionMessageRenderCache, + peekSessionMessageRenderCache, + purgeMessageRenderCache, + extractReasoningTextForCopy, + extractReasoningTitleForRender, +} from "../lib/message-render-cache" +import { accountSessionTranscript } from "../stores/session-transcript-memory" const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const NO_STEP_BORDER = "none" - const LazyToolCall = lazy(() => import("./tool-call")) function ToolCallFallback() { @@ -53,38 +62,6 @@ function extractTaskSessionId(state: ToolState | undefined): string { return typeof directId === "string" ? directId : "" } -function reasoningHasRenderableContent(part: ClientPart): boolean { - if (!part || part.type !== "reasoning") { - return false - } - const checkSegment = (segment: unknown): boolean => { - if (typeof segment === "string") { - return segment.trim().length > 0 - } - if (segment && typeof segment === "object") { - const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] } - if (typeof candidate.text === "string" && candidate.text.trim().length > 0) { - return true - } - if (typeof candidate.value === "string" && candidate.value.trim().length > 0) { - return true - } - if (Array.isArray(candidate.content)) { - return candidate.content.some((entry) => checkSegment(entry)) - } - } - return false - } - - if (checkSegment((part as any).text)) { - return true - } - if (Array.isArray((part as any).content)) { - return (part as any).content.some((entry: unknown) => checkSegment(entry)) - } - return false -} - interface TaskSessionLocation { sessionId: string instanceId: string @@ -132,47 +109,27 @@ interface CachedBlockEntry { toolKeys: string[] } -interface SessionRenderCache { - messageItems: Map - toolItems: Map - messageBlocks: Map -} - -const renderCaches = new Map() - -function makeSessionCacheKey(instanceId: string, sessionId: string) { - return `${instanceId}:${sessionId}` -} - export function clearSessionRenderCache(instanceId: string, sessionId: string) { - renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) + clearSessionMessageRenderCache(instanceId, sessionId) } -function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache { - const key = makeSessionCacheKey(instanceId, sessionId) - let cache = renderCaches.get(key) - if (!cache) { - cache = { - messageItems: new Map(), - toolItems: new Map(), - messageBlocks: new Map(), - } - renderCaches.set(key, cache) +function clearMessageRenderCache(instanceId: string, sessionId: string, messageIds: readonly string[]) { + const cache = peekSessionMessageRenderCache(instanceId, sessionId) + if (!cache) return + purgeMessageRenderCache(cache, messageIds) + if (cache.messageBlocks.size === 0 && cache.messageItems.size === 0 && cache.toolItems.size === 0) { + clearSessionMessageRenderCache(instanceId, sessionId) } - return cache } function clearInstanceCaches(instanceId: string) { clearRecordDisplayCacheForInstance(instanceId) - const prefix = `${instanceId}:` - for (const key of renderCaches.keys()) { - if (key.startsWith(prefix)) { - renderCaches.delete(key) - } - } + clearInstanceMessageRenderCaches(instanceId) } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) +messageStoreBus.onSessionCleared(clearSessionRenderCache) +messageStoreBus.onMessagesRemoved(clearMessageRenderCache) function removeSearchMarks(root: HTMLElement) { const marks = Array.from(root.querySelectorAll("mark.session-search-match")) @@ -256,6 +213,7 @@ interface ContentDisplayItem { key: string messageId: string startPartId: string + partCount: number } interface ToolDisplayItem { @@ -271,6 +229,7 @@ interface MessageContentItemProps { store: () => InstanceMessageStore messageId: string startPartId: string + partCount: number messageIndex: number lastAssistantIndex: () => number onRevert?: (messageId: string) => void @@ -314,7 +273,7 @@ function MessageContentItem(props: MessageContentItemProps) { if (startIndex === -1) return [] const resolved: ClientPart[] = [] - for (let idx = startIndex; idx < ids.length; idx++) { + for (let idx = startIndex; idx < ids.length && resolved.length < props.partCount; idx++) { const partId = ids[idx] const part = current.parts[partId]?.data if (!part) continue @@ -477,7 +436,7 @@ interface StepDisplayItem { type ReasoningDisplayItem = { type: "reasoning" key: string - part: ClientPart + text: string messageInfo?: MessageInfo durationMs?: number showAgentMeta?: boolean @@ -499,8 +458,9 @@ type CompactionDisplayItem = { type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem interface MessageDisplayBlock { - record: MessageRecord + messageId: string items: MessageBlockItem[] + truncated: boolean } interface MessageBlockProps { @@ -526,12 +486,22 @@ export default function MessageBlock(props: MessageBlockProps) { const { t } = useI18n() const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) - const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) + const sessionCache = getSessionMessageRenderCache(props.instanceId, props.sessionId) as { + messageItems: Map + toolItems: Map + messageBlocks: Map + } let blockRef: HTMLDivElement | undefined const isSearchResult = () => Boolean(props.searchResultMessageIds?.().has(props.messageId)) const activeSearchMatch = () => props.activeSearchMatch?.() ?? null const isActiveSearchResult = () => activeSearchMatch()?.messageId === props.messageId let lastInlineScrolledSearchMatchId: string | null = null + const handleContentRendered = () => { + props.onContentRendered?.() + accountSessionTranscript(props.instanceId, props.sessionId) + } + + onCleanup(() => accountSessionTranscript(props.instanceId, props.sessionId)) createEffect(() => { const query = props.searchQuery?.() ?? "" @@ -581,7 +551,8 @@ export default function MessageBlock(props: MessageBlockProps) { // Only capture info after cache check fails - ensures fresh data on version bump const info = untrack(messageInfo) - const { orderedParts } = buildRecordDisplayData(props.instanceId, current) + const displayData = buildRecordDisplayData(props.instanceId, current) + const { orderedParts } = displayData const items: MessageBlockItem[] = [] const blockContentKeys: string[] = [] const blockToolKeys: string[] = [] @@ -610,8 +581,11 @@ export default function MessageBlock(props: MessageBlockProps) { key: segmentKey, messageId: current.id, startPartId, + partCount: pendingParts.length, } sessionCache.messageItems.set(segmentKey, cached) + } else { + cached.partCount = pendingParts.length } items.push(cached) @@ -689,7 +663,8 @@ export default function MessageBlock(props: MessageBlockProps) { if (part.type === "reasoning") { flushContent() - if (props.showThinking() && reasoningHasRenderableContent(part)) { + const text = typeof (part as any).text === "string" ? (part as any).text : "" + if (props.showThinking() && text.trim().length > 0) { const partId = part.id ?? "" const key = `${current.id}:${partId || partIndex}:reasoning` const showAgentMeta = current.role === "assistant" && !agentMetaAttached @@ -699,7 +674,7 @@ export default function MessageBlock(props: MessageBlockProps) { items.push({ type: "reasoning", key, - part, + text, messageInfo: info, durationMs: inferReasoningDurationMs(orderedParts, part, info, current.status), showAgentMeta, @@ -717,13 +692,14 @@ export default function MessageBlock(props: MessageBlockProps) { flushContent() - const resultBlock: MessageDisplayBlock = { record: current, items } + const resultBlock: MessageDisplayBlock = { messageId: current.id, items, truncated: displayData.truncated } sessionCache.messageBlocks.set(current.id, { signature: cacheSignature, block: resultBlock, contentKeys: blockContentKeys.slice(), toolKeys: blockToolKeys.slice(), }) + accountSessionTranscript(props.instanceId, props.sessionId) const messagePrefix = `${current.id}:` for (const [key] of sessionCache.messageItems) { @@ -753,13 +729,13 @@ export default function MessageBlock(props: MessageBlockProps) { return ( {(resolvedBlock) => ( - +
{ blockRef = el }} class="message-stream-block" - data-message-id={resolvedBlock().record.id} + data-message-id={resolvedBlock().messageId} data-search-result={isSearchResult() ? "true" : undefined} data-search-active={isActiveSearchResult() ? "true" : undefined} > @@ -772,12 +748,13 @@ export default function MessageBlock(props: MessageBlockProps) { sessionId={props.sessionId} store={props.store} messageId={(item() as ContentDisplayItem).messageId} - startPartId={(item() as ContentDisplayItem).startPartId} + startPartId={(item() as ContentDisplayItem).startPartId} + partCount={(item() as ContentDisplayItem).partCount} messageIndex={props.messageIndex} lastAssistantIndex={props.lastAssistantIndex} onRevert={props.onRevert} onFork={props.onFork} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -792,7 +769,7 @@ export default function MessageBlock(props: MessageBlockProps) { store={props.store} messageId={toolItem.messageId} partId={toolItem.partId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -820,7 +797,7 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -835,7 +812,11 @@ export default function MessageBlock(props: MessageBlockProps) { extractReasoningTextForCopy( + props.store().getMessage((item() as ReasoningDisplayItem).messageId)?.parts[(item() as ReasoningDisplayItem).partId]?.data, + )} messageInfo={(item() as ReasoningDisplayItem).messageInfo} durationMs={(item() as ReasoningDisplayItem).durationMs} instanceId={props.instanceId} @@ -843,13 +824,30 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={(item() as ReasoningDisplayItem).messageId} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId} /> )} + +
+ {t("toolCall.output.truncated")} + +
+
)} @@ -857,6 +855,13 @@ export default function MessageBlock(props: MessageBlockProps) { ) } +function orderedMessageParts(record: MessageRecord): ClientPart[] { + return record.partIds.flatMap((partId) => { + const part = record.parts[partId]?.data + return part ? [part] : [] + }) +} + interface StepCardProps { kind: "start" | "finish" part: ClientPart @@ -1061,7 +1066,9 @@ function formatCostValue(value: number) { } interface ReasoningCardProps { - part: ClientPart + text: string + partId: string + copyText: () => string messageInfo?: MessageInfo durationMs?: number instanceId: string @@ -1169,51 +1176,9 @@ function ReasoningCard(props: ReasoningCardProps) { return modelID } - const reasoningText = () => { - const part = props.part as any - if (!part) return "" - - const stringifySegment = (segment: unknown): string => { - if (typeof segment === "string") { - return segment - } - if (segment && typeof segment === "object") { - const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] } - const pieces: string[] = [] - if (typeof obj.text === "string") { - pieces.push(obj.text) - } - if (typeof obj.value === "string") { - pieces.push(obj.value) - } - if (Array.isArray(obj.content)) { - pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n")) - } - return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n") - } - return "" - } - - const textValue = stringifySegment(part.text) - if (textValue.trim().length > 0) { - return textValue - } - if (Array.isArray(part.content)) { - return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n") - } - return "" - } - - const extractedTitle = () => { - const firstLine = reasoningText() - .split(/\r?\n/) - .map((line: string) => line.trim()) - .find((line: string) => line.length > 0) - if (!firstLine) return "" + const reasoningText = () => props.text - const match = firstLine.match(/^\*\*([^*]+)\*\*/) - return match?.[1]?.trim() ?? "" - } + const extractedTitle = () => extractReasoningTitleForRender(reasoningText()) const thoughtDurationTitle = () => { const duration = props.durationMs @@ -1255,14 +1220,14 @@ function ReasoningCard(props: ReasoningCardProps) { const toggle = () => setExpanded((prev) => !prev) const speech = useSpeech({ - id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`, + id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${props.partId || "reasoning"}`, text: reasoningText, }) const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() const handleCopyReasoning = async () => { - const text = reasoningText() + const text = props.copyText() if (!text.trim()) return await copyToClipboard(text) } @@ -1294,7 +1259,7 @@ function ReasoningCard(props: ReasoningCardProps) { return (
- - +
) } @@ -402,6 +406,7 @@ function ToolCallDetails(props: { error={permissionError} renderDiff={renderDiffContent} fallbackSessionId={() => props.sessionId} + onApprovalBlockedChange={setPermissionApprovalBlocked} onRespond={(permission, sessionId, response, message) => void handlePermissionResponse(permission, response, message)} /> ) @@ -418,6 +423,10 @@ function ToolCallDetails(props: { await copyToClipboard(text) } + const copyToolInput = async (event: MouseEvent) => { + await copyIoText(event, formatToolInputForCopy(props.toolInput())?.text) + } + const outputWrapTitle = () => props.outputWrapEnabled() ? props.t("toolCall.diff.disableWordWrap") @@ -431,6 +440,7 @@ function ToolCallDetails(props: { copyText?: () => string | null | undefined copyTitle?: () => string copyAriaLabel?: () => string + onCopy?: (event: MouseEvent) => void actions?: () => JSXElement wrapToggle?: () => boolean | undefined }) => ( @@ -447,18 +457,16 @@ function ToolCallDetails(props: { {(actions) => {actions()}} - - {(copyText) => ( - - )} + + @@ -539,7 +547,7 @@ function ToolCallDetails(props: { language: () => toolInputDisplay()?.language, expanded: props.inputSectionExpanded, onToggle: props.toggleInputSection, - copyText: () => toolInputDisplay()?.copyText, + onCopy: copyToolInput, copyTitle: () => props.t("toolCall.io.copyInputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyInputAriaLabel"), }) @@ -564,6 +572,7 @@ function ToolCallDetails(props: { expanded: props.outputSectionExpanded, onToggle: props.toggleOutputSection, copyText: () => outputChrome().copyText, + onCopy: canCopyOutput() ? (event) => void copyIoText(event, resolveOutputCopyText()) : undefined, copyTitle: () => props.t("toolCall.io.copyOutputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyOutputAriaLabel"), actions: () => outputChrome().actions, @@ -697,7 +706,9 @@ export default function ToolCall(props: ToolCallProps) { const hasToolInput = createMemo(() => { const input = toolInput() - return input && Object.keys(input).length > 0 + if (!input) return false + for (const key in input) if (Object.prototype.hasOwnProperty.call(input, key)) return true + return false }) const [toolCallRootEl, setToolCallRootEl] = createSignal() @@ -710,11 +721,7 @@ export default function ToolCall(props: ToolCallProps) { if (override !== undefined) return override return diagnosticsDefaultExpanded() } - const diagnosticsEntries = createMemo(() => { - const state = toolState() - if (!state) return [] - return extractDiagnostics(state) - }) + const diagnosticsView = createMemo(() => extractDiagnosticsView(toolState())) const toggleInputSection = () => { setInputSectionOverride((prev) => { @@ -824,6 +831,7 @@ export default function ToolCall(props: ToolCallProps) { } const toolTypeLabel = createMemo(() => toolName()) + const renderedToolTypeLabel = createMemo(() => limitToolTitleForRender(toolTypeLabel())) const headerTitleDetail = createMemo(() => { const rawTitle = renderToolTitle().trim() @@ -845,9 +853,10 @@ export default function ToolCall(props: ToolCallProps) { const detail = headerTitleDetail() return [typeLabel, detail].filter(Boolean).join(" ") }) + const renderedHeaderTitleDetail = createMemo(() => limitToolTitleForRender(headerTitleDetail())) - const headerCopyText = createMemo(() => headerOutputChrome().copyText || "") - const canCopyHeaderOutput = () => headerCopyText().length > 0 + const headerCopyText = () => headerOutputChrome().copyText || headerOutputChrome().getCopyText?.() || "" + const canCopyHeaderOutput = () => headerOutputChrome().hasCopyText ?? Boolean(headerOutputChrome().copyText || headerOutputChrome().getCopyText) const canToggleOutputWrap = () => Boolean(headerOutputChrome().wrapToggle) const outputWrapTitle = () => outputWrapEnabled() @@ -960,8 +969,8 @@ export default function ToolCall(props: ToolCallProps) { > - {toolTypeLabel()} - + {renderedToolTypeLabel()} + {(detail) => {detail()}} @@ -976,7 +985,7 @@ export default function ToolCall(props: ToolCallProps) { aria-label={t("toolCall.header.copyOutputAriaLabel")} title={t("toolCall.header.copyOutputTitle")} > - + @@ -1059,17 +1068,17 @@ export default function ToolCall(props: ToolCallProps) { /> - + 0 || diagnosticsView().truncated) && diagnosticsVisibility() !== "hidden"}> {renderDiagnosticsSection( t, - diagnosticsEntries(), + diagnosticsView(), diagnosticsExpanded(), () => setDiagnosticsOverride((prev) => { const current = prev === undefined ? diagnosticsDefaultExpanded() : prev return !current }), - diagnosticFileName(diagnosticsEntries()), + diagnosticFileName(diagnosticsView().entries), )} diff --git a/packages/ui/src/components/tool-call/ansi-render.tsx b/packages/ui/src/components/tool-call/ansi-render.tsx index d7eb2e7de..b378920d0 100644 --- a/packages/ui/src/components/tool-call/ansi-render.tsx +++ b/packages/ui/src/components/tool-call/ansi-render.tsx @@ -3,6 +3,7 @@ import type { RenderCache } from "../../types/message" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { escapeHtml } from "../../lib/text-render-utils" import type { AnsiRenderOptions, ToolScrollHelpers } from "./types" +import { limitToolOutputForRender } from "./utils" type AnsiRenderCache = RenderCache & { hasAnsi: boolean } @@ -129,6 +130,7 @@ export function createAnsiContentRenderer(params: { return null } + const content = limitToolOutputForRender(options.content) const size = options.size || "default" const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` const cacheHandle = options.variant === "running" ? params.ansiRunningCache : params.ansiFinalCache @@ -143,7 +145,6 @@ export function createAnsiContentRenderer(params: { let nextCache: AnsiRenderCache if (isRunningVariant) { - const content = options.content const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource if (resetStreaming) { @@ -182,12 +183,12 @@ export function createAnsiContentRenderer(params: { runningAnsiSource = nextCache.text cacheHandle.set(nextCache) } else { - if (cached && cached.text === options.content) { + if (cached && cached.text === content) { nextCache = { ...cached, mode } } else { - const detectedAnsi = hasAnsi(options.content) - const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content) - nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi } + const detectedAnsi = hasAnsi(content) + const html = detectedAnsi ? ansiToHtml(content) : escapeHtml(content) + nextCache = { text: content, html, mode, hasAnsi: detectedAnsi } cacheHandle.set(nextCache) } } diff --git a/packages/ui/src/components/tool-call/diagnostic-selection.ts b/packages/ui/src/components/tool-call/diagnostic-selection.ts new file mode 100644 index 000000000..304563099 --- /dev/null +++ b/packages/ui/src/components/tool-call/diagnostic-selection.ts @@ -0,0 +1,12 @@ +export function selectSeverityBounded(values: readonly T[], rank: (value: T) => number | undefined, limit: number): T[] { + const buckets: T[][] = [[], [], []] + const scanLimit = Math.max(limit, limit * 100) + for (let index = 0; index < values.length && index < scanLimit; index += 1) { + const value = values[index] + const valueRank = rank(value) + if (valueRank === undefined) continue + const bucket = buckets[Math.max(0, Math.min(2, valueRank))]! + if (bucket.length < limit) bucket.push(value) + } + return buckets.flat().slice(0, limit) +} diff --git a/packages/ui/src/components/tool-call/diagnostics-section.tsx b/packages/ui/src/components/tool-call/diagnostics-section.tsx index b4b9057f7..71af68a49 100644 --- a/packages/ui/src/components/tool-call/diagnostics-section.tsx +++ b/packages/ui/src/components/tool-call/diagnostics-section.tsx @@ -1,14 +1,30 @@ import { For, Show } from "solid-js" -import type { DiagnosticEntry } from "./diagnostics" +import { Copy } from "lucide-solid" +import { hasDiagnosticMessages, type DiagnosticsMap, type DiagnosticsView } from "./diagnostics" +import { copyToClipboard } from "../../lib/clipboard" +import { formatUnknownForCopy } from "./utils" + +export function DiagnosticsPayloadAccess(props: { diagnostics: DiagnosticsMap; truncated: boolean; t: (key: string, params?: Record) => string }) { + return ( +
+ + {props.t(props.truncated ? "toolCall.output.truncated" : "toolCall.diagnostics.title")} + + +
+ ) +} export function renderDiagnosticsSection( t: (key: string, params?: Record) => string, - entries: DiagnosticEntry[], + view: DiagnosticsView, expanded: boolean, toggle: () => void, fileLabel: string, ) { - if (entries.length === 0) return null + if (!hasDiagnosticMessages(view.diagnostics)) return null return (
+
- + {(entry) => (
diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts index f7f50dcab..5bd739cad 100644 --- a/packages/ui/src/components/tool-call/diagnostics.ts +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -1,6 +1,7 @@ import type { ToolState } from "../../types/tool-state" import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" import { tGlobal } from "../../lib/i18n" +import { selectSeverityBounded } from "./diagnostic-selection" interface LspRangePosition { line?: number @@ -26,12 +27,50 @@ export interface DiagnosticEntry { label: string icon: string message: string + messageTruncated: boolean filePath: string displayPath: string line: number column: number } +export interface DiagnosticsView { + diagnostics: DiagnosticsMap + entries: DiagnosticEntry[] + key?: string + truncated: boolean +} + +function diagnosticListHasMessages(list: unknown): boolean { + if (!Array.isArray(list)) return false + for (let index = 0; index < list.length && index < 10_000; index += 1) { + if (typeof list[index]?.message === "string") return true + } + return list.length > 10_000 +} + +const DIAGNOSTIC_SCAN_LIMIT = 10_000 + +export function hasDiagnosticMessages(diagnostics: DiagnosticsMap): boolean { + let scanned = 0 + let scannedKeys = 0 + for (const key in diagnostics) { + if (!Object.prototype.hasOwnProperty.call(diagnostics, key)) continue + scannedKeys += 1 + if (scannedKeys > DIAGNOSTIC_SCAN_LIMIT) return true + const list = diagnostics[key] + if (!Array.isArray(list)) continue + const remaining = DIAGNOSTIC_SCAN_LIMIT - scanned + if (remaining <= 0) return true + for (let index = 0; index < list.length && index < remaining; index += 1) { + scanned += 1 + if (typeof list[index]?.message === "string") return true + } + if (list.length > remaining) return true + } + return false +} + export function normalizeDiagnosticPath(path: string) { return path.replace(/\\/g, "/") } @@ -48,26 +87,45 @@ function getSeverityMeta(tone: DiagnosticEntry["tone"]) { return { label: tGlobal("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 } } -export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { - if (!state) return [] +export function extractDiagnosticsView(state: ToolState | undefined): DiagnosticsView { + if (!state) return buildDiagnosticView({}, []) const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) - if (!supportsMetadata) return [] + if (!supportsMetadata) return buildDiagnosticView({}, []) const metadata = (state.metadata || {}) as Record const input = (state.input || {}) as Record const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined - if (!diagnosticsMap) return [] + if (!diagnosticsMap) return buildDiagnosticView({}, []) - return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) => + const view = buildDiagnosticView(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) => typeof value === "string" ? value : undefined, )) + let scanned = 0 + let scannedKeys = 0 + for (const key in diagnosticsMap) { + if (!Object.prototype.hasOwnProperty.call(diagnosticsMap, key)) continue + scannedKeys += 1 + if (scannedKeys > DIAGNOSTIC_SCAN_LIMIT) return { ...view, truncated: true } + const list = diagnosticsMap[key] + if (!Array.isArray(list)) continue + const remaining = DIAGNOSTIC_SCAN_LIMIT - scanned + if (remaining <= 0) return { ...view, truncated: true } + for (let index = 0; index < list.length && index < remaining; index += 1) { + scanned += 1 + if (key !== view.key && typeof list[index]?.message === "string") return { ...view, truncated: true } + } + if (list.length > remaining) return { ...view, truncated: true } + } + return view } -export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array): string | undefined { - if (Object.keys(diagnostics).length === 0) return undefined +export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { + return extractDiagnosticsView(state).entries +} +export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array): string | undefined { const normalizedPreferred = preferredPaths - .filter((value): value is string => typeof value === "string" && value.length > 0) + .filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096) .map((value) => normalizeDiagnosticPath(value)) if (normalizedPreferred.length === 0) return undefined @@ -76,7 +134,15 @@ export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPath if (diagnostics[preferred]) return preferred } - const keys = Object.keys(diagnostics) + const keys: string[] = [] + let scannedKeys = 0 + for (const key in diagnostics) { + if (!Object.prototype.hasOwnProperty.call(diagnostics, key)) continue + scannedKeys += 1 + if (scannedKeys > 10_000) break + if (key.length > 4_096) continue + keys.push(key) + } for (const preferred of normalizedPreferred) { const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred) @@ -94,29 +160,38 @@ export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPath return undefined } -export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array): DiagnosticEntry[] { +export function buildDiagnosticView(diagnostics: DiagnosticsMap, preferredPaths: Array): DiagnosticsView { const key = resolveDiagnosticsKey(diagnostics, preferredPaths) - if (!key) return [] + if (!key) return { diagnostics, entries: [], truncated: false } const list = diagnostics[key] - if (!Array.isArray(list) || list.length === 0) return [] + if (!Array.isArray(list) || list.length === 0) return { diagnostics, entries: [], key, truncated: false } + const limit = 100 const entries: DiagnosticEntry[] = [] const normalizedPath = normalizeDiagnosticPath(key) - for (let index = 0; index < list.length; index++) { - const diagnostic = list[index] + const selected = selectSeverityBounded( + list, + (diagnostic) => diagnostic && typeof diagnostic.message === "string" + ? getSeverityMeta(determineSeverityTone(diagnostic.severity)).rank + : undefined, + limit, + ) + for (const diagnostic of selected) { + const index = entries.length if (!diagnostic || typeof diagnostic.message !== "string") continue const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) const severityMeta = getSeverityMeta(tone) const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 entries.push({ - id: `${normalizedPath}-${index}-${diagnostic.message}`, + id: String(index), severity: severityMeta.rank, tone, label: severityMeta.label, icon: severityMeta.icon, - message: diagnostic.message, + message: diagnostic.message.slice(0, 2_000), + messageTruncated: diagnostic.message.length > 2_000, filePath: normalizedPath, displayPath: getRelativePath(normalizedPath), line, @@ -124,7 +199,16 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat }) } - return entries.sort((a, b) => a.severity - b.severity) + return { + diagnostics, + entries, + key, + truncated: list.length > entries.length || entries.some((entry) => entry.messageTruncated), + } +} + +export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array): DiagnosticEntry[] { + return buildDiagnosticView(diagnostics, preferredPaths).entries } export function diagnosticFileName(entries: DiagnosticEntry[]) { diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 88c6fca90..287bed38f 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -1,11 +1,11 @@ -import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js" +import { Show, Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import type { ToolState } from "../../types/tool-state" import useMediaQuery from "@suid/material/useMediaQuery" import { AlignJustify, Copy, Split, WrapText } from "lucide-solid" import type { RenderCache } from "../../types/message" import type { DiffViewMode } from "../../stores/preferences" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" -import { getRelativePath } from "./utils" +import { getRelativePath, limitToolOutputForRender, shouldRenderDiffPayloadAsPlainText } from "./utils" import { getCacheEntry } from "../../lib/global-cache" import { copyToClipboard } from "../../lib/clipboard" @@ -65,6 +65,8 @@ export function createDiffContentRenderer(params: { } function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null { + const renderedDiffText = limitToolOutputForRender(payload.diffText) + const diffWasTruncated = shouldRenderDiffPayloadAsPlainText(payload) const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath ? params.t("toolCall.diff.label.withPath", { path: relativePath }) @@ -100,7 +102,7 @@ export function createDiffContentRenderer(params: { const cached = getCacheEntry(cacheEntryParams) if ( cached - && cached.text === payload.diffText + && cached.text === renderedDiffText && cached.theme === themeKey && cached.mode === currentMode() && cached.wrap === currentWrap() @@ -127,6 +129,10 @@ export function createDiffContentRenderer(params: { ? params.t("toolCall.diff.disableWordWrap") : params.t("toolCall.diff.enableWordWrap") const copyPatchTitle = () => params.t("toolCall.diff.copyPatch") + const copyFullDiff = async () => { + const copiedDiff = payload.copyText ?? payload.diffText + if (await copyToClipboard(copiedDiff)) options?.onFullDiffAccess?.(copiedDiff) + } const handleDiffRendered = () => { params.handleScrollRendered() @@ -146,38 +152,42 @@ export function createDiffContentRenderer(params: { - - + + + +
- {cachedHtml() ? ( + {diffWasTruncated ? ( +
{renderedDiffText}
+ ) : cachedHtml() ? ( ) : ( - {payload.diffText}}> + {renderedDiffText}}> { + const payload = { diffText: "x".repeat(10_001) } + assert.equal(isPermissionApprovalBlocked(payload, false), true) + assert.equal(isPermissionApprovalBlocked(payload, true), false) + assert.equal(isPermissionApprovalBlocked({ diffText: "small" }, false), false) + assert.deepEqual(getPermissionDiffPayload({ metadata: { diff: payload.diffText, path: "/file" } } as any), { diffText: payload.diffText, filePath: "/file" }) +}) diff --git a/packages/ui/src/components/tool-call/permission-block.tsx b/packages/ui/src/components/tool-call/permission-block.tsx index 7d4f05f91..dcb545d97 100644 --- a/packages/ui/src/components/tool-call/permission-block.tsx +++ b/packages/ui/src/components/tool-call/permission-block.tsx @@ -6,9 +6,22 @@ import { useI18n } from "../../lib/i18n" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./permission-constants" import type { DiffPayload, DiffRenderOptions } from "./types" import { getRelativePath } from "./utils" +import { shouldRenderDiffPayloadAsPlainText } from "./utils" type PermissionResponse = "once" | "always" | "reject" +export function isPermissionApprovalBlocked(payload: DiffPayload | null, fullDiffReviewed: boolean): boolean { + return Boolean(payload && shouldRenderDiffPayloadAsPlainText(payload) && !fullDiffReviewed) +} + +export function getPermissionDiffPayload(permission: PermissionRequest | undefined): DiffPayload | null { + if (!permission) return null + const metadata = (permission.metadata ?? {}) as Record + const diffText = typeof metadata.diff === "string" ? metadata.diff : null + const filePath = typeof metadata.filePath === "string" ? metadata.filePath : typeof metadata.path === "string" ? metadata.path : undefined + return diffText?.trim() ? { diffText, filePath } : null +} + export type PermissionToolBlockProps = { permission: Accessor active: Accessor @@ -17,34 +30,21 @@ export type PermissionToolBlockProps = { onRespond: (permission: PermissionRequest, sessionId: string, response: PermissionResponse, message?: string) => void | Promise renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null fallbackSessionId: Accessor + onApprovalBlockedChange?: (blocked: boolean) => void } export function PermissionToolBlock(props: PermissionToolBlockProps) { const { t } = useI18n() const [rejectReason, setRejectReason] = createSignal("") + const [fullDiffReviewed, setFullDiffReviewed] = createSignal(false) createEffect(() => { props.permission()?.id setRejectReason("") + setFullDiffReviewed(false) }) - const diffPayload = () => { - const permission = props.permission() - if (!permission) return null - const metadata = (permission.metadata ?? {}) as Record - const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null - const diffPathRaw = (() => { - if (typeof metadata.filePath === "string") { - return metadata.filePath as string - } - if (typeof metadata.path === "string") { - return metadata.path as string - } - return undefined - })() - if (!diffValue || diffValue.trim().length === 0) return null - return { diffText: diffValue, filePath: diffPathRaw } satisfies DiffPayload - } + const diffPayload = () => getPermissionDiffPayload(props.permission()) const respond = (response: PermissionResponse, message?: string) => { const permission = props.permission() @@ -56,6 +56,11 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) { const confirmReject = () => { respond("reject", rejectReason().trim() || undefined) } + const approvalBlocked = () => { + const payload = diffPayload() + return isPermissionApprovalBlocked(payload, fullDiffReviewed()) + } + createEffect(() => props.onApprovalBlockedChange?.(approvalBlocked())) return ( @@ -80,6 +85,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) { label: payload().filePath ? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") }) : t("toolCall.permission.requestedDiff.label"), + onFullDiffAccess: () => setFullDiffReviewed(true), })}
)} @@ -105,7 +111,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
- +
Enter {t("toolCall.permission.shortcuts.allowOnce")} diff --git a/packages/ui/src/components/tool-call/render-memory.test.ts b/packages/ui/src/components/tool-call/render-memory.test.ts new file mode 100644 index 000000000..339dc0a04 --- /dev/null +++ b/packages/ui/src/components/tool-call/render-memory.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + limitToolOutputForRender, + limitToolTitleForRender, + shouldRenderDiffAsPlainText, + TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, + TOOL_TITLE_RENDER_CHARACTER_LIMIT, +} from "./utils.ts" + +test("bounds representative tool output, title, and diff rendering", () => { + const tail = "COPY_TAIL" + const rendered = limitToolOutputForRender(`${"x".repeat(20_000)}${tail}`) + assert.equal(rendered.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) + assert.equal(rendered.includes(tail), false) + assert.equal(limitToolTitleForRender("x".repeat(20_000)).length, TOOL_TITLE_RENDER_CHARACTER_LIMIT) + assert.equal(shouldRenderDiffAsPlainText("x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 1)), true) +}) diff --git a/packages/ui/src/components/tool-call/renderer-copy.test.ts b/packages/ui/src/components/tool-call/renderer-copy.test.ts new file mode 100644 index 000000000..1504ec285 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderer-copy.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { bashRenderer } from "./renderers/bash.tsx" + +test("resolves complete tool output only through lazy copy", () => { + const full = `${"x".repeat(10_000)}COPY_TAIL` + const chrome = bashRenderer.getOutputChrome?.({ + toolName: () => "bash", + t: (key: string) => key, + toolState: () => ({ status: "completed", input: { command: "echo" }, metadata: {}, output: full }), + } as any) + + assert.equal(chrome?.copyText, undefined) + assert.equal(chrome?.getCopyText?.()?.endsWith(full), true) +}) diff --git a/packages/ui/src/components/tool-call/renderers/apply-patch-data.ts b/packages/ui/src/components/tool-call/renderers/apply-patch-data.ts new file mode 100644 index 000000000..13637922f --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/apply-patch-data.ts @@ -0,0 +1,140 @@ +export type ApplyPatchFile = { + filePath?: string + relativePath?: string + type?: string + diff?: string + patch?: string +} + +export const APPLY_PATCH_FILE_RENDER_LIMIT = 20 +const APPLY_PATCH_SCAN_LIMIT = 10_000 + +export function getApplyPatchPathLabel(path: string, limit = 384): string { + const tail = path.slice(-(limit + 1)).replace(/\\/g, "/") + const separator = tail.lastIndexOf("/") + const label = separator >= 0 ? tail.slice(separator + 1) : tail + return label.length <= limit ? label : `...${label.slice(-(limit - 3))}` +} + +export function* getApplyPatchDiagnosticPaths(diagnostics: Record): Generator { + for (const path in diagnostics) if (Object.prototype.hasOwnProperty.call(diagnostics, path)) yield path +} + +function getApplyPatchDiff(file: ApplyPatchFile): string { + return typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "" +} + +function probeApplyPatchCopyText(file: ApplyPatchFile, limit: number) { + const diff = getApplyPatchDiff(file) + const prefix = diff.slice(0, Math.max(0, limit)) + return { hasContent: /\S/.test(prefix), scanned: prefix.length, truncated: prefix.length < diff.length } +} + +export function hasApplyPatchCopyText(files: ApplyPatchFile[], scanLimit = APPLY_PATCH_SCAN_LIMIT): boolean { + let characters = 0 + let index = 0 + for (; index < files.length && index < scanLimit; index += 1) { + const probe = probeApplyPatchCopyText(files[index], scanLimit - characters) + if (probe.hasContent || probe.truncated) return true + characters += probe.scanned + } + return index < files.length +} + +export function getApplyPatchCopyText(files: ApplyPatchFile[], limit = Number.POSITIVE_INFINITY): string { + const diffs: string[] = [] + let characters = 0 + for (const file of files) { + const remaining = limit - characters + if (remaining <= 0) break + const diff = getApplyPatchDiff(file) + const copyText = diff.slice(0, remaining) + if (!copyText.trim()) { + if (copyText.length < diff.length) break + continue + } + diffs.push(copyText) + characters += Math.min(diff.length, remaining) + if (diff.length > remaining) break + } + return diffs.join("\n") +} + +export function getApplyPatchCopyOutput(files: ApplyPatchFile[], fallback: unknown): string | null { + return getApplyPatchCopyText(files) || (typeof fallback === "string" && fallback.length > 0 ? fallback : null) +} + +export function getApplyPatchCopyAccess(files: ApplyPatchFile[], fallback: unknown) { + if (hasApplyPatchCopyText(files)) { + return { language: "diff" as const, getCopyText: () => getApplyPatchCopyOutput(files, fallback), hasCopyText: true as const } + } + if (typeof fallback !== "string" || fallback.length === 0) return undefined + return { language: "text" as const, getCopyText: () => fallback, hasCopyText: true as const } +} + +export function getApplyPatchRenderData(files: ApplyPatchFile[], fileLimit: number, characterLimit: number, sourceTruncated = false) { + const rendered: Array<{ file: ApplyPatchFile; diffText: string }> = [] + let characters = 0 + let scannedCharacters = 0 + let truncated = sourceTruncated + for (const file of files) { + if (rendered.length >= fileLimit || characters >= characterLimit) { + truncated = true + break + } + const remaining = characterLimit - characters + const fullDiff = getApplyPatchDiff(file) + const probe = probeApplyPatchCopyText(file, Math.min(APPLY_PATCH_SCAN_LIMIT - scannedCharacters, remaining)) + scannedCharacters += probe.scanned + const firstContent = probe.hasContent ? fullDiff.slice(0, probe.scanned).search(/\S/) : -1 + const diffText = firstContent >= 0 ? fullDiff.slice(firstContent, firstContent + remaining) : "" + rendered.push({ file, diffText }) + characters += diffText.length + if (probe.truncated || (firstContent >= 0 && fullDiff.length - firstContent > remaining)) truncated = true + } + return { rendered, truncated } +} + +export function getApplyPatchFilesForRender(files: ApplyPatchFile[], diagnosticPaths: Iterable, limit = APPLY_PATCH_FILE_RENDER_LIMIT) { + const normalize = (path: string) => path.replace(/\\/g, "/") + const matchesPath = (left: string, right: string) => left === right || left.endsWith(`/${right}`) || right.endsWith(`/${left}`) + const paths: Array<{ raw: string; normalized: string }> = [] + let diagnosticPathsTruncated = false + for (const path of diagnosticPaths) { + if (paths.length >= limit) { + diagnosticPathsTruncated = true + break + } + paths.push({ raw: path, normalized: normalize(path) }) + } + const rendered: ApplyPatchFile[] = [] + const knownPaths: string[] = [] + let scannedFiles = 0 + let scannedCharacters = 0 + for (let fileIndex = 0; fileIndex < files.length && rendered.length < limit; fileIndex += 1) { + const file = files[fileIndex] + scannedFiles += 1 + const normalizedFilePaths = [file.filePath, file.relativePath] + .filter((path): path is string => typeof path === "string") + .map(normalize) + const matchesDiagnostic = normalizedFilePaths.some((path) => paths.some((diagnostic) => matchesPath(path, diagnostic.normalized))) + const probe = probeApplyPatchCopyText(file, APPLY_PATCH_SCAN_LIMIT - scannedCharacters) + scannedCharacters += probe.scanned + if (probe.hasContent || probe.truncated || matchesDiagnostic) { + rendered.push(file) + knownPaths.push(...normalizedFilePaths) + } + if (scannedFiles >= 10_000) break + } + for (const path of paths) { + if (rendered.length >= limit) break + if (!knownPaths.some((knownPath) => matchesPath(knownPath, path.normalized))) { + rendered.push({ filePath: path.raw }) + knownPaths.push(path.normalized) + } + } + return { + files: rendered, + truncated: scannedFiles < files.length || diagnosticPathsTruncated, + } +} diff --git a/packages/ui/src/components/tool-call/renderers/apply-patch.tsx b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx index 65b2bd111..bdf041fec 100644 --- a/packages/ui/src/components/tool-call/renderers/apply-patch.tsx +++ b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx @@ -1,16 +1,10 @@ import { For, Show, createMemo } from "solid-js" import type { ToolRenderer } from "../types" -import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" -import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics" +import { getToolName, isToolStateCompleted, limitToolOutputForRender, limitToolTitleForRender, readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils" +import { buildDiagnosticView, hasDiagnosticMessages, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics" +import { DiagnosticsPayloadAccess } from "../diagnostics-section" import { getApplyPatchToolSearchText } from "../search-text" - -type ApplyPatchFile = { - filePath?: string - relativePath?: string - type?: string - diff?: string - patch?: string -} +import { APPLY_PATCH_FILE_RENDER_LIMIT, getApplyPatchCopyAccess, getApplyPatchCopyText, getApplyPatchDiagnosticPaths, getApplyPatchFilesForRender, getApplyPatchPathLabel, getApplyPatchRenderData, hasApplyPatchCopyText, type ApplyPatchFile } from "./apply-patch-data" function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record) => string }) { return ( @@ -68,62 +62,108 @@ export const applyPatchRenderer: ToolRenderer = { const payload = readToolStatePayload(state) const files = Array.isArray((payload.metadata as any).files) ? ((payload.metadata as any).files as ApplyPatchFile[]) : [] - const diffs = files - .map((file) => (typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "")) - .filter((diff) => diff.trim().length > 0) - if (diffs.length > 0) { - return { language: "diff", copyText: diffs.join("\n"), suppressInnerHeader: false } - } - - const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - if (!fallback) return undefined - return { language: "text", copyText: fallback, wrapToggle: true, suppressInnerHeader: true } + const fallback = isToolStateCompleted(state) && typeof state.output === "string" && state.output.length > 0 ? state.output : null + const access = getApplyPatchCopyAccess(files, fallback) + if (!access) return undefined + return access.language === "diff" + ? { ...access, suppressInnerHeader: false } + : { ...access, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderDiff, renderMarkdown, t }) { const state = toolState() if (!state || state.status === "pending") return null const payload = readToolStatePayload(state) - const files = createMemo(() => { - const list = (payload.metadata as any).files - return Array.isArray(list) ? (list as ApplyPatchFile[]) : [] - }) const diagnosticsMap = createMemo(() => { const value = (payload.metadata as any).diagnostics return value && typeof value === "object" ? (value as DiagnosticsMap) : {} }) + const allFiles = createMemo(() => { + const list = (payload.metadata as any).files + return getApplyPatchFilesForRender(Array.isArray(list) ? list as ApplyPatchFile[] : [], getApplyPatchDiagnosticPaths(diagnosticsMap())) + }) + const renderData = createMemo(() => getApplyPatchRenderData(allFiles().files, APPLY_PATCH_FILE_RENDER_LIMIT, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, allFiles().truncated)) + const files = createMemo(() => renderData().rendered) + const fallback = createMemo(() => { + if (!isToolStateCompleted(state) || hasApplyPatchCopyText(files().map(({ file }) => file))) return null + return typeof state.output === "string" && state.output.length > 0 ? state.output : null + }) + const diagnosticViews = createMemo(() => { + let remaining = 100 + return files().map(({ file }) => { + const view = buildDiagnosticView(diagnosticsMap(), [file.filePath, file.relativePath]) + const entries = view.entries.slice(0, remaining) + remaining -= entries.length + return { ...view, entries, truncated: view.truncated || entries.length < view.entries.length } + }) + }) + const diagnosticsTruncated = createMemo(() => { + const views = diagnosticViews() + const renderedKeys = new Set(views.map((view) => view.key).filter(Boolean)) + if (views.some((view) => view.truncated)) return true + let scanned = 0 + let scannedKeys = 0 + for (const key in diagnosticsMap()) { + if (!Object.prototype.hasOwnProperty.call(diagnosticsMap(), key)) continue + scannedKeys += 1 + if (scannedKeys > 10_000) return true + const list = diagnosticsMap()[key] + if (!renderedKeys.has(key) && Array.isArray(list)) { + const remaining = 10_000 - scanned + if (remaining <= 0) return true + for (let index = 0; index < list.length && index < remaining; index += 1) { + scanned += 1 + if (typeof list[index]?.message === "string") return true + } + if (list.length > remaining) return true + } + } + return false + }) - if (files().length === 0) { - const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - if (!fallback) return null - return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" }) - } + if (files().length === 0 && !fallback() && !renderData().truncated) return null return (
+ + {(content) => renderMarkdown({ content: limitToolOutputForRender(content()), size: "large", disableHighlight: state.status === "running" })} + - {(file, index) => { + {(renderedFile, index) => { + const file = renderedFile.file const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 }) - const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "" + const label = getApplyPatchPathLabel(labelBase) + const fullDiff = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "" + const diffText = renderedFile.diffText const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath - const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath])) + const entries = createMemo(() => diagnosticViews()[index()]?.entries ?? []) return (
0}> {renderDiff( - { diffText, filePath }, + { diffText, copyText: fullDiff, filePath }, { - label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }), - cacheKey: `apply_patch:${labelBase}:${index()}`, + label: limitToolTitleForRender(t("toolCall.diff.label.withPath", { path: label })), + cacheKey: `apply_patch:${index()}`, }, )} - +
) }}
+ +
{t("toolCall.output.truncated")}
+
+ +
+
+ +
+
+
) }, diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 2123760f7..1d2a815a6 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -1,7 +1,7 @@ import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import type { ToolState } from "../../../types/tool-state" import type { ToolRenderer, ToolScrollHelpers } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { createStableAnsiStreamUpdater } from "../ansi-render" import { ansiToHtml, hasAnsi } from "../../../lib/ansi" @@ -99,7 +99,7 @@ function getBashCopyText(state: ToolState | undefined): string { const { input, metadata } = readToolStatePayload(state) const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const outputResult = formatUnknownForCopy( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output @@ -122,8 +122,8 @@ function BashToolBody(props: { if (!current || current.status === "pending") return "" const { input, metadata } = readToolStatePayload(current) - const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const command = typeof input.command === "string" && input.command.length > 0 ? limitToolOutputForRender(`$ ${input.command}`) : "" + const outputResult = formatUnknownForRender( isToolStateCompleted(current) ? current.output : (isToolStateRunning(current) || isToolStateError(current)) && metadata.output @@ -132,10 +132,11 @@ function BashToolBody(props: { ) return [command, outputResult?.text].filter(Boolean).join("\n") }) + const renderedContent = createMemo(() => limitToolOutputForRender(joinedContent())) const finalMarkdown = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -147,7 +148,7 @@ function BashToolBody(props: { const finalAnsiHtml = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -169,7 +170,7 @@ function BashToolBody(props: { } > - + ) @@ -196,9 +197,14 @@ export const bashRenderer: ToolRenderer = { return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}` }, getOutputChrome({ toolState }) { - const text = getBashCopyText(toolState()) - if (!text) return undefined - return { language: "bash", copyText: text, wrapToggle: true, suppressInnerHeader: true } + const state = toolState() + if (!state || state.status === "pending") return undefined + const { input, metadata } = readToolStatePayload(state) + const output = isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) ? metadata.output : undefined + const hasCopyText = (typeof input.command === "string" && input.command.length > 0) + || (output !== undefined && output !== null && output !== "" && (!Array.isArray(output) || output.length > 0)) + if (!hasCopyText) return undefined + return { language: "bash", getCopyText: () => getBashCopyText(state), hasCopyText: true, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) { return diff --git a/packages/ui/src/components/tool-call/renderers/default.tsx b/packages/ui/src/components/tool-call/renderers/default.tsx index f19682675..78aaad939 100644 --- a/packages/ui/src/components/tool-call/renderers/default.tsx +++ b/packages/ui/src/components/tool-call/renderers/default.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const defaultRenderer: ToolRenderer = { @@ -16,12 +16,13 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) - if (!result) return undefined + if (primaryOutput === undefined || primaryOutput === null || primaryOutput === "" || (Array.isArray(primaryOutput) && primaryOutput.length === 0)) return undefined + const result = formatUnknownForRender(primaryOutput) return { - language: result.language ?? "text", - copyText: result.text, + language: result?.language ?? "text", + getCopyText: () => formatUnknownForCopy(primaryOutput)?.text ?? null, + hasCopyText: true, wrapToggle: true, suppressInnerHeader: true, } @@ -37,7 +38,7 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) + const result = formatUnknownForRender(primaryOutput) if (!result) return null const content = ensureMarkdownContent(result.text, result.language, true) diff --git a/packages/ui/src/components/tool-call/renderers/edit.tsx b/packages/ui/src/components/tool-call/renderers/edit.tsx index b4f84fb1d..0c715675f 100644 --- a/packages/ui/src/components/tool-call/renderers/edit.tsx +++ b/packages/ui/src/components/tool-call/renderers/edit.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -21,7 +21,7 @@ export const editRenderer: ToolRenderer = { const diffPayload = extractDiffPayload(toolName(), state) if (diffPayload) { - return { language: "diff", copyText: diffPayload.diffText, suppressInnerHeader: false } + return { language: "diff", getCopyText: () => diffPayload.diffText, suppressInnerHeader: false } } const { metadata } = readToolStatePayload(state) @@ -29,7 +29,7 @@ export const editRenderer: ToolRenderer = { const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null const copyText = diffText || fallback if (!copyText) return undefined - return { language: "diff", copyText, wrapToggle: true, suppressInnerHeader: true } + return { language: "diff", getCopyText: () => copyText, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, toolName, renderDiff, renderMarkdown }) { const state = toolState() @@ -43,7 +43,8 @@ export const editRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/patch.tsx b/packages/ui/src/components/tool-call/renderers/patch.tsx index 356bba5a6..cd3a42fe6 100644 --- a/packages/ui/src/components/tool-call/renderers/patch.tsx +++ b/packages/ui/src/components/tool-call/renderers/patch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -21,7 +21,7 @@ export const patchRenderer: ToolRenderer = { const diffPayload = extractDiffPayload(toolName(), state) if (diffPayload) { - return { language: "diff", copyText: diffPayload.diffText, suppressInnerHeader: false } + return { language: "diff", getCopyText: () => diffPayload.diffText, suppressInnerHeader: false } } const { metadata } = readToolStatePayload(state) @@ -29,7 +29,7 @@ export const patchRenderer: ToolRenderer = { const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null const copyText = diffText || fallback if (!copyText) return undefined - return { language: "diff", copyText, wrapToggle: true, suppressInnerHeader: true } + return { language: "diff", getCopyText: () => copyText, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, toolName, renderDiff, renderMarkdown }) { const state = toolState() @@ -43,7 +43,8 @@ export const patchRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/read.tsx b/packages/ui/src/components/tool-call/renderers/read.tsx index ff37e2e85..133e8bc3b 100644 --- a/packages/ui/src/components/tool-call/renderers/read.tsx +++ b/packages/ui/src/components/tool-call/renderers/read.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getReadToolSearchText } from "../search-text" @@ -48,7 +48,7 @@ export const readRenderer: ToolRenderer = { const preview = typeof metadata.preview === "string" ? metadata.preview : null if (!preview) return undefined const language = inferLanguageFromPath(getReadPath(input)) ?? "text" - return { language, copyText: preview, wrapToggle: true, suppressInnerHeader: true } + return { language, getCopyText: () => preview, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown }) { const state = toolState() @@ -56,7 +56,7 @@ export const readRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const preview = typeof metadata.preview === "string" ? metadata.preview : null const language = inferLanguageFromPath(getReadPath(input)) - const content = ensureMarkdownContent(preview, language, true) + const content = ensureMarkdownContent(preview ? limitToolOutputForRender(preview) : preview, language, true) if (!content) return null return renderMarkdown({ content, disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/renderers/skill.tsx b/packages/ui/src/components/tool-call/renderers/skill.tsx index adaa2cc66..ee783e0f1 100644 --- a/packages/ui/src/components/tool-call/renderers/skill.tsx +++ b/packages/ui/src/components/tool-call/renderers/skill.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const skillRenderer: ToolRenderer = { @@ -12,15 +12,14 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return undefined - const output = formatUnknown(state.output)?.text ?? null - if (!output) return undefined - return { copyText: output, suppressInnerHeader: true } + if (state.output === undefined || state.output === null || state.output === "" || (Array.isArray(state.output) && state.output.length === 0)) return undefined + return { getCopyText: () => formatUnknownForCopy(state.output)?.text ?? null, hasCopyText: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown }) { const state = toolState() if (!state || state.status !== "completed") return null - const output = formatUnknown(state.output)?.text ?? null + const output = formatUnknownForRender(state.output)?.text ?? null const content = ensureMarkdownContent(output, undefined, false) if (!content) return null return
{renderMarkdown({ content })}
diff --git a/packages/ui/src/components/tool-call/renderers/task-summary.ts b/packages/ui/src/components/tool-call/renderers/task-summary.ts new file mode 100644 index 000000000..a0563cfb3 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/task-summary.ts @@ -0,0 +1,53 @@ +import { limitToolTitleForRender } from "../utils" + +export const TASK_STEP_RENDER_LIMIT = 200 + +export function isTaskStepListTruncated(count: number): boolean { + return count > TASK_STEP_RENDER_LIMIT +} + +export function isTaskScanTruncated(...sources: boolean[]): boolean { + return sources.some(Boolean) +} + +export function resolveTaskStepTruncation(childSourceActive: boolean, childTruncated: boolean, legacyTruncated: boolean): boolean { + return childTruncated || (!childSourceActive && legacyTruncated) +} + +export function getTaskOutputCopyText(state: unknown): string | null { + const output = (state as { output?: unknown } | null | undefined)?.output + return typeof output === "string" && output.length > 0 ? output : null +} + +export function stringifyChildTaskSteps( + messageIds: readonly string[], + getMessage: (messageId: string) => { partIds: readonly string[]; parts: Record } | undefined, +): string { + const steps: unknown[] = [] + for (const messageId of messageIds) { + const message = getMessage(messageId) + if (!message) continue + for (const partId of message.partIds) { + const part = message.parts[partId]?.data as { type?: unknown } | undefined + if (part?.type === "tool") steps.push(part) + } + } + return JSON.stringify(steps, null, 2) +} + +export function getLegacyTaskSummary(summary: unknown) { + const entries = Array.isArray(summary) ? summary : [] + return { + entries, + renderedEntries: entries.slice(-TASK_STEP_RENDER_LIMIT), + truncated: isTaskStepListTruncated(entries.length), + } +} + +export function stringifyLegacyTaskSummary(summary: unknown): string { + return JSON.stringify(getLegacyTaskSummary(summary).entries, null, 2) +} + +export function getTruncatedTaskStepTitleCopyText(title: string): string | null { + return limitToolTitleForRender(title) === title ? null : title +} diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 7af160e35..d8327f578 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,11 +1,20 @@ -import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" +import { Copy } from "lucide-solid" import type { ToolState } from "../../../types/tool-state" import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, limitToolOutputForRender, limitToolTitleForRender, readToolStatePayload } from "../utils" import { messageStoreBus } from "../../../stores/message-v2/bus" import { loadMessages } from "../../../stores/session-api" -import { loading, messagesLoaded } from "../../../stores/session-state" +import { getSessionMessagesLoadError, messagesLoaded, sessions } from "../../../stores/session-state" +import { setSessionTranscriptVisible } from "../../../stores/session-transcript-memory" +import { waitForInstanceWorkspaceMetadataHydration } from "../../../stores/instances" +import { useActiveSessionMessageLoad } from "../../../lib/hooks/use-active-session-message-load" import { getTaskToolSearchText } from "../search-text" +import { copyToClipboard } from "../../../lib/clipboard" +import LoadErrorState from "../../load-error-state" +import { getLegacyTaskSummary, getTaskOutputCopyText, getTruncatedTaskStepTitleCopyText, isTaskScanTruncated, isTaskStepListTruncated, resolveTaskStepTruncation, stringifyChildTaskSteps, stringifyLegacyTaskSummary, TASK_STEP_RENDER_LIMIT } from "./task-summary" + +const TASK_MESSAGE_SCAN_LIMIT = 10_000 interface TaskSummaryItem { id: string @@ -17,6 +26,8 @@ interface TaskSummaryItem { title?: string } +type TaskScanBudget = { remaining: number } + function extractSessionIdFromTaskState(state?: ToolState): string { if (!state) return "" const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} @@ -163,6 +174,10 @@ export const taskRenderer: ToolRenderer = { tools: ["task"], getSearchText: getTaskToolSearchText, getAction: ({ t }) => t("toolCall.task.action.delegating"), + getOutputChrome({ toolState }) { + const output = getTaskOutputCopyText(toolState()) + return output ? { getCopyText: () => output, hasCopyText: true } : undefined + }, getTitle({ toolState }) { const state = toolState() if (!state) return undefined @@ -171,7 +186,6 @@ export const taskRenderer: ToolRenderer = { }, renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { const store = messageStoreBus.getOrCreate(instanceId) - const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) const childSessionId = createMemo(() => { const state = toolState() @@ -185,24 +199,40 @@ export const taskRenderer: ToolRenderer = { return loadedForInstance?.has(id) ?? false }) - const childSessionLoading = createMemo(() => { + const childSessionLoadError = createMemo(() => { const id = childSessionId() - if (!id) return false - const loadingSet = loading().loadingMessages.get(instanceId) - return loadingSet?.has(id) ?? false + return id && !childSessionLoaded() ? getSessionMessagesLoadError(instanceId, id) : undefined + }) + + function retryChildSessionLoad() { + const id = childSessionId() + if (!id) return + void loadMessages(instanceId, id, { force: true }).catch(() => {}) + } + + useActiveSessionMessageLoad({ + isActive: () => Boolean(childSessionId()), + instanceId: () => instanceId, + session: () => { + const id = childSessionId() + return id ? sessions().get(instanceId)?.get(id) : undefined + }, + shouldLoad: () => !childSessionLoaded(), + loadMessages: (childInstanceId, id, options) => loadMessages(childInstanceId, id, { + registerInvalidation: options?.registerInvalidation, + }), + waitForHydration: waitForInstanceWorkspaceMetadataHydration, }) createEffect(() => { const id = childSessionId() if (!id) return - if (requestedChildLoad()) return - if (childSessionLoaded()) return - if (childSessionLoading()) return - setRequestedChildLoad(true) - void loadMessages(instanceId, id) + setSessionTranscriptVisible(instanceId, id, true) + onCleanup(() => setSessionTranscriptVisible(instanceId, id, false)) }) const [childToolKeys, setChildToolKeys] = createSignal([]) + const [childToolsTruncated, setChildToolsTruncated] = createSignal(false) let indexedSessionId = "" let indexedMessageCount = 0 @@ -215,21 +245,32 @@ export const taskRenderer: ToolRenderer = { indexedMessageTail = "" indexedPartCounts.clear() setChildToolKeys([]) + setChildToolsTruncated(false) } - function scanMessageToolParts(messageId: string, startIndex: number) { + function scanMessageToolParts(messageId: string, startIndex: number, limit: number, budget: TaskScanBudget) { + if (budget.remaining <= 0) { + setChildToolsTruncated(true) + return [] as string[] + } + budget.remaining -= 1 const record = store.getMessage(messageId) if (!record) return [] as string[] const partIds = record.partIds const keys: string[] = [] - for (let idx = startIndex; idx < partIds.length; idx += 1) { + const oldestScannedIndex = Math.max(startIndex, partIds.length - budget.remaining) + if (oldestScannedIndex > startIndex) setChildToolsTruncated(true) + let idx = partIds.length - 1 + for (; idx >= oldestScannedIndex && keys.length < limit && budget.remaining > 0; idx -= 1) { + budget.remaining -= 1 const partId = partIds[idx] const entry = record.parts?.[partId] const data = entry?.data if (!data || (data as any).type !== "tool") continue - keys.push(`${messageId}::${partId}`) + keys.unshift(`${messageId}::${partId}`) } + if (idx >= oldestScannedIndex) setChildToolsTruncated(true) indexedPartCounts.set(messageId, partIds.length) return keys } @@ -239,12 +280,18 @@ export const taskRenderer: ToolRenderer = { indexedMessageCount = messageIds.length indexedMessageTail = messageIds[messageIds.length - 1] ?? "" indexedPartCounts.clear() + setChildToolsTruncated(false) const nextKeys: string[] = [] - for (const messageId of messageIds) { - nextKeys.push(...scanMessageToolParts(messageId, 0)) + const scanLimit = TASK_STEP_RENDER_LIMIT + 1 + const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT } + const oldestScannedIndex = Math.max(0, messageIds.length - TASK_MESSAGE_SCAN_LIMIT) + for (let index = messageIds.length - 1; index >= oldestScannedIndex && nextKeys.length < scanLimit && budget.remaining > 0; index -= 1) { + const keys = scanMessageToolParts(messageIds[index], 0, scanLimit - nextKeys.length, budget) + for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex]) } - setChildToolKeys(nextKeys) + setChildToolsTruncated((truncated) => isTaskScanTruncated(truncated, oldestScannedIndex > 0, isTaskStepListTruncated(nextKeys.length))) + setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT)) } createEffect(() => { @@ -284,33 +331,47 @@ export const taskRenderer: ToolRenderer = { } const appendedKeys: string[] = [] + const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT } // Scan any new messages appended since last index. - for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) { + const appendedStart = Math.max(indexedMessageCount, messageIds.length - TASK_MESSAGE_SCAN_LIMIT) + if (appendedStart > indexedMessageCount) setChildToolsTruncated(true) + for (let idx = appendedStart; idx < messageIds.length && budget.remaining > 0; idx += 1) { const messageId = messageIds[idx] - appendedKeys.push(...scanMessageToolParts(messageId, 0)) + appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT, budget)) + if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) { + setChildToolsTruncated(true) + appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT) + } } - // Scan a small window of recent messages for newly appended parts. - // Deltas typically affect the most recent tool call, so this avoids - // iterating every message on every revision. + // Scan the bounded indexed window so out-of-order updates are not missed. const existingCount = Math.min(indexedMessageCount, messageIds.length) - const windowStart = Math.max(0, existingCount - 3) - for (let idx = windowStart; idx < existingCount; idx += 1) { + const windowStart = Math.max(0, existingCount - TASK_MESSAGE_SCAN_LIMIT) + for (let idx = windowStart; idx < existingCount && budget.remaining > 0; idx += 1) { const messageId = messageIds[idx] const previousPartCount = indexedPartCounts.get(messageId) ?? 0 const record = store.getMessage(messageId) const nextPartCount = record?.partIds.length ?? 0 if (nextPartCount > previousPartCount) { - appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount)) + appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT, budget)) + if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) { + setChildToolsTruncated(true) + appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT) + } } } indexedMessageCount = messageIds.length indexedMessageTail = messageIds[messageIds.length - 1] ?? "" + if (indexedPartCounts.size > TASK_MESSAGE_SCAN_LIMIT) { + const retainedIds = new Set(messageIds.slice(-TASK_MESSAGE_SCAN_LIMIT)) + for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId) + } if (appendedKeys.length > 0) { - setChildToolKeys((prev) => [...prev, ...appendedKeys]) + if (childToolKeys().length + appendedKeys.length > TASK_STEP_RENDER_LIMIT) setChildToolsTruncated(true) + setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT)) } }) }) @@ -319,21 +380,21 @@ export const taskRenderer: ToolRenderer = { if (!state) return null const { input } = readToolStatePayload(state) const prompt = typeof input.prompt === "string" ? input.prompt : null - return ensureMarkdownContent(prompt, undefined, false) + return ensureMarkdownContent(prompt ? limitToolOutputForRender(prompt) : prompt, undefined, false) }) const outputContent = createMemo(() => { const state = toolState() if (!state) return null const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null - return ensureMarkdownContent(output, undefined, false) + return ensureMarkdownContent(output ? limitToolOutputForRender(output) : output, undefined, false) }) const agentLabel = createMemo(() => { const state = toolState() if (!state) return null const { input } = readToolStatePayload(state) - return typeof input.subagent_type === "string" ? input.subagent_type : null + return typeof input.subagent_type === "string" ? limitToolTitleForRender(input.subagent_type) : null }) const modelLabel = createMemo(() => { @@ -342,8 +403,8 @@ export const taskRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const model = (metadata as any).model if (!model || typeof model !== "object") return null - const providerId = typeof model.providerID === "string" ? model.providerID : null - const modelId = typeof model.modelID === "string" ? model.modelID : null + const providerId = typeof model.providerID === "string" ? limitToolTitleForRender(model.providerID) : null + const modelId = typeof model.modelID === "string" ? limitToolTitleForRender(model.modelID) : null if (!providerId && !modelId) return null if (providerId && modelId) return `${providerId}/${modelId}` return providerId ?? modelId @@ -352,27 +413,26 @@ export const taskRenderer: ToolRenderer = { const headerMeta = createMemo(() => { const agent = agentLabel() const model = modelLabel() - if (agent && model) return t("toolCall.task.meta.agentModel", { agent, model }) - if (agent) return t("toolCall.task.meta.agent", { agent }) - if (model) return t("toolCall.task.meta.model", { model }) + if (agent && model) return limitToolTitleForRender(t("toolCall.task.meta.agentModel", { agent, model })) + if (agent) return limitToolTitleForRender(t("toolCall.task.meta.agent", { agent })) + if (model) return limitToolTitleForRender(t("toolCall.task.meta.model", { model })) return null }) - const legacyItems = createMemo(() => { + const legacySummary = createMemo(() => { // Track the reactive change points so we only recompute when the part/message changes messageVersion?.() partVersion?.() const state = toolState() - if (!state) return [] - - // Prefer deriving steps from the child session when loaded. - if (childSessionLoaded()) return [] - + if (!state) return getLegacyTaskSummary(undefined) const { metadata } = readToolStatePayload(state) - const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] + return getLegacyTaskSummary((metadata as any).summary) + }) - return summary.map((entry, index) => { + const legacyItems = createMemo(() => { + if (childToolKeys().length > 0) return [] + return legacySummary().renderedEntries.map((entry, index) => { const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {} @@ -383,6 +443,8 @@ export const taskRenderer: ToolRenderer = { return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title } }) }) + const childSourceActive = () => childToolKeys().length > 0 || childToolsTruncated() + const stepsTruncated = () => resolveTaskStepTruncation(childSourceActive(), childToolsTruncated(), legacySummary().truncated) createEffect(() => { const childCount = childToolKeys().length @@ -415,15 +477,42 @@ export const taskRenderer: ToolRenderer = { - 0 || legacyItems().length > 0}> + + {(error) => ( + + )} + + + 0 || legacyItems().length > 0 || stepsTruncated()}>
{t("toolCall.task.sections.steps")} -
+ +
{t("toolCall.task.steps.truncated", { count: TASK_STEP_RENDER_LIMIT })}
+
0} fallback={ @@ -438,8 +527,10 @@ export const taskRenderer: ToolRenderer = { {(item) => { const icon = getToolIcon(item.tool) - const description = describeToolTitle(item) - const toolLabel = getToolName(item.tool) + const fullDescription = describeToolTitle(item) + const description = limitToolTitleForRender(fullDescription) + const copyTitle = getTruncatedTaskStepTitleCopyText(fullDescription) + const toolLabel = limitToolTitleForRender(getToolName(item.tool)) const status = normalizeStatus(item.status ?? item.state?.status) const statusIcon = summarizeStatusIcon(status) const statusKey = summarizeStatusLabel(status) @@ -453,6 +544,13 @@ export const taskRenderer: ToolRenderer = { {toolLabel} {description} + + {(title) => ( + + )} + {statusIcon} diff --git a/packages/ui/src/components/tool-call/renderers/todo-data.ts b/packages/ui/src/components/tool-call/renderers/todo-data.ts new file mode 100644 index 000000000..c8ebc3ebd --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/todo-data.ts @@ -0,0 +1,94 @@ +import type { ToolState } from "../../../types/tool-state" +import { readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils" + +export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" + +export interface TodoViewItem { + id: string + content: string + status: TodoViewStatus +} + +type TodoViewItems = TodoViewItem[] & { partial?: boolean } +export const TODO_ITEM_RENDER_LIMIT = 200 + +function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { + if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus + return "pending" +} + +export function extractTodosFromState(state?: ToolState): TodoViewItems { + if (!state) return [] + const { metadata } = readToolStatePayload(state) + const todos: any[] = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] + const normalized: TodoViewItems = [] + let characters = 0 + let scannedCharacters = 0 + let index = 0 + for (; index < todos.length && index < 10_000 && normalized.length < TODO_ITEM_RENDER_LIMIT && scannedCharacters < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT; index += 1) { + const todo = todos[index] + const rawContent = typeof todo?.content === "string" ? todo.content : "" + const contentPrefix = rawContent.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - scannedCharacters) + scannedCharacters += contentPrefix.length + const contentStart = contentPrefix.search(/\S/) + if (contentStart < 0) { + if (contentPrefix.length < rawContent.length) break + continue + } + const remaining = TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters + const contentSlice = contentPrefix.slice(contentStart, contentStart + remaining) + const content = contentSlice.trimEnd() + if (!content) continue + const status = normalizeTodoStatus(todo.status) + const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : String(index) + normalized.push({ id, content, status }) + characters += content.length + if (contentStart + contentSlice.length < rawContent.length) break + } + if (index < todos.length) normalized.partial = true + return normalized +} + +export function getRenderedTodos(todos: TodoViewItem[]) { + const items: TodoViewItem[] = [] + let characters = 0 + for (let index = 0; index < todos.length && index < TODO_ITEM_RENDER_LIMIT; index += 1) { + if (characters >= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) break + const todo = todos[index] + const content = todo.content.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters) + items.push({ ...todo, content }) + characters += content.length + } + return { items, truncated: Boolean((todos as TodoViewItems).partial) || items.length < todos.length } +} + +export function hasTodoCopyText(state?: ToolState): boolean { + if (!state) return false + const { metadata } = readToolStatePayload(state) + return Array.isArray((metadata as any).todos) && (metadata as any).todos.length > 0 +} + +export function getTodoCopyText(state?: ToolState): string { + if (!state) return "[]" + const { metadata } = readToolStatePayload(state) + return JSON.stringify(Array.isArray((metadata as any).todos) ? (metadata as any).todos : [], null, 2) +} + +export function getTodoTitleKind(state?: ToolState): "plan" | "creating" | "completing" | "updating" { + if (state?.status !== "completed") return "plan" + const { metadata } = readToolStatePayload(state) + const todos: any[] = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] + if (todos.length === 0) return "plan" + let allPending = true + let allCompleted = true + for (let index = 0; index < todos.length && index < 10_000; index += 1) { + const status = normalizeTodoStatus(todos[index]?.status) + allPending = allPending && status === "pending" + allCompleted = allCompleted && status === "completed" + if (!allPending && !allCompleted) return "updating" + } + if (todos.length > 10_000) return "updating" + if (allPending) return "creating" + if (allCompleted) return "completing" + return "updating" +} diff --git a/packages/ui/src/components/tool-call/renderers/todo.tsx b/packages/ui/src/components/tool-call/renderers/todo.tsx index 6dc820d99..cddc7d83a 100644 --- a/packages/ui/src/components/tool-call/renderers/todo.tsx +++ b/packages/ui/src/components/tool-call/renderers/todo.tsx @@ -1,41 +1,11 @@ import { For, Show } from "solid-js" import type { ToolState } from "../../../types/tool-state" -import { CheckCircle, CircleEllipsis, MinusCircle, PauseCircle } from "lucide-solid" +import { CheckCircle, CircleEllipsis, Copy, MinusCircle, PauseCircle } from "lucide-solid" import type { ToolRenderer } from "../types" -import { readToolStatePayload } from "../utils" import { useI18n, tGlobal } from "../../../lib/i18n" import { getTodoToolSearchText } from "../search-text" - -export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" - -export interface TodoViewItem { - id: string - content: string - status: TodoViewStatus -} - -function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { - if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus - return "pending" -} - -function extractTodosFromState(state?: ToolState): TodoViewItem[] { - if (!state) return [] - const { metadata } = readToolStatePayload(state) - const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] - const items: TodoViewItem[] = [] - - for (let index = 0; index < todos.length; index++) { - const todo = todos[index] - const content = typeof todo?.content === "string" ? todo.content.trim() : "" - if (!content) continue - const status = normalizeTodoStatus((todo as any).status) - const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}` - items.push({ id, content, status }) - } - - return items -} +import { extractTodosFromState, getRenderedTodos, getTodoCopyText, getTodoTitleKind, hasTodoCopyText, type TodoViewItem, type TodoViewStatus } from "./todo-data" +import { copyToClipboard } from "../../../lib/clipboard" function summarizeTodos(todos: TodoViewItem[]) { return todos.reduce( @@ -82,17 +52,18 @@ interface TodoListViewProps { export function TodoListView(props: TodoListViewProps) { const { t } = useI18n() - const todos = extractTodosFromState(props.state) - const counts = summarizeTodos(todos) + const allTodos = extractTodosFromState(props.state) + const todos = getRenderedTodos(allTodos) + const counts = summarizeTodos(allTodos) - if (counts.total === 0) { + if (counts.total === 0 && !todos.truncated) { return
{props.emptyLabel ?? t("toolCall.renderer.todo.empty")}
} return (
- + {(todo) => { const label = getTodoStatusLabel(t, todo.status) return ( @@ -120,20 +91,20 @@ export function TodoListView(props: TodoListViewProps) { }}
+ +
+ {t("toolCall.output.truncated")} + +
+
) } export function getTodoTitle(state?: ToolState): string { - if (!state) return tGlobal("toolCall.renderer.todo.title.plan") - - const todos = extractTodosFromState(state) - if (state.status !== "completed" || todos.length === 0) return tGlobal("toolCall.renderer.todo.title.plan") - - const counts = summarizeTodos(todos) - if (counts.pending === counts.total) return tGlobal("toolCall.renderer.todo.title.creating") - if (counts.completed === counts.total) return tGlobal("toolCall.renderer.todo.title.completing") - return tGlobal("toolCall.renderer.todo.title.updating") + return tGlobal(`toolCall.renderer.todo.title.${getTodoTitleKind(state)}`) } export const todoRenderer: ToolRenderer = { @@ -143,6 +114,10 @@ export const todoRenderer: ToolRenderer = { getTitle({ toolState }) { return getTodoTitle(toolState()) }, + getOutputChrome({ toolState }) { + const state = toolState() + return hasTodoCopyText(state) ? { getCopyText: () => getTodoCopyText(state), hasCopyText: true } : undefined + }, renderBody({ toolState }) { const state = toolState() if (!state) return null diff --git a/packages/ui/src/components/tool-call/renderers/webfetch.tsx b/packages/ui/src/components/tool-call/renderers/webfetch.tsx index 59aa5645a..a0f7b0536 100644 --- a/packages/ui/src/components/tool-call/renderers/webfetch.tsx +++ b/packages/ui/src/components/tool-call/renderers/webfetch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWebfetchToolSearchText } from "../search-text" @@ -21,16 +21,14 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return undefined const { metadata } = readToolStatePayload(state) - const result = formatUnknown( - state.status === "completed" - ? state.output - : metadata.output, - ) - if (!result) return undefined + const output = state.status === "completed" ? state.output : metadata.output + if (output === undefined || output === null || output === "" || (Array.isArray(output) && output.length === 0)) return undefined + const result = formatUnknownForRender(output) return { - language: result.language ?? "text", - copyText: result.text, + language: result?.language ?? "text", + getCopyText: () => formatUnknownForCopy(output)?.text ?? null, + hasCopyText: true, wrapToggle: true, suppressInnerHeader: true, } @@ -40,7 +38,7 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return null const { metadata } = readToolStatePayload(state) - const result = formatUnknown( + const result = formatUnknownForRender( state.status === "completed" ? state.output : metadata.output, diff --git a/packages/ui/src/components/tool-call/renderers/write.tsx b/packages/ui/src/components/tool-call/renderers/write.tsx index 9a7f3d540..486c69e73 100644 --- a/packages/ui/src/components/tool-call/renderers/write.tsx +++ b/packages/ui/src/components/tool-call/renderers/write.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWriteToolSearchText } from "../search-text" @@ -24,7 +24,7 @@ export const writeRenderer: ToolRenderer = { const filePath = typeof input.filePath === "string" ? input.filePath : undefined return { language: inferLanguageFromPath(filePath) ?? "text", - copyText: contentValue, + getCopyText: () => contentValue, wrapToggle: true, suppressInnerHeader: true, } @@ -35,7 +35,7 @@ export const writeRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const contentValue = typeof input.content === "string" ? input.content : metadata.content const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true) + const content = ensureMarkdownContent(typeof contentValue === "string" ? limitToolOutputForRender(contentValue) : null, inferLanguageFromPath(filePath), true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index d9f4baebe..0997d4dcd 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -6,6 +6,7 @@ export type ToolCallPart = Extract export interface DiffPayload { diffText: string + copyText?: string filePath?: string } @@ -37,6 +38,7 @@ export interface DiffRenderOptions { variant?: string disableScrollTracking?: boolean label?: string + onFullDiffAccess?: (diffText: string) => void /** * Optional cache key suffix to avoid collisions when rendering multiple diffs * within the same tool call (e.g. apply_patch). @@ -103,6 +105,8 @@ export interface ToolOutputChrome { title?: string language?: string copyText?: string | null + getCopyText?: () => string | null + hasCopyText?: boolean actions?: JSXElement wrapToggle?: boolean suppressInnerHeader?: boolean diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 29ca0dc3d..2862fd3f4 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -4,12 +4,40 @@ import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } import type { DiffPayload } from "./types" import { getLogger } from "../../lib/logger" import { tGlobal } from "../../lib/i18n" +import { exceedsRetainedByteLimit } from "../../lib/retained-size" const log = getLogger("session") export type { ToolStateCompleted, ToolStateError, ToolStateRunning } export const diffCapableTools = new Set(["edit", "patch"]) +export const TOOL_OUTPUT_RENDER_CHARACTER_LIMIT = 10_000 +export const TOOL_TITLE_RENDER_CHARACTER_LIMIT = 384 +export const MESSAGE_PART_RENDER_LIMIT = 200 + +export function getItemsForRender(items: readonly T[], limit: number) { + return { parts: items.slice(0, limit), truncated: items.length > limit } +} + +export function limitToolOutputForRender(text: string): string { + if (text.length <= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) return text + const suffix = `\n\n${tGlobal("toolCall.output.truncated")}` + return `${text.slice(0, Math.max(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - suffix.length))}${suffix}` +} + +export function shouldRenderDiffAsPlainText(text: string): boolean { + return text.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT +} + +export function shouldRenderDiffPayloadAsPlainText(payload: DiffPayload): boolean { + return shouldRenderDiffAsPlainText(payload.diffText) + || (payload.copyText?.length ?? 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT +} + +export function limitToolTitleForRender(text: string): string { + if (text.length <= TOOL_TITLE_RENDER_CHARACTER_LIMIT) return text + return `${text.slice(0, TOOL_TITLE_RENDER_CHARACTER_LIMIT - 3)}...` +} export function isToolStateRunning(state: ToolState): state is ToolStateRunning { return state.status === "running" @@ -148,6 +176,41 @@ export function formatUnknown(value: unknown): { text: string; language?: string return null } +export function formatUnknownForRender(value: unknown): { text: string; language?: string } | null { + if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { text: tGlobal("toolCall.output.tooLarge") } + } + const result = formatUnknown(value) + return result ? { ...result, text: limitToolOutputForRender(result.text) } : null +} + +export function formatToolInputForCopy(input: unknown): { text: string; language?: string } | null { + try { + const text = JSON.stringify(input, null, 2) + return typeof text === "string" ? { text, language: "json" } : null + } catch (error) { + log.error("Failed to stringify tool call input", error) + return null + } +} + +export function formatToolInputForRender(input: unknown): { text: string; language?: string } | null { + if (typeof input !== "string" && exceedsRetainedByteLimit(input, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { text: JSON.stringify(tGlobal("toolCall.output.tooLarge")), language: "json" } + } + const formatted = formatToolInputForCopy(input) + return formatted ? { ...formatted, text: limitToolOutputForRender(formatted.text) } : null +} + +export function formatUnknownForCopy(value: unknown): { text: string; language?: string } | null { + try { + return formatUnknown(value) + } catch (error) { + log.error("Failed to format tool call output for copy", error) + return { text: tGlobal("toolCall.output.tooLarge") } + } +} + export function inferLanguageFromPath(path?: string): string | undefined { return getLanguageFromPath(path || "") } @@ -161,7 +224,11 @@ export function extractDiffPayload(toolName: string, state?: ToolState): DiffPay let diffText: string | null = null for (const candidate of candidates) { - if (typeof candidate === "string" && isRenderableDiffText(candidate)) { + if (typeof candidate !== "string") continue + const renderable = candidate.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + ? /(^|\n)@@/.test(candidate.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) + : isRenderableDiffText(candidate) + if (renderable) { diffText = candidate break } @@ -234,13 +301,14 @@ export function buildToolSpeechText(options: { }): string { const sections: string[] = [] - if (options.title.trim()) { - sections.push(options.title.trim()) + const title = limitToolOutputForRender(options.title).trim() + if (title) { + sections.push(title) } const { input, output } = readToolStatePayload(options.state) - const formattedInput = formatUnknown(input) - const formattedOutput = formatUnknown(output) + const formattedInput = formatUnknownForRender(input) + const formattedOutput = formatUnknownForRender(output) if (formattedInput?.text?.trim()) { sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`) @@ -250,13 +318,14 @@ export function buildToolSpeechText(options: { sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`) } - if (options.state?.status === "error" && options.state.error?.trim()) { - sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`) + const error = options.state?.status === "error" ? limitToolOutputForRender(options.state.error ?? "").trim() : "" + if (error) { + sections.push(`${options.t("toolCall.error.label")} ${error}`) } if (sections.length === 1 && options.state?.status === "pending") { sections.push(options.t("toolCall.pending.waitingToRun")) } - return sections.join("\n\n").trim() + return limitToolOutputForRender(sections.join("\n\n").trim()) } diff --git a/packages/ui/src/lib/global-cache.test.ts b/packages/ui/src/lib/global-cache.test.ts new file mode 100644 index 000000000..70c2b5e1d --- /dev/null +++ b/packages/ui/src/lib/global-cache.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { captureCacheAuthority, clearCacheForInstance, clearCacheForSession, getCacheEntry, onCacheSessionChanged, setCacheEntry } from "./global-cache.ts" + +test("rejects stale writes and evicts the globally oldest cache entries", () => { + const entry = { instanceId: "cache", sessionId: "stale", scope: "render", cacheId: "value", version: "1" } + const authority = captureCacheAuthority(entry) + const changed: string[] = [] + const stop = onCacheSessionChanged((instanceId, sessionId) => changed.push(`${instanceId}/${sessionId}`)) + try { + clearCacheForSession(entry.instanceId, entry.sessionId) + setCacheEntry(entry, "stale", authority) + assert.equal(getCacheEntry(entry), undefined) + + for (let index = 0; index < 11; index += 1) { + setCacheEntry({ ...entry, sessionId: `session-${index}` }, "x".repeat(1_500_000)) + } + changed.length = 0 + setCacheEntry({ ...entry, sessionId: "trigger" }, "x".repeat(1_500_000)) + assert.ok(changed.includes("cache/session-0")) + assert.equal(getCacheEntry({ ...entry, sessionId: "session-0" }), undefined) + assert.notEqual(getCacheEntry({ ...entry, sessionId: "session-10" }), undefined) + } finally { + stop() + clearCacheForInstance(entry.instanceId) + } +}) + +test("does not double-subtract an entry while replacing it", () => { + const entry = { instanceId: "replace", scope: "render", cacheId: "value", version: "1" } + try { + for (let index = 0; index < 15; index += 1) { + setCacheEntry({ ...entry, sessionId: `session-${index}` }, "x".repeat(1_060_000)) + } + setCacheEntry({ ...entry, sessionId: "session-0" }, "x".repeat(2_000_000)) + assert.equal(getCacheEntry({ ...entry, sessionId: "session-1" }), undefined) + } finally { + clearCacheForInstance(entry.instanceId) + } +}) diff --git a/packages/ui/src/lib/global-cache.ts b/packages/ui/src/lib/global-cache.ts index 1a5cb2575..fbdaf0627 100644 --- a/packages/ui/src/lib/global-cache.ts +++ b/packages/ui/src/lib/global-cache.ts @@ -1,3 +1,5 @@ +import { estimateRetainedBytes } from "./retained-size" + export interface CacheEntryBaseParams { instanceId?: string sessionId?: string @@ -9,9 +11,22 @@ export interface CacheEntryParams extends CacheEntryBaseParams { version: string } +export interface CacheAuthority { + instanceKey: string + sessionKey: string + scope: string + cacheId: string + version: string + generation: number + writeToken: number +} + type VersionedCacheEntry = { version: string value: unknown + byteSize: number + keyBytes: number + accessedAt: number } type CacheValueMap = Map @@ -19,12 +34,105 @@ type CacheScopeMap = Map type CacheSessionMap = Map const GLOBAL_KEY = "GLOBAL" +const MAX_SCOPE_CACHE_ENTRIES = 64 +const MAX_CACHE_ENTRY_BYTES = 4 * 1024 * 1024 +const MAX_GLOBAL_CACHE_BYTES = 32 * 1024 * 1024 +const MAX_GLOBAL_CACHE_ENTRIES = 4_096 const cacheStore = new Map() +let cacheGeneration = 0 +let writeSequence = 0 +const pendingWrites = new Map() +const pendingAuthorityByParams = new WeakMap() +const cacheSessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>() +let retainedBytes = 0 +let retainedEntries = 0 +let accessSequence = 0 + +function recalculateRetainedSize(): void { + retainedBytes = 0 + retainedEntries = 0 + for (const sessionMap of cacheStore.values()) { + for (const scopeMap of sessionMap.values()) { + for (const valueMap of scopeMap.values()) { + for (const entry of valueMap.values()) { + retainedBytes += entry.byteSize + retainedEntries += 1 + } + } + } + } +} function resolveKey(value?: string) { return value && value.length > 0 ? value : GLOBAL_KEY } +function writeKey(instanceKey: string, sessionKey: string, scope: string, cacheId: string): string { + return `${instanceKey}\u0000${sessionKey}\u0000${scope}\u0000${cacheId}` +} + +function invalidateAllPendingWrites(): void { + cacheGeneration += 1 + pendingWrites.clear() +} + +function invalidatePendingWrite(instanceKey: string, sessionKey: string, scope: string, cacheId: string): void { + pendingWrites.delete(writeKey(instanceKey, sessionKey, scope, cacheId)) +} + +function notifyCacheSessionChanged(params: CacheEntryBaseParams): void { + if (!params.instanceId || !params.sessionId) return + for (const handler of cacheSessionChangeHandlers) handler(params.instanceId, params.sessionId) +} + +export function onCacheSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void { + cacheSessionChangeHandlers.add(handler) + return () => cacheSessionChangeHandlers.delete(handler) +} + +export function* getCacheRetainedEntriesForSession(instanceId: string, sessionId: string): Generator<{ value: unknown; keyBytes: number }> { + const scopeMap = cacheStore.get(resolveKey(instanceId))?.get(resolveKey(sessionId)) + if (!scopeMap) return + for (const valueMap of scopeMap.values()) { + for (const entry of valueMap.values()) yield { value: entry.value, keyBytes: entry.keyBytes } + } +} + +export function captureCacheAuthority(params: CacheEntryParams): CacheAuthority { + const instanceKey = resolveKey(params.instanceId) + const sessionKey = resolveKey(params.sessionId) + const scopePrefix = `${instanceKey}\u0000${sessionKey}\u0000${params.scope}\u0000` + const currentKey = writeKey(instanceKey, sessionKey, params.scope, params.cacheId) + const scopeWrites = [...pendingWrites.keys()].filter((key) => key.startsWith(scopePrefix) && key !== currentKey) + if (scopeWrites.length >= MAX_SCOPE_CACHE_ENTRIES) pendingWrites.delete(scopeWrites[0]!) + if (pendingWrites.size >= MAX_GLOBAL_CACHE_ENTRIES && !pendingWrites.has(currentKey)) invalidateAllPendingWrites() + const writeToken = ++writeSequence + pendingWrites.set(currentKey, writeToken) + const authority = { + instanceKey, + sessionKey, + scope: params.scope, + cacheId: params.cacheId, + version: params.version, + generation: cacheGeneration, + writeToken, + } + pendingAuthorityByParams.set(params, authority) + return authority +} + +function hasCacheAuthority(params: CacheEntryParams, authority: CacheAuthority): boolean { + const instanceKey = resolveKey(params.instanceId) + const sessionKey = resolveKey(params.sessionId) + return instanceKey === authority.instanceKey + && sessionKey === authority.sessionKey + && params.scope === authority.scope + && params.cacheId === authority.cacheId + && params.version === authority.version + && cacheGeneration === authority.generation + && pendingWrites.get(writeKey(instanceKey, sessionKey, params.scope, params.cacheId)) === authority.writeToken +} + function getScopeValueMap(params: CacheEntryParams, create: boolean): CacheValueMap | undefined { const instanceKey = resolveKey(params.instanceId) const sessionKey = resolveKey(params.sessionId) @@ -83,54 +191,127 @@ function cleanupHierarchy(instanceKey: string, sessionKey: string, scopeKey?: st } } -export function setCacheEntry(params: CacheEntryParams, value: T | undefined): void { +function evictOldestCacheEntry(): boolean { + let oldest: { instanceKey: string; sessionKey: string; scope: string; cacheId: string; entry: VersionedCacheEntry } | undefined + for (const [instanceKey, sessionMap] of cacheStore) { + for (const [sessionKey, scopeMap] of sessionMap) { + for (const [scope, valueMap] of scopeMap) { + for (const [cacheId, entry] of valueMap) { + if (!oldest || entry.accessedAt < oldest.entry.accessedAt) oldest = { instanceKey, sessionKey, scope, cacheId, entry } + } + } + } + } + if (!oldest) return false + cacheStore.get(oldest.instanceKey)?.get(oldest.sessionKey)?.get(oldest.scope)?.delete(oldest.cacheId) + retainedBytes -= oldest.entry.byteSize + retainedEntries -= 1 + invalidatePendingWrite(oldest.instanceKey, oldest.sessionKey, oldest.scope, oldest.cacheId) + cleanupHierarchy(oldest.instanceKey, oldest.sessionKey, oldest.scope) + if (oldest.instanceKey !== GLOBAL_KEY && oldest.sessionKey !== GLOBAL_KEY) { + notifyCacheSessionChanged({ instanceId: oldest.instanceKey, sessionId: oldest.sessionKey, scope: oldest.scope }) + } + return true +} + +export function setCacheEntry(params: CacheEntryParams, value: T | undefined, authority?: CacheAuthority): void { const instanceKey = resolveKey(params.instanceId) const sessionKey = resolveKey(params.sessionId) + const resolvedAuthority = authority ?? pendingAuthorityByParams.get(params) + pendingAuthorityByParams.delete(params) + if (resolvedAuthority && !hasCacheAuthority(params, resolvedAuthority)) return + invalidatePendingWrite(instanceKey, sessionKey, params.scope, params.cacheId) if (value === undefined) { const existingMap = getScopeValueMap(params, false) + const existing = existingMap?.get(params.cacheId) + retainedBytes -= existing?.byteSize ?? 0 + if (existing) retainedEntries -= 1 existingMap?.delete(params.cacheId) cleanupHierarchy(instanceKey, sessionKey, params.scope) + if (existing) notifyCacheSessionChanged(params) return } - const scopeEntries = getScopeValueMap(params, true) - scopeEntries?.set(params.cacheId, { version: params.version, value }) + const scopeEntries = getScopeValueMap(params, false) + const existing = scopeEntries?.get(params.cacheId) + retainedBytes -= existing?.byteSize ?? 0 + if (existing) retainedEntries -= 1 + scopeEntries?.delete(params.cacheId) + const keyBytes = [params.instanceId, params.sessionId, params.scope, params.cacheId, params.version] + .reduce((total, part) => total + (part?.length ?? 0) * 2 + 16, 0) + const byteSize = estimateRetainedBytes(value, MAX_CACHE_ENTRY_BYTES) + keyBytes + if (byteSize > MAX_CACHE_ENTRY_BYTES) { + scopeEntries?.delete(params.cacheId) + cleanupHierarchy(instanceKey, sessionKey, params.scope) + if (existing) notifyCacheSessionChanged(params) + return + } + while (retainedBytes + byteSize > MAX_GLOBAL_CACHE_BYTES || retainedEntries >= MAX_GLOBAL_CACHE_ENTRIES) { + if (!evictOldestCacheEntry()) break + } + const target = getScopeValueMap(params, true) + if (!target) return + target.delete(params.cacheId) + target.set(params.cacheId, { version: params.version, value, byteSize, keyBytes, accessedAt: ++accessSequence }) + retainedBytes += byteSize + retainedEntries += 1 + while (target.size > MAX_SCOPE_CACHE_ENTRIES) { + const oldest = target.keys().next().value + if (oldest === undefined) break + retainedBytes -= target.get(oldest)?.byteSize ?? 0 + target.delete(oldest) + retainedEntries -= 1 + invalidatePendingWrite(instanceKey, sessionKey, params.scope, oldest) + } + notifyCacheSessionChanged(params) } export function getCacheEntry(params: CacheEntryParams): T | undefined { const scopeEntries = getScopeValueMap(params, false) const entry = scopeEntries?.get(params.cacheId) if (!entry || entry.version !== params.version) { + captureCacheAuthority(params) return undefined } + invalidatePendingWrite(resolveKey(params.instanceId), resolveKey(params.sessionId), params.scope, params.cacheId) + scopeEntries!.delete(params.cacheId) + entry.accessedAt = ++accessSequence + scopeEntries!.set(params.cacheId, entry) + captureCacheAuthority(params) return entry.value as T } export function clearCacheScope(params: CacheEntryBaseParams): void { const instanceKey = resolveKey(params.instanceId) const sessionKey = resolveKey(params.sessionId) + invalidateAllPendingWrites() const sessionMap = cacheStore.get(instanceKey) if (!sessionMap) return const scopeMap = sessionMap.get(sessionKey) if (!scopeMap) return scopeMap.delete(params.scope) cleanupHierarchy(instanceKey, sessionKey) + recalculateRetainedSize() } export function clearCacheForSession(instanceId?: string, sessionId?: string): void { const instanceKey = resolveKey(instanceId) const sessionKey = resolveKey(sessionId) + invalidateAllPendingWrites() const sessionMap = cacheStore.get(instanceKey) if (!sessionMap) return sessionMap.delete(sessionKey) if (sessionMap.size === 0) { cacheStore.delete(instanceKey) } + recalculateRetainedSize() } export function clearCacheForInstance(instanceId?: string): void { const instanceKey = resolveKey(instanceId) + invalidateAllPendingWrites() cacheStore.delete(instanceKey) + recalculateRetainedSize() } diff --git a/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts b/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts index 08ac16a00..166cd2d66 100644 --- a/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts +++ b/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts @@ -116,4 +116,5 @@ describe("useActiveSessionMessageLoad", () => { dispose() } }) + }) diff --git a/packages/ui/src/lib/hooks/use-active-session-message-load.ts b/packages/ui/src/lib/hooks/use-active-session-message-load.ts index 05fc98471..75902c670 100644 --- a/packages/ui/src/lib/hooks/use-active-session-message-load.ts +++ b/packages/ui/src/lib/hooks/use-active-session-message-load.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" /** * Dependencies for {@link useActiveSessionMessageLoad}. Everything is injected @@ -13,8 +13,14 @@ export interface ActiveSessionMessageLoadDeps { instanceId: () => string /** The current session object (or undefined). Read reactively. */ session: () => { id: string } | undefined + /** Optional reactive gate. Each false-to-true transition reloads the same session. */ + shouldLoad?: () => boolean /** Loads the messages for a session. */ - loadMessages: (instanceId: string, sessionId: string) => Promise | void + loadMessages: ( + instanceId: string, + sessionId: string, + options?: { registerInvalidation?: (invalidate: () => void) => void }, + ) => Promise | void /** Resolves once the instance's workspace metadata has hydrated. */ waitForHydration: (instanceId: string) => Promise /** Optional error sink for a rejected load. */ @@ -38,17 +44,44 @@ export interface ActiveSessionMessageLoadDeps { * preserving load-on-activation and load-on-switch behavior. */ export function useActiveSessionMessageLoad(deps: ActiveSessionMessageLoadDeps): void { - const activeSessionId = createMemo(() => (deps.isActive() ? (deps.session()?.id ?? null) : null)) + const activeBinding = createMemo(() => { + const sessionId = deps.isActive() ? deps.session()?.id : undefined + return sessionId ? `${deps.instanceId()}\u0000${sessionId}` : null + }) + const [reloadVersion, setReloadVersion] = createSignal(0) + let previousLoadBinding: string | null = null + createEffect(() => { + if (!deps.shouldLoad) return + const binding = activeBinding() + const loadBinding = binding && deps.shouldLoad() ? binding : null + if (loadBinding && loadBinding !== previousLoadBinding) setReloadVersion((value) => value + 1) + previousLoadBinding = loadBinding + }) + createEffect(() => { - const sessionId = activeSessionId() - if (!sessionId) return - const instanceId = deps.instanceId() + const binding = activeBinding() + if (deps.shouldLoad) { + reloadVersion() + if (!untrack(deps.shouldLoad)) return + } + if (!binding) return + const [instanceId, sessionId] = binding.split("\u0000") + let invalidate = () => {} + let cancelled = false + let pending = false + onCleanup(() => { + cancelled = true + if (pending) invalidate() + }) void Promise.resolve(deps.waitForHydration(instanceId)) .then(() => { // Re-check after the async gate: the user may have switched away or to // a different session while metadata was hydrating. - if (!deps.isActive() || deps.session()?.id !== sessionId) return - return deps.loadMessages(instanceId, sessionId) + if (cancelled || !deps.isActive() || deps.instanceId() !== instanceId || deps.session()?.id !== sessionId) return + pending = true + return Promise.resolve(deps.loadMessages(instanceId, sessionId, { + registerInvalidation: (next) => { invalidate = next }, + })).finally(() => { pending = false }) }) .catch((error) => deps.onError?.(error)) }) diff --git a/packages/ui/src/lib/hooks/use-global-cache.ts b/packages/ui/src/lib/hooks/use-global-cache.ts index b3ba1aae4..fe9eebbf8 100644 --- a/packages/ui/src/lib/hooks/use-global-cache.ts +++ b/packages/ui/src/lib/hooks/use-global-cache.ts @@ -1,6 +1,8 @@ import { type Accessor, createMemo } from "solid-js" import { + type CacheAuthority, type CacheEntryParams, + captureCacheAuthority, getCacheEntry, setCacheEntry, clearCacheScope, @@ -14,6 +16,7 @@ import { * automatically fall back to the global buckets. */ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle { + let pendingAuthority: CacheAuthority | undefined const resolvedEntry = createMemo(() => { const instanceId = normalizeId(resolveValue(params.instanceId)) const sessionId = normalizeId(resolveValue(params.sessionId)) @@ -35,10 +38,12 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle return { get() { - return getCacheEntry(resolvedEntry()) + const entry = resolvedEntry() + return getCacheEntry(entry) }, - set(value: T | undefined) { - setCacheEntry(resolvedEntry(), value) + set(value: T | undefined, authority?: CacheAuthority) { + setCacheEntry(resolvedEntry(), value, authority ?? pendingAuthority) + pendingAuthority = undefined }, clearScope() { clearCacheScope(scopeParams()) @@ -54,6 +59,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle params() { return resolvedEntry() }, + authority() { + return pendingAuthority = captureCacheAuthority(resolvedEntry()) + }, } } @@ -80,9 +88,10 @@ interface UseGlobalCacheParams { interface GlobalCacheHandle { get(): T | undefined - set(value: T | undefined): void + set(value: T | undefined, authority?: CacheAuthority): void clearScope(): void clearSession(): void clearInstance(): void params(): CacheEntryParams + authority(): CacheAuthority } diff --git a/packages/ui/src/lib/hooks/use-message-window-paging.ts b/packages/ui/src/lib/hooks/use-message-window-paging.ts new file mode 100644 index 000000000..94921d753 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-message-window-paging.ts @@ -0,0 +1,92 @@ +import { createEffect, onCleanup, type Accessor } from "solid-js" +import type { VirtualFollowListApi, VirtualFollowScrollSnapshot } from "../../components/virtual-follow-list" +import type { InstanceMessageStore } from "../../stores/message-v2/instance-store" +import { loadNewerMessages, loadOlderMessages } from "../../stores/session-api" + +export function useMessageWindowPaging(options: { + instanceId: Accessor + sessionId: Accessor + isActive: Accessor + store: Accessor + api: Accessor + element: Accessor +}): void { + let loading = false + + const load = (direction: "older" | "newer") => { + if (loading || !options.isActive()) return + const window = options.store().getMessageWindow(options.sessionId()) + if (!window || (direction === "older" ? !window.olderCursor : window.cursor === undefined)) return + + const instanceId = options.instanceId() + const sessionId = options.sessionId() + if (options.store().hasPendingSends(sessionId)) return + const api = options.api() + const followSnapshot = api?.captureScrollSnapshot() + loading = true + const request = direction === "older" ? loadOlderMessages(instanceId, sessionId) : loadNewerMessages(instanceId, sessionId) + void request.then((committed) => { + if (!committed || !options.isActive() || options.instanceId() !== instanceId || options.sessionId() !== sessionId) { + loading = false + return + } + if (!api || !followSnapshot) { + loading = false + return + } + const snapshot: VirtualFollowScrollSnapshot = direction === "older" + ? { scrollTop: Number.MAX_SAFE_INTEGER, atBottom: true, followModeType: followSnapshot.followModeType } + : { scrollTop: 0, atBottom: false, followModeType: followSnapshot.followModeType } + api.restoreScrollSnapshot(snapshot, { + behavior: "auto", + onApplied: () => { loading = false }, + onCancelled: () => { loading = false }, + fallback: () => { loading = false }, + }) + }).catch(() => { loading = false }) + } + + createEffect(() => { + const element = options.element() + void options.sessionId() + if (!element || !options.isActive()) return + loading = false + let previousTop = element.scrollTop + let lastTouchY: number | null = null + const atTop = () => element.scrollTop <= 1 + const atBottom = () => element.scrollHeight - element.scrollTop - element.clientHeight <= 1 + const handleScroll = () => { + const nextTop = element.scrollTop + if (nextTop < previousTop && atTop()) load("older") + else if (nextTop > previousTop && atBottom()) load("newer") + previousTop = nextTop + } + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && atTop()) load("older") + else if (event.deltaY > 0 && atBottom()) load("newer") + } + const handleTouchStart = (event: TouchEvent) => { lastTouchY = event.touches[0]?.clientY ?? null } + const handleTouchMove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY ?? null + if (nextY !== null && lastTouchY !== null) { + if (nextY > lastTouchY && atTop()) load("older") + else if (nextY < lastTouchY && atBottom()) load("newer") + } + lastTouchY = nextY + } + const handleTouchEnd = () => { lastTouchY = null } + element.addEventListener("scroll", handleScroll, { passive: true }) + element.addEventListener("wheel", handleWheel, { passive: true }) + element.addEventListener("touchstart", handleTouchStart, { passive: true }) + element.addEventListener("touchmove", handleTouchMove, { passive: true }) + element.addEventListener("touchend", handleTouchEnd, { passive: true }) + onCleanup(() => { + element.removeEventListener("scroll", handleScroll) + element.removeEventListener("wheel", handleWheel) + element.removeEventListener("touchstart", handleTouchStart) + element.removeEventListener("touchmove", handleTouchMove) + element.removeEventListener("touchend", handleTouchEnd) + loading = false + }) + }) +} diff --git a/packages/ui/src/lib/i18n/messages/de/toolCall.ts b/packages/ui/src/lib/i18n/messages/de/toolCall.ts index 33e42ec6f..11852db3f 100644 --- a/packages/ui/src/lib/i18n/messages/de/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/de/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Verzeichnis wird aufgelistet...", "toolCall.renderer.bash.title.timeout": "Zeitüberschreitung: {timeout}", + "toolCall.output.truncated": "[Ausgabe für die Darstellung gekürzt; kopieren Sie sie für die vollständige Ausgabe]", + "toolCall.output.tooLarge": "Die strukturierte Ausgabe wird nicht dargestellt, da sie zu groß ist.", + "toolCall.task.steps.truncated": "Die neuesten {count} Schritte werden angezeigt; ältere Schritte wurden ausgelassen.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 3bb657c01..c929ce5ad 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listing directory...", "toolCall.renderer.bash.title.timeout": "Timeout: {timeout}", + "toolCall.output.truncated": "[Output truncated for rendering; copy to access the full output]", + "toolCall.output.tooLarge": "Structured output omitted from rendering because it is too large.", + "toolCall.task.steps.truncated": "Showing the most recent {count} steps; older steps are omitted.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index a04eb7b10..1b5959f5a 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listando directorio...", "toolCall.renderer.bash.title.timeout": "Tiempo de espera: {timeout}", + "toolCall.output.truncated": "[Salida truncada para la visualización; cópiala para acceder a la salida completa]", + "toolCall.output.tooLarge": "La salida estructurada no se muestra porque es demasiado grande.", + "toolCall.task.steps.truncated": "Se muestran los {count} pasos más recientes; se omiten los anteriores.", "toolCall.renderer.read.detail.offset": "Desplazamiento: {offset}", "toolCall.renderer.read.detail.limit": "Límite: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index 3e8ff28c9..42b832ebc 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Liste du répertoire...", "toolCall.renderer.bash.title.timeout": "Délai : {timeout}", + "toolCall.output.truncated": "[Sortie tronquée pour l’affichage ; copiez-la pour accéder à la sortie complète]", + "toolCall.output.tooLarge": "Sortie structurée omise de l’affichage car elle est trop volumineuse.", + "toolCall.task.steps.truncated": "Affichage des {count} étapes les plus récentes ; les étapes antérieures sont omises.", "toolCall.renderer.read.detail.offset": "Décalage : {offset}", "toolCall.renderer.read.detail.limit": "Limite : {limit}", diff --git a/packages/ui/src/lib/i18n/messages/he/toolCall.ts b/packages/ui/src/lib/i18n/messages/he/toolCall.ts index f282f619f..ed084ab36 100644 --- a/packages/ui/src/lib/i18n/messages/he/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/he/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "מפרט ספרייה...", "toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}", + "toolCall.output.truncated": "[הפלט קוצר לצורך תצוגה; יש להעתיק כדי לגשת לפלט המלא]", + "toolCall.output.tooLarge": "הפלט המובנה לא מוצג מכיוון שהוא גדול מדי.", + "toolCall.task.steps.truncated": "מוצגים {count} השלבים האחרונים; שלבים קודמים הושמטו.", "toolCall.renderer.read.detail.offset": "היסט: {offset}", "toolCall.renderer.read.detail.limit": "מגבלה: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 2bcc9c078..f37b580a7 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "ディレクトリ一覧を取得中...", "toolCall.renderer.bash.title.timeout": "タイムアウト: {timeout}", + "toolCall.output.truncated": "[表示用に出力を省略しました。完全な出力にアクセスするにはコピーしてください]", + "toolCall.output.tooLarge": "構造化出力が大きすぎるため表示を省略しました。", + "toolCall.task.steps.truncated": "最新の{count}件の手順を表示しています。以前の手順は省略されています。", "toolCall.renderer.read.detail.offset": "オフセット: {offset}", "toolCall.renderer.read.detail.limit": "上限: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts index 2d6089fe2..f53cb2fd2 100644 --- a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "डाइरेक्टरी सूचीबद्ध गर्दै...", "toolCall.renderer.bash.title.timeout": "समय समाप्त: {timeout}", + "toolCall.output.truncated": "[प्रदर्शनका लागि आउटपुट छोट्याइएको छ; पूर्ण आउटपुटका लागि प्रतिलिपि गर्नुहोस्]", + "toolCall.output.tooLarge": "संरचित आउटपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।", + "toolCall.task.steps.truncated": "पछिल्ला {count} चरणहरू देखाइँदैछन्; पुराना चरणहरू हटाइएका छन्।", "toolCall.renderer.read.detail.offset": "अफसेट: {offset}", "toolCall.renderer.read.detail.limit": "सीमा: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index ddf2ddd06..af6fdb103 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Просмотр каталога…", "toolCall.renderer.bash.title.timeout": "Таймаут: {timeout}", + "toolCall.output.truncated": "[Вывод сокращён для отображения; скопируйте его для доступа к полному выводу]", + "toolCall.output.tooLarge": "Структурированный вывод не отображается, поскольку он слишком большой.", + "toolCall.task.steps.truncated": "Показаны последние {count} шагов; более ранние шаги опущены.", "toolCall.renderer.read.detail.offset": "Смещение: {offset}", "toolCall.renderer.read.detail.limit": "Лимит: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index 166ee30d9..dbda27479 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "正在列出目录...", "toolCall.renderer.bash.title.timeout": "超时:{timeout}", + "toolCall.output.truncated": "[输出已截断以便显示;复制即可访问完整输出]", + "toolCall.output.tooLarge": "结构化输出过大,已省略显示。", + "toolCall.task.steps.truncated": "正在显示最近的 {count} 个步骤;更早的步骤已省略。", "toolCall.renderer.read.detail.offset": "偏移:{offset}", "toolCall.renderer.read.detail.limit": "限制:{limit}", diff --git a/packages/ui/src/lib/message-render-cache.ts b/packages/ui/src/lib/message-render-cache.ts new file mode 100644 index 000000000..a33303874 --- /dev/null +++ b/packages/ui/src/lib/message-render-cache.ts @@ -0,0 +1,130 @@ +interface MessageCacheItem { + messageId: string +} + +export interface MessageRenderCache { + messageItems: Map + toolItems: Map + messageBlocks: Map +} + +const renderCaches = new Map() + +export const REASONING_RENDER_CHARACTER_LIMIT = 10_000 +export const REASONING_RENDER_NODE_LIMIT = 1_000 +export const REASONING_TITLE_CHARACTER_LIMIT = 384 + +interface TraversalCursor { + array: unknown[] + index: number +} + +function isTraversalCursor(item: unknown): item is TraversalCursor { + if (!item || typeof item !== "object") return false + const cursor = item as Partial + return Array.isArray(cursor.array) && typeof cursor.index === "number" +} + +function extractReasoningSource(source: unknown, characterLimit: number, nodeLimit: number): string { + const pieces: string[] = [] + const stack: unknown[] = [source] + const seen = new WeakSet() + let characters = 0 + let visited = 0 + + while (stack.length > 0 && characters < characterLimit && visited < nodeLimit) { + const item = stack.pop() + visited += 1 + + if (isTraversalCursor(item)) { + if (item.index >= item.array.length) continue + stack.push({ array: item.array, index: item.index + 1 }, item.array[item.index]) + continue + } + + if (typeof item === "string") { + const separatorLength = pieces.length > 0 ? 1 : 0 + const available = characterLimit - characters - separatorLength + if (available <= 0) break + const candidate = item.slice(0, available) + if (/\S/.test(candidate)) { + pieces.push(candidate) + characters += candidate.length + separatorLength + } + if (candidate.length < item.length) break + continue + } + + if (!item || typeof item !== "object" || seen.has(item)) continue + seen.add(item) + + if (Array.isArray(item)) { + stack.push({ array: item, index: 0 }) + continue + } + + const segment = item as { text?: unknown; value?: unknown; content?: unknown } + stack.push(segment.content, segment.value, segment.text) + } + + return pieces.join("\n") +} + +function extractReasoningText(part: unknown, characterLimit: number, nodeLimit: number): string { + const reasoning = part as { text?: unknown; content?: unknown } | null + if (!reasoning || typeof reasoning !== "object") return "" + const text = extractReasoningSource(reasoning.text, characterLimit, nodeLimit) + return text || extractReasoningSource(reasoning.content, characterLimit, nodeLimit) +} + +export function extractReasoningTextForRender(part: unknown): string { + return extractReasoningText(part, REASONING_RENDER_CHARACTER_LIMIT, REASONING_RENDER_NODE_LIMIT) +} + +export function extractReasoningTextForCopy(part: unknown): string { + return extractReasoningText(part, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY) +} + +export function extractReasoningTitleForRender(text: string): string { + const bounded = text.slice(0, REASONING_TITLE_CHARACTER_LIMIT) + const firstLine = bounded.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? "" + return firstLine.match(/^\*\*([^*]+)\*\*/)?.[1]?.trim() ?? "" +} + +function makeSessionCacheKey(instanceId: string, sessionId: string) { + return `${instanceId}:${sessionId}` +} + +export function getSessionMessageRenderCache(instanceId: string, sessionId: string): MessageRenderCache { + const key = makeSessionCacheKey(instanceId, sessionId) + let cache = renderCaches.get(key) + if (!cache) { + cache = { messageItems: new Map(), toolItems: new Map(), messageBlocks: new Map() } + renderCaches.set(key, cache) + } + return cache +} + +export function peekSessionMessageRenderCache(instanceId: string, sessionId: string): MessageRenderCache | undefined { + return renderCaches.get(makeSessionCacheKey(instanceId, sessionId)) +} + +export function clearSessionMessageRenderCache(instanceId: string, sessionId: string): void { + renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) +} + +export function clearInstanceMessageRenderCaches(instanceId: string): void { + const prefix = `${instanceId}:` + for (const key of renderCaches.keys()) if (key.startsWith(prefix)) renderCaches.delete(key) +} + +export function purgeMessageRenderCache(cache: MessageRenderCache, messageIds: readonly string[]): void { + const removed = new Set(messageIds) + for (const messageId of removed) cache.messageBlocks.delete(messageId) + for (const [key, item] of cache.messageItems) { + if (removed.has(item.messageId)) cache.messageItems.delete(key) + } + for (const [key, item] of cache.toolItems) { + if (removed.has(item.messageId)) cache.toolItems.delete(key) + } +} diff --git a/packages/ui/src/lib/retained-size.test.ts b/packages/ui/src/lib/retained-size.test.ts new file mode 100644 index 000000000..6acb06a4b --- /dev/null +++ b/packages/ui/src/lib/retained-size.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "./retained-size.ts" + +test("handles shared buffers and aborts before traversing a lazy tail", async () => { + const buffer = new ArrayBuffer(64) + const references = [buffer, new Uint8Array(buffer), new DataView(buffer)] + assert.equal(estimateRetainedBytes(references), estimateRetainedBytes([null, null, null]) + buffer.byteLength) + + let accessed = false + Object.defineProperty(references, 3, { enumerable: true, get: () => { accessed = true; return "tail" } }) + references.length = 4 + const controller = new AbortController() + const measurement = estimateRetainedBytesIncrementally(references, { signal: controller.signal, yieldEvery: 1 }) + await Promise.resolve() + assert.equal(accessed, false) + controller.abort() + await assert.rejects(measurement, { name: "AbortError" }) +}) diff --git a/packages/ui/src/lib/retained-size.ts b/packages/ui/src/lib/retained-size.ts new file mode 100644 index 000000000..a7bb9f010 --- /dev/null +++ b/packages/ui/src/lib/retained-size.ts @@ -0,0 +1,158 @@ +const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get +const arrayBufferResizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable")?.get +const MAP_ENTRY_BYTES = 24 +const SET_ENTRY_BYTES = 16 + +type RetainedChild = { value: unknown; keyBytes: number } + +function bufferSize(value: object): { bytes: number; growable: boolean } | undefined { + try { + return { bytes: arrayBufferByteLength?.call(value) as number, growable: Boolean(arrayBufferResizable?.call(value)) } + } catch { + return undefined + } +} + +export function estimateRetainedBytes(value: unknown, limit = Number.POSITIVE_INFINITY): number { + const seen = new WeakSet() + const pending: unknown[] = [value] + const children: Iterator[] = [] + let total = 0 + while ((pending.length > 0 || children.length > 0) && total <= limit) { + if (pending.length === 0) { + const child = children[children.length - 1]!.next() + if (child.done) { + children.pop() + continue + } + total += child.value.keyBytes + if (total > limit) break + pending.push(child.value.value) + } + const current = pending.pop() + if (typeof current === "string") total += current.length * 2 + 16 + else if (typeof current === "number" || typeof current === "bigint") total += 8 + else if (typeof current === "boolean") total += 4 + else if (current && typeof current === "object") { + if (ArrayBuffer.isView(current)) { + if (seen.has(current.buffer)) continue + seen.add(current.buffer) + const backing = bufferSize(current.buffer as object) + total += backing?.growable ? limit + 1 : backing?.bytes ?? current.byteLength + continue + } + if (seen.has(current)) continue + seen.add(current) + const bytes = bufferSize(current) + if (bytes !== undefined) { + total += bytes.growable ? limit + 1 : bytes.bytes + continue + } + total += Array.isArray(current) + ? 24 + current.length * 8 + : current instanceof Map + ? 32 + current.size * MAP_ENTRY_BYTES + : current instanceof Set + ? 32 + current.size * SET_ENTRY_BYTES + : 32 + if (total <= limit) children.push(objectChildren(current)) + } + } + return total +} + +export function exceedsRetainedByteLimit(value: unknown, limit: number): boolean { + return estimateRetainedBytes(value, limit) > limit +} + +export async function estimateRetainedBytesIncrementally( + value: unknown, + options: { + signal?: AbortSignal + yieldEvery?: number + rootIterable?: boolean + maxBytes?: number + maxNodes?: number + } = {}, +): Promise { + const seen = new WeakSet() + const pending: unknown[] = [] + const children: Iterator[] = [] + const roots = options.rootIterable ? (value as Iterable)[Symbol.iterator]() : undefined + if (!roots) pending.push(value) + const yieldEvery = Math.max(1, options.yieldEvery ?? 500) + const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY + const maxNodes = options.maxNodes ?? Number.POSITIVE_INFINITY + let processed = 0 + let total = 0 + + while (pending.length > 0 || children.length > 0 || roots) { + options.signal?.throwIfAborted() + if (pending.length === 0) { + const child = children[children.length - 1]?.next() + if (child && !child.done) { + total += child.value.keyBytes + if (total > maxBytes) return Number.POSITIVE_INFINITY + pending.push(child.value.value) + } else if (child) { + children.pop() + continue + } else { + const next = roots!.next() + if (next.done) break + pending.push(next.value) + } + } + if (++processed > maxNodes) return Number.POSITIVE_INFINITY + if (processed % yieldEvery === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)) + options.signal?.throwIfAborted() + } + const current = pending.pop() + if (typeof current === "string") total += current.length * 2 + 16 + else if (typeof current === "number" || typeof current === "bigint") total += 8 + else if (typeof current === "boolean") total += 4 + else if (current && typeof current === "object") { + if (ArrayBuffer.isView(current)) { + if (seen.has(current.buffer)) continue + seen.add(current.buffer) + const backing = bufferSize(current.buffer as object) + total += backing?.growable ? Number.POSITIVE_INFINITY : backing?.bytes ?? current.byteLength + continue + } + if (seen.has(current)) continue + seen.add(current) + const bytes = bufferSize(current) + if (bytes !== undefined) { + total += bytes.growable ? Number.POSITIVE_INFINITY : bytes.bytes + continue + } + total += Array.isArray(current) + ? 24 + current.length * 8 + : current instanceof Map + ? 32 + current.size * MAP_ENTRY_BYTES + : current instanceof Set + ? 32 + current.size * SET_ENTRY_BYTES + : 32 + children.push(objectChildren(current)) + } + if (total > maxBytes) return Number.POSITIVE_INFINITY + } + options.signal?.throwIfAborted() + return total +} + +function* objectChildren(current: object): Generator { + if (current instanceof Map) { + for (const [key, entry] of current) { + yield { value: key, keyBytes: 0 } + yield { value: entry, keyBytes: 0 } + } + } else if (current instanceof Set) { + for (const entry of current) yield { value: entry, keyBytes: 0 } + } + for (const key in current) { + if (!Object.prototype.hasOwnProperty.call(current, key)) continue + yield { value: (current as Record)[key], keyBytes: key.length * 2 + 8 } + } +} diff --git a/packages/ui/src/lib/session-transcript-lru.test.ts b/packages/ui/src/lib/session-transcript-lru.test.ts new file mode 100644 index 000000000..0ca2d8dc0 --- /dev/null +++ b/packages/ui/src/lib/session-transcript-lru.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { SessionTranscriptMeasurementQueue } from "./session-transcript-measurement.ts" +import { isSessionTranscriptProtected, SessionTranscriptLru, selectTranscriptEvictions, type TranscriptLruEntry } from "./session-transcript-lru.ts" + +const entry = (sessionId: string, bytes: number, lastUsed: number): TranscriptLruEntry => ({ + instanceId: "instance", sessionId, bytes, lastUsed, +}) + +test("measures, accounts, touches, and enforces transcript LRU order", async (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }) + const evicted: string[] = [] + const lru = new SessionTranscriptLru({ + byteBudget: 12, + isProtected: () => false, + evict: (_instanceId, sessionId) => evicted.push(sessionId), + }) + const queue = new SessionTranscriptMeasurementQueue({ + delayMs: 1, + measure: async () => 6, + account: (instanceId, sessionId, bytes) => lru.account(instanceId, sessionId, bytes), + onError: () => {}, + }) + + for (const sessionId of ["old", "new"]) { + lru.touch("instance", sessionId) + queue.schedule("instance", sessionId) + context.mock.timers.tick(1) + await Promise.resolve() + } + lru.touch("instance", "old") + queue.schedule("instance", "latest") + context.mock.timers.tick(1) + await Promise.resolve() + lru.enforce() + + assert.deepEqual(evicted, ["new"]) +}) + +test("protects visible, live, and form-blocked transcripts despite temporary budget overage", () => { + const entries = [entry("visible", 4, 1), entry("live", 4, 2), entry("form", 4, 3), entry("inactive", 4, 4)] + assert.deepEqual(selectTranscriptEvictions(entries, 1, ({ sessionId }) => isSessionTranscriptProtected( + sessionId === "visible" ? { visible: true } : sessionId === "live" ? { liveMessages: true } : sessionId === "form" ? { questionBlocked: true } : {}, + )).map(({ sessionId }) => sessionId), ["inactive"]) +}) diff --git a/packages/ui/src/lib/session-transcript-lru.ts b/packages/ui/src/lib/session-transcript-lru.ts new file mode 100644 index 000000000..0f04e7f25 --- /dev/null +++ b/packages/ui/src/lib/session-transcript-lru.ts @@ -0,0 +1,130 @@ +export interface TranscriptLruEntry { + instanceId: string + sessionId: string + bytes: number + lastUsed: number +} + +export interface TranscriptProtectionState { + visible?: boolean + loading?: boolean + status?: "idle" | "working" | "compacting" + generationPending?: boolean + liveMessages?: boolean + permissionBlocked?: boolean + questionBlocked?: boolean +} + +export function isSessionTranscriptProtected(state: TranscriptProtectionState): boolean { + return Boolean( + state.visible + || state.loading + || state.status === "working" + || state.status === "compacting" + || state.generationPending + || state.liveMessages + || state.permissionBlocked + || state.questionBlocked, + ) +} + +export function selectTranscriptEvictions( + entries: readonly TranscriptLruEntry[], + byteBudget: number, + isProtected: (entry: TranscriptLruEntry) => boolean, +): TranscriptLruEntry[] { + let retainedBytes = 0 + let unboundedEntries = 0 + for (const entry of entries) { + if (Number.isFinite(entry.bytes)) retainedBytes += entry.bytes + else unboundedEntries += 1 + } + if (unboundedEntries === 0 && retainedBytes <= byteBudget) return [] + + const selected: TranscriptLruEntry[] = [] + for (const entry of [...entries].sort((left, right) => left.lastUsed - right.lastUsed)) { + if (isProtected(entry)) continue + selected.push(entry) + if (Number.isFinite(entry.bytes)) retainedBytes -= entry.bytes + else unboundedEntries -= 1 + if (unboundedEntries === 0 && retainedBytes <= byteBudget) break + } + return selected +} + +interface SessionTranscriptLruOptions { + byteBudget: number + isProtected: (instanceId: string, sessionId: string) => boolean + evict: (instanceId: string, sessionId: string) => void +} + +export class SessionTranscriptLru { + private entries = new Map() + private pendingTouches = new Map() + private sequence = 0 + + constructor(private options: SessionTranscriptLruOptions) {} + + account(instanceId: string, sessionId: string, bytes: number): void { + const key = this.key(instanceId, sessionId) + if (bytes <= 0) { + this.entries.delete(key) + this.pendingTouches.delete(key) + return + } + const current = this.entries.get(key) + const lastUsed = current?.lastUsed ?? this.pendingTouches.get(key) ?? ++this.sequence + this.pendingTouches.delete(key) + this.entries.set(key, { + instanceId, + sessionId, + bytes, + lastUsed, + }) + this.enforce() + } + + touch(instanceId: string, sessionId: string): boolean { + const key = this.key(instanceId, sessionId) + const lastUsed = ++this.sequence + const entry = this.entries.get(key) + if (entry) { + entry.lastUsed = lastUsed + return false + } + const needsAccounting = !this.pendingTouches.has(key) + this.pendingTouches.set(key, lastUsed) + return needsAccounting + } + + forget(instanceId: string, sessionId: string): void { + const key = this.key(instanceId, sessionId) + this.entries.delete(key) + this.pendingTouches.delete(key) + } + + forgetInstance(instanceId: string): void { + for (const [key, entry] of this.entries) { + if (entry.instanceId === instanceId) this.entries.delete(key) + } + for (const key of this.pendingTouches.keys()) { + if (key.startsWith(`${instanceId}\u0000`)) this.pendingTouches.delete(key) + } + } + + enforce(): void { + const evictions = selectTranscriptEvictions( + [...this.entries.values()], + this.options.byteBudget, + (entry) => this.options.isProtected(entry.instanceId, entry.sessionId), + ) + for (const entry of evictions) { + this.entries.delete(this.key(entry.instanceId, entry.sessionId)) + this.options.evict(entry.instanceId, entry.sessionId) + } + } + + private key(instanceId: string, sessionId: string): string { + return `${instanceId}\u0000${sessionId}` + } +} diff --git a/packages/ui/src/lib/session-transcript-measurement.ts b/packages/ui/src/lib/session-transcript-measurement.ts new file mode 100644 index 000000000..993e562cb --- /dev/null +++ b/packages/ui/src/lib/session-transcript-measurement.ts @@ -0,0 +1,97 @@ +type PendingMeasurement = { + revision: number + timer?: ReturnType + controller?: AbortController +} + +type SessionTranscriptMeasurementOptions = { + delayMs: number + measure: (instanceId: string, sessionId: string, signal: AbortSignal) => Promise + account: (instanceId: string, sessionId: string, bytes: number) => void + onError: (instanceId: string, sessionId: string, error: unknown) => void +} + +export class SessionTranscriptMeasurementQueue { + private pending = new Map() + + constructor(private options: SessionTranscriptMeasurementOptions) {} + + schedule(instanceId: string, sessionId: string): void { + const entryKey = this.key(instanceId, sessionId) + const current = this.pending.get(entryKey) + if (current) { + if (current.controller) { + current.controller.abort() + const replacement = { revision: current.revision + 1 } + this.pending.set(entryKey, replacement) + this.arm(instanceId, sessionId, replacement) + return + } + current.revision += 1 + return + } + + const pending = { revision: 1 } + this.pending.set(entryKey, pending) + this.arm(instanceId, sessionId, pending) + } + + cancel(instanceId: string, sessionId: string): void { + const entryKey = this.key(instanceId, sessionId) + const pending = this.pending.get(entryKey) + if (!pending) return + if (pending.timer !== undefined) clearTimeout(pending.timer) + pending.controller?.abort() + this.pending.delete(entryKey) + } + + cancelInstance(instanceId: string): void { + for (const [entryKey, pending] of this.pending) { + if (!entryKey.startsWith(`${instanceId}\u0000`)) continue + if (pending.timer !== undefined) clearTimeout(pending.timer) + pending.controller?.abort() + this.pending.delete(entryKey) + } + } + + private arm(instanceId: string, sessionId: string, pending: PendingMeasurement): void { + pending.timer = setTimeout(() => { + pending.timer = undefined + void this.measure(instanceId, sessionId, pending) + }, this.options.delayMs) + } + + private async measure(instanceId: string, sessionId: string, pending: PendingMeasurement): Promise { + const entryKey = this.key(instanceId, sessionId) + const measuredRevision = pending.revision + const controller = new AbortController() + pending.controller = controller + try { + const bytes = await this.options.measure(instanceId, sessionId, controller.signal) + if (controller.signal.aborted || this.pending.get(entryKey) !== pending) return + if (pending.revision === measuredRevision) { + this.pending.delete(entryKey) + this.options.account(instanceId, sessionId, bytes) + return + } + } catch (error) { + if (!controller.signal.aborted && this.pending.get(entryKey) === pending && pending.revision === measuredRevision) { + this.options.account(instanceId, sessionId, Number.POSITIVE_INFINITY) + try { + this.options.onError(instanceId, sessionId, error) + } catch { + // Accounting is authoritative; error reporting must not undo it. + } + } + } finally { + if (this.pending.get(entryKey) !== pending) return + pending.controller = undefined + if (pending.revision !== measuredRevision) this.arm(instanceId, sessionId, pending) + else this.pending.delete(entryKey) + } + } + + private key(instanceId: string, sessionId: string): string { + return `${instanceId}\u0000${sessionId}` + } +} diff --git a/packages/ui/src/stores/client-state-codec.test.ts b/packages/ui/src/stores/client-state-codec.test.ts index f5d1012cf..6288653ad 100644 --- a/packages/ui/src/stores/client-state-codec.test.ts +++ b/packages/ui/src/stores/client-state-codec.test.ts @@ -47,7 +47,7 @@ describe("client state codec", () => { tabs: [workspace({ type: "instance", kind: undefined, folder: "C:/work/project", occurrence: 1, projectName: "Project", drafts: { session1: "unfinished prompt" }, - scrollSnapshots: { session1: { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 } }, + scrollSnapshots: { session1: { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200, windowCursor: "older", newerCursors: [null, "newer"] } }, unseenIdleSince: { session1: 1100, malformed: -1 }, generationRecovery: { session1: "working", session2: "interrupted", malformed: "idle" }, expandedSessionIds: ["session1", "session1", 42], @@ -65,7 +65,7 @@ describe("client state codec", () => { assert.deepEqual({ ...tab.unseenIdleSince }, { session1: 1100 }) assert.deepEqual({ ...tab.generationRecovery }, { session1: "working", session2: "interrupted" }) assert.deepEqual(tab.expandedSessionIds, ["session1"]) - assert.deepEqual(tab.scrollSnapshots.session1, { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 }) + assert.deepEqual(tab.scrollSnapshots.session1, { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200, windowCursor: "older", newerCursors: [null, "newer"] }) assert.deepEqual(decoded?.session?.tabs[1], { kind: "sidecar", sidecarId: "docs" }) }) diff --git a/packages/ui/src/stores/client-state-codec.ts b/packages/ui/src/stores/client-state-codec.ts index 1ca551188..881f976e9 100644 --- a/packages/ui/src/stores/client-state-codec.ts +++ b/packages/ui/src/stores/client-state-codec.ts @@ -101,6 +101,15 @@ function normalizeScrollSnapshot(value: unknown, budget: StringBudget): ScrollSn if (anchorKey !== undefined) result.anchorKey = anchorKey if (anchorOffset !== undefined) result.anchorOffset = anchorOffset if (value.followModeType === "following" || value.followModeType === "escaped") result.followModeType = value.followModeType + const windowCursor = value.windowCursor === undefined ? undefined : takeString(value.windowCursor, MAX_ANCHOR_KEY, budget) + if (windowCursor !== undefined) result.windowCursor = windowCursor + if (Array.isArray(value.newerCursors)) { + result.newerCursors = value.newerCursors.slice(-256).flatMap((cursor) => { + if (cursor === null) return [null] + const normalized = takeString(cursor, MAX_ANCHOR_KEY, budget) + return normalized === undefined ? [] : [normalized] + }) + } return result } diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 320aa0567..4e07e909b 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -44,7 +44,7 @@ import { } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" -import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data" +import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, projectOpenCodeMessages } from "./opencode-data" import { upsertPermissionV2, removePermissionV2, removeMessageV2 } from "./message-v2/bridge" import { clearRepliedPermissions, @@ -1718,6 +1718,7 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters() +const promptDisplayOverrideBytes = new Map() +let retainedBytes = 0 function makeKey(_instanceId: string, sessionId: string, messageId: string): string { return `${sessionId}:${messageId}` @@ -52,15 +58,39 @@ function ensureLoaded(): void { if (persist() && legacyRaw) storage.removeItem(LEGACY_STORAGE_KEY) } catch { promptDisplayOverrides.clear() + promptDisplayOverrideBytes.clear() + retainedBytes = 0 } } function loadStoredEntries(parsed: Record, migrateLegacyKeys: boolean): void { for (const [key, value] of Object.entries(parsed)) { - if (isPromptDisplayMetadata(value)) { - promptDisplayOverrides.set(migrateLegacyKeys ? migrateStoredKey(key) : key, value) - } + if (isPromptDisplayMetadata(value)) setEntry(migrateLegacyKeys ? migrateStoredKey(key) : key, value) + } +} + +function deleteEntry(key: string): boolean { + const bytes = promptDisplayOverrideBytes.get(key) + if (bytes === undefined) return false + retainedBytes -= bytes + promptDisplayOverrideBytes.delete(key) + promptDisplayOverrides.delete(key) + return true +} + +function setEntry(key: string, value: PromptDisplayMetadata): boolean { + const bytes = key.length * 2 + estimateRetainedBytes(value, ENTRY_BYTE_LIMIT) + if (bytes > ENTRY_BYTE_LIMIT) return false + deleteEntry(key) + promptDisplayOverrides.set(key, value) + promptDisplayOverrideBytes.set(key, bytes) + retainedBytes += bytes + while (promptDisplayOverrides.size > ENTRY_LIMIT || retainedBytes > BYTE_LIMIT) { + const oldest = promptDisplayOverrides.keys().next().value + if (oldest === undefined) break + deleteEntry(oldest) } + return promptDisplayOverrides.has(key) } function persist(): boolean { @@ -96,7 +126,13 @@ export function getPromptDisplayOverride( messageId: string, ): PromptDisplayMetadata | undefined { ensureLoaded() - return promptDisplayOverrides.get(makeKey(instanceId, sessionId, messageId)) + const key = makeKey(instanceId, sessionId, messageId) + const value = promptDisplayOverrides.get(key) + if (value) { + promptDisplayOverrides.delete(key) + promptDisplayOverrides.set(key, value) + } + return value } export function setPromptDisplayOverride( @@ -111,10 +147,9 @@ export function setPromptDisplayOverride( if (displayMetadata && isPromptDisplayMetadata(displayMetadata)) { const serialized = JSON.stringify(displayMetadata) if (previous && JSON.stringify(previous) === serialized) return - promptDisplayOverrides.set(key, displayMetadata) + if (!setEntry(key, displayMetadata)) return } else { - if (!promptDisplayOverrides.has(key)) return - promptDisplayOverrides.delete(key) + if (!deleteEntry(key)) return } persist() } @@ -127,14 +162,14 @@ export function movePromptDisplayOverride(instanceId: string, sessionId: string, const newKey = makeKey(instanceId, sessionId, newMessageId) if (oldKey === newKey) return - promptDisplayOverrides.delete(oldKey) - promptDisplayOverrides.set(newKey, nextValue) + if (!setEntry(newKey, nextValue)) return + deleteEntry(oldKey) persist() } export function clearPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): void { ensureLoaded() - if (!promptDisplayOverrides.delete(makeKey(instanceId, sessionId, messageId))) { + if (!deleteEntry(makeKey(instanceId, sessionId, messageId))) { return } persist() @@ -147,7 +182,7 @@ export function clearPromptDisplayOverridesForSession(instanceId: string, sessio let changed = false for (const key of promptDisplayOverrides.keys()) { if (key.startsWith(stablePrefix) || key.startsWith(legacyPrefix)) { - promptDisplayOverrides.delete(key) + deleteEntry(key) changed = true } } @@ -162,7 +197,7 @@ export function clearPromptDisplayOverridesForInstance(instanceId: string, sessi const shouldDeleteStableKey = sessionIds.some((sessionId) => key.startsWith(`${sessionId}:`)) const shouldDeleteLegacyKey = key.startsWith(`${instanceId}:`) if (shouldDeleteStableKey || shouldDeleteLegacyKey) { - promptDisplayOverrides.delete(key) + deleteEntry(key) changed = true } } @@ -173,4 +208,6 @@ export function clearPromptDisplayOverridesForInstance(instanceId: string, sessi export function resetPromptDisplayOverrideStateForTests(): void { loaded = false promptDisplayOverrides.clear() + promptDisplayOverrideBytes.clear() + retainedBytes = 0 } diff --git a/packages/ui/src/stores/message-v2/bus.test.ts b/packages/ui/src/stores/message-v2/bus.test.ts index 127b0d442..bb3f578b2 100644 --- a/packages/ui/src/stores/message-v2/bus.test.ts +++ b/packages/ui/src/stores/message-v2/bus.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { messageStoreBus } from "./bus.ts" -import { invalidateSessionMessageLoad, messagesLoaded, setMessagesLoaded } from "../session-state.ts" +import { messagesLoaded, setMessagesLoaded } from "../session-state.ts" describe("message store scroll snapshots", () => { it("seeds an unregistered instance without claiming runtime authority", () => { @@ -67,8 +67,7 @@ describe("message store scroll snapshots", () => { setMessagesLoaded((prev) => new Map(prev).set(instanceId, new Set(["session-1"]))) try { store.restoreScrollSnapshot("session-1", "message-stream", snapshot) - invalidateSessionMessageLoad(instanceId, "session-1") - store.clearSession("session-1", { preserveScroll: true, notify: false }) + store.clearSession("session-1", { preserveScroll: true }) assert.deepEqual(store.getScrollSnapshot("session-1", "message-stream"), snapshot) assert.equal(messagesLoaded().get(instanceId)?.has("session-1") ?? false, false) } finally { diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index 86900cd9f..baefccc7b 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -1,6 +1,6 @@ import { createInstanceMessageStore } from "./instance-store" import type { InstanceMessageStore } from "./instance-store" -import { clearCacheForInstance } from "../../lib/global-cache" +import { clearCacheForInstance, clearCacheForSession } from "../../lib/global-cache" import { getLogger } from "../../lib/logger" import type { ScrollSnapshot } from "./types" @@ -16,6 +16,8 @@ class MessageStoreBus { private stores = new Map() private teardownHandlers = new Set<(instanceId: string) => void>() private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>() + private sessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>() + private messageRemovalHandlers = new Set<(instanceId: string, sessionId: string, messageIds: readonly string[]) => void>() private scrollSnapshotHandlers = new Set< (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void >() @@ -30,6 +32,8 @@ class MessageStoreBus { store ?? createInstanceMessageStore(instanceId, { onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId), + onSessionChanged: (id, sessionId) => this.notifySessionChanged(id, sessionId), + onMessagesRemoved: (id, sessionId, messageIds) => this.notifyMessagesRemoved(id, sessionId, messageIds), onScrollSnapshotChanged: (id, sessionId, scope, snapshot) => this.notifyScrollSnapshotChanged(id, sessionId, scope, snapshot), }) @@ -52,6 +56,7 @@ class MessageStoreBus { } private notifySessionCleared(instanceId: string, sessionId: string) { + clearCacheForSession(instanceId, sessionId) for (const handler of this.sessionClearHandlers) { try { handler(instanceId, sessionId) @@ -61,6 +66,37 @@ class MessageStoreBus { } } + onSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void { + this.sessionChangeHandlers.add(handler) + return () => this.sessionChangeHandlers.delete(handler) + } + + private notifySessionChanged(instanceId: string, sessionId: string) { + for (const handler of this.sessionChangeHandlers) { + try { + handler(instanceId, sessionId) + } catch (error) { + log.error("Failed to run session change handler", error) + } + } + } + + onMessagesRemoved(handler: (instanceId: string, sessionId: string, messageIds: readonly string[]) => void): () => void { + this.messageRemovalHandlers.add(handler) + return () => this.messageRemovalHandlers.delete(handler) + } + + private notifyMessagesRemoved(instanceId: string, sessionId: string, messageIds: readonly string[]) { + clearCacheForSession(instanceId, sessionId) + for (const handler of this.messageRemovalHandlers) { + try { + handler(instanceId, sessionId, messageIds) + } catch (error) { + log.error("Failed to run message removal handler", error) + } + } + } + onScrollSnapshotChanged( handler: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void, ): () => void { diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 49e7d5a0d..b6f129b65 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { createInstanceMessageStore } from "./instance-store.ts" +import { getSessionMessageRenderCache, purgeMessageRenderCache } from "../../lib/message-render-cache.ts" describe("message-v2 permission state", () => { it("keeps one permission attachment when a duplicate moves from global to a tool part", () => { @@ -66,6 +67,38 @@ describe("message-v2 todo state", () => { }) describe("message-v2 hydrateMessages vs pending optimistic sends", () => { + it("trims to 200 messages and purges removed render-cache entries", () => { + const instanceId = "window-trim", sessionId = "session" + const cache = getSessionMessageRenderCache(instanceId, sessionId) + const store = createInstanceMessageStore(instanceId, { + onMessagesRemoved: (_instanceId, _sessionId, messageIds) => purgeMessageRenderCache(cache, messageIds), + }) + store.hydrateMessages(sessionId, Array.from({ length: 201 }, (_, index) => ({ + id: `message-${index}`, sessionId, role: "assistant" as const, status: "complete" as const, + }))) + cache.messageBlocks.set("message-0", {}) + cache.messageBlocks.set("message-200", {}) + + store.trimSessionMessages(sessionId, 200) + + assert.equal(store.getSessionMessageIds(sessionId).length, 200) + assert.equal(store.getSessionMessageIds(sessionId).includes("message-0"), false) + assert.deepEqual([...cache.messageBlocks.keys()], ["message-200"]) + store.clearInstance() + }) + + it("clears pending parts omitted by authoritative hydration", () => { + const store = createInstanceMessageStore("pending-cleanup") + store.hydrateMessages("session-1", [{ id: "old", sessionId: "session-1", role: "assistant", status: "complete" }]) + store.bufferPendingPart({ messageId: "old", sessionId: "session-1", part: { type: "text", text: "pending" } as any, receivedAt: 1 }) + + store.hydrateMessages("session-1", [{ id: "current", sessionId: "session-1", role: "user", status: "complete" }]) + + assert.equal(store.state.pendingParts.old, undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), ["current"]) + store.clearInstance() + }) + it("keeps an in-flight pending 'sending' message visible when a force reload snapshot doesn't include it yet", () => { const store = createInstanceMessageStore("instance-1") store.addOrUpdateSession({ id: "session-1" }) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 7a0f7d24a..d7c0e5875 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -2,6 +2,7 @@ import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" import { getLogger } from "../../lib/logger" +import { getCacheRetainedEntriesForSession } from "../../lib/global-cache" import { clearPromptDisplayOverride, clearPromptDisplayOverridesForInstance, @@ -12,7 +13,10 @@ import { } from "../message-prompt-display" import type { ClientPart, MessageInfo } from "../../types/message" import { mergePermissionRequest } from "../../types/permission" -import { clearRecordDisplayCacheForMessages } from "./record-display-cache" +import { clearRecordDisplayCacheForInstance, clearRecordDisplayCacheForMessages, getRecordDisplayCacheEntries } from "./record-display-cache" +import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "../../lib/retained-size" +import { clearInstanceMessageRenderCaches, clearSessionMessageRenderCache, peekSessionMessageRenderCache } from "../../lib/message-render-cache" +import type { MessageWindowState } from "./message-window" import { shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" import type { InstanceMessageState, @@ -34,6 +38,8 @@ const storeLog = getLogger("session") interface MessageStoreHooks { onSessionCleared?: (instanceId: string, sessionId: string) => void + onSessionChanged?: (instanceId: string, sessionId: string) => void + onMessagesRemoved?: (instanceId: string, sessionId: string, messageIds: readonly string[]) => void onScrollSnapshotChanged?: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void } @@ -73,6 +79,51 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin } const PENDING_PART_MAX_AGE_MS = 30_000 +const PENDING_PARTS_PER_MESSAGE_LIMIT = 100 +const PENDING_PARTS_PER_SESSION_LIMIT = 100 +const PENDING_PARTS_GLOBAL_LIMIT = 500 +const PENDING_PART_MAX_RETAINED_BYTES = 1024 * 1024 +const PENDING_PARTS_PER_SESSION_BYTE_LIMIT = 4 * 1024 * 1024 +const PENDING_PARTS_GLOBAL_BYTE_LIMIT = 8 * 1024 * 1024 +const DROPPED_PENDING_MESSAGE_LIMIT = 500 +const MAX_TRANSCRIPT_MEASUREMENT_BYTES = 64 * 1024 * 1024 +const MAX_TRANSCRIPT_MEASUREMENT_NODES = 500_000 +const pendingPartRetainedBytes = Symbol("pendingPartRetainedBytes") +const pendingPartBudgetId = Symbol("pendingPartBudgetId") +let nextPendingPartBudgetId = 0 +const pendingPartBudgetEntries = new Map boolean + remove: () => void +}>() +type SizedPendingPartEntry = PendingPartEntry & { [pendingPartRetainedBytes]?: number; [pendingPartBudgetId]?: number } + +function getPendingPartRetainedBytes(entry: PendingPartEntry): number { + return (entry as SizedPendingPartEntry)[pendingPartRetainedBytes] + ?? estimateRetainedBytes(entry, PENDING_PART_MAX_RETAINED_BYTES) +} + +function forgetPendingPartBudgetEntry(entry: PendingPartEntry): void { + const id = (entry as SizedPendingPartEntry)[pendingPartBudgetId] + if (id !== undefined) pendingPartBudgetEntries.delete(id) +} + +function enforceGlobalPendingPartBudget(): void { + for (const [id, entry] of pendingPartBudgetEntries) if (!entry.isRetained()) pendingPartBudgetEntries.delete(id) + let bytes = 0 + for (const entry of pendingPartBudgetEntries.values()) bytes += entry.bytes + if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) return + const oldest = [...pendingPartBudgetEntries.entries()].sort((left, right) => + left[1].receivedAt - right[1].receivedAt || left[0] - right[0]) + for (const [id, entry] of oldest) { + if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break + pendingPartBudgetEntries.delete(id) + bytes -= entry.bytes + entry.remove() + } +} function clonePart(part: ClientPart): ClientPart { // Cloning is intentionally disabled; message parts @@ -252,6 +303,13 @@ export interface InstanceMessageStore { getLastCompactionMessageIndex: (sessionId: string) => number getMessage: (messageId: string) => MessageRecord | undefined getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined + hasPendingSends: (sessionId: string) => boolean + setMessageWindow: (sessionId: string, window: MessageWindowState) => void + getMessageWindow: (sessionId: string) => MessageWindowState | undefined + trimSessionMessages: (sessionId: string, limit: number) => void + estimateSessionRetainedBytes: (sessionId: string, signal?: AbortSignal) => Promise + hasLiveSessionMessages: (sessionId: string) => boolean + evictSessionTranscript: (sessionId: string) => void clearSession: (sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) => void clearScrollSnapshots: () => void clearInstance: () => void @@ -266,6 +324,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt // Requests awaiting same-ID persistence confirmation. const pendingSendIds = new Set() + const droppedPendingMessageIds = new Set() const optimisticPartIdsByMessage = new Map>() function forgetPendingSend(messageId: string): void { @@ -365,6 +424,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function bumpSessionRevision(sessionId: string) { if (!sessionId) return setState("sessionRevisions", sessionId, (value = 0) => value + 1) + hooks?.onSessionChanged?.(instanceId, sessionId) } function getSessionRevisionValue(sessionId: string) { @@ -410,6 +470,68 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.usage[sessionId] } + function hasLiveSessionMessages(sessionId: string): boolean { + for (const messageId of state.sessions[sessionId]?.messageIds ?? []) { + const message = state.messages[messageId] + if (pendingSendIds.has(messageId) || message?.status === "sending" || message?.status === "streaming") return true + } + return false + } + + function hasPendingSends(sessionId: string): boolean { + for (const messageId of pendingSendIds) { + if (state.messages[messageId]?.sessionId === sessionId) return true + } + return false + } + + function estimateSessionRetainedBytes(sessionId: string, signal?: AbortSignal): Promise { + const session = state.sessions[sessionId] + const renderCache = peekSessionMessageRenderCache(instanceId, sessionId) + const globalCacheEntries = [...getCacheRetainedEntriesForSession(instanceId, sessionId)] + const globalCacheKeyBytes = globalCacheEntries.reduce((total, entry) => total + entry.keyBytes, 0) + let hasPendingParts = false + for (const messageId in state.pendingParts) { + if (state.pendingParts[messageId]?.some((entry) => entry.sessionId === sessionId)) { + hasPendingParts = true + break + } + } + if ((!session || (session.messageIds.length === 0 && !session.revert)) && !renderCache && !hasPendingParts && globalCacheEntries.length === 0) { + return Promise.resolve(0) + } + function* retainedValues(): Generator { + if (session) yield session + for (const messageId of session?.messageIds ?? []) { + yield state.messages[messageId] + yield messageInfoCache.get(messageId) + yield state.messageInfoVersion[messageId] + yield state.pendingParts[messageId] + } + for (const messageId in state.pendingParts) { + const entries = state.pendingParts[messageId] + if (entries?.some((entry) => entry.sessionId === sessionId) && !state.messages[messageId]) yield entries + } + for (const entry of state.permissions.queue) if (entry.permission.sessionID === sessionId) yield entry + if (session) { + yield state.usage[sessionId] + yield state.sessionRevisions[sessionId] + yield state.lastAssistantMessageIds[sessionId] + yield state.latestTodos[sessionId] + } + yield renderCache + if (session) yield* getRecordDisplayCacheEntries(instanceId, session.messageIds) + for (const entry of globalCacheEntries) yield entry.value + } + if (globalCacheKeyBytes > MAX_TRANSCRIPT_MEASUREMENT_BYTES) return Promise.resolve(Number.POSITIVE_INFINITY) + return estimateRetainedBytesIncrementally(retainedValues(), { + signal, + rootIterable: true, + maxBytes: MAX_TRANSCRIPT_MEASUREMENT_BYTES - globalCacheKeyBytes, + maxNodes: MAX_TRANSCRIPT_MEASUREMENT_NODES, + }).then((bytes) => Number.isFinite(bytes) ? bytes + globalCacheKeyBytes : bytes) + } + function ensureSessionEntry(sessionId: string): SessionRecord { const existing = state.sessions[sessionId] if (existing) { @@ -501,6 +623,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt seenInputIds.add(input.id) return true }) + for (const input of dedupedInputs) droppedPendingMessageIds.delete(input.id) const serverIds = dedupedInputs.map((item) => item.id) const serverIdSet = new Set(serverIds) @@ -581,12 +704,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt messageInfoCache.delete(id) forgetPendingSend(id) clearPromptDisplayOverride(instanceId, sessionId, id) + nextPendingParts[id]?.forEach(forgetPendingPartBudgetEntry) delete nextMessages[id] delete nextMessageInfoVersion[id] delete nextPendingParts[id] delete nextPermissionsByMessage[id] }) clearRecordDisplayCacheForMessages(instanceId, omittedIds) + hooks?.onMessagesRemoved?.(instanceId, sessionId, omittedIds) } // A send that reappears under its own id is confirmed — it is no longer in @@ -713,6 +838,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt pendingSendIds.delete(messageId) if (options?.clearOptimisticParts) optimisticPartIdsByMessage.delete(messageId) const record = state.messages[messageId] + if (record) hooks?.onSessionChanged?.(instanceId, record.sessionId) if (!record) return const optimisticPartIds = options?.clearOptimisticParts && record.role === "user" ? record.partIds.filter((id) => clientPartIds?.has(id)) @@ -755,10 +881,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function retirePendingSends(sessionId: string) { + let changed = false for (const messageId of Array.from(pendingSendIds)) { const record = state.messages[messageId] - if (record?.sessionId === sessionId && record.status === "sent") pendingSendIds.delete(messageId) + if (record?.sessionId === sessionId && record.status === "sent") { + pendingSendIds.delete(messageId) + changed = true + } } + if (changed) hooks?.onSessionChanged?.(instanceId, sessionId) } // Apply an AUTHORITATIVE empty snapshot (server returned zero messages for @@ -787,6 +918,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt clearPromptDisplayOverride(instanceId, sessionId, id) }) clearRecordDisplayCacheForMessages(instanceId, droppedIds) + hooks?.onMessagesRemoved?.(instanceId, sessionId, droppedIds) batch(() => { setState( @@ -872,6 +1004,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function upsertMessage(input: MessageUpsertInput) { + const pendingDropRequiresReload = droppedPendingMessageIds.delete(input.id) const normalizedParts = normalizeParts(input.id, input.parts) const shouldBump = Boolean(input.bumpRevision || normalizedParts) const now = Date.now() @@ -915,21 +1048,106 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt flushPendingParts(input.id) recomputeLastAssistantMessageId(input.sessionId) bumpSessionRevision(input.sessionId) + if (pendingDropRequiresReload) hooks?.onSessionCleared?.(instanceId, input.sessionId) + } + + function markPendingPartDropped(messageId: string, sessionId?: string): void { + if (sessionId) { + hooks?.onSessionCleared?.(instanceId, sessionId) + hooks?.onSessionChanged?.(instanceId, sessionId) + return + } + droppedPendingMessageIds.delete(messageId) + droppedPendingMessageIds.add(messageId) + while (droppedPendingMessageIds.size > DROPPED_PENDING_MESSAGE_LIMIT) { + const oldest = droppedPendingMessageIds.values().next().value + if (oldest === undefined) break + droppedPendingMessageIds.delete(oldest) + } } function bufferPendingPart(entry: PendingPartEntry) { - setState("pendingParts", entry.messageId, (list = []) => [...list, entry]) + const sessionId = entry.sessionId ?? (typeof (entry.part as any).sessionID === "string" ? (entry.part as any).sessionID : undefined) + const nextEntry = { ...entry, sessionId } as SizedPendingPartEntry + const retainedBytes = estimateRetainedBytes(nextEntry, PENDING_PART_MAX_RETAINED_BYTES) + if (retainedBytes > PENDING_PART_MAX_RETAINED_BYTES) { + markPendingPartDropped(entry.messageId, sessionId) + return + } + const budgetId = ++nextPendingPartBudgetId + Object.defineProperty(nextEntry, pendingPartRetainedBytes, { value: retainedBytes }) + Object.defineProperty(nextEntry, pendingPartBudgetId, { value: budgetId }) + const changedSessions = new Set() + const droppedMessages = new Map() + if (sessionId) changedSessions.add(sessionId) + setState("pendingParts", produce((draft: Record) => { + const messageEntries = [...(draft[entry.messageId] ?? []), nextEntry] + for (const dropped of messageEntries.slice(0, Math.max(0, messageEntries.length - PENDING_PARTS_PER_MESSAGE_LIMIT))) { + droppedMessages.set(entry.messageId, dropped.sessionId) + } + draft[entry.messageId] = messageEntries.slice(-PENDING_PARTS_PER_MESSAGE_LIMIT) + + const pending: { messageId: string; entry: PendingPartEntry; bytes: number }[] = [] + for (const messageId in draft) { + for (const value of draft[messageId] ?? []) pending.push({ messageId, entry: value, bytes: getPendingPartRetainedBytes(value) }) + } + pending.sort((left, right) => left.entry.receivedAt - right.entry.receivedAt) + + const sessionEntries = pending.filter((value) => value.entry.sessionId === sessionId) + const remove = new Set(pending.slice(0, Math.max(0, pending.length - PENDING_PARTS_GLOBAL_LIMIT))) + for (const value of sessionEntries.slice(0, Math.max(0, sessionEntries.length - PENDING_PARTS_PER_SESSION_LIMIT))) remove.add(value) + let sessionBytes = sessionEntries.reduce((total, value) => total + value.bytes, 0) + for (const value of sessionEntries) { + if (sessionBytes <= PENDING_PARTS_PER_SESSION_BYTE_LIMIT) break + remove.add(value) + sessionBytes -= value.bytes + } + let globalBytes = pending.reduce((total, value) => total + value.bytes, 0) + for (const value of pending) { + if (globalBytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break + remove.add(value) + globalBytes -= value.bytes + } + for (const value of remove) { + forgetPendingPartBudgetEntry(value.entry) + droppedMessages.set(value.messageId, value.entry.sessionId) + if (value.entry.sessionId) changedSessions.add(value.entry.sessionId) + const list = draft[value.messageId] + const index = list?.indexOf(value.entry) ?? -1 + if (index >= 0) list.splice(index, 1) + if (list?.length === 0) delete draft[value.messageId] + } + })) + const isRetained = () => state.pendingParts[entry.messageId]?.some((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? false + if (isRetained()) { + pendingPartBudgetEntries.set(budgetId, { + instanceId, + bytes: retainedBytes, + receivedAt: entry.receivedAt, + isRetained, + remove: () => { + setState("pendingParts", produce((draft: Record) => { + const list = draft[entry.messageId] + const index = list?.findIndex((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? -1 + if (index >= 0) list.splice(index, 1) + if (list?.length === 0) delete draft[entry.messageId] + })) + markPendingPartDropped(entry.messageId, sessionId) + }, + }) + enforceGlobalPendingPartBudget() + } + for (const [messageId, droppedSessionId] of droppedMessages) markPendingPartDropped(messageId, droppedSessionId) + for (const changedSessionId of changedSessions) hooks?.onSessionChanged?.(instanceId, changedSessionId) } function clearPendingPartsForMessage(messageId: string) { - setState("pendingParts", (prev) => { - if (!prev[messageId]) { - return prev - } - const next = { ...prev } - delete next[messageId] - return next - }) + const entries = state.pendingParts[messageId] + if (!entries) return + entries.forEach(forgetPendingPartBudgetEntry) + setState("pendingParts", produce((draft: Record) => { + delete draft[messageId] + })) } function rebindPermissionForPart(messageId: string, partId: string, part: ClientPart) { @@ -982,7 +1200,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function applyPartUpdate(input: PartUpdateInput) { const message = state.messages[input.messageId] if (!message) { - bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() }) + bufferPendingPart({ messageId: input.messageId, sessionId: typeof (input.part as any).sessionID === "string" ? (input.part as any).sessionID : undefined, part: input.part, receivedAt: Date.now() }) return } @@ -1095,6 +1313,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (!sessionIds.size && fallbackSessionId) sessionIds.add(fallbackSessionId) clearRecordDisplayCacheForMessages(instanceId, [messageId]) + sessionIds.forEach((sessionId) => hooks?.onMessagesRemoved?.(instanceId, sessionId, [messageId])) batch(() => { sessionIds.forEach((sessionId) => { @@ -1406,6 +1625,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) removedIds.forEach((id) => messageInfoCache.delete(id)) + clearRecordDisplayCacheForMessages(instanceId, removedIds) + hooks?.onMessagesRemoved?.(instanceId, sessionId, removedIds) setState("pendingParts", (prev) => { const next = { ...prev } @@ -1428,7 +1649,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) recomputeLastAssistantMessageId(sessionId, keptIds) - bumpSessionRevision(sessionId) } function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) { @@ -1438,6 +1658,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt pruneMessagesAfterRevert(sessionId, revert.messageID) } setState("sessions", sessionId, "revert", revert ?? null) + bumpSessionRevision(sessionId) } function getSessionRevert(sessionId: string) { @@ -1465,17 +1686,39 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } - function clearSession(sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) { + function setMessageWindow(sessionId: string, window: MessageWindowState) { + ensureSessionEntry(sessionId) + setState("sessions", sessionId, "messageWindow", window) + } + + function getMessageWindow(sessionId: string) { + return state.sessions[sessionId]?.messageWindow + } + + function trimSessionMessages(sessionId: string, limit: number) { + const ids = state.sessions[sessionId]?.messageIds ?? [] + const overflow = ids.length - Math.max(1, Math.floor(limit)) + for (const messageId of ids.slice(0, Math.max(0, overflow))) removeMessage(messageId, sessionId) + } + + function clearSession(sessionId: string, options?: { preserveScroll?: boolean; preservePromptDisplayOverrides?: boolean; notify?: boolean }) { if (!sessionId) return - clearPromptDisplayOverridesForSession(instanceId, sessionId) + if (!options?.preservePromptDisplayOverrides) clearPromptDisplayOverridesForSession(instanceId, sessionId) const messageIds = Object.values(state.messages) .filter((record) => record.sessionId === sessionId) .map((record) => record.id) + const messageIdSet = new Set(messageIds) + for (const [messageId, entries] of Object.entries(state.pendingParts)) { + for (const entry of entries) { + if (messageIdSet.has(messageId) || entry.sessionId === sessionId) forgetPendingPartBudgetEntry(entry) + } + } storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length }) clearRecordDisplayCacheForMessages(instanceId, messageIds) + clearSessionMessageRenderCache(instanceId, sessionId) messageIds.forEach((id) => forgetPendingSend(id)) batch(() => { @@ -1493,13 +1736,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt messageIds.forEach((id) => messageInfoCache.delete(id)) - setState("pendingParts", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) + setState("pendingParts", produce((draft: Record) => { + for (const id of messageIds) delete draft[id] + for (const id in draft) if (draft[id]?.some((entry) => entry.sessionId === sessionId)) delete draft[id] + })) setState("permissions", "byMessage", (prev) => { const next = { ...prev } @@ -1559,9 +1799,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (options?.notify !== false) hooks?.onSessionCleared?.(instanceId, sessionId) } + function evictSessionTranscript(sessionId: string) { + clearSession(sessionId, { preserveScroll: true, preservePromptDisplayOverrides: true }) + } + function clearInstance() { + for (const [id, entry] of pendingPartBudgetEntries) if (entry.instanceId === instanceId) pendingPartBudgetEntries.delete(id) + droppedPendingMessageIds.clear() clearPromptDisplayOverridesForInstance(instanceId, Object.keys(state.sessions)) + clearRecordDisplayCacheForInstance(instanceId) + clearInstanceMessageRenderCaches(instanceId) messageInfoCache.clear() pendingSendIds.clear() optimisticPartIdsByMessage.clear() @@ -1613,6 +1861,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt getLastCompactionMessageIndex, getMessage: (messageId: string) => state.messages[messageId], getLatestTodoSnapshot, + hasPendingSends, + setMessageWindow, + getMessageWindow, + trimSessionMessages, + estimateSessionRetainedBytes, + hasLiveSessionMessages, + evictSessionTranscript, clearSession, clearScrollSnapshots, clearInstance, diff --git a/packages/ui/src/stores/message-v2/message-window.test.ts b/packages/ui/src/stores/message-v2/message-window.test.ts new file mode 100644 index 000000000..6cdd1cb62 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-window.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { completeMessageWindow, latestMessageWindow, planNewerMessageWindow, planOlderMessageWindow, preserveMessageWindowCursor } from "./message-window.ts" + +test("moves backward and forward through bounded transcript windows", () => { + const latest = completeMessageWindow(latestMessageWindow(), "older") + const older = planOlderMessageWindow(latest) + assert.deepEqual(older, { cursor: "older", newerCursors: [null] }) + assert.deepEqual(planNewerMessageWindow(older!), latestMessageWindow()) +}) + +test("preserves historical cursors while replacing ordinary scroll coordinates", () => { + assert.deepEqual(preserveMessageWindowCursor( + { scrollTop: 20, atBottom: false }, + { windowCursor: "persisted", newerCursors: [null] }, + { cursor: "current", newerCursors: [null, "newer"] }, + ), { + scrollTop: 20, + atBottom: false, + windowCursor: "current", + newerCursors: [null, "newer"], + }) +}) diff --git a/packages/ui/src/stores/message-v2/message-window.ts b/packages/ui/src/stores/message-v2/message-window.ts new file mode 100644 index 000000000..b347d51e1 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-window.ts @@ -0,0 +1,47 @@ +export const SESSION_MESSAGE_WINDOW_LIMIT = 200 + +export interface MessageWindowState { + cursor?: string + olderCursor?: string + newerCursors: (string | null)[] +} + +export function latestMessageWindow(): MessageWindowState { + return { newerCursors: [] } +} + +export function restoreMessageWindow(snapshot?: { windowCursor?: string; newerCursors?: (string | null)[] }): MessageWindowState { + return { + cursor: snapshot?.windowCursor, + newerCursors: snapshot?.newerCursors?.filter((cursor) => cursor === null || typeof cursor === "string") ?? [], + } +} + +export function planOlderMessageWindow(current: MessageWindowState): MessageWindowState | null { + if (!current.olderCursor) return null + return { cursor: current.olderCursor, newerCursors: [...current.newerCursors, current.cursor ?? null] } +} + +export function planNewerMessageWindow(current: MessageWindowState): MessageWindowState | null { + if (current.cursor === undefined) return null + const cursor = current.newerCursors.at(-1) + return cursor === undefined || cursor === null + ? latestMessageWindow() + : { cursor, newerCursors: current.newerCursors.slice(0, -1) } +} + +export function completeMessageWindow(window: MessageWindowState, olderCursor?: string): MessageWindowState { + return { ...window, olderCursor } +} + +export function preserveMessageWindowCursor( + snapshot: T, + current: { windowCursor?: string; newerCursors?: (string | null)[] } | undefined, + window: MessageWindowState | undefined, +): T & { windowCursor?: string; newerCursors?: (string | null)[] } { + return { + ...snapshot, + windowCursor: window ? window.cursor : current?.windowCursor, + newerCursors: window ? window.newerCursors : current?.newerCursors, + } +} diff --git a/packages/ui/src/stores/message-v2/record-display-cache.ts b/packages/ui/src/stores/message-v2/record-display-cache.ts index 9e89ee716..88d733ae0 100644 --- a/packages/ui/src/stores/message-v2/record-display-cache.ts +++ b/packages/ui/src/stores/message-v2/record-display-cache.ts @@ -1,10 +1,12 @@ import type { ClientPart } from "../../types/message" +import { extractReasoningTextForRender } from "../../lib/message-render-cache" import type { MessageRecord } from "./types" type ClientPartWithRevision = ClientPart & { revision?: number } export interface RecordDisplayData { orderedParts: ClientPartWithRevision[] + truncated: boolean } interface RecordDisplayCacheEntry { @@ -13,6 +15,7 @@ interface RecordDisplayCacheEntry { } const recordDisplayCache = new Map() +export const MESSAGE_PART_DISPLAY_LIMIT = 200 function makeCacheKey(instanceId: string, messageId: string) { return `${instanceId}:${messageId}` @@ -27,13 +30,26 @@ export function buildRecordDisplayData(instanceId: string, record: MessageRecord const orderedParts: ClientPartWithRevision[] = [] - for (const partId of record.partIds) { + for (let index = 0; index < record.partIds.length && index < MESSAGE_PART_DISPLAY_LIMIT; index += 1) { + const partId = record.partIds[index] const entry = record.parts[partId] if (!entry?.data) continue - orderedParts.push({ ...(entry.data as ClientPart), revision: entry.revision }) + const part = entry.data as ClientPart + if (part.type === "reasoning") { + const time = (part as any).time + orderedParts.push({ + id: part.id, + type: "reasoning", + text: extractReasoningTextForRender(part), + time: time ? { start: time.start, end: time.end, created: time.created } : undefined, + revision: entry.revision, + } as ClientPartWithRevision) + continue + } + orderedParts.push({ ...part, revision: entry.revision }) } - const data: RecordDisplayData = { orderedParts } + const data: RecordDisplayData = { orderedParts, truncated: record.partIds.length > MESSAGE_PART_DISPLAY_LIMIT } recordDisplayCache.set(cacheKey, { revision: record.revision, data }) return data } @@ -53,3 +69,10 @@ export function clearRecordDisplayCacheForMessages(instanceId: string, messageId recordDisplayCache.delete(makeCacheKey(instanceId, messageId)) } } + +export function* getRecordDisplayCacheEntries(instanceId: string, messageIds: Iterable): Generator { + for (const messageId of messageIds) { + const entry = recordDisplayCache.get(makeCacheKey(instanceId, messageId)) + if (entry) yield entry + } +} diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 5bbe2dec2..624176c28 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,6 +1,7 @@ import type { ClientPart } from "../../types/message" import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata" import type { PermissionRequest } from "../../types/permission" +import type { MessageWindowState } from "./message-window" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageRole = "user" | "assistant" @@ -40,10 +41,12 @@ export interface SessionRecord { updatedAt: number messageIds: string[] revert?: SessionRevertState | null + messageWindow?: MessageWindowState } export interface PendingPartEntry { messageId: string + sessionId?: string part: ClientPart receivedAt: number } @@ -69,6 +72,8 @@ export interface ScrollSnapshot { anchorOffset?: number atBottom: boolean followModeType?: "following" | "escaped" + windowCursor?: string + newerCursors?: (string | null)[] updatedAt: number } diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index cd1a7100f..167a0b3ff 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -3,7 +3,7 @@ import { describe, it } from "node:test" import { messageStoreBus } from "./message-v2/bus.ts" import { seedSessionMessagesV2 } from "./message-v2/bridge.ts" import { normalizeSessionMessage } from "./message-v2/normalizers.ts" -import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data.ts" +import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, projectOpenCodeMessages } from "./opencode-data.ts" describe("OpenCode data projection", () => { it("uses createData to reduce messages, permissions, and forms", () => { @@ -133,6 +133,54 @@ describe("OpenCode data projection", () => { } }) + it("does not mix live projection into a historical message window", () => { + const instanceId = "opencode-data-historical-window" + const sessionId = "session" + const store = messageStoreBus.getOrCreate(instanceId) + try { + store.setMessageWindow(sessionId, { cursor: "older-page", newerCursors: [null] }) + const data = applyOpenCodeDataEvent(instanceId, "/work", { + id: "inbox-event", + type: "session.inbox.enqueued", + created: 1, + durable: { aggregateID: sessionId, seq: 1, version: 1 }, + data: { + sessionID: sessionId, + inboxID: "live-message", + item: { type: "user", payload: { text: "live" }, delivery: "queue" }, + }, + } as any) + projectOpenCodeMessages(instanceId, sessionId, data) + assert.equal(store.getMessage("live-message"), undefined) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + + it("keeps another session's live reducer after a terminal event", () => { + const instanceId = "opencode-data-session-isolation" + const event = (sessionID: string, type: string, data: Record = {}) => ({ + id: `${sessionID}-${type}`, type, created: 1, + data: { sessionID, assistantMessageID: `${sessionID}-assistant`, ...data }, + } as any) + try { + applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.step.started", { + agent: "build", model: { providerID: "provider", id: "model" }, + })) + const idle = event("idle", "session.idle") + applyOpenCodeDataEvent(instanceId, "/work", idle) + finishOpenCodeDataEvent(instanceId, idle) + applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.text.started")) + const data = applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.text.delta", { ordinal: 0, delta: "kept" })) + projectOpenCodeMessages(instanceId, "active", data) + assert.equal((messageStoreBus.getOrCreate(instanceId).getMessage("active-assistant")?.parts["active-assistant-text-0"]?.data as any)?.text, "kept") + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + it("projects native inbox delivery order", () => { const instanceId = "opencode-data-delivery-order" const sessionId = "session" diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index a6735f44e..1e80d575e 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -9,8 +9,17 @@ import { messageStoreBus } from "./message-v2/bus" const entries = new Map void; dispose: () => void }>() -function ensureData(instanceId: string, directory: string) { - const existing = entries.get(instanceId) +const entryKey = (instanceId: string, sessionId?: string) => `${instanceId}\u0000${sessionId ?? ""}` + +function eventSessionId(event: OpenCodeEvent): string | undefined { + if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID + if (event.type === "form.created") return event.data.form.sessionID + return undefined +} + +function ensureData(instanceId: string, directory: string, sessionId?: string) { + const key = entryKey(instanceId, sessionId) + const existing = entries.get(key) if (existing) return existing const listeners = new Set<(event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void>() @@ -41,13 +50,13 @@ function ensureData(instanceId: string, directory: string) { dispose, } }) - entries.set(instanceId, entry) + entries.set(key, entry) return entry } export function applyOpenCodeDataEvent(instanceId: string, directory: string, event: OpenCodeEvent): Data { if (event.type === "server.connected") destroyOpenCodeData(instanceId) - const entry = ensureData(instanceId, directory) + const entry = ensureData(instanceId, directory, eventSessionId(event)) entry.emit(event) return entry.data } @@ -56,6 +65,7 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d const source = data.session.message.list(sessionId) if (!source.length) return const store = messageStoreBus.getOrCreate(instanceId) + if (store.getMessageWindow(sessionId)?.cursor !== undefined) return const projectedIds: string[] = [] for (const item of source) { const normalized = normalizeSessionMessage(sessionId, item) @@ -78,7 +88,23 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d }) } -export function destroyOpenCodeData(instanceId: string): void { - entries.get(instanceId)?.dispose() - entries.delete(instanceId) +export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent): void { + if (event.type === "session.idle" || event.type === "session.deleted") { + destroyOpenCodeData(instanceId, eventSessionId(event)) + } +} + +export function destroyOpenCodeData(instanceId: string, sessionId?: string): void { + if (sessionId !== undefined) { + const key = entryKey(instanceId, sessionId) + entries.get(key)?.dispose() + entries.delete(key) + return + } + const prefix = `${instanceId}\u0000` + for (const [key, entry] of entries) { + if (!key.startsWith(prefix)) continue + entry.dispose() + entries.delete(key) + } } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index a5ae38b7f..ea2e5a731 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -10,6 +10,7 @@ import { isSessionBusy } from "./session-status" import { getDefaultModel, isModelValid } from "./session-models" import { updateSessionInfo } from "./message-v2/session-info" import { messageStoreBus } from "./message-v2/bus" +import { SESSION_MESSAGE_WINDOW_LIMIT } from "./message-v2/message-window" import { getLogger } from "../lib/logger" import { clearConversationPlaybackForSession, isConversationModeEnabled } from "./conversation-speech" @@ -221,6 +222,7 @@ async function sendMessage( isEphemeral: true, clientPromptDisplayMetadata: preparedPrompt.displayMetadata, }) + store.trimSessionMessages(sessionId, SESSION_MESSAGE_WINDOW_LIMIT) // Preserve the optimistic bubble only while the prompt request is unresolved. store.markSendPending(messageId) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index c70898515..a6e572623 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -4,7 +4,7 @@ import { type Session, } from "../types/session" import type { Message } from "../types/message" -import type { LocationRef, SessionInfo as SDKSession, SessionMessagesResponse } from "@opencode-ai/client" +import type { LocationRef, SessionInfo as SDKSession, SessionsResponse } from "@opencode-ai/client" import { instances, reconcilePendingSessionIndicators } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -19,7 +19,6 @@ import { cancelSessionGenerationAdmissions, markSessionDeletedAuthoritative, getAuthoritativelyDeletedSessionIdsForInstance, - getDescendantSessions, isBlankSession, messagesLoaded, getSessionMessagesLoadError, @@ -27,6 +26,8 @@ import { setAgents, setMessagesLoaded, advanceMessageLoadEpoch, + finishMessageLoad, + getMessageLoadSignal, invalidateSessionMessageLoad, isCurrentMessageLoad, setSessionMessagesLoadError, @@ -61,7 +62,7 @@ import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { getOpencodeErrorMessage } from "../lib/opencode-api" -import { getRootClient } from "./opencode-client" +import { getRootClient, type OpenCodeClient } from "./opencode-client" import { tGlobal } from "../lib/i18n" import { getWorktrees, @@ -72,6 +73,16 @@ import { } from "./session-list-options" import { getInstanceMetadata } from "./instance-metadata" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" +import { listMessageWindow } from "./session-message-pages" +import { + SESSION_MESSAGE_WINDOW_LIMIT, + completeMessageWindow, + latestMessageWindow, + planNewerMessageWindow, + planOlderMessageWindow, + restoreMessageWindow, + type MessageWindowState, +} from "./message-v2/message-window" import { fetchCommands } from "./commands" import { toRequestLocation } from "./request-locations" @@ -84,6 +95,11 @@ const providerRequestIds = new Map() const sessionPageRequests = new Map>() const MAX_DESCENDANT_SESSION_REQUESTS = 1_000_000 let nextSessionListRequestId = 0 +const MAX_MESSAGE_REVISION_RETRIES = 1 + +function hasInstanceClientAuthority(instanceId: string, client: OpenCodeClient): boolean { + return instances().get(instanceId)?.client === client +} function catalogLocationKey(location: LocationRef): string { return `${location.directory}\0${location.workspaceID ?? ""}` @@ -591,6 +607,7 @@ async function createSession(instanceId: string, agent?: string): Promise { @@ -623,6 +642,7 @@ async function createSession(instanceId: string, agent?: string): Promise { - const next = { ...prev } - next.creatingSession.set(instanceId, false) - return next - }) + if (hasInstanceClientAuthority(instanceId, instanceClient)) { + setLoading((prev) => { + const next = { ...prev } + next.creatingSession.set(instanceId, false) + return next + }) + } } } @@ -694,6 +717,7 @@ async function forkSession( if (!instance || !instance.client) { throw new Error("Instance not ready") } + const instanceClient = instance.client const client = getRootClient(instanceId) @@ -706,6 +730,7 @@ async function forkSession( log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await client.session.fork(request) + if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready") const forkedSession = toClientSessionV2(instanceId, info) setSessions((prev) => { @@ -754,6 +779,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { - const next = { ...prev } - const deleting = next.deletingSession.get(instanceId) - if (deleting) { - deleting.delete(sessionId) - } - return next - }) + if (hasInstanceClientAuthority(instanceId, instanceClient)) { + setLoading((prev) => { + const next = { ...prev } + const deleting = next.deletingSession.get(instanceId) + if (deleting) { + deleting.delete(sessionId) + } + return next + }) + } } } @@ -845,6 +874,7 @@ async function fetchAgents(instanceId: string, location = getActiveCatalogLocati if (!instance || !instance.client) { throw new Error("Instance not ready") } + const instanceClient = instance.client const rootClient = getRootClient(instanceId) const requestId = (agentRequestIds.get(instanceId) ?? 0) + 1 @@ -877,7 +907,9 @@ async function fetchAgents(instanceId: string, location = getActiveCatalogLocati : undefined, })) - if (agentRequestIds.get(instanceId) !== requestId || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false + if (!hasInstanceClientAuthority(instanceId, instanceClient) + || agentRequestIds.get(instanceId) !== requestId + || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false setAgents((prev) => { const next = new Map(prev) next.set(instanceId, agentList) @@ -895,6 +927,7 @@ async function fetchProviders(instanceId: string, location = getActiveCatalogLoc if (!instance || !instance.client) { throw new Error("Instance not ready") } + const instanceClient = instance.client const rootClient = getRootClient(instanceId) const requestId = (providerRequestIds.get(instanceId) ?? 0) + 1 @@ -926,7 +959,9 @@ async function fetchProviders(instanceId: string, location = getActiveCatalogLoc })), })) - if (providerRequestIds.get(instanceId) !== requestId || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false + if (!hasInstanceClientAuthority(instanceId, instanceClient) + || providerRequestIds.get(instanceId) !== requestId + || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false setProviders((prev) => { const next = new Map(prev) next.set(instanceId, providerList) @@ -939,6 +974,15 @@ async function fetchProviders(instanceId: string, location = getActiveCatalogLoc } } +type MessageWindowIntent = "open" | "older" | "newer" | "latest" + +function planMessageWindow(current: MessageWindowState, intent: MessageWindowIntent): MessageWindowState | null { + if (intent === "older") return planOlderMessageWindow(current) + if (intent === "newer") return planNewerMessageWindow(current) + if (intent === "latest") return latestMessageWindow() + return current +} + async function loadMessages( instanceId: string, sessionId: string, @@ -946,24 +990,33 @@ async function loadMessages( force?: boolean skipChildren?: boolean registerInvalidation?: (invalidate: () => void) => void + revisionRetryCount?: number + intent?: MessageWindowIntent }, -): Promise { +): Promise { const force = options?.force ?? false const skipChildren = options?.skipChildren ?? false + const intent = options?.intent ?? "open" + const store = messageStoreBus.getOrCreate(instanceId) + const plannedWindow = planMessageWindow( + store.getMessageWindow(sessionId) ?? restoreMessageWindow(store.getScrollSnapshot(sessionId, "message-stream")), + intent, + ) + if (!plannedWindow) return false const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId) if (alreadyLoaded && !force) { - return + return false } const previousError = getSessionMessagesLoadError(instanceId, sessionId) if (previousError && !force) { - return + return false } const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId) if (isLoading && !force) { - return + return false } const instance = instances().get(instanceId) @@ -972,6 +1025,7 @@ async function loadMessages( } const client = getRootClient(instanceId) + const instanceClient = instance.client const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) @@ -980,11 +1034,13 @@ async function loadMessages( } const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) + const signal = getMessageLoadSignal(instanceId, sessionId) options?.registerInvalidation?.(() => { if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) invalidateSessionMessageLoad(instanceId, sessionId) }) - const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) + const messageRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false + let committed = false setLoading((prev) => { const next = { ...prev } @@ -997,27 +1053,23 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages: SessionMessagesResponse["data"] = [] - let cursor: string | undefined - const seenCursors = new Set() - do { - const response: SessionMessagesResponse = await client.message.list({ - sessionID: sessionId, - limit: 200, - ...(cursor ? { cursor } : { order: "asc" }), - }) - apiMessages.push(...response.data) - cursor = response.cursor?.next ?? undefined - if (cursor && seenCursors.has(cursor)) throw new Error(`Repeated message cursor: ${cursor}`) - if (cursor) seenCursors.add(cursor) - } while (cursor) + const windowPage = await listMessageWindow(client, sessionId, { + limit: SESSION_MESSAGE_WINDOW_LIMIT, + cursor: plannedWindow.cursor, + signal, + isAuthoritative: () => hasInstanceClientAuthority(instanceId, instanceClient) + && isCurrentMessageLoad(instanceId, sessionId, loadEpoch) + && Boolean(sessions().get(instanceId)?.has(sessionId)), + }) + const apiMessages = windowPage?.messages ?? null - if (!instances().has(instanceId) + if (!apiMessages + || !hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId)) return + || !sessions().get(instanceId)?.has(sessionId)) return false if (!Array.isArray(apiMessages)) { - return + return false } const latestSession = sessions().get(instanceId)?.get(sessionId) @@ -1028,7 +1080,7 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) if (apiMessages.length === 0) { - if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { + if (store.getSessionRevision(sessionId) !== messageRevision) { retryAfterRevisionConflict = true } else { // Authoritative empty snapshot: on a forced reconnect load the server @@ -1036,8 +1088,10 @@ async function loadMessages( // before the reconnect (hydrateMessages ignores empty input). Still // in-flight optimistic sends are preserved by the store. if (force) { - messageStoreBus.getOrCreate(instanceId).reconcileEmptyAuthoritativeSnapshot(sessionId) + store.reconcileEmptyAuthoritativeSnapshot(sessionId) } + commitMessageWindow(store, sessionId, completeMessageWindow(plannedWindow, windowPage?.olderCursor)) + committed = true setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() @@ -1080,16 +1134,16 @@ async function loadMessages( if (!agentName && !providerID && !modelID) { const defaultModel = await getDefaultModel(instanceId, session.agent) - if (!instances().has(instanceId) + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId)) return + || !sessions().get(instanceId)?.has(sessionId)) return false agentName = session.agent providerID = defaultModel.providerId modelID = defaultModel.modelId } setSessions((prev) => { - if (!instances().has(instanceId) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev const next = new Map(prev) const nextInstanceSessions = next.get(instanceId) if (!nextInstanceSessions) return next @@ -1107,10 +1161,13 @@ async function loadMessages( const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, } - if (!instances().has(instanceId) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return false if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { retryAfterRevisionConflict = true } else { + store.trimSessionMessages(sessionId, SESSION_MESSAGE_WINDOW_LIMIT) + commitMessageWindow(store, sessionId, completeMessageWindow(plannedWindow, windowPage?.olderCursor)) + committed = true setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() @@ -1125,6 +1182,7 @@ async function loadMessages( } catch (error) { + if (signal?.aborted || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return false log.error("Failed to load messages:", error) if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) @@ -1138,36 +1196,61 @@ async function loadMessages( if (loadingSet) loadingSet.delete(sessionId) return next }) + finishMessageLoad(instanceId, sessionId, loadEpoch) } } - if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { + const revisionRetryCount = options?.revisionRetryCount ?? 0 + if (retryAfterRevisionConflict + && revisionRetryCount < MAX_MESSAGE_REVISION_RETRIES + && hasInstanceClientAuthority(instanceId, instanceClient) + && sessions().get(instanceId)?.has(sessionId)) { await new Promise((resolve) => setTimeout(resolve, 50)) - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return false return loadMessages(instanceId, sessionId, { force: true, skipChildren, registerInvalidation: options?.registerInvalidation, + revisionRetryCount: revisionRetryCount + 1, + intent, }) } - if (!instances().has(instanceId) + if (retryAfterRevisionConflict) { + setSessionMessagesLoadError(instanceId, sessionId, tGlobal("messageSection.loadError.detail")) + return false + } + + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId)) return + || !sessions().get(instanceId)?.has(sessionId)) return false updateSessionInfo(instanceId, sessionId) - if (!skipChildren && session.parentId === null) { - for (const child of getDescendantSessions(instanceId, sessionId)) { - void loadMessages(instanceId, child.id, { skipChildren: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - parentSessionId: sessionId, - error, - }), - ) - } - } + return committed +} + +function commitMessageWindow(store: ReturnType, sessionId: string, window: MessageWindowState) { + store.setMessageWindow(sessionId, window) + const snapshot = store.getScrollSnapshot(sessionId, "message-stream") + store.setScrollSnapshot(sessionId, "message-stream", { + scrollTop: snapshot?.scrollTop ?? 0, + scrollRatio: snapshot?.scrollRatio, + maxScrollTop: snapshot?.maxScrollTop, + anchorKey: snapshot?.anchorKey, + anchorOffset: snapshot?.anchorOffset, + atBottom: snapshot?.atBottom ?? window.cursor === undefined, + followModeType: snapshot?.followModeType, + windowCursor: window.cursor, + newerCursors: window.newerCursors, + }) +} + +function loadOlderMessages(instanceId: string, sessionId: string): Promise { + return loadMessages(instanceId, sessionId, { force: true, skipChildren: true, intent: "older" }) +} + +function loadNewerMessages(instanceId: string, sessionId: string): Promise { + return loadMessages(instanceId, sessionId, { force: true, skipChildren: true, intent: "newer" }) } export { @@ -1182,6 +1265,8 @@ export { fetchSessions, hydrateRestoredSessionChain, loadMoreSessions, + loadOlderMessages, + loadNewerMessages, searchSessions, forkSession, loadMessages, diff --git a/packages/ui/src/stores/session-message-pages.ts b/packages/ui/src/stores/session-message-pages.ts new file mode 100644 index 000000000..342e8e4bd --- /dev/null +++ b/packages/ui/src/stores/session-message-pages.ts @@ -0,0 +1,42 @@ +import type { SessionMessageInfo as SDKMessage } from "@opencode-ai/client" +import type { OpenCodeClient } from "./opencode-client" + +export interface MessageWindowPage { + messages: SDKMessage[] + olderCursor?: string +} + +const MAX_WINDOW_PAGES = 1_000 + +async function listMessageWindow( + client: OpenCodeClient, + sessionId: string, + options: { limit: number; cursor?: string; signal?: AbortSignal; isAuthoritative?: () => boolean }, +): Promise { + const isAuthoritative = options.isAuthoritative ?? (() => true) + const messages: SDKMessage[] = [] + const seenCursors = new Set() + let cursor = options.cursor + + for (let page = 0; page < MAX_WINDOW_PAGES && messages.length < options.limit; page += 1) { + options.signal?.throwIfAborted() + if (!isAuthoritative()) return null + const remaining = options.limit - messages.length + const response = await client.message.list(cursor + ? { sessionID: sessionId, limit: remaining, cursor } + : { sessionID: sessionId, limit: remaining, order: "desc" }, { signal: options.signal }) + if (!isAuthoritative()) return null + messages.unshift(...response.data.slice(0, remaining).reverse()) + + const nextCursor = response.cursor?.next ?? undefined + if (!nextCursor) return { messages, olderCursor: undefined } + if (seenCursors.has(nextCursor)) throw new Error(`Repeated message cursor for session ${sessionId}`) + seenCursors.add(nextCursor) + cursor = nextCursor + } + + if (messages.length >= options.limit) return { messages, olderCursor: cursor } + throw new Error(`Message window pagination exceeded ${MAX_WINDOW_PAGES} pages for session ${sessionId}`) +} + +export { listMessageWindow } diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 4359eda04..a4fc1091e 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -179,7 +179,7 @@ describe("session request authority", () => { await loadMessages(instanceId, sessionId) failSecondPage = true await assert.rejects(loadMessages(instanceId, sessionId, { force: true }), /cursor failed/) - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-1", "old-2"]) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-2", "old-1"]) assert.equal(messagesLoaded().get(instanceId)?.has(sessionId), true) } finally { cleanup() diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 1309212f8..d60f9eeee 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -78,6 +78,7 @@ const [messagesLoaded, setMessagesLoaded] = createSignal const [messageLoadErrors, setMessageLoadErrors] = createSignal>>(new Map()) const [sessionListErrors, setSessionListErrors] = createSignal>(new Map()) const messageLoadEpochs = new Map() +const messageLoadControllers = new Map() let nextMessageLoadEpoch = 0 const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal>>(new Map()) @@ -335,8 +336,10 @@ function clearLoadedFlag(instanceId: string, sessionId: string) { function advanceMessageLoadEpoch(instanceId: string, sessionId: string): number { const key = getDraftKey(instanceId, sessionId) + messageLoadControllers.get(key)?.abort() const epoch = ++nextMessageLoadEpoch messageLoadEpochs.set(key, epoch) + messageLoadControllers.set(key, new AbortController()) return epoch } @@ -344,6 +347,15 @@ function isCurrentMessageLoad(instanceId: string, sessionId: string, epoch: numb return messageLoadEpochs.get(getDraftKey(instanceId, sessionId)) === epoch } +function getMessageLoadSignal(instanceId: string, sessionId: string): AbortSignal | undefined { + return messageLoadControllers.get(getDraftKey(instanceId, sessionId))?.signal +} + +function finishMessageLoad(instanceId: string, sessionId: string, epoch: number): void { + if (!isCurrentMessageLoad(instanceId, sessionId, epoch)) return + messageLoadControllers.delete(getDraftKey(instanceId, sessionId)) +} + function clearMessageLoadingFlag(instanceId: string, sessionId: string): void { setLoading((prev) => { const existing = prev.loadingMessages.get(instanceId) @@ -358,13 +370,81 @@ function clearMessageLoadingFlag(instanceId: string, sessionId: string): void { } function invalidateSessionMessageLoad(instanceId: string, sessionId: string): void { - advanceMessageLoadEpoch(instanceId, sessionId) + const key = getDraftKey(instanceId, sessionId) + messageLoadControllers.get(key)?.abort() + messageLoadControllers.delete(key) + messageLoadEpochs.set(key, ++nextMessageLoadEpoch) clearLoadedFlag(instanceId, sessionId) clearMessageLoadingFlag(instanceId, sessionId) } messageStoreBus.onSessionCleared(invalidateSessionMessageLoad) +function clearInstanceSessionState(instanceId: string): void { + if (!instanceId) return + const prefix = `${instanceId}:` + for (const [key, controller] of messageLoadControllers) { + if (!key.startsWith(prefix)) continue + controller.abort() + messageLoadControllers.delete(key) + } + for (const key of messageLoadEpochs.keys()) { + if (key.startsWith(prefix)) messageLoadEpochs.delete(key) + } + for (const key of generationAdmissions.keys()) { + if (key.startsWith(prefix)) generationAdmissions.delete(key) + } + + const deleteInstance = (prev: Map) => { + if (!prev.has(instanceId)) return prev + const next = new Map(prev) + next.delete(instanceId) + return next + } + const deletePrefixed = (prev: Set) => { + const next = new Set([...prev].filter((key) => !key.startsWith(prefix))) + return next.size === prev.size ? prev : next + } + + batch(() => { + setSessions(deleteInstance) + setActiveSessionId(deleteInstance) + setActiveParentSessionId(deleteInstance) + setAgents(deleteInstance) + setProviders(deleteInstance) + setMessagesLoaded(deleteInstance) + setMessageLoadErrors(deleteInstance) + setSessionListErrors(deleteInstance) + setSessionInfoByInstance(deleteInstance) + setThreadTotalsByInstance(deleteInstance) + setExpandedSessions(deleteInstance) + setInstanceIndicatorCounts(deleteInstance) + setSessionPagination(deleteInstance) + setSessionSearch(deleteInstance) + setLoading((prev) => ({ + fetchingSessions: deleteInstance(prev.fetchingSessions), + creatingSession: deleteInstance(prev.creatingSession), + deletingSession: deleteInstance(prev.deletingSession), + loadingMessages: deleteInstance(prev.loadingMessages), + })) + setSessionDraftPrompts((prev) => { + const next = new Map([...prev].filter(([key]) => !key.startsWith(prefix))) + return next.size === prev.size ? prev : next + }) + setAuthoritativeDraftKeys(deletePrefixed) + setAuthoritativelyDeletedSessionKeys(deletePrefixed) + setAuthoritativeSessionExpansionKeys(deletePrefixed) + setAuthoritativeSessionSelectionInstanceIds((prev) => { + if (!prev.has(instanceId)) return prev + const next = new Set(prev) + next.delete(instanceId) + return next + }) + }) +} + +messageStoreBus.onInstanceDestroyed(clearInstanceSessionState) + function getDraftKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` } @@ -1210,6 +1290,8 @@ export { setSessionListError, advanceMessageLoadEpoch, isCurrentMessageLoad, + getMessageLoadSignal, + finishMessageLoad, invalidateSessionMessageLoad, setSessionMessagesLoadError, sessionInfoByInstance, diff --git a/packages/ui/src/stores/session-transcript-memory.ts b/packages/ui/src/stores/session-transcript-memory.ts new file mode 100644 index 000000000..4876ce40b --- /dev/null +++ b/packages/ui/src/stores/session-transcript-memory.ts @@ -0,0 +1,94 @@ +import { createEffect, createRoot } from "solid-js" +import { getLogger } from "../lib/logger" +import { onCacheSessionChanged } from "../lib/global-cache" +import { SessionTranscriptMeasurementQueue } from "../lib/session-transcript-measurement" +import { isSessionTranscriptProtected, SessionTranscriptLru } from "../lib/session-transcript-lru" +import { messageStoreBus } from "./message-v2/bus" +import { loading, sessions } from "./session-state" +import { destroyOpenCodeData } from "./opencode-data" + +// Inactive transcript budget. Protected active/live 200-message windows retain authoritative content and may exceed it. +export const SESSION_TRANSCRIPT_BYTE_BUDGET = 64 * 1024 * 1024 + +const log = getLogger("session") +const visible = new Map() +const key = (instanceId: string, sessionId: string) => `${instanceId}\u0000${sessionId}` + +const coordinator = new SessionTranscriptLru({ + byteBudget: SESSION_TRANSCRIPT_BYTE_BUDGET, + isProtected: (instanceId, sessionId) => { + const session = sessions().get(instanceId)?.get(sessionId) + return isSessionTranscriptProtected({ + visible: visible.has(key(instanceId, sessionId)), + loading: loading().loadingMessages.get(instanceId)?.has(sessionId), + status: session?.status, + generationPending: session?.generationAdmissionToken !== undefined || session?.generationRecovery === "pending", + permissionBlocked: session?.pendingPermission, + questionBlocked: session?.pendingForm, + liveMessages: messageStoreBus.getInstance(instanceId)?.hasLiveSessionMessages(sessionId), + }) + }, + evict: (instanceId, sessionId) => { + log.info("Evicting inactive session transcript", { instanceId, sessionId }) + destroyOpenCodeData(instanceId, sessionId) + messageStoreBus.getInstance(instanceId)?.evictSessionTranscript(sessionId) + }, +}) + +const measurements = new SessionTranscriptMeasurementQueue({ + delayMs: 100, + measure: async (instanceId, sessionId, signal) => { + return messageStoreBus.getInstance(instanceId)?.estimateSessionRetainedBytes(sessionId, signal) ?? 0 + }, + account: (instanceId, sessionId, bytes) => coordinator.account(instanceId, sessionId, bytes), + onError: (instanceId, sessionId, error) => { + log.warn("Failed to measure session transcript", { instanceId, sessionId, error }) + }, +}) + +export function accountSessionTranscript(instanceId: string, sessionId: string): void { + measurements.schedule(instanceId, sessionId) +} + +export function touchSessionTranscript(instanceId: string, sessionId: string): void { + coordinator.touch(instanceId, sessionId) + measurements.schedule(instanceId, sessionId) +} + +export function setSessionTranscriptVisible(instanceId: string, sessionId: string, value: boolean): void { + const entryKey = key(instanceId, sessionId) + if (value) { + visible.set(entryKey, (visible.get(entryKey) ?? 0) + 1) + touchSessionTranscript(instanceId, sessionId) + } else { + const count = (visible.get(entryKey) ?? 0) - 1 + if (count > 0) visible.set(entryKey, count) + else visible.delete(entryKey) + measurements.schedule(instanceId, sessionId) + } + coordinator.enforce() +} + +export function reconcileSessionTranscriptBudget(): void { + coordinator.enforce() +} + +createRoot(() => createEffect(() => { + loading() + sessions() + queueMicrotask(() => coordinator.enforce()) +})) + +messageStoreBus.onSessionChanged(accountSessionTranscript) +onCacheSessionChanged(accountSessionTranscript) +messageStoreBus.onSessionCleared((instanceId, sessionId) => { + measurements.cancel(instanceId, sessionId) + coordinator.forget(instanceId, sessionId) +}) +messageStoreBus.onInstanceDestroyed((instanceId) => { + coordinator.forgetInstance(instanceId) + for (const entryKey of visible.keys()) { + if (entryKey.startsWith(`${instanceId}\u0000`)) visible.delete(entryKey) + } + measurements.cancelInstance(instanceId) +})