mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,24 @@ describe('AI Vault session scanner text values', () => {
|
||||
expect(normalizeTitleText('<INSTRUCTIONS>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 = `<codex_internal_context source="goal">${'SECRET\n'.repeat(10_000)}</codex_internal_context>`
|
||||
|
||||
@@ -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 = '<!doctype html><p>Grüße</p>'
|
||||
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'), '<p>a</p>')
|
||||
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'), '<p>b</p>')
|
||||
const changed = await readNativeChatVisualFile(folder, 'chart.html', first.revision)
|
||||
expect(changed).toMatchObject({ ok: true, html: '<p>b</p>' })
|
||||
})
|
||||
|
||||
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'), '<p>other chat</p>')
|
||||
const worktree = join(stateDirectory, 'worktree')
|
||||
await mkdir(worktree)
|
||||
await writeFile(join(worktree, 'index.html'), '<p>repo</p>')
|
||||
|
||||
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'), '<p>elsewhere</p>')
|
||||
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'), '<p>a</p>')
|
||||
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'), '<p>a</p>')
|
||||
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'), '<p>old</p>')
|
||||
await rm(join(folder, 'chart.html'))
|
||||
await writeFile(join(folder, 'chart.html'), '<p>new</p>')
|
||||
expect(await readNativeChatVisualFile(folder, 'chart.html')).toMatchObject({
|
||||
ok: true,
|
||||
html: '<p>new</p>'
|
||||
})
|
||||
})
|
||||
|
||||
posixIt('reports an unexpected filesystem fault without the host path', async () => {
|
||||
await writeFile(join(folder, 'chart.html'), '<p>a</p>')
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
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<FileHandle> {
|
||||
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<void> {
|
||||
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<AgentSessionReadVisualResult> {
|
||||
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<AgentSessionReadVisualResult> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
* `<stateDirectory>/native-chat-visuals/<sha256(session id), first 32 hex>`. 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {}): RpcDispatcher {
|
||||
export function dispatcher(
|
||||
runtimeOverrides: Record<string, unknown> = {},
|
||||
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<string, unknown> = {}): 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<string, unknown> = {}
|
||||
runtimeOverrides: Record<string, unknown> = {},
|
||||
methods?: readonly RpcAnyMethodDeclaration[]
|
||||
): Promise<RpcResponse> {
|
||||
const replies: RpcResponse[] = []
|
||||
await dispatcher(runtimeOverrides).dispatchStreaming(
|
||||
await dispatcher(runtimeOverrides, methods).dispatchStreaming(
|
||||
request(method, params),
|
||||
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
|
||||
client
|
||||
|
||||
@@ -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<typeof callStructured>[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<void> {
|
||||
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', '<p>chart</p>')
|
||||
const reply = await call(
|
||||
'agentSession.readVisual',
|
||||
{ sessionId: SESSION, file: 'chart.html' },
|
||||
STRUCTURED_CLIENT
|
||||
)
|
||||
expect(reply).toMatchObject({
|
||||
ok: true,
|
||||
result: { ok: true, html: '<p>chart</p>', sizeBytes: 12 }
|
||||
})
|
||||
})
|
||||
|
||||
it('answers unchanged for the revision the client holds', async () => {
|
||||
await writeVisual('chart.html', '<p>chart</p>')
|
||||
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', '<p>chart</p>')
|
||||
const reply = await call(
|
||||
'agentSession.readVisual',
|
||||
{ sessionId: SESSION, file: 'chart.html' },
|
||||
{ clientKind: 'runtime', clientCapabilities: [] }
|
||||
)
|
||||
expect(reply.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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<AgentSessionReadVisualResult> => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
]
|
||||
+21
-4
@@ -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)
|
||||
})
|
||||
})
|
||||
+26
-8
@@ -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<WebFrameMain, 'frameTreeNodeId' | 'isDestroyed' | 'name'>
|
||||
|
||||
/**
|
||||
* 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<number, RegisteredFrame>()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<p className="my-2 text-xs text-muted-foreground">
|
||||
{translate('components.native-chat.visualUnavailable', 'Visualization unavailable')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement | null>(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 <NativeChatVisualUnavailable />
|
||||
}
|
||||
const label = title ?? file
|
||||
const openLabel = translate('components.native-chat.visualOpenInSidebar', 'Open in sidebar')
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="group/visual relative my-3">
|
||||
{state.status === 'ready' && near ? (
|
||||
<>
|
||||
<NativeChatVisualFrame
|
||||
document={state.document}
|
||||
title={label}
|
||||
layout="inline"
|
||||
themeScope={boxRef}
|
||||
onRetired={() => setRetired(true)}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 transition-opacity can-hover:opacity-0 group-hover/visual:opacity-100 focus-within:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={openLabel}
|
||||
onClick={() =>
|
||||
openRightSidebarVisual({
|
||||
target: owner.target,
|
||||
sessionId: owner.sessionId,
|
||||
tabId: owner.tabId,
|
||||
worktreeId: owner.worktreeId,
|
||||
messageId,
|
||||
file,
|
||||
title
|
||||
})
|
||||
}
|
||||
>
|
||||
<Maximize2 />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{openLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={translate('components.native-chat.visualLoading', 'Loading visualization')}
|
||||
className="w-full rounded-md bg-muted/30"
|
||||
style={{ height: NATIVE_CHAT_VISUAL_RESERVED_HEIGHT }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof CommentMarkdown> & {
|
||||
/** 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<typeof CommentMarkdown>): React.JSX.Element {
|
||||
return <CommentMarkdown {...props} className={cn('native-chat-markdown', className)} />
|
||||
}: NativeChatMarkdownProps): React.JSX.Element {
|
||||
const extension = useNativeChatVisualMarkdownExtension(visualMessageId)
|
||||
return (
|
||||
<CommentMarkdown
|
||||
{...props}
|
||||
content={
|
||||
extension && streaming ? withoutPendingNativeChatVisualDirectiveTail(content) : content
|
||||
}
|
||||
extension={extension}
|
||||
className={cn('native-chat-markdown', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<NativeChatVisualOwnerContext.Provider value={value}>
|
||||
{children}
|
||||
</NativeChatVisualOwnerContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<IntersectionObserverEntry>[]) => 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: '<p>v</p>' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('NativeChatMarkdown visuals', () => {
|
||||
it('holds back an unfinished directive at the end of a streaming reply', () => {
|
||||
const { container } = render(
|
||||
withOwner(
|
||||
<NativeChatMarkdown
|
||||
content={'Here it is:\n::orca-visual{file="usa'}
|
||||
visualMessageId="m1"
|
||||
streaming
|
||||
/>
|
||||
)
|
||||
)
|
||||
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(
|
||||
<NativeChatMarkdown content={'Here it is:\n::orca-visual{file="usa'} visualMessageId="m1" />
|
||||
)
|
||||
)
|
||||
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(
|
||||
<NativeChatMarkdown content={`Intro\n\n${LINE}\n\nMore`} visualMessageId="m1" streaming />
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull())
|
||||
const frame = container.querySelector('iframe')
|
||||
rerender(
|
||||
withOwner(
|
||||
<NativeChatMarkdown
|
||||
content={`Intro\n\n${LINE}\n\nMore text arriving now`}
|
||||
visualMessageId="m1"
|
||||
streaming
|
||||
/>
|
||||
)
|
||||
)
|
||||
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(<NativeChatMarkdown content={LINE} visualMessageId="m1" />)
|
||||
)
|
||||
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(<NativeChatMarkdown content={LINE} />))
|
||||
expect(noMessage.container).toHaveTextContent('::orca-visual')
|
||||
cleanup()
|
||||
const noOwner = render(
|
||||
withOwner(<NativeChatMarkdown content={LINE} visualMessageId="m1" />, 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(
|
||||
<MessageRow
|
||||
message={{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'text', text: 'Here it is:\n::orca-visual{file="usa' }]
|
||||
}}
|
||||
expandSignal={false}
|
||||
activeTurnIsWorking={activeTurnIsWorking}
|
||||
trailingRun={trailingRun}
|
||||
onScrollMessageToTop={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const { container, rerender } = render(row(true))
|
||||
expect(container).toHaveTextContent('Here it is:')
|
||||
expect(container).not.toHaveTextContent('::orca-visual')
|
||||
rerender(row(false))
|
||||
expect(container).toHaveTextContent('::orca-visual{file="usa')
|
||||
// An earlier row of a still-working turn has stopped growing, so it shows what it says.
|
||||
rerender(row(true, false))
|
||||
expect(container).toHaveTextContent('::orca-visual{file="usa')
|
||||
})
|
||||
})
|
||||
@@ -90,6 +90,37 @@ describe('MessageRow control visibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('copies an assistant reply without its visual lines, which mean nothing outside Orca', async () => {
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(window, { api: { ui: { writeClipboardText } } })
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MessageRow
|
||||
message={{
|
||||
id: 'message',
|
||||
role: 'assistant',
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here it is.\n\n::orca-visual{file="usage.html" title="Usage"}\n\nDone.'
|
||||
}
|
||||
]
|
||||
}}
|
||||
expandSignal={false}
|
||||
onScrollMessageToTop={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
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(
|
||||
<MessageRow
|
||||
|
||||
@@ -319,6 +319,14 @@ export const MessageRow = memo(function MessageRow({
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
visualMessageId={message.role === 'assistant' ? message.id : undefined}
|
||||
// Structured text streams in place with no per-row state: only the live turn's frontier
|
||||
// row, still ending in prose, can be mid-sentence.
|
||||
streaming={
|
||||
activeTurnIsWorking === true &&
|
||||
trailingRun === true &&
|
||||
message.blocks.at(-1)?.type === 'text'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{run || tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? (
|
||||
|
||||
@@ -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<Element, VisibilityListener>()
|
||||
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 (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<NativeChatCopyButton text={markdown} />
|
||||
{/* A visual line means nothing pasted outside Orca, so the copy leaves it out. */}
|
||||
<NativeChatCopyButton text={withoutNativeChatVisualDirectiveLines(markdown)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onScrollToTop}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NativeChatResolvedView } from './NativeChatResolvedView'
|
||||
import { useNativeChatStatusEntry } from './use-native-chat-status-entry'
|
||||
import type { NativeChatViewProps } from './native-chat-view-types'
|
||||
import { NativeChatPaneFileDropSurface } from './NativeChatPaneFileDropSurface'
|
||||
import { NativeChatVisualOwnerProvider } from './native-chat-visual-owner'
|
||||
|
||||
export type { NativeChatViewProps } from './native-chat-view-types'
|
||||
|
||||
@@ -12,7 +13,14 @@ export default function NativeChatView(props: NativeChatViewProps): React.JSX.El
|
||||
return (
|
||||
<NativeChatPaneFileDropSurface className="relative flex h-full min-h-0 min-w-0 w-full">
|
||||
{props.mode === 'structured' ? (
|
||||
<NativeChatStructuredSession key={props.sessionId} {...props} />
|
||||
<NativeChatVisualOwnerProvider
|
||||
key={props.sessionId}
|
||||
target={props.target}
|
||||
sessionId={props.sessionId}
|
||||
tabId={props.tabId}
|
||||
>
|
||||
<NativeChatStructuredSession {...props} />
|
||||
</NativeChatVisualOwnerProvider>
|
||||
) : (
|
||||
<NativeChatBridgeView {...props} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX,
|
||||
NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
|
||||
NATIVE_CHAT_VISUAL_SIZE_TYPE,
|
||||
NATIVE_CHAT_VISUAL_THEME_TYPE
|
||||
} from '../../../../shared/native-chat-visual-shell'
|
||||
|
||||
const openHttpLink = vi.fn()
|
||||
vi.mock('@/lib/http-link-routing', () => ({
|
||||
openHttpLink: (...args: unknown[]) => openHttpLink(...args)
|
||||
}))
|
||||
|
||||
import { NativeChatVisualFrame } from './NativeChatVisualFrame'
|
||||
|
||||
const visual = { revision: 'r1', html: '<p>chart</p>' }
|
||||
|
||||
function Harness(props: { layout?: 'inline' | 'panel'; onRetired?: () => void }) {
|
||||
const scope = useRef<HTMLDivElement | null>(null)
|
||||
return (
|
||||
<div ref={scope}>
|
||||
<NativeChatVisualFrame
|
||||
document={visual}
|
||||
title="Chart"
|
||||
layout={props.layout ?? 'inline'}
|
||||
themeScope={scope}
|
||||
onRetired={props.onRetired ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function frameOf(container: HTMLElement): HTMLIFrameElement {
|
||||
const frame = container.querySelector('iframe')
|
||||
if (!frame) {
|
||||
throw new Error('no frame')
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
function channelOf(frame: HTMLIFrameElement): string {
|
||||
return frame.name.slice(NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX.length)
|
||||
}
|
||||
|
||||
function post(data: unknown, source: MessageEventSource | null): void {
|
||||
act(() => {
|
||||
window.dispatchEvent(new MessageEvent('message', { data, source }))
|
||||
})
|
||||
}
|
||||
|
||||
function setUserActivation(isActive: boolean): void {
|
||||
Object.defineProperty(navigator, 'userActivation', {
|
||||
configurable: true,
|
||||
value: { isActive, hasBeenActive: isActive }
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
openHttpLink.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
describe('NativeChatVisualFrame', () => {
|
||||
it('runs the visual in a scripts-only sandbox, named for host registration, with its CSP first', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
expect(frame.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
expect(frame.name.startsWith(NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX)).toBe(true)
|
||||
const srcdoc = frame.getAttribute('srcdoc') ?? ''
|
||||
expect(srcdoc.indexOf('Content-Security-Policy')).toBeLessThan(srcdoc.indexOf('<p>chart</p>'))
|
||||
expect(channelOf(frame)).toMatch(/^[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
it('fits its height to what the frame reports, ignoring other windows and channels', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
const box = frame.parentElement
|
||||
const channel = channelOf(frame)
|
||||
post({ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel, height: 420 }, frame.contentWindow)
|
||||
expect(box).toHaveStyle({ height: '420px' })
|
||||
post({ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel, height: 900 }, window)
|
||||
post({ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'other', height: 900 }, frame.contentWindow)
|
||||
expect(box).toHaveStyle({ height: '420px' })
|
||||
})
|
||||
|
||||
it('restyles in place on a theme change instead of reloading the visual', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
const contentWindow = frame.contentWindow
|
||||
if (!contentWindow) {
|
||||
throw new Error('no content window')
|
||||
}
|
||||
const postMessage = vi.spyOn(contentWindow, 'postMessage')
|
||||
act(() => {
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
})
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: NATIVE_CHAT_VISUAL_THEME_TYPE, colorScheme: 'light' }),
|
||||
'*'
|
||||
)
|
||||
const srcdoc = frame.getAttribute('srcdoc')
|
||||
act(() => {
|
||||
document.documentElement.classList.add('dark')
|
||||
})
|
||||
return vi.waitFor(() => {
|
||||
expect(postMessage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ type: NATIVE_CHAT_VISUAL_THEME_TYPE, colorScheme: 'dark' }),
|
||||
'*'
|
||||
)
|
||||
expect(frameOf(container)).toBe(frame)
|
||||
expect(frame.getAttribute('srcdoc')).toBe(srcdoc)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a link only while the frame has focus and the user has just acted', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
const channel = channelOf(frame)
|
||||
const link = { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel, url: 'https://example.com/' }
|
||||
|
||||
setUserActivation(true)
|
||||
post(link, frame.contentWindow)
|
||||
expect(openHttpLink).not.toHaveBeenCalled()
|
||||
|
||||
frame.tabIndex = 0
|
||||
frame.focus()
|
||||
setUserActivation(false)
|
||||
post(link, frame.contentWindow)
|
||||
expect(openHttpLink).not.toHaveBeenCalled()
|
||||
|
||||
setUserActivation(true)
|
||||
post(link, window)
|
||||
post({ ...link, url: 'javascript:alert(1)' }, frame.contentWindow)
|
||||
expect(openHttpLink).not.toHaveBeenCalled()
|
||||
|
||||
post(link, frame.contentWindow)
|
||||
expect(openHttpLink).toHaveBeenCalledWith('https://example.com/', { forceSystemBrowser: true })
|
||||
// A burst of requests opens at most one page.
|
||||
post(link, frame.contentWindow)
|
||||
expect(openHttpLink).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retires itself if the frame loads a second document', () => {
|
||||
const onRetired = vi.fn()
|
||||
const { container } = render(<Harness onRetired={onRetired} />)
|
||||
const frame = frameOf(container)
|
||||
act(() => {
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
})
|
||||
expect(onRetired).not.toHaveBeenCalled()
|
||||
act(() => {
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
})
|
||||
expect(onRetired).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fills a panel instead of fitting its height', () => {
|
||||
const { container } = render(<Harness layout="panel" />)
|
||||
const frame = frameOf(container)
|
||||
post(
|
||||
{ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: channelOf(frame), height: 420 },
|
||||
frame.contentWindow
|
||||
)
|
||||
expect(frame.parentElement?.style.height).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX,
|
||||
buildNativeChatVisualDocument,
|
||||
nativeChatVisualThemeMessage,
|
||||
readNativeChatVisualFrameMessage
|
||||
} from '../../../../shared/native-chat-visual-shell'
|
||||
import type { NativeChatVisualDocument } from './native-chat-visual-read-client'
|
||||
import { createNativeChatVisualHeightGovernor } from '../../../../shared/native-chat-visual-height-governor'
|
||||
import { useNativeChatVisualTheme } from './use-native-chat-visual-theme'
|
||||
|
||||
/** Height reserved before a visual reports its own, so the reply below does not jump far. */
|
||||
export const NATIVE_CHAT_VISUAL_RESERVED_HEIGHT = 160
|
||||
// Chromium keeps a click's activation for about five seconds; one open per window means one click
|
||||
// in the visual opens at most one page.
|
||||
const LINK_COOLDOWN_MS = 5_000
|
||||
|
||||
function newChannel(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent-written visual in an opaque-origin frame that may run scripts and nothing else. Inline
|
||||
* it fits its height to the page; in a panel it fills the panel. A theme change restyles the page in
|
||||
* place, so interaction state survives it. The frame is never allowed to become another page: the
|
||||
* main process refuses its navigation, and a second load (a navigation that got through anyway)
|
||||
* retires it.
|
||||
*/
|
||||
export function NativeChatVisualFrame({
|
||||
document: visual,
|
||||
title,
|
||||
layout,
|
||||
themeScope,
|
||||
onRetired
|
||||
}: {
|
||||
document: NativeChatVisualDocument
|
||||
title: string
|
||||
layout: 'inline' | 'panel'
|
||||
/** An element already mounted where the frame sits, so the first paint wears that scope's theme. */
|
||||
themeScope: RefObject<Element | null>
|
||||
onRetired: () => void
|
||||
}): React.JSX.Element {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
||||
const theme = useNativeChatVisualTheme(themeScope)
|
||||
// The latest theme for the next document build and the load-time post, without rebuilding on it.
|
||||
const themeRef = useRef(theme)
|
||||
useLayoutEffect(() => {
|
||||
themeRef.current = theme
|
||||
}, [theme])
|
||||
const [height, setHeight] = useState(NATIVE_CHAT_VISUAL_RESERVED_HEIGHT)
|
||||
const loadsRef = useRef(0)
|
||||
|
||||
// Built once per revision: the theme at build time paints first, later themes arrive by message.
|
||||
const built = useMemo(() => {
|
||||
const channel = newChannel()
|
||||
return {
|
||||
channel,
|
||||
srcDoc: buildNativeChatVisualDocument({
|
||||
html: visual.html,
|
||||
channel,
|
||||
theme: themeRef.current
|
||||
})
|
||||
}
|
||||
}, [visual.html])
|
||||
|
||||
useEffect(() => {
|
||||
loadsRef.current = 0
|
||||
const governor = createNativeChatVisualHeightGovernor()
|
||||
let deferred: ReturnType<typeof setTimeout> | null = null
|
||||
let latestReported: number | null = null
|
||||
let lastLinkAt = -Infinity
|
||||
const applyHeight = (): void => {
|
||||
deferred = null
|
||||
if (latestReported === null) {
|
||||
return
|
||||
}
|
||||
const decision = governor.decide(latestReported, performance.now())
|
||||
if (decision.kind === 'apply') {
|
||||
setHeight(decision.height)
|
||||
} else if (decision.kind === 'defer') {
|
||||
deferred = setTimeout(applyHeight, decision.retryInMs)
|
||||
}
|
||||
}
|
||||
const onMessage = (event: MessageEvent): void => {
|
||||
const frame = frameRef.current
|
||||
if (!frame || event.source !== frame.contentWindow) {
|
||||
return
|
||||
}
|
||||
const message = readNativeChatVisualFrameMessage(event.data, built.channel)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
if (message.kind === 'size') {
|
||||
if (layout === 'inline') {
|
||||
latestReported = message.height
|
||||
if (!deferred) {
|
||||
applyHeight()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// A link opens only from a real gesture in this very frame: it must hold focus and the user
|
||||
// must have just acted. This stops opens on load, not a page that waits for the next click,
|
||||
// and it cannot prove the click was on the link the page names.
|
||||
const now = performance.now()
|
||||
if (
|
||||
window.document.activeElement !== frame ||
|
||||
navigator.userActivation?.isActive !== true ||
|
||||
now - lastLinkAt < LINK_COOLDOWN_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastLinkAt = now
|
||||
openHttpLink(message.url, { forceSystemBrowser: true })
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (deferred) {
|
||||
clearTimeout(deferred)
|
||||
}
|
||||
}
|
||||
}, [built.channel, layout])
|
||||
|
||||
useEffect(() => {
|
||||
if (loadsRef.current > 0) {
|
||||
frameRef.current?.contentWindow?.postMessage(
|
||||
nativeChatVisualThemeMessage(theme, built.channel),
|
||||
'*'
|
||||
)
|
||||
}
|
||||
}, [built.channel, theme])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full', layout === 'panel' && 'flex min-h-0 flex-1')}
|
||||
style={layout === 'inline' ? { height } : undefined}
|
||||
>
|
||||
<iframe
|
||||
key={built.channel}
|
||||
ref={frameRef}
|
||||
name={`${NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX}${built.channel}`}
|
||||
title={title}
|
||||
srcDoc={built.srcDoc}
|
||||
// Never allow-same-origin: the opaque origin keeps the page away from Orca's own.
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
className="block size-full border-0 bg-transparent"
|
||||
style={{ colorScheme: theme.colorScheme }}
|
||||
onLoad={() => {
|
||||
loadsRef.current += 1
|
||||
if (loadsRef.current > 1) {
|
||||
onRetired()
|
||||
return
|
||||
}
|
||||
// Covers a theme change that landed while the page loaded.
|
||||
frameRef.current?.contentWindow?.postMessage(
|
||||
nativeChatVisualThemeMessage(themeRef.current, built.channel),
|
||||
'*'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } 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 type { RightSidebarVisualState } from '@/store/slices/editor/actions/right-sidebar-state'
|
||||
import { findNativeChatTabOwnerWorktreeId } from './native-chat-file-link'
|
||||
import { NativeChatVisualFrame } from './NativeChatVisualFrame'
|
||||
import { NativeChatVisualUnavailable } from './NativeChatInlineVisual'
|
||||
import { useNativeChatVisualDocument } from './use-native-chat-visual-document'
|
||||
|
||||
/**
|
||||
* A chat visual opened in the right sidebar: the same isolated frame as inline, filling the panel.
|
||||
* It belongs to its chat and workspace, so it closes when the chat tab goes away or the user moves
|
||||
* to another workspace.
|
||||
*/
|
||||
export default function NativeChatVisualPanel({
|
||||
route
|
||||
}: {
|
||||
route: RightSidebarVisualState
|
||||
}): React.JSX.Element {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const [retired, setRetired] = useState(false)
|
||||
const closeRightSidebarVisual = useAppStore((state) => state.closeRightSidebarVisual)
|
||||
const ownerPresent = useAppStore(
|
||||
(state) =>
|
||||
state.activeWorktreeId === route.worktreeId &&
|
||||
findNativeChatTabOwnerWorktreeId(state, route.tabId) === route.worktreeId
|
||||
)
|
||||
const state = useNativeChatVisualDocument(
|
||||
{ target: route.target, sessionId: route.sessionId, file: route.file },
|
||||
ownerPresent
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ownerPresent) {
|
||||
closeRightSidebarVisual()
|
||||
}
|
||||
}, [closeRightSidebarVisual, ownerPresent])
|
||||
|
||||
const label = route.title ?? route.file
|
||||
const closeLabel = translate('components.native-chat.visualClose', 'Close visualization')
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-9 min-h-9 items-center justify-between gap-2 border-b border-border pr-1 pl-3">
|
||||
<span className="min-w-0 truncate text-[13px] text-foreground">{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={closeLabel}
|
||||
onClick={closeRightSidebarVisual}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{closeLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{retired || state.status === 'unavailable' ? (
|
||||
<div className="px-3">
|
||||
<NativeChatVisualUnavailable />
|
||||
</div>
|
||||
) : state.status === 'ready' ? (
|
||||
<NativeChatVisualFrame
|
||||
document={state.document}
|
||||
title={label}
|
||||
layout="panel"
|
||||
themeScope={panelRef}
|
||||
onRetired={() => setRetired(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
|
||||
import type { ExtraProps } from 'react-markdown'
|
||||
import type { CommentMarkdownExtension } from '@/components/sidebar/CommentMarkdown'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH,
|
||||
isNativeChatVisualFileName
|
||||
} from '../../../../shared/native-chat-visual-directive'
|
||||
import { NativeChatInlineVisual } from './NativeChatInlineVisual'
|
||||
import type { NativeChatVisualOwner } from './native-chat-visual-owner'
|
||||
import { useNativeChatVisualOwner } from './native-chat-visual-owner'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES,
|
||||
remarkNativeChatVisuals
|
||||
} from './native-chat-visual-markdown-syntax'
|
||||
|
||||
type DivProps = ComponentPropsWithoutRef<'div'> & ExtraProps
|
||||
|
||||
/** The placeholder's attributes, as react-markdown passed them (absent on an ordinary div). */
|
||||
function placeholderAttributes(props: DivProps): { nonce: unknown; file: unknown; title: unknown } {
|
||||
return {
|
||||
nonce: 'data-orca-visual' in props ? props['data-orca-visual'] : undefined,
|
||||
file: 'data-orca-visual-file' in props ? props['data-orca-visual-file'] : undefined,
|
||||
title: 'data-orca-visual-title' in props ? props['data-orca-visual-title'] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function newNonce(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function createVisualExtension(
|
||||
owner: NativeChatVisualOwner,
|
||||
messageId: string,
|
||||
nonce: string
|
||||
): CommentMarkdownExtension {
|
||||
function VisualPlaceholder({ node: _node, ...props }: DivProps): React.JSX.Element {
|
||||
const { nonce: placeholderNonce, file, title } = placeholderAttributes(props)
|
||||
// Only the parser's own placeholders carry this message's nonce; the fields are re-checked anyway.
|
||||
if (
|
||||
placeholderNonce !== nonce ||
|
||||
typeof file !== 'string' ||
|
||||
!isNativeChatVisualFileName(file) ||
|
||||
(title !== undefined &&
|
||||
(typeof title !== 'string' || title.length > NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH))
|
||||
) {
|
||||
return <div {...props} />
|
||||
}
|
||||
return (
|
||||
<NativeChatInlineVisual
|
||||
owner={owner}
|
||||
messageId={messageId}
|
||||
file={file}
|
||||
title={typeof title === 'string' && title.length > 0 ? title : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return {
|
||||
remarkPlugins: [[remarkNativeChatVisuals, nonce]],
|
||||
sanitizeAttributes: { div: [...NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES] },
|
||||
components: { div: VisualPlaceholder }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual extension for one assistant message, or undefined outside a structured chat. Stable
|
||||
* for the message's lifetime, so a mounted visual keeps its frame while the reply streams.
|
||||
*/
|
||||
export function useNativeChatVisualMarkdownExtension(
|
||||
messageId: string | undefined
|
||||
): CommentMarkdownExtension | undefined {
|
||||
const owner = useNativeChatVisualOwner()
|
||||
const [nonce] = useState(newNonce)
|
||||
return useMemo(
|
||||
() => (owner && messageId ? createVisualExtension(owner, messageId, nonce) : undefined),
|
||||
[messageId, nonce, owner]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ExtraProps } from 'react-markdown'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CommentMarkdown, {
|
||||
type CommentMarkdownExtension
|
||||
} from '@/components/sidebar/CommentMarkdown'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES,
|
||||
remarkNativeChatVisuals
|
||||
} from './native-chat-visual-markdown-syntax'
|
||||
|
||||
const NONCE = 'nonce-123'
|
||||
|
||||
function Probe(props: ComponentProps<'div'> & ExtraProps): React.JSX.Element {
|
||||
const nonce = 'data-orca-visual' in props ? props['data-orca-visual'] : undefined
|
||||
if (nonce !== NONCE) {
|
||||
return <div className={props.className}>{props.children}</div>
|
||||
}
|
||||
const title = 'data-orca-visual-title' in props ? props['data-orca-visual-title'] : undefined
|
||||
const file = 'data-orca-visual-file' in props ? props['data-orca-visual-file'] : undefined
|
||||
return (
|
||||
<span data-visual={String(file)}>
|
||||
{typeof title === 'string' ? `[${title}]` : '[untitled]'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const extension: CommentMarkdownExtension = {
|
||||
remarkPlugins: [[remarkNativeChatVisuals, NONCE]],
|
||||
sanitizeAttributes: { div: [...NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES] },
|
||||
components: { div: Probe }
|
||||
}
|
||||
|
||||
function render(content: string): string {
|
||||
return renderToStaticMarkup(
|
||||
<CommentMarkdown content={content} variant="document" extension={extension} />
|
||||
)
|
||||
}
|
||||
|
||||
function mountedFiles(markup: string): string[] {
|
||||
return [...markup.matchAll(/data-visual="([^"]+)"/g)].map((match) => match[1])
|
||||
}
|
||||
|
||||
const LINE = '::orca-visual{file="usage.html" title="Usage by day"}'
|
||||
|
||||
describe('remarkNativeChatVisuals', () => {
|
||||
it('mounts a directive on its own line with its title', () => {
|
||||
const markup = render(`Here it is:\n\n${LINE}\n\nThat is the trend.`)
|
||||
expect(mountedFiles(markup)).toEqual(['usage.html'])
|
||||
expect(markup).toContain('[Usage by day]')
|
||||
expect(markup).not.toContain('::orca-visual')
|
||||
expect(markup).toContain('That is the trend.')
|
||||
})
|
||||
|
||||
it('splits a paragraph that runs straight into and out of the directive', () => {
|
||||
const markup = render(`Here it is:\n${LINE}\nThat is the trend.`)
|
||||
expect(mountedFiles(markup)).toEqual(['usage.html'])
|
||||
expect(markup).toContain('Here it is:')
|
||||
expect(markup).toContain('That is the trend.')
|
||||
})
|
||||
|
||||
it('mounts at the very end of the reply without a trailing newline', () => {
|
||||
expect(mountedFiles(render(`Intro\n\n${LINE}`))).toEqual(['usage.html'])
|
||||
})
|
||||
|
||||
it('accepts CRLF line endings and up to three spaces of indent', () => {
|
||||
expect(mountedFiles(render(`Intro\r\n\r\n ${LINE}\r\n\r\nAfter`))).toEqual(['usage.html'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fenced code', `\`\`\`\n${LINE}\n\`\`\``],
|
||||
['tilde fenced code', `~~~md\n${LINE}\n~~~`],
|
||||
['indented code', `Intro\n\n ${LINE}`],
|
||||
['inline code', `Use \`${LINE}\` to show a chart.`],
|
||||
['a block quote', `> ${LINE}`],
|
||||
['a list item', `- ${LINE}`],
|
||||
['an ordered list item', `1. ${LINE}`],
|
||||
['mid-sentence', `See ${LINE} here`],
|
||||
['a malformed directive', '::orca-visual{file="../usage.html"}'],
|
||||
['an unclosed directive', '::orca-visual{file="usage.html"'],
|
||||
['trailing prose on the line', `${LINE} done`]
|
||||
])('leaves it as text inside %s', (_name, content) => {
|
||||
const markup = render(content)
|
||||
expect(mountedFiles(markup)).toEqual([])
|
||||
expect(markup).toContain('::orca-visual')
|
||||
})
|
||||
|
||||
it('does not mount a raw HTML placeholder forged without the nonce', () => {
|
||||
const markup = render(
|
||||
'<div data-orca-visual="guess" data-orca-visual-file="usage.html">x</div>\n\nafter'
|
||||
)
|
||||
expect(mountedFiles(markup)).toEqual([])
|
||||
})
|
||||
|
||||
it('mounts a raw HTML placeholder only if it knew the nonce, which the reply cannot', () => {
|
||||
// The nonce is per rendered message and never part of the reply, so this cannot be authored.
|
||||
const markup = render(
|
||||
`<div data-orca-visual="${NONCE}" data-orca-visual-file="usage.html">x</div>`
|
||||
)
|
||||
expect(mountedFiles(markup)).toEqual(['usage.html'])
|
||||
})
|
||||
|
||||
it('caps how many visuals one reply mounts and shows the rest as text', () => {
|
||||
const lines = Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => `::orca-visual{file="chart-${index}.html"}`
|
||||
)
|
||||
const markup = render(lines.join('\n\n'))
|
||||
expect(mountedFiles(markup)).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => `chart-${index}.html`)
|
||||
)
|
||||
expect(markup).toContain('chart-8.html')
|
||||
expect(markup).toContain('chart-9.html')
|
||||
})
|
||||
|
||||
it('keeps reference links defined after the directive working', () => {
|
||||
const markup = render(`See [the docs][d].\n\n${LINE}\n\n[d]: https://example.com/docs`)
|
||||
expect(mountedFiles(markup)).toEqual(['usage.html'])
|
||||
expect(markup).toContain('href="https://example.com/docs"')
|
||||
})
|
||||
|
||||
it('renders other markdown unchanged around it', () => {
|
||||
const markup = render(`# Title\n\n${LINE}\n\n| a | b |\n| - | - |\n| 1 | 2 |`)
|
||||
expect(mountedFiles(markup)).toEqual(['usage.html'])
|
||||
expect(markup).toContain('<table')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CommentMarkdown without the extension', () => {
|
||||
it('shows the directive as text', () => {
|
||||
const markup = renderToStaticMarkup(<CommentMarkdown content={LINE} variant="document" />)
|
||||
expect(markup).toContain('::orca-visual')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
// The visual directive as a markdown block, for native-chat assistant prose only. The parser sees it
|
||||
// like any other block (so code fences, indented code, quotes and lists keep their meaning), and the
|
||||
// line itself is judged by the one shared grammar.
|
||||
|
||||
import type { Paragraph, Root, RootContent } from 'mdast'
|
||||
import type { Extension as FromMarkdownExtension } from 'mdast-util-from-markdown'
|
||||
import type {
|
||||
Code,
|
||||
Construct,
|
||||
Effects,
|
||||
Extension as MicromarkExtension,
|
||||
State,
|
||||
TokenizeContext
|
||||
} from 'micromark-util-types'
|
||||
import type { Processor } from 'unified'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH,
|
||||
NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE,
|
||||
parseNativeChatVisualDirectiveLine
|
||||
} from '../../../../shared/native-chat-visual-directive'
|
||||
|
||||
const TOKEN_TYPE = 'nativeChatVisual'
|
||||
const COLON = 58
|
||||
const HORIZONTAL_TAB = -2
|
||||
const VIRTUAL_SPACE = -1
|
||||
|
||||
// Hast properties the placeholder carries through sanitize to the React component.
|
||||
const NONCE_PROPERTY = 'dataOrcaVisual'
|
||||
const FILE_PROPERTY = 'dataOrcaVisualFile'
|
||||
const TITLE_PROPERTY = 'dataOrcaVisualTitle'
|
||||
export const NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES = [
|
||||
NONCE_PROPERTY,
|
||||
FILE_PROPERTY,
|
||||
TITLE_PROPERTY
|
||||
] as const
|
||||
|
||||
export type NativeChatVisualNode = {
|
||||
type: 'nativeChatVisual'
|
||||
file: string
|
||||
title: string | null
|
||||
/** The line as written, for a directive shown as text instead. */
|
||||
source: string
|
||||
data?: { hName?: string; hProperties?: Record<string, string> }
|
||||
}
|
||||
|
||||
declare module 'mdast' {
|
||||
// oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface.
|
||||
interface RootContentMap {
|
||||
nativeChatVisual: NativeChatVisualNode
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'micromark-util-types' {
|
||||
// oxlint-disable-next-line typescript/consistent-type-definitions -- declaration merging needs an interface.
|
||||
interface TokenTypeMap {
|
||||
nativeChatVisual: 'nativeChatVisual'
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeVisualLine(this: TokenizeContext, effects: Effects, ok: State, nok: State): State {
|
||||
let line = ''
|
||||
const inside: State = (code: Code) => {
|
||||
// EOF, or a line ending (the negative codes below the tab/virtual-space codes).
|
||||
if (code === null || code < HORIZONTAL_TAB) {
|
||||
effects.exit(TOKEN_TYPE)
|
||||
return parseNativeChatVisualDirectiveLine(line) ? ok(code) : nok(code)
|
||||
}
|
||||
if (line.length >= NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH) {
|
||||
return nok(code)
|
||||
}
|
||||
if (code !== VIRTUAL_SPACE) {
|
||||
line += code === HORIZONTAL_TAB ? '\t' : String.fromCharCode(code)
|
||||
}
|
||||
effects.consume(code)
|
||||
return inside
|
||||
}
|
||||
return (code) => {
|
||||
effects.enter(TOKEN_TYPE)
|
||||
return inside(code)
|
||||
}
|
||||
}
|
||||
|
||||
const visualLineConstruct: Construct = { name: TOKEN_TYPE, tokenize: tokenizeVisualLine }
|
||||
|
||||
const visualMicromarkExtension: MicromarkExtension = { flow: { [COLON]: visualLineConstruct } }
|
||||
|
||||
const visualFromMarkdown: FromMarkdownExtension = {
|
||||
enter: {
|
||||
[TOKEN_TYPE](token) {
|
||||
this.enter({ type: 'nativeChatVisual', file: '', title: null, source: '' }, token)
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
[TOKEN_TYPE](token) {
|
||||
const node = this.stack.at(-1)
|
||||
const source = this.sliceSerialize(token)
|
||||
const directive = parseNativeChatVisualDirectiveLine(source)
|
||||
if (node?.type === 'nativeChatVisual' && directive) {
|
||||
node.file = directive.file
|
||||
node.title = directive.title
|
||||
node.source = source
|
||||
}
|
||||
this.exit(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function literalParagraph(node: NativeChatVisualNode): Paragraph {
|
||||
return { type: 'paragraph', children: [{ type: 'text', value: node.source }] }
|
||||
}
|
||||
|
||||
type ChildList = { children: RootContent[] }
|
||||
|
||||
function hasChildren(node: RootContent): node is RootContent & ChildList {
|
||||
return 'children' in node && Array.isArray(node.children)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts only directives that stand alone at the top of the reply, up to the per-message cap. One
|
||||
* nested in a quote or list, or past the cap, reads as the text the agent wrote.
|
||||
*/
|
||||
function placeVisuals(
|
||||
parent: ChildList,
|
||||
isRoot: boolean,
|
||||
nonce: string,
|
||||
mounted: { count: number }
|
||||
): void {
|
||||
parent.children = parent.children.map((child) => {
|
||||
if (child.type === 'nativeChatVisual') {
|
||||
if (!isRoot || mounted.count >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) {
|
||||
return literalParagraph(child)
|
||||
}
|
||||
mounted.count += 1
|
||||
child.data = {
|
||||
hName: 'div',
|
||||
hProperties: {
|
||||
[NONCE_PROPERTY]: nonce,
|
||||
[FILE_PROPERTY]: child.file,
|
||||
...(child.title ? { [TITLE_PROPERTY]: child.title } : {})
|
||||
}
|
||||
}
|
||||
return child
|
||||
}
|
||||
if (hasChildren(child)) {
|
||||
placeVisuals(child, false, nonce, mounted)
|
||||
}
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The remark plugin. `nonce` is unguessable per rendered message, so raw HTML in the reply cannot
|
||||
* forge a placeholder the component would mount.
|
||||
*/
|
||||
export function remarkNativeChatVisuals(this: Processor, nonce: string): (tree: Root) => void {
|
||||
const data = this.data()
|
||||
data.micromarkExtensions = [...(data.micromarkExtensions ?? []), visualMicromarkExtension]
|
||||
data.fromMarkdownExtensions = [...(data.fromMarkdownExtensions ?? []), visualFromMarkdown]
|
||||
return (tree) => placeVisuals(tree, true, nonce, { count: 0 })
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner'
|
||||
|
||||
/** The structured chat a transcript belongs to: where its visuals are read and what owns them. */
|
||||
export type NativeChatVisualOwner = {
|
||||
target: RuntimeClientTarget
|
||||
sessionId: string
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export const NativeChatVisualOwnerContext = createContext<NativeChatVisualOwner | null>(null)
|
||||
|
||||
/** Null outside a structured chat's transcript, where no visual can be read. */
|
||||
export function useNativeChatVisualOwner(): NativeChatVisualOwner | null {
|
||||
return useContext(NativeChatVisualOwnerContext)
|
||||
}
|
||||
|
||||
/** Names the structured chat below it as the owner of the visuals its replies show. */
|
||||
export function NativeChatVisualOwnerProvider({
|
||||
target,
|
||||
sessionId,
|
||||
tabId,
|
||||
children
|
||||
}: {
|
||||
target: RuntimeClientTarget
|
||||
sessionId: string
|
||||
tabId: string
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
// Until the tab's workspace is known there is nothing to scope a sidebar visual to.
|
||||
const worktreeId = useNativeChatTabOwnerWorktreeId(tabId)
|
||||
const environmentId = target.kind === 'environment' ? target.environmentId : null
|
||||
const owner = useMemo<NativeChatVisualOwner | null>(
|
||||
() =>
|
||||
worktreeId
|
||||
? {
|
||||
target:
|
||||
environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId },
|
||||
sessionId,
|
||||
tabId,
|
||||
worktreeId
|
||||
}
|
||||
: null,
|
||||
[environmentId, sessionId, tabId, worktreeId]
|
||||
)
|
||||
return (
|
||||
<NativeChatVisualOwnerContext.Provider value={owner}>
|
||||
{children}
|
||||
</NativeChatVisualOwnerContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callRuntimeRpc = vi.fn()
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args)
|
||||
}))
|
||||
|
||||
import {
|
||||
clearNativeChatVisualCacheForTests,
|
||||
isRetryableNativeChatVisualFailure,
|
||||
peekCachedNativeChatVisual,
|
||||
readNativeChatVisual
|
||||
} from './native-chat-visual-read-client'
|
||||
|
||||
const local = { target: { kind: 'local' as const }, sessionId: 'session-alpha', file: 'a.html' }
|
||||
const remote = {
|
||||
target: { kind: 'environment' as const, environmentId: 'env-1' },
|
||||
sessionId: 'session-alpha',
|
||||
file: 'a.html'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNativeChatVisualCacheForTests()
|
||||
callRuntimeRpc.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearNativeChatVisualCacheForTests()
|
||||
})
|
||||
|
||||
describe('readNativeChatVisual', () => {
|
||||
it("reads through the chat's own runtime with only the session and file name", async () => {
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
expect(await readNativeChatVisual(remote)).toEqual({
|
||||
ok: true,
|
||||
document: { revision: 'r1', html: '<p>' }
|
||||
})
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
remote.target,
|
||||
'agentSession.readVisual',
|
||||
{ sessionId: 'session-alpha', file: 'a.html' },
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('revalidates with the held revision and keeps the cached bytes when unchanged', async () => {
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
await readNativeChatVisual(local)
|
||||
callRuntimeRpc.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
revision: 'r1',
|
||||
sizeBytes: 3,
|
||||
unchanged: true
|
||||
})
|
||||
expect(await readNativeChatVisual(local)).toEqual({
|
||||
ok: true,
|
||||
document: { revision: 'r1', html: '<p>' }
|
||||
})
|
||||
expect(callRuntimeRpc.mock.calls[1][2]).toEqual({
|
||||
sessionId: 'session-alpha',
|
||||
file: 'a.html',
|
||||
knownRevision: 'r1'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a rewritten file as its new revision', async () => {
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
await readNativeChatVisual(local)
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r2', sizeBytes: 4, html: '<hr>' })
|
||||
expect(await readNativeChatVisual(local)).toEqual({
|
||||
ok: true,
|
||||
document: { revision: 'r2', html: '<hr>' }
|
||||
})
|
||||
expect(peekCachedNativeChatVisual(local)?.revision).toBe('r2')
|
||||
})
|
||||
|
||||
it('keeps one runtime from answering for another', async () => {
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
await readNativeChatVisual(local)
|
||||
expect(peekCachedNativeChatVisual(remote)).toBeNull()
|
||||
})
|
||||
|
||||
it('shares one in-flight read between concurrent mounts', async () => {
|
||||
let resolve: (value: unknown) => void = () => {}
|
||||
callRuntimeRpc.mockReturnValueOnce(new Promise((next) => (resolve = next)))
|
||||
const first = readNativeChatVisual(local)
|
||||
const second = readNativeChatVisual(local)
|
||||
resolve({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
expect(await first).toEqual(await second)
|
||||
expect(callRuntimeRpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads a lost connection or an older host as unavailable, never as a verdict', async () => {
|
||||
callRuntimeRpc.mockRejectedValueOnce(new Error('method_not_found'))
|
||||
expect(await readNativeChatVisual(local)).toEqual({ ok: false, reason: 'unavailable' })
|
||||
callRuntimeRpc.mockRejectedValueOnce(new Error('socket closed'))
|
||||
expect(await readNativeChatVisual(local)).toEqual({ ok: false, reason: 'unavailable' })
|
||||
})
|
||||
|
||||
it('passes host refusals through, degrades an unknown one, and forgets a refused file', async () => {
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
await readNativeChatVisual(local)
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: false, error: 'not_found' })
|
||||
expect(await readNativeChatVisual(local)).toEqual({ ok: false, reason: 'not_found' })
|
||||
expect(peekCachedNativeChatVisual(local)).toBeNull()
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r2', sizeBytes: 3, html: '<p>' })
|
||||
await readNativeChatVisual(local)
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: false, error: 'too_large' })
|
||||
expect(await readNativeChatVisual(local)).toEqual({ ok: false, reason: 'too_large' })
|
||||
// A rewrite the host now refuses must not keep showing the old bytes on the next mount.
|
||||
expect(peekCachedNativeChatVisual(local)).toBeNull()
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: false, error: 'some_future_reason' })
|
||||
expect(await readNativeChatVisual(local)).toEqual({ ok: false, reason: 'unavailable' })
|
||||
})
|
||||
|
||||
it('asks again for the bytes when an unchanged answer finds nothing cached', async () => {
|
||||
callRuntimeRpc.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
await readNativeChatVisual(local)
|
||||
clearNativeChatVisualCacheForTests()
|
||||
callRuntimeRpc
|
||||
.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, unchanged: true })
|
||||
.mockResolvedValueOnce({ ok: true, revision: 'r1', sizeBytes: 3, html: '<p>' })
|
||||
expect(await readNativeChatVisual(local)).toEqual({
|
||||
ok: true,
|
||||
document: { revision: 'r1', html: '<p>' }
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds the cache by entry count', async () => {
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
callRuntimeRpc.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
revision: `r${index}`,
|
||||
sizeBytes: 1,
|
||||
html: 'x'
|
||||
})
|
||||
await readNativeChatVisual({ ...local, file: `f${index}.html` })
|
||||
}
|
||||
expect(peekCachedNativeChatVisual({ ...local, file: 'f0.html' })).toBeNull()
|
||||
expect(peekCachedNativeChatVisual({ ...local, file: 'f29.html' })?.revision).toBe('r29')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRetryableNativeChatVisualFailure', () => {
|
||||
it('retries only what may change on its own', () => {
|
||||
expect(isRetryableNativeChatVisualFailure('unavailable')).toBe(true)
|
||||
expect(isRetryableNativeChatVisualFailure('not_found')).toBe(true)
|
||||
expect(isRetryableNativeChatVisualFailure('outside_folder')).toBe(false)
|
||||
expect(isRetryableNativeChatVisualFailure('too_large')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
// Reading a chat visual from the host that owns the chat, through a small revision cache.
|
||||
//
|
||||
// The cache only spares bytes: every mount still asks the host, passing the revision it holds, so a
|
||||
// file the agent rewrote shows its new content and a deleted one stops showing. Failures are never
|
||||
// cached; the next mount or retry asks again.
|
||||
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import {
|
||||
AGENT_SESSION_VISUAL_READ_ERRORS,
|
||||
type AgentSessionReadVisualResult,
|
||||
type AgentSessionVisualReadError
|
||||
} from '../../../../shared/rpc-contract/agent-session-visual-params'
|
||||
|
||||
export type NativeChatVisualIdentity = {
|
||||
target: RuntimeClientTarget
|
||||
sessionId: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export type NativeChatVisualDocument = { revision: string; html: string }
|
||||
|
||||
/** `unavailable`: the host could not answer (contact lost, older host); never a verdict on the file. */
|
||||
export type NativeChatVisualReadOutcome =
|
||||
| { ok: true; document: NativeChatVisualDocument }
|
||||
| { ok: false; reason: AgentSessionVisualReadError | 'unavailable' }
|
||||
|
||||
const MAX_CACHED_DOCUMENTS = 24
|
||||
const MAX_CACHED_CHARS = 6 * 1024 * 1024
|
||||
const READ_TIMEOUT_MS = 15_000
|
||||
|
||||
const cache = new Map<string, NativeChatVisualDocument>()
|
||||
const inFlight = new Map<string, Promise<NativeChatVisualReadOutcome>>()
|
||||
|
||||
export function nativeChatVisualKey(identity: NativeChatVisualIdentity): string {
|
||||
const runtime =
|
||||
identity.target.kind === 'local' ? 'local' : `environment:${identity.target.environmentId}`
|
||||
return JSON.stringify([runtime, identity.sessionId, identity.file])
|
||||
}
|
||||
|
||||
export function peekCachedNativeChatVisual(
|
||||
identity: NativeChatVisualIdentity
|
||||
): NativeChatVisualDocument | null {
|
||||
return cache.get(nativeChatVisualKey(identity)) ?? null
|
||||
}
|
||||
|
||||
function remember(key: string, document: NativeChatVisualDocument): void {
|
||||
cache.delete(key)
|
||||
cache.set(key, document)
|
||||
let chars = 0
|
||||
for (const entry of cache.values()) {
|
||||
chars += entry.html.length
|
||||
}
|
||||
for (const [oldestKey, oldest] of cache) {
|
||||
if (cache.size <= MAX_CACHED_DOCUMENTS && chars <= MAX_CACHED_CHARS) {
|
||||
break
|
||||
}
|
||||
cache.delete(oldestKey)
|
||||
chars -= oldest.html.length
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownReadError(value: unknown): value is AgentSessionVisualReadError {
|
||||
return AGENT_SESSION_VISUAL_READ_ERRORS.some((known) => known === value)
|
||||
}
|
||||
|
||||
function interpret(
|
||||
key: string,
|
||||
result: AgentSessionReadVisualResult | null | undefined
|
||||
): NativeChatVisualReadOutcome {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { ok: false, reason: 'unavailable' }
|
||||
}
|
||||
if (result.ok) {
|
||||
if ('unchanged' in result) {
|
||||
const cached = cache.get(key)
|
||||
if (cached?.revision === result.revision) {
|
||||
remember(key, cached)
|
||||
return { ok: true, document: cached }
|
||||
}
|
||||
return { ok: false, reason: 'unavailable' }
|
||||
}
|
||||
if (typeof result.html !== 'string' || typeof result.revision !== 'string') {
|
||||
return { ok: false, reason: 'unavailable' }
|
||||
}
|
||||
const document = { revision: result.revision, html: result.html }
|
||||
remember(key, document)
|
||||
return { ok: true, document }
|
||||
}
|
||||
// Any refusal means the cached revision no longer stands for the file.
|
||||
cache.delete(key)
|
||||
// Why: a newer host may name a refusal this build does not know; it still reads as "can't show".
|
||||
return { ok: false, reason: isKnownReadError(result.error) ? result.error : 'unavailable' }
|
||||
}
|
||||
|
||||
async function readOnce(
|
||||
key: string,
|
||||
identity: NativeChatVisualIdentity
|
||||
): Promise<NativeChatVisualReadOutcome> {
|
||||
const known = cache.get(key)?.revision
|
||||
try {
|
||||
const result = await callRuntimeRpc<AgentSessionReadVisualResult>(
|
||||
identity.target,
|
||||
'agentSession.readVisual',
|
||||
{
|
||||
sessionId: identity.sessionId,
|
||||
file: identity.file,
|
||||
...(known ? { knownRevision: known } : {})
|
||||
},
|
||||
{ timeoutMs: READ_TIMEOUT_MS }
|
||||
)
|
||||
const outcome = interpret(key, result)
|
||||
// An `unchanged` answer for an entry evicted meanwhile: ask once more for the bytes.
|
||||
if (!outcome.ok && outcome.reason === 'unavailable' && result?.ok && 'unchanged' in result) {
|
||||
cache.delete(key)
|
||||
return interpret(
|
||||
key,
|
||||
await callRuntimeRpc<AgentSessionReadVisualResult>(
|
||||
identity.target,
|
||||
'agentSession.readVisual',
|
||||
{ sessionId: identity.sessionId, file: identity.file },
|
||||
{ timeoutMs: READ_TIMEOUT_MS }
|
||||
)
|
||||
)
|
||||
}
|
||||
return outcome
|
||||
} catch {
|
||||
return { ok: false, reason: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
/** One read per visual at a time; concurrent mounts of the same visual share it. */
|
||||
export function readNativeChatVisual(
|
||||
identity: NativeChatVisualIdentity
|
||||
): Promise<NativeChatVisualReadOutcome> {
|
||||
const key = nativeChatVisualKey(identity)
|
||||
const pending = inFlight.get(key)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
const read = readOnce(key, identity).finally(() => {
|
||||
inFlight.delete(key)
|
||||
})
|
||||
inFlight.set(key, read)
|
||||
return read
|
||||
}
|
||||
|
||||
/** Whether a failed read may succeed if asked again shortly: contact restored, or the file landing. */
|
||||
export function isRetryableNativeChatVisualFailure(
|
||||
reason: AgentSessionVisualReadError | 'unavailable'
|
||||
): boolean {
|
||||
return reason === 'unavailable' || reason === 'not_found'
|
||||
}
|
||||
|
||||
export function clearNativeChatVisualCacheForTests(): void {
|
||||
cache.clear()
|
||||
inFlight.clear()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isRetryableNativeChatVisualFailure,
|
||||
peekCachedNativeChatVisual,
|
||||
readNativeChatVisual,
|
||||
type NativeChatVisualDocument,
|
||||
type NativeChatVisualIdentity
|
||||
} from './native-chat-visual-read-client'
|
||||
|
||||
export type NativeChatVisualDocumentState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; document: NativeChatVisualDocument }
|
||||
| { status: 'unavailable' }
|
||||
|
||||
/** Waits before each retry of a read that may yet succeed; bounded, then the failure shows. */
|
||||
export const NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS = [1_500, 4_000, 10_000] as const
|
||||
|
||||
function waitFor(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(!signal.aborted), ms)
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadWithRetries(
|
||||
identity: NativeChatVisualIdentity,
|
||||
signal: AbortSignal,
|
||||
setState: (
|
||||
update: (shown: NativeChatVisualDocumentState) => NativeChatVisualDocumentState
|
||||
) => void
|
||||
): Promise<void> {
|
||||
for (let retry = 0; ; retry += 1) {
|
||||
const outcome = await readNativeChatVisual(identity)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
if (outcome.ok) {
|
||||
setState((shown) =>
|
||||
shown.status === 'ready' && shown.document.revision === outcome.document.revision
|
||||
? shown
|
||||
: { status: 'ready', document: outcome.document }
|
||||
)
|
||||
return
|
||||
}
|
||||
const delay = NATIVE_CHAT_VISUAL_RETRY_DELAYS_MS[retry]
|
||||
if (!isRetryableNativeChatVisualFailure(outcome.reason) || delay === undefined) {
|
||||
// Why keep a shown document on `unavailable`: losing contact says nothing about the file.
|
||||
setState((shown) =>
|
||||
shown.status === 'ready' && outcome.reason === 'unavailable'
|
||||
? shown
|
||||
: { status: 'unavailable' }
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!(await waitFor(delay, signal))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual's current document. Starts from the cached revision when there is one, then asks the
|
||||
* host (revalidating it) once `enabled`. A later failure keeps a document already shown.
|
||||
*/
|
||||
export function useNativeChatVisualDocument(
|
||||
identity: NativeChatVisualIdentity,
|
||||
enabled: boolean
|
||||
): NativeChatVisualDocumentState {
|
||||
const { sessionId, file } = identity
|
||||
const environmentId =
|
||||
identity.target.kind === 'environment' ? identity.target.environmentId : null
|
||||
const [state, setState] = useState<NativeChatVisualDocumentState>(() => {
|
||||
const cached = peekCachedNativeChatVisual(identity)
|
||||
return cached ? { status: 'ready', document: cached } : { status: 'loading' }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
const identityNow: NativeChatVisualIdentity = {
|
||||
target: environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId },
|
||||
sessionId,
|
||||
file
|
||||
}
|
||||
const abort = new AbortController()
|
||||
void loadWithRetries(identityNow, abort.signal, setState)
|
||||
return () => abort.abort()
|
||||
}, [enabled, environmentId, file, sessionId])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState, type RefObject } from 'react'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_THEME_TOKENS,
|
||||
type NativeChatVisualTheme
|
||||
} from '../../../../shared/native-chat-visual-shell'
|
||||
|
||||
// The visual blends into the thread: its background and text follow the chat column, which a
|
||||
// chat's own appearance can set apart from the app root.
|
||||
const SCOPED_SOURCES: Partial<Record<string, readonly string[]>> = {
|
||||
'--background': ['--chat-canvas', '--background'],
|
||||
'--foreground': ['--chat-foreground', '--foreground'],
|
||||
'--font-mono': ['--chat-code-font-family', '--font-mono']
|
||||
}
|
||||
|
||||
/** The resolved theme at `element`, read from computed styles in its own appearance scope. */
|
||||
export function readNativeChatVisualTheme(element: Element | null): NativeChatVisualTheme {
|
||||
const source = element ?? document.documentElement
|
||||
const styles = getComputedStyle(source)
|
||||
const tokens: Record<string, string> = {}
|
||||
for (const token of NATIVE_CHAT_VISUAL_THEME_TOKENS) {
|
||||
if (token === '--font-sans') {
|
||||
tokens[token] = styles.fontFamily
|
||||
continue
|
||||
}
|
||||
for (const name of SCOPED_SOURCES[token] ?? [token]) {
|
||||
const value = styles.getPropertyValue(name).trim()
|
||||
if (value.length > 0) {
|
||||
tokens[token] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return { colorScheme: resolvedColorScheme(element), tokens }
|
||||
}
|
||||
|
||||
/** A chat can pin its own scheme apart from the app's (see the `dark` variant in main.css). */
|
||||
function resolvedColorScheme(element: Element | null): 'light' | 'dark' {
|
||||
const pinned = element
|
||||
?.closest('[data-native-chat-scheme]')
|
||||
?.getAttribute('data-native-chat-scheme')
|
||||
if (pinned === 'light' || pinned === 'dark') {
|
||||
return pinned
|
||||
}
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function themeSnapshot(theme: NativeChatVisualTheme): string {
|
||||
return JSON.stringify(theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme a visual should wear, updated when the app theme or the chat's appearance changes.
|
||||
* Compares resolved values, so unrelated style writes (a sidebar drag) never produce a new theme.
|
||||
*/
|
||||
export function useNativeChatVisualTheme(
|
||||
elementRef: RefObject<Element | null>
|
||||
): NativeChatVisualTheme {
|
||||
const [theme, setTheme] = useState<NativeChatVisualTheme>(() =>
|
||||
readNativeChatVisualTheme(elementRef.current)
|
||||
)
|
||||
useEffect(() => {
|
||||
let snapshot = ''
|
||||
const refresh = (): void => {
|
||||
const next = readNativeChatVisualTheme(elementRef.current)
|
||||
const nextSnapshot = themeSnapshot(next)
|
||||
if (nextSnapshot !== snapshot) {
|
||||
snapshot = nextSnapshot
|
||||
setTheme(next)
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
const observer = new MutationObserver(refresh)
|
||||
const watched = new Set<Element>([document.documentElement])
|
||||
for (const selector of ['.native-chat-appearance', '[data-native-chat-scheme]']) {
|
||||
const scope = elementRef.current?.closest(selector)
|
||||
if (scope) {
|
||||
watched.add(scope)
|
||||
}
|
||||
}
|
||||
for (const element of watched) {
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-native-chat-scheme']
|
||||
})
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [elementRef])
|
||||
return theme
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import {
|
||||
RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
clampRightSidebarPanelWidth,
|
||||
computeMaxRightSidebarPanelWidth
|
||||
computeMaxRightSidebarPanelWidth,
|
||||
rightSidebarVisualWidth
|
||||
} from './right-sidebar-width'
|
||||
import { selectVisibleRightSidebarVisual } from '@/store/slices/editor/actions/right-sidebar-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
|
||||
import { useMeasuredWidth } from './right-sidebar-measured-width'
|
||||
@@ -39,6 +41,8 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
|
||||
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
|
||||
const visual = useAppStore(selectVisibleRightSidebarVisual)
|
||||
const setRightSidebarVisualWidth = useAppStore((s) => s.setRightSidebarVisualWidth)
|
||||
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
|
||||
const checksStatus = useAppStore((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null))
|
||||
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
|
||||
@@ -62,8 +66,10 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
const activityBarSideWidth = activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0
|
||||
const windowWidth = useWindowWidth()
|
||||
const maxWidth = computeMaxRightSidebarPanelWidth(windowWidth, activityBarSideWidth)
|
||||
// Why a separate width: widening for a visual must not persist or reach other clients, and
|
||||
// closing the visual has to land back on the width the user chose.
|
||||
const renderedRightSidebarWidth = clampRightSidebarPanelWidth(
|
||||
rightSidebarWidth,
|
||||
visual ? rightSidebarVisualWidth(rightSidebarWidth, visual.width) : rightSidebarWidth,
|
||||
windowWidth,
|
||||
activityBarSideWidth
|
||||
)
|
||||
@@ -74,7 +80,7 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
maxWidth,
|
||||
deltaSign: -1,
|
||||
renderedExtraWidth: activityBarSideWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
setWidth: visual ? setRightSidebarVisualWidth : setRightSidebarWidth
|
||||
})
|
||||
const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth)
|
||||
|
||||
@@ -91,7 +97,11 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
property) rather than in a bottom-docked dashboard panel that
|
||||
competed with file Explorer/Search for vertical space. The right
|
||||
sidebar is back to tab-only content. */}
|
||||
<RightSidebarPanelContent effectiveTab={effectiveTab} rightSidebarOpen={rightSidebarOpen} />
|
||||
<RightSidebarPanelContent
|
||||
effectiveTab={effectiveTab}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
visual={visual}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from 'react'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import type { ActiveRightSidebarTab } from '@/store/slices/editor'
|
||||
import type { RightSidebarVisualState } from '@/store/slices/editor/actions/right-sidebar-state'
|
||||
import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest'
|
||||
|
||||
const FileExplorer = lazy(() => import('./FileExplorer'))
|
||||
@@ -11,16 +12,32 @@ const AiVaultPanel = lazy(() => import('./AiVaultPanel'))
|
||||
const FolderWorkspaceWorktreesPanel = lazy(() => import('./FolderWorkspaceWorktreesPanel'))
|
||||
const FolderWorkspacePrChecksPanel = lazy(() => import('./FolderWorkspacePrChecksPanel'))
|
||||
const PluginPanel = lazy(() => import('./PluginPanel'))
|
||||
const NativeChatVisualPanel = lazy(() => import('../native-chat/NativeChatVisualPanel'))
|
||||
|
||||
type RightSidebarPanelContentProps = {
|
||||
effectiveTab: ActiveRightSidebarTab
|
||||
rightSidebarOpen: boolean
|
||||
visual: RightSidebarVisualState | null
|
||||
}
|
||||
|
||||
export function RightSidebarPanelContent({
|
||||
effectiveTab,
|
||||
rightSidebarOpen
|
||||
rightSidebarOpen,
|
||||
visual
|
||||
}: RightSidebarPanelContentProps): React.JSX.Element {
|
||||
if (visual) {
|
||||
// Why key: a different visual is a different frame, never a reused one.
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Suspense fallback={null}>
|
||||
<NativeChatVisualPanel
|
||||
key={`${visual.sessionId}:${visual.messageId}:${visual.file}`}
|
||||
route={visual}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -26,3 +26,11 @@ export function clampRightSidebarPanelWidth(
|
||||
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, width)
|
||||
)
|
||||
}
|
||||
|
||||
/** Width the sidebar opens to while it shows a chat visual: wide enough for a chart to read. */
|
||||
export const RIGHT_SIDEBAR_VISUAL_PREFERRED_WIDTH = 720
|
||||
|
||||
/** The sidebar's width while it shows a visual: its own resize, else the wider of stored and preferred. */
|
||||
export function rightSidebarVisualWidth(storedWidth: number, visualWidth: number | null): number {
|
||||
return visualWidth ?? Math.max(storedWidth, RIGHT_SIDEBAR_VISUAL_PREFERRED_WIDTH)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
||||
import Markdown, { defaultUrlTransform, type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import rehypeSanitize, { defaultSchema, type Options as SanitizeSchema } from 'rehype-sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
compactCommentMarkdownComponents,
|
||||
@@ -19,6 +19,7 @@ import { remarkNativeChatFileLinks } from './comment-markdown-native-chat-file-l
|
||||
export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers'
|
||||
|
||||
type MarkdownPlugins = NonNullable<React.ComponentProps<typeof Markdown>['rehypePlugins']>
|
||||
type RemarkPlugins = NonNullable<React.ComponentProps<typeof Markdown>['remarkPlugins']>
|
||||
type UrlTransform = NonNullable<React.ComponentProps<typeof Markdown>['urlTransform']>
|
||||
|
||||
type GitHubRepoReference = {
|
||||
@@ -181,6 +182,27 @@ const commentMarkdownSanitizeSchema = {
|
||||
// `<br />`). Parse it, then sanitize immediately before React renders it.
|
||||
const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkdownSanitizeSchema]]
|
||||
|
||||
/**
|
||||
* A surface-specific markdown addition: its own remark plugins, the element attributes those
|
||||
* plugins need to survive sanitize, and the components that render them. Keep the object stable
|
||||
* per surface; a new one rebuilds the components and remounts what they rendered.
|
||||
*/
|
||||
export type CommentMarkdownExtension = {
|
||||
remarkPlugins: RemarkPlugins
|
||||
sanitizeAttributes: Record<string, readonly string[]>
|
||||
components: Components
|
||||
}
|
||||
|
||||
function extensionRehypePlugins(extension: CommentMarkdownExtension): MarkdownPlugins {
|
||||
const attributes: NonNullable<SanitizeSchema['attributes']> = {
|
||||
...commentMarkdownSanitizeSchema.attributes
|
||||
}
|
||||
for (const [tagName, names] of Object.entries(extension.sanitizeAttributes)) {
|
||||
attributes[tagName] = [...(attributes[tagName] ?? []), ...names]
|
||||
}
|
||||
return [rehypeRaw, [rehypeSanitize, { ...commentMarkdownSanitizeSchema, attributes }]]
|
||||
}
|
||||
|
||||
type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
|
||||
content: string
|
||||
variant?: 'compact' | 'document'
|
||||
@@ -190,6 +212,7 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
|
||||
linkifyFilePaths?: boolean
|
||||
expandImages?: boolean
|
||||
renderCodeBlock?: DocumentCodeBlockRenderer
|
||||
extension?: CommentMarkdownExtension
|
||||
}
|
||||
|
||||
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
|
||||
@@ -207,11 +230,12 @@ const CommentMarkdown = React.memo(
|
||||
linkifyFilePaths = false,
|
||||
expandImages = false,
|
||||
renderCodeBlock,
|
||||
extension,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const components = React.useMemo(() => {
|
||||
const baseComponents = React.useMemo(() => {
|
||||
if (!onLinkClick) {
|
||||
return variant === 'document'
|
||||
? renderCodeBlock
|
||||
@@ -225,12 +249,21 @@ const CommentMarkdown = React.memo(
|
||||
? createDocumentCommentMarkdownComponents(onLinkClick, renderCodeBlock)
|
||||
: createCompactCommentMarkdownComponents(onLinkClick, expandImages)
|
||||
}, [expandImages, renderCodeBlock, variant, onLinkClick])
|
||||
const components = React.useMemo(
|
||||
() => (extension ? { ...baseComponents, ...extension.components } : baseComponents),
|
||||
[baseComponents, extension]
|
||||
)
|
||||
const activeRehypePlugins = React.useMemo(
|
||||
() => (extension ? extensionRehypePlugins(extension) : rehypePlugins),
|
||||
[extension]
|
||||
)
|
||||
const activeRemarkPlugins = React.useMemo(() => {
|
||||
const plugins = linkifyFilePaths
|
||||
? [...remarkPlugins, remarkNativeChatFileLinks]
|
||||
: remarkPlugins
|
||||
return githubRepo ? [...plugins, remarkGitHubReferences(githubRepo)] : plugins
|
||||
}, [githubRepo, linkifyFilePaths])
|
||||
const withExtension = extension ? [...plugins, ...extension.remarkPlugins] : plugins
|
||||
return githubRepo ? [...withExtension, remarkGitHubReferences(githubRepo)] : withExtension
|
||||
}, [extension, githubRepo, linkifyFilePaths])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -247,7 +280,7 @@ const CommentMarkdown = React.memo(
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={activeRemarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
rehypePlugins={activeRehypePlugins}
|
||||
components={components}
|
||||
urlTransform={
|
||||
allowFileUriLinks ? commentMarkdownFileUriUrlTransform : commentMarkdownUrlTransform
|
||||
|
||||
@@ -18532,6 +18532,10 @@
|
||||
"messageNotSent": "Message not sent",
|
||||
"deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying",
|
||||
"messageSending": "Sending…",
|
||||
"visualUnavailable": "Visualization unavailable",
|
||||
"visualLoading": "Loading visualization",
|
||||
"visualOpenInSidebar": "Open in sidebar",
|
||||
"visualClose": "Close visualization",
|
||||
"queuedMessages": {
|
||||
"behindReturnedHold": "Waiting — a message ahead needs attention",
|
||||
"awaitingAnswerHold": "Waiting for your answer",
|
||||
|
||||
@@ -5,6 +5,31 @@ import type {
|
||||
RightSidebarExplorerView
|
||||
} from '../../../../../../shared/ui-chrome-types'
|
||||
import { defaultFileSearchState } from '../search/file-search-state'
|
||||
import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target'
|
||||
|
||||
/** A chat visual shown in place of the active tab. Identity only, never the HTML. */
|
||||
export type RightSidebarVisualRoute = {
|
||||
target: RuntimeClientTarget
|
||||
sessionId: string
|
||||
messageId: string
|
||||
file: string
|
||||
title: string | null
|
||||
/** The chat tab and workspace that own it; the panel closes once either is gone or left. */
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* In memory only: never persisted or mirrored to other clients. Shown while no later tab route was
|
||||
* requested, so picking any tab returns the sidebar to it without clearing this explicitly.
|
||||
*/
|
||||
export type RightSidebarVisualState = RightSidebarVisualRoute & {
|
||||
routeRequestId: number
|
||||
/** The sidebar was closed before the visual opened it; closing the visual closes it again. */
|
||||
reopenedSidebar: boolean
|
||||
/** The sidebar's width while it shows the visual; the stored width is left untouched. */
|
||||
width: number | null
|
||||
}
|
||||
|
||||
export type RightSidebarState = {
|
||||
rightSidebarOpen: boolean
|
||||
@@ -16,6 +41,7 @@ export type RightSidebarState = {
|
||||
aiVaultSearchFocusRequested: boolean
|
||||
rightSidebarTabByWorktree: Record<string, ActiveRightSidebarTab>
|
||||
rightSidebarExplorerViewByWorktree: Record<string, RightSidebarExplorerView>
|
||||
rightSidebarVisual: RightSidebarVisualState | null
|
||||
activityBarPosition: ActivityBarPosition
|
||||
toggleRightSidebar: () => void
|
||||
setRightSidebarOpen: (open: boolean) => void
|
||||
@@ -29,6 +55,9 @@ export type RightSidebarState = {
|
||||
}) => void
|
||||
showAiVaultSearch: () => void
|
||||
clearAiVaultSearchFocusRequest: () => void
|
||||
openRightSidebarVisual: (route: RightSidebarVisualRoute) => void
|
||||
closeRightSidebarVisual: () => void
|
||||
setRightSidebarVisualWidth: (width: number) => void
|
||||
setActivityBarPosition: (position: ActivityBarPosition) => void
|
||||
}
|
||||
|
||||
@@ -42,9 +71,15 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
aiVaultSearchFocusRequested: false,
|
||||
rightSidebarTabByWorktree: {},
|
||||
rightSidebarExplorerViewByWorktree: {},
|
||||
rightSidebarVisual: null,
|
||||
activityBarPosition: 'top',
|
||||
toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })),
|
||||
setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }),
|
||||
toggleRightSidebar: () =>
|
||||
set((s) => ({
|
||||
rightSidebarOpen: !s.rightSidebarOpen,
|
||||
...(s.rightSidebarOpen ? { rightSidebarVisual: null } : {})
|
||||
})),
|
||||
setRightSidebarOpen: (open) =>
|
||||
set({ rightSidebarOpen: open, ...(open ? {} : { rightSidebarVisual: null }) }),
|
||||
setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }),
|
||||
setRightSidebarTab: (tab) =>
|
||||
set((s) => ({
|
||||
@@ -138,6 +173,44 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
aiVaultSearchFocusRequested: true
|
||||
})),
|
||||
clearAiVaultSearchFocusRequest: () => set({ aiVaultSearchFocusRequested: false }),
|
||||
openRightSidebarVisual: (route) =>
|
||||
set((s) => {
|
||||
const current = selectVisibleRightSidebarVisual(s)
|
||||
return {
|
||||
rightSidebarOpen: true,
|
||||
rightSidebarVisual: {
|
||||
...route,
|
||||
routeRequestId: s.rightSidebarRouteRequestId,
|
||||
reopenedSidebar: current ? current.reopenedSidebar : !s.rightSidebarOpen,
|
||||
width: current?.width ?? null
|
||||
}
|
||||
}
|
||||
}),
|
||||
closeRightSidebarVisual: () =>
|
||||
set((s) => ({
|
||||
rightSidebarVisual: null,
|
||||
...(selectVisibleRightSidebarVisual(s)?.reopenedSidebar ? { rightSidebarOpen: false } : {})
|
||||
})),
|
||||
setRightSidebarVisualWidth: (width) =>
|
||||
set((s) => {
|
||||
const visual = selectVisibleRightSidebarVisual(s)
|
||||
return visual ? { rightSidebarVisual: { ...visual, width } } : {}
|
||||
}),
|
||||
setActivityBarPosition: (position) => set({ activityBarPosition: position })
|
||||
}
|
||||
}
|
||||
|
||||
/** The visual the sidebar shows now, or null once a tab route superseded it or it was closed. */
|
||||
export function selectVisibleRightSidebarVisual(
|
||||
state: Pick<
|
||||
RightSidebarState,
|
||||
'rightSidebarVisual' | 'rightSidebarRouteRequestId' | 'rightSidebarOpen'
|
||||
>
|
||||
): RightSidebarVisualState | null {
|
||||
const visual = state.rightSidebarVisual
|
||||
return visual &&
|
||||
state.rightSidebarOpen &&
|
||||
visual.routeRequestId === state.rightSidebarRouteRequestId
|
||||
? visual
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createRightSidebarState,
|
||||
selectVisibleRightSidebarVisual,
|
||||
type RightSidebarState,
|
||||
type RightSidebarVisualRoute
|
||||
} from './right-sidebar-state'
|
||||
import { rightSidebarVisualWidth } from '../../../../components/right-sidebar/right-sidebar-width'
|
||||
|
||||
type Harness = RightSidebarState & { activeWorktreeId: string | null }
|
||||
|
||||
function harness(): { state: () => Harness } {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: filled by the slice on the next line before any read.
|
||||
let state = { activeWorktreeId: 'wt-1' } as Harness
|
||||
const set = (update: Partial<Harness> | ((current: Harness) => Partial<Harness>)): void => {
|
||||
state = { ...state, ...(typeof update === 'function' ? update(state) : update) }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice only reads and writes the fields this harness holds.
|
||||
Object.assign(state, createRightSidebarState(set as never, (() => state) as never))
|
||||
return { state: () => state }
|
||||
}
|
||||
|
||||
const route: RightSidebarVisualRoute = {
|
||||
target: { kind: 'local' },
|
||||
sessionId: 'session-alpha',
|
||||
messageId: 'message-1',
|
||||
file: 'chart.html',
|
||||
title: 'Chart',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
|
||||
describe('right sidebar visual route', () => {
|
||||
it('opens the sidebar on the visual and closes it again when it had been closed', () => {
|
||||
const { state } = harness()
|
||||
expect(state().rightSidebarOpen).toBe(false)
|
||||
state().openRightSidebarVisual(route)
|
||||
expect(state().rightSidebarOpen).toBe(true)
|
||||
expect(selectVisibleRightSidebarVisual(state())).toMatchObject({ file: 'chart.html' })
|
||||
state().closeRightSidebarVisual()
|
||||
expect(selectVisibleRightSidebarVisual(state())).toBeNull()
|
||||
expect(state().rightSidebarOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('returns to the open sidebar it replaced', () => {
|
||||
const { state } = harness()
|
||||
state().setRightSidebarOpen(true)
|
||||
state().setRightSidebarTab('source-control')
|
||||
state().openRightSidebarVisual(route)
|
||||
state().closeRightSidebarVisual()
|
||||
expect(state().rightSidebarOpen).toBe(true)
|
||||
expect(state().rightSidebarTab).toBe('source-control')
|
||||
})
|
||||
|
||||
it('gives way to any tab the user picks, without being cleared explicitly', () => {
|
||||
const { state } = harness()
|
||||
state().openRightSidebarVisual(route)
|
||||
state().setRightSidebarTab('checks')
|
||||
expect(selectVisibleRightSidebarVisual(state())).toBeNull()
|
||||
expect(state().rightSidebarTab).toBe('checks')
|
||||
})
|
||||
|
||||
it('is dropped when the sidebar closes', () => {
|
||||
const { state } = harness()
|
||||
state().openRightSidebarVisual(route)
|
||||
state().toggleRightSidebar()
|
||||
expect(state().rightSidebarVisual).toBeNull()
|
||||
state().toggleRightSidebar()
|
||||
expect(selectVisibleRightSidebarVisual(state())).toBeNull()
|
||||
})
|
||||
|
||||
it('widens while shown without touching the stored width, and keeps a resize to itself', () => {
|
||||
const { state } = harness()
|
||||
state().setRightSidebarWidth(300)
|
||||
state().openRightSidebarVisual(route)
|
||||
const visual = selectVisibleRightSidebarVisual(state())
|
||||
expect(rightSidebarVisualWidth(state().rightSidebarWidth, visual?.width ?? null)).toBe(720)
|
||||
state().setRightSidebarVisualWidth(900)
|
||||
expect(selectVisibleRightSidebarVisual(state())?.width).toBe(900)
|
||||
expect(state().rightSidebarWidth).toBe(300)
|
||||
state().closeRightSidebarVisual()
|
||||
expect(state().rightSidebarWidth).toBe(300)
|
||||
})
|
||||
|
||||
it('keeps a wider stored width and the reopen bookkeeping when switching visuals', () => {
|
||||
const { state } = harness()
|
||||
state().setRightSidebarWidth(1000)
|
||||
state().openRightSidebarVisual(route)
|
||||
expect(rightSidebarVisualWidth(1000, null)).toBe(1000)
|
||||
state().openRightSidebarVisual({ ...route, file: 'other.html' })
|
||||
expect(selectVisibleRightSidebarVisual(state())).toMatchObject({
|
||||
file: 'other.html',
|
||||
reopenedSidebar: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH,
|
||||
NATIVE_CHAT_VISUAL_FILE_MAX_LENGTH,
|
||||
NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH,
|
||||
isNativeChatVisualFileName,
|
||||
isPendingNativeChatVisualDirectiveTail,
|
||||
parseNativeChatVisualDirectiveLine,
|
||||
withoutNativeChatVisualDirectiveLines,
|
||||
withoutPendingNativeChatVisualDirectiveTail
|
||||
} from './native-chat-visual-directive'
|
||||
|
||||
describe('parseNativeChatVisualDirectiveLine', () => {
|
||||
it('parses file and title', () => {
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine(
|
||||
'::orca-visual{file="usage-3f2a.html" title="Usage by day"}'
|
||||
)
|
||||
).toEqual({ file: 'usage-3f2a.html', title: 'Usage by day' })
|
||||
})
|
||||
|
||||
it('treats title as optional and trims it', () => {
|
||||
expect(parseNativeChatVisualDirectiveLine('::orca-visual{file="a.html"}')).toEqual({
|
||||
file: 'a.html',
|
||||
title: null
|
||||
})
|
||||
expect(parseNativeChatVisualDirectiveLine('::orca-visual{file="a.html" title=" "}')).toEqual({
|
||||
file: 'a.html',
|
||||
title: null
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts attributes in any order, with spaces and tabs around them', () => {
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine('::orca-visual{ \ttitle="T" file="a.html"\t }')
|
||||
).toEqual({ file: 'a.html', title: 'T' })
|
||||
})
|
||||
|
||||
it('accepts up to 3 leading spaces, trailing whitespace, and CRLF or LF endings', () => {
|
||||
expect(parseNativeChatVisualDirectiveLine(' ::orca-visual{file="a.html"} \t\r\n')).toEqual({
|
||||
file: 'a.html',
|
||||
title: null
|
||||
})
|
||||
expect(parseNativeChatVisualDirectiveLine('::orca-visual{file="a.html"}\n')).not.toBeNull()
|
||||
expect(parseNativeChatVisualDirectiveLine(' ::orca-visual{file="a.html"}')).toBeNull()
|
||||
expect(parseNativeChatVisualDirectiveLine('\t::orca-visual{file="a.html"}')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unknown attributes but refuses a repeated one', () => {
|
||||
expect(parseNativeChatVisualDirectiveLine('::orca-visual{file="a.html" height="300"}')).toEqual(
|
||||
{ file: 'a.html', title: null }
|
||||
)
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine('::orca-visual{file="a.html" file="b.html"}')
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine('::orca-visual{file="a.html" title="x" title="y"}')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps braces and markdown characters in a title verbatim', () => {
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine(
|
||||
'::orca-visual{file="a.html" title="Cost {USD} *by* <team>"}'
|
||||
)
|
||||
).toEqual({ file: 'a.html', title: 'Cost {USD} *by* <team>' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing file', '::orca-visual{title="T"}'],
|
||||
['empty braces', '::orca-visual{}'],
|
||||
['no braces', '::orca-visual'],
|
||||
['unclosed', '::orca-visual{file="a.html"'],
|
||||
['text after the line', '::orca-visual{file="a.html"} see above'],
|
||||
['text before the marker', 'Chart: ::orca-visual{file="a.html"}'],
|
||||
['wrong case', '::Orca-Visual{file="a.html"}'],
|
||||
['single quotes', "::orca-visual{file='a.html'}"],
|
||||
['unquoted value', '::orca-visual{file=a.html}'],
|
||||
['escaped quote', '::orca-visual{file="a.html" title="say \\"hi\\""}'],
|
||||
['backslash', '::orca-visual{file="a.html" title="a\\b"}'],
|
||||
['control character', '::orca-visual{file="a.html" title="a\u0007b"}'],
|
||||
['attributes glued together', '::orca-visual{file="a.html"title="T"}'],
|
||||
['uppercase key', '::orca-visual{FILE="a.html"}'],
|
||||
['embedded newline', '::orca-visual{file="a.html"\ntitle="T"}'],
|
||||
['three colons', ':::orca-visual{file="a.html"}']
|
||||
])('refuses %s', (_name, line) => {
|
||||
expect(parseNativeChatVisualDirectiveLine(line)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses lines and titles past their length caps', () => {
|
||||
const title = 'x'.repeat(NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH)
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine(`::orca-visual{file="a.html" title="${title}"}`)
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
parseNativeChatVisualDirectiveLine(`::orca-visual{file="a.html" title="${title}x"}`)
|
||||
).toBeNull()
|
||||
const padding = ' '.repeat(NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH)
|
||||
expect(parseNativeChatVisualDirectiveLine(`::orca-visual{file="a.html"}${padding}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNativeChatVisualFileName', () => {
|
||||
it.each(['a.html', 'usage-chart_3f2a.html', 'A1.b.html', '0.html'])('accepts %s', (name) => {
|
||||
expect(isNativeChatVisualFileName(name)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', ''],
|
||||
['no extension', 'chart'],
|
||||
['other extension', 'chart.htm'],
|
||||
['uppercase extension', 'chart.HTML'],
|
||||
['markdown', 'chart.md'],
|
||||
['directory', 'sub/chart.html'],
|
||||
['backslash directory', 'sub\\chart.html'],
|
||||
['parent', '../chart.html'],
|
||||
['double dot inside', 'a..html'],
|
||||
['leading dot', '.chart.html'],
|
||||
['leading dash', '-chart.html'],
|
||||
['absolute posix', '/tmp/chart.html'],
|
||||
['drive', 'C:chart.html'],
|
||||
['unc', '\\\\server\\share\\chart.html'],
|
||||
['alternate stream', 'chart.html:stream'],
|
||||
['space', 'my chart.html'],
|
||||
['unicode', 'grafik-ä.html'],
|
||||
['device name', 'con.html'],
|
||||
['device name with dots', 'NUL.tar.html'],
|
||||
['com port', 'COM1.html'],
|
||||
['superscript com port', 'com¹.html'],
|
||||
['too long', `${'a'.repeat(NATIVE_CHAT_VISUAL_FILE_MAX_LENGTH - 4)}.html`]
|
||||
])('refuses %s', (_name, name) => {
|
||||
expect(isNativeChatVisualFileName(name)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a name exactly at the length cap', () => {
|
||||
expect(
|
||||
isNativeChatVisualFileName(`${'a'.repeat(NATIVE_CHAT_VISUAL_FILE_MAX_LENGTH - 5)}.html`)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming tail', () => {
|
||||
it.each([
|
||||
':',
|
||||
'::',
|
||||
'::orca',
|
||||
'::orca-visual{',
|
||||
'::orca-visual{file="a.ht',
|
||||
' ::orca-visual{file="a.html"}'
|
||||
])('holds back %j', (line) => {
|
||||
expect(isPendingNativeChatVisualDirectiveTail(line)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'Here is a chart:',
|
||||
'::other',
|
||||
'::orca-visualx',
|
||||
' ::orca-visual{',
|
||||
'- ::orca'
|
||||
])('does not hold back %j', (line) => {
|
||||
expect(isPendingNativeChatVisualDirectiveTail(line)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops only the pending final line', () => {
|
||||
expect(withoutPendingNativeChatVisualDirectiveTail('Intro\n::orca-visual{file="a')).toBe(
|
||||
'Intro\n'
|
||||
)
|
||||
expect(withoutPendingNativeChatVisualDirectiveTail('::orca-vis')).toBe('')
|
||||
expect(withoutPendingNativeChatVisualDirectiveTail('Intro\nMore text')).toBe('Intro\nMore text')
|
||||
// A finished line (followed by a newline) is no longer the tail.
|
||||
expect(withoutPendingNativeChatVisualDirectiveTail('::orca-visual{file="a.html"}\n')).toBe(
|
||||
'::orca-visual{file="a.html"}\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutNativeChatVisualDirectiveLines', () => {
|
||||
const LINE = '::orca-visual{file="usage.html" title="Usage"}'
|
||||
|
||||
it('drops visual lines and the blank lines they leave behind', () => {
|
||||
expect(withoutNativeChatVisualDirectiveLines(`Here it is.\n\n${LINE}\n\nTuesday peaked.`)).toBe(
|
||||
'Here it is.\n\nTuesday peaked.'
|
||||
)
|
||||
expect(withoutNativeChatVisualDirectiveLines(`Intro\r\n${LINE}\r\n`)).toBe('Intro')
|
||||
expect(withoutNativeChatVisualDirectiveLines(LINE)).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the line inside fenced code and anything that is not a whole directive line', () => {
|
||||
const fenced = `\`\`\`\n${LINE}\n\`\`\`\nafter`
|
||||
expect(withoutNativeChatVisualDirectiveLines(fenced)).toBe(fenced)
|
||||
const tilde = `~~~~\n${LINE}\n~~~\nstill code\n~~~~`
|
||||
expect(withoutNativeChatVisualDirectiveLines(tilde)).toBe(tilde)
|
||||
expect(withoutNativeChatVisualDirectiveLines(`> ${LINE}`)).toBe(`> ${LINE}`)
|
||||
expect(withoutNativeChatVisualDirectiveLines(`See ${LINE}`)).toBe(`See ${LINE}`)
|
||||
})
|
||||
|
||||
it('follows CommonMark fence rules: no info string on a closer, no backtick in an opener', () => {
|
||||
// ```js inside a block does not close it, so the visual line is still code.
|
||||
const notClosed = `\`\`\`\n\`\`\`js\n${LINE}\n\`\`\``
|
||||
expect(withoutNativeChatVisualDirectiveLines(notClosed)).toBe(notClosed)
|
||||
// Inline triple backticks open no fence, so a later visual line is still dropped.
|
||||
expect(withoutNativeChatVisualDirectiveLines(`Use \`\`\`a\`\`\` inline\n${LINE}`)).toBe(
|
||||
'Use ```a``` inline'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves code blocks and indentation as written for a copy', () => {
|
||||
const code = '```\na\n\n\n\nb\n```'
|
||||
expect(withoutNativeChatVisualDirectiveLines(`${code}\n\n${LINE}`)).toBe(code)
|
||||
expect(withoutNativeChatVisualDirectiveLines(` code\npara\n${LINE}`)).toBe(' code\npara')
|
||||
expect(withoutNativeChatVisualDirectiveLines(`${LINE}\n\nText`)).toBe('Text')
|
||||
})
|
||||
|
||||
it('returns text without a marker untouched', () => {
|
||||
expect(withoutNativeChatVisualDirectiveLines(' plain\n\n\n text ')).toBe(
|
||||
' plain\n\n\n text '
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
// The one grammar for a native-chat visual: an assistant reply line naming an HTML file in that
|
||||
// chat's visuals folder. Desktop/web (a markdown syntax extension) and mobile both parse with it.
|
||||
//
|
||||
// ::orca-visual{file="usage-chart-3f2a.html" title="Usage by day"}
|
||||
//
|
||||
// - The line may carry up to 3 leading spaces and any trailing spaces/tabs; a trailing CR is
|
||||
// ignored. Nothing else may share the line.
|
||||
// - Attributes are `key="value"`, separated by spaces/tabs. Values have no escapes: they cannot
|
||||
// contain `"`, `\`, or control characters. Keys are lowercase ASCII letters.
|
||||
// - `file` is required. `title` is optional. A repeated key refuses the line. Other keys are
|
||||
// ignored, so a newer agent's extra attribute still renders on this viewer.
|
||||
// - The name is case-sensitive.
|
||||
|
||||
export const NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER = '::orca-visual{'
|
||||
|
||||
/** Longest line the grammar considers. Bounds parsing and the streaming-tail check. */
|
||||
export const NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH = 512
|
||||
export const NATIVE_CHAT_VISUAL_FILE_MAX_LENGTH = 128
|
||||
export const NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH = 120
|
||||
|
||||
/**
|
||||
* Directives one reply may mount; later ones show as text. Each visual runs author script in its
|
||||
* own frame (lazily mounted, at most 512 KiB), so this bounds one reply's live frames and memory
|
||||
* while leaving room for a reply that compares a few charts.
|
||||
*/
|
||||
export const NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE = 8
|
||||
|
||||
/** Largest visual file, in UTF-8 bytes. The execution host enforces it in a bounded read. */
|
||||
export const NATIVE_CHAT_VISUAL_MAX_BYTES = 512 * 1024
|
||||
|
||||
export type NativeChatVisualDirective = {
|
||||
file: string
|
||||
title: string | null
|
||||
}
|
||||
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.html$/
|
||||
// Why: Windows maps these base names to devices whatever the extension.
|
||||
const WINDOWS_DEVICE_BASE_NAME = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i
|
||||
const ATTRIBUTE_PATTERN = /^([a-z]+)="([^"]*)"/
|
||||
const LINE_SPACE = /^[ \t]*/
|
||||
|
||||
/**
|
||||
* A single file name inside the chat's visuals folder: no directories, separators, `..`, drive,
|
||||
* UNC, device or stream forms, ending in `.html`.
|
||||
*/
|
||||
export function isNativeChatVisualFileName(name: string): boolean {
|
||||
if (
|
||||
name.length === 0 ||
|
||||
name.length > NATIVE_CHAT_VISUAL_FILE_MAX_LENGTH ||
|
||||
!FILE_NAME_PATTERN.test(name) ||
|
||||
name.includes('..')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const baseName = name.slice(0, name.indexOf('.'))
|
||||
return !WINDOWS_DEVICE_BASE_NAME.test(baseName)
|
||||
}
|
||||
|
||||
function isPlainAttributeValue(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code < 0x20 || code === 0x7f || code === 0x5c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function stripLineEnding(line: string): string {
|
||||
if (line.endsWith('\r\n')) {
|
||||
return line.slice(0, -2)
|
||||
}
|
||||
return line.endsWith('\n') || line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
function leadingSpaceCount(line: string): number {
|
||||
let count = 0
|
||||
while (line[count] === ' ') {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function parseAttributes(body: string): Map<string, string> | null {
|
||||
const attributes = new Map<string, string>()
|
||||
let rest = body.replace(LINE_SPACE, '')
|
||||
while (rest.length > 0) {
|
||||
const match = ATTRIBUTE_PATTERN.exec(rest)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [whole, key, value] = match
|
||||
if (attributes.has(key) || !isPlainAttributeValue(value)) {
|
||||
return null
|
||||
}
|
||||
attributes.set(key, value)
|
||||
rest = rest.slice(whole.length)
|
||||
const separator = LINE_SPACE.exec(rest)?.[0] ?? ''
|
||||
if (rest.length > 0 && separator.length === 0) {
|
||||
return null
|
||||
}
|
||||
rest = rest.slice(separator.length)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
/** The directive a whole line spells, or null when the line is anything else. */
|
||||
export function parseNativeChatVisualDirectiveLine(
|
||||
rawLine: string
|
||||
): NativeChatVisualDirective | null {
|
||||
const line = stripLineEnding(rawLine)
|
||||
if (line.length > NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH || /[\r\n]/.test(line)) {
|
||||
return null
|
||||
}
|
||||
const indent = leadingSpaceCount(line)
|
||||
if (indent > 3 || !line.startsWith(NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER, indent)) {
|
||||
return null
|
||||
}
|
||||
const trimmedEnd = line.replace(/[ \t]+$/, '')
|
||||
if (!trimmedEnd.endsWith('}')) {
|
||||
return null
|
||||
}
|
||||
const body = trimmedEnd.slice(indent + NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER.length, -1)
|
||||
const attributes = parseAttributes(body)
|
||||
const file = attributes?.get('file')
|
||||
if (!attributes || file === undefined || !isNativeChatVisualFileName(file)) {
|
||||
return null
|
||||
}
|
||||
const title = attributes.get('title')?.trim() ?? ''
|
||||
if (title.length > NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH) {
|
||||
return null
|
||||
}
|
||||
return { file, title: title.length > 0 ? title : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the final, still-growing line of a streaming reply may yet become a directive, so the
|
||||
* viewer holds it back instead of flashing raw syntax. A complete-looking line counts too: the
|
||||
* agent may still append to it. Only a streaming tail may be held; a finished reply shows it.
|
||||
*/
|
||||
export function isPendingNativeChatVisualDirectiveTail(lastLine: string): boolean {
|
||||
const line = stripLineEnding(lastLine)
|
||||
if (line.length === 0 || line.length > NATIVE_CHAT_VISUAL_DIRECTIVE_MAX_LINE_LENGTH) {
|
||||
return false
|
||||
}
|
||||
const indent = leadingSpaceCount(line)
|
||||
if (indent > 3 || indent === line.length) {
|
||||
return false
|
||||
}
|
||||
const rest = line.slice(indent)
|
||||
if (rest.length <= NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER.length) {
|
||||
return NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER.startsWith(rest)
|
||||
}
|
||||
return rest.startsWith(NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER) && !/[\r\n]/.test(rest)
|
||||
}
|
||||
|
||||
/** Drops a pending directive tail (see above) from streaming reply text. */
|
||||
export function withoutPendingNativeChatVisualDirectiveTail(text: string): string {
|
||||
const lineStart = text.lastIndexOf('\n') + 1
|
||||
return isPendingNativeChatVisualDirectiveTail(text.slice(lineStart))
|
||||
? text.slice(0, lineStart)
|
||||
: text
|
||||
}
|
||||
|
||||
// CommonMark fences: a backtick opener's info string has no backtick; a closer has no info string.
|
||||
const FENCE_OPEN = /^ {0,3}(`{3,}(?=[^`]*$)|~{3,})/
|
||||
const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
||||
|
||||
/**
|
||||
* Reply text for plain-text surfaces (a sidebar row, a notification, a copy): the visual lines are
|
||||
* dropped, since only the transcript can show them. Lines inside fenced code stay, as the transcript
|
||||
* shows them too, and everything else keeps its spacing and indentation.
|
||||
*/
|
||||
export function withoutNativeChatVisualDirectiveLines(text: string): string {
|
||||
if (!text.includes(NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER)) {
|
||||
return text
|
||||
}
|
||||
let fence: string | null = null
|
||||
let skipBlank = false
|
||||
const kept: string[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const bare = line.replace(/\r$/, '')
|
||||
const blank = bare.trim().length === 0
|
||||
if (skipBlank && blank) {
|
||||
// The blank line that separated a removed visual from what follows.
|
||||
skipBlank = false
|
||||
continue
|
||||
}
|
||||
skipBlank = false
|
||||
if (fence) {
|
||||
const closer = FENCE_CLOSE.exec(bare)?.[1]
|
||||
if (closer && closer[0] === fence[0] && closer.length >= fence.length) {
|
||||
fence = null
|
||||
}
|
||||
kept.push(line)
|
||||
continue
|
||||
}
|
||||
const opener = FENCE_OPEN.exec(bare)?.[1]
|
||||
if (opener) {
|
||||
fence = opener
|
||||
kept.push(line)
|
||||
continue
|
||||
}
|
||||
if (parseNativeChatVisualDirectiveLine(line)) {
|
||||
// Drop one blank neighbour too, so the gap closes; every other line keeps its spacing.
|
||||
const previous = kept.at(-1)
|
||||
skipBlank = previous === undefined || previous.replace(/\r$/, '').trim().length === 0
|
||||
continue
|
||||
}
|
||||
kept.push(line)
|
||||
}
|
||||
// Only blank lines the removal left at the very start or end go; indentation is kept.
|
||||
return kept
|
||||
.join('\n')
|
||||
.replace(/^(?:[ \t]*\r?\n)+/, '')
|
||||
.replace(/(?:\r?\n[ \t]*)+$/, '')
|
||||
.replace(/\r$/, '')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createNativeChatVisualHeightGovernor } from './native-chat-visual-height-governor'
|
||||
|
||||
describe('createNativeChatVisualHeightGovernor', () => {
|
||||
it('applies clamped changes and ignores repeats', () => {
|
||||
const governor = createNativeChatVisualHeightGovernor()
|
||||
expect(governor.decide(300, 0)).toEqual({ kind: 'apply', height: 300 })
|
||||
expect(governor.decide(300.2, 10)).toEqual({ kind: 'ignore' })
|
||||
expect(governor.decide(5, 20)).toEqual({ kind: 'apply', height: 80 })
|
||||
expect(governor.decide(50_000, 30)).toEqual({ kind: 'apply', height: 2000 })
|
||||
})
|
||||
|
||||
it('defers changes past the per-second budget instead of dropping the last one', () => {
|
||||
const governor = createNativeChatVisualHeightGovernor()
|
||||
let now = 0
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
// Alternate shrinking and growing in large steps so no runaway is detected.
|
||||
expect(governor.decide(index % 2 === 0 ? 200 : 900, now).kind).toBe('apply')
|
||||
now += 10
|
||||
}
|
||||
expect(governor.decide(500, now)).toEqual({ kind: 'defer', retryInMs: 1_000 - now })
|
||||
expect(governor.decide(500, 1_000)).toEqual({ kind: 'apply', height: 500 })
|
||||
})
|
||||
|
||||
it('stops growth that only follows the frame (100vh plus a margin) but still lets it shrink', () => {
|
||||
const governor = createNativeChatVisualHeightGovernor()
|
||||
let height = 200
|
||||
let now = 0
|
||||
governor.decide(height, now)
|
||||
const applied: number[] = []
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
height += 16
|
||||
now += 50
|
||||
const decision = governor.decide(height, now)
|
||||
if (decision.kind === 'apply') {
|
||||
applied.push(decision.height)
|
||||
}
|
||||
}
|
||||
expect(applied.length).toBeLessThan(12)
|
||||
const frozenAt = applied.at(-1) ?? 200
|
||||
expect(governor.decide(frozenAt + 16, now + 2_000)).toEqual({ kind: 'ignore' })
|
||||
expect(governor.decide(150, now + 3_000)).toEqual({ kind: 'apply', height: 150 })
|
||||
})
|
||||
|
||||
it('lets a page grow in steps as slow images and fonts arrive', () => {
|
||||
const governor = createNativeChatVisualHeightGovernor()
|
||||
expect(governor.decide(200, 0).kind).toBe('apply')
|
||||
expect(governor.decide(240, 800).kind).toBe('apply')
|
||||
expect(governor.decide(280, 1_600).kind).toBe('apply')
|
||||
expect(governor.decide(600, 1_650).kind).toBe('apply')
|
||||
})
|
||||
|
||||
it('lets an animated expansion finish, whose steps vary frame to frame', () => {
|
||||
const governor = createNativeChatVisualHeightGovernor()
|
||||
let height = 200
|
||||
let now = 0
|
||||
governor.decide(height, now)
|
||||
const steps = [3, 9, 15, 20, 24, 26, 26, 24, 20, 15, 9, 5, 3, 2]
|
||||
for (const step of steps) {
|
||||
height += step
|
||||
now += 16
|
||||
expect(governor.decide(height, now)).toEqual({ kind: 'apply', height })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { clampNativeChatVisualHeight } from './native-chat-visual-shell'
|
||||
|
||||
const WINDOW_MS = 1_000
|
||||
const MAX_CHANGES_PER_WINDOW = 20
|
||||
// A page sized from the frame (100vh plus a margin) grows by the same small step on every report;
|
||||
// an animated expansion grows by varying steps and is left alone.
|
||||
const RUNAWAY_STEP_PX = 64
|
||||
const RUNAWAY_STEP_TOLERANCE_PX = 1
|
||||
const RUNAWAY_GAP_MS = 500
|
||||
const RUNAWAY_STREAK = 10
|
||||
|
||||
export type NativeChatVisualHeightDecision =
|
||||
| { kind: 'apply'; height: number }
|
||||
| { kind: 'defer'; retryInMs: number }
|
||||
| { kind: 'ignore' }
|
||||
|
||||
/**
|
||||
* Turns a visual's reported heights into frame heights: clamped, rate-limited, and frozen against
|
||||
* further growth once the page is plainly growing because the frame grew (the same small step, again
|
||||
* and again). Shrinking stays allowed.
|
||||
*/
|
||||
export function createNativeChatVisualHeightGovernor(): {
|
||||
decide: (reported: number, now: number) => NativeChatVisualHeightDecision
|
||||
} {
|
||||
let applied: number | null = null
|
||||
let windowStart = 0
|
||||
let changes = 0
|
||||
let lastGrowthAt = -Infinity
|
||||
let lastStep = 0
|
||||
let streak = 0
|
||||
let growthFrozen = false
|
||||
|
||||
return {
|
||||
decide(reported, now) {
|
||||
const height = clampNativeChatVisualHeight(reported)
|
||||
if (height === applied) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
const grows = applied !== null && height > applied
|
||||
if (grows && growthFrozen) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
if (now - windowStart >= WINDOW_MS) {
|
||||
windowStart = now
|
||||
changes = 0
|
||||
}
|
||||
if (changes >= MAX_CHANGES_PER_WINDOW) {
|
||||
return { kind: 'defer', retryInMs: windowStart + WINDOW_MS - now }
|
||||
}
|
||||
if (grows && applied !== null) {
|
||||
const step = height - applied
|
||||
const sameSmallStep =
|
||||
step <= RUNAWAY_STEP_PX && Math.abs(step - lastStep) <= RUNAWAY_STEP_TOLERANCE_PX
|
||||
streak = sameSmallStep && now - lastGrowthAt < RUNAWAY_GAP_MS ? streak + 1 : 0
|
||||
lastGrowthAt = now
|
||||
lastStep = step
|
||||
if (streak >= RUNAWAY_STREAK) {
|
||||
growthFrozen = true
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
} else {
|
||||
streak = 0
|
||||
}
|
||||
changes += 1
|
||||
applied = height
|
||||
return { kind: 'apply', height }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_CSP,
|
||||
NATIVE_CHAT_VISUAL_MAX_HEIGHT,
|
||||
NATIVE_CHAT_VISUAL_MIN_HEIGHT,
|
||||
NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
|
||||
NATIVE_CHAT_VISUAL_SIZE_TYPE,
|
||||
NATIVE_CHAT_VISUAL_THEME_TYPE,
|
||||
buildNativeChatVisualDocument,
|
||||
clampNativeChatVisualHeight,
|
||||
nativeChatVisualThemeCss,
|
||||
nativeChatVisualThemeMessage,
|
||||
readNativeChatVisualFrameMessage
|
||||
} from './native-chat-visual-shell'
|
||||
|
||||
const theme = {
|
||||
colorScheme: 'dark' as const,
|
||||
tokens: { '--background': 'oklch(0.2 0 0)', '--chart-1': '#123456' }
|
||||
}
|
||||
|
||||
function directives(): Map<string, string> {
|
||||
return new Map(
|
||||
NATIVE_CHAT_VISUAL_CSP.split(';').map((part) => {
|
||||
const [name, ...values] = part.trim().split(/\s+/)
|
||||
return [name, values.join(' ')]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('NATIVE_CHAT_VISUAL_CSP', () => {
|
||||
it('denies by default and closes every channel a chart does not need', () => {
|
||||
const policy = directives()
|
||||
expect(policy.get('default-src')).toBe("'none'")
|
||||
for (const name of [
|
||||
'connect-src',
|
||||
'frame-src',
|
||||
'child-src',
|
||||
'worker-src',
|
||||
'object-src',
|
||||
'media-src',
|
||||
'manifest-src',
|
||||
'form-action',
|
||||
'base-uri'
|
||||
]) {
|
||||
expect(policy.get(name)).toBe("'none'")
|
||||
}
|
||||
})
|
||||
|
||||
it('allows assets only inline, from data/blob, or from the pinned CDNs, and never eval', () => {
|
||||
const policy = directives()
|
||||
for (const name of ['script-src', 'style-src']) {
|
||||
expect(policy.get(name)).toContain("'unsafe-inline'")
|
||||
expect(policy.get(name)).not.toContain("'unsafe-eval'")
|
||||
expect(policy.get(name)).not.toContain('*')
|
||||
}
|
||||
expect(policy.get('img-src')).toBe(
|
||||
'data: blob: https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com https://esm.sh https://fonts.googleapis.com https://fonts.gstatic.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatVisualDocument', () => {
|
||||
const html = '<!doctype html><html><head><title>x</title></head><body><p>hi</p></body></html>'
|
||||
const document = buildNativeChatVisualDocument({ html, channel: 'abc123', theme })
|
||||
|
||||
it('puts the CSP before anything the visual wrote', () => {
|
||||
const cspAt = document.indexOf('http-equiv="Content-Security-Policy"')
|
||||
expect(cspAt).toBeGreaterThan(0)
|
||||
expect(cspAt).toBeLessThan(document.indexOf('<title>x</title>'))
|
||||
expect(cspAt).toBeLessThan(document.indexOf('<script>'))
|
||||
expect(document.endsWith(html)).toBe(true)
|
||||
})
|
||||
|
||||
it('paints the theme before the visual and carries the channel in the bootstrap', () => {
|
||||
expect(document).toContain('<html class="dark">')
|
||||
expect(document).toContain('--background:oklch(0.2 0 0)')
|
||||
expect(document).toContain('"channel":"abc123"')
|
||||
})
|
||||
|
||||
it('checks the theme message comes from the host window', () => {
|
||||
expect(document).toContain('event.source !== host')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatVisualThemeCss', () => {
|
||||
it('drops structural characters and invalid names so a value cannot escape the rule', () => {
|
||||
const css = nativeChatVisualThemeCss({
|
||||
colorScheme: 'light',
|
||||
tokens: {
|
||||
'--background': 'red;}</style><script>alert(1)</script>',
|
||||
'not-a-token': 'blue',
|
||||
'--Upper': 'green'
|
||||
}
|
||||
})
|
||||
expect(css).not.toContain('</style>')
|
||||
expect(css).not.toContain('}<')
|
||||
expect(css).not.toContain('not-a-token')
|
||||
expect(css).not.toContain('--Upper')
|
||||
expect(css.startsWith(':root{color-scheme:light')).toBe(true)
|
||||
})
|
||||
|
||||
it('builds the live theme message on the same channel', () => {
|
||||
expect(nativeChatVisualThemeMessage(theme, 'abc')).toMatchObject({
|
||||
type: NATIVE_CHAT_VISUAL_THEME_TYPE,
|
||||
channel: 'abc',
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNativeChatVisualFrameMessage', () => {
|
||||
it('accepts a finite size and an http(s) link on its own channel', () => {
|
||||
expect(
|
||||
readNativeChatVisualFrameMessage(
|
||||
{ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: 321.5 },
|
||||
'c'
|
||||
)
|
||||
).toEqual({ kind: 'size', height: 321.5 })
|
||||
expect(
|
||||
readNativeChatVisualFrameMessage(
|
||||
{ type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'https://example.com/a b' },
|
||||
'c'
|
||||
)
|
||||
).toEqual({ kind: 'open-link', url: 'https://example.com/a%20b' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['another channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'x', height: 10 }],
|
||||
['no channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, height: 10 }],
|
||||
['NaN height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Number.NaN }],
|
||||
['infinite height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Infinity }],
|
||||
['negative height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: -5 }],
|
||||
['string height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: '50' }],
|
||||
[
|
||||
'javascript link',
|
||||
{ type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'javascript:alert(1)' }
|
||||
],
|
||||
['file link', { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'file:///etc' }],
|
||||
[
|
||||
'overlong link',
|
||||
{
|
||||
type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
|
||||
channel: 'c',
|
||||
url: `https://example.com/${'a'.repeat(3000)}`
|
||||
}
|
||||
],
|
||||
['unknown type', { type: 'orca-panel-action', channel: 'c' }],
|
||||
['a string', 'orca-visual-size'],
|
||||
['null', null]
|
||||
])('refuses %s', (_name, data) => {
|
||||
expect(readNativeChatVisualFrameMessage(data, 'c')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampNativeChatVisualHeight', () => {
|
||||
it('keeps heights in range', () => {
|
||||
expect(clampNativeChatVisualHeight(1)).toBe(NATIVE_CHAT_VISUAL_MIN_HEIGHT)
|
||||
expect(clampNativeChatVisualHeight(99_999)).toBe(NATIVE_CHAT_VISUAL_MAX_HEIGHT)
|
||||
expect(clampNativeChatVisualHeight(300.4)).toBe(300)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
import { PANEL_DESIGN_TOKEN_ALLOWLIST } from './plugins/plugin-panel-shell'
|
||||
|
||||
/**
|
||||
* The document wrapped around an agent-written chat visual before it enters its sandboxed frame.
|
||||
* Same placement rule as the plugin panel shell: the CSP meta parses before any visual markup, so
|
||||
* nothing later in the document can loosen it, and the visual's own <html>/<head> merge into ours.
|
||||
*
|
||||
* Network: scripts, styles, fonts and images may load from a pinned set of public CDNs; nothing may
|
||||
* fetch, open sockets, frame, or submit. A page can still encode data in a request to an allowed
|
||||
* CDN — an accepted, disclosed risk, not a guarantee that no data leaves.
|
||||
*
|
||||
* Electron-free string builder: desktop, the web client and mobile build the same document.
|
||||
*/
|
||||
|
||||
export const NATIVE_CHAT_VISUAL_CDN_ORIGINS = [
|
||||
'https://cdn.jsdelivr.net',
|
||||
'https://unpkg.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://esm.sh',
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com'
|
||||
] as const
|
||||
|
||||
const ASSET_SOURCES = `'unsafe-inline' data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}`
|
||||
const MEDIA_SOURCES = `data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}`
|
||||
|
||||
export const NATIVE_CHAT_VISUAL_CSP = [
|
||||
"default-src 'none'",
|
||||
`script-src ${ASSET_SOURCES}`,
|
||||
`style-src ${ASSET_SOURCES}`,
|
||||
`font-src ${MEDIA_SOURCES}`,
|
||||
`img-src ${MEDIA_SOURCES}`,
|
||||
"connect-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"worker-src 'none'",
|
||||
"object-src 'none'",
|
||||
"media-src 'none'",
|
||||
"manifest-src 'none'",
|
||||
"form-action 'none'",
|
||||
"base-uri 'none'"
|
||||
].join('; ')
|
||||
|
||||
/** Frame names carry this prefix so the main process registers them before content runs. */
|
||||
export const NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX = 'orca-chat-visual:'
|
||||
|
||||
export const NATIVE_CHAT_VISUAL_SIZE_TYPE = 'orca-visual-size'
|
||||
export const NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE = 'orca-visual-open-link'
|
||||
export const NATIVE_CHAT_VISUAL_THEME_TYPE = 'orca-visual-theme'
|
||||
|
||||
export const NATIVE_CHAT_VISUAL_MIN_HEIGHT = 80
|
||||
export const NATIVE_CHAT_VISUAL_MAX_HEIGHT = 2000
|
||||
const MAX_LINK_LENGTH = 2048
|
||||
|
||||
/** Theme variables a visual may style against: the plugin panel set plus the chart series. */
|
||||
export const NATIVE_CHAT_VISUAL_THEME_TOKENS = [
|
||||
...PANEL_DESIGN_TOKEN_ALLOWLIST,
|
||||
'--chart-1',
|
||||
'--chart-2',
|
||||
'--chart-3',
|
||||
'--chart-4',
|
||||
'--chart-5',
|
||||
'--font-sans',
|
||||
'--font-mono'
|
||||
] as const
|
||||
|
||||
export type NativeChatVisualTheme = {
|
||||
colorScheme: 'light' | 'dark'
|
||||
tokens: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/
|
||||
|
||||
/** Token values land inside a <style> block, so structural characters are stripped. */
|
||||
function sanitizeTokenValue(value: string): string {
|
||||
return value.replaceAll(/[{}<>;\\]/g, '').trim()
|
||||
}
|
||||
|
||||
export function nativeChatVisualThemeCss(theme: NativeChatVisualTheme): string {
|
||||
const declarations = [`color-scheme:${theme.colorScheme === 'dark' ? 'dark' : 'light'}`]
|
||||
for (const [name, value] of Object.entries(theme.tokens)) {
|
||||
const clean = sanitizeTokenValue(value)
|
||||
if (TOKEN_NAME_PATTERN.test(name) && clean.length > 0) {
|
||||
declarations.push(`${name}:${clean}`)
|
||||
}
|
||||
}
|
||||
return `:root{${declarations.join(';')}}`
|
||||
}
|
||||
|
||||
// A visual sits in the reply on the thread's own background; its scrollbar stays hidden because the
|
||||
// frame grows to fit it. The visual's own CSS comes later and wins.
|
||||
const BASE_CSS =
|
||||
'html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);' +
|
||||
'font-size:14px;line-height:1.5;scrollbar-width:none}html::-webkit-scrollbar{display:none}' +
|
||||
'body{margin:0}code,pre,kbd,samp{font-family:var(--font-mono)}'
|
||||
|
||||
function bootstrapScript(channel: string): string {
|
||||
const constants = JSON.stringify({
|
||||
channel,
|
||||
size: NATIVE_CHAT_VISUAL_SIZE_TYPE,
|
||||
link: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
|
||||
theme: NATIVE_CHAT_VISUAL_THEME_TYPE
|
||||
})
|
||||
// Plain ES5 so it runs before, and independent of, anything the visual loads.
|
||||
return `(function () {
|
||||
'use strict'
|
||||
var C = ${constants}
|
||||
var host = window.parent
|
||||
function send(message) {
|
||||
message.channel = C.channel
|
||||
try { host.postMessage(message, '*') } catch (_) {}
|
||||
}
|
||||
// Containment: a visual is a document, never a browsing context. The host also refuses navigation.
|
||||
if (window.navigation && typeof window.navigation.addEventListener === 'function') {
|
||||
window.navigation.addEventListener('navigate', function (event) {
|
||||
if (!event.hashChange && event.cancelable) event.preventDefault()
|
||||
})
|
||||
}
|
||||
try { Object.defineProperty(window, 'open', { value: function () { return null }, writable: false, configurable: false }) }
|
||||
catch (_) { try { window.open = function () { return null } } catch (_) {} }
|
||||
document.addEventListener('click', function (event) {
|
||||
var node = event.target
|
||||
while (node && node.nodeType !== 1) node = node.parentNode
|
||||
var anchor = node && node.closest ? node.closest('a[href]') : null
|
||||
if (!anchor) return
|
||||
var href = anchor.getAttribute('href') || ''
|
||||
if (href.charAt(0) === '#') return
|
||||
event.preventDefault()
|
||||
if (!event.isTrusted) return
|
||||
var url
|
||||
try { url = new URL(href, document.baseURI) } catch (_) { return }
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') send({ type: C.link, url: url.href })
|
||||
}, true)
|
||||
document.addEventListener('submit', function (event) { event.preventDefault() }, true)
|
||||
// Theme: only the host window may restyle the visual.
|
||||
var themeStyle = document.getElementById('orca-visual-theme')
|
||||
window.addEventListener('message', function (event) {
|
||||
var data = event.data
|
||||
if (event.source !== host || !data || data.type !== C.theme || data.channel !== C.channel) return
|
||||
if (typeof data.css !== 'string' || !themeStyle) return
|
||||
themeStyle.textContent = data.css
|
||||
document.documentElement.classList.toggle('dark', data.colorScheme === 'dark')
|
||||
})
|
||||
// Height: report the document's own height whenever layout changes; the host fits the frame.
|
||||
var reported = -1
|
||||
var pending = false
|
||||
function measure() {
|
||||
pending = false
|
||||
var height = document.documentElement.getBoundingClientRect().height
|
||||
var body = document.body
|
||||
// A page pinned to the frame (html/body height 100%) overflows its body instead of growing it;
|
||||
// add only what overflows, so a padded full-height body does not grow the frame by itself.
|
||||
if (body && body.scrollHeight > body.clientHeight) height += body.scrollHeight - body.clientHeight
|
||||
height = Math.ceil(height)
|
||||
if (height !== reported && height > 0) {
|
||||
reported = height
|
||||
send({ type: C.size, height: height })
|
||||
}
|
||||
}
|
||||
function schedule() {
|
||||
if (pending) return
|
||||
pending = true
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(measure)
|
||||
else setTimeout(measure, 16)
|
||||
}
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
var observer = new ResizeObserver(schedule)
|
||||
observer.observe(document.documentElement)
|
||||
document.addEventListener('DOMContentLoaded', function () { if (document.body) observer.observe(document.body) })
|
||||
}
|
||||
// Content that overflows a fixed-size body changes no observed box, so watch the content too.
|
||||
if (typeof MutationObserver === 'function') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (document.body) new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true })
|
||||
})
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', schedule)
|
||||
window.addEventListener('load', schedule)
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule)
|
||||
})()`
|
||||
}
|
||||
|
||||
/**
|
||||
* The full srcdoc for one visual. `channel` is unique per built document and rides on every message
|
||||
* the shell sends, so the host can tell this document's messages from anything else.
|
||||
*/
|
||||
export function buildNativeChatVisualDocument(args: {
|
||||
html: string
|
||||
channel: string
|
||||
theme: NativeChatVisualTheme
|
||||
}): string {
|
||||
const scheme = args.theme.colorScheme === 'dark' ? 'dark' : 'light'
|
||||
return `<!doctype html>
|
||||
<html class="${scheme}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="${NATIVE_CHAT_VISUAL_CSP}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style id="orca-visual-theme">${nativeChatVisualThemeCss(args.theme)}</style>
|
||||
<style>${BASE_CSS}</style>
|
||||
<script>${bootstrapScript(args.channel)}</script>
|
||||
</head>
|
||||
${args.html}`
|
||||
}
|
||||
|
||||
export type NativeChatVisualFrameMessage =
|
||||
| { kind: 'size'; height: number }
|
||||
| { kind: 'open-link'; url: string }
|
||||
|
||||
/** A message from a visual's shell, validated; null for anything else. */
|
||||
export function readNativeChatVisualFrameMessage(
|
||||
data: unknown,
|
||||
channel: string
|
||||
): NativeChatVisualFrameMessage | null {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return null
|
||||
}
|
||||
if (!('channel' in data) || data.channel !== channel || !('type' in data)) {
|
||||
return null
|
||||
}
|
||||
if (data.type === NATIVE_CHAT_VISUAL_SIZE_TYPE) {
|
||||
const height = 'height' in data ? data.height : undefined
|
||||
return typeof height === 'number' && Number.isFinite(height) && height > 0
|
||||
? { kind: 'size', height }
|
||||
: null
|
||||
}
|
||||
if (data.type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) {
|
||||
const url = 'url' in data ? data.url : undefined
|
||||
if (typeof url !== 'string' || url.length > MAX_LINK_LENGTH) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? { kind: 'open-link', url: parsed.href }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function clampNativeChatVisualHeight(height: number): number {
|
||||
return Math.min(
|
||||
NATIVE_CHAT_VISUAL_MAX_HEIGHT,
|
||||
Math.max(NATIVE_CHAT_VISUAL_MIN_HEIGHT, Math.round(height))
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatVisualThemeMessage(
|
||||
theme: NativeChatVisualTheme,
|
||||
channel: string
|
||||
): { type: string; channel: string; css: string; colorScheme: 'light' | 'dark' } {
|
||||
return {
|
||||
type: NATIVE_CHAT_VISUAL_THEME_TYPE,
|
||||
channel,
|
||||
css: nativeChatVisualThemeCss(theme),
|
||||
colorScheme: theme.colorScheme
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod'
|
||||
import { isNativeChatVisualFileName } from '../native-chat-visual-directive'
|
||||
import { SessionId } from './structured-agent-session-identifiers'
|
||||
|
||||
// `agentSession.readVisual`: one HTML visual from a structured chat's visuals folder, read on the
|
||||
// host that owns the chat. The client names only the session and a bare file name; the host
|
||||
// resolves its own folder, so no client path ever reaches another host.
|
||||
|
||||
/** Content revision: hex digest of the file's bytes. */
|
||||
const Revision = z.string().regex(/^[0-9a-f]{16,64}$/)
|
||||
|
||||
export const ReadVisualParams = z
|
||||
.object({
|
||||
sessionId: SessionId,
|
||||
file: z.string().refine(isNativeChatVisualFileName, 'Invalid visual file name'),
|
||||
// The revision the client already holds; a match answers `unchanged` without the bytes.
|
||||
knownRevision: Revision.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AgentSessionReadVisualParams = z.infer<typeof ReadVisualParams>
|
||||
|
||||
/**
|
||||
* Why a visual cannot be shown, as positively observed by the owning host. Transport loss and an
|
||||
* older host are never one of these: the client reads those as unavailable and may retry.
|
||||
*/
|
||||
export const AGENT_SESSION_VISUAL_READ_ERRORS = [
|
||||
'session_not_found',
|
||||
'unsupported_location',
|
||||
'not_found',
|
||||
'not_a_file',
|
||||
'outside_folder',
|
||||
'too_large',
|
||||
'not_text'
|
||||
] as const
|
||||
|
||||
export type AgentSessionVisualReadError = (typeof AGENT_SESSION_VISUAL_READ_ERRORS)[number]
|
||||
|
||||
export type AgentSessionReadVisualResult =
|
||||
| { ok: true; revision: string; sizeBytes: number; html: string }
|
||||
| { ok: true; revision: string; sizeBytes: number; unchanged: true }
|
||||
| { ok: false; error: AgentSessionVisualReadError }
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import { PrepareCodexForWslPaneParams } from './agent-hooks-params'
|
||||
import { AgentLaunch, AgentLaunchReplay } from './agent-launch-params'
|
||||
import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params'
|
||||
import { ReadVisualParams } from './agent-session-visual-params'
|
||||
import {
|
||||
AiVaultListSessionsParams,
|
||||
AiVaultPrepareSessionResumeParams,
|
||||
@@ -615,6 +616,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'agentSession.queuedMessageDelete': QueuedMessageActionParams,
|
||||
'agentSession.queuedMessageSend': QueuedMessageActionParams,
|
||||
'agentSession.queuedMessagesResume': QueuedMessagesResumeParams,
|
||||
'agentSession.readVisual': ReadVisualParams,
|
||||
'agentSession.release': HoldParams,
|
||||
'agentSession.respondToApproval': RespondParams,
|
||||
'agentSession.respondToQuestion': RespondToQuestionParams,
|
||||
|
||||
@@ -319,3 +319,37 @@ describe('the sidebar verdict agrees with the rejection classifier', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('the assistant line plain-text surfaces show', () => {
|
||||
function message(
|
||||
itemId: string,
|
||||
sequence: number,
|
||||
role: 'user' | 'assistant',
|
||||
text: string
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'message', role, blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps a visual line out of the preview every status reader shows', () => {
|
||||
const items = [
|
||||
message('ask', 1, 'user', 'chart it'),
|
||||
message(
|
||||
'said',
|
||||
2,
|
||||
'assistant',
|
||||
'p95 is highest in ap-south.\n\n::orca-visual{file="latency.html" title="p95"}'
|
||||
),
|
||||
message('only-visual', 3, 'assistant', '::orca-visual{file="table.html"}')
|
||||
]
|
||||
// A reply that is nothing but a visual leaves the turn's earlier prose as the preview.
|
||||
expect(projectStructuredAgentSessionStatusSummary(items).lastAssistantMessage).toBe(
|
||||
'p95 is highest in ap-south.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
|
||||
import { isRootAgentJournalItem } from './agent-session-journal-producer'
|
||||
import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record'
|
||||
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
|
||||
import { withoutNativeChatVisualDirectiveLines } from './native-chat-visual-directive'
|
||||
import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch'
|
||||
import {
|
||||
isStructuredAgentSessionCommandEntry,
|
||||
@@ -187,7 +188,8 @@ export function latestStructuredAgentSessionAssistantMessage(
|
||||
return ''
|
||||
}
|
||||
if (body?.kind === 'message' && body.role === 'assistant') {
|
||||
const prose = messageProse(body.blocks)
|
||||
// A visual line shows only in the transcript; every plain-text reader of this line drops it.
|
||||
const prose = withoutNativeChatVisualDirectiveLines(messageProse(body.blocks))
|
||||
if (prose.trim()) {
|
||||
return prose
|
||||
}
|
||||
|
||||
@@ -140,6 +140,14 @@ export const STRUCTURED_CALLS: {
|
||||
hostMethod: 'revealSession',
|
||||
result: { ok: true, sessionId: SESSION, workspaceId: WORKSPACE, agent: 'codex', readable: true }
|
||||
},
|
||||
// A chat's visual, read from the host's own record and state directory. A bare addition: an older
|
||||
// host answers `method_not_found` and the client shows the visual as unavailable. The stub host
|
||||
// holds no record, so the typed refusal is the declared answer.
|
||||
{
|
||||
method: 'agentSession.readVisual',
|
||||
hostMethod: null,
|
||||
result: { ok: false, error: 'session_not_found' }
|
||||
},
|
||||
// A no-op on a host that starts an agent only for work; it still builds the host.
|
||||
{ method: 'agentSession.hold', hostMethod: null, result: { held: true } },
|
||||
// The restart-resume surface. Bare additions, not capability-negotiated: an RPC method's
|
||||
@@ -326,6 +334,8 @@ export function paramsFor(method: string): unknown {
|
||||
return { ...ATTENTION_READ, observedCursor: { ...ATTENTION_READ.observedCursor } }
|
||||
case 'agentSession.modelCatalog':
|
||||
return { agent: 'codex', sessionId: SESSION }
|
||||
case 'agentSession.readVisual':
|
||||
return { sessionId: SESSION, file: 'usage-chart.html' }
|
||||
case 'agentSession.hold':
|
||||
case 'agentSession.release':
|
||||
return { sessionId: SESSION, holderId: 'surface-1' }
|
||||
|
||||
Reference in New Issue
Block a user