diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts index 5309dce8f..639a16df2 100644 --- a/tools/ui/src/app.d.ts +++ b/tools/ui/src/app.d.ts @@ -137,7 +137,6 @@ declare global { declare global { interface Window { - idxThemeStyle?: number; idxCodeBlock?: number; // File System Access API - not in the DOM lib and unavailable in some browsers diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte index fa2a50bc5..46d05338b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -404,7 +404,7 @@ } -
+
{#if message.role === MessageRole.SYSTEM} {:else if mcpPromptExtra} @@ -425,25 +425,3 @@ /> {/if}
- - diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte index a2c742f0f..dac55caff 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte @@ -82,8 +82,11 @@ let lastUserMessageHeight = $state(0); let assistantMarginTop = $state(0); + // The measured CSS vars feed the :last-child min-height rule only, so only + // the last assistant message needs them. Reading isLastAssistantMessage + // here also re-runs the effect when this message stops being the last. $effect(() => { - if (!assistantEl) return; + if (!assistantEl || !isLastAssistantMessage) return; assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop)); diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte index a604a97e3..cc2b4a562 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte @@ -13,7 +13,12 @@ import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte'; import { BuiltInTool } from '$lib/enums'; import type { AgenticSection, DatabaseMessageExtra } from '$lib/types'; - import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils'; + import { + extractSearchQuery, + extractSearchResults, + isWebSearchToolName, + looksLikeSearchResult + } from '$lib/utils'; interface Props { section: AgenticSection; @@ -26,11 +31,16 @@ let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props(); - const searchResults = $derived(extractSearchResults(section.toolResult)); - const searchQuery = $derived(extractSearchQuery(section.toolArgs)); - const isSearchCall = $derived( - searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName)) - ); + // Runs for every tool block on mount, before the body renders: the cheap + // content prefilter and the tool-name allow-list come first so blobs from + // exec/file tools are never line-split or JSON-parsed here + const isSearchCall = $derived.by(() => { + if (looksLikeSearchResult(section.toolResult)) { + return extractSearchResults(section.toolResult).length > 0; + } + + return isWebSearchToolName(section.toolName) && extractSearchQuery(section.toolArgs).length > 0; + }); {#if isSearchCall} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte index 2067e4268..22ffc256b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte @@ -1,5 +1,5 @@ @@ -45,11 +49,11 @@ {meta.errorMessage}
- {:else if meta && meta.edits.length > 0} + {:else if meta && editFileBody && editFileBody.edits.length > 0} {#each editDiffs as diffLines, ei (ei)}
- Edit {ei + 1} of {meta.edits.length} + Edit {ei + 1} of {editFileBody.edits.length}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte index 178c479d9..cafa5280b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte @@ -1,5 +1,5 @@ @@ -45,7 +49,7 @@
{:else if meta} | null { } } +// Compiled per key on first use; the key set is tiny and fixed. +const toolArgStringRegexes = new Map(); + +/** + * Extract a string field from a JSON tool-args blob without parsing the + * whole document. write_file and edit_file args embed full file contents, + * yet the block title needs only the path; a targeted key match plus a + * JSON.parse of the captured string literal alone keeps title rendering + * O(path) instead of O(blob). Returns undefined when the key is missing + * or its value is not a string; callers fall back to the full parse. + */ +export function extractToolArgString( + toolArgs: string, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + let pattern = toolArgStringRegexes.get(key); + + if (!pattern) { + pattern = new RegExp(TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE.replace('{key}', key)); + toolArgStringRegexes.set(key, pattern); + } + + const match = pattern.exec(toolArgs); + + if (!match) continue; + + try { + const value: unknown = JSON.parse(`"${match[1]}"`); + + if (typeof value === 'string') return value; + } catch { + // fall through to the next key; the full parse is the fallback + } + } + + return undefined; +} + /** * Parse a section's toolArgs against an expected tool name. Returns * `null` when: diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts index 9ed6f92bc..d711466cb 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts @@ -3,26 +3,12 @@ // rendering), plus the result blob for `result` / `edits_applied` / // `error` fields. -import { parseToolArgs } from './_shared'; -import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants'; +import { extractToolArgString, parseToolArgs } from './_shared'; +import { FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants'; import { BuiltInTool } from '$lib/enums'; -import type { AgenticSection } from '$lib/types'; +import type { AgenticSection, EditFileEdit, EditFileMeta, EditFileTitleMeta } from '$lib/types'; import { tryParseToolResultObject } from '$lib/utils'; -export type EditFileEdit = { - oldText: string; - newText: string; -}; - -export type EditFileMeta = { - fileName: string; - filePath: string; - edits: EditFileEdit[]; - resultMessage?: string; - editsApplied?: number; - errorMessage?: string; -}; - export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null { const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true }); @@ -79,3 +65,45 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null resultMessage }; } + +/** + * Title-tier meta for edit_file blocks: everything the header and status + * pill render, obtained without parsing the embedded edit strings. The path + * comes from a targeted key extraction; the full parse runs only as a + * fallback for arg shapes the extraction can't see. + */ +export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMeta | null { + if (section.toolName !== BuiltInTool.SERVER_EDIT_FILE || !section.toolArgs) return null; + + let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS); + + if (!rawPath) { + const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true }); + const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath; + + if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath; + } + + if (!rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + const resultObj = tryParseToolResultObject(section.toolResult); + + let resultMessage: string | undefined; + let editsApplied: number | undefined; + let errorMessage: string | undefined; + + if (typeof resultObj?.error === 'string') { + errorMessage = resultObj.error; + } else if (resultObj) { + if (typeof resultObj.result === 'string') { + resultMessage = resultObj.result; + } + + if (Number.isFinite(Number(resultObj.edits_applied))) { + editsApplied = Number(resultObj.edits_applied); + } + } + + return { editsApplied, errorMessage, fileName, filePath: rawPath, resultMessage }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts index 440a1f5d6..bd97cd2fe 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts @@ -6,6 +6,7 @@ // are handled. import { parseToolArgs } from './_shared'; +import { JSON_ARRAY_OPEN, JSON_OBJECT_OPEN } from '$lib/constants'; import { BuiltInTool } from '$lib/enums'; import type { AgenticSection } from '$lib/types'; @@ -38,14 +39,21 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe // do we scan raw lines for the `Error:` prefix. let parsedObject: Record | null = null; - try { - const parsed: unknown = JSON.parse(toolResultString); + // Successful sandbox output is a JSON array, errors are objects; plain + // text (huge console logs) fails the parse below anyway, so only try + // when the blob starts with a JSON container + const trimmedResult = toolResultString.trimStart(); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - parsedObject = parsed as Record; + if (trimmedResult[0] === JSON_OBJECT_OPEN || trimmedResult[0] === JSON_ARRAY_OPEN) { + try { + const parsed: unknown = JSON.parse(trimmedResult); + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedObject = parsed as Record; + } + } catch { + parsedObject = null; } - } catch { - parsedObject = null; } if (typeof parsedObject?.error === 'string') { diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts index 5b9bf9f88..4a8e1a9c9 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts @@ -3,22 +3,12 @@ // finishes) and surfaces `bytes`, `result`, and `error` from the // result blob. -import { parseToolArgs } from './_shared'; -import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants'; +import { extractToolArgString, parseToolArgs } from './_shared'; +import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants'; import { BuiltInTool } from '$lib/enums'; -import type { AgenticSection } from '$lib/types'; +import type { AgenticSection, WriteFileMeta, WriteFileTitleMeta } from '$lib/types'; import { getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils'; -export type WriteFileMeta = { - fileName: string; - filePath: string; - language: string; - content: string; - bytesWritten?: number; - resultMessage?: string; - errorMessage?: string; -}; - export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null { const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true }); @@ -51,3 +41,43 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul resultMessage }; } + +/** + * Title-tier meta for write_file blocks: everything the header and status + * pill render, obtained without parsing the embedded file content. The path + * comes from a targeted key extraction; the full parse runs only as a + * fallback for arg shapes the extraction can't see. + */ +export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitleMeta | null { + if (section.toolName !== BuiltInTool.SERVER_WRITE_FILE || !section.toolArgs) return null; + + let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS); + + if (!rawPath) { + const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true }); + const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath; + + if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath; + } + + if (!rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + const language = + getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ?? + CODE_BLOCK.DEFAULT_LANGUAGE; + const resultObj = tryParseToolResultObject(section.toolResult); + const bytesWritten = + resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined; + const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined; + const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined; + + return { + bytesWritten, + errorMessage, + fileName, + filePath: rawPath, + language, + resultMessage + }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index 5137e261f..ea9428e07 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -46,49 +46,44 @@ isLastAssistantMessage ? !!agenticStore.getLastError(message.convId) : false ); - let permissionDismissed = $state(false); - const pendingPermission = $derived( isStreaming && isLastAssistantMessage ? agenticStore.getPendingPermissionRequest(message.convId) : null ); - let prevPendingRef: typeof pendingPermission = null; - $effect(() => { - if (pendingPermission !== prevPendingRef) { - prevPendingRef = pendingPermission; + // dismissal applies to the request object, so the next request ( new + // identity ) shows the card again without any reset bookkeeping + let dismissedPermission: typeof pendingPermission = $state(null); - if (pendingPermission) { - permissionDismissed = false; - } - } - }); + const visiblePermission = $derived( + pendingPermission && dismissedPermission !== pendingPermission ? pendingPermission : null + ); function handlePermission(decision: ToolPermissionDecision) { - permissionDismissed = true; + dismissedPermission = pendingPermission; agenticStore.resolvePermission(message.convId, decision); } - let continueDismissed = $state(false); - const pendingContinue = $derived( isStreaming && isLastAssistantMessage ? agenticStore.getPendingContinueRequest(message.convId) : false ); - let prevContinueRef = false; - $effect(() => { - if (pendingContinue !== prevContinueRef) { - prevContinueRef = pendingContinue; + let continueDismissed = $state(false); - if (pendingContinue) { - continueDismissed = false; - } + // the continue request is a plain boolean, so there is no identity to + // compare against; clear the dismissal whenever no request is pending so + // the next one starts from a clean state + $effect(() => { + if (!pendingContinue) { + continueDismissed = false; } }); + const showContinue = $derived(Boolean(pendingContinue) && !continueDismissed); + function handleContinue(shouldContinue: boolean) { continueDismissed = true; agenticStore.resolveContinue(message.convId, shouldContinue); @@ -238,15 +233,15 @@ {/each} {/if} - {#if pendingPermission && !permissionDismissed} + {#if visiblePermission} {/if} - {#if pendingContinue && !continueDismissed} + {#if showContinue} {/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte index 4750a9f7c..0078225c0 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -1,5 +1,6 @@ -
- {#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)} - - {/each} - - {#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)} - {@const convId = conversationsStore.activeConversation!.id} - {@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)} - - {#if pendingContent} - agenticStore.clearSteeringMessage(convId)} - onEdit={(newContent, extras) => - agenticStore.injectSteeringMessage(convId, newContent, extras)} - onSendImmediately={() => chatStore.abortCurrentFlow(convId)} + +{#key conversationsStore.activeConversation?.id ?? 'new'} +
+ {#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)} + - {/if} - {:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)} - {@const convId = conversationsStore.activeConversation!.id} - {@const pendingContent = chatStore.getPendingMessageContent(convId)} + {/each} - {#if pendingContent} - chatStore.clearPendingMessage(convId)} - onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)} - onSendImmediately={() => chatStore.abortCurrentFlow(convId)} - /> + {#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)} + {@const convId = conversationsStore.activeConversation!.id} + {@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)} + + {#if pendingContent} + agenticStore.clearSteeringMessage(convId)} + onEdit={(newContent, extras) => + agenticStore.injectSteeringMessage(convId, newContent, extras)} + onSendImmediately={() => chatStore.abortCurrentFlow(convId)} + /> + {/if} + {:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)} + {@const convId = conversationsStore.activeConversation!.id} + {@const pendingContent = chatStore.getPendingMessageContent(convId)} + + {#if pendingContent} + chatStore.clearPendingMessage(convId)} + onEdit={(newContent, extras) => + chatStore.injectPendingMessage(convId, newContent, extras)} + onSendImmediately={() => chatStore.abortCurrentFlow(convId)} + /> + {/if} {/if} - {/if} -
+
+{/key} + + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte new file mode 100644 index 000000000..f9667bbbb --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte @@ -0,0 +1,105 @@ + + +
+ {#if mounted} + + {/if} +
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index 3ad3f2468..6cea95d0d 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -315,13 +315,18 @@
bottomed move with transform, not bottom: + // layout-property transitions need the main thread every frame and + // stutter while a long conversation loads; transform transitions + // run on the compositor and stay smooth + 'pointer-events-none md:sticky fixed mt-auto transition-transform duration-200', deviceStore.isStandalone ? 'bottom-6 right-4 left-4' : deviceStore.isIOSSafari ? 'bottom-1 left-2 right-2' : 'bottom-2 right-2 left-2', - isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4' + 'md:bottom-4', + isEmpty ? 'md:translate-y-[calc(-50dvh+8rem)] 2xl:translate-y-[calc(-50dvh+5rem)]' : '' ]} > diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index 87b41bd00..c217a769a 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -1,23 +1,12 @@ diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts new file mode 100644 index 000000000..e973a6a4b --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-processor.ts @@ -0,0 +1,112 @@ +// Shared remark/rehype pipeline factory for MarkdownContent. +// +// The frozen plugin chain is expensive to build ( ~15 plugin instances ), +// and MarkdownContent used to rebuild it on every processMarkdown call: +// once per block at mount, and again on every coalesced chunk while +// streaming. Pipelines without attachments are shared process-wide per +// math flag; attachment-bearing pipelines are cached by the attachments +// array identity, which changes whenever extras are updated. + +import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks'; +import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links'; +import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks'; +import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks'; +import { rehypeFileBadge } from './plugins/rehype/file-badge'; +import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre'; +import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support'; +import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images'; +import { rehypeSvgPre } from './plugins/rehype/svg-pre'; +import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer'; +import { remarkLiteralHtml } from './plugins/remark/literal-html'; +import { FileTypeText } from '$lib/enums/files.enums'; +import type { DatabaseMessageExtra } from '$lib/types/database'; +import type { Root as HastRoot } from 'hast'; +import { all as lowlightAll } from 'lowlight'; +import type { Root as MdastRoot } from 'mdast'; +import rehypeHighlight from 'rehype-highlight'; +import rehypeKatex from 'rehype-katex'; +import rehypeStringify from 'rehype-stringify'; +import { remark } from 'remark'; +import remarkBreaks from 'remark-breaks'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import remarkRehype from 'remark-rehype'; + +export interface MarkdownProcessor { + parse(markdown: string): MdastRoot; + run(tree: MdastRoot): Promise; + stringify(tree: HastRoot): string; +} + +export interface MarkdownProcessorOptions { + attachments?: DatabaseMessageExtra[]; + disableMath?: boolean; +} + +const sharedPipelines = new Map(); +const attachmentPipelines = new WeakMap(); + +function buildPipeline({ + attachments, + disableMath = false +}: MarkdownProcessorOptions): MarkdownProcessor { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown + + if (!disableMath) { + proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math + } + + proc = proc + .use(remarkBreaks) // Convert line breaks to
+ // Treat raw HTML as literal text with preserved indentation + .use(remarkLiteralHtml) + .use(remarkRehype); // Convert Markdown AST to rehype + + if (!disableMath) { + proc = proc.use(rehypeKatex); // Render math using KaTeX + } + + const pipeline = proc + .use(rehypeHighlight, { + aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }, + languages: lowlightAll + }) // Add syntax highlighting + .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.
,
    ) inside Markdown tables + .use(rehypeEnhanceLinks) // Add target="_blank" to links + .use(rehypeFileBadge) // Render file:// anchors as inline badge chips + .use(rehypeMermaidPre) // Convert mermaid blocks to
    +		.use(rehypeSvgPre) // Convert svg blocks to 
    +		.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
    +		.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
    +		.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
    +		.use(rehypeResolveAttachmentImages, { attachments })
    +		.use(rehypeRtlSupport) // Add bidirectional text support
    +		.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
    +
    +	return pipeline as MarkdownProcessor;
    +}
    +
    +export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
    +	if (options.attachments && options.attachments.length > 0) {
    +		let cached = attachmentPipelines.get(options.attachments);
    +
    +		if (!cached) {
    +			cached = buildPipeline(options);
    +			attachmentPipelines.set(options.attachments, cached);
    +		}
    +
    +		return cached;
    +	}
    +
    +	const key = String(Boolean(options.disableMath));
    +
    +	let cached = sharedPipelines.get(key);
    +
    +	if (!cached) {
    +		cached = buildPipeline(options);
    +		sharedPipelines.set(key, cached);
    +	}
    +
    +	return cached;
    +}
    diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
    index e3241373e..d93ae6429 100644
    --- a/tools/ui/src/lib/constants/index.ts
    +++ b/tools/ui/src/lib/constants/index.ts
    @@ -16,6 +16,7 @@ export * from './context-gauge-popup.constants';
     export * from './conversation-import.constants';
     export * from './binary-detection.constants';
     export * from './content-detection.constants';
    +export * from './tool-call-args.constants';
     export * from './tool-ui.constants';
     export * from './cache.constants';
     export * from './chat-form.constants';
    diff --git a/tools/ui/src/lib/constants/tool-call-args.constants.ts b/tools/ui/src/lib/constants/tool-call-args.constants.ts
    new file mode 100644
    index 000000000..e74260be2
    --- /dev/null
    +++ b/tools/ui/src/lib/constants/tool-call-args.constants.ts
    @@ -0,0 +1,23 @@
    +// Tool-args and tool-result parsing helpers: the file tools' path field
    +// aliases, the JSON container gates for result blobs, and the targeted
    +// string-field pattern used for cheap title-tier extraction.
    +
    +/**
    + * Field aliases the file tools accept for the path argument. Tool contracts
    + * drifted over time: some models emit `file_path` / `filePath`.
    + */
    +export const TOOL_ARG_PATH_KEYS: readonly string[] = ['path', 'file_path', 'filePath'];
    +
    +/** Opening character of a JSON object; only an object root can carry fields. */
    +export const JSON_OBJECT_OPEN = '{';
    +
    +/** Opening character of a JSON array; successful sandbox output is one. */
    +export const JSON_ARRAY_OPEN = '[';
    +
    +/**
    + * Matches `"": ""` in a JSON args blob ( whitespace between
    + * tokens allowed ), capturing the raw string literal so only that literal
    + * gets decoded; escaped quotes stay inside the value group. `{key}` is
    + * replaced with the field name before use.
    + */
    +export const TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE = '"{key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"';
    diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts
    index 296c2cca5..4bdcc6845 100644
    --- a/tools/ui/src/lib/stores/chat/index.svelte.ts
    +++ b/tools/ui/src/lib/stores/chat/index.svelte.ts
    @@ -55,7 +55,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
     		string,
     		{ response: string; messageId: string; model?: string | null }
     	>();
    -	currentResponse = $state('');
     	errorDialogState = $state(null);
     	// true while the active conversation has a local pipe (send, attach or resume-wait)
     	isLoading = $derived(this.activity.isLocal(conversationsStore.activeConversation?.id ?? ''));
    @@ -256,8 +255,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
     		}
     
     		this.chatStreamingStates.delete(convId);
    -
    -		if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
     	}
     	clearEditMode(): void {
     		this.isEditModeActive = false;
    @@ -272,11 +269,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
     		this.pendingMessages.delete(convId);
     	}
     
    -	/** Reset per-view state when (re)mounting the empty chat screen. */
    -	clearUIState(): void {
    -		this.currentResponse = '';
    -	}
    -
     	consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
     		if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
     
    @@ -766,8 +758,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
     			model: model ?? this.chatStreamingStates.get(convId)?.model,
     			response
     		});
    -
    -		if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
     	}
     
     	setEditModeActive(handler: (files: File[]) => void): void {
    @@ -1244,7 +1234,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
     	syncLoadingStateForChat(convId: string): void {
     		const s = this.chatStreamingStates.get(convId);
     
    -		this.currentResponse = s?.response || '';
     		this.processing.setActiveConversation(convId);
     
     		// Sync streaming content to activeMessages so UI displays current content
    diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts
    index df5b1ecef..c4fea2e4e 100644
    --- a/tools/ui/src/lib/stores/conversations/index.svelte.ts
    +++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts
    @@ -52,6 +52,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
     	/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
     	private initPromise: Promise | null = null;
     
    +	/**
    +	 * Messages loadConversation just read, handed off once so the chat
    +	 * screen can reuse them for sibling info instead of re-fetching the
    +	 * whole conversation a second time.
    +	 */
    +	private lastLoadedMessages: { convId: string; messages: DatabaseMessage[] } | null = null;
    +
     	/**
     	 * Memo of the last findMessageIndex() lookup. Streaming calls it once per
     	 * chunk for the same message, so a validated cache hit keeps that O(1)
    @@ -88,7 +95,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
     		}
     
     		if (this.activeConversation?.id === id) {
    -			this.activeConversation = { ...this.activeConversation, ...updates };
    +			// field-wise, not object replacement: effects that track the active
    +			// conversation identity would otherwise refire on every rename or pin
    +			const target = this.activeConversation as unknown as Record;
    +
    +			for (const [key, value] of Object.entries(updates)) {
    +				if (target[key] !== value) target[key] = value;
    +			}
     		}
     	}
     
    @@ -202,11 +215,8 @@ class ConversationsStore implements ConversationsPreferencesHost {
     			const updates = await DatabaseService.bulkToggleConversationPins(convIds);
     			const activeId = this.activeConversation?.id;
     
    -			if (activeId && updates.has(activeId)) {
    -				this.activeConversation = {
    -					...this.activeConversation!,
    -					pinned: updates.get(activeId)!
    -				};
    +			if (this.activeConversation && activeId && updates.has(activeId)) {
    +				this.activeConversation.pinned = updates.get(activeId)!;
     			}
     
     			for (let i = 0; i < this.conversations.length; i++) {
    @@ -236,6 +246,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
     		this.preferences.resetPending();
     	}
     
    +	/** One-shot handoff of the messages the last loadConversation read. */
    +	consumeLastLoadedMessages(convId: string): DatabaseMessage[] | null {
    +		if (this.lastLoadedMessages?.convId !== convId) return null;
    +
    +		const messages = this.lastLoadedMessages.messages;
    +
    +		this.lastLoadedMessages = null;
    +
    +		return messages;
    +	}
    +
     	/**
     	 * Creates a new conversation and navigates to it
     	 * @param name - Optional name for the conversation
    @@ -509,22 +530,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
     			// it doesn't belong to this conversation.
     			this.preferences.pendingCwd = null;
     
    +			const allMessages = await DatabaseService.getConversationMessages(convId);
    +
    +			// set conversation and messages in one sync block so effects never see
    +			// the new conversation with the previous conversation's messages
    +			this.lastLoadedMessages = { convId, messages: allMessages };
     			this.activeConversation = conversation;
    -
    -			if (conversation.currNode) {
    -				const allMessages = await DatabaseService.getConversationMessages(convId);
    -				const filteredMessages = filterByLeafNodeId(
    -					allMessages,
    -					conversation.currNode,
    -					false
    -				) as DatabaseMessage[];
    -
    -				this.activeMessages = filteredMessages;
    -			} else {
    -				const messages = await DatabaseService.getConversationMessages(convId);
    -
    -				this.activeMessages = messages;
    -			}
    +			this.activeMessages = conversation.currNode
    +				? (filterByLeafNodeId(allMessages, conversation.currNode, false) as DatabaseMessage[])
    +				: allMessages;
     
     			return true;
     		} catch (error) {
    @@ -558,7 +572,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
     		const currentLeafNodeId = findLeafNode(allMessages, siblingId);
     
     		await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
    -		this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
    +		this.activeConversation.currNode = currentLeafNodeId;
     		await this.refreshActiveMessages();
     
     		if (rootMessage && this.activeMessages.length > 0) {
    @@ -694,7 +708,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
     		}
     
     		if (this.activeConversation?.id === targetId) {
    -			this.activeConversation = { ...this.activeConversation, lastModified: now };
    +			this.activeConversation.lastModified = now;
     		}
     
     		DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
    @@ -710,7 +724,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
     		if (!this.activeConversation) return;
     
     		await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
    -		this.activeConversation = { ...this.activeConversation, currNode: nodeId };
    +		this.activeConversation.currNode = nodeId;
     	}
     
     	/**
    diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
    index d91c2811a..333c1bd3c 100644
    --- a/tools/ui/src/lib/types/index.ts
    +++ b/tools/ui/src/lib/types/index.ts
    @@ -209,7 +209,16 @@ export type {
     export type { DesktopIconStripItem } from './navigation';
     
     // Tools types
    -export type { ToolEntry, ToolGroup, ToolUiEntry } from './tools';
    +export type {
    +	EditFileEdit,
    +	EditFileMeta,
    +	EditFileTitleMeta,
    +	ToolEntry,
    +	ToolGroup,
    +	ToolUiEntry,
    +	WriteFileMeta,
    +	WriteFileTitleMeta
    +} from './tools';
     
     // Reasoning
     export type { ReasoningEffortLevel } from './reasoning';
    diff --git a/tools/ui/src/lib/types/tools.d.ts b/tools/ui/src/lib/types/tools.d.ts
    index edcec65c7..fa8963bd1 100644
    --- a/tools/ui/src/lib/types/tools.d.ts
    +++ b/tools/ui/src/lib/types/tools.d.ts
    @@ -31,3 +31,50 @@ export interface ToolGroup {
     	serverId?: string;
     	tools: ToolEntry[];
     }
    +
    +export interface WriteFileMeta {
    +	fileName: string;
    +	filePath: string;
    +	language: string;
    +	content: string;
    +	bytesWritten?: number;
    +	resultMessage?: string;
    +	errorMessage?: string;
    +}
    +
    +/** Everything the write_file block title and status pill show; the full meta
    + *  ( with the embedded file content ) stays body-only so collapsed blocks
    + *  never parse the content blob. */
    +export interface WriteFileTitleMeta {
    +	fileName: string;
    +	filePath: string;
    +	language: string;
    +	bytesWritten?: number;
    +	resultMessage?: string;
    +	errorMessage?: string;
    +}
    +
    +export interface EditFileEdit {
    +	oldText: string;
    +	newText: string;
    +}
    +
    +export interface EditFileMeta {
    +	fileName: string;
    +	filePath: string;
    +	edits: EditFileEdit[];
    +	resultMessage?: string;
    +	editsApplied?: number;
    +	errorMessage?: string;
    +}
    +
    +/** Everything the edit_file block title and status pill show; the full meta
    + *  ( with the embedded edit strings ) stays body-only so collapsed blocks
    + *  never parse the args blob. */
    +export interface EditFileTitleMeta {
    +	fileName: string;
    +	filePath: string;
    +	resultMessage?: string;
    +	editsApplied?: number;
    +	errorMessage?: string;
    +}
    diff --git a/tools/ui/src/lib/utils/agentic.ts b/tools/ui/src/lib/utils/agentic.ts
    index cd150c5ef..28b3f43ee 100644
    --- a/tools/ui/src/lib/utils/agentic.ts
    +++ b/tools/ui/src/lib/utils/agentic.ts
    @@ -109,6 +109,89 @@ function deriveSingleTurnSections(
     	return sections;
     }
     
    +interface TurnSectionsCacheEntry {
    +	content: string | undefined;
    +	extra: DatabaseMessageExtra[] | undefined;
    +	reasoningContent: string | undefined;
    +	toolCalls: string | undefined;
    +	toolMessageContents: (string | undefined)[];
    +	toolMessageExtras: (DatabaseMessageExtra[] | undefined)[];
    +	toolMessages: DatabaseMessage[];
    +	sections: AgenticSection[];
    +}
    +
    +const turnSectionsCache = new WeakMap();
    +
    +function isTurnCacheValid(
    +	entry: TurnSectionsCacheEntry,
    +	message: DatabaseMessage,
    +	toolMessages: DatabaseMessage[]
    +): boolean {
    +	if (
    +		entry.content !== message.content ||
    +		entry.reasoningContent !== message.reasoningContent ||
    +		entry.toolCalls !== message.toolCalls ||
    +		entry.extra !== message.extra
    +	) {
    +		return false;
    +	}
    +
    +	if (entry.toolMessages.length !== toolMessages.length) return false;
    +
    +	for (let i = 0; i < toolMessages.length; i++) {
    +		if (entry.toolMessages[i] !== toolMessages[i]) return false;
    +
    +		if (entry.toolMessageContents[i] !== toolMessages[i].content) return false;
    +
    +		if (entry.toolMessageExtras[i] !== toolMessages[i].extra) return false;
    +	}
    +
    +	return true;
    +}
    +
    +/**
    + * deriveSingleTurnSections with structural reuse for completed turns.
    + *
    + * deriveAgenticSections runs in a $derived invalidated per streamed chunk, but
    + * only the last turn actually changes. Messages mutate in place and are never
    + * replaced, so a WeakMap keyed by the turn's assistant message plus reference
    + * checks on every field deriveSingleTurnSections reads detects any change. A
    + * cache hit also returns the same section objects, keeping downstream props
    + * stable so tool blocks skip their per-chunk re-derive. The streaming turn
    + * recomputes uncached on every chunk.
    + */
    +function deriveTurnSections(
    +	message: DatabaseMessage,
    +	toolMessages: DatabaseMessage[],
    +	streamingToolCalls: ApiChatCompletionToolCall[],
    +	isStreaming: boolean
    +): AgenticSection[] {
    +	if (isStreaming || streamingToolCalls.length > 0) {
    +		return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
    +	}
    +
    +	const cached = turnSectionsCache.get(message);
    +
    +	if (cached && isTurnCacheValid(cached, message, toolMessages)) {
    +		return cached.sections;
    +	}
    +
    +	const sections = deriveSingleTurnSections(message, toolMessages, [], false);
    +
    +	turnSectionsCache.set(message, {
    +		content: message.content,
    +		extra: message.extra,
    +		reasoningContent: message.reasoningContent,
    +		sections,
    +		toolCalls: message.toolCalls,
    +		toolMessageContents: toolMessages.map((tm) => tm.content),
    +		toolMessageExtras: toolMessages.map((tm) => tm.extra),
    +		toolMessages
    +	});
    +
    +	return sections;
    +}
    +
     /**
      * Derives display sections from structured message data.
      *
    @@ -132,13 +215,13 @@ export function deriveAgenticSections(
     	const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
     
     	if (!hasAssistantContinuations) {
    -		return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
    +		return deriveTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
     	}
     
     	const sections: AgenticSection[] = [];
     	const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
     
    -	sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
    +	sections.push(...deriveTurnSections(message, firstTurnToolMsgs, [], false));
     
     	let i = firstTurnToolMsgs.length;
     
    @@ -150,7 +233,7 @@ export function deriveAgenticSections(
     			const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
     
     			sections.push(
    -				...deriveSingleTurnSections(
    +				...deriveTurnSections(
     					msg,
     					turnToolMsgs,
     					isLastTurn ? streamingToolCalls : [],
    diff --git a/tools/ui/src/lib/utils/branching.ts b/tools/ui/src/lib/utils/branching.ts
    index 6c2c895cb..43d33d424 100644
    --- a/tools/ui/src/lib/utils/branching.ts
    +++ b/tools/ui/src/lib/utils/branching.ts
    @@ -105,18 +105,34 @@ export function filterByLeafNodeId(
      */
     function findLeafNodeInMap(
     	nodeMap: ReadonlyMap,
    -	messageId: string
    +	messageId: string,
    +	leafCache?: Map
     ): string {
    +	const path: string[] = [];
    +
     	let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
     
     	while (currentNode && currentNode.children.length > 0) {
     		// Follow the last child (most recent branch)
    +		const cached = leafCache?.get(currentNode.id);
    +
    +		if (cached !== undefined) {
    +			for (const id of path) leafCache?.set(id, cached);
    +
    +			return cached;
    +		}
    +
    +		path.push(currentNode.id);
     		const lastChildId = currentNode.children[currentNode.children.length - 1];
     
     		currentNode = nodeMap.get(lastChildId);
     	}
     
    -	return currentNode?.id ?? messageId;
    +	const leafId = currentNode?.id ?? messageId;
    +
    +	for (const id of path) leafCache?.set(id, leafId);
    +
    +	return leafId;
     }
     
     /**
    @@ -176,7 +192,8 @@ export function findDescendantMessages(
      */
     export function getMessageSiblings(
     	nodeMap: ReadonlyMap,
    -	messageId: string
    +	messageId: string,
    +	leafCache?: Map
     ): ChatMessageSiblingInfo | null {
     	const message = nodeMap.get(messageId);
     
    @@ -212,7 +229,7 @@ export function getMessageSiblings(
     	// Convert sibling message IDs to their corresponding leaf node IDs
     	// This allows navigation between different conversation branches
     	const siblingLeafIds = siblingIds.map((siblingId: string) =>
    -		findLeafNodeInMap(nodeMap, siblingId)
    +		findLeafNodeInMap(nodeMap, siblingId, leafCache)
     	);
     	// Find current message's position among siblings
     	const currentIndex = siblingIds.indexOf(messageId);
    @@ -236,9 +253,12 @@ export function buildSiblingInfoMap(
     ): Map {
     	const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
     	const siblingMap = new Map();
    +	// Leaf walks repeat along the same child chains for every message; memoize
    +	// them per build so each edge is walked once instead of O(messages^2)
    +	const leafCache = new Map();
     
     	for (const msg of messages) {
    -		const info = getMessageSiblings(nodeMap, msg.id);
    +		const info = getMessageSiblings(nodeMap, msg.id, leafCache);
     
     		if (info) {
     			siblingMap.set(msg.id, info);
    diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
    index 079cdc871..721618c48 100644
    --- a/tools/ui/src/lib/utils/index.ts
    +++ b/tools/ui/src/lib/utils/index.ts
    @@ -285,7 +285,8 @@ export {
     	extractSearchResults,
     	extractSearchQuery,
     	faviconForUrl,
    -	isWebSearchToolName
    +	isWebSearchToolName,
    +	looksLikeSearchResult
     } from './search-results';
     
     // Cache utilities
    diff --git a/tools/ui/src/lib/utils/parse-exec-shell-error.ts b/tools/ui/src/lib/utils/parse-exec-shell-error.ts
    index 42d2ee254..a7b2eb5c8 100644
    --- a/tools/ui/src/lib/utils/parse-exec-shell-error.ts
    +++ b/tools/ui/src/lib/utils/parse-exec-shell-error.ts
    @@ -3,8 +3,14 @@ export function parseExecShellCommandError(
     ): string | undefined {
     	if (!toolResultString) return undefined;
     
    +	// Exec results are usually large plain-text stdout; only a JSON object
    +	// root can carry an error field, so skip the parse otherwise
    +	const trimmed = toolResultString.trimStart();
    +
    +	if (trimmed[0] !== '{') return undefined;
    +
     	try {
    -		const parsed: unknown = JSON.parse(toolResultString);
    +		const parsed: unknown = JSON.parse(trimmed);
     
     		if (
     			parsed &&
    diff --git a/tools/ui/src/lib/utils/parse-exec-shell-status.ts b/tools/ui/src/lib/utils/parse-exec-shell-status.ts
    index 1f7ec557e..71dd110bd 100644
    --- a/tools/ui/src/lib/utils/parse-exec-shell-status.ts
    +++ b/tools/ui/src/lib/utils/parse-exec-shell-status.ts
    @@ -15,15 +15,18 @@ export interface ExecShellExitStatus {
     }
     
     // Anchor to the absolute end so intermediate "[exit code: N]" string content
    -// (e.g. a shell echo) doesn't false-positive.
    +// (e.g. a shell echo) doesn't false-positive. The marker is at most ~50 chars
    +// with the timed-out suffix, so matching a tail slice keeps the cost constant
    +// for megabyte exec outputs instead of scanning the whole blob.
     const EXIT_CODE_TAIL_REGEX = /\[exit code: (-?\d+)\](?: \[exit due to timed out\])?\s*$/;
    +const EXIT_CODE_TAIL_SCAN = 128;
     
     export function parseExecShellCommandExitStatus(
     	toolResultString: string | undefined
     ): ExecShellExitStatus | undefined {
     	if (!toolResultString) return undefined;
     
    -	const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
    +	const match = toolResultString.slice(-EXIT_CODE_TAIL_SCAN).match(EXIT_CODE_TAIL_REGEX);
     
     	if (!match) return undefined;
     
    diff --git a/tools/ui/src/lib/utils/search-results.ts b/tools/ui/src/lib/utils/search-results.ts
    index facf7766d..0fe861d94 100644
    --- a/tools/ui/src/lib/utils/search-results.ts
    +++ b/tools/ui/src/lib/utils/search-results.ts
    @@ -156,6 +156,20 @@ function parseChunk(chunk: string): SearchResult | null {
     	return result;
     }
     
    +const EMPTY_SEARCH_RESULTS: SearchResult[] = [];
    +
    +/**
    + * Cheap prefilter for the wire format: a parseable result needs both a
    + * `Title:` and a `URL:` field line, so a blob missing either substring can
    + * never yield a result. Two substring scans cost far less than the
    + * line-split parse for the megabyte tool results exec and file tools emit.
    + */
    +export function looksLikeSearchResult(text: string | undefined | null): boolean {
    +	if (!text) return false;
    +
    +	return text.includes('Title:') && text.includes('URL:');
    +}
    +
     /** Bounded cache for extractSearchResults results. */
     const SEARCH_RESULTS_CACHE_MAX_SIZE = 32;
     const searchResultsCache = new Map();
    @@ -168,7 +182,7 @@ const searchResultsCache = new Map();
      * tool result strings.
      */
     export function extractSearchResults(text: string | undefined | null): SearchResult[] {
    -	if (!text) return [];
    +	if (!text || !looksLikeSearchResult(text)) return EMPTY_SEARCH_RESULTS;
     
     	const cached = searchResultsCache.get(text);
     
    diff --git a/tools/ui/src/lib/utils/tool-call-meta.ts b/tools/ui/src/lib/utils/tool-call-meta.ts
    index b64bca786..2c035446d 100644
    --- a/tools/ui/src/lib/utils/tool-call-meta.ts
    +++ b/tools/ui/src/lib/utils/tool-call-meta.ts
    @@ -4,6 +4,8 @@
     // Each tool needs to surface fields like `error`, `result`, `bytes`,
     // `edits_applied` without repeating the try/JSON.parse/object guard inline.
     
    +import { JSON_OBJECT_OPEN } from '$lib/constants';
    +
     /**
      * Parse a tool-result blob into a JSON object, or `null` if it isn't
      * one. Returns null for:
    @@ -16,8 +18,14 @@ export function tryParseToolResultObject(
     ): Record | null {
     	if (!toolResultString) return null;
     
    +	// Tool results are usually large plain text (file contents, stdout); only
    +	// a JSON object root can carry fields, so skip the parse otherwise
    +	const trimmed = toolResultString.trimStart();
    +
    +	if (trimmed[0] !== JSON_OBJECT_OPEN) return null;
    +
     	try {
    -		const parsed: unknown = JSON.parse(toolResultString);
    +		const parsed: unknown = JSON.parse(trimmed);
     
     		if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
     			return parsed as Record;
    diff --git a/tools/ui/src/routes/(chat)/+page.svelte b/tools/ui/src/routes/(chat)/+page.svelte
    index 08a6b11ad..53975d7b3 100644
    --- a/tools/ui/src/routes/(chat)/+page.svelte
    +++ b/tools/ui/src/routes/(chat)/+page.svelte
    @@ -3,7 +3,7 @@
     	import { page } from '$app/state';
     	import { DialogModelNotAvailable } from '$lib/components/app';
     	import { APP_NAME, URL_PARAMS } from '$lib/constants';
    -	import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
    +	import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
     	import { onMount } from 'svelte';
     
     	let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
    @@ -77,7 +77,6 @@
     		}
     
     		conversationsStore.clearActiveConversation();
    -		chatStore.clearUIState();
     
     		await modelsStore.fetch();
     
    diff --git a/tools/ui/tests/unit/agentic-sections.test.ts b/tools/ui/tests/unit/agentic-sections.test.ts
    index 4096a1710..fdb3b2217 100644
    --- a/tools/ui/tests/unit/agentic-sections.test.ts
    +++ b/tools/ui/tests/unit/agentic-sections.test.ts
    @@ -290,3 +290,114 @@ describe('hasAgenticContent', () => {
     		expect(hasAgenticContent(msg)).toBe(false);
     	});
     });
    +
    +// The turn-section cache: completed turns are immutable, so repeated
    +// derivations return the same section objects - which is what keeps tool
    +// block props stable while another turn streams. Every field the cache
    +// compares must invalidate it; a miss here renders stale content.
    +
    +describe('completed turn section reuse', () => {
    +	const toolCallsJson = JSON.stringify([
    +		{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
    +	]);
    +
    +	function makeSession() {
    +		return {
    +			anchor: makeAssistant({
    +				content: 'answer',
    +				reasoningContent: 'thinking',
    +				toolCalls: toolCallsJson
    +			}),
    +			tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
    +		};
    +	}
    +
    +	it('returns the same section objects for unchanged inputs', () => {
    +		const { anchor, tools } = makeSession();
    +		const first = deriveAgenticSections(anchor, tools, [], false);
    +		const second = deriveAgenticSections(anchor, tools, [], false);
    +
    +		expect(second[0]).toBe(first[0]);
    +		expect(second[1]).toBe(first[1]);
    +	});
    +
    +	it('recomputes when the assistant content changes', () => {
    +		const { anchor, tools } = makeSession();
    +		const first = deriveAgenticSections(anchor, tools, [], false);
    +
    +		anchor.content = 'edited';
    +		const second = deriveAgenticSections(anchor, tools, [], false);
    +
    +		expect(second).not.toBe(first);
    +		expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
    +			true
    +		);
    +	});
    +
    +	it('recomputes when reasoning content changes', () => {
    +		const { anchor, tools } = makeSession();
    +		const first = deriveAgenticSections(anchor, tools, [], false);
    +
    +		anchor.reasoningContent = 'new thinking';
    +		const second = deriveAgenticSections(anchor, tools, [], false);
    +
    +		expect(second).not.toBe(first);
    +	});
    +
    +	it('recomputes when toolCalls change', () => {
    +		const { anchor, tools } = makeSession();
    +		const first = deriveAgenticSections(anchor, tools, [], false);
    +
    +		anchor.toolCalls = '[]';
    +		const second = deriveAgenticSections(anchor, tools, [], false);
    +
    +		expect(second).not.toBe(first);
    +	});
    +
    +	it('recomputes when a tool result or its extras change', () => {
    +		const { anchor, tools } = makeSession();
    +		const first = deriveAgenticSections(anchor, tools, [], false);
    +
    +		tools[0].content = 'new tool result';
    +		expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
    +
    +		const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
    +
    +		tools[0].extra = [{ type: 'image' } as never];
    +		expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
    +	});
    +
    +	it('never reuses the streaming turn', () => {
    +		const { anchor, tools } = makeSession();
    +		const first = deriveAgenticSections(anchor, tools, [], true);
    +		const second = deriveAgenticSections(anchor, tools, [], true);
    +
    +		expect(second).not.toBe(first);
    +	});
    +
    +	it('keeps completed turns stable while the last turn streams', () => {
    +		const anchor = makeAssistant({
    +			content: 'turn one',
    +			id: 'ast-1',
    +			toolCalls: JSON.stringify([
    +				{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
    +			])
    +		});
    +		const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
    +		const tools = [
    +			makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
    +			continuation,
    +			makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
    +		];
    +		const first = deriveAgenticSections(anchor, tools, [], true);
    +		const second = deriveAgenticSections(anchor, tools, [], true);
    +
    +		// turn one is complete: identical section objects across derivations
    +		expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
    +		expect(second[0]).toBe(first[0]);
    +		expect(second[1]).toBe(first[1]);
    +
    +		// the streaming last turn recomputed: fresh section objects
    +		expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
    +	});
    +});
    diff --git a/tools/ui/tests/unit/branching.test.ts b/tools/ui/tests/unit/branching.test.ts
    new file mode 100644
    index 000000000..8a752ae2f
    --- /dev/null
    +++ b/tools/ui/tests/unit/branching.test.ts
    @@ -0,0 +1,95 @@
    +// Sibling-info correctness for buildSiblingInfoMap, including the memoized
    +// leaf resolution. A wrong leaf id here breaks branch navigation, so the
    +// deep-chain and multi-branch cases below pin the resolution down.
    +
    +import { MessageRole, MessageType } from '$lib/enums';
    +import type { DatabaseMessage } from '$lib/types/database';
    +import { buildSiblingInfoMap, findLeafNode } from '$lib/utils/branching';
    +import { describe, expect, it } from 'vitest';
    +
    +function msg(id: string, parent: string | null, children: string[] = []): DatabaseMessage {
    +	return {
    +		children,
    +		content: '',
    +		convId: 'c1',
    +		id,
    +		parent,
    +		role: MessageRole.USER,
    +		timestamp: 0,
    +		type: MessageType.TEXT
    +	} as DatabaseMessage;
    +}
    +
    +/** root -> m1 -> ... -> m depth, each node with a single child. */
    +function linearChain(depth: number): DatabaseMessage[] {
    +	const messages = [msg('m0', null, ['m1'])];
    +
    +	for (let i = 1; i <= depth; i++) {
    +		messages.push(msg(`m${i}`, `m${i - 1}`, i < depth ? [`m${i + 1}`] : []));
    +	}
    +
    +	return messages;
    +}
    +
    +describe('buildSiblingInfoMap', () => {
    +	it('resolves the deepest leaf for every node of a long single chain', () => {
    +		const messages = linearChain(50);
    +		const map = buildSiblingInfoMap(messages);
    +		const leafId = messages[messages.length - 1].id;
    +
    +		// every non-root message of the chain is an only child, and its
    +		// navigation target is the chain's deepest leaf
    +		for (const m of messages.slice(1)) {
    +			const info = map.get(m.id);
    +
    +			expect(info?.totalSiblings).toBe(1);
    +			expect(info?.siblingIds).toEqual([leafId]);
    +		}
    +	});
    +
    +	it('reports sibling position and leaf targets on a branched tree', () => {
    +		// m0 -> m1, m4 ; m1 -> m2 ; m2 -> m3, m6 ; m4 -> m5
    +		const root = msg('m0', null, ['m1', 'm4']);
    +		const m1 = msg('m1', 'm0', ['m2']);
    +		const m2 = msg('m2', 'm1', ['m3', 'm6']);
    +		const m3 = msg('m3', 'm2');
    +		const m4 = msg('m4', 'm0', ['m5']);
    +		const m5 = msg('m5', 'm4');
    +		const m6 = msg('m6', 'm2');
    +		const map = buildSiblingInfoMap([root, m1, m2, m3, m4, m5, m6]);
    +
    +		// m1 and m4 share the root as parent; their nav targets are the
    +		// leaves of their subtrees ( m6 for the first branch, m5 for the second )
    +		expect(map.get(m1.id)).toMatchObject({
    +			currentIndex: 0,
    +			siblingIds: [m6.id, m5.id],
    +			totalSiblings: 2
    +		});
    +		expect(map.get(m4.id)).toMatchObject({
    +			currentIndex: 1,
    +			siblingIds: [m6.id, m5.id],
    +			totalSiblings: 2
    +		});
    +
    +		// m3 and m6 are siblings under m2; both are leaves
    +		expect(map.get(m3.id)?.siblingIds).toEqual([m3.id, m6.id]);
    +		expect(map.get(m6.id)?.currentIndex).toBe(1);
    +
    +		// the root has no parent and reports itself
    +		expect(map.get(root.id)).toMatchObject({
    +			currentIndex: 0,
    +			siblingIds: [root.id],
    +			totalSiblings: 1
    +		});
    +	});
    +
    +	it('agrees with findLeafNode for arbitrary nodes', () => {
    +		const messages = linearChain(20);
    +		const leafId = messages[messages.length - 1].id;
    +
    +		// every node of the chain resolves to the deepest leaf
    +		for (const m of messages) {
    +			expect(findLeafNode(messages, m.id), `leaf of ${m.id}`).toBe(leafId);
    +		}
    +	});
    +});
    diff --git a/tools/ui/tests/unit/conversations-store.test.ts b/tools/ui/tests/unit/conversations-store.test.ts
    new file mode 100644
    index 000000000..e06546597
    --- /dev/null
    +++ b/tools/ui/tests/unit/conversations-store.test.ts
    @@ -0,0 +1,90 @@
    +// Field updates to the active conversation must keep the object identity
    +// stable: effects that track the identity ( the chat screen's sibling-info
    +// refresh ) refire on every identity change, which used to trigger a full
    +// message refetch on every send and tool result.
    +
    +import { beforeEach, describe, expect, it, vi } from 'vitest';
    +
    +vi.mock('$lib/services/database.service', () => ({
    +	DatabaseService: {
    +		getConversation: vi.fn(),
    +		getConversationMessages: vi.fn(),
    +		updateConversation: vi.fn(),
    +		updateCurrentNode: vi.fn()
    +	}
    +}));
    +
    +import { DatabaseService } from '$lib/services/database.service';
    +import { conversationsStore } from '$lib/stores/conversations/index.svelte';
    +import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
    +
    +const getConversationMock = vi.mocked(DatabaseService.getConversation);
    +const getMessagesMock = vi.mocked(DatabaseService.getConversationMessages);
    +const updateCurrentNodeMock = vi.mocked(DatabaseService.updateCurrentNode);
    +
    +function makeConversation(overrides: Partial = {}): DatabaseConversation {
    +	return {
    +		currNode: 'node-1',
    +		id: 'conv-1',
    +		lastModified: 1000,
    +		name: 'conversation',
    +		...overrides
    +	};
    +}
    +
    +async function loadActive(conversation: DatabaseConversation, messages: DatabaseMessage[]) {
    +	getConversationMock.mockResolvedValue(conversation);
    +	getMessagesMock.mockResolvedValue(messages);
    +
    +	expect(await conversationsStore.loadConversation(conversation.id)).toBe(true);
    +}
    +
    +beforeEach(() => {
    +	getConversationMock.mockReset();
    +	getMessagesMock.mockReset();
    +	updateCurrentNodeMock.mockReset();
    +	updateCurrentNodeMock.mockResolvedValue(undefined);
    +	vi.mocked(DatabaseService.updateConversation).mockReset();
    +	vi.mocked(DatabaseService.updateConversation).mockResolvedValue(undefined);
    +});
    +
    +describe('active conversation identity', () => {
    +	it('hands the load read off exactly once', async () => {
    +		await loadActive(makeConversation(), []);
    +
    +		expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toEqual([]);
    +		// a second consume is a miss: branch actions must fall back to a refetch
    +		expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toBeNull();
    +	});
    +
    +	it('writes currNode in place on updateCurrentNode', async () => {
    +		await loadActive(makeConversation(), []);
    +		const before = conversationsStore.activeConversation;
    +
    +		await conversationsStore.updateCurrentNode('node-2');
    +
    +		expect(conversationsStore.activeConversation).toBe(before);
    +		expect(conversationsStore.activeConversation?.currNode).toBe('node-2');
    +	});
    +
    +	it('writes renamed and pinned fields in place on applyConversationUpdate', async () => {
    +		await loadActive(makeConversation(), []);
    +		const before = conversationsStore.activeConversation;
    +
    +		conversationsStore.applyConversationUpdate('conv-1', { name: 'renamed', pinned: true });
    +
    +		expect(conversationsStore.activeConversation).toBe(before);
    +		expect(conversationsStore.activeConversation?.name).toBe('renamed');
    +		expect(conversationsStore.activeConversation?.pinned).toBe(true);
    +	});
    +
    +	it('writes lastModified in place on updateConversationTimestamp', async () => {
    +		await loadActive(makeConversation(), []);
    +		const before = conversationsStore.activeConversation;
    +
    +		conversationsStore.updateConversationTimestamp('conv-1');
    +
    +		expect(conversationsStore.activeConversation).toBe(before);
    +		expect(conversationsStore.activeConversation?.lastModified).toBeGreaterThan(1000);
    +	});
    +});
    diff --git a/tools/ui/tests/unit/parse-exec-shell-status.test.ts b/tools/ui/tests/unit/parse-exec-shell-status.test.ts
    index ed499d078..7e22bf9ee 100644
    --- a/tools/ui/tests/unit/parse-exec-shell-status.test.ts
    +++ b/tools/ui/tests/unit/parse-exec-shell-status.test.ts
    @@ -71,3 +71,21 @@ describe('isExitCodeSummaryLine', () => {
     		expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
     	});
     });
    +
    +describe('parseExecShellCommandExitStatus tail scan', () => {
    +	it('finds the marker at the end of a blob larger than the tail window', () => {
    +		// the parser matches only the last ~128 chars; a marker past that
    +		// window must still parse, and an earlier fake must not match
    +		const blob = `${'the shell prints [exit code: 1] mid-stream\n'.repeat(2000)}[exit code: 0]`;
    +		const status = parseExecShellCommandExitStatus(blob);
    +
    +		expect(status?.code).toBe(0);
    +		expect(status?.timedOut).toBe(false);
    +	});
    +
    +	it('keeps rejecting markers that are not at the absolute end', () => {
    +		const blob = `${'stdout\n'.repeat(2000)}[exit code: 0]\nsome trailing log line`;
    +
    +		expect(parseExecShellCommandExitStatus(blob)).toBeUndefined();
    +	});
    +});
    diff --git a/tools/ui/tests/unit/search-results.test.ts b/tools/ui/tests/unit/search-results.test.ts
    index c168dec25..561ab935a 100644
    --- a/tools/ui/tests/unit/search-results.test.ts
    +++ b/tools/ui/tests/unit/search-results.test.ts
    @@ -2,7 +2,8 @@ import {
     	extractSearchQuery,
     	extractSearchResults,
     	faviconForUrl,
    -	isWebSearchToolName
    +	isWebSearchToolName,
    +	looksLikeSearchResult
     } from '$lib/utils/search-results';
     import { describe, expect, it } from 'vitest';
     
    @@ -119,3 +120,27 @@ describe('isWebSearchToolName', () => {
     		expect(isWebSearchToolName('exec_shell_command')).toBe(false);
     	});
     });
    +
    +describe('extractSearchResults prefilter', () => {
    +	it('returns the shared empty array for blobs without the wire format', () => {
    +		// exec/file tool results never carry Title:/URL: field lines; the
    +		// cheap prefilter must skip the line-split parse for them
    +		const stdout = `${'make[1]: entering directory\n'.repeat(5000)}`;
    +
    +		expect(extractSearchResults(stdout)).toEqual([]);
    +	});
    +
    +	it('returns an empty result when only one required field is present', () => {
    +		expect(extractSearchResults('URL: https://example.com')).toEqual([]);
    +		expect(extractSearchResults('Title: only a title')).toEqual([]);
    +	});
    +});
    +
    +describe('looksLikeSearchResult', () => {
    +	it('requires both Title and URL field markers', () => {
    +		expect(looksLikeSearchResult('Title: a\nURL: https://b')).toBe(true);
    +		expect(looksLikeSearchResult('URL: https://b')).toBe(false);
    +		expect(looksLikeSearchResult('plain stdout')).toBe(false);
    +		expect(looksLikeSearchResult(undefined)).toBe(false);
    +	});
    +});
    diff --git a/tools/ui/tests/unit/tool-call-meta.test.ts b/tools/ui/tests/unit/tool-call-meta.test.ts
    index bb28e3830..f94d2279f 100644
    --- a/tools/ui/tests/unit/tool-call-meta.test.ts
    +++ b/tools/ui/tests/unit/tool-call-meta.test.ts
    @@ -28,3 +28,15 @@ describe('tryParseToolResultObject', () => {
     		expect(tryParseToolResultObject('{bad')).toBeNull();
     	});
     });
    +
    +describe('tryParseToolResultObject gating', () => {
    +	it('parses JSON objects that start after leading whitespace', () => {
    +		expect(tryParseToolResultObject('\n  {"result":"ok"}')).toEqual({ result: 'ok' });
    +	});
    +
    +	it('skips the parse for large plain-text results', () => {
    +		// most tool results are file contents or stdout; the gate avoids a
    +		// doomed JSON.parse over the whole blob
    +		expect(tryParseToolResultObject(`${'stdout line\n'.repeat(2000)}`)).toBeNull();
    +	});
    +});
    diff --git a/tools/ui/tests/unit/tool-calls.test.ts b/tools/ui/tests/unit/tool-calls.test.ts
    index f84a2405e..a2274f9d2 100644
    --- a/tools/ui/tests/unit/tool-calls.test.ts
    +++ b/tools/ui/tests/unit/tool-calls.test.ts
    @@ -1,5 +1,8 @@
     import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
    -import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
    +import {
    +	parseEditFileMeta,
    +	parseEditFileTitleMeta
    +} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
     import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
     import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
     import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
    @@ -7,10 +10,10 @@ import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMes
     import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
     import {
     	parseWriteFileMeta,
    -	type WriteFileMeta
    +	parseWriteFileTitleMeta
     } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
     import { AgenticSectionType, BuiltInTool } from '$lib/enums';
    -import type { AgenticSection } from '$lib/types';
    +import type { AgenticSection, WriteFileMeta } from '$lib/types';
     import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
     import { describe, expect, it } from 'vitest';
     
    @@ -223,6 +226,113 @@ describe('parseWriteFileMeta', () => {
     	});
     });
     
    +describe('parseWriteFileTitleMeta', () => {
    +	it('matches the full meta for path, language and result fields', () => {
    +		const args = JSON.stringify({ content: 'x'.repeat(50_000), path: '/foo.ts' });
    +		const toolResult = '{"result":"wrote","bytes":42}';
    +		const section = makeSection(
    +			{ toolArgs: args, toolName: BuiltInTool.SERVER_WRITE_FILE, toolResult },
    +			BuiltInTool.SERVER_WRITE_FILE
    +		);
    +		const full = parseWriteFileMeta(section);
    +		const title = parseWriteFileTitleMeta(section);
    +
    +		expect(title?.filePath).toBe(full?.filePath);
    +		expect(title?.fileName).toBe(full?.fileName);
    +		expect(title?.language).toBe(full?.language);
    +		expect(title?.bytesWritten).toBe(full?.bytesWritten);
    +		expect(title?.resultMessage).toBe(full?.resultMessage);
    +		expect(title?.errorMessage).toBe(full?.errorMessage);
    +	});
    +
    +	it('extracts a path with escaped characters without parsing the content blob', () => {
    +		const section = makeSection(
    +			{
    +				toolArgs: '{"path":"/a\\nb\\"c/d.ts","content":"x"}',
    +				toolName: BuiltInTool.SERVER_WRITE_FILE
    +			},
    +			BuiltInTool.SERVER_WRITE_FILE
    +		);
    +
    +		expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/a\nb"c/d.ts');
    +	});
    +
    +	it('falls back to the full parse for args the extractor can not see', () => {
    +		const section = makeSection(
    +			{
    +				// key written with an escaped unicode escape sequence in the name
    +				toolArgs: '{"\\u0070ath":"/foo.ts","content":"x"}',
    +				toolName: BuiltInTool.SERVER_WRITE_FILE
    +			},
    +			BuiltInTool.SERVER_WRITE_FILE
    +		);
    +
    +		expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.ts');
    +	});
    +
    +	it('accepts partial args like the full parser', () => {
    +		const section = makeSection(
    +			{ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE },
    +			BuiltInTool.SERVER_WRITE_FILE
    +		);
    +
    +		expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t');
    +	});
    +
    +	it('returns null for sections with a different tool name', () => {
    +		expect(
    +			parseWriteFileTitleMeta(
    +				makeSection({
    +					toolArgs: '{"path":"/x","content":"y"}',
    +					toolName: BuiltInTool.SERVER_READ_FILE
    +				})
    +			)
    +		).toBeNull();
    +	});
    +});
    +
    +describe('parseEditFileTitleMeta', () => {
    +	it('matches the full meta for path and result fields', () => {
    +		const section = makeSection(
    +			{
    +				toolArgs: '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"}]}' + ' '.repeat(0),
    +				toolName: BuiltInTool.SERVER_EDIT_FILE,
    +				toolResult: '{"result":"ok","edits_applied":1}'
    +			},
    +			BuiltInTool.SERVER_EDIT_FILE
    +		);
    +		const full = parseEditFileMeta(section);
    +		const title = parseEditFileTitleMeta(section);
    +
    +		expect(title?.filePath).toBe(full?.filePath);
    +		expect(title?.fileName).toBe(full?.fileName);
    +		expect(title?.editsApplied).toBe(full?.editsApplied);
    +		expect(title?.resultMessage).toBe(full?.resultMessage);
    +		expect(title?.errorMessage).toBe(full?.errorMessage);
    +	});
    +
    +	it('surfaces errorMessage from the result blob without parsing args', () => {
    +		const section = makeSection(
    +			{
    +				toolArgs: '{"path":"/foo.ts","edits":[]}',
    +				toolName: BuiltInTool.SERVER_EDIT_FILE,
    +				toolResult: '{"error":"permission denied"}'
    +			},
    +			BuiltInTool.SERVER_EDIT_FILE
    +		);
    +
    +		expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied');
    +	});
    +
    +	it('returns null when args have no path-like field', () => {
    +		expect(
    +			parseEditFileTitleMeta(
    +				makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
    +			)
    +		).toBeNull();
    +	});
    +});
    +
     describe('parseEditFileMeta', () => {
     	it('parses edits array and applies editsApplied from the result', () => {
     		const section = makeSection(