Repository navigation
Save chat HTML visuals as ordinary workspace files - #27633
brennanb2025 wants to merge 12 commits into
Conversation
Agents now write inline visuals as normal .html files in the chat's working folder and name them by relative path in the reply line, so the page can be opened, edited and shared like any other file. The host reads the page from the chat's own folder with real-path containment. Removes the per-chat visuals folder, its env var, extra writable roots, startup sweep, and the separate read-only chat-visual tab. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The approval card now reaches the file-open routing through the inline visual's Open button, which reads i18n.language at import. Co-Authored-By: Claude <noreply@anthropic.com>
… context A visual is now a chat file link that renders. Desktop resolves it with the chat's file-link context and reads it with readRuntimeFilePreview under a new read-only `chat-html` automatic-load access (the chat-image rule with an HTML real-target test). It re-reads at every file-link recheck (turn end, host return) and when another view's watch reports the file, and swaps the frame only when the page changed. The phone reads through files.read and opens the page in its file viewer; floating chats show unavailable without a request. Removes agentSession.readVisual, its params, host reader and phone allowlist entry, the read client's revision protocol, the legacy `file=` line and the phone's fullscreen modal. The skill asks for a self-contained page. Co-Authored-By: Claude <noreply@anthropic.com>
…l-workspace-files # Conflicts: # src/main/acp/acp-structured-launch-resolution-omp.test.ts # src/main/acp/acp-structured-launch-resolution.ts # src/main/acp/acp-structured-launch-visuals.test.ts # src/main/claude/claude-structured-launch-resolution.ts # src/main/codex/codex-structured-child-environment.ts # src/main/codex/codex-structured-visuals.test.ts # src/main/native-chat/native-chat-inline-visuals-setting.test.ts # src/main/native-chat/native-chat-visuals-delivery.test.ts # src/main/runtime/structured-agent-runtime-registrations.ts
Co-Authored-By: Claude <noreply@anthropic.com>
…l-workspace-files Co-Authored-By: Claude <noreply@anthropic.com>
Review status (quick review loop, 2026-10-12)The problem this PR fixes. When an agent draws a chart or report in a native chat, Orca saved that HTML page in a hidden per-chat folder inside Orca's own data. It wasn't a normal file: you couldn't edit it, share it, see it in the file tree, or open it on SSH and paired hosts. Orca kept a separate read call ( What changes for you. The agent now writes an ordinary
This round. Fresh reviewers went through the whole diff, and one architecture pass covered all three stacked PRs; the architecture pass kept this PR's design as is. Then a readiness check ran on the final diff, followed by the one main sync.
Verified.
Not verified.
|
…l-workspace-files
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Priority: ⬇️ Low Merge Risk: 🔵 Low · up to A context change can leave the previous visual on screen when the new file is unreachable, and large local visuals that cannot be previewed may still incur repeated reads. These are bounded follow-ups; ordinary visual previews remain available. Pre-merge checks ·
|
There was a problem hiding this comment.
Actionable comments posted: 3
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
1ca36097-c631-4cd8-b9cf-e1b0c5dd7b04
⛔ Files ignored due to path filters (2)
src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snapis excluded by!**/*.snapsrc/shared/rpc-contract/rpc-params-catalog.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (109)
mobile/src/components/MobileMarkdown.tsxmobile/src/components/WorktreeAgentRow.visual-line.test.tsxmobile/src/components/mobile-markdown-visual-lines.test.tsmobile/src/session/MobileNativeChatVisual.tsxmobile/src/session/mobile-native-chat-message-plain-text.test.tsmobile/src/session/mobile-native-chat-message-visuals.test.tsmobile/src/session/mobile-native-chat-visual-read.test.tsmobile/src/session/mobile-native-chat-visual-read.tsmobile/src/session/mobile-structured-agent-session-contract.tsmobile/src/session/use-mobile-native-chat-controller.tsmobile/src/session/use-mobile-native-chat-visual.test.tsxmobile/src/session/use-mobile-native-chat-visual.tsmobile/src/session/use-mobile-structured-agent-session.tsresources/native-chat-visuals/skills/orca-chat-visuals/SKILL.mdsrc/main/acp/acp-structured-launch-resolution-omp.test.tssrc/main/acp/acp-structured-launch-resolution.tssrc/main/acp/acp-structured-launch-visuals.test.tssrc/main/acp/acp-structured-launch-visuals.tssrc/main/ai-vault/session-scanner-values.test.tssrc/main/claude/claude-child-process-environment.tssrc/main/claude/claude-structured-launch-resolution.tssrc/main/claude/claude-structured-launch-visuals.test.tssrc/main/claude/claude-structured-real-cli-visuals.test.tssrc/main/codex/codex-structured-launch-resolution.test.tssrc/main/codex/codex-structured-launch-resolution.tssrc/main/codex/codex-structured-real-cli-visuals.test.tssrc/main/codex/codex-structured-session-acquire.tssrc/main/codex/codex-structured-thread-open.tssrc/main/codex/codex-structured-visuals.test.tssrc/main/codex/codex-structured-visuals.tssrc/main/ipc/local-file-access-resolution-network-path.test.tssrc/main/ipc/local-file-access-resolution.test.tssrc/main/ipc/local-file-access-resolution.tssrc/main/native-chat/native-chat-inline-visuals-setting.test.tssrc/main/native-chat/native-chat-visual-file-read.test.tssrc/main/native-chat/native-chat-visual-file-read.tssrc/main/native-chat/native-chat-visuals-delivery.test.tssrc/main/native-chat/native-chat-visuals-delivery.tssrc/main/native-chat/native-chat-visuals-folder.tssrc/main/native-chat/native-chat-visuals-sweep.test.tssrc/main/native-chat/native-chat-visuals-sweep.tssrc/main/runtime/agent-session-record-store.tssrc/main/runtime/agent-session-store-state.tssrc/main/runtime/mobile-rpc-allowlist.test.tssrc/main/runtime/native-chat-visuals-workspace-verdict.test.tssrc/main/runtime/native-chat-visuals-workspace-verdict.tssrc/main/runtime/orca-runtime-get-worktree-ps.tssrc/main/runtime/rpc/methods/index.tssrc/main/runtime/rpc/methods/structured-agent-session-visual.test.tssrc/main/runtime/rpc/methods/structured-agent-session-visual.tssrc/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.tssrc/main/runtime/structured-agent-launch-composition.tssrc/main/runtime/structured-agent-launch-parity.test.tssrc/main/runtime/structured-agent-session-runtime.tssrc/renderer/src/components/editor/EditorContent.tsxsrc/renderer/src/components/editor/closed-editor-tab-disposal.tssrc/renderer/src/components/editor/editor-labels.tssrc/renderer/src/components/native-chat/NativeChatInlineVisual.tsxsrc/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsxsrc/renderer/src/components/native-chat/NativeChatMessageRow.test.tsxsrc/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsxsrc/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsxsrc/renderer/src/components/native-chat/NativeChatView.tsxsrc/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsxsrc/renderer/src/components/native-chat/NativeChatVisualFrame.tsxsrc/renderer/src/components/native-chat/NativeChatVisualTab.test.tsxsrc/renderer/src/components/native-chat/NativeChatVisualTab.tsxsrc/renderer/src/components/native-chat/native-chat-file-link-existence.test.tssrc/renderer/src/components/native-chat/native-chat-file-link-existence.tssrc/renderer/src/components/native-chat/native-chat-visual-file.test.tssrc/renderer/src/components/native-chat/native-chat-visual-file.tssrc/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsxsrc/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsxsrc/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.tssrc/renderer/src/components/native-chat/native-chat-visual-open.tssrc/renderer/src/components/native-chat/native-chat-visual-owner.tsxsrc/renderer/src/components/native-chat/native-chat-visual-read-client.test.tssrc/renderer/src/components/native-chat/native-chat-visual-read-client.tssrc/renderer/src/components/native-chat/native-chat-visual-tab.tssrc/renderer/src/components/native-chat/use-native-chat-file-link-existence.tsxsrc/renderer/src/components/native-chat/use-native-chat-visual-document.tssrc/renderer/src/components/tab-bar/EditorFileTab.tsxsrc/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsxsrc/renderer/src/components/tab-bar/RecentTabSwitcher.tsxsrc/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.tssrc/renderer/src/hooks/useEditorExternalWatch-targets.test.tssrc/renderer/src/lib/local-file-access.tssrc/renderer/src/lib/workspace-session-unified-tabs.tssrc/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.tssrc/renderer/src/store/slices/editor-chat-visual-tabs.test.tssrc/renderer/src/store/slices/editor/actions/chat-visual-tab-actions.tssrc/renderer/src/store/slices/editor/actions/close-file-action.tssrc/renderer/src/store/slices/editor/actions/open-file-action.tssrc/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.tssrc/renderer/src/store/slices/editor/create-editor-slice.tssrc/renderer/src/store/slices/editor/tabs/editor-tab-content-type.tssrc/renderer/src/store/slices/editor/types/editor-files-slice.tssrc/renderer/src/store/slices/editor/types/open-file.tssrc/shared/editor-tab-view-types.tssrc/shared/local-file-access.tssrc/shared/native-chat-visual-directive.test.tssrc/shared/native-chat-visual-directive.tssrc/shared/rpc-contract/agent-session-visual-params.tssrc/shared/structured-agent-session-latest-request.test.tssrc/shared/tab-types.tssrc/shared/workspace-layout/old-client/old-client-session-tabs-projection.tssrc/shared/workspace-layout/tab-order.tstests/e2e/cross-version-wire/structured-agent-session-surface-calls.tstests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts
💤 Files with no reviewable changes (35)
- src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts
- src/renderer/src/components/native-chat/NativeChatVisualTab.test.tsx
- src/main/native-chat/native-chat-visuals-sweep.test.ts
- src/renderer/src/components/editor/closed-editor-tab-disposal.ts
- src/renderer/src/store/slices/editor/create-editor-slice.ts
- src/renderer/src/components/native-chat/native-chat-visual-tab.ts
- tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts
- src/shared/rpc-contract/agent-session-visual-params.ts
- src/main/runtime/native-chat-visuals-workspace-verdict.test.ts
- src/renderer/src/components/native-chat/NativeChatVisualTab.tsx
- src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts
- src/main/native-chat/native-chat-visual-file-read.test.ts
- src/renderer/src/components/editor/EditorContent.tsx
- src/main/runtime/structured-agent-launch-composition.ts
- src/renderer/src/store/slices/editor/actions/chat-visual-tab-actions.ts
- src/renderer/src/components/native-chat/NativeChatView.tsx
- src/main/runtime/rpc/methods/index.ts
- tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts
- src/renderer/src/components/editor/editor-labels.ts
- mobile/src/session/mobile-structured-agent-session-contract.ts
- src/main/runtime/mobile-rpc-allowlist.test.ts
- src/renderer/src/store/slices/editor-chat-visual-tabs.test.ts
- src/main/native-chat/native-chat-visuals-folder.ts
- src/main/runtime/agent-session-store-state.ts
- src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts
- src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts
- src/renderer/src/components/native-chat/native-chat-visual-read-client.ts
- src/shared/editor-tab-view-types.ts
- src/main/claude/claude-child-process-environment.ts
- src/main/native-chat/native-chat-visual-file-read.ts
- src/main/native-chat/native-chat-visuals-sweep.ts
- src/main/runtime/native-chat-visuals-workspace-verdict.ts
- src/main/runtime/rpc/methods/structured-agent-session-visual.ts
- mobile/src/session/use-mobile-structured-agent-session.ts
- src/shared/tab-types.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.
| async function readOnce( | ||
| key: string, | ||
| file: NativeChatVisualFile | ||
| ): Promise<NativeChatVisualReadOutcome> { | ||
| try { | ||
| const result = await readRuntimeFilePreview( | ||
| file.fileContext, | ||
| file.absolutePath, | ||
| chatHtmlAccess() | ||
| ) | ||
| if ( | ||
| result.isBinary || | ||
| new TextEncoder().encode(result.content).byteLength > NATIVE_CHAT_VISUAL_MAX_BYTES | ||
| ) { | ||
| cache.delete(key) | ||
| return { kind: 'refused' } | ||
| } | ||
| const document = { html: result.content } | ||
| remember(key, document) | ||
| return { kind: 'ready', document } | ||
| } catch (error) { | ||
| // Why only these: a paired host answers `file_too_large` past 512 KiB; anything else may be transport. | ||
| if ( | ||
| isMissingRuntimePathError(error) || | ||
| (error instanceof Error && error.message === 'file_too_large') | ||
| ) { | ||
| cache.delete(key) | ||
| return { kind: 'refused' } | ||
| } | ||
| return { kind: 'unreachable' } | ||
| } | ||
| } |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '100,155p' src/renderer/src/components/native-chat/native-chat-visual-file.ts
rg -n 'readRuntimeFilePreview|file_too_large|maxBytes|maxSize' src/renderer/src src/main/ipc | head -100Repository: stablyai/orca
Length of output: 12133
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- runtime preview implementation ---'
nl -ba src/renderer/src/runtime/runtime-file-read-client.ts | sed -n '1,190p'
printf '%s\n' '--- preview tests ---'
nl -ba src/renderer/src/runtime/runtime-file-client.test.ts | sed -n '430,555p'
printf '%s\n' '--- visual reader declarations/callers ---'
rg -n -F --glob '*.{ts,tsx}' -- 'readNativeChatVisualFile' src/renderer/src
printf '%s\n' '--- visual file constants and reader context ---'
nl -ba src/renderer/src/components/native-chat/native-chat-visual-file.ts | sed -n '1,180p'Repository: stablyai/orca
Length of output: 20384
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- local fs.readFile bindings and limits ---'
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'fs.readFile' src/main src/shared src/preload src/renderer | head -120
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'readFile:' src/main src/shared src/preload src/renderer | head -120
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'FILE_PREVIEW' src/main src/shared src/preload src/renderer
printf '%s\n' '--- visual document hook ---'
nl -ba src/renderer/src/components/native-chat/use-native-chat-visual-document.ts | sed -n '1,180p'
printf '%s\n' '--- visual reader tests ---'
nl -ba src/renderer/src/components/native-chat/native-chat-visual-file.test.ts | sed -n '1,155p'Repository: stablyai/orca
Length of output: 16316
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- preload filesystem API ---'
nl -ba src/preload/api/filesystem-api.ts | sed -n '1,130p'
nl -ba src/preload/api/fs-bridge.ts | sed -n '1,120p'
printf '%s\n' '--- filesystem IPC bindings ---'
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'readFile' src/main/ipc src/main | head -180
printf '%s\n' '--- size/stat APIs ---'
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'stat' src/preload src/main/ipc src/main src/shared | head -160
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'readFileChunk' src/preload src/main/ipc src/main src/shared | head -160Repository: stablyai/orca
Length of output: 41463
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- local read handler ---'
nl -ba src/main/ipc/filesystem/filesystem-read-handlers.ts | sed -n '1,215p'
printf '%s\n' '--- regular-file reader ---'
nl -ba src/main/ipc/filesystem/local-regular-file-read.ts | sed -n '1,135p'
printf '%s\n' '--- stat API declaration/bridge ---'
rg -n -F --glob '*.{ts,tsx}' -- 'stat:' src/preload/api/filesystem-api.ts src/preload/api/fs-bridge.ts
rg -n -F --glob '*.{ts,tsx}' -- 'ipcRenderer.invoke('\"'\"'fs:stat'\"'\"'' src/preload/api/fs-bridge.ts
nl -ba src/preload/api/filesystem-api.ts | sed -n '125,230p'Repository: stablyai/orca
Length of output: 16538
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- delegated local content read ---'
nl -ba src/main/ipc/filesystem/filesystem-file-content-inspection.ts | sed -n '1,240p'
printf '%s\n' '--- local content read references and limits ---'
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'readLocalFileContent' src
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' --glob '!**/*.test.*' -- '50 * 1024 * 1024' src
rg -n -F --glob '*.{ts,tsx}' --glob '!**/*.test.*' -- 'FILE_READ' src/main src/sharedRepository: stablyai/orca
Length of output: 8054
🏁 Script executed:
set -o pipefail
rg -n -F --glob '*.{ts,tsx}' -- 'useNativeChatVisualDocument(' src/renderer/srcRepository: stablyai/orca
Length of output: 389
Check the local file size before reading its content.
For local HTML between 512 KiB and 50 MiB, readRuntimeFilePreview reads and transfers the complete file. readOnce then rejects it. The visual hook repeats this read on refreshes and file-change events, causing repeated I/O, IPC transfer, and memory allocation for files that will always be refused.
🐛 Suggested fix
diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts
@@
export async function readRuntimeFilePreview(
context: RuntimeFileOperationArgs,
filePath: string,
- access?: LocalFileAccess
+ access?: LocalFileAccess,
+ maxBytes?: number
): Promise<RuntimeFilePreviewResult> {
@@
if (!remoteArgs) {
if (hasRemoteRuntimeOwner(context)) {
throw new Error('Remote file is outside the owning runtime worktree')
}
+ if (maxBytes !== undefined) {
+ const stats = await window.api.fs.stat({
+ filePath,
+ connectionId: context.connectionId,
+ ...localAccess(context.connectionId, access)
+ })
+ if (stats.size > maxBytes) {
+ throw new Error('file_too_large')
+ }
+ }
return window.api.fs.readFile({
filePath,
connectionId: context.connectionId,
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-file.ts b/src/renderer/src/components/native-chat/native-chat-visual-file.ts
@@
const result = await readRuntimeFilePreview(
file.fileContext,
file.absolutePath,
- chatHtmlAccess()
+ chatHtmlAccess(),
+ NATIVE_CHAT_VISUAL_MAX_BYTES
)| /** Reads the file now; visuals of the same file asking at once share one read. */ | ||
| export function readNativeChatVisualFile( | ||
| file: NativeChatVisualFile | ||
| ): Promise<NativeChatVisualReadOutcome> { | ||
| const key = cacheKey(file) | ||
| const pending = inFlight.get(key) | ||
| if (pending) { | ||
| return pending | ||
| } | ||
| const read = readOnce(key, file).finally(() => { | ||
| inFlight.delete(key) | ||
| }) | ||
| inFlight.set(key, read) | ||
| return read | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '65,115p' src/renderer/src/components/native-chat/use-native-chat-visual-document.ts
sed -n '130,155p' src/renderer/src/components/native-chat/native-chat-visual-file.tsRepository: stablyai/orca
Length of output: 2285
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- hook outline and references ---'
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'useNativeChatVisualDocument' src/renderer/src
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'useVisualFileChangeCount' src/renderer/src
printf '%s\n' '--- hook source ---'
sed -n '1,180p' src/renderer/src/components/native-chat/use-native-chat-visual-document.ts
printf '%s\n' '--- visual file source ---'
sed -n '1,180p' src/renderer/src/components/native-chat/native-chat-visual-file.ts
printf '%s\n' '--- relevant tests ---'
rg -n -F --glob '*test*' --glob '*spec*' -- 'refreshKey' src/renderer/src/components/native-chat
rg -n -F --glob '*test*' --glob '*spec*' -- 'readNativeChatVisualFile' src/renderer/src/components/native-chatRepository: stablyai/orca
Length of output: 10999
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- inline visual caller ---'
sed -n '1,150p' src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx
printf '%s\n' '--- change event declarations and dispatchers ---'
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'ORCA_WORKTREE_FILE_CHANGE_EVENT' src
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'dispatchEvent' src/renderer/src src/hooks shared
printf '%s\n' '--- refresh and file-link resolution call sites ---'
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'refreshKey' src/renderer/src/components/native-chat src
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'resolveNativeChatVisualFile' src
printf '%s\n' '--- preview implementation and tests ---'
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'function readRuntimeFilePreview' src
sed -n '1,180p' src/renderer/src/runtime/runtime-file-read-client.ts
sed -n '1,180p' src/renderer/src/components/native-chat/native-chat-visual-file.test.tsRepository: stablyai/orca
Length of output: 42150
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- file-link existence recheck hook ---'
sed -n '1,220p' src/renderer/src/components/native-chat/use-native-chat-file-link-existence.ts
printf '%s\n' '--- worktree change event source ---'
sed -n '1,130p' src/renderer/src/hooks/worktree-file-change-event.ts
sed -n '1,125p' src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts
printf '%s\n' '--- visual refresh tests ---'
sed -n '1,190p' src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
printf '%s\n' '--- change-specific diff ---'
git diff --no-ext-diff --unified=30 8e8c08758118f821a76453388c4d004de0144c2b 7590f90e286557a37d594351b55210ec40e5ec76 -- src/renderer/src/components/native-chat/use-native-chat-visual-document.ts src/renderer/src/components/native-chat/native-chat-visual-file.ts src/renderer/src/components/native-chat/use-native-chat-file-link-existence.ts src/renderer/src/hooks/editor-external-watch-event-reconciliation.tsRepository: stablyai/orca
Length of output: 25446
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- locate existence implementation ---'
rg --files src/renderer/src/components/native-chat | rg 'native-chat-file-link-existence'
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'useNativeChatFileLinkRecheckGeneration' src/renderer/src/components/native-chat
printf '%s\n' '--- visual refresh test remainder ---'
sed -n '190,430p' src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
printf '%s\n' '--- existence implementation ---'
for f in src/renderer/src/components/native-chat/native-chat-file-link-existence.*; do
[ -f "$f" ] && { echo "### $f"; sed -n '1,260p' "$f"; }
doneRepository: stablyai/orca
Length of output: 29140
Start a new read for refreshes that supersede an in-flight read.
When a matching file-change event or file-link recheck occurs during a pending read, the effect runs again but readNativeChatVisualFile returns the existing promise. If that promise contains the pre-change content, the refresh displays that content. No further read starts until another trigger changes the effect dependencies.
Keep concurrent initial loads deduplicated, but add a refresh-aware read path that bypasses or queues behind the pending promise. Ensure an older promise cannot remove a newer entry from inFlight.
Suggested fix
- import { useEffect, useState } from 'react'
+ import { useEffect, useRef, useState } from 'react'
...
+ const previousLoad = useRef<{
+ file: NativeChatVisualFile
+ enabled: boolean
+ refreshKey: unknown
+ changes: number
+ } | null>(null)
...
+ const forceRefresh =
+ previousLoad.current !== null &&
+ (previousLoad.current.file !== file ||
+ previousLoad.current.enabled !== enabled ||
+ previousLoad.current.refreshKey !== refreshKey ||
+ previousLoad.current.changes !== changes)
+ previousLoad.current = { file, enabled, refreshKey, changes }
- void readNativeChatVisualFile(file).then((outcome) => {
+ void readNativeChatVisualFile(file, { force: forceRefresh }).then((outcome) => { export function readNativeChatVisualFile(
- file: NativeChatVisualFile
+ file: NativeChatVisualFile,
+ options: { force?: boolean } = {}
): Promise<NativeChatVisualReadOutcome> {
const key = cacheKey(file)
const pending = inFlight.get(key)
- if (pending) {
+ if (pending && !options.force) {
return pending
}
const read = readOnce(key, file).finally(() => {
- inFlight.delete(key)
+ if (inFlight.get(key) === read) {
+ inFlight.delete(key)
+ }
})| /** | ||
| * The visual's current document. Starts from the cached revision when there is one, then asks the | ||
| * host (revalidating it) once `enabled`, and again whenever `refreshKey` changes. A later failure | ||
| * keeps a document already shown. | ||
| * The visual's current page. Starts from the cached copy when there is one, reads the file once | ||
| * `enabled`, and again whenever `refreshKey` changes or the file changes under a watch. An | ||
| * unchanged page keeps its frame; an unreachable host keeps the page already shown. | ||
| */ | ||
| export function useNativeChatVisualDocument( | ||
| identity: NativeChatVisualIdentity, | ||
| file: NativeChatVisualFile | null, | ||
| enabled: boolean, | ||
| refreshKey?: unknown | ||
| refreshKey: unknown | ||
| ): NativeChatVisualDocumentState { | ||
| const { sessionId, file } = identity | ||
| const environmentId = | ||
| identity.target.kind === 'environment' ? identity.target.environmentId : null | ||
| const [state, setState] = useState<NativeChatVisualDocumentState>(() => { | ||
| const cached = peekCachedNativeChatVisual(identity) | ||
| const cached = file ? peekNativeChatVisualFile(file) : null | ||
| return cached ? { status: 'ready', document: cached } : { status: 'loading' } | ||
| }) | ||
| const changes = useVisualFileChangeCount(file, enabled) | ||
|
|
||
| useEffect(() => { | ||
| if (!enabled) { | ||
| return | ||
| } | ||
| const identityNow: NativeChatVisualIdentity = { | ||
| target: environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId }, | ||
| sessionId, | ||
| file | ||
| if (!file) { | ||
| setState({ status: 'unavailable' }) | ||
| return | ||
| } | ||
| let current = true | ||
| void readNativeChatVisualFile(file).then((outcome) => { | ||
| if (!current) { | ||
| return | ||
| } | ||
| setState((shown) => { | ||
| if (outcome.kind === 'ready') { | ||
| return shown.status === 'ready' && shown.document.html === outcome.document.html | ||
| ? shown | ||
| : { status: 'ready', document: outcome.document } | ||
| } | ||
| return outcome.kind === 'unreachable' && shown.status === 'ready' | ||
| ? shown | ||
| : { status: 'unavailable' } | ||
| }) | ||
| }) | ||
| return () => { | ||
| current = false | ||
| } | ||
| const abort = new AbortController() | ||
| void loadWithRetries(identityNow, abort.signal, setState) | ||
| return () => abort.abort() | ||
| }, [enabled, environmentId, file, sessionId, refreshKey]) | ||
| }, [enabled, file, refreshKey, changes]) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
rg -n 'NativeChatInlineVisual|NativeChatVisualOwnerProvider|NativeChatFileLinkContext|useNativeChatFileLinkContext' src/renderer/src/components/native-chat
sed -n '1,110p' src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx
sed -n '1,125p' src/renderer/src/components/native-chat/use-native-chat-visual-document.tsRepository: stablyai/orca
Length of output: 20820
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- visual markdown extension ---'
sed -n '1,150p' src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
printf '%s\n' '--- visual owner and view ---'
sed -n '1,180p' src/renderer/src/components/native-chat/native-chat-visual-owner.tsx
sed -n '1,120p' src/renderer/src/components/native-chat/NativeChatView.tsx
printf '%s\n' '--- file-link context hook and resolver ---'
sed -n '1,230p' src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts
sed -n '1,230p' src/renderer/src/components/native-chat/native-chat-file-link.ts
printf '%s\n' '--- structured session visual-related render path ---'
rg -n -F --glob '*.tsx' -- 'NativeChatView' src/renderer/src/components src/renderer/src | head -80
rg -n -F --glob '*.tsx' -- 'NativeChatVisualOwnerProvider' src/renderer/src | head -80Repository: stablyai/orca
Length of output: 20261
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- owner worktree hook and directory resolution ---'
sed -n '1,180p' src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts
sed -n '1,260p' src/renderer/src/components/native-chat/native-chat-tab-directory.ts
printf '%s\n' '--- visual extension callers and nearby keys ---'
rg -n -F --glob '*.tsx' -- 'useNativeChatVisualMarkdownExtension' src/renderer/src
rg -n -F --glob '*.tsx' -- 'key={props.sessionId}' src/renderer/src/components/native-chat
rg -n -F --glob '*.tsx' -- 'key=' src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
printf '%s\n' '--- connection lookup declaration/usages ---'
rg -n -F --glob '*.ts' -- 'function getConnectionIdFromState' src/renderer/src
rg -n -F --glob '*.ts' -- 'getConnectionIdFromState' src/renderer/src/lib src/renderer/src/store src/renderer/src/components/native-chatRepository: stablyai/orca
Length of output: 13824
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- markdown consumer ---'
sed -n '1,180p' src/renderer/src/components/native-chat/NativeChatMarkdown.tsx
printf '%s\n' '--- structured session render blocks ---'
sed -n '250,390p' src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
printf '%s\n' '--- connection resolver ---'
sed -n '1,180p' src/renderer/src/lib/connection-owner-resolution.ts
printf '%s\n' '--- visual file identity and reader ---'
sed -n '1,300p' src/renderer/src/components/native-chat/native-chat-visual-file.tsRepository: stablyai/orca
Length of output: 19816
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- owner selector update tests ---'
sed -n '185,250p' src/renderer/src/components/native-chat/native-chat-owner-selectors.test.tsx
printf '%s\n' '--- message list and row declarations ---'
rg -n -F --glob '*.tsx' -- 'function NativeChatMessageList' src/renderer/src/components/native-chat
rg -n -F --glob '*.tsx' -- 'NativeChatMarkdown' src/renderer/src/components/native-chat | head -60
printf '%s\n' '--- numbered decisive source ---'
nl -ba src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx | sed -n '38,72p'
nl -ba src/renderer/src/components/native-chat/use-native-chat-visual-document.ts | sed -n '68,118p'
nl -ba src/renderer/src/components/native-chat/native-chat-visual-file.ts | sed -n '40,100p'
nl -ba src/renderer/src/components/native-chat/NativeChatView.tsx | sed -n '13,30p'
nl -ba src/renderer/src/components/native-chat/native-chat-visual-owner.tsx | sed -n '25,52p'Repository: stablyai/orca
Length of output: 16346
Reset the visual document when the resolved file changes.
NativeChatInlineVisual can stay mounted while its folder or connection changes. An unreachable read then preserves the previous ready document, so the frame can display HTML from the prior folder or host. Reset the document to loading for a new file identity. Retain the old document only when rereading the same file.
Suggested fix
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
...
const [state, setState] = useState<NativeChatVisualDocumentState>(() => {
const cached = file ? peekNativeChatVisualFile(file) : null
return cached ? { status: 'ready', document: cached } : { status: 'loading' }
})
+ const previousFile = useRef(file)
+ const fileChanged = previousFile.current !== file
+ if (fileChanged) {
+ previousFile.current = file
+ }
const changes = useVisualFileChangeCount(file, enabled)
...
- return outcome.kind === 'unreachable' && shown.status === 'ready'
+ return !fileChanged && outcome.kind === 'unreachable' && shown.status === 'ready'
? shown
: { status: 'unavailable' }
...
- return state
+ return fileChanged ? { status: 'loading' } : state…l-workspace-files # Conflicts: # mobile/src/session/mobile-structured-agent-session-contract.ts # mobile/src/session/use-mobile-structured-agent-session.ts
…l-workspace-files # Conflicts: # src/main/codex/codex-structured-real-cli-visuals.test.ts # src/main/codex/codex-structured-visuals.test.ts # src/main/native-chat/native-chat-inline-visuals-setting.test.ts
…l-workspace-files # Conflicts: # mobile/src/session/mobile-native-chat-message-plain-text.test.ts
Main's new copy tests used the file= visual lines, which this branch no longer treats as visuals.
ELI5
When an agent draws a chart or report in a native chat, Orca shows it inline. Today that HTML page is saved in a hidden per-chat folder inside Orca's own data, so it isn't a normal file: you can't edit it, share it, or open it on SSH and paired hosts the way you open every other file. This PR saves those pages as ordinary
.htmlfiles in the chat's working folder and shows them the same way a file link in the chat works: read from the workspace, opened like any file. Edit the page and the inline view catches up.What Changed
The problem (STA-10097):
agentSession.readVisualcall that looked the folder up from the chat session) and a separate read-only "chat visual" tab (Open a chat visual in its own tab instead of the sidebar #26728). Neither could edit or share, and both were special cases next to the normal file features.Before:
After:
.htmlfile in its working folder, for examplereports/latency-by-region.html. It shows up in the file tree like anything else the agent writes.Mechanism:
::orca-visual{path="..." title="..."}. The path is relative to the chat's folder and must be a plain relative.html/.htmpath: no.., no absolute paths, no drive letters. The oldfile="<bare name>"form is no longer recognised and shows as text.readRuntimeFilePreview). A paired host answers through its normalfiles.readPreview. If the host can't be pinned down, the visual shows "unavailable" and nothing is read. A small cache paints a page instantly when a reply remounts; every load still reads the file.chat-html, next to the existingchat-imageone and using the same rule: any local file whose real target (after symlinks) is.html/.htm, never a device or a network share. Sopage.html -> ~/.ssh/id_rsais refused. Files inside a project still get the ordinary project check first.files.readwith the chat's workspace and the relative path, the same call the phone's file viewer makes. Truncated (over 512 KiB) or binary reads show "unavailable".ORCA_CHAT_VISUALS_DIR, adds an extra writable root for Claude or Codex, or sweeps old folders on startup.agentSession.readVisual(method, params, host reader and its folder-containment code, phone allowlist entry), the read client's revision protocol, the per-chat folder and its startup sweep and workspace check, the chat-visual tab kind, and the phone's full-screen sheet.Why
The decision (STA-10097) was to make chat HTML a regular file, unified with everything else Orca does with files. Reading, opening and sharing it through the same path as a chat file link means it inherits that path's host routing and permissions instead of keeping a second copy of them.
Alternatives considered:
chat-htmllimits the new allowance to HTML files, the same waychat-imagelimits images.Difference from the common pattern:
Linked Issue
STA-10097 (Linear). STA-10098 (Share right from the inline preview) follows in a separate PR on top of this one and #27608.
Visual Proof
Live check on the M4Air at head
745b0011590(all three PRs together): a hidden, isolated dev build with real Claude, plus a second isolated Orca paired to it as a remote host.Claude wrote
fruit-chart.htmlinto the repo root (it appears in the file tree, uncommitted), and the reply shows it inline:"Open in new tab" opens it as an ordinary local browser tab on the
file://path:With the file open in the editor beside the chat, a save showed up in the inline view 362 ms and 464 ms later. No new file watch was added; the inline view listens to the workspace's existing file-change events:
Asking Claude to recolor it rewrote the same file (no second
.html). After the turn, the earlier reply shows the new version too:A floating chat (default folder: home) wrote
pets-bar-chart.htmlthere, and it renders on desktop:After that file was deleted, the floating chat shows "Visualization unavailable" and the workspace chat's visuals keep rendering:
The same flow on a workspace of a paired remote host: Claude wrote the file on that host, and it renders inline:
Not checked live: the phone (the emulator skill isn't installed on the QA machine).
Testing
Unit tests:
file=line staying text;chat-htmlaccess: reads a local HTML file outside every project and in a home-folder chat; refuses relative paths, devices, network shares, missing pages, other types,page.htmllinked to a text file or a device, and any write;chat-html, placeholder and no read before a floating chat's folder is pinned, a floating chat page in the home folder, unavailable with no read when the host is ambiguous, a recheck re-reads and keeps the frame when the page didn't change, an earlier reply shows the rewritten page, unreachable keeps the frame while missing drops it, file-change events filtered by path and host (overflow included), binary and oversize pages;files.read, sends nothing for a floating chat, truncated / binary / refused / older host read as unavailable, transport failures keep the cached page, one request for concurrent reads;Typecheck: node, web and mobile typechecks pass, and so does the mobile test-typecheck ratchet.
orca-ci-checkspasses except two known local-only reds (a bun script lookup and mobile import-cycle warnings that only appear with mobile dependencies installed).I manually tested these changes locally (desktop rows live on head
745b0011590, incl. a paired host; phone not run)Automated tests added/updated, or explained why not below
Review
Agent skill upstream boundary
Notes
path=lines render. An older desktop or phone shows a newpath=line as text, and this build shows an oldfile=line as text. Old per-chat folders stay on disk; nothing migrates them.agentSession.readVisualis removed; an older phone that still calls it gets the "not allowed" answer it already treats as "unavailable".files.readcan only name the current floating folder, which may not be the folder the chat was pinned to, so reading it could show the wrong file. Desktop is unaffected.ORCA_CHAT_VISUALS_DIRvalue, Claude's--add-dirfor the old folder, and that key in the inherited-env removal list./in the reply line and are checked for Windows-reserved names on every platform;chat-htmlkeepschat-image's Windows device and share refusals.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)