From 98f0193afb6445f46ba89312face5b93ab437f8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:11:35 -0700 Subject: [PATCH] feat(native-chat): show agent-written visuals inline in structured chats (#26103) * feat(native-chat): visual directive grammar and host read for a chat's visuals folder A shared grammar for the ::orca-visual{file="..." title="..."} reply line, the per-chat visuals folder location on the owning host, and the agentSession.readVisual runtime method that reads one visual with lexical and canonical containment, a 512 KiB bounded read and UTF-8 refusal. * feat(native-chat): render chat visuals inline and in the right sidebar Native-chat assistant replies render a ::orca-visual{...} line as the chat's HTML visual in an opaque, scripts-only sandboxed frame: CSP first, the host frame navigation guard registered before content runs, live theme without a reload, fitted height, links opened in the viewer's browser only from a real gesture, lazy mount, and one muted line when the visual cannot be shown. Open in sidebar shows the same frame in the right sidebar, widened while it is open and restored after. * 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). * 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. * 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. * fix(native-chat): resolve the visuals folder without the removed journal-paths helper Main removed the per-chat journal paths and the journal database's state directory; the visuals folder keeps the same sha256 layout on its own and the read method uses the profile state directory the chat host is opened in. * fix(native-chat): review round 2 fixes; copy a reply without its visual lines Reply previews in Agent Session History drop visual lines per text part before lines are folded; the frame adds a body's overflow only when the body really overflows; fence tracking follows CommonMark closers and openers; the copy button copies a reply without visual lines; a coded read fault keeps its cause. * fix(native-chat): update the frame's theme ref after render; read the visuals folder pair at once * test(native-chat): declare agentSession.readVisual on the cross-version agent-session surface * fix(native-chat): copying a reply keeps its code blocks and indentation Removing visual lines now closes only the gap each removal leaves, instead of collapsing blank lines across the whole reply and trimming its indentation; the visuals folder is checked parent first again so a broken path answers the same way every time. --- .../ai-vault/session-scanner-accumulator.ts | 4 +- .../session-scanner-text-normalization.ts | 29 +- .../ai-vault/session-scanner-values.test.ts | 18 ++ .../native-chat-visual-file-read.test.ts | 204 ++++++++++++++ .../native-chat-visual-file-read.ts | 223 +++++++++++++++ .../native-chat/native-chat-visuals-folder.ts | 17 ++ src/main/runtime/rpc/methods/index.ts | 2 + ...ructured-agent-session-rpc.test-fixture.ts | 14 +- .../structured-agent-session-visual.test.ts | 149 ++++++++++ .../structured-agent-session-visual.ts | 42 +++ .../host-frame-navigation-guard.test.ts} | 25 +- .../host-frame-navigation-guard.ts} | 34 ++- .../main-window-webview-security.test.ts | 4 +- .../window/main-window-webview-security.ts | 8 +- .../native-chat/NativeChatInlineVisual.tsx | 111 ++++++++ .../native-chat/NativeChatMarkdown.tsx | 26 +- .../NativeChatMarkdown.visual.test.tsx | 155 +++++++++++ .../native-chat/NativeChatMessageRow.test.tsx | 31 +++ .../native-chat/NativeChatMessageRow.tsx | 8 + .../NativeChatTranscriptChrome.tsx | 9 +- .../components/native-chat/NativeChatView.tsx | 10 +- .../NativeChatVisualFrame.test.tsx | 176 ++++++++++++ .../native-chat/NativeChatVisualFrame.tsx | 169 ++++++++++++ .../native-chat/NativeChatVisualPanel.tsx | 80 ++++++ .../native-chat-visual-markdown-extension.tsx | 79 ++++++ ...ative-chat-visual-markdown-syntax.test.tsx | 135 +++++++++ .../native-chat-visual-markdown-syntax.ts | 160 +++++++++++ .../native-chat/native-chat-visual-owner.tsx | 53 ++++ .../native-chat-visual-read-client.test.ts | 151 ++++++++++ .../native-chat-visual-read-client.ts | 158 +++++++++++ .../use-native-chat-visual-document.ts | 99 +++++++ .../use-native-chat-visual-theme.ts | 89 ++++++ .../src/components/right-sidebar/index.tsx | 18 +- .../right-sidebar-panel-content.tsx | 19 +- .../right-sidebar/right-sidebar-width.ts | 8 + .../components/sidebar/CommentMarkdown.tsx | 45 ++- src/renderer/src/i18n/locales/en.json | 4 + .../editor/actions/right-sidebar-state.ts | 77 +++++- .../right-sidebar-visual-state.test.ts | 96 +++++++ .../native-chat-visual-directive.test.ts | 221 +++++++++++++++ src/shared/native-chat-visual-directive.ts | 218 +++++++++++++++ ...native-chat-visual-height-governor.test.ts | 65 +++++ .../native-chat-visual-height-governor.ts | 69 +++++ src/shared/native-chat-visual-shell.test.ts | 161 +++++++++++ src/shared/native-chat-visual-shell.ts | 261 ++++++++++++++++++ .../agent-session-visual-params.ts | 42 +++ .../rpc-params-catalog.generated.ts | 2 + ...tured-agent-session-latest-request.test.ts | 34 +++ ...structured-agent-session-latest-request.ts | 4 +- ...ructured-agent-session-surface-manifest.ts | 10 + 50 files changed, 3775 insertions(+), 51 deletions(-) create mode 100644 src/main/native-chat/native-chat-visual-file-read.test.ts create mode 100644 src/main/native-chat/native-chat-visual-file-read.ts create mode 100644 src/main/native-chat/native-chat-visuals-folder.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-visual.ts rename src/main/{plugins/plugin-panel-navigation-guard.test.ts => window/host-frame-navigation-guard.test.ts} (51%) rename src/main/{plugins/plugin-panel-navigation-guard.ts => window/host-frame-navigation-guard.ts} (62%) create mode 100644 src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatVisualPanel.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-owner.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-visual-read-client.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-visual-document.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-visual-theme.ts create mode 100644 src/renderer/src/store/slices/editor/actions/right-sidebar-visual-state.test.ts create mode 100644 src/shared/native-chat-visual-directive.test.ts create mode 100644 src/shared/native-chat-visual-directive.ts create mode 100644 src/shared/native-chat-visual-height-governor.test.ts create mode 100644 src/shared/native-chat-visual-height-governor.ts create mode 100644 src/shared/native-chat-visual-shell.test.ts create mode 100644 src/shared/native-chat-visual-shell.ts create mode 100644 src/shared/rpc-contract/agent-session-visual-params.ts diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index cb664b41581..357816cb9b4 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 } @@ -267,7 +267,7 @@ export function addPreviewContent( } addPreviewMessage(accumulator, { role, - text: extractPreviewContentText(content), + text: extractPreviewContentText(content, role), timestamp, // Content path already seeded above when capture is enabled. seedFirstUserPrompt: false, diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 50fc54ab7aa..9ad459575f6 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 } @@ -38,17 +39,31 @@ export function normalizeTitleText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_TITLE_TEXT_LIMIT)) } -export function extractPreviewContentText(value: unknown): string | null { - return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT) +/** + * `role` 'assistant': a reply's visual lines show only in a chat transcript, so a preview drops + * them, per text part and before lines are folded into one. + */ +export function extractPreviewContentText(value: unknown, role?: string): string | null { + return normalizeContentText(value, SESSION_PREVIEW_TEXT_LIMIT, role === 'assistant') } -export function normalizePreviewText(value: string): string | null { - return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) +export function normalizePreviewText(value: string, role?: string): string | null { + return finalizeNormalizedText( + normalizeStringText(previewSource(value, role === 'assistant'), SESSION_PREVIEW_TEXT_LIMIT) + ) } -function normalizeContentText(value: unknown, limit: number): string | null { +function previewSource(text: string, dropVisualLines: boolean): string { + return dropVisualLines ? withoutNativeChatVisualDirectiveLines(text) : text +} + +function normalizeContentText( + value: unknown, + limit: number, + dropVisualLines = false +): string | null { if (typeof value === 'string') { - return finalizeNormalizedText(normalizeStringText(value, limit)) + return finalizeNormalizedText(normalizeStringText(previewSource(value, dropVisualLines), limit)) } if (!Array.isArray(value)) { return null @@ -61,7 +76,7 @@ function normalizeContentText(value: unknown, limit: number): string | null { continue } appendInterPartSpace(builder) - appendNormalizedString(builder, text) + appendNormalizedString(builder, previewSource(text, dropVisualLines)) if (builder.truncated) { break } diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 10f6195b07c..8fce0a6c49b 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -24,6 +24,24 @@ 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) + // Provider content (Claude/Codex message parts) folds lines; the visual line goes first. + expect( + extractPreviewContentText( + [{ type: 'text', text: `Here it is.\n\n${line}\n\nDone.` }], + 'assistant' + ) + ).toBe('Here it is. Done.') + expect(extractPreviewContentText(`Here it is.\n${line}`, 'assistant')).toBe('Here it is.') + expect(extractPreviewContentText([{ type: 'text', text: 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/main/native-chat/native-chat-visual-file-read.test.ts b/src/main/native-chat/native-chat-visual-file-read.test.ts new file mode 100644 index 00000000000..c28798e88b5 --- /dev/null +++ b/src/main/native-chat/native-chat-visual-file-read.test.ts @@ -0,0 +1,204 @@ +import { execFileSync } from 'node:child_process' +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' +import { NATIVE_CHAT_VISUAL_MAX_BYTES } from '../../shared/native-chat-visual-directive' +import { nativeChatVisualsFolderFor } from './native-chat-visuals-folder' +import { nativeChatVisualRevision, readNativeChatVisualFile } from './native-chat-visual-file-read' + +const posixIt = process.platform === 'win32' ? it.skip : it + +let stateDirectory: string +let folder: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-read-')) + folder = nativeChatVisualsFolderFor(stateDirectory, 'session-alpha') + await mkdir(folder, { recursive: true }) +}) + +afterEach(async () => { + await rm(stateDirectory, { recursive: true, force: true }) +}) + +describe('nativeChatVisualsFolderFor', () => { + it('places each chat in its own hashed folder under the state directory', () => { + const alpha = nativeChatVisualsFolderFor('/state', 'session-alpha') + const beta = nativeChatVisualsFolderFor('/state', 'session-beta') + expect(alpha).toMatch(/native-chat-visuals[\\/][0-9a-f]{32}$/) + expect(alpha.startsWith(join('/state', 'native-chat-visuals'))).toBe(true) + expect(alpha).not.toBe(beta) + }) +}) + +describe('readNativeChatVisualFile', () => { + it('reads a UTF-8 visual with its revision and size', async () => { + const html = '

Grüße

' + await writeFile(join(folder, 'chart.html'), html) + const result = await readNativeChatVisualFile(folder, 'chart.html') + expect(result).toEqual({ + ok: true, + html, + revision: nativeChatVisualRevision(Buffer.from(html)), + sizeBytes: Buffer.byteLength(html) + }) + }) + + it('answers unchanged without the bytes when the client already holds the revision', async () => { + await writeFile(join(folder, 'chart.html'), '

a

') + const first = await readNativeChatVisualFile(folder, 'chart.html') + if (!first.ok) { + throw new Error('expected a read') + } + const again = await readNativeChatVisualFile(folder, 'chart.html', first.revision) + expect(again).toEqual({ ok: true, revision: first.revision, sizeBytes: 8, unchanged: true }) + + await writeFile(join(folder, 'chart.html'), '

b

') + const changed = await readNativeChatVisualFile(folder, 'chart.html', first.revision) + expect(changed).toMatchObject({ ok: true, html: '

b

' }) + }) + + it('reports a missing file or a missing folder as not_found', async () => { + expect(await readNativeChatVisualFile(folder, 'nope.html')).toEqual({ + ok: false, + error: 'not_found' + }) + const absent = nativeChatVisualsFolderFor(stateDirectory, 'session-never') + expect(await readNativeChatVisualFile(absent, 'chart.html')).toEqual({ + ok: false, + error: 'not_found' + }) + }) + + it('refuses names that are not a bare visual file name', async () => { + await mkdir(join(stateDirectory, 'other'), { recursive: true }) + await writeFile(join(stateDirectory, 'other', 'secret.html'), 'secret') + for (const name of ['../other/secret.html', '/etc/hosts', 'chart.txt', 'sub/chart.html']) { + expect(await readNativeChatVisualFile(folder, name)).toEqual({ + ok: false, + error: 'outside_folder' + }) + } + }) + + posixIt('refuses a symlink to a sibling chat folder or another workspace', async () => { + const sibling = nativeChatVisualsFolderFor(stateDirectory, 'session-beta') + await mkdir(sibling, { recursive: true }) + await writeFile(join(sibling, 'theirs.html'), '

other chat

') + const worktree = join(stateDirectory, 'worktree') + await mkdir(worktree) + await writeFile(join(worktree, 'index.html'), '

repo

') + + await symlink(join(sibling, 'theirs.html'), join(folder, 'theirs.html')) + await symlink(join(worktree, 'index.html'), join(folder, 'repo.html')) + await symlink(join(folder, 'missing-target.html'), join(folder, 'dangling.html')) + + for (const name of ['theirs.html', 'repo.html', 'dangling.html']) { + expect(await readNativeChatVisualFile(folder, name)).toEqual({ + ok: false, + error: 'outside_folder' + }) + } + }) + + posixIt('refuses a visuals folder replaced by a symlink', async () => { + const elsewhere = join(stateDirectory, 'elsewhere') + await mkdir(elsewhere) + await writeFile(join(elsewhere, 'chart.html'), '

elsewhere

') + await rm(folder, { recursive: true }) + await symlink(elsewhere, folder) + expect(await readNativeChatVisualFile(folder, 'chart.html')).toEqual({ + ok: false, + error: 'outside_folder' + }) + }) + + posixIt('refuses when the shared visuals root is a symlink', async () => { + const root = join(stateDirectory, 'native-chat-visuals') + const moved = join(stateDirectory, 'moved-root') + await rm(root, { recursive: true }) + await mkdir(join(moved, 'x'), { recursive: true }) + await symlink(moved, root) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, 'chart.html'), '

a

') + expect(await readNativeChatVisualFile(folder, 'chart.html')).toEqual({ + ok: false, + error: 'outside_folder' + }) + }) + + it('refuses a directory named like a visual', async () => { + await mkdir(join(folder, 'dir.html')) + expect(await readNativeChatVisualFile(folder, 'dir.html')).toEqual({ + ok: false, + error: 'not_a_file' + }) + }) + + posixIt('refuses a FIFO without waiting for a writer', async () => { + execFileSync('mkfifo', [join(folder, 'pipe.html')]) + expect(await readNativeChatVisualFile(folder, 'pipe.html')).toEqual({ + ok: false, + error: 'not_a_file' + }) + }) + + it('refuses a file over the byte cap and accepts one exactly at it', async () => { + await writeFile(join(folder, 'max.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES)) + expect(await readNativeChatVisualFile(folder, 'max.html')).toMatchObject({ ok: true }) + await writeFile(join(folder, 'big.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES + 1)) + expect(await readNativeChatVisualFile(folder, 'big.html')).toEqual({ + ok: false, + error: 'too_large' + }) + }) + + it('refuses binary and invalid UTF-8 content', async () => { + await writeFile(join(folder, 'zero-byte.html'), Buffer.from([0x3c, 0x00, 0x3e])) + await writeFile(join(folder, 'latin1.html'), Buffer.from([0x3c, 0xe9, 0x3e])) + expect(await readNativeChatVisualFile(folder, 'zero-byte.html')).toEqual({ + ok: false, + error: 'not_text' + }) + expect(await readNativeChatVisualFile(folder, 'latin1.html')).toEqual({ + ok: false, + error: 'not_text' + }) + }) + + it('reads a hard link the agent made inside its own folder', async () => { + // A hard link grants nothing a copy would not: the agent could write the same bytes itself. + await writeFile(join(folder, 'a.html'), '

a

') + await link(join(folder, 'a.html'), join(folder, 'b.html')) + expect(await readNativeChatVisualFile(folder, 'b.html')).toMatchObject({ ok: true }) + }) + + it('reads the replacement after a file is swapped for new content', async () => { + await writeFile(join(folder, 'chart.html'), '

old

') + await rm(join(folder, 'chart.html')) + await writeFile(join(folder, 'chart.html'), '

new

') + expect(await readNativeChatVisualFile(folder, 'chart.html')).toMatchObject({ + ok: true, + 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 new file mode 100644 index 00000000000..36faa39f1ae --- /dev/null +++ b/src/main/native-chat/native-chat-visual-file-read.ts @@ -0,0 +1,223 @@ +// Reading one visual out of a chat's visuals folder. This is the boundary, not the renderer: the +// file must sit directly in the folder both lexically and canonically, be a regular file reached +// without a symlink, and be UTF-8 text within the byte cap. + +import { createHash } from 'node:crypto' +import { constants, type Stats } from 'node:fs' +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { + NATIVE_CHAT_VISUAL_MAX_BYTES, + isNativeChatVisualFileName +} from '../../shared/native-chat-visual-directive' +import type { + AgentSessionReadVisualResult, + AgentSessionVisualReadError +} from '../../shared/rpc-contract/agent-session-visual-params' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { readLocalFileBounded } from '../ipc/filesystem/local-regular-file-read' + +// Why O_NOFOLLOW: refuses a final-component symlink at open, so no swap after a check can redirect +// the read. Windows has no such flag; there the lstat and post-open identity checks hold the line. +const VISUAL_OPEN_FLAGS = + constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0) + +const REVISION_HEX_LENGTH = 32 + +class VisualReadRefusal extends Error { + constructor(readonly refusal: AgentSessionVisualReadError) { + super(refusal) + } +} + +function refuse(refusal: AgentSessionVisualReadError): never { + throw new VisualReadRefusal(refusal) +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined +} + +function sameFile(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** + * The folder and its parent are Orca's own directories below the trusted state directory. Either + * one replaced by a symlink would aim every read somewhere else, so a link there is refused. + */ +async function canonicalFolder(folder: string): Promise { + try { + // Parent first, so the answer for a broken path does not depend on which check settles first. + const parentStats = await lstat(dirname(folder)) + const owned = [parentStats, await lstat(folder)] + if (owned.some((stats) => stats.isSymbolicLink())) { + refuse('outside_folder') + } + if (owned.some((stats) => !stats.isDirectory())) { + refuse('not_found') + } + return await realpath(folder) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error) || errorCode(error) === 'ENOTDIR') { + refuse('not_found') + } + throw error + } +} + +async function openVisual(candidate: string): Promise { + try { + if ((await lstat(candidate)).isSymbolicLink()) { + refuse('outside_folder') + } + return await open(candidate, VISUAL_OPEN_FLAGS) + } catch (error) { + if (error instanceof VisualReadRefusal) { + throw error + } + if (isENOENT(error) || errorCode(error) === 'ENOTDIR') { + refuse('not_found') + } + // ELOOP: the path became a symlink between the lstat and the open. + if (errorCode(error) === 'ELOOP' || errorCode(error) === 'EMLINK') { + refuse('outside_folder') + } + if (errorCode(error) === 'EISDIR') { + refuse('not_a_file') + } + throw error + } +} + +/** + * 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, + opened: Stats +): Promise { + 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') + } + throw error + } + // Why compare names case-insensitively: a case-insensitive volume may report the stored case. + if ( + dirname(resolved) !== folderReal || + basename(resolved).toLowerCase() !== file.toLowerCase() || + current.isSymbolicLink() || + !sameFile(current, opened) + ) { + refuse('outside_folder') + } +} + +function decodeVisualText(buffer: Buffer): string { + if (buffer.includes(0)) { + refuse('not_text') + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { + return refuse('not_text') + } +} + +export function nativeChatVisualRevision(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex').slice(0, REVISION_HEX_LENGTH) +} + +async function readContained( + folder: string, + file: string, + knownRevision: string | undefined +): Promise { + if (!isNativeChatVisualFileName(file)) { + refuse('outside_folder') + } + const folderReal = await canonicalFolder(folder) + const candidate = join(folderReal, file) + if (dirname(candidate) !== folderReal) { + refuse('outside_folder') + } + const handle = await openVisual(candidate) + try { + const stats = await handle.stat() + if (!stats.isFile()) { + refuse('not_a_file') + } + if (stats.size > NATIVE_CHAT_VISUAL_MAX_BYTES) { + refuse('too_large') + } + await assertStillContained(folder, candidate, folderReal, file, stats) + let buffer: Buffer + try { + buffer = await readLocalFileBounded(handle, NATIVE_CHAT_VISUAL_MAX_BYTES, stats.size) + } catch (error) { + if (error instanceof Error && error.message.startsWith('File too large')) { + refuse('too_large') + } + throw error + } + const html = decodeVisualText(buffer) + const revision = nativeChatVisualRevision(buffer) + const sizeBytes = buffer.length + return knownRevision === revision + ? { ok: true, revision, sizeBytes, unchanged: true } + : { ok: true, revision, sizeBytes, html } + } finally { + await handle.close() + } +} + +/** + * 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; the + * thrown error names only the error code, never a host path. + */ +export async function readNativeChatVisualFile( + folder: string, + file: string, + knownRevision?: string +): Promise { + try { + return await readContained(folder, file, knownRevision) + } catch (error) { + if (error instanceof VisualReadRefusal) { + return { ok: false, error: error.refusal } + } + throw new Error(`visual_read_failed:${errorCode(error) ?? 'unknown'}`, { cause: error }) + } +} diff --git a/src/main/native-chat/native-chat-visuals-folder.ts b/src/main/native-chat/native-chat-visuals-folder.ts new file mode 100644 index 00000000000..7af2263b5d1 --- /dev/null +++ b/src/main/native-chat/native-chat-visuals-folder.ts @@ -0,0 +1,17 @@ +// Where a structured chat's visuals live on the host that owns the chat: Orca-owned state beside the +// chat journal, never inside the user's workspace. + +import { createHash } from 'node:crypto' +import { join } from 'node:path' + +const NATIVE_CHAT_VISUALS_DIR_NAME = 'native-chat-visuals' + +/** + * `/native-chat-visuals/`. Keyed by the session id + * alone, the chat record's primary key, so the folder needs no workspace lookup to find or remove; + * hashed so any id is one safe path segment. + */ +export function nativeChatVisualsFolderFor(stateDirectory: string, sessionId: string): string { + const segment = createHash('sha256').update(sessionId, 'utf8').digest('hex').slice(0, 32) + return join(stateDirectory, NATIVE_CHAT_VISUALS_DIR_NAME, segment) +} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 00bd68f5746..9f87aab0b90 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -49,6 +49,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' @@ -69,6 +70,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 43f813b35ee..c4b3f62700b 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 new file mode 100644 index 00000000000..8d73e3b374f --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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 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 +vi.mock('../../../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => stateDirectory +})) +let record: AgentSessionRecord | null + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-visual-rpc-')) + record = { ...agentSessionRecordFixture(), sessionId: SESSION } + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { + store: { getRecord: (id: string) => (record?.sessionId === id ? record : null) } + } + }) + ) +}) + +afterEach(async () => { + clearStructuredHostStub() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +async function writeVisual(name: string, html: string): Promise { + const folder = nativeChatVisualsFolderFor(stateDirectory, SESSION) + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, name), html) +} + +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( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ + ok: true, + result: { ok: true, html: '

chart

', sizeBytes: 12 } + }) + }) + + it('answers unchanged for the revision the client holds', async () => { + await writeVisual('chart.html', '

chart

') + const first = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + const result: unknown = first.ok ? first.result : null + 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 } }) + 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 () => { + record = null + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(reply).toMatchObject({ ok: true, result: { ok: false, error: 'session_not_found' } }) + }) + + it('refuses a chat recorded on another execution host or a WSL distro', async () => { + const base = agentSessionRecordFixture() + record = { + ...base, + sessionId: SESSION, + location: { ...base.location, executionHostId: 'ssh:box' } + } + const ssh = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(ssh).toMatchObject({ ok: true, result: { ok: false, error: 'unsupported_location' } }) + + record = { ...base, sessionId: SESSION, location: { ...base.location, wslDistro: 'Ubuntu' } } + const wsl = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + STRUCTURED_CLIENT + ) + expect(wsl).toMatchObject({ ok: true, result: { ok: false, error: 'unsupported_location' } }) + }) + + it.each([ + ['a path', { sessionId: SESSION, file: '../chart.html' }], + ['a non-html file', { sessionId: SESSION, file: 'chart.txt' }], + ['a bad session id', { sessionId: '../x', file: 'chart.html' }], + ['an unknown field', { sessionId: SESSION, file: 'chart.html', path: '/etc' }], + ['a malformed revision', { sessionId: SESSION, file: 'chart.html', knownRevision: 'zz' }] + ])('rejects %s as invalid params', async (_name, params) => { + const reply = await call('agentSession.readVisual', params, STRUCTURED_CLIENT) + expect(reply).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + }) + + it('is refused to a client that cannot read structured sessions', async () => { + await writeVisual('chart.html', '

chart

') + const reply = await call( + 'agentSession.readVisual', + { sessionId: SESSION, file: 'chart.html' }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(reply.ok).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-visual.ts b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts new file mode 100644 index 00000000000..ed486013a65 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-visual.ts @@ -0,0 +1,42 @@ +// `agentSession.readVisual` — one HTML visual from a chat's own visuals folder. +// +// 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 { + ReadVisualParams, + type AgentSessionReadVisualResult +} from '../../../../shared/rpc-contract/agent-session-visual-params' +import { nativeChatVisualsFolderFor } from '../../../native-chat/native-chat-visuals-folder' +import { getProfileUserDataPath } from '../../../orca-profiles/profile-storage-paths' +import { readNativeChatVisualFile } from '../../../native-chat/native-chat-visual-file-read' +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' + +export const STRUCTURED_AGENT_SESSION_VISUAL_METHODS = [ + defineMethod({ + name: 'agentSession.readVisual', + params: ReadVisualParams, + handler: async (params, ctx): Promise => { + const host = await requireInstalledStructuredHost(ctx) + const record = host.deps.store.getRecord(params.sessionId) + if (!record) { + return { ok: false, error: 'session_not_found' } + } + // Structured chats run on their owning runtime's own filesystem; any other location has no + // visuals folder this process can read. + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro + ) { + return { ok: false, error: 'unsupported_location' } + } + // The same state directory the chat host and its journal are opened in on this process. + const folder = nativeChatVisualsFolderFor(getProfileUserDataPath(), params.sessionId) + return readNativeChatVisualFile(folder, params.file, params.knownRevision) + } + }) +] diff --git a/src/main/plugins/plugin-panel-navigation-guard.test.ts b/src/main/window/host-frame-navigation-guard.test.ts similarity index 51% rename from src/main/plugins/plugin-panel-navigation-guard.test.ts rename to src/main/window/host-frame-navigation-guard.test.ts index 2889e57cf70..1a4aae3d042 100644 --- a/src/main/plugins/plugin-panel-navigation-guard.test.ts +++ b/src/main/window/host-frame-navigation-guard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' -import { PluginPanelNavigationRegistry } from './plugin-panel-navigation-guard' +import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat-visual-shell' +import { HostFrameNavigationRegistry, hostFrameKindForName } from './host-frame-navigation-guard' function frame(input: { id: number; name?: string; url?: string }) { let destroyed = false @@ -14,9 +15,9 @@ function frame(input: { id: number; name?: string; url?: string }) { } } -describe('PluginPanelNavigationRegistry', () => { +describe('HostFrameNavigationRegistry', () => { it('blocks only host-marked plugin srcdoc frames', () => { - const registry = new PluginPanelNavigationRegistry() + const registry = new HostFrameNavigationRegistry() const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) const notebook = frame({ id: 2 }) registry.register(plugin) @@ -28,7 +29,7 @@ describe('PluginPanelNavigationRegistry', () => { }) it('keeps pre-parse identity after name mutation and prunes destroyed frames', () => { - const registry = new PluginPanelNavigationRegistry() + const registry = new HostFrameNavigationRegistry() const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) registry.register(plugin) plugin.name = '' @@ -38,4 +39,20 @@ describe('PluginPanelNavigationRegistry', () => { plugin.destroy() expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(false) }) + + it('contains chat visual frames the same way, as their own kind', () => { + const registry = new HostFrameNavigationRegistry() + const visual = frame({ id: 3, name: `${NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX}abc` }) + registry.register(visual) + expect(hostFrameKindForName(visual.name)).toBe('chat-visual') + expect(hostFrameKindForName(`${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo`)).toBe('plugin-panel') + expect(hostFrameKindForName('orca-chat-visual')).toBeNull() + + expect(registry.shouldBlock(visual, null, 'about:srcdoc')).toBe(false) + // Its own navigation (location, meta refresh, link) and any child it starts are refused. + expect(registry.shouldBlock(visual, visual, 'https://example.com')).toBe(true) + expect(registry.shouldBlock(visual, null, 'https://example.com')).toBe(true) + const child = frame({ id: 4 }) + expect(registry.shouldBlock(child, visual, 'https://example.com')).toBe(true) + }) }) diff --git a/src/main/plugins/plugin-panel-navigation-guard.ts b/src/main/window/host-frame-navigation-guard.ts similarity index 62% rename from src/main/plugins/plugin-panel-navigation-guard.ts rename to src/main/window/host-frame-navigation-guard.ts index d183eae9888..110b3705c5d 100644 --- a/src/main/plugins/plugin-panel-navigation-guard.ts +++ b/src/main/window/host-frame-navigation-guard.ts @@ -1,22 +1,40 @@ import type { WebContents, WebFrameMain } from 'electron' +import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat-visual-shell' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' type NavigationFrame = Pick +/** + * Host-built srcdoc frames that hold content Orca did not write. Each kind is only a navigation + * containment class: registering a frame grants it nothing (no plugin identity, no actions). + */ +const HOST_FRAME_KINDS = [ + { kind: 'plugin-panel', namePrefix: PLUGIN_PANEL_FRAME_NAME_PREFIX }, + { kind: 'chat-visual', namePrefix: NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } +] as const + +export type HostFrameKind = (typeof HOST_FRAME_KINDS)[number]['kind'] + type RegisteredFrame = { frame: NavigationFrame + kind: HostFrameKind initialSrcdocPending: boolean } -/** Records host-marked panel frame identities at browsing-context creation, - * before plugin parsing can mutate window.name. */ -export class PluginPanelNavigationRegistry { +export function hostFrameKindForName(name: string): HostFrameKind | null { + return HOST_FRAME_KINDS.find((entry) => name.startsWith(entry.namePrefix))?.kind ?? null +} + +/** Records host-marked frame identities at browsing-context creation, before their content can + * mutate window.name. */ +export class HostFrameNavigationRegistry { private readonly frames = new Map() register(frame: NavigationFrame): void { this.prune() - if (frame.name.startsWith(PLUGIN_PANEL_FRAME_NAME_PREFIX)) { - this.frames.set(frame.frameTreeNodeId, { frame, initialSrcdocPending: true }) + const kind = hostFrameKindForName(frame.name) + if (kind) { + this.frames.set(frame.frameTreeNodeId, { frame, kind, initialSrcdocPending: true }) } } @@ -52,8 +70,8 @@ export class PluginPanelNavigationRegistry { } } -export function registerPluginPanelNavigationGuard(webContents: WebContents): void { - const registry = new PluginPanelNavigationRegistry() +export function registerHostFrameNavigationGuard(webContents: WebContents): void { + const registry = new HostFrameNavigationRegistry() webContents.on('frame-created', (_event, { frame }) => { if (frame) { registry.register(frame) @@ -62,7 +80,7 @@ export function registerPluginPanelNavigationGuard(webContents: WebContents): vo webContents.on('did-start-navigation', (event) => { if (!event.isMainFrame && event.url === 'about:srcdoc' && event.frame) { // Some Chromium builds populate the frame name only when navigation - // starts; this event still precedes document parsing and plugin code. + // starts; this event still precedes document parsing and frame content. registry.register(event.frame) } }) diff --git a/src/main/window/main-window-webview-security.test.ts b/src/main/window/main-window-webview-security.test.ts index e54dc6a6836..d47a2f8118e 100644 --- a/src/main/window/main-window-webview-security.test.ts +++ b/src/main/window/main-window-webview-security.test.ts @@ -15,8 +15,8 @@ vi.mock('../browser/browser-manager', () => ({ vi.mock('../browser/browser-session-registry', () => ({ browserSessionRegistry: { isAllowedPartition: mocks.isAllowedPartition } })) -vi.mock('../plugins/plugin-panel-navigation-guard', () => ({ - registerPluginPanelNavigationGuard: mocks.registerPluginGuard +vi.mock('./host-frame-navigation-guard', () => ({ + registerHostFrameNavigationGuard: mocks.registerPluginGuard })) vi.mock('./privileged-window-navigation', () => ({ installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy diff --git a/src/main/window/main-window-webview-security.ts b/src/main/window/main-window-webview-security.ts index a662449eb58..7c026d7cf4a 100644 --- a/src/main/window/main-window-webview-security.ts +++ b/src/main/window/main-window-webview-security.ts @@ -20,7 +20,7 @@ import { revokeAllDocPreviewGrants } from '../browser/doc-preview-grant-registry' import { isDocPreviewSession } from '../browser/doc-preview-protocol' -import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { registerHostFrameNavigationGuard } from './host-frame-navigation-guard' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' /** @@ -54,9 +54,9 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi setDocPreviewFailureSink(null) revokeAllDocPreviewGrants() }) - // Why: containment must be listening before any plugin panel frame is created, - // so register it with the window's other navigation policy. - registerPluginPanelNavigationGuard(mainWindow.webContents) + // Why: containment must be listening before any plugin panel or chat visual frame is + // created, so register it with the window's other navigation policy. + registerHostFrameNavigationGuard(mainWindow.webContents) const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') // Why a preview gets a preload at all: it is our own editor surface, not a browsing guest. This diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx new file mode 100644 index 00000000000..eda0f0e7f9e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react' +import { Maximize2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { NATIVE_CHAT_VISUAL_RESERVED_HEIGHT, NativeChatVisualFrame } from './NativeChatVisualFrame' +import { observeTranscriptVisibility } from './NativeChatTranscriptChrome' +import type { NativeChatVisualOwner } from './native-chat-visual-owner' +import { useNativeChatVisualDocument } from './use-native-chat-visual-document' + +export function NativeChatVisualUnavailable(): React.JSX.Element { + return ( +

+ {translate('components.native-chat.visualUnavailable', 'Visualization unavailable')} +

+ ) +} + +/** + * A visual inside an assistant reply. Its frame mounts once the reply scrolls near it and stays + * mounted, so what the reader did in it survives scrolling away and back. + */ +export function NativeChatInlineVisual({ + owner, + messageId, + file, + title +}: { + owner: NativeChatVisualOwner + messageId: string + file: string + title: string | null +}): React.JSX.Element { + const boxRef = useRef(null) + const [near, setNear] = useState(false) + const [retired, setRetired] = useState(false) + const openRightSidebarVisual = useAppStore((state) => state.openRightSidebarVisual) + const state = useNativeChatVisualDocument( + { target: owner.target, sessionId: owner.sessionId, file }, + near + ) + + useEffect(() => { + const element = boxRef.current + if (!element || near) { + return + } + return observeTranscriptVisibility(element, (visible) => { + if (visible) { + setNear(true) + } + }) + }, [near]) + + if (retired || state.status === 'unavailable') { + return + } + const label = title ?? file + const openLabel = translate('components.native-chat.visualOpenInSidebar', 'Open in sidebar') + + return ( +
+ {state.status === 'ready' && near ? ( + <> + setRetired(true)} + /> +
+ + + + + + {openLabel} + + +
+ + ) : ( +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx index 3f2e89fb07e..4033b51fc62 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx @@ -1,11 +1,33 @@ import type { ComponentProps } from 'react' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' +import { withoutPendingNativeChatVisualDirectiveTail } from '../../../../shared/native-chat-visual-directive' +import { useNativeChatVisualMarkdownExtension } from './native-chat-visual-markdown-extension' import './native-chat-markdown.css' +type NativeChatMarkdownProps = ComponentProps & { + /** On assistant prose in a structured chat: this message may show visuals. */ + visualMessageId?: string + /** The reply is still arriving, so an unfinished visual line at its end is held back. */ + streaming?: boolean +} + export function NativeChatMarkdown({ className, + visualMessageId, + streaming = false, + content, ...props -}: ComponentProps): React.JSX.Element { - return +}: NativeChatMarkdownProps): React.JSX.Element { + const extension = useNativeChatVisualMarkdownExtension(visualMessageId) + return ( + + ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx new file mode 100644 index 00000000000..98a08e57436 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +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 { NativeChatMarkdown } from './NativeChatMarkdown' +import { MessageRow } from './NativeChatMessageRow' +import { + NativeChatVisualOwnerContext, + type NativeChatVisualOwner +} from './native-chat-visual-owner' +import { clearNativeChatVisualCacheForTests } from './native-chat-visual-read-client' +import { TooltipProvider } from '@/components/ui/tooltip' + +const owner: NativeChatVisualOwner = { + target: { kind: 'local' }, + sessionId: 'session-alpha', + tabId: 'tab-1', + worktreeId: 'wt-1' +} +const LINE = '::orca-visual{file="usage.html" title="Usage"}' + +function withOwner(children: ReactNode, value: NativeChatVisualOwner | null = owner): ReactNode { + return ( + + + {children} + + + ) +} + +// The transcript mounts a visual once it scrolls near; here every visual is in view at once. +class InViewObserver { + constructor(private readonly callback: (entries: Partial[]) => void) {} + observe(target: Element): void { + this.callback([{ target, isIntersecting: true }]) + } + unobserve(): void {} + disconnect(): void {} +} + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', InViewObserver) + clearNativeChatVisualCacheForTests() + callRuntimeRpc.mockReset() + callRuntimeRpc.mockResolvedValue({ ok: true, revision: 'r1', sizeBytes: 9, html: '

v

' }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('NativeChatMarkdown visuals', () => { + it('holds back an unfinished directive at the end of a streaming reply', () => { + const { container } = render( + withOwner( + + ) + ) + expect(container).toHaveTextContent('Here it is:') + expect(container).not.toHaveTextContent('::orca-visual') + }) + + it('shows unfinished syntax as text once the reply is no longer streaming', () => { + const { container } = render( + withOwner( + + ) + ) + expect(container).toHaveTextContent('::orca-visual{file="usa') + }) + + it('keeps a mounted visual frame while the rest of the reply streams in', async () => { + const { container, rerender } = render( + withOwner( + + ) + ) + await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()) + const frame = container.querySelector('iframe') + rerender( + withOwner( + + ) + ) + expect(container.querySelector('iframe')).toBe(frame) + expect(container).toHaveTextContent('More text arriving now') + expect(callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + + it('shows one muted line when the host refuses the file', async () => { + callRuntimeRpc.mockResolvedValue({ ok: false, error: 'outside_folder' }) + const { container } = render( + withOwner() + ) + await waitFor(() => expect(container).toHaveTextContent('Visualization unavailable')) + expect(container.querySelector('iframe')).toBeNull() + }) + + it('leaves the line as text where visuals do not apply', () => { + const noMessage = render(withOwner()) + expect(noMessage.container).toHaveTextContent('::orca-visual') + cleanup() + const noOwner = render( + withOwner(, null) + ) + 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, trailingRun = true) => + withOwner( + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Copy message' })) + + await waitFor(() => { + expect(writeClipboardText).toHaveBeenCalledWith('Here it is.\n\nDone.') + }) + }) + it('omits the copy button on image-only user messages', () => { render( ) : null} {run || tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index e2f712c742c..ac9d54d748d 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -7,6 +7,7 @@ import type { NativeChatBlock } from '../../../../shared/native-chat-types' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-provider-frame-summary' +import { withoutNativeChatVisualDirectiveLines } from '../../../../shared/native-chat-visual-directive' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { getLocalImageCacheKey, @@ -24,7 +25,10 @@ type VisibilityListener = (isVisible: boolean) => void const visibilityListeners = new Map() let visibilityObserver: IntersectionObserver | null = null -function observeTranscriptVisibility(element: Element, listener: VisibilityListener): () => void { +export function observeTranscriptVisibility( + element: Element, + listener: VisibilityListener +): () => void { if (typeof IntersectionObserver === 'undefined') { listener(true) return () => {} @@ -289,7 +293,8 @@ export function NativeChatAgentControls({ }): React.JSX.Element { return (
- + {/* A visual line means nothing pasted outside Orca, so the copy leaves it out. */} +