From bb39dab1fcf76d46ae05d484f6ee02f005a58d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 13:09:45 +0200 Subject: [PATCH] perf(ui): harden bounded transcript memory Re-scope the renderer-memory fix on the native V2 baseline after removal of the duplicate Tauri event transport. Bound Markdown, reasoning, tool output, diagnostics, diffs, task steps, todos, and per-message part rendering while retaining complete authoritative content behind lazy copy actions. Coordinate message records and derived render caches under one 64 MiB byte-aware LRU. Protect mounted, loading, and live transcripts; cap pending parts and prompt display overrides; fence stale cache writes; and conservatively account measurement failures. Reload evicted transcripts through bounded complete V2 pagination, reject stale lifecycle completions, purge all per-instance session state, and prevent delayed SSE/delta work from recreating removed stores. Keep DEV-v2 PR validation enabled without reintroducing native Tauri transport changes. Validated with full UI and Electron typecheck, UI production build, 248 standard UI tests, 71 browser-condition UI tests, 118 Electron native tests, and git diff checks. The long-duration WebKit soak remains pending while the PR stays draft. --- .github/workflows/comment-pr-artifacts.yml | 2 +- .github/workflows/pr-build.yml | 17 +- .github/workflows/restrict-non-dev-prs.yml | 4 +- .../components/markdown-render-limit.test.ts | 17 + packages/ui/src/components/markdown.tsx | 45 ++- .../message-block-render-limit.test.ts | 57 +++ packages/ui/src/components/message-block.tsx | 220 +++++------ .../ui/src/components/message-section.tsx | 12 +- .../components/permission-approval-modal.tsx | 44 +-- packages/ui/src/components/tool-call.tsx | 55 +-- .../src/components/tool-call/diagnostics.ts | 6 + .../src/components/tool-call/diff-render.tsx | 46 +-- .../components/tool-call/permission-block.tsx | 26 +- .../tool-call/permission-constants.ts | 6 - .../tool-call/render-memory.test.ts | 133 ++++++- .../tool-call/renderer-copy.test.ts | 101 ++++- .../tool-call/renderers/apply-patch-data.ts | 124 ++++++- .../tool-call/renderers/apply-patch.tsx | 80 ++-- .../components/tool-call/renderers/bash.tsx | 7 +- .../tool-call/renderers/default.tsx | 3 +- .../components/tool-call/renderers/skill.tsx | 4 +- .../tool-call/renderers/task-summary.ts | 41 ++- .../components/tool-call/renderers/task.tsx | 132 +++++-- .../tool-call/renderers/todo-data.ts | 94 +++++ .../components/tool-call/renderers/todo.tsx | 71 ++-- .../tool-call/renderers/webfetch.tsx | 3 +- .../src/components/tool-call/search-text.ts | 35 +- packages/ui/src/components/tool-call/types.ts | 2 + packages/ui/src/components/tool-call/utils.ts | 37 +- packages/ui/src/lib/global-cache.test.ts | 93 ++++- packages/ui/src/lib/global-cache.ts | 101 ++++- .../use-active-session-message-load.test.ts | 113 ++++-- .../hooks/use-active-session-message-load.ts | 18 +- packages/ui/src/lib/hooks/use-global-cache.ts | 19 +- .../ui/src/lib/i18n/messages/de/messaging.ts | 2 - .../ui/src/lib/i18n/messages/de/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/en/messaging.ts | 2 - .../ui/src/lib/i18n/messages/en/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/es/messaging.ts | 2 - .../ui/src/lib/i18n/messages/es/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/fr/messaging.ts | 2 - .../ui/src/lib/i18n/messages/fr/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/he/messaging.ts | 2 - .../ui/src/lib/i18n/messages/he/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/ja/messaging.ts | 2 - .../ui/src/lib/i18n/messages/ja/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/ne/messaging.ts | 2 - .../ui/src/lib/i18n/messages/ne/toolCall.ts | 3 +- .../ui/src/lib/i18n/messages/ru/messaging.ts | 2 - .../ui/src/lib/i18n/messages/ru/toolCall.ts | 3 +- .../lib/i18n/messages/zh-Hans/messaging.ts | 2 - .../src/lib/i18n/messages/zh-Hans/toolCall.ts | 3 +- .../ui/src/lib/message-render-cache.test.ts | 37 ++ packages/ui/src/lib/message-render-cache.ts | 132 +++++++ packages/ui/src/lib/retained-size.test.ts | 77 ++++ packages/ui/src/lib/retained-size.ts | 125 +++++-- packages/ui/src/lib/session-search.test.ts | 35 -- packages/ui/src/lib/session-search.ts | 164 ++++----- .../ui/src/lib/session-transcript-lru.test.ts | 43 +++ packages/ui/src/lib/session-transcript-lru.ts | 41 ++- .../session-transcript-measurement.test.ts | 129 +++++++ .../src/lib/session-transcript-measurement.ts | 97 +++++ packages/ui/src/stores/delta-buffer.test.ts | 16 + packages/ui/src/stores/delta-buffer.ts | 10 + packages/ui/src/stores/instances.ts | 20 +- packages/ui/src/stores/message-v2/bus.test.ts | 60 +++ packages/ui/src/stores/message-v2/bus.ts | 18 + .../stores/message-v2/instance-store.test.ts | 291 ++++++++++++++- .../src/stores/message-v2/instance-store.ts | 344 +++++++++++++++--- .../stores/message-v2/record-display-cache.ts | 29 +- packages/ui/src/stores/message-v2/types.ts | 1 + packages/ui/src/stores/session-api.ts | 126 ++++--- packages/ui/src/stores/session-events.ts | 29 +- .../src/stores/session-message-pages.test.ts | 52 +++ .../ui/src/stores/session-message-pages.ts | 51 +++ .../src/stores/session-native-events.test.ts | 27 +- .../stores/session-request-authority.test.ts | 224 ++++++++---- packages/ui/src/stores/session-state.ts | 1 - .../src/stores/session-transcript-memory.ts | 56 +-- packages/ui/src/types/permission.ts | 23 +- 80 files changed, 3136 insertions(+), 935 deletions(-) create mode 100644 packages/ui/src/components/markdown-render-limit.test.ts create mode 100644 packages/ui/src/components/message-block-render-limit.test.ts create mode 100644 packages/ui/src/components/tool-call/renderers/todo-data.ts create mode 100644 packages/ui/src/lib/message-render-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 delete mode 100644 packages/ui/src/lib/session-search.test.ts create mode 100644 packages/ui/src/lib/session-transcript-measurement.test.ts create mode 100644 packages/ui/src/lib/session-transcript-measurement.ts create mode 100644 packages/ui/src/stores/session-message-pages.test.ts create mode 100644 packages/ui/src/stores/session-message-pages.ts diff --git a/.github/workflows/comment-pr-artifacts.yml b/.github/workflows/comment-pr-artifacts.yml index e6383bcf..e296828b 100644 --- a/.github/workflows/comment-pr-artifacts.yml +++ b/.github/workflows/comment-pr-artifacts.yml @@ -32,7 +32,7 @@ jobs: shell: bash run: | set -euo pipefail - if [ "$BASE_REF" = "dev" ]; then + if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then echo "allowed=true" >> "$GITHUB_OUTPUT" exit 0 fi diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index a87e1674..20762487 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -32,7 +32,7 @@ jobs: shell: bash run: | set -euo pipefail - if [ "$BASE_REF" = "dev" ]; then + if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then echo "allowed=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -103,13 +103,21 @@ jobs: - name: Test changed runnable UI behavior run: >- - node --import tsx --test + node --import tsx --test --test-force-exit + packages/ui/src/components/markdown-render-limit.test.ts + packages/ui/src/components/message-block-render-limit.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/lib/global-cache.test.ts packages/ui/src/lib/hooks/use-app-session-capture.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-render-cache.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/retained-size.test.ts + packages/ui/src/lib/session-transcript-lru.test.ts + packages/ui/src/lib/session-transcript-measurement.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 @@ -120,12 +128,14 @@ jobs: packages/ui/src/stores/restore-workspace-commit-gates.test.ts packages/ui/src/stores/client-state-codec.test.ts packages/ui/src/stores/client-state.test.ts + packages/ui/src/stores/delta-buffer.test.ts packages/ui/src/stores/instances-restore-cancellation.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-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/session-generation-recovery.test.ts + packages/ui/src/stores/session-message-pages.test.ts packages/ui/src/stores/session-pagination.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -133,9 +143,12 @@ jobs: - name: Test restore ownership integration run: >- node --conditions=browser --import tsx --test --test-force-exit + 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/instances-restore-ownership.test.ts + packages/ui/src/stores/message-v2/bus.test.ts packages/ui/src/stores/permission-lifecycle.test.ts + packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts diff --git a/.github/workflows/restrict-non-dev-prs.yml b/.github/workflows/restrict-non-dev-prs.yml index ab27f943..d8367109 100644 --- a/.github/workflows/restrict-non-dev-prs.yml +++ b/.github/workflows/restrict-non-dev-prs.yml @@ -14,7 +14,7 @@ permissions: jobs: restrict-non-dev-prs: - if: ${{ github.event.pull_request.base.ref != 'dev' }} + if: ${{ github.event.pull_request.base.ref != 'dev' && github.event.pull_request.base.ref != 'DEV-v2' }} runs-on: ubuntu-latest env: ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} @@ -39,7 +39,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch" + gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target the \`dev\` or \`DEV-v2\` branch. Please retarget this PR to an authorized development branch." - name: Close unauthorized PR if: ${{ steps.auth.outputs.authorized != 'true' }} diff --git a/packages/ui/src/components/markdown-render-limit.test.ts b/packages/ui/src/components/markdown-render-limit.test.ts new file mode 100644 index 00000000..1837e242 --- /dev/null +++ b/packages/ui/src/components/markdown-render-limit.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getMarkdownTextForRender } from "./markdown.tsx" +import { TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils.ts" + +test("ordinary markdown is bounded before parsing or escaping", () => { + const rendered = getMarkdownTextForRender(`${"<".repeat(20_000)}COPY_TAIL`) + assert.equal(rendered.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) + assert.equal(rendered.includes("COPY_TAIL"), false) + assert.equal(rendered.includes("Output truncated for rendering"), true) +}) + +test("truncated markdown retains the full original outside the parse input", () => { + const source = `${"x".repeat(20_000)}COPY_TAIL` + assert.equal(getMarkdownTextForRender(source).includes("COPY_TAIL"), false) + assert.equal(source.includes("COPY_TAIL"), true) +}) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 91d40793..ca2af151 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-render-limit.test.ts b/packages/ui/src/components/message-block-render-limit.test.ts new file mode 100644 index 00000000..8a185447 --- /dev/null +++ b/packages/ui/src/components/message-block-render-limit.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + extractReasoningTextForCopy, + extractReasoningTextForRender, + extractReasoningTitleForRender, + REASONING_RENDER_CHARACTER_LIMIT, + REASONING_RENDER_NODE_LIMIT, + REASONING_TITLE_CHARACTER_LIMIT, +} from "../lib/message-render-cache.ts" +import { buildRecordDisplayData, MESSAGE_PART_DISPLAY_LIMIT } from "../stores/message-v2/record-display-cache.ts" +import type { MessageRecord } from "../stores/message-v2/types.ts" + +test("caps parts before traversing or cloning the record tail", () => { + const accessed: string[] = [] + const partIds = Array.from({ length: MESSAGE_PART_DISPLAY_LIMIT + 1 }, (_, index) => `part-${index}`) + const parts = new Proxy({}, { + get(_target, partId: string) { + accessed.push(partId) + return { revision: 1, data: { id: partId, type: "text", text: partId } } + }, + }) + const record = { id: "bounded-message", revision: 1, partIds, parts } as unknown as MessageRecord + + const display = buildRecordDisplayData("render-limit-test", record) + + assert.equal(display.orderedParts.length, MESSAGE_PART_DISPLAY_LIMIT) + assert.equal(display.truncated, true) + assert.equal(accessed.length, MESSAGE_PART_DISPLAY_LIMIT) + assert.equal(accessed.includes(`part-${MESSAGE_PART_DISPLAY_LIMIT}`), false) +}) + +test("bounds reasoning display work while copy remains authoritative and lazy", () => { + const tail = "COPY_ONLY_TAIL" + const part = { type: "reasoning", text: `${"x".repeat(REASONING_RENDER_CHARACTER_LIMIT)}${tail}` } + const rendered = extractReasoningTextForRender(part) + + assert.equal(rendered.length, REASONING_RENDER_CHARACTER_LIMIT) + assert.equal(rendered.includes(tail), false) + assert.equal(extractReasoningTextForCopy(part).endsWith(tail), true) + assert.equal(extractReasoningTitleForRender(`\n**bounded title**\n${"t".repeat(REASONING_TITLE_CHARACTER_LIMIT)}`), "bounded title") +}) + +test("stops traversing nested reasoning at the display node budget", () => { + let furthestIndex = -1 + const content = new Proxy(Array.from({ length: REASONING_RENDER_NODE_LIMIT }, (_, index) => String(index)), { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) furthestIndex = Number(key) + return Reflect.get(target, key, receiver) + }, + }) + + extractReasoningTextForRender({ type: "reasoning", content }) + + assert.ok(furthestIndex >= 0) + assert.ok(furthestIndex < content.length - 1) +}) diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 3ef4e11e..08f93790 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,48 +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 && cache.recordDisplayCache.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")) @@ -257,6 +213,7 @@ interface ContentDisplayItem { key: string messageId: string startPartId: string + partCount: number } interface ToolDisplayItem { @@ -272,6 +229,7 @@ interface MessageContentItemProps { store: () => InstanceMessageStore messageId: string startPartId: string + partCount: number messageIndex: number lastAssistantIndex: () => number onRevert?: (messageId: string) => void @@ -315,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 @@ -478,7 +436,7 @@ interface StepDisplayItem { type ReasoningDisplayItem = { type: "reasoning" key: string - part: ClientPart + text: string messageInfo?: MessageInfo durationMs?: number showAgentMeta?: boolean @@ -500,8 +458,9 @@ type CompactionDisplayItem = { type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem interface MessageDisplayBlock { - record: MessageRecord + messageId: string items: MessageBlockItem[] + truncated: boolean } interface MessageBlockProps { @@ -527,12 +486,23 @@ 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 + recordDisplayCache: 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?.() ?? "" @@ -582,7 +552,9 @@ 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) + sessionCache.recordDisplayCache.set(current.id, { revision: current.revision, data: displayData }) + const { orderedParts } = displayData const items: MessageBlockItem[] = [] const blockContentKeys: string[] = [] const blockToolKeys: string[] = [] @@ -611,8 +583,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) @@ -690,7 +665,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 @@ -700,7 +676,7 @@ export default function MessageBlock(props: MessageBlockProps) { items.push({ type: "reasoning", key, - part, + text, messageInfo: info, durationMs: inferReasoningDurationMs(orderedParts, part, info, current.status), showAgentMeta, @@ -718,13 +694,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) { @@ -755,13 +732,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} > @@ -774,12 +751,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} /> @@ -794,7 +772,7 @@ export default function MessageBlock(props: MessageBlockProps) { store={props.store} messageId={toolItem.messageId} partId={toolItem.partId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -822,7 +800,7 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -837,7 +815,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} @@ -845,13 +827,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")} + +
+
)} @@ -859,6 +858,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 @@ -1063,7 +1069,9 @@ function formatCostValue(value: number) { } interface ReasoningCardProps { - part: ClientPart + text: string + partId: string + copyText: () => string messageInfo?: MessageInfo durationMs?: number instanceId: string @@ -1171,51 +1179,9 @@ function ReasoningCard(props: ReasoningCardProps) { return modelID } - const reasoningText = () => { - const part = props.part as any - if (!part) return "" + const reasoningText = () => props.text - 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 match = firstLine.match(/^\*\*([^*]+)\*\*/) - return match?.[1]?.trim() ?? "" - } + const extractedTitle = () => extractReasoningTitleForRender(reasoningText()) const thoughtDurationTitle = () => { const duration = props.durationMs @@ -1257,14 +1223,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) } @@ -1296,7 +1262,7 @@ function ReasoningCard(props: ReasoningCardProps) { return (
- - - = SEARCH_MIN_CHARS && isSearchPending()}> diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 221be2d9..b2de5f11 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -13,9 +13,7 @@ import { } from "../stores/instances" import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" -import { isPermissionDiffTooLarge, PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" -import { copyToClipboard } from "../lib/clipboard" -import { limitToolTitleForRender } from "./tool-call/utils" +import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" const LazyToolCall = lazy(() => import("./tool-call")) @@ -141,20 +139,6 @@ const PermissionApprovalModal: Component = (props) const [permissionSubmitting, setPermissionSubmitting] = createSignal>(new Set()) const [permissionError, setPermissionError] = createSignal>(new Map()) const [permissionRejectReasons, setPermissionRejectReasons] = createSignal>(new Map()) - const [accessedDiffs, setAccessedDiffs] = createSignal>(new Map()) - - const permissionDiff = (permission: PermissionRequest): string => { - const metadata = (permission.metadata ?? {}) as Record - return typeof metadata.diff === "string" ? metadata.diff : "" - } - - const approvalBlocked = (permission: PermissionRequest) => - isPermissionDiffTooLarge(permissionDiff(permission)) && accessedDiffs().get(permission.id) !== permissionDiff(permission) - - const copyFullPermissionDiff = async (permission: PermissionRequest) => { - if (!await copyToClipboard(permissionDiff(permission))) return - setAccessedDiffs((previous) => new Map(previous).set(permission.id, permissionDiff(permission))) - } const getPermissionRejectReason = (permissionId: string) => permissionRejectReasons().get(permissionId) ?? "" @@ -190,7 +174,6 @@ const PermissionApprovalModal: Component = (props) if (!permissionId) return if (permissionSubmitting().has(permissionId)) return - if (response !== "reject" && approvalBlocked(permission)) return setPermissionBusy(permissionId, true) setPermissionItemError(permissionId, null) @@ -214,11 +197,6 @@ const PermissionApprovalModal: Component = (props) const questionQueue = createMemo(() => getQuestionQueue(props.instanceId)) const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) - createEffect(() => { - const current = new Map(permissionQueue().map((permission) => [permission.id, permissionDiff(permission)])) - setAccessedDiffs((previous) => new Map([...previous].filter(([id, diff]) => current.get(id) === diff))) - }) - type InterruptionItem = | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequest } | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } @@ -339,10 +317,10 @@ const PermissionApprovalModal: Component = (props) const primaryTitle = () => { if (item.kind === "permission") { - return limitToolTitleForRender(getPermissionDisplayTitle(item.payload)) + return getPermissionDisplayTitle(item.payload) } const first = item.payload.questions?.[0]?.question - return typeof first === "string" && first.trim().length > 0 ? limitToolTitleForRender(first) : t("permissionApproval.kind.question") + return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question") } const secondaryTitle = () => { @@ -406,18 +384,6 @@ const PermissionApprovalModal: Component = (props) {primaryTitle()}
- -
- {t("toolCall.permission.fullDiffRequired")} - -
-