From b50ebf83d3a32a104e0e51d567fece1dfb9f3e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 13:00:28 +0200 Subject: [PATCH 1/4] perf(ui): bound transcript memory on dev Reconnect the bounded transcript-memory work directly to dev after the native V2 migration landed through #647. Apply only the #648 content delta on top of dev@52f0e629, avoiding the now-squashed DEV-v2 history. Bound transcript rendering and derived caches, retain complete content behind lazy copy paths, and coordinate inactive transcripts under a byte-aware 64 MiB LRU. Protect visible, loading, live, and interruption-blocked sessions while fully purging evicted instance/session state. Restore evicted transcripts through bounded 200-message V2 windows with opaque cursor authority, stale-request fencing, revision retries, committed viewport movement, and exhaustive task-copy behavior. Regression coverage exercises cache replacement, pagination, reconnect authority, permission safety, and eviction reloads across the supported desktop clients. --- .github/workflows/pr-build.yml | 9 +- .../components/instance/instance-shell2.tsx | 1 + .../instance/shell/useSessionCache.ts | 101 ++---- packages/ui/src/components/markdown.tsx | 45 ++- packages/ui/src/components/message-block.tsx | 171 ++++++---- .../ui/src/components/message-section.tsx | 35 ++- .../components/permission-approval-modal.tsx | 5 +- .../src/components/session/session-view.tsx | 5 + packages/ui/src/components/tool-call.tsx | 106 ++++--- .../src/components/tool-call/ansi-render.tsx | 11 +- .../tool-call/diagnostic-selection.ts | 12 + .../tool-call/diagnostics-section.tsx | 28 +- .../src/components/tool-call/diagnostics.ts | 118 ++++++- .../src/components/tool-call/diff-render.tsx | 64 ++-- .../tool-call/permission-block.test.ts | 11 + .../components/tool-call/permission-block.tsx | 46 +-- .../tool-call/render-memory.test.ts | 32 ++ .../tool-call/renderer-copy.test.ts | 16 + .../tool-call/renderers/apply-patch-data.ts | 140 +++++++++ .../tool-call/renderers/apply-patch.tsx | 112 ++++--- .../components/tool-call/renderers/bash.tsx | 26 +- .../tool-call/renderers/default.tsx | 13 +- .../components/tool-call/renderers/edit.tsx | 9 +- .../components/tool-call/renderers/patch.tsx | 9 +- .../components/tool-call/renderers/read.tsx | 6 +- .../components/tool-call/renderers/skill.tsx | 9 +- .../tool-call/renderers/task-summary.ts | 60 ++++ .../components/tool-call/renderers/task.tsx | 233 +++++++++++--- .../tool-call/renderers/todo-data.ts | 94 ++++++ .../components/tool-call/renderers/todo.tsx | 67 ++-- .../tool-call/renderers/webfetch.tsx | 18 +- .../components/tool-call/renderers/write.tsx | 6 +- packages/ui/src/components/tool-call/types.ts | 4 + packages/ui/src/components/tool-call/utils.ts | 85 ++++- packages/ui/src/lib/clipboard.ts | 15 +- packages/ui/src/lib/global-cache.test.ts | 41 +++ packages/ui/src/lib/global-cache.ts | 187 ++++++++++- .../use-active-session-message-load.test.ts | 1 + .../hooks/use-active-session-message-load.ts | 49 ++- packages/ui/src/lib/hooks/use-global-cache.ts | 17 +- .../ui/src/lib/i18n/messages/de/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/en/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/es/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/fr/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/he/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/ja/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/ne/toolCall.ts | 3 + .../ui/src/lib/i18n/messages/ru/toolCall.ts | 3 + .../src/lib/i18n/messages/zh-Hans/toolCall.ts | 3 + packages/ui/src/lib/message-render-cache.ts | 130 ++++++++ packages/ui/src/lib/retained-size.test.ts | 20 ++ packages/ui/src/lib/retained-size.ts | 158 ++++++++++ .../ui/src/lib/session-transcript-lru.test.ts | 46 +++ packages/ui/src/lib/session-transcript-lru.ts | 130 ++++++++ .../src/lib/session-transcript-measurement.ts | 97 ++++++ .../ui/src/stores/client-state-codec.test.ts | 4 +- packages/ui/src/stores/instances.ts | 3 +- .../ui/src/stores/message-prompt-display.ts | 61 +++- packages/ui/src/stores/message-v2/bus.test.ts | 5 +- packages/ui/src/stores/message-v2/bus.ts | 38 ++- .../stores/message-v2/instance-store.test.ts | 48 +++ .../src/stores/message-v2/instance-store.ts | 292 ++++++++++++++++-- .../stores/message-v2/message-window.test.ts | 15 + .../src/stores/message-v2/message-window.ts | 9 + .../stores/message-v2/record-display-cache.ts | 32 +- packages/ui/src/stores/message-v2/types.ts | 1 + packages/ui/src/stores/opencode-data.test.ts | 25 +- packages/ui/src/stores/opencode-data.ts | 22 +- packages/ui/src/stores/session-actions.ts | 2 + packages/ui/src/stores/session-api.ts | 185 ++++++----- .../session-generation-recovery.test.ts | 10 +- .../ui/src/stores/session-message-pages.ts | 53 ++++ .../stores/session-request-authority.test.ts | 99 +++++- packages/ui/src/stores/session-state.ts | 24 +- .../src/stores/session-transcript-memory.ts | 94 ++++++ 75 files changed, 3017 insertions(+), 630 deletions(-) create mode 100644 packages/ui/src/components/tool-call/diagnostic-selection.ts create mode 100644 packages/ui/src/components/tool-call/permission-block.test.ts create mode 100644 packages/ui/src/components/tool-call/render-memory.test.ts create mode 100644 packages/ui/src/components/tool-call/renderer-copy.test.ts create mode 100644 packages/ui/src/components/tool-call/renderers/apply-patch-data.ts create mode 100644 packages/ui/src/components/tool-call/renderers/task-summary.ts create mode 100644 packages/ui/src/components/tool-call/renderers/todo-data.ts create mode 100644 packages/ui/src/lib/global-cache.test.ts create mode 100644 packages/ui/src/lib/message-render-cache.ts create mode 100644 packages/ui/src/lib/retained-size.test.ts create mode 100644 packages/ui/src/lib/retained-size.ts create mode 100644 packages/ui/src/lib/session-transcript-lru.test.ts create mode 100644 packages/ui/src/lib/session-transcript-lru.ts create mode 100644 packages/ui/src/lib/session-transcript-measurement.ts create mode 100644 packages/ui/src/stores/session-message-pages.ts create mode 100644 packages/ui/src/stores/session-transcript-memory.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 76fa00d00..744910681 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -106,7 +106,7 @@ jobs: - name: Test changed runnable UI behavior run: >- - node --import tsx --test + node --import tsx --test --test-force-exit packages/ui/src/components/browser-frame-security.test.ts packages/ui/src/components/message-history-pagination.test.ts packages/ui/src/components/message-timeline-v2.test.ts @@ -115,10 +115,13 @@ jobs: packages/ui/src/components/session/session-bottom-pin-intent.test.ts packages/ui/src/components/session/session-idle-attention.test.ts packages/ui/src/components/session-list-visibility.test.ts + packages/ui/src/components/tool-call/permission-block.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/client-identity.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 @@ -126,7 +129,9 @@ jobs: 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 @@ -158,11 +163,13 @@ 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/components/session/provider-usage-panel.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts packages/ui/src/stores/app-tabs.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts + packages/ui/src/stores/message-v2/bus.test.ts packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/shell-store-reactivity.test.ts diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 9e9e5e553..8b4fb5b98 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -668,6 +668,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 aac215745..09ad654c0 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -21,6 +21,15 @@ 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, +} from "../lib/message-render-cache" +import { accountSessionTranscript } from "../stores/session-transcript-memory" import { parseReasoningSummary } from "../lib/reasoning-summary" import { getFormQueue } from "../stores/forms" import { backgroundSession, deleteMessagePart, deleteTechnicalPartGroup } from "../stores/session-actions" @@ -125,47 +134,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")) @@ -249,6 +238,7 @@ interface ContentDisplayItem { key: string messageId: string startPartId: string + partIds: string[] } interface ToolDisplayItem { @@ -264,6 +254,7 @@ interface MessageContentItemProps { store: () => InstanceMessageStore messageId: string startPartId: string + partIds: string[] messageIndex: number onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void @@ -300,18 +291,12 @@ function MessageContentItem(props: MessageContentItemProps) { const parts = createMemo(() => { const current = record() if (!current) return [] - const ids = current.partIds - const startIndex = ids.indexOf(props.startPartId) - if (startIndex === -1) return [] - const resolved: ClientPart[] = [] - for (let idx = startIndex; idx < ids.length; idx++) { - const partId = ids[idx] + for (const partId of props.partIds) { const part = current.parts[partId]?.data if (!part) continue if (!isSupportedPartType(part)) continue - - if (!isContentPartType((part as any).type)) break + if (!isContentPartType((part as any).type)) continue resolved.push(part) } @@ -560,8 +545,10 @@ type CompactionDisplayItem = { type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | ExplorationDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem interface MessageDisplayBlock { - record: MessageRecord + messageId: string + status: MessageRecord["status"] items: MessageBlockItem[] + truncated: boolean } interface MessageBlockProps { @@ -599,7 +586,11 @@ 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 + } const [blockRef, setBlockRef] = createSignal() const isSearchResult = () => Boolean(props.searchResultMessageIds?.().has(props.messageId)) const activeSearchMatch = () => props.activeSearchMatch?.() ?? null @@ -613,6 +604,12 @@ export default function MessageBlock(props: MessageBlockProps) { const pendingFormToolTargets = () => props.pendingFormToolTargets?.() ?? localPendingFormToolTargets() const technicalCleanupPartKeys = () => props.technicalCleanupPartKeys?.() ?? new Set() 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?.() ?? "" @@ -656,7 +653,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[] = [] @@ -685,8 +683,11 @@ export default function MessageBlock(props: MessageBlockProps) { key: segmentKey, messageId: current.id, startPartId, + partIds: pendingParts.flatMap((part) => typeof part.id === "string" ? [part.id] : []), } sessionCache.messageItems.set(segmentKey, cached) + } else { + cached.partIds = pendingParts.flatMap((part) => typeof part.id === "string" ? [part.id] : []) } items.push(cached) @@ -827,13 +828,14 @@ export default function MessageBlock(props: MessageBlockProps) { flushContent() - const resultBlock: MessageDisplayBlock = { record: current, items } + const resultBlock: MessageDisplayBlock = { messageId: current.id, status: current.status, 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) { @@ -888,19 +890,21 @@ export default function MessageBlock(props: MessageBlockProps) { .filter(isDisplayItemVisible) .map((item) => item.key))) return ( - 0}> -
{ - setBlockRef(element) - onCleanup(() => setBlockRef(undefined)) - }} - class="message-stream-block" - data-message-id={block()!.record.id} - data-search-result={isSearchResult() ? "true" : undefined} - data-search-active={isActiveSearchResult() ? "true" : undefined} - > - - {(item, index) => ( + + {(resolvedBlock) => ( + +
{ + setBlockRef(element) + onCleanup(() => setBlockRef(undefined)) + }} + class="message-stream-block" + data-message-id={resolvedBlock().messageId} + data-search-result={isSearchResult() ? "true" : undefined} + data-search-active={isActiveSearchResult() ? "true" : undefined} + > + + {(item) => ( @@ -943,7 +948,7 @@ export default function MessageBlock(props: MessageBlockProps) { store={props.store} messageId={(item() as ToolDisplayItem).messageId} partId={(item() as ToolDisplayItem).partId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -970,7 +975,7 @@ export default function MessageBlock(props: MessageBlockProps) { ? (expanded) => props.setTechnicalGroupExpanded?.((item() as ExplorationDisplayItem).technicalGroup!.id, expanded) : undefined} technicalCleanupPartKeys={technicalCleanupPartKeys} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -995,19 +1000,19 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -1019,10 +1024,10 @@ export default function MessageBlock(props: MessageBlockProps) { completed={(item() as ReasoningDisplayItem).completed} instanceId={props.instanceId} sessionId={props.sessionId} - status={block()!.record.status} + status={resolvedBlock().status} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} activePartId={activeSearchMatch()?.partId} showHeader={technicalGroupStartsHere((item() as ReasoningDisplayItem).technicalGroup)} expanded={(item() as ReasoningDisplayItem).technicalGroup @@ -1038,9 +1043,28 @@ export default function MessageBlock(props: MessageBlockProps) { /> - )} -
-
+ )} + + +
+ {t("toolCall.output.truncated")} + +
+
+
+ + )} ) } @@ -1240,6 +1264,13 @@ function ExplorationGroup(props: ExplorationGroupProps) { ) } +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 @@ -1572,6 +1603,9 @@ function ReasoningGroupCard(props: { const renderCard = (item: ReasoningDisplayPart) => ( extractReasoningTextForCopy( + messageStoreBus.getOrCreate(props.instanceId).getMessage(item.messageId)?.parts[item.partId]?.data, + )} messageInfo={item.messageInfo} durationMs={item.durationMs} instanceId={props.instanceId} @@ -1642,6 +1676,7 @@ function ReasoningGroupCard(props: { interface ReasoningCardProps { part: ClientPart + copyText: () => string messageInfo?: MessageInfo durationMs?: number instanceId: string @@ -1777,14 +1812,14 @@ function ReasoningCard(props: ReasoningCardProps) { } const speech = useSpeech({ - id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`, + id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${props.part.id || "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) } diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index db4c2c7ef..b4d604f39 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -21,6 +21,7 @@ import { getMessageSelectionActionPosition } from "../lib/message-selection-posi import { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" +import { preserveMessageWindowCursor } from "../stores/message-v2/message-window" import { createSearchLocatorAuthority, getMessageWindowPageKey, hasMessageSearchAuthority, loadCompleteMessageHistory, loadPagesUntilAnchor, MESSAGE_HISTORY_TRAVERSAL_PAGE_LIMIT, reconcileResidentSearchMatches } from "./message-history-pagination" import { isLatestWindow, toWindowSnapshot } from "../stores/message-v2/message-window" import { getLogger } from "../lib/logger" @@ -54,10 +55,10 @@ export interface MessageSectionProps { onQuoteSelection?: (text: string, mode: "quote" | "code") => void onReloadMessages?: () => void hasMoreMessages?: boolean - onLoadMoreMessages?: (signal?: AbortSignal) => Promise - onLoadNewerMessages?: (signal?: AbortSignal) => Promise - onLoadLatestMessages?: (signal?: AbortSignal) => Promise - onLoadOldestMessages?: (signal?: AbortSignal) => Promise + onLoadMoreMessages?: (signal?: AbortSignal) => Promise + onLoadNewerMessages?: (signal?: AbortSignal) => Promise + onLoadLatestMessages?: (signal?: AbortSignal) => Promise + onLoadOldestMessages?: (signal?: AbortSignal) => Promise getMessageHistoryCursor?: () => string | undefined isActive?: boolean sessionStreamingActive?: boolean @@ -459,14 +460,24 @@ export default function MessageSection(props: MessageSectionProps) { const snapshot = overlayWindowOnSnapshot(options?.snapshot ?? listApi()?.captureScrollSnapshot()) if (snapshot) { setLastGoodScrollSnapshot(sessionId, snapshot) - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) + const resolvedStore = store() + resolvedStore.setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, preserveMessageWindowCursor( + snapshot, + resolvedStore.getScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE), + resolvedStore.getMessageWindow(sessionId), + )) return } } const lastGoodScrollSnapshot = getLastGoodScrollSnapshot(sessionId) if (lastGoodScrollSnapshot) { - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, lastGoodScrollSnapshot) + const resolvedStore = store() + resolvedStore.setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, preserveMessageWindowCursor( + lastGoodScrollSnapshot, + resolvedStore.getScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE), + resolvedStore.getMessageWindow(sessionId), + )) return } @@ -634,7 +645,7 @@ export default function MessageSection(props: MessageSectionProps) { hasAnchor: () => visibleMessageIds().includes(snapshot.anchorKey!), hasMore: () => Boolean(props.hasMoreMessages), isCurrent: isCurrentRestore, - loadMore: props.onLoadMoreMessages, + loadMore: async () => { await props.onLoadMoreMessages?.() }, getCursor: () => props.getMessageHistoryCursor?.(), }) } catch (error) { @@ -861,8 +872,8 @@ export default function MessageSection(props: MessageSectionProps) { pagingWindow = true try { if (!isCurrent()) return - await load(controller.signal) - if (!isCurrent()) return + const committed = await load(controller.signal) + if (!committed || !isCurrent()) return api.setAutoScroll(direction === "latest") api.notifyContentRendered() await waitTwoFrames() @@ -951,8 +962,8 @@ export default function MessageSection(props: MessageSectionProps) { getPageKey: messageWindowPageKey, isCurrent: isCurrentSearch, isLatest: () => isLatestWindow(store().getMessageWindow(sessionId)), - loadOldest: props.onLoadOldestMessages ?? (() => Promise.resolve()), - loadNewer: props.onLoadNewerMessages ?? (() => Promise.resolve()), + loadOldest: async () => { await props.onLoadOldestMessages?.() }, + loadNewer: async () => { await props.onLoadNewerMessages?.() }, visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking }), }).then((matches) => { if (!matches) { @@ -1036,7 +1047,7 @@ export default function MessageSection(props: MessageSectionProps) { isCurrent: () => searchLocatorAuthority.isCurrent(locatorAuthority) && activeSearchMatch()?.id === match.id && isSearchOpen(), - loadMore: props.onLoadNewerMessages ?? (() => Promise.resolve()), + loadMore: async () => { await props.onLoadNewerMessages?.() }, getCursor: messageWindowPageKey, maxPages: MESSAGE_HISTORY_TRAVERSAL_PAGE_LIMIT, }) diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 1c0be9f7d..ecf6d4685 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -11,6 +11,7 @@ import { import { activeSessionId, ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" +import { getPermissionDiffPayload, isPermissionApprovalBlocked } from "./tool-call/permission-block" import FormRequest from "./form-request" import { getFormQueue, type FormInfo } from "../stores/forms" import { sendFormCancel, sendFormReply } from "../stores/instances" @@ -368,10 +369,10 @@ const PermissionApprovalModal: Component = (props)
- - +
) } @@ -397,6 +402,7 @@ function ToolCallDetails(props: { error={permissionError} renderDiff={renderDiffContent} fallbackSessionId={() => props.sessionId} + onApprovalBlockedChange={setPermissionApprovalBlocked} onRespond={(permission, sessionId, response, message) => void handlePermissionResponse(permission, response, message)} /> ) @@ -413,6 +419,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") @@ -426,6 +436,7 @@ function ToolCallDetails(props: { copyText?: () => string | null | undefined copyTitle?: () => string copyAriaLabel?: () => string + onCopy?: (event: MouseEvent) => void actions?: () => JSXElement }) => (
@@ -441,18 +452,16 @@ function ToolCallDetails(props: { {(actions) => {actions()}} - - {(copyText) => ( - - )} + +
) @@ -539,7 +548,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 +573,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, @@ -686,7 +696,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() @@ -699,11 +711,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) => { @@ -813,6 +821,7 @@ export default function ToolCall(props: ToolCallProps) { } const toolTypeLabel = createMemo(() => toolName()) + const renderedToolTypeLabel = createMemo(() => limitToolTitleForRender(toolTypeLabel())) const headerTitleDetail = createMemo(() => { const rawTitle = renderToolTitle().trim() @@ -834,9 +843,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 speechText = createMemo(() => buildToolSpeechText({ title: headerText(), @@ -932,8 +942,8 @@ export default function ToolCall(props: ToolCallProps) { >