From 7eb6c45f96e903d48b32d952be04ac3efa4b1f3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:07:11 -0700 Subject: [PATCH 01/54] refactor(native-chat): open a chat visual like any file (#28437) * fix(files): never write through a dangling symlink A path that doesn't exist yet was authorized by walking up to the nearest existing folder. A dangling symlink fails realpath with ENOENT, so the walk stepped over it and approved the link's own path; the write then followed the link and created its target outside the allowed folders. The walk now continues from the link's target, and returns that real destination. * refactor(native-chat): rename the chat visuals folder to the chat scratch folder The per-chat folder agents write into is the chat's scratch space, not only a visuals folder. Its root becomes /chat-scratch/ (the hash is unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every agent child inherits, Pi included. The visuals skill names the new variable and says the folder is temporary. * test(native-chat): keep the delivery test to the rename The launch parity snapshot already pins that every agent drops the pre-rename variable. * fix(files): refuse any dangling symlink in the missing-path walk Following a dangling link by hand resolved relative targets against the lexical parent, not the link's real folder: through a folder link the walk could loop forever or authorize the wrong file. Refuse instead: realpath can't say where a dangling link leads, but a write would follow it. * feat(runtime-files): the host serves a chat's scratch folder as a file root File commands accept `path:/<32 hex>` and resolve it only when it names this host's own folder, at the file-commands seam so the selector never reaches chat placement. They authorize against the project roots plus the chat-scratch root, refuse files.open/openDiff for the folder, and agentSession.readVisual's ok results carry the file's absolute path so a client can build the selector. * test(runtime-files): name the readVisual helper's parameter type * test(files): drop the loop case that duplicated the dangling-link refusal * test(runtime-files): run the readVisual RPC test, which opens a real SQLite store, under Node * test(runtime-files): put the same-suffix folder inside the authorized root so only the selector refuses it * refactor(native-chat): open a chat visual like any file "Open in new tab" on an inline chat visual now opens the visual's file the way any HTML file of the chat's workspace opens: a file:// browser tab for a local chat (its overflow menu gains "Open source file"), the document preview for a paired-host chat (with its existing source action and Share). Edits save on the chat's host through the exact path: selector the host serves, and a matching editor save re-reads the inline copy freshly. The read-only chat-visual tab is removed. * fix(native-chat): a visual read without a path is a malformed reply A visual's path is always on the current host's readVisual answer, so the inline Open needs no branch for a missing one. Also prune the deleted chat-visual tab action from the layout-writer baseline. * test(native-chat): give visual read replies their path without longer lines * fix(native-chat): address a scratch file by its folder only outside every workspace A workspace that contains the host's state folder keeps its own address, so explorer drops, folder creation and renames inside it stay on one root. Document preview Share reads a runtime grant's own selector and root directly instead of passing the selector off as a workspace id. * test(runtime-files): wait for the stores to close before removing the state folder --- config/renderer-layout-writer-baseline.txt | 1 - .../assemble-chrome/BrowserToolbarMenu.tsx | 4 + .../assemble-chrome/browser-page-toolbar.tsx | 20 ++- .../browser-toolbar-menu-dropdown.tsx | 16 +- .../navigate/browser-file-editor-open.test.ts | 32 ++++ .../navigate/browser-file-editor-open.ts | 30 ++++ .../doc-preview-artifact-share.tsx | 36 +++-- .../src/components/editor/EditorContent.tsx | 11 -- .../editor/closed-editor-tab-disposal.ts | 1 - .../src/components/editor/editor-labels.ts | 4 - .../editor/useEditorPanelFileContentLoader.ts | 8 +- ...tiveChatInlineVisual.open-as-file.test.tsx | 124 +++++++++++++++ .../native-chat/NativeChatInlineVisual.tsx | 22 +-- .../NativeChatMarkdown.visual.test.tsx | 25 +-- .../NativeChatVisualFrame.test.tsx | 17 +-- .../native-chat/NativeChatVisualFrame.tsx | 24 +-- .../native-chat/NativeChatVisualTab.test.tsx | 49 ------ .../native-chat/NativeChatVisualTab.tsx | 43 ------ .../native-chat-visual-read-client.test.ts | 30 ++-- .../native-chat-visual-read-client.ts | 29 +++- .../native-chat/native-chat-visual-tab.ts | 9 -- .../use-native-chat-visual-document.ts | 54 ++++++- .../src/components/tab-bar/EditorFileTab.tsx | 15 +- .../tab-bar/EditorFileTabContextMenu.test.tsx | 4 +- .../components/tab-bar/RecentTabSwitcher.tsx | 5 +- .../tab-bar-item-model.git-status.test.ts | 2 +- .../useEditorExternalWatch-targets.test.ts | 2 +- src/renderer/src/lib/doc-preview-grants.ts | 20 ++- src/renderer/src/lib/file-preview.ts | 8 +- .../src/lib/workspace-session-unified-tabs.ts | 3 +- .../runtime-chat-scratch-file-address.ts | 18 +++ .../src/runtime/runtime-file-read-client.ts | 12 +- .../src/runtime/runtime-file-routing.ts | 12 +- .../mobile-session-surfaces.ts | 3 +- .../slices/editor-chat-visual-tabs.test.ts | 144 ------------------ .../editor/actions/chat-visual-tab-actions.ts | 51 ------- .../editor/actions/close-file-action.ts | 3 +- .../slices/editor/actions/open-file-action.ts | 5 +- .../actions/recently-closed-editor-tabs.ts | 6 +- .../slices/editor/create-editor-slice.ts | 2 - .../editor/tabs/editor-tab-content-type.ts | 7 +- .../slices/editor/types/editor-files-slice.ts | 4 +- .../store/slices/editor/types/open-file.ts | 5 +- src/shared/editor-tab-view-types.ts | 10 -- src/shared/tab-types.ts | 1 - .../old-client-session-tabs-projection.ts | 1 - src/shared/workspace-layout/tab-order.ts | 3 +- 47 files changed, 435 insertions(+), 500 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts create mode 100644 src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatVisualTab.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatVisualTab.tsx delete mode 100644 src/renderer/src/components/native-chat/native-chat-visual-tab.ts create mode 100644 src/renderer/src/runtime/runtime-chat-scratch-file-address.ts delete mode 100644 src/renderer/src/store/slices/editor-chat-visual-tabs.test.ts delete mode 100644 src/renderer/src/store/slices/editor/actions/chat-visual-tab-actions.ts diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index fa2387d0920..225a2c2f0ab 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -60,7 +60,6 @@ 4 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts 2 src/renderer/src/store/slices/browser/create-browser-slice.ts 1 src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts -1 src/renderer/src/store/slices/editor/actions/chat-visual-tab-actions.ts 9 src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts 7 src/renderer/src/store/slices/editor/actions/close-file-action.ts 1 src/renderer/src/store/slices/editor/actions/git-status-actions.ts diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx index c964a3fb6f3..0d3997ab2ed 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx @@ -21,6 +21,8 @@ type BrowserToolbarMenuProps = { viewportPresetId: BrowserViewportPresetId | null onDestroyWebview: () => void isActive: boolean + /** Set only on a local HTML page whose file the editor can open. */ + onOpenSourceFile?: (() => void) | null overflow: BrowserChromeOverflowMenuProps } @@ -31,6 +33,7 @@ export function BrowserToolbarMenu({ viewportPresetId, onDestroyWebview, isActive, + onOpenSourceFile, overflow }: BrowserToolbarMenuProps): React.JSX.Element { const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) @@ -244,6 +247,7 @@ export function BrowserToolbarMenu({ onImportFromFile={() => void handleImportFromFile()} viewportPresetId={viewportPresetId} onApplyViewportPreset={applyViewportPreset} + onOpenSourceFile={onOpenSourceFile} overflow={overflow} /> diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx index b049b6add37..24bf097a699 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx @@ -11,7 +11,14 @@ import { BrowserReloadControl } from './browser-reload-control' import { BrowserToolbarMenu } from './BrowserToolbarMenu' import { SshEgressIndicator } from './browser-egress-indicator' import { destroyPersistentWebview } from '../host-guest/webview-registry' -import { readBrowserHtmlArtifactRequest } from '../describe-page/browser-artifact-upload' +import { + getShareableBrowserArtifactFile, + readBrowserHtmlArtifactRequest +} from '../describe-page/browser-artifact-upload' +import { + canOpenBrowserFileInWorkspaceEditor, + openBrowserFileSource +} from '../navigate/browser-file-editor-open' import type { GrabModeHook } from '../annotate/useGrabMode' import type { BrowserPageConversionOrigin, @@ -114,6 +121,15 @@ export function BrowserPageToolbar({ : browserTourStep === 1 ? ('annotate' as const) : undefined + const localHtmlFilePath = getShareableBrowserArtifactFile(currentBrowserUrl)?.filePath ?? null + // Why the URL check first: every other page skips the workspace-owner lookup on each store write. + const canOpenSourceFile = useAppStore( + (state) => localHtmlFilePath !== null && canOpenBrowserFileInWorkspaceEditor(state, worktreeId) + ) + const openSourceFile = + canOpenSourceFile && localHtmlFilePath !== null + ? () => openBrowserFileSource(worktreeId, localHtmlFilePath) + : null return ( ( destroyPersistentWebview(browserPageId)} isActive={isActive} + onOpenSourceFile={openSourceFile} overflow={overflow} /> )} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx index 63098b9b2b4..ab2b8d25230 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx @@ -1,4 +1,4 @@ -import { Check, Ellipsis, Import, Monitor, Plus, Settings } from 'lucide-react' +import { Check, Ellipsis, FileCode2, Import, Monitor, Plus, Settings } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -49,6 +49,7 @@ type BrowserToolbarMenuDropdownProps = { onImportFromFile: () => void viewportPresetId: BrowserViewportPresetId | null onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void + onOpenSourceFile?: (() => void) | null overflow: BrowserChromeOverflowMenuProps } @@ -66,6 +67,7 @@ export function BrowserToolbarMenuDropdown({ onImportFromFile, viewportPresetId, onApplyViewportPreset, + onOpenSourceFile = null, overflow }: BrowserToolbarMenuDropdownProps): React.JSX.Element { return ( @@ -93,6 +95,18 @@ export function BrowserToolbarMenuDropdown({ tools={overflow.tools} deferUntilClose={overflow.deferUntilClose} /> + {onOpenSourceFile ? ( + <> + + + {translate( + 'auto.components.editor.HtmlDocPreview.openSourceControl', + 'Open source file' + )} + + + + ) : null} {allProfiles.map((profile) => { const isSelectedProfile = profile.id === effectiveProfileId return ( diff --git a/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts new file mode 100644 index 00000000000..f1993e55fef --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from 'vitest' +import type * as ConnectionOwnerModule from '@/lib/connection-owner-resolution' + +// A workspace this computer owns. +vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => ({ + ...(await importOriginal()), + getConnectionIdFromState: () => null, + getConnectionIdForFileFromState: () => null +})) + +import { useAppStore } from '@/store' +import { editorTabFileAccess } from '@/lib/local-file-access' +import { getShareableBrowserArtifactFile } from '../describe-page/browser-artifact-upload' +import { + canOpenBrowserFileInWorkspaceEditor, + openBrowserFileSource +} from './browser-file-editor-open' + +const PATH = '/Users/me/orca/chat-scratch/0123456789abcdef0123456789abcdef/usage.html' + +it("opens a local chat visual's page as an editable source tab, read and saved in place", () => { + const openFile = vi.fn() + useAppStore.setState({ openFile }) + expect(getShareableBrowserArtifactFile(`file://${encodeURI(PATH)}`)?.filePath).toBe(PATH) + expect(canOpenBrowserFileInWorkspaceEditor(useAppStore.getState(), 'wt-1')).toBe(true) + + openBrowserFileSource('wt-1', PATH) + const opened = openFile.mock.calls[0][0] + expect(opened).toMatchObject({ filePath: PATH, relativePath: PATH, mode: 'edit' }) + // What its editor reads and saves with: the user-named file itself, outside any project. + expect(editorTabFileAccess(useAppStore.getState(), opened)).toEqual({ kind: 'user-file' }) +}) diff --git a/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts new file mode 100644 index 00000000000..b4885047e55 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts @@ -0,0 +1,30 @@ +import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' +import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target' +import { getRelativePathInsideRoot } from '@/lib/path' +import type { AppState } from '@/store' +import { useAppStore } from '@/store' +import { openDocPreviewSource } from '../workspace-doc/doc-preview-document-actions' + +/** + * A local browser page's file:// URL names a file on this computer, so its source can open in the + * editor only where this computer owns the workspace; elsewhere the editor would read the + * workspace host's file at that path. + */ +export function canOpenBrowserFileInWorkspaceEditor(state: AppState, worktreeId: string): boolean { + return ( + getConnectionIdFromState(state, worktreeId) === null && + getRuntimeTargetForWorktreeOwner(state, worktreeId)?.kind === 'local' + ) +} + +/** Opens the file behind a local browser page as its editable source, as a document preview does. */ +export function openBrowserFileSource(worktreeId: string, filePath: string): void { + const worktreeRoot = useAppStore.getState().getKnownWorktreeById(worktreeId)?.path ?? null + openDocPreviewSource({ + filePath, + relativePath: getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath, + worktreeId, + runtimeEnvironmentId: null, + externalSshTargetId: null + }) +} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-artifact-share.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-artifact-share.tsx index 5e8ddc7cab7..2969fc9b4dc 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-artifact-share.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-artifact-share.tsx @@ -11,8 +11,10 @@ import { isShareableHtmlPath } from '@/components/editor/editor-artifact-upload' import type { DocPreviewGrantLocation } from '@/lib/doc-preview-grants' import { fileArtifactSourceKey, type FileArtifactOwner } from '@/lib/file-artifact-source-key' import { basename } from '@/lib/path' -import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { readRuntimeFilePreview } from '@/runtime/runtime-file-read-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { relativePathInsideRoot } from '../../../../../shared/cross-platform-path' +import type { RuntimeFilePreviewResult } from '../../../../../shared/runtime-types' /** The host a grant reads from, spelled as the editor's route for that host. */ export function docPreviewGrantFileOwner(owner: DocPreviewGrantOwner): FileArtifactOwner { @@ -27,22 +29,26 @@ export function docPreviewGrantFileOwner(owner: DocPreviewGrantOwner): FileArtif } } -function docPreviewGrantReadContext(owner: DocPreviewGrantOwner): RuntimeFileOperationArgs { +function readDocPreviewGrantFile( + owner: DocPreviewGrantOwner, + filePath: string +): Promise { if (owner.kind === 'ssh') { // Why the connection id is kept: dropping it would read this path on this machine. - return { - target: { kind: 'local' }, - worktreeId: null, - worktreePath: null, - connectionId: owner.connectionId - } + const context = { target: { kind: 'local' as const }, worktreeId: null, worktreePath: null } + return readRuntimeFilePreview({ ...context, connectionId: owner.connectionId }, filePath) } - return { - target: { kind: 'environment', environmentId: owner.environmentId }, - // A selector passes through toRuntimeWorktreeSelector unchanged. - worktreeId: owner.worktreeSelector, - worktreePath: owner.worktreeRoot + // The grant's own selector and root, which may be a chat's scratch folder rather than a workspace. + const relativePath = relativePathInsideRoot(owner.worktreeRoot, filePath) + if (!relativePath) { + throw new Error('outside_grant_root') } + return callRuntimeRpc( + { kind: 'environment', environmentId: owner.environmentId }, + 'files.readPreview', + { worktree: owner.worktreeSelector, relativePath }, + { timeoutMs: 15_000 } + ) } /** Reads the document from the host its grant was minted for, never the one selected now. */ @@ -51,9 +57,9 @@ export async function readDocPreviewArtifactRequest( filePath: string, sourceKey: string ): Promise { - let result: Awaited> + let result: RuntimeFilePreviewResult try { - result = await readRuntimeFilePreview(docPreviewGrantReadContext(location.owner), filePath) + result = await readDocPreviewGrantFile(location.owner, filePath) } catch { throw new ArtifactPublishPreparationError('unreadable') } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 46b7f213750..3f6bb2610c2 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -3,8 +3,6 @@ import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/sl import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../shared/git-status-types' import { CheckRunDetailsPanel } from './CheckRunDetailsPanel' -import { NativeChatVisualTab } from '../native-chat/NativeChatVisualTab' -import { NativeChatVisualUnavailable } from '../native-chat/NativeChatInlineVisual' import { CombinedDiffViewer, MarkdownPreview } from './editor-lazy-views' import { EditorConflictReviewSurface } from './EditorConflictReviewSurface' import { EditorDiffFileSurface } from './EditorDiffFileSurface' @@ -154,15 +152,6 @@ export function EditorContent({ ) } - if (activeFile.mode === 'chat-visual') { - // Why key: a different visual is a different frame, never a reused one. - return activeFile.chatVisual ? ( - - ) : ( - - ) - } - if (activeFile.mode === 'conflict-review') { return ( >() @@ -185,7 +186,12 @@ export function useEditorPanelFileContentLoader({ await reownRestoredFile(route, runtimeEnvironmentId ?? null) return } - if (runtimeEnvironmentId && !route && !restoredOpenFile.terminalArtifactGrantId) { + if ( + runtimeEnvironmentId && + !route && + !restoredOpenFile.terminalArtifactGrantId && + !chatScratchFileAddress(filePath) + ) { throw new Error('External local files are not available for remote workspaces.') } if (!externalSshOwnerId) { diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx new file mode 100644 index 00000000000..fccea2baabc --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type * as ConnectionOwnerModule from '@/lib/connection-owner-resolution' +import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner' + +const callRuntimeRpc = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args) +})) +vi.mock('./NativeChatTranscriptChrome', () => ({ + observeTranscriptVisibility: (_element: Element, onChange: (visible: boolean) => void) => { + onChange(true) + return () => {} + } +})) +// A workspace on the paired host env-1. +vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => ({ + ...(await importOriginal()), + getConnectionIdForFileFromState: () => null +})) +vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => ({ + ...(await importOriginal()), + getRuntimeEnvironmentIdForWorktree: () => 'env-1' +})) + +import { NativeChatInlineVisual } from './NativeChatInlineVisual' +import { readNativeChatVisual } from './native-chat-visual-read-client' +import { TooltipProvider } from '@/components/ui/tooltip' +import { openDocPreviewSource } from '@/components/browser-pane/workspace-doc/doc-preview-document-actions' +import { ORCA_EDITOR_FILE_SAVED_EVENT } from '@/components/editor/editor-autosave' +import { buildDocPreviewGrantRequest } from '@/lib/doc-preview-grants' +import { readRuntimeFileContent } from '@/runtime/runtime-file-read-client' +import { writeRuntimeFile } from '@/runtime/runtime-file-mutation-client' +import { getRemoteFileArgs } from '@/runtime/runtime-file-routing' +import { readDocPreviewArtifactRequest } from '@/components/browser-pane/workspace-doc/doc-preview-artifact-share' +import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +const ROOT = '/home/host/.config/orca/chat-scratch/0123456789abcdef0123456789abcdef' +const PATH = `${ROOT}/usage.html` +const ADDRESS = { worktree: `path:${ROOT}`, relativePath: 'usage.html' } +const target = { kind: 'environment' as const, environmentId: 'env-1' } +const visual = (revision: string, html: string) => ({ ok: true, revision, html, path: PATH }) + +afterEach(cleanup) + +it('previews, edits and saves a paired chat visual on its host, then shows the save inline', async () => { + const visualReads: ((result: unknown) => void)[] = [] + callRuntimeRpc.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.readVisual' + ? new Promise((resolve) => visualReads.push(resolve)) + : Promise.resolve( + method === 'files.read' + ? { content: '

v1

', truncated: false, byteLength: 9 } + : { capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] } + ) + ) + const [createBrowserTab, openFile] = [vi.fn(), vi.fn()] + useAppStore.setState({ + createBrowserTab, + openFile, + getKnownWorktreeById: () => makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/srv/repo' }) + }) + const { container } = render( + + + + ) + await waitFor(() => expect(visualReads).toHaveLength(1)) + act(() => visualReads[0](visual('r1', '

v1

'))) + + // Open: the workspace's document preview, granted through the chat folder's own selector. + fireEvent.click(await screen.findByRole('button', { name: 'Open in new tab' })) + expect(createBrowserTab.mock.calls[0][2].docLocation).toMatchObject({ filePath: PATH }) + const grant = buildDocPreviewGrantRequest(useAppStore.getState(), 'wt-1', PATH) + expect(grant?.owner).toMatchObject({ worktreeSelector: ADDRESS.worktree, worktreeRoot: ROOT }) + await (grant && readDocPreviewArtifactRequest(grant, PATH, 'share-key')) + + // Source: the preview's source tab reads and saves on the chat's host by that selector. + openDocPreviewSource({ + filePath: PATH, + relativePath: PATH, + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-1', + externalSshTargetId: null + }) + const opened = { id: 'f1', isDirty: false, ...openFile.mock.calls[0][0] } + await readRuntimeFileContent({ target, ...opened }) + await writeRuntimeFile({ target, worktreeId: 'wt-1', worktreePath: null }, PATH, '

v2

') + // Share, the editor read and the save use the exact `path:` selector, never `id:path:`. + for (const method of ['files.readPreview', 'files.read', 'files.write']) { + const params = expect.objectContaining(ADDRESS) + expect(callRuntimeRpc).toHaveBeenCalledWith(target, method, params, expect.anything()) + } + // A workspace containing the host's state folder keeps its own address (explorer drops land). + const home = getRemoteFileArgs({ target, worktreeId: 'wt-h', worktreePath: '/home/host' }, PATH) + expect([home?.worktreeSelector, home?.relativePath]).toEqual([ + 'id:wt-h', + PATH.replace('/home/host/', '') + ]) + + // The save lands while a read from before it is in flight: the inline copy reads again after it. + void readNativeChatVisual({ target, sessionId: 'session-1', file: 'usage.html' }) + await waitFor(() => expect(visualReads).toHaveLength(2)) + useAppStore.setState({ openFiles: [opened] }) + act(() => { + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_FILE_SAVED_EVENT, { detail: { fileId: 'f1', content: '' } }) + ) + }) + act(() => visualReads[1]({ ok: true, revision: 'r1', sizeBytes: 9, path: PATH, unchanged: true })) + await waitFor(() => expect(visualReads).toHaveLength(3)) + act(() => visualReads[2](visual('r2', '

v2

'))) + await waitFor(() => + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('

v2

') + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx index d8b6d4e381e..8e51e0d0b42 100644 --- a/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react' import { SquareArrowOutUpRight } from 'lucide-react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' +import { openFileInBrowserTab } from '@/lib/file-preview' import { NATIVE_CHAT_VISUAL_RESERVED_HEIGHT, NativeChatVisualFrame } from './NativeChatVisualFrame' import { observeTranscriptVisibility } from './NativeChatTranscriptChrome' import type { NativeChatVisualOwner } from './native-chat-visual-owner' @@ -17,6 +18,14 @@ export function NativeChatVisualUnavailable(): React.JSX.Element { ) } +/** Opens the visual like any HTML file of the chat's workspace: rendered, its source a click away. */ +function openNativeChatVisualFile(worktreeId: string, path: string): void { + const plan = openFileInBrowserTab({ filePath: path, worktreeId }) + if (plan.status === 'unsupported') { + toast.error(plan.message) + } +} + /** * 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. @@ -33,7 +42,6 @@ export function NativeChatInlineVisual({ const boxRef = useRef(null) const [near, setNear] = useState(false) const [retired, setRetired] = useState(false) - const openChatVisualTab = useAppStore((state) => state.openChatVisualTab) const state = useNativeChatVisualDocument( { target: owner.target, sessionId: owner.sessionId, file }, near @@ -64,7 +72,6 @@ export function NativeChatInlineVisual({ setRetired(true)} /> @@ -75,14 +82,7 @@ export function NativeChatInlineVisual({ variant="outline" size="icon-xs" aria-label={openLabel} - onClick={() => - openChatVisualTab(owner.worktreeId, { - target: owner.target, - sessionId: owner.sessionId, - file, - title - }) - } + onClick={() => openNativeChatVisualFile(owner.worktreeId, state.document.path)} > diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx index 73a233cc7d3..1e71b1963c7 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, render, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -53,7 +53,7 @@ beforeEach(() => { vi.stubGlobal('IntersectionObserver', InViewObserver) clearNativeChatVisualCacheForTests() callRuntimeRpc.mockReset() - callRuntimeRpc.mockResolvedValue({ ok: true, revision: 'r1', sizeBytes: 9, html: '

v

' }) + callRuntimeRpc.mockResolvedValue({ ok: true, revision: 'r1', html: '

v

', path: '/v' }) }) afterEach(() => { @@ -78,27 +78,6 @@ describe('NativeChatMarkdown visuals', () => { } }) - it('opens the visual in its own workspace tab', async () => { - const openChatVisualTab = vi.fn() - const original = useAppStore.getState().openChatVisualTab - useAppStore.setState({ openChatVisualTab }) - try { - const { container } = render( - withOwner() - ) - await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()) - fireEvent.click(screen.getByRole('button', { name: 'Open in new tab' })) - expect(openChatVisualTab).toHaveBeenCalledWith('wt-1', { - target: { kind: 'local' }, - sessionId: 'session-alpha', - file: 'usage.html', - title: 'Usage' - }) - } finally { - useAppStore.setState({ openChatVisualTab: original }) - } - }) - it('holds back an unfinished directive at the end of a streaming reply', () => { const { container } = render( withOwner( diff --git a/src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx b/src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx index 3e082b11f57..124aff8801f 100644 --- a/src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx @@ -23,16 +23,15 @@ vi.mock('@/lib/http-link-routing', () => ({ import { NativeChatVisualFrame } from './NativeChatVisualFrame' import { acquireWebviewsDragPassthrough } from '../browser-pane/host-guest/webview-drag-passthrough' -const visual = { revision: 'r1', html: '

chart

' } +const visual = { revision: 'r1', html: '

chart

', path: '/v' } -function Harness(props: { layout?: 'inline' | 'panel'; onRetired?: () => void }) { +function Harness(props: { onRetired?: () => void }) { const scope = useRef(null) return (
{})} /> @@ -119,7 +118,7 @@ afterEach(() => { describe('NativeChatVisualFrame', () => { it('lets a dragged tab pass over the frame while a tab drag holds pointer passthrough', () => { - const { container } = render() + const { container } = render() const frame = frameOf(container) expect(frame.style.pointerEvents).toBe('') @@ -262,14 +261,4 @@ describe('NativeChatVisualFrame', () => { waitOutTheAnswer() expect(onRetired).not.toHaveBeenCalled() }) - - it('fills a panel instead of fitting its height', () => { - const { container } = render() - const frame = frameOf(container) - post( - { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: channelOf(frame), height: 420 }, - frame.contentWindow - ) - expect(frame.parentElement?.style.height).toBe('') - }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx index e17e27caff1..5e82d417b10 100644 --- a/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +++ b/src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx @@ -1,5 +1,4 @@ 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, @@ -28,22 +27,20 @@ function newChannel(): string { } /** - * 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. On desktop the main process refuses the frame's + * One agent-written visual in an opaque-origin frame that may run scripts and nothing else. It fits + * its height to the page. A theme change restyles the page in place, so interaction state survives + * it. On desktop the main process refuses the frame's * navigation; a later load that nothing answers (the frame gone blank or showing another page) * retires it, as a tripwire rather than a boundary. */ 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 onRetired: () => void @@ -111,11 +108,9 @@ export function NativeChatVisualFrame({ return } if (message.kind === 'size') { - if (layout === 'inline') { - latestReported = message.height - if (!deferred) { - applyHeight() - } + latestReported = message.height + if (!deferred) { + applyHeight() } return } @@ -140,7 +135,7 @@ export function NativeChatVisualFrame({ clearTimeout(deferred) } } - }, [built.channel, layout]) + }, [built.channel]) // A frame rebuilt for a new revision, or unmounted, owes no answer. useEffect( @@ -163,10 +158,7 @@ export function NativeChatVisualFrame({ }, [built.channel, theme]) return ( -
+