+ )
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-height-governor.test.ts b/src/renderer/src/components/native-chat/native-chat-visual-height-governor.test.ts
new file mode 100644
index 000000000000..fd72076ac841
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-height-governor.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { createNativeChatVisualHeightGovernor } from './native-chat-visual-height-governor'
+
+describe('createNativeChatVisualHeightGovernor', () => {
+ it('applies clamped changes and ignores repeats', () => {
+ const governor = createNativeChatVisualHeightGovernor()
+ expect(governor.decide(300, 0)).toEqual({ kind: 'apply', height: 300 })
+ expect(governor.decide(300.2, 10)).toEqual({ kind: 'ignore' })
+ expect(governor.decide(5, 20)).toEqual({ kind: 'apply', height: 80 })
+ expect(governor.decide(50_000, 30)).toEqual({ kind: 'apply', height: 2000 })
+ })
+
+ it('defers changes past the per-second budget instead of dropping the last one', () => {
+ const governor = createNativeChatVisualHeightGovernor()
+ let now = 0
+ for (let index = 0; index < 20; index += 1) {
+ // Alternate shrinking and growing in large steps so no runaway is detected.
+ expect(governor.decide(index % 2 === 0 ? 200 : 900, now).kind).toBe('apply')
+ now += 10
+ }
+ expect(governor.decide(500, now)).toEqual({ kind: 'defer', retryInMs: 1_000 - now })
+ expect(governor.decide(500, 1_000)).toEqual({ kind: 'apply', height: 500 })
+ })
+
+ it('stops growth that only follows the frame (100vh plus a margin) but still lets it shrink', () => {
+ const governor = createNativeChatVisualHeightGovernor()
+ let height = 200
+ let now = 0
+ governor.decide(height, now)
+ const applied: number[] = []
+ for (let index = 0; index < 30; index += 1) {
+ height += 16
+ now += 50
+ const decision = governor.decide(height, now)
+ if (decision.kind === 'apply') {
+ applied.push(decision.height)
+ }
+ }
+ expect(applied.length).toBeLessThan(12)
+ const frozenAt = applied.at(-1) ?? 200
+ expect(governor.decide(frozenAt + 16, now + 2_000)).toEqual({ kind: 'ignore' })
+ expect(governor.decide(150, now + 3_000)).toEqual({ kind: 'apply', height: 150 })
+ })
+
+ it('lets a page grow in steps as slow images and fonts arrive', () => {
+ const governor = createNativeChatVisualHeightGovernor()
+ expect(governor.decide(200, 0).kind).toBe('apply')
+ expect(governor.decide(240, 800).kind).toBe('apply')
+ expect(governor.decide(280, 1_600).kind).toBe('apply')
+ expect(governor.decide(600, 1_650).kind).toBe('apply')
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-height-governor.ts b/src/renderer/src/components/native-chat/native-chat-visual-height-governor.ts
new file mode 100644
index 000000000000..3cb45a166cf6
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-height-governor.ts
@@ -0,0 +1,62 @@
+import { clampNativeChatVisualHeight } from '../../../../shared/native-chat-visual-shell'
+
+const WINDOW_MS = 1_000
+const MAX_CHANGES_PER_WINDOW = 20
+// A page sized from the frame (100vh plus a margin) grows by the same small step on every report.
+const RUNAWAY_STEP_PX = 64
+const RUNAWAY_GAP_MS = 500
+const RUNAWAY_STREAK = 10
+
+export type NativeChatVisualHeightDecision =
+ | { kind: 'apply'; height: number }
+ | { kind: 'defer'; retryInMs: number }
+ | { kind: 'ignore' }
+
+/**
+ * Turns a visual's reported heights into frame heights: clamped, rate-limited, and frozen against
+ * further growth once the page is plainly growing because the frame grew. Shrinking stays allowed.
+ */
+export function createNativeChatVisualHeightGovernor(): {
+ decide: (reported: number, now: number) => NativeChatVisualHeightDecision
+} {
+ let applied: number | null = null
+ let windowStart = 0
+ let changes = 0
+ let lastGrowthAt = -Infinity
+ let streak = 0
+ let growthFrozen = false
+
+ return {
+ decide(reported, now) {
+ const height = clampNativeChatVisualHeight(reported)
+ if (height === applied) {
+ return { kind: 'ignore' }
+ }
+ const grows = applied !== null && height > applied
+ if (grows && growthFrozen) {
+ return { kind: 'ignore' }
+ }
+ if (now - windowStart >= WINDOW_MS) {
+ windowStart = now
+ changes = 0
+ }
+ if (changes >= MAX_CHANGES_PER_WINDOW) {
+ return { kind: 'defer', retryInMs: windowStart + WINDOW_MS - now }
+ }
+ if (grows && applied !== null) {
+ const smallStep = height - applied <= RUNAWAY_STEP_PX
+ streak = smallStep && now - lastGrowthAt < RUNAWAY_GAP_MS ? streak + 1 : 0
+ lastGrowthAt = now
+ if (streak >= RUNAWAY_STREAK) {
+ growthFrozen = true
+ return { kind: 'ignore' }
+ }
+ } else {
+ streak = 0
+ }
+ changes += 1
+ applied = height
+ return { kind: 'apply', height }
+ }
+ }
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx b/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
new file mode 100644
index 000000000000..c4c3e2933961
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
@@ -0,0 +1,75 @@
+import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
+import type { ExtraProps } from 'react-markdown'
+import type { CommentMarkdownExtension } from '@/components/sidebar/CommentMarkdown'
+import {
+ NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH,
+ isNativeChatVisualFileName
+} from '../../../../shared/native-chat-visual-directive'
+import { NativeChatInlineVisual } from './NativeChatInlineVisual'
+import type { NativeChatVisualOwner } from './native-chat-visual-owner'
+import { useNativeChatVisualOwner } from './native-chat-visual-owner'
+import {
+ NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES,
+ remarkNativeChatVisuals
+} from './native-chat-visual-markdown-syntax'
+
+type DivProps = ComponentPropsWithoutRef<'div'> & ExtraProps
+
+function dataAttribute(props: DivProps, name: string): unknown {
+ return Reflect.get(props, name)
+}
+
+function newNonce(): string {
+ const bytes = new Uint8Array(16)
+ crypto.getRandomValues(bytes)
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
+}
+
+function createVisualExtension(
+ owner: NativeChatVisualOwner,
+ messageId: string,
+ nonce: string
+): CommentMarkdownExtension {
+ function VisualPlaceholder({ node: _node, ...props }: DivProps): React.JSX.Element {
+ const file = dataAttribute(props, 'data-orca-visual-file')
+ const title = dataAttribute(props, 'data-orca-visual-title')
+ // Only the parser's own placeholders carry this message's nonce; the fields are re-checked anyway.
+ if (
+ dataAttribute(props, 'data-orca-visual') !== nonce ||
+ typeof file !== 'string' ||
+ !isNativeChatVisualFileName(file) ||
+ (title !== undefined &&
+ (typeof title !== 'string' || title.length > NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH))
+ ) {
+ return
+ }
+ return (
+ 0 ? title : null}
+ />
+ )
+ }
+ return {
+ remarkPlugins: [[remarkNativeChatVisuals, nonce]],
+ sanitizeAttributes: { div: [...NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES] },
+ components: { div: VisualPlaceholder }
+ }
+}
+
+/**
+ * The visual extension for one assistant message, or undefined outside a structured chat. Stable
+ * for the message's lifetime, so a mounted visual keeps its frame while the reply streams.
+ */
+export function useNativeChatVisualMarkdownExtension(
+ messageId: string | undefined
+): CommentMarkdownExtension | undefined {
+ const owner = useNativeChatVisualOwner()
+ const [nonce] = useState(newNonce)
+ return useMemo(
+ () => (owner && messageId ? createVisualExtension(owner, messageId, nonce) : undefined),
+ [messageId, nonce, owner]
+ )
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx
new file mode 100644
index 000000000000..8f21249556bf
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx
@@ -0,0 +1,133 @@
+import type { ComponentProps } from 'react'
+import type { ExtraProps } from 'react-markdown'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import CommentMarkdown, {
+ type CommentMarkdownExtension
+} from '@/components/sidebar/CommentMarkdown'
+import {
+ NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES,
+ remarkNativeChatVisuals
+} from './native-chat-visual-markdown-syntax'
+
+const NONCE = 'nonce-123'
+
+function Probe(props: ComponentProps<'div'> & ExtraProps): React.JSX.Element {
+ if (Reflect.get(props, 'data-orca-visual') !== NONCE) {
+ return
{props.children}
+ }
+ const title: unknown = Reflect.get(props, 'data-orca-visual-title')
+ return (
+
+ {typeof title === 'string' ? `[${title}]` : '[untitled]'}
+
+ )
+}
+
+const extension: CommentMarkdownExtension = {
+ remarkPlugins: [[remarkNativeChatVisuals, NONCE]],
+ sanitizeAttributes: { div: [...NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES] },
+ components: { div: Probe }
+}
+
+function render(content: string): string {
+ return renderToStaticMarkup(
+
+ )
+}
+
+function mountedFiles(markup: string): string[] {
+ return [...markup.matchAll(/data-visual="([^"]+)"/g)].map((match) => match[1])
+}
+
+const LINE = '::orca-visual{file="usage.html" title="Usage by day"}'
+
+describe('remarkNativeChatVisuals', () => {
+ it('mounts a directive on its own line with its title', () => {
+ const markup = render(`Here it is:\n\n${LINE}\n\nThat is the trend.`)
+ expect(mountedFiles(markup)).toEqual(['usage.html'])
+ expect(markup).toContain('[Usage by day]')
+ expect(markup).not.toContain('::orca-visual')
+ expect(markup).toContain('That is the trend.')
+ })
+
+ it('splits a paragraph that runs straight into and out of the directive', () => {
+ const markup = render(`Here it is:\n${LINE}\nThat is the trend.`)
+ expect(mountedFiles(markup)).toEqual(['usage.html'])
+ expect(markup).toContain('Here it is:')
+ expect(markup).toContain('That is the trend.')
+ })
+
+ it('mounts at the very end of the reply without a trailing newline', () => {
+ expect(mountedFiles(render(`Intro\n\n${LINE}`))).toEqual(['usage.html'])
+ })
+
+ it('accepts CRLF line endings and up to three spaces of indent', () => {
+ expect(mountedFiles(render(`Intro\r\n\r\n ${LINE}\r\n\r\nAfter`))).toEqual(['usage.html'])
+ })
+
+ it.each([
+ ['fenced code', `\`\`\`\n${LINE}\n\`\`\``],
+ ['tilde fenced code', `~~~md\n${LINE}\n~~~`],
+ ['indented code', `Intro\n\n ${LINE}`],
+ ['inline code', `Use \`${LINE}\` to show a chart.`],
+ ['a block quote', `> ${LINE}`],
+ ['a list item', `- ${LINE}`],
+ ['an ordered list item', `1. ${LINE}`],
+ ['mid-sentence', `See ${LINE} here`],
+ ['a malformed directive', '::orca-visual{file="../usage.html"}'],
+ ['an unclosed directive', '::orca-visual{file="usage.html"'],
+ ['trailing prose on the line', `${LINE} done`]
+ ])('leaves it as text inside %s', (_name, content) => {
+ const markup = render(content)
+ expect(mountedFiles(markup)).toEqual([])
+ expect(markup).toContain('::orca-visual')
+ })
+
+ it('does not mount a raw HTML placeholder forged without the nonce', () => {
+ const markup = render(
+ '
x
\n\nafter'
+ )
+ expect(mountedFiles(markup)).toEqual([])
+ })
+
+ it('mounts a raw HTML placeholder only if it knew the nonce, which the reply cannot', () => {
+ // The nonce is per rendered message and never part of the reply, so this cannot be authored.
+ const markup = render(
+ `
x
`
+ )
+ expect(mountedFiles(markup)).toEqual(['usage.html'])
+ })
+
+ it('caps how many visuals one reply mounts and shows the rest as text', () => {
+ const lines = Array.from(
+ { length: 10 },
+ (_, index) => `::orca-visual{file="chart-${index}.html"}`
+ )
+ const markup = render(lines.join('\n\n'))
+ expect(mountedFiles(markup)).toEqual(
+ Array.from({ length: 8 }, (_, index) => `chart-${index}.html`)
+ )
+ expect(markup).toContain('chart-8.html')
+ expect(markup).toContain('chart-9.html')
+ })
+
+ it('keeps reference links defined after the directive working', () => {
+ const markup = render(`See [the docs][d].\n\n${LINE}\n\n[d]: https://example.com/docs`)
+ expect(mountedFiles(markup)).toEqual(['usage.html'])
+ expect(markup).toContain('href="https://example.com/docs"')
+ })
+
+ it('renders other markdown unchanged around it', () => {
+ const markup = render(`# Title\n\n${LINE}\n\n| a | b |\n| - | - |\n| 1 | 2 |`)
+ expect(mountedFiles(markup)).toEqual(['usage.html'])
+ expect(markup).toContain('
{
+ it('shows the directive as text', () => {
+ const markup = renderToStaticMarkup()
+ expect(markup).toContain('::orca-visual')
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.ts b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.ts
new file mode 100644
index 000000000000..44b0717ecd87
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.ts
@@ -0,0 +1,160 @@
+// The visual directive as a markdown block, for native-chat assistant prose only. The parser sees it
+// like any other block (so code fences, indented code, quotes and lists keep their meaning), and the
+// line itself is judged by the one shared grammar.
+
+import type { Paragraph, Root, RootContent } from 'mdast'
+import type { Extension as FromMarkdownExtension } from 'mdast-util-from-markdown'
+import type {
+ Code,
+ Construct,
+ Effects,
+ Extension as MicromarkExtension,
+ State,
+ TokenizeContext
+} from 'micromark-util-types'
+import type { Processor } from 'unified'
+import {
+ NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH,
+ NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE,
+ parseNativeChatVisualDirectiveLine
+} from '../../../../shared/native-chat-visual-directive'
+
+const TOKEN_TYPE = 'nativeChatVisual'
+const COLON = 58
+const HORIZONTAL_TAB = -2
+const VIRTUAL_SPACE = -1
+
+// Hast properties the placeholder carries through sanitize to the React component.
+const NONCE_PROPERTY = 'dataOrcaVisual'
+const FILE_PROPERTY = 'dataOrcaVisualFile'
+const TITLE_PROPERTY = 'dataOrcaVisualTitle'
+export const NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES = [
+ NONCE_PROPERTY,
+ FILE_PROPERTY,
+ TITLE_PROPERTY
+] as const
+
+export type NativeChatVisualNode = {
+ type: 'nativeChatVisual'
+ file: string
+ title: string | null
+ /** The line as written, for a directive shown as text instead. */
+ source: string
+ data?: { hName?: string; hProperties?: Record }
+}
+
+declare module 'mdast' {
+ // oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface.
+ interface RootContentMap {
+ nativeChatVisual: NativeChatVisualNode
+ }
+}
+
+declare module 'micromark-util-types' {
+ // oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface.
+ interface TokenTypeMap {
+ nativeChatVisual: 'nativeChatVisual'
+ }
+}
+
+function tokenizeVisualLine(this: TokenizeContext, effects: Effects, ok: State, nok: State): State {
+ let line = ''
+ const inside: State = (code: Code) => {
+ // EOF, or a line ending (the negative codes below the tab/virtual-space codes).
+ if (code === null || code < HORIZONTAL_TAB) {
+ effects.exit(TOKEN_TYPE)
+ return parseNativeChatVisualDirectiveLine(line) ? ok(code) : nok(code)
+ }
+ if (line.length >= NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH) {
+ return nok(code)
+ }
+ if (code !== VIRTUAL_SPACE) {
+ line += code === HORIZONTAL_TAB ? '\t' : String.fromCharCode(code)
+ }
+ effects.consume(code)
+ return inside
+ }
+ return (code) => {
+ effects.enter(TOKEN_TYPE)
+ return inside(code)
+ }
+}
+
+const visualLineConstruct: Construct = { name: TOKEN_TYPE, tokenize: tokenizeVisualLine }
+
+const visualMicromarkExtension: MicromarkExtension = { flow: { [COLON]: visualLineConstruct } }
+
+const visualFromMarkdown: FromMarkdownExtension = {
+ enter: {
+ [TOKEN_TYPE](token) {
+ this.enter({ type: 'nativeChatVisual', file: '', title: null, source: '' }, token)
+ }
+ },
+ exit: {
+ [TOKEN_TYPE](token) {
+ const node = this.stack.at(-1)
+ const source = this.sliceSerialize(token)
+ const directive = parseNativeChatVisualDirectiveLine(source)
+ if (node?.type === 'nativeChatVisual' && directive) {
+ node.file = directive.file
+ node.title = directive.title
+ node.source = source
+ }
+ this.exit(token)
+ }
+ }
+}
+
+function literalParagraph(node: NativeChatVisualNode): Paragraph {
+ return { type: 'paragraph', children: [{ type: 'text', value: node.source }] }
+}
+
+type ChildList = { children: RootContent[] }
+
+function hasChildren(node: RootContent): node is RootContent & ChildList {
+ return 'children' in node && Array.isArray(node.children)
+}
+
+/**
+ * Mounts only directives that stand alone at the top of the reply, up to the per-message cap. One
+ * nested in a quote or list, or past the cap, reads as the text the agent wrote.
+ */
+function placeVisuals(
+ parent: ChildList,
+ isRoot: boolean,
+ nonce: string,
+ mounted: { count: number }
+): void {
+ parent.children = parent.children.map((child) => {
+ if (child.type === 'nativeChatVisual') {
+ if (!isRoot || mounted.count >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) {
+ return literalParagraph(child)
+ }
+ mounted.count += 1
+ child.data = {
+ hName: 'div',
+ hProperties: {
+ [NONCE_PROPERTY]: nonce,
+ [FILE_PROPERTY]: child.file,
+ ...(child.title ? { [TITLE_PROPERTY]: child.title } : {})
+ }
+ }
+ return child
+ }
+ if (hasChildren(child)) {
+ placeVisuals(child, false, nonce, mounted)
+ }
+ return child
+ })
+}
+
+/**
+ * The remark plugin. `nonce` is unguessable per rendered message, so raw HTML in the reply cannot
+ * forge a placeholder the component would mount.
+ */
+export function remarkNativeChatVisuals(this: Processor, nonce: string): (tree: Root) => void {
+ const data = this.data()
+ data.micromarkExtensions = [...(data.micromarkExtensions ?? []), visualMicromarkExtension]
+ data.fromMarkdownExtensions = [...(data.fromMarkdownExtensions ?? []), visualFromMarkdown]
+ return (tree) => placeVisuals(tree, true, nonce, { count: 0 })
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx b/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx
new file mode 100644
index 000000000000..51d84a119a01
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-owner.tsx
@@ -0,0 +1,53 @@
+import { createContext, useContext, useMemo, type ReactNode } from 'react'
+import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
+import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner'
+
+/** The structured chat a transcript belongs to: where its visuals are read and what owns them. */
+export type NativeChatVisualOwner = {
+ target: RuntimeClientTarget
+ sessionId: string
+ tabId: string
+ worktreeId: string
+}
+
+export const NativeChatVisualOwnerContext = createContext(null)
+
+/** Null outside a structured chat's transcript, where no visual can be read. */
+export function useNativeChatVisualOwner(): NativeChatVisualOwner | null {
+ return useContext(NativeChatVisualOwnerContext)
+}
+
+/** Names the structured chat below it as the owner of the visuals its replies show. */
+export function NativeChatVisualOwnerProvider({
+ target,
+ sessionId,
+ tabId,
+ children
+}: {
+ target: RuntimeClientTarget
+ sessionId: string
+ tabId: string
+ children: ReactNode
+}): React.JSX.Element {
+ // Until the tab's workspace is known there is nothing to scope a sidebar visual to.
+ const worktreeId = useNativeChatTabOwnerWorktreeId(tabId)
+ const environmentId = target.kind === 'environment' ? target.environmentId : null
+ const owner = useMemo(
+ () =>
+ worktreeId
+ ? {
+ target:
+ environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId },
+ sessionId,
+ tabId,
+ worktreeId
+ }
+ : null,
+ [environmentId, sessionId, tabId, worktreeId]
+ )
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts b/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts
new file mode 100644
index 000000000000..17c79b90f4eb
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const callRuntimeRpc = vi.fn()
+vi.mock('@/runtime/runtime-rpc-client', () => ({
+ callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args)
+}))
+
+import {
+ clearNativeChatVisualCacheForTests,
+ isRetryableNativeChatVisualFailure,
+ peekCachedNativeChatVisual,
+ readNativeChatVisual
+} from './native-chat-visual-read-client'
+
+const local = { target: { kind: 'local' as const }, sessionId: 'session-alpha', file: 'a.html' }
+const remote = {
+ target: { kind: 'environment' as const, environmentId: 'env-1' },
+ sessionId: 'session-alpha',
+ file: 'a.html'
+}
+
+beforeEach(() => {
+ clearNativeChatVisualCacheForTests()
+ callRuntimeRpc.mockReset()
+})
+
+afterEach(() => {
+ clearNativeChatVisualCacheForTests()
+})
+
+describe('readNativeChatVisual', () => {
+ it("reads through the chat's own runtime with only the session and file name", async () => {
+ callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '
' }
+ })
+ })
+
+ it('bounds the cache by entry count', async () => {
+ for (let index = 0; index < 30; index += 1) {
+ callRuntimeRpc.mockResolvedValueOnce({
+ ok: true,
+ revision: `r${index}`,
+ sizeBytes: 1,
+ html: 'x'
+ })
+ await readNativeChatVisual({ ...local, file: `f${index}.html` })
+ }
+ expect(peekCachedNativeChatVisual({ ...local, file: 'f0.html' })).toBeNull()
+ expect(peekCachedNativeChatVisual({ ...local, file: 'f29.html' })?.revision).toBe('r29')
+ })
+})
+
+describe('isRetryableNativeChatVisualFailure', () => {
+ it('retries only what may change on its own', () => {
+ expect(isRetryableNativeChatVisualFailure('unavailable')).toBe(true)
+ expect(isRetryableNativeChatVisualFailure('not_found')).toBe(true)
+ expect(isRetryableNativeChatVisualFailure('outside_folder')).toBe(false)
+ expect(isRetryableNativeChatVisualFailure('too_large')).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts b/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts
new file mode 100644
index 000000000000..d6cf965d61f0
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-visual-read-client.ts
@@ -0,0 +1,159 @@
+// Reading a chat visual from the host that owns the chat, through a small revision cache.
+//
+// The cache only spares bytes: every mount still asks the host, passing the revision it holds, so a
+// file the agent rewrote shows its new content and a deleted one stops showing. Failures are never
+// cached; the next mount or retry asks again.
+
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
+import {
+ AGENT_SESSION_VISUAL_READ_ERRORS,
+ type AgentSessionReadVisualResult,
+ type AgentSessionVisualReadError
+} from '../../../../shared/rpc-contract/agent-session-visual-params'
+
+export type NativeChatVisualIdentity = {
+ target: RuntimeClientTarget
+ sessionId: string
+ file: string
+}
+
+export type NativeChatVisualDocument = { revision: string; html: string }
+
+/** `unavailable`: the host could not answer (contact lost, older host); never a verdict on the file. */
+export type NativeChatVisualReadOutcome =
+ | { ok: true; document: NativeChatVisualDocument }
+ | { ok: false; reason: AgentSessionVisualReadError | 'unavailable' }
+
+const MAX_CACHED_DOCUMENTS = 24
+const MAX_CACHED_CHARS = 6 * 1024 * 1024
+const READ_TIMEOUT_MS = 15_000
+
+const cache = new Map()
+const inFlight = new Map>()
+
+export function nativeChatVisualKey(identity: NativeChatVisualIdentity): string {
+ const runtime =
+ identity.target.kind === 'local' ? 'local' : `environment:${identity.target.environmentId}`
+ return JSON.stringify([runtime, identity.sessionId, identity.file])
+}
+
+export function peekCachedNativeChatVisual(
+ identity: NativeChatVisualIdentity
+): NativeChatVisualDocument | null {
+ return cache.get(nativeChatVisualKey(identity)) ?? null
+}
+
+function remember(key: string, document: NativeChatVisualDocument): void {
+ cache.delete(key)
+ cache.set(key, document)
+ let chars = 0
+ for (const entry of cache.values()) {
+ chars += entry.html.length
+ }
+ for (const [oldestKey, oldest] of cache) {
+ if (cache.size <= MAX_CACHED_DOCUMENTS && chars <= MAX_CACHED_CHARS) {
+ break
+ }
+ cache.delete(oldestKey)
+ chars -= oldest.html.length
+ }
+}
+
+function isKnownReadError(value: unknown): value is AgentSessionVisualReadError {
+ return AGENT_SESSION_VISUAL_READ_ERRORS.some((known) => known === value)
+}
+
+function interpret(
+ key: string,
+ result: AgentSessionReadVisualResult | null | undefined
+): NativeChatVisualReadOutcome {
+ if (!result || typeof result !== 'object') {
+ return { ok: false, reason: 'unavailable' }
+ }
+ if (result.ok) {
+ if ('unchanged' in result) {
+ const cached = cache.get(key)
+ if (cached?.revision === result.revision) {
+ remember(key, cached)
+ return { ok: true, document: cached }
+ }
+ return { ok: false, reason: 'unavailable' }
+ }
+ if (typeof result.html !== 'string' || typeof result.revision !== 'string') {
+ return { ok: false, reason: 'unavailable' }
+ }
+ const document = { revision: result.revision, html: result.html }
+ remember(key, document)
+ return { ok: true, document }
+ }
+ if (result.error === 'not_found') {
+ cache.delete(key)
+ }
+ // Why: a newer host may name a refusal this build does not know; it still reads as "can't show".
+ return { ok: false, reason: isKnownReadError(result.error) ? result.error : 'unavailable' }
+}
+
+async function readOnce(
+ key: string,
+ identity: NativeChatVisualIdentity
+): Promise {
+ const known = cache.get(key)?.revision
+ try {
+ const result = await callRuntimeRpc(
+ identity.target,
+ 'agentSession.readVisual',
+ {
+ sessionId: identity.sessionId,
+ file: identity.file,
+ ...(known ? { knownRevision: known } : {})
+ },
+ { timeoutMs: READ_TIMEOUT_MS }
+ )
+ const outcome = interpret(key, result)
+ // An `unchanged` answer for an entry evicted meanwhile: ask once more for the bytes.
+ if (!outcome.ok && outcome.reason === 'unavailable' && result?.ok && 'unchanged' in result) {
+ cache.delete(key)
+ return interpret(
+ key,
+ await callRuntimeRpc(
+ identity.target,
+ 'agentSession.readVisual',
+ { sessionId: identity.sessionId, file: identity.file },
+ { timeoutMs: READ_TIMEOUT_MS }
+ )
+ )
+ }
+ return outcome
+ } catch {
+ return { ok: false, reason: 'unavailable' }
+ }
+}
+
+/** One read per visual at a time; concurrent mounts of the same visual share it. */
+export function readNativeChatVisual(
+ identity: NativeChatVisualIdentity
+): Promise {
+ const key = nativeChatVisualKey(identity)
+ const pending = inFlight.get(key)
+ if (pending) {
+ return pending
+ }
+ const read = readOnce(key, identity).finally(() => {
+ inFlight.delete(key)
+ })
+ inFlight.set(key, read)
+ return read
+}
+
+/** Whether a failed read may succeed if asked again shortly: contact restored, or the file landing. */
+export function isRetryableNativeChatVisualFailure(
+ reason: AgentSessionVisualReadError | 'unavailable'
+): boolean {
+ return reason === 'unavailable' || reason === 'not_found'
+}
+
+export function clearNativeChatVisualCacheForTests(): void {
+ cache.clear()
+ inFlight.clear()
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts b/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts
new file mode 100644
index 000000000000..de36392f8d83
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-visual-document.ts
@@ -0,0 +1,99 @@
+import { useEffect, useState } from 'react'
+import {
+ isRetryableNativeChatVisualFailure,
+ peekCachedNativeChatVisual,
+ readNativeChatVisual,
+ type NativeChatVisualDocument,
+ type NativeChatVisualIdentity
+} from './native-chat-visual-read-client'
+
+export type NativeChatVisualDocumentState =
+ | { status: 'loading' }
+ | { status: 'ready'; document: NativeChatVisualDocument }
+ | { status: 'unavailable' }
+
+/** Waits before each retry of a read that may yet succeed; bounded, then the failure shows. */
+export const NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS = [1_500, 4_000, 10_000] as const
+
+function waitFor(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(!signal.aborted), ms)
+ signal.addEventListener(
+ 'abort',
+ () => {
+ clearTimeout(timer)
+ resolve(false)
+ },
+ { once: true }
+ )
+ })
+}
+
+async function loadWithRetries(
+ identity: NativeChatVisualIdentity,
+ signal: AbortSignal,
+ setState: (
+ update: (shown: NativeChatVisualDocumentState) => NativeChatVisualDocumentState
+ ) => void
+): Promise {
+ for (let retry = 0; ; retry += 1) {
+ const outcome = await readNativeChatVisual(identity)
+ if (signal.aborted) {
+ return
+ }
+ if (outcome.ok) {
+ setState((shown) =>
+ shown.status === 'ready' && shown.document.revision === outcome.document.revision
+ ? shown
+ : { status: 'ready', document: outcome.document }
+ )
+ return
+ }
+ const delay = NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS[retry]
+ if (!isRetryableNativeChatVisualFailure(outcome.reason) || delay === undefined) {
+ // Why keep a shown document on `unavailable`: losing contact says nothing about the file.
+ setState((shown) =>
+ shown.status === 'ready' && outcome.reason === 'unavailable'
+ ? shown
+ : { status: 'unavailable' }
+ )
+ return
+ }
+ if (!(await waitFor(delay, signal))) {
+ return
+ }
+ }
+}
+
+/**
+ * The visual's current document. Starts from the cached revision when there is one, then asks the
+ * host (revalidating it) once `enabled`. A later failure keeps a document already shown.
+ */
+export function useNativeChatVisualDocument(
+ identity: NativeChatVisualIdentity,
+ enabled: boolean
+): NativeChatVisualDocumentState {
+ const { sessionId, file } = identity
+ const environmentId =
+ identity.target.kind === 'environment' ? identity.target.environmentId : null
+ const [state, setState] = useState(() => {
+ const cached = peekCachedNativeChatVisual(identity)
+ return cached ? { status: 'ready', document: cached } : { status: 'loading' }
+ })
+
+ useEffect(() => {
+ if (!enabled) {
+ return
+ }
+ const identityNow: NativeChatVisualIdentity = {
+ target: environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId },
+ sessionId,
+ file
+ }
+ const abort = new AbortController()
+ void loadWithRetries(identityNow, abort.signal, setState)
+ return () => abort.abort()
+ }, [enabled, environmentId, file, sessionId])
+
+ return state
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-visual-theme.ts b/src/renderer/src/components/native-chat/use-native-chat-visual-theme.ts
new file mode 100644
index 000000000000..fcc3e6f6a039
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-visual-theme.ts
@@ -0,0 +1,89 @@
+import { useEffect, useState, type RefObject } from 'react'
+import {
+ NATIVE_CHAT_VISUAL_THEME_TOKENS,
+ type NativeChatVisualTheme
+} from '../../../../shared/native-chat-visual-shell'
+
+// The visual blends into the thread: its background and text follow the chat column, which a
+// chat's own appearance can set apart from the app root.
+const SCOPED_SOURCES: Partial> = {
+ '--background': ['--chat-canvas', '--background'],
+ '--foreground': ['--chat-foreground', '--foreground'],
+ '--font-mono': ['--chat-code-font-family', '--font-mono']
+}
+
+/** The resolved theme at `element`, read from computed styles in its own appearance scope. */
+export function readNativeChatVisualTheme(element: Element | null): NativeChatVisualTheme {
+ const source = element ?? document.documentElement
+ const styles = getComputedStyle(source)
+ const tokens: Record = {}
+ for (const token of NATIVE_CHAT_VISUAL_THEME_TOKENS) {
+ if (token === '--font-sans') {
+ tokens[token] = styles.fontFamily
+ continue
+ }
+ for (const name of SCOPED_SOURCES[token] ?? [token]) {
+ const value = styles.getPropertyValue(name).trim()
+ if (value.length > 0) {
+ tokens[token] = value
+ break
+ }
+ }
+ }
+ return { colorScheme: resolvedColorScheme(element), tokens }
+}
+
+/** A chat can pin its own scheme apart from the app's (see the `dark` variant in main.css). */
+function resolvedColorScheme(element: Element | null): 'light' | 'dark' {
+ const pinned = element
+ ?.closest('[data-native-chat-scheme]')
+ ?.getAttribute('data-native-chat-scheme')
+ if (pinned === 'light' || pinned === 'dark') {
+ return pinned
+ }
+ return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
+}
+
+function themeSnapshot(theme: NativeChatVisualTheme): string {
+ return JSON.stringify(theme)
+}
+
+/**
+ * The theme a visual should wear, updated when the app theme or the chat's appearance changes.
+ * Compares resolved values, so unrelated style writes (a sidebar drag) never produce a new theme.
+ */
+export function useNativeChatVisualTheme(
+ elementRef: RefObject
+): NativeChatVisualTheme {
+ const [theme, setTheme] = useState(() =>
+ readNativeChatVisualTheme(elementRef.current)
+ )
+ useEffect(() => {
+ let snapshot = ''
+ const refresh = (): void => {
+ const next = readNativeChatVisualTheme(elementRef.current)
+ const nextSnapshot = themeSnapshot(next)
+ if (nextSnapshot !== snapshot) {
+ snapshot = nextSnapshot
+ setTheme(next)
+ }
+ }
+ refresh()
+ const observer = new MutationObserver(refresh)
+ const watched = new Set([document.documentElement])
+ for (const selector of ['.native-chat-appearance', '[data-native-chat-scheme]']) {
+ const scope = elementRef.current?.closest(selector)
+ if (scope) {
+ watched.add(scope)
+ }
+ }
+ for (const element of watched) {
+ observer.observe(element, {
+ attributes: true,
+ attributeFilter: ['class', 'style', 'data-native-chat-scheme']
+ })
+ }
+ return () => observer.disconnect()
+ }, [elementRef])
+ return theme
+}
diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx
index cc9011bb2988..4476b59c8c7f 100644
--- a/src/renderer/src/components/right-sidebar/index.tsx
+++ b/src/renderer/src/components/right-sidebar/index.tsx
@@ -12,8 +12,10 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import {
RIGHT_SIDEBAR_MIN_WIDTH,
clampRightSidebarPanelWidth,
- computeMaxRightSidebarPanelWidth
+ computeMaxRightSidebarPanelWidth,
+ rightSidebarVisualWidth
} from './right-sidebar-width'
+import { selectVisibleRightSidebarVisual } from '@/store/slices/editor/actions/right-sidebar-state'
import { translate } from '@/i18n/i18n'
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
import { useMeasuredWidth } from './right-sidebar-measured-width'
@@ -39,6 +41,8 @@ function RightSidebarInner(): React.JSX.Element {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
+ const visual = useAppStore(selectVisibleRightSidebarVisual)
+ const setRightSidebarVisualWidth = useAppStore((s) => s.setRightSidebarVisualWidth)
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const checksStatus = useAppStore((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null))
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
@@ -62,8 +66,10 @@ function RightSidebarInner(): React.JSX.Element {
const activityBarSideWidth = activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0
const windowWidth = useWindowWidth()
const maxWidth = computeMaxRightSidebarPanelWidth(windowWidth, activityBarSideWidth)
+ // Why a separate width: widening for a visual must not persist or reach other clients, and
+ // closing the visual has to land back on the width the user chose.
const renderedRightSidebarWidth = clampRightSidebarPanelWidth(
- rightSidebarWidth,
+ visual ? rightSidebarVisualWidth(rightSidebarWidth, visual.width) : rightSidebarWidth,
windowWidth,
activityBarSideWidth
)
@@ -74,7 +80,7 @@ function RightSidebarInner(): React.JSX.Element {
maxWidth,
deltaSign: -1,
renderedExtraWidth: activityBarSideWidth,
- setWidth: setRightSidebarWidth
+ setWidth: visual ? setRightSidebarVisualWidth : setRightSidebarWidth
})
const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth)
@@ -91,7 +97,11 @@ function RightSidebarInner(): React.JSX.Element {
property) rather than in a bottom-docked dashboard panel that
competed with file Explorer/Search for vertical space. The right
sidebar is back to tab-only content. */}
-
+
) : null
diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx
index 8da2783e309a..9c0205b84d62 100644
--- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import type { ActiveRightSidebarTab } from '@/store/slices/editor'
+import type { RightSidebarVisualState } from '@/store/slices/editor/actions/right-sidebar-state'
import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest'
const FileExplorer = lazy(() => import('./FileExplorer'))
@@ -11,16 +12,32 @@ const AiVaultPanel = lazy(() => import('./AiVaultPanel'))
const FolderWorkspaceWorktreesPanel = lazy(() => import('./FolderWorkspaceWorktreesPanel'))
const FolderWorkspacePrChecksPanel = lazy(() => import('./FolderWorkspacePrChecksPanel'))
const PluginPanel = lazy(() => import('./PluginPanel'))
+const NativeChatVisualPanel = lazy(() => import('../native-chat/NativeChatVisualPanel'))
type RightSidebarPanelContentProps = {
effectiveTab: ActiveRightSidebarTab
rightSidebarOpen: boolean
+ visual: RightSidebarVisualState | null
}
export function RightSidebarPanelContent({
effectiveTab,
- rightSidebarOpen
+ rightSidebarOpen,
+ visual
}: RightSidebarPanelContentProps): React.JSX.Element {
+ if (visual) {
+ // Why key: a different visual is a different frame, never a reused one.
+ return (
+
+
+
+
+
+ )
+ }
return (
diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-width.ts b/src/renderer/src/components/right-sidebar/right-sidebar-width.ts
index 6598d7314549..6f04b5218c5e 100644
--- a/src/renderer/src/components/right-sidebar/right-sidebar-width.ts
+++ b/src/renderer/src/components/right-sidebar/right-sidebar-width.ts
@@ -26,3 +26,11 @@ export function clampRightSidebarPanelWidth(
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, width)
)
}
+
+/** Width the sidebar opens to while it shows a chat visual: wide enough for a chart to read. */
+export const RIGHT_SIDEBAR_VISUAL_PREFERRED_WIDTH = 720
+
+/** The sidebar's width while it shows a visual: its own resize, else the wider of stored and preferred. */
+export function rightSidebarVisualWidth(storedWidth: number, visualWidth: number | null): number {
+ return visualWidth ?? Math.max(storedWidth, RIGHT_SIDEBAR_VISUAL_PREFERRED_WIDTH)
+}
diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx
index 8b862c90c78e..f9cb930eba0f 100644
--- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx
+++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx
@@ -1,9 +1,9 @@
import React from 'react'
-import Markdown, { defaultUrlTransform } from 'react-markdown'
+import Markdown, { defaultUrlTransform, type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import rehypeRaw from 'rehype-raw'
-import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
+import rehypeSanitize, { defaultSchema, type Options as SanitizeSchema } from 'rehype-sanitize'
import { cn } from '@/lib/utils'
import {
compactCommentMarkdownComponents,
@@ -19,6 +19,7 @@ import { remarkNativeChatFileLinks } from './comment-markdown-native-chat-file-l
export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers'
type MarkdownPlugins = NonNullable['rehypePlugins']>
+type RemarkPlugins = NonNullable['remarkPlugins']>
type UrlTransform = NonNullable['urlTransform']>
type GitHubRepoReference = {
@@ -181,6 +182,27 @@ const commentMarkdownSanitizeSchema = {
// ` `). Parse it, then sanitize immediately before React renders it.
const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkdownSanitizeSchema]]
+/**
+ * A surface-specific markdown addition: its own remark plugins, the element attributes those
+ * plugins need to survive sanitize, and the components that render them. Keep the object stable
+ * per surface; a new one rebuilds the components and remounts what they rendered.
+ */
+export type CommentMarkdownExtension = {
+ remarkPlugins: RemarkPlugins
+ sanitizeAttributes: Record
+ components: Components
+}
+
+function extensionRehypePlugins(extension: CommentMarkdownExtension): MarkdownPlugins {
+ const attributes: NonNullable = {
+ ...commentMarkdownSanitizeSchema.attributes
+ }
+ for (const [tagName, names] of Object.entries(extension.sanitizeAttributes)) {
+ attributes[tagName] = [...(attributes[tagName] ?? []), ...names]
+ }
+ return [rehypeRaw, [rehypeSanitize, { ...commentMarkdownSanitizeSchema, attributes }]]
+}
+
type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
content: string
variant?: 'compact' | 'document'
@@ -190,6 +212,7 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
linkifyFilePaths?: boolean
expandImages?: boolean
renderCodeBlock?: DocumentCodeBlockRenderer
+ extension?: CommentMarkdownExtension
}
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
@@ -207,11 +230,12 @@ const CommentMarkdown = React.memo(
linkifyFilePaths = false,
expandImages = false,
renderCodeBlock,
+ extension,
...rest
},
ref
) {
- const components = React.useMemo(() => {
+ const baseComponents = React.useMemo(() => {
if (!onLinkClick) {
return variant === 'document'
? renderCodeBlock
@@ -225,12 +249,21 @@ const CommentMarkdown = React.memo(
? createDocumentCommentMarkdownComponents(onLinkClick, renderCodeBlock)
: createCompactCommentMarkdownComponents(onLinkClick, expandImages)
}, [expandImages, renderCodeBlock, variant, onLinkClick])
+ const components = React.useMemo(
+ () => (extension ? { ...baseComponents, ...extension.components } : baseComponents),
+ [baseComponents, extension]
+ )
+ const activeRehypePlugins = React.useMemo(
+ () => (extension ? extensionRehypePlugins(extension) : rehypePlugins),
+ [extension]
+ )
const activeRemarkPlugins = React.useMemo(() => {
const plugins = linkifyFilePaths
? [...remarkPlugins, remarkNativeChatFileLinks]
: remarkPlugins
- return githubRepo ? [...plugins, remarkGitHubReferences(githubRepo)] : plugins
- }, [githubRepo, linkifyFilePaths])
+ const withExtension = extension ? [...plugins, ...extension.remarkPlugins] : plugins
+ return githubRepo ? [...withExtension, remarkGitHubReferences(githubRepo)] : withExtension
+ }, [extension, githubRepo, linkifyFilePaths])
return (
rightSidebarExplorerViewByWorktree: Record
+ rightSidebarVisual: RightSidebarVisualState | null
activityBarPosition: ActivityBarPosition
toggleRightSidebar: () => void
setRightSidebarOpen: (open: boolean) => void
@@ -29,6 +55,9 @@ export type RightSidebarState = {
}) => void
showAiVaultSearch: () => void
clearAiVaultSearchFocusRequest: () => void
+ openRightSidebarVisual: (route: RightSidebarVisualRoute) => void
+ closeRightSidebarVisual: () => void
+ setRightSidebarVisualWidth: (width: number) => void
setActivityBarPosition: (position: ActivityBarPosition) => void
}
@@ -42,9 +71,15 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
aiVaultSearchFocusRequested: false,
rightSidebarTabByWorktree: {},
rightSidebarExplorerViewByWorktree: {},
+ rightSidebarVisual: null,
activityBarPosition: 'top',
- toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })),
- setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }),
+ toggleRightSidebar: () =>
+ set((s) => ({
+ rightSidebarOpen: !s.rightSidebarOpen,
+ ...(s.rightSidebarOpen ? { rightSidebarVisual: null } : {})
+ })),
+ setRightSidebarOpen: (open) =>
+ set({ rightSidebarOpen: open, ...(open ? {} : { rightSidebarVisual: null }) }),
setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }),
setRightSidebarTab: (tab) =>
set((s) => ({
@@ -138,6 +173,44 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
aiVaultSearchFocusRequested: true
})),
clearAiVaultSearchFocusRequest: () => set({ aiVaultSearchFocusRequested: false }),
+ openRightSidebarVisual: (route) =>
+ set((s) => {
+ const current = selectVisibleRightSidebarVisual(s)
+ return {
+ rightSidebarOpen: true,
+ rightSidebarVisual: {
+ ...route,
+ routeRequestId: s.rightSidebarRouteRequestId,
+ reopenedSidebar: current ? current.reopenedSidebar : !s.rightSidebarOpen,
+ width: current?.width ?? null
+ }
+ }
+ }),
+ closeRightSidebarVisual: () =>
+ set((s) => ({
+ rightSidebarVisual: null,
+ ...(selectVisibleRightSidebarVisual(s)?.reopenedSidebar ? { rightSidebarOpen: false } : {})
+ })),
+ setRightSidebarVisualWidth: (width) =>
+ set((s) => {
+ const visual = selectVisibleRightSidebarVisual(s)
+ return visual ? { rightSidebarVisual: { ...visual, width } } : {}
+ }),
setActivityBarPosition: (position) => set({ activityBarPosition: position })
}
}
+
+/** The visual the sidebar shows now, or null once a tab route superseded it or it was closed. */
+export function selectVisibleRightSidebarVisual(
+ state: Pick<
+ RightSidebarState,
+ 'rightSidebarVisual' | 'rightSidebarRouteRequestId' | 'rightSidebarOpen'
+ >
+): RightSidebarVisualState | null {
+ const visual = state.rightSidebarVisual
+ return visual &&
+ state.rightSidebarOpen &&
+ visual.routeRequestId === state.rightSidebarRouteRequestId
+ ? visual
+ : null
+}
diff --git a/src/renderer/src/store/slices/editor/actions/right-sidebar-visual-state.test.ts b/src/renderer/src/store/slices/editor/actions/right-sidebar-visual-state.test.ts
new file mode 100644
index 000000000000..3fb4b08b69b4
--- /dev/null
+++ b/src/renderer/src/store/slices/editor/actions/right-sidebar-visual-state.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import {
+ createRightSidebarState,
+ selectVisibleRightSidebarVisual,
+ type RightSidebarState,
+ type RightSidebarVisualRoute
+} from './right-sidebar-state'
+import { rightSidebarVisualWidth } from '../../../../components/right-sidebar/right-sidebar-width'
+
+type Harness = RightSidebarState & { activeWorktreeId: string | null }
+
+function harness(): { state: () => Harness } {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: filled by the slice on the next line before any read.
+ let state = { activeWorktreeId: 'wt-1' } as Harness
+ const set = (update: Partial | ((current: Harness) => Partial)): void => {
+ state = { ...state, ...(typeof update === 'function' ? update(state) : update) }
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice only reads and writes the fields this harness holds.
+ Object.assign(state, createRightSidebarState(set as never, (() => state) as never))
+ return { state: () => state }
+}
+
+const route: RightSidebarVisualRoute = {
+ target: { kind: 'local' },
+ sessionId: 'session-alpha',
+ messageId: 'message-1',
+ file: 'chart.html',
+ title: 'Chart',
+ tabId: 'tab-1',
+ worktreeId: 'wt-1'
+}
+
+describe('right sidebar visual route', () => {
+ it('opens the sidebar on the visual and closes it again when it had been closed', () => {
+ const { state } = harness()
+ expect(state().rightSidebarOpen).toBe(false)
+ state().openRightSidebarVisual(route)
+ expect(state().rightSidebarOpen).toBe(true)
+ expect(selectVisibleRightSidebarVisual(state())).toMatchObject({ file: 'chart.html' })
+ state().closeRightSidebarVisual()
+ expect(selectVisibleRightSidebarVisual(state())).toBeNull()
+ expect(state().rightSidebarOpen).toBe(false)
+ })
+
+ it('returns to the open sidebar it replaced', () => {
+ const { state } = harness()
+ state().setRightSidebarOpen(true)
+ state().setRightSidebarTab('source-control')
+ state().openRightSidebarVisual(route)
+ state().closeRightSidebarVisual()
+ expect(state().rightSidebarOpen).toBe(true)
+ expect(state().rightSidebarTab).toBe('source-control')
+ })
+
+ it('gives way to any tab the user picks, without being cleared explicitly', () => {
+ const { state } = harness()
+ state().openRightSidebarVisual(route)
+ state().setRightSidebarTab('checks')
+ expect(selectVisibleRightSidebarVisual(state())).toBeNull()
+ expect(state().rightSidebarTab).toBe('checks')
+ })
+
+ it('is dropped when the sidebar closes', () => {
+ const { state } = harness()
+ state().openRightSidebarVisual(route)
+ state().toggleRightSidebar()
+ expect(state().rightSidebarVisual).toBeNull()
+ state().toggleRightSidebar()
+ expect(selectVisibleRightSidebarVisual(state())).toBeNull()
+ })
+
+ it('widens while shown without touching the stored width, and keeps a resize to itself', () => {
+ const { state } = harness()
+ state().setRightSidebarWidth(300)
+ state().openRightSidebarVisual(route)
+ const visual = selectVisibleRightSidebarVisual(state())
+ expect(rightSidebarVisualWidth(state().rightSidebarWidth, visual?.width ?? null)).toBe(720)
+ state().setRightSidebarVisualWidth(900)
+ expect(selectVisibleRightSidebarVisual(state())?.width).toBe(900)
+ expect(state().rightSidebarWidth).toBe(300)
+ state().closeRightSidebarVisual()
+ expect(state().rightSidebarWidth).toBe(300)
+ })
+
+ it('keeps a wider stored width and the reopen bookkeeping when switching visuals', () => {
+ const { state } = harness()
+ state().setRightSidebarWidth(1000)
+ state().openRightSidebarVisual(route)
+ expect(rightSidebarVisualWidth(1000, null)).toBe(1000)
+ state().openRightSidebarVisual({ ...route, file: 'other.html' })
+ expect(selectVisibleRightSidebarVisual(state())).toMatchObject({
+ file: 'other.html',
+ reopenedSidebar: true
+ })
+ })
+})
diff --git a/src/shared/native-chat-visual-shell.test.ts b/src/shared/native-chat-visual-shell.test.ts
new file mode 100644
index 000000000000..7a69001e8e77
--- /dev/null
+++ b/src/shared/native-chat-visual-shell.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from 'vitest'
+import {
+ NATIVE_CHAT_VISUAL_CSP,
+ NATIVE_CHAT_VISUAL_MAX_HEIGHT,
+ NATIVE_CHAT_VISUAL_MIN_HEIGHT,
+ NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
+ NATIVE_CHAT_VISUAL_SIZE_TYPE,
+ NATIVE_CHAT_VISUAL_THEME_TYPE,
+ buildNativeChatVisualDocument,
+ clampNativeChatVisualHeight,
+ nativeChatVisualThemeCss,
+ nativeChatVisualThemeMessage,
+ readNativeChatVisualFrameMessage
+} from './native-chat-visual-shell'
+
+const theme = {
+ colorScheme: 'dark' as const,
+ tokens: { '--background': 'oklch(0.2 0 0)', '--chart-1': '#123456' }
+}
+
+function directives(): Map {
+ return new Map(
+ NATIVE_CHAT_VISUAL_CSP.split(';').map((part) => {
+ const [name, ...values] = part.trim().split(/\s+/)
+ return [name, values.join(' ')]
+ })
+ )
+}
+
+describe('NATIVE_CHAT_VISUAL_CSP', () => {
+ it('denies by default and closes every channel a chart does not need', () => {
+ const policy = directives()
+ expect(policy.get('default-src')).toBe("'none'")
+ for (const name of [
+ 'connect-src',
+ 'frame-src',
+ 'child-src',
+ 'worker-src',
+ 'object-src',
+ 'media-src',
+ 'manifest-src',
+ 'form-action',
+ 'base-uri'
+ ]) {
+ expect(policy.get(name)).toBe("'none'")
+ }
+ })
+
+ it('allows assets only inline, from data/blob, or from the pinned CDNs, and never eval', () => {
+ const policy = directives()
+ for (const name of ['script-src', 'style-src']) {
+ expect(policy.get(name)).toContain("'unsafe-inline'")
+ expect(policy.get(name)).not.toContain("'unsafe-eval'")
+ expect(policy.get(name)).not.toContain('*')
+ }
+ expect(policy.get('img-src')).toBe(
+ 'data: blob: https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com https://esm.sh https://fonts.googleapis.com https://fonts.gstatic.com'
+ )
+ })
+})
+
+describe('buildNativeChatVisualDocument', () => {
+ const html = 'x
hi
'
+ const document = buildNativeChatVisualDocument({ html, channel: 'abc123', theme })
+
+ it('puts the CSP before anything the visual wrote', () => {
+ const cspAt = document.indexOf('http-equiv="Content-Security-Policy"')
+ expect(cspAt).toBeGreaterThan(0)
+ expect(cspAt).toBeLessThan(document.indexOf('x'))
+ expect(cspAt).toBeLessThan(document.indexOf('',
+ 'not-a-token': 'blue',
+ '--Upper': 'green'
+ }
+ })
+ expect(css).not.toContain('')
+ expect(css).not.toContain('}<')
+ expect(css).not.toContain('not-a-token')
+ expect(css).not.toContain('--Upper')
+ expect(css.startsWith(':root{color-scheme:light')).toBe(true)
+ })
+
+ it('builds the live theme message on the same channel', () => {
+ expect(nativeChatVisualThemeMessage(theme, 'abc')).toMatchObject({
+ type: NATIVE_CHAT_VISUAL_THEME_TYPE,
+ channel: 'abc',
+ colorScheme: 'dark'
+ })
+ })
+})
+
+describe('readNativeChatVisualFrameMessage', () => {
+ it('accepts a finite size and an http(s) link on its own channel', () => {
+ expect(
+ readNativeChatVisualFrameMessage(
+ { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: 321.5 },
+ 'c'
+ )
+ ).toEqual({ kind: 'size', height: 321.5 })
+ expect(
+ readNativeChatVisualFrameMessage(
+ { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'https://example.com/a b' },
+ 'c'
+ )
+ ).toEqual({ kind: 'open-link', url: 'https://example.com/a%20b' })
+ })
+
+ it.each([
+ ['another channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'x', height: 10 }],
+ ['no channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, height: 10 }],
+ ['NaN height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Number.NaN }],
+ ['infinite height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Infinity }],
+ ['negative height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: -5 }],
+ ['string height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: '50' }],
+ [
+ 'javascript link',
+ { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'javascript:alert(1)' }
+ ],
+ ['file link', { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'file:///etc' }],
+ [
+ 'overlong link',
+ {
+ type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
+ channel: 'c',
+ url: `https://example.com/${'a'.repeat(3000)}`
+ }
+ ],
+ ['unknown type', { type: 'orca-panel-action', channel: 'c' }],
+ ['a string', 'orca-visual-size'],
+ ['null', null]
+ ])('refuses %s', (_name, data) => {
+ expect(readNativeChatVisualFrameMessage(data, 'c')).toBeNull()
+ })
+})
+
+describe('clampNativeChatVisualHeight', () => {
+ it('keeps heights in range', () => {
+ expect(clampNativeChatVisualHeight(1)).toBe(NATIVE_CHAT_VISUAL_MIN_HEIGHT)
+ expect(clampNativeChatVisualHeight(99_999)).toBe(NATIVE_CHAT_VISUAL_MAX_HEIGHT)
+ expect(clampNativeChatVisualHeight(300.4)).toBe(300)
+ })
+})
diff --git a/src/shared/native-chat-visual-shell.ts b/src/shared/native-chat-visual-shell.ts
new file mode 100644
index 000000000000..c870d7ced695
--- /dev/null
+++ b/src/shared/native-chat-visual-shell.ts
@@ -0,0 +1,252 @@
+import { PANEL_DESIGN_TOKEN_ALLOWLIST } from './plugins/plugin-panel-shell'
+
+/**
+ * The document wrapped around an agent-written chat visual before it enters its sandboxed frame.
+ * Same placement rule as the plugin panel shell: the CSP meta parses before any visual markup, so
+ * nothing later in the document can loosen it, and the visual's own / merge into ours.
+ *
+ * Network: scripts, styles, fonts and images may load from a pinned set of public CDNs; nothing may
+ * fetch, open sockets, frame, or submit. A page can still encode data in a request to an allowed
+ * CDN — an accepted, disclosed risk, not a guarantee that no data leaves.
+ *
+ * Electron-free string builder: desktop, the web client and mobile build the same document.
+ */
+
+export const NATIVE_CHAT_VISUAL_CDN_ORIGINS = [
+ 'https://cdn.jsdelivr.net',
+ 'https://unpkg.com',
+ 'https://cdnjs.cloudflare.com',
+ 'https://esm.sh',
+ 'https://fonts.googleapis.com',
+ 'https://fonts.gstatic.com'
+] as const
+
+const ASSET_SOURCES = `'unsafe-inline' data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}`
+const MEDIA_SOURCES = `data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}`
+
+export const NATIVE_CHAT_VISUAL_CSP = [
+ "default-src 'none'",
+ `script-src ${ASSET_SOURCES}`,
+ `style-src ${ASSET_SOURCES}`,
+ `font-src ${MEDIA_SOURCES}`,
+ `img-src ${MEDIA_SOURCES}`,
+ "connect-src 'none'",
+ "frame-src 'none'",
+ "child-src 'none'",
+ "worker-src 'none'",
+ "object-src 'none'",
+ "media-src 'none'",
+ "manifest-src 'none'",
+ "form-action 'none'",
+ "base-uri 'none'"
+].join('; ')
+
+/** Frame names carry this prefix so the main process registers them before content runs. */
+export const NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX = 'orca-chat-visual:'
+
+export const NATIVE_CHAT_VISUAL_SIZE_TYPE = 'orca-visual-size'
+export const NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE = 'orca-visual-open-link'
+export const NATIVE_CHAT_VISUAL_THEME_TYPE = 'orca-visual-theme'
+
+export const NATIVE_CHAT_VISUAL_MIN_HEIGHT = 80
+export const NATIVE_CHAT_VISUAL_MAX_HEIGHT = 2000
+const MAX_LINK_LENGTH = 2048
+
+/** Theme variables a visual may style against: the plugin panel set plus the chart series. */
+export const NATIVE_CHAT_VISUAL_THEME_TOKENS = [
+ ...PANEL_DESIGN_TOKEN_ALLOWLIST,
+ '--chart-1',
+ '--chart-2',
+ '--chart-3',
+ '--chart-4',
+ '--chart-5',
+ '--font-sans',
+ '--font-mono'
+] as const
+
+export type NativeChatVisualTheme = {
+ colorScheme: 'light' | 'dark'
+ tokens: Readonly>
+}
+
+const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/
+
+/** Token values land inside a
+
+
+
+${args.html}`
+}
+
+export type NativeChatVisualFrameMessage =
+ | { kind: 'size'; height: number }
+ | { kind: 'open-link'; url: string }
+
+/** A message from a visual's shell, validated; null for anything else. */
+export function readNativeChatVisualFrameMessage(
+ data: unknown,
+ channel: string
+): NativeChatVisualFrameMessage | null {
+ if (typeof data !== 'object' || data === null) {
+ return null
+ }
+ const field = (name: string): unknown => Reflect.get(data, name)
+ if (field('channel') !== channel) {
+ return null
+ }
+ const type = field('type')
+ if (type === NATIVE_CHAT_VISUAL_SIZE_TYPE) {
+ const height = field('height')
+ return typeof height === 'number' && Number.isFinite(height) && height > 0
+ ? { kind: 'size', height }
+ : null
+ }
+ if (type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) {
+ const url = field('url')
+ if (typeof url !== 'string' || url.length > MAX_LINK_LENGTH) {
+ return null
+ }
+ try {
+ const parsed = new URL(url)
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? { kind: 'open-link', url: parsed.href }
+ : null
+ } catch {
+ return null
+ }
+ }
+ return null
+}
+
+export function clampNativeChatVisualHeight(height: number): number {
+ return Math.min(
+ NATIVE_CHAT_VISUAL_MAX_HEIGHT,
+ Math.max(NATIVE_CHAT_VISUAL_MIN_HEIGHT, Math.round(height))
+ )
+}
+
+export function nativeChatVisualThemeMessage(
+ theme: NativeChatVisualTheme,
+ channel: string
+): { type: string; channel: string; css: string; colorScheme: 'light' | 'dark' } {
+ return {
+ type: NATIVE_CHAT_VISUAL_THEME_TYPE,
+ channel,
+ css: nativeChatVisualThemeCss(theme),
+ colorScheme: theme.colorScheme
+ }
+}
From 360412db41767a60dfa4c2d888a08e795d779a80 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 6 Oct 2026 22:47:27 -0700
Subject: [PATCH 03/10] fix(native-chat): visual CI fixes, shared height
governor, live-turn streaming hold
Registers agentSession.readVisual from the methods index so the structured
method file stays under its line budget, replaces reflective reads with checked
narrowing, moves the pure height governor to src/shared for mobile, and holds a
half-written directive tail while the turn works (structured text rows carry no
running state).
---
src/main/runtime/rpc/methods/index.ts | 2 ++
...ructured-agent-session-rpc.test-fixture.ts | 14 +++++++----
.../structured-agent-session-visual.test.ts | 24 ++++++++++++++++---
.../structured-agent-session-visual.ts | 7 +++---
.../methods/structured-agent-session.test.ts | 2 +-
.../rpc/methods/structured-agent-session.ts | 2 --
.../NativeChatMarkdown.visual.test.tsx | 24 +++++++++++++++++++
.../native-chat/NativeChatMessageRow.tsx | 3 ++-
.../native-chat/NativeChatVisualFrame.tsx | 2 +-
.../native-chat-visual-markdown-extension.tsx | 14 +++++++----
...ative-chat-visual-markdown-syntax.test.tsx | 8 ++++---
...native-chat-visual-height-governor.test.ts | 0
.../native-chat-visual-height-governor.ts | 2 +-
src/shared/native-chat-visual-shell.ts | 12 ++++------
14 files changed, 84 insertions(+), 32 deletions(-)
rename src/{renderer/src/components/native-chat => shared}/native-chat-visual-height-governor.test.ts (100%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-visual-height-governor.ts (95%)
diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts
index 267b201edf90..05d45d62d1ab 100644
--- a/src/main/runtime/rpc/methods/index.ts
+++ b/src/main/runtime/rpc/methods/index.ts
@@ -46,6 +46,7 @@ import { PAIRING_METHODS } from './pairing'
import { UPDATER_METHODS } from './updater'
import { AGENT_SESSION_METHODS } from './agent-session'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
+import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual'
import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents'
import { ARTIFACT_METHODS } from './artifacts'
import { AGENT_HOOK_METHODS } from './agent-hooks'
@@ -64,6 +65,7 @@ export const ALL_RPC_METHODS = [
...WORKTREE_METHODS,
...AGENT_SESSION_METHODS,
...STRUCTURED_AGENT_SESSION_METHODS,
+ ...STRUCTURED_AGENT_SESSION_VISUAL_METHODS,
...STRUCTURED_AGENT_SESSION_AGENTS_METHODS,
...AGENT_LAUNCH_METHODS,
...TERMINAL_METHODS,
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts
index 43f813b35ee6..c4b3f62700b6 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts
@@ -16,7 +16,7 @@ import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
-import type { RpcRequest, RpcResponse } from '../core'
+import type { RpcAnyMethodDeclaration, RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger'
@@ -216,7 +216,10 @@ export function hostStub(): StructuredAgentSessionHost {
return hostCalls as unknown as StructuredAgentSessionHost
}
-export function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher {
+export function dispatcher(
+ runtimeOverrides: Record = {},
+ methods: readonly RpcAnyMethodDeclaration[] = STRUCTURED_AGENT_SESSION_METHODS
+): RpcDispatcher {
reset(runtimeCalls)
Object.assign(runtimeCalls, {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
@@ -254,7 +257,7 @@ export function dispatcher(runtimeOverrides: Record = {}): RpcD
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
- methods: STRUCTURED_AGENT_SESSION_METHODS
+ methods
})
}
@@ -269,10 +272,11 @@ export async function call(
clientCapabilities?: string[]
signal?: AbortSignal
},
- runtimeOverrides: Record = {}
+ runtimeOverrides: Record = {},
+ methods?: readonly RpcAnyMethodDeclaration[]
): Promise {
const replies: RpcResponse[] = []
- await dispatcher(runtimeOverrides).dispatchStreaming(
+ await dispatcher(runtimeOverrides, methods).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts
index 49fec6e7946e..c53565d85859 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts
@@ -6,14 +6,22 @@ import type { AgentSessionRecord } from '../../../../shared/agent-session-record
import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import { nativeChatVisualsFolderFor } from '../../../native-chat/native-chat-visuals-folder'
+import { ALL_RPC_METHODS } from '.'
+import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual'
import {
- call,
+ call as callStructured,
clearStructuredHostStub,
hostStub,
SESSION,
STRUCTURED_CLIENT
} from './structured-agent-session-rpc.test-fixture'
+type CallClient = Parameters[2]
+
+function call(method: string, params: unknown, client: CallClient) {
+ return callStructured(method, params, client, {}, STRUCTURED_AGENT_SESSION_VISUAL_METHODS)
+}
+
let stateDirectory: string
let record: AgentSessionRecord | null
@@ -42,6 +50,10 @@ async function writeVisual(name: string, html: string): Promise {
}
describe('agentSession.readVisual', () => {
+ it('is registered on the runtime manifest', () => {
+ expect(ALL_RPC_METHODS.map((method) => method.name)).toContain('agentSession.readVisual')
+ })
+
it("reads a file from the chat's own folder under the host's state directory", async () => {
await writeVisual('chart.html', '
chart
')
const reply = await call(
@@ -63,14 +75,20 @@ describe('agentSession.readVisual', () => {
STRUCTURED_CLIENT
)
const result: unknown = first.ok ? first.result : null
- const revision = String(Reflect.get(result ?? {}, 'revision'))
+ const revision =
+ typeof result === 'object' && result !== null && 'revision' in result
+ ? String(result.revision)
+ : ''
const again = await call(
'agentSession.readVisual',
{ sessionId: SESSION, file: 'chart.html', knownRevision: revision },
STRUCTURED_CLIENT
)
expect(again).toMatchObject({ ok: true, result: { ok: true, unchanged: true, revision } })
- expect(again.ok ? Reflect.has(Object(again.result), 'html') : true).toBe(false)
+ const againResult: unknown = again.ok ? again.result : null
+ expect(typeof againResult === 'object' && againResult !== null && 'html' in againResult).toBe(
+ false
+ )
})
it('reports a session this host has no record of', async () => {
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts
index b8e2e49f1669..04c2141a7d51 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts
@@ -1,8 +1,9 @@
// `agentSession.readVisual` — one HTML visual from a chat's own visuals folder.
//
-// Additive: an older host answers `method_not_found` and the client shows the visual as
-// unavailable. The host resolves the folder from its own state directory and record; a client
-// supplies only the session id and a bare file name.
+// Additive: an older host answers `method_not_found` (a phone gets `forbidden` from the mobile
+// allowlist gate on a host without the entry), and the client shows the visual as unavailable.
+// The host resolves the folder from its own state directory and record; a client supplies only the
+// session id and a bare file name.
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import {
diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts
index 93fff5d92680..e33c7800cabb 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts
@@ -175,7 +175,7 @@ describe('capability gating', () => {
}
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
// so an additive method is invisible to old clients and needs no protocol bump.
- expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(34)
+ expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(33)
})
it('hides the surface from a declared client that did not advertise it', async () => {
diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts
index 4d9c27cddd21..d2da7b0f92c2 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session.ts
@@ -34,7 +34,6 @@ import {
} from './structured-agent-session-create'
import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold'
import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal'
-import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual'
import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods'
import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume'
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
@@ -317,7 +316,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
}),
...STRUCTURED_AGENT_SESSION_HOLD_METHODS,
...STRUCTURED_AGENT_SESSION_REVEAL_METHODS,
- ...STRUCTURED_AGENT_SESSION_VISUAL_METHODS,
...STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS,
...STRUCTURED_AGENT_SESSION_STATUS_METHODS,
...STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS,
diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
index 5ffde06b6c5e..36bf29ec6b5a 100644
--- a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
@@ -12,6 +12,7 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
}))
import { NativeChatMarkdown } from './NativeChatMarkdown'
+import { MessageRow } from './NativeChatMessageRow'
import {
NativeChatVisualOwnerContext,
type NativeChatVisualOwner
@@ -124,4 +125,27 @@ describe('NativeChatMarkdown visuals', () => {
expect(noOwner.container).toHaveTextContent('::orca-visual')
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
+
+ it('holds the tail of a text row while its turn works, though the row carries no state', () => {
+ const row = (activeTurnIsWorking: boolean) =>
+ withOwner(
+
+ )
+ const { container, rerender } = render(row(true))
+ expect(container).toHaveTextContent('Here it is:')
+ expect(container).not.toHaveTextContent('::orca-visual')
+ rerender(row(false))
+ expect(container).toHaveTextContent('::orca-visual{file="usa')
+ })
})
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
index 560c7e4a0e59..6f7c3da015c7 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
@@ -306,7 +306,8 @@ export const MessageRow = memo(function MessageRow({
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
visualMessageId={message.role === 'assistant' ? message.id : undefined}
- streaming={message.state === 'running' && activeTurnIsWorking === true}
+ // Structured text streams in place with no per-row state, so the live turn decides.
+ streaming={activeTurnIsWorking === true}
/>
) : null}
{tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? (
diff --git a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
index e31951d44a14..1fb3da8f9295 100644
--- a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
@@ -8,7 +8,7 @@ import {
readNativeChatVisualFrameMessage
} from '../../../../shared/native-chat-visual-shell'
import type { NativeChatVisualDocument } from './native-chat-visual-read-client'
-import { createNativeChatVisualHeightGovernor } from './native-chat-visual-height-governor'
+import { createNativeChatVisualHeightGovernor } from '../../../../shared/native-chat-visual-height-governor'
import { useNativeChatVisualTheme } from './use-native-chat-visual-theme'
/** Height reserved before a visual reports its own, so the reply below does not jump far. */
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx b/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
index c4c3e2933961..592f22e591ed 100644
--- a/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
@@ -15,8 +15,13 @@ import {
type DivProps = ComponentPropsWithoutRef<'div'> & ExtraProps
-function dataAttribute(props: DivProps, name: string): unknown {
- return Reflect.get(props, name)
+/** The placeholder's attributes, as react-markdown passed them (absent on an ordinary div). */
+function placeholderAttributes(props: DivProps): { nonce: unknown; file: unknown; title: unknown } {
+ return {
+ nonce: 'data-orca-visual' in props ? props['data-orca-visual'] : undefined,
+ file: 'data-orca-visual-file' in props ? props['data-orca-visual-file'] : undefined,
+ title: 'data-orca-visual-title' in props ? props['data-orca-visual-title'] : undefined
+ }
}
function newNonce(): string {
@@ -31,11 +36,10 @@ function createVisualExtension(
nonce: string
): CommentMarkdownExtension {
function VisualPlaceholder({ node: _node, ...props }: DivProps): React.JSX.Element {
- const file = dataAttribute(props, 'data-orca-visual-file')
- const title = dataAttribute(props, 'data-orca-visual-title')
+ const { nonce: placeholderNonce, file, title } = placeholderAttributes(props)
// Only the parser's own placeholders carry this message's nonce; the fields are re-checked anyway.
if (
- dataAttribute(props, 'data-orca-visual') !== nonce ||
+ placeholderNonce !== nonce ||
typeof file !== 'string' ||
!isNativeChatVisualFileName(file) ||
(title !== undefined &&
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx
index 8f21249556bf..645999795757 100644
--- a/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx
@@ -13,12 +13,14 @@ import {
const NONCE = 'nonce-123'
function Probe(props: ComponentProps<'div'> & ExtraProps): React.JSX.Element {
- if (Reflect.get(props, 'data-orca-visual') !== NONCE) {
+ const nonce = 'data-orca-visual' in props ? props['data-orca-visual'] : undefined
+ if (nonce !== NONCE) {
return
{props.children}
}
- const title: unknown = Reflect.get(props, 'data-orca-visual-title')
+ const title = 'data-orca-visual-title' in props ? props['data-orca-visual-title'] : undefined
+ const file = 'data-orca-visual-file' in props ? props['data-orca-visual-file'] : undefined
return (
-
+
{typeof title === 'string' ? `[${title}]` : '[untitled]'}
)
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-height-governor.test.ts b/src/shared/native-chat-visual-height-governor.test.ts
similarity index 100%
rename from src/renderer/src/components/native-chat/native-chat-visual-height-governor.test.ts
rename to src/shared/native-chat-visual-height-governor.test.ts
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-height-governor.ts b/src/shared/native-chat-visual-height-governor.ts
similarity index 95%
rename from src/renderer/src/components/native-chat/native-chat-visual-height-governor.ts
rename to src/shared/native-chat-visual-height-governor.ts
index 3cb45a166cf6..f6fce92410bb 100644
--- a/src/renderer/src/components/native-chat/native-chat-visual-height-governor.ts
+++ b/src/shared/native-chat-visual-height-governor.ts
@@ -1,4 +1,4 @@
-import { clampNativeChatVisualHeight } from '../../../../shared/native-chat-visual-shell'
+import { clampNativeChatVisualHeight } from './native-chat-visual-shell'
const WINDOW_MS = 1_000
const MAX_CHANGES_PER_WINDOW = 20
diff --git a/src/shared/native-chat-visual-shell.ts b/src/shared/native-chat-visual-shell.ts
index c870d7ced695..aa18ec347768 100644
--- a/src/shared/native-chat-visual-shell.ts
+++ b/src/shared/native-chat-visual-shell.ts
@@ -204,19 +204,17 @@ export function readNativeChatVisualFrameMessage(
if (typeof data !== 'object' || data === null) {
return null
}
- const field = (name: string): unknown => Reflect.get(data, name)
- if (field('channel') !== channel) {
+ if (!('channel' in data) || data.channel !== channel || !('type' in data)) {
return null
}
- const type = field('type')
- if (type === NATIVE_CHAT_VISUAL_SIZE_TYPE) {
- const height = field('height')
+ if (data.type === NATIVE_CHAT_VISUAL_SIZE_TYPE) {
+ const height = 'height' in data ? data.height : undefined
return typeof height === 'number' && Number.isFinite(height) && height > 0
? { kind: 'size', height }
: null
}
- if (type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) {
- const url = field('url')
+ if (data.type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) {
+ const url = 'url' in data ? data.url : undefined
if (typeof url !== 'string' || url.length > MAX_LINK_LENGTH) {
return null
}
From 79b63cf822838d4833e2d7e1739bafcfcadbe867 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 6 Oct 2026 22:52:34 -0700
Subject: [PATCH 04/10] fix(native-chat): harden the visual read and link
opening
Re-checks after the open that the chat's visuals folder is still the real
directory at Orca's path, reports unexpected filesystem faults by code without
host paths, and lets one click in a visual open at most one page.
---
.../native-chat-visual-file-read.test.ts | 20 ++++++++++++-
.../native-chat-visual-file-read.ts | 29 ++++++++++++++++---
.../native-chat/NativeChatVisualFrame.tsx | 7 +++--
3 files changed, 49 insertions(+), 7 deletions(-)
diff --git a/src/main/native-chat/native-chat-visual-file-read.test.ts b/src/main/native-chat/native-chat-visual-file-read.test.ts
index 901487c688bd..c28798e88b5f 100644
--- a/src/main/native-chat/native-chat-visual-file-read.test.ts
+++ b/src/main/native-chat/native-chat-visual-file-read.test.ts
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
-import { mkdir, mkdtemp, rm, symlink, writeFile, link } from 'node:fs/promises'
+import { chmod, mkdir, mkdtemp, rm, symlink, writeFile, link } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -183,4 +183,22 @@ describe('readNativeChatVisualFile', () => {
html: '
new
'
})
})
+
+ posixIt('reports an unexpected filesystem fault without the host path', async () => {
+ await writeFile(join(folder, 'chart.html'), '
a
')
+ await chmod(join(folder, 'chart.html'), 0o000)
+ try {
+ const failure = await readNativeChatVisualFile(folder, 'chart.html').then(
+ () => null,
+ (error: unknown) => error
+ )
+ // Root reads through the mode bits; everyone else gets the coded fault.
+ if (failure !== null) {
+ expect(String(failure)).toContain('visual_read_failed:EACCES')
+ expect(String(failure)).not.toContain(stateDirectory)
+ }
+ } finally {
+ await chmod(join(folder, 'chart.html'), 0o644)
+ }
+ })
})
diff --git a/src/main/native-chat/native-chat-visual-file-read.ts b/src/main/native-chat/native-chat-visual-file-read.ts
index e6eaba3d9a32..41d03c6c4ab1 100644
--- a/src/main/native-chat/native-chat-visual-file-read.ts
+++ b/src/main/native-chat/native-chat-visual-file-read.ts
@@ -95,8 +95,13 @@ async function openVisual(candidate: string): Promise {
}
}
-/** After the open: the handle must still be the file directly inside the canonical folder. */
+/**
+ * After the open: the handle must still be the file directly inside the canonical folder, and the
+ * folder that canonical path names must still be the real directory at Orca's own path (a symlink
+ * swapped in and out around the first check would otherwise aim `folderReal` elsewhere).
+ */
async function assertStillContained(
+ folder: string,
candidate: string,
folderReal: string,
file: string,
@@ -105,9 +110,24 @@ async function assertStillContained(
let resolved: string
let current: Stats
try {
+ const [parentNow, folderNow, canonicalFolderNow] = await Promise.all([
+ lstat(dirname(folder)),
+ lstat(folder),
+ lstat(folderReal)
+ ])
+ if (
+ parentNow.isSymbolicLink() ||
+ folderNow.isSymbolicLink() ||
+ !sameFile(folderNow, canonicalFolderNow)
+ ) {
+ refuse('outside_folder')
+ }
resolved = await realpath(candidate)
current = await lstat(candidate)
} catch (error) {
+ if (error instanceof VisualReadRefusal) {
+ throw error
+ }
if (isENOENT(error)) {
refuse('not_found')
}
@@ -161,7 +181,7 @@ async function readContained(
if (stats.size > NATIVE_CHAT_VISUAL_MAX_BYTES) {
refuse('too_large')
}
- await assertStillContained(candidate, folderReal, file, stats)
+ await assertStillContained(folder, candidate, folderReal, file, stats)
let buffer: Buffer
try {
buffer = await readLocalFileBounded(handle, NATIVE_CHAT_VISUAL_MAX_BYTES, stats.size)
@@ -184,7 +204,8 @@ async function readContained(
/**
* The visual `file` from `folder`, or the refusal the host observed. Unexpected filesystem faults
- * still throw, so the client reads them as unavailable rather than as a verdict about the file.
+ * still throw, so the client reads them as unavailable rather than as a verdict about the file; the
+ * thrown error names only the error code, never a host path.
*/
export async function readNativeChatVisualFile(
folder: string,
@@ -197,6 +218,6 @@ export async function readNativeChatVisualFile(
if (error instanceof VisualReadRefusal) {
return { ok: false, error: error.refusal }
}
- throw error
+ throw new Error(`visual_read_failed:${errorCode(error) ?? 'unknown'}`)
}
}
diff --git a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
index 1fb3da8f9295..437b1025d368 100644
--- a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
@@ -13,7 +13,9 @@ import { useNativeChatVisualTheme } from './use-native-chat-visual-theme'
/** Height reserved before a visual reports its own, so the reply below does not jump far. */
export const NATIVE_CHAT_VISUAL_RESERVED_HEIGHT = 160
-const LINK_COOLDOWN_MS = 1_000
+// Chromium keeps a click's activation for about five seconds; one open per window means one click
+// in the visual opens at most one page.
+const LINK_COOLDOWN_MS = 5_000
function newChannel(): string {
const bytes = new Uint8Array(16)
@@ -99,7 +101,8 @@ export function NativeChatVisualFrame({
return
}
// A link opens only from a real gesture in this very frame: it must hold focus and the user
- // must have just acted. This stops opens on load, not a page that waits for the next click.
+ // must have just acted. This stops opens on load, not a page that waits for the next click,
+ // and it cannot prove the click was on the link the page names.
const now = performance.now()
if (
window.document.activeElement !== frame ||
From 047fe149954045212b686072a184f4ee41b203cc Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 6 Oct 2026 23:15:16 -0700
Subject: [PATCH 05/10] fix(native-chat): keep visual lines out of plain-text
reply surfaces; review fixes
One shared helper drops visual lines (outside fenced code) from reply text where
it becomes plain text: the structured status summary that feeds the sidebar row,
dashboard, notifications, phone rows and handoffs, and AI Vault reply previews.
Review fixes: height also counts a pinned body's overflow, only the live
frontier row holds a half-written visual line, the runaway-height stop needs the
same step repeated, and any host refusal evicts the cached revision.
---
.../ai-vault/session-scanner-accumulator.ts | 2 +-
.../session-scanner-text-normalization.ts | 7 ++--
.../ai-vault/session-scanner-values.test.ts | 9 +++++
.../NativeChatMarkdown.visual.test.tsx | 6 +++-
.../native-chat/NativeChatMessageRow.tsx | 9 +++--
.../native-chat-visual-read-client.test.ts | 6 +++-
.../native-chat-visual-read-client.ts | 5 ++-
.../native-chat-visual-directive.test.ts | 26 ++++++++++++++
src/shared/native-chat-visual-directive.ts | 34 +++++++++++++++++++
...native-chat-visual-height-governor.test.ts | 13 +++++++
.../native-chat-visual-height-governor.ts | 15 +++++---
src/shared/native-chat-visual-shell.ts | 15 +++++++-
...structured-agent-session-latest-request.ts | 4 ++-
...tructured-agent-session-projection.test.ts | 28 +++++++++++++++
14 files changed, 163 insertions(+), 16 deletions(-)
diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts
index cb664b41581c..a91b7f373ef4 100644
--- a/src/main/ai-vault/session-scanner-accumulator.ts
+++ b/src/main/ai-vault/session-scanner-accumulator.ts
@@ -230,7 +230,7 @@ export function addPreviewMessage(
() => (args.text ? normalizeFullFirstUserPromptText(args.text) : null),
args.seedFirstUserPrompt
)
- const text = normalizePreviewText(args.text ?? '')
+ const text = normalizePreviewText(args.text ?? '', args.role)
if (!text) {
return
}
diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts
index 50fc54ab7aaa..d24357719df2 100644
--- a/src/main/ai-vault/session-scanner-text-normalization.ts
+++ b/src/main/ai-vault/session-scanner-text-normalization.ts
@@ -1,4 +1,5 @@
import { sliceAtCodeUnitLimit } from '../../shared/surrogate-safe-text-slice'
+import { withoutNativeChatVisualDirectiveLines } from '../../shared/native-chat-visual-directive'
export { sliceAtCodeUnitLimit }
@@ -42,8 +43,10 @@ export function extractPreviewContentText(value: unknown): string | null {
return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT)
}
-export function normalizePreviewText(value: string): string | null {
- return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT))
+/** `role` 'assistant': a reply's visual lines show only in a chat transcript, so a preview drops them. */
+export function normalizePreviewText(value: string, role?: string): string | null {
+ const text = role === 'assistant' ? withoutNativeChatVisualDirectiveLines(value) : value
+ return finalizeNormalizedText(normalizeStringText(text, SESSION_PREVIEW_TEXT_LIMIT))
}
function normalizeContentText(value: unknown, limit: number): string | null {
diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts
index 10f6195b07c2..78e8d284dc9f 100644
--- a/src/main/ai-vault/session-scanner-values.test.ts
+++ b/src/main/ai-vault/session-scanner-values.test.ts
@@ -24,6 +24,15 @@ describe('AI Vault session scanner text values', () => {
expect(normalizeTitleText('Use this repo guidance')).toBeNull()
})
+ it("drops a reply's visual lines from its preview, but not a user's", () => {
+ const line = '::orca-visual{file="latency.html" title="p95"}'
+ expect(normalizePreviewText(`p95 is highest.\n\n${line}\n\nDone.`, 'assistant')).toBe(
+ 'p95 is highest. Done.'
+ )
+ expect(normalizePreviewText(line, 'assistant')).toBeNull()
+ expect(normalizePreviewText(line, 'user')).toBe(line)
+ })
+
it('folds large preview text directly without full-string replacement', () => {
const replaceSpy = vi.spyOn(String.prototype, 'replace')
const hiddenContext = `${'SECRET\n'.repeat(10_000)}`
diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
index 36bf29ec6b5a..98a08e57436e 100644
--- a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
@@ -127,7 +127,7 @@ describe('NativeChatMarkdown visuals', () => {
})
it('holds the tail of a text row while its turn works, though the row carries no state', () => {
- const row = (activeTurnIsWorking: boolean) =>
+ const row = (activeTurnIsWorking: boolean, trailingRun = true) =>
withOwner(
{
}}
expandSignal={false}
activeTurnIsWorking={activeTurnIsWorking}
+ trailingRun={trailingRun}
onScrollMessageToTop={vi.fn()}
/>
)
@@ -147,5 +148,8 @@ describe('NativeChatMarkdown visuals', () => {
expect(container).not.toHaveTextContent('::orca-visual')
rerender(row(false))
expect(container).toHaveTextContent('::orca-visual{file="usa')
+ // An earlier row of a still-working turn has stopped growing, so it shows what it says.
+ rerender(row(true, false))
+ expect(container).toHaveTextContent('::orca-visual{file="usa')
})
})
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
index 6f7c3da015c7..85087fd278e6 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
@@ -306,8 +306,13 @@ export const MessageRow = memo(function MessageRow({
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
visualMessageId={message.role === 'assistant' ? message.id : undefined}
- // Structured text streams in place with no per-row state, so the live turn decides.
- streaming={activeTurnIsWorking === true}
+ // Structured text streams in place with no per-row state: only the live turn's frontier
+ // row, still ending in prose, can be mid-sentence.
+ streaming={
+ activeTurnIsWorking === true &&
+ trailingRun === true &&
+ message.blocks.at(-1)?.type === 'text'
+ }
/>
) : null}
{tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? (
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts b/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts
index 17c79b90f4eb..1c56e1f69ab0 100644
--- a/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts
@@ -97,14 +97,18 @@ describe('readNativeChatVisual', () => {
expect(await readNativeChatVisual(local)).toEqual({ ok: false, reason: 'unavailable' })
})
- it('passes host refusals through, degrades an unknown one, and forgets a deleted file', async () => {
+ it('passes host refusals through, degrades an unknown one, and forgets a refused file', async () => {
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '