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. */} +