From ad0bf9a3932afaebd38f7cd26f44d1bf9aae0be9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:35:48 -0700 Subject: [PATCH] fix(native-chat): open inline images directly in preview dialog (#31620) * fix(native-chat): open inline images directly in preview dialog * fix(i18n): add inline image preview translation keys --- ...native-chat-inline-image-preview-design.md | 118 ++++++ .../components/editor/PanZoomImageDialog.tsx | 15 +- .../NativeChatMarkdown.image-preview.test.tsx | 344 ++++++++++++++++++ .../native-chat/NativeChatMarkdown.tsx | 65 +++- .../native-chat/NativeChatMarkdownImage.tsx | 220 +++++++++++ .../components/native-chat/NativeChatView.tsx | 37 +- .../native-chat-markdown-image-owner.tsx | 35 ++ .../sidebar/MarkdownImageLightbox.tsx | 70 +++- src/renderer/src/i18n/locales/en.json | 7 +- .../native-chat-inline-image-preview.spec.ts | 160 ++++++++ 10 files changed, 1032 insertions(+), 39 deletions(-) create mode 100644 docs/native-chat-inline-image-preview-design.md create mode 100644 src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMarkdownImage.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-markdown-image-owner.tsx create mode 100644 tests/e2e/native-chat-inline-image-preview.spec.ts diff --git a/docs/native-chat-inline-image-preview-design.md b/docs/native-chat-inline-image-preview-design.md new file mode 100644 index 00000000000..1e251735336 --- /dev/null +++ b/docs/native-chat-inline-image-preview-design.md @@ -0,0 +1,118 @@ +# Native chat inline image preview + +Status: implemented and independently reviewed. Focused behavior tests and hidden Electron visual validation pass. + +## Decision + +An ordinary click, tap, or keyboard activation on an image displayed in native chat opens the existing pan-and-zoom image dialog immediately. This includes Markdown images in paragraphs, lists, blockquotes, and GFM table cells. The image itself is the preview affordance; choosing a file destination becomes a secondary action inside the dialog. + +Text links, linked file paths, document chips, terminal links, and the user's link-click setting keep their existing behavior. A text link such as `[screenshot](image.png)` remains a link. The new behavior applies to rendered images, rather than every destination with an image extension. Composer and transcript attachment thumbnails already open the correct dialog and retain that behavior. + +## Current mechanism and reuse + +| Existing owner | Relevant behavior | Use in this change | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `components/sidebar/comment-markdown-element-renderers.tsx` | Its document `img` renderer uses `handleMarkdownImageClick` when `onLinkClick` exists; that forwards the image `src` to ordinary link routing. Tables reuse this same renderer. | Give native chat an explicit image-preview policy; leave ordinary anchors on the current link path. | +| `components/native-chat/use-native-chat-link-actions.ts` and `use-native-chat-file-link-click.ts` | A plain file click usually creates `LinkActionRequest`, resulting in the popup the user currently sees. Modifier clicks use existing direct-open routing. | Keep these as the authority for non-image and modified link actions. | +| `components/sidebar/MarkdownImageLightbox.tsx` | `ExpandableMarkdownImage` already supplies an image trigger and opens `PanZoomImageDialog`. | Extend this component for the needed source, gesture, and optional dialog-action inputs. Do not create another image viewer. | +| `components/editor/PanZoomImageDialog.tsx` | Owns fitting, zoom controls, panning, loading/error fallback, modal focus, Escape, and focus restoration. | Add one optional toolbar-content slot, beside the existing controls. Existing callers require no changes. | +| `components/native-chat/native-chat-image-runtime-context.ts` | Resolves a chat tab's execution owner, directory, paired host, and SSH expectation. Unknown ownership returns `null`. | Reuse the context already calculated by `NativeChatStructuredSession`; never infer ownership from the active workspace. | +| `components/editor/useLocalImageSrc.ts` and `lib/local-file-access.ts` | Read local/remote image bytes with owner-sensitive caching, object URL leases, and the `chat-image` access policy. | Reuse for local Markdown image previews. Keep the source pinned while its dialog is open. | +| `components/native-chat/native-chat-link-target-actions.ts` | Builds file and web actions using the existing owner, platform, group, and capability rules. | Supply dialog menu actions from these builders rather than reproducing Open/Reveal/Download logic. | +| `components/native-chat/native-chat-image-copy.ts` | Adds full-size Copy image support and prevents the chat context menu dismissing an image dialog. | Apply the existing attributes and outside-interaction policy to the new preview. | + +The cause is a rendering-policy coupling: attaching link routing replaces document image expansion with link handling. It is not a table parser problem or a missing dialog implementation. + +## Interaction contract + +| Gesture or state | Result | +| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Primary click / tap on displayed image | Open the dialog synchronously. Prevent enclosing chat rows or linked-image wrappers from navigating. No file-actions popup appears first. | +| Enter or Space on focused image trigger | Open the same dialog. Prevent Space from scrolling the transcript. | +| Platform direct-open modifier on an image that previously routed through `onLinkClick` | Preserve existing direct-open behavior through that callback: Cmd on macOS, Ctrl on Windows/Linux. Shift keeps the current system-default routing. Reuse platform gesture helpers. | +| Other modified clicks | Delegate to the prior link handler where the source was already link-routed; do not invent another shortcut or coerce them into preview. | +| Middle click / right click | Preserve existing auxiliary-link and context-menu behavior; they do not open the image dialog. Native-chat Copy image remains available where supported. | +| Link-click setting is `actions`, `open`, or `none` | The image's ordinary activation opens preview in every case. The setting still governs links. | +| Image source is missing | Render the existing alt/placeholder text; do not create an empty interactive control. | +| Source read or image decoding is pending | The dialog is already open with a usable Close control. Retain the existing viewer's fallback until the source is ready; the shared source hook currently represents both pending and unavailable sources as undefined. A separate delayed-loading state is outside this change. | +| Image unavailable, permission denied, offline, or invalid image data | Keep the dialog open with localized “Preview unavailable” feedback. Do not silently open a different app or fall back to another host. Offer eligible file actions. | + +Use `View image: ` as the trigger's accessible name, and use the alt text, filename, or localized “Image” as the dialog title. Do not add duplicate labels or a visible instruction under each thumbnail. Existing thumbnail sizing, table sizing, scroll behavior, and source dimensions stay intact. A zoom cursor and existing focus-visible ring communicate the affordance. + +Linked images require explicit coverage. Their ordinary activation previews the image source, matching the current image handler's source choice. Preserve the enclosing link's existing auxiliary/modifier handling where applicable. Render a single interactive trigger: avoid a button nested inside an anchor. An image-only anchor renderer can provide the preview trigger for its image, while ordinary anchors continue through the existing renderer. A click must produce only one outcome. + +## Secondary actions inside the dialog + +Add a quiet **File actions** ghost control to the image dialog toolbar for a resolved file source. Use the existing shadcn `DropdownMenu` with `modal={false}`, following existing menus inside dialogs. The preview remains the modal owner; a second modal menu must not block its Close control. Retain standard menu keyboard navigation and focus behavior. Keep Close at the end of the toolbar. The control may have a visible text label; an icon-only form requires the existing Tooltip primitive and an accessible name. + +Build its rows from `nativeChatFileLinkActions` / `nativeChatLinkActions` for the original source and chat owner. File-menu eligibility requires a resolved image runtime owner as well as a file-link context; a non-null link context alone does not prove a remote image owner is available. Re-evaluate eligibility on owner changes, and do not retain runnable stale-owner rows. Preserve eligible Open file, Open in this group, Open with default app or Download & open with default app, and Reveal actions. Never use a renderer's `blob:` URL as the file destination. For an HTTP(S) image, the corresponding optional control is **Link actions** and uses existing web destinations. Data/blob-only images have no file menu. No new settings row is needed. + +Opening or dismissing the dropdown leaves the image dialog open. Escape dismisses the menu first, then dismisses the dialog on the next press; a click on Close closes the dialog immediately even with a menu open. Selecting an action closes the menu and preview, then invokes the existing action once after the close has committed, so a newly opened editor is visible. Suppress menu focus restoration when handing off to an action or closing the preview; it must not focus an unmounted toolbar or pull focus back from the destination. A normal menu dismissal restores focus to its trigger. Closing the dialog restores focus to the original image when still mounted, using the viewer's existing active-modal guard. Action failure keeps its current toast/error contract. Keep menu content within the viewer's React subtree so Radix nested-layer handling applies; test the portaled menu rather than broadly disabling outside dismissal or the dialog focus trap. + +The slot in `PanZoomImageDialog` accepts content only; the generic viewer should not import chat stores, file ownership, or action builders. Existing image previews outside chat continue to render their current toolbar. + +## Rendering and ownership design + +Use the existing `CommentMarkdownExtension.components` boundary for the document variant used by transcript prose. Compact cards retain their existing trust and rendering behavior in this change. `NativeChatMarkdown` composes a stable native-chat `img` renderer with `useNativeChatVisualMarkdownExtension`, preserving its remark plugins, sanitize attributes, and `div` renderer. There is no need to add a second shared `renderImage` API or change ordinary shared document defaults. Leave normal anchor rendering and `onLinkClick` intact; only add a narrowly scoped linked-image adaptation if needed to avoid nested interactive elements. The custom image renderer must preserve missing-source handling and GitHub/Jira special attachment adapters rather than bypassing those existing branches. It must not override the compact variant's image trust checks. + +The native-chat renderer adapts sources and action callbacks into the extended `ExpandableMarkdownImage`, reusing document image styling and the one `PanZoomImageDialog`. Add a small tab-aware provider at `NativeChatView` for structured and bridged chats, using existing runtime and file-link context hooks once per surface. Expose the contexts and visibility/session identity to descendants; do not resolve ownership independently for every image or thread fields through prose/tool components. Prefer already-resolved contexts where the chosen boundary provides them. Keep the extension object and image component type stable while unrelated chat state or streamed text changes. + +Source handling follows existing preview contracts: + +1. HTTP(S), trusted data, and blob sources retain their current URL handling and sanitation. Do not broaden the URL protocol allowlist, arbitrary remote fetching in compact cards, or private-attachment authentication handling. Existing GitHub/Jira attachment adapters continue to own their special sources. +2. Relative, absolute, and allowed file-URI sources resolve against the chat's own directory using existing path/URI resolvers. Preserve Windows drive paths, escaped spaces, and separators. Reuse `resolveNativeChatFileLink` where its literal-path behavior fits; do not treat an image filename suffix as a source-code line locator. +3. Read a resolved file through `useLocalImageSrc` with the existing owner runtime context and `chatImageAccess()`. The latter preserves real image-type checks and local share restrictions; an agent-authored path is not a user-named arbitrary file grant. +4. Explicitly distinguish unresolved owner (`null`, no read) from legacy local context (`undefined`). A paired/SSH source must never fall back to the client's filesystem while ownership is loading or disconnected. +5. Let `useLocalImageSrc` own its existing cache lease and stale-read cancellation. Reuse `observeTranscriptVisibility` only if the new file thumbnails need the same read gating as transcript attachments; then keep the existing `near || open` lease rule. Do not introduce a second cache, resource manager, or custom lease subsystem. Async results for a previous owner/source cannot replace the current preview. + +Keep the image component identity stable during streaming so appending unrelated prose does not reset its dialog. Start with component-local preview state, matching `ExpandableMarkdownImage` and existing transcript attachment previews. Conversation replacement or chat deactivation must not leave a stale portal visible; use the existing conversation/visibility boundary. Ordinary virtualized row unmount may close its preview, matching existing attachment behavior. Persisting a viewer across row eviction is outside this bug fix; do not add a transcript preview manager or global store. + +## Local, remote, mobile, and workspace parity + +- **Desktop platforms:** reuse path utilities, modifier helpers, and action builders for macOS/Linux/Windows. No new process, shell, native module, or IPC channel is required. Test both theme modes and large chat text settings. +- **Paired and SSH:** all file bytes and open actions retain their owner route and SSH generation expectations. Keep the dialog responsive while reads are delayed. Old servers continue using existing file-preview APIs; unsupported or failed reads show the existing unavailable state. This design adds no required wire field, migration, or coordinated client/server upgrade. +- **Folder and floating workspaces:** use existing native-chat directory/owner resolution, which already includes folder workspace records and local floating workspaces. No git-root, branch, or repository prerequisite is introduced. A chat launched in a subdirectory resolves relative images against that chat's directory. +- **Web renderer:** use the same image preview path and existing capability-dependent actions. Keep the existing policy that web clients do not offer the desktop Copy image operation. Do not invoke client-local filesystem APIs as a remote fallback. +- **Native mobile:** it uses `MobileMarkdown`, not `CommentMarkdown`. `MobileNativeChatProse` attachment presses already call `MobileNativeChatRowOverlaysContext.openImage`, which presents `MobileNativeChatImageViewer`. Those displayed thumbnails already have the requested preview behavior. Inline Markdown currently lacks that `renderImage` wiring and generally remains text links rather than displayed thumbnails. Keep this implementation focused on desktop/web rendered inline images; native-mobile inline-image rendering is a separate parity consideration. A future small extension can use `MobileMarkdown.renderImage` and the same attachment pressable/viewer, including table cells, while preserving long press. Keep unavailable host paths as placeholders and do not introduce a mobile host-file transport here. + +## Implementation sequence + +1. Extend the existing lightbox trigger and generic dialog toolbar slot. Keep current defaults for callers outside chat. +2. Compose the stable native-chat image renderer into the existing Markdown surface extension. Retain visual rendering, ordinary link routing, source sanitation, and attachment adapters. +3. Reuse existing image owner context, read/cache primitives, copy policy, and file/web action builders. Ensure a table image has the exact same preview path as a paragraph image. +4. Confirm native mobile's existing displayed attachment previews remain unaffected; record inline Markdown rendering as a parity consideration without expanding this change into a mobile renderer rewrite. +5. Verify the actual renderer and gesture boundaries below, then update this document with results and any remaining platform limitations. + +## Concrete file plan + +- `components/native-chat/NativeChatMarkdown.tsx`: compose the document image override into the existing visual extension with stable identity. +- `components/native-chat/NativeChatView.tsx` and one focused native-chat image module: provide tab/session ownership and implement source adaptation plus dialog actions using existing hooks and action builders. +- `components/sidebar/MarkdownImageLightbox.tsx`: extend the existing trigger with optional source/fallback, gesture, and dialog-action inputs while preserving other callers. +- `components/editor/PanZoomImageDialog.tsx`: add the optional toolbar slot beside existing controls, without importing chat state. +- Existing Markdown/image interaction test files: add focused composed behavior coverage; leave mobile and server code unchanged. + +## Validation + +Add behavior coverage at the existing Markdown/native-chat interaction tests, using the production Markdown renderer and existing action hooks rather than mocks that implement the routing being asserted. A paragraph image and an image in a real GFM table must open a named dialog in one ordinary click, show the intended source, and create no destination popup or editor tab. Cover keyboard activation and focus return. Keep controls for an ordinary file text link, web link, and non-image chip under the same settings to prove their previous behavior remains. + +Exercise the platform modifier matrix through existing helpers, including Shift/direct open, auxiliary click, linked-image wrappers, and right click. Cover missing/failed sources and the composed remote-owner boundary: an unresolved owner makes no local read and supplies no unsafe file actions; an owner change cannot show stale image bytes. Where a file read is pending after activation, the modal appears before the read completes and uses the existing unavailable fallback contract. Assert the read uses the expected remote owner and does not read the client-local path. Reuse existing source-reader and owner-resolution tests for cache lifetime, folder, floating, paired, and SSH contexts; add only missing composed boundary coverage, rather than duplicating their inventories. + +For the dialog menu, exercise real dropdown selection, Escape order, immediate Close, action destination/ownership, and focus restoration. Run existing image-copy/context-menu tests to catch regressions. Native mobile is unchanged; do not claim its inline Markdown parity is delivered or add tests for an unimplemented mobile renderer. + +Run relevant tests with `ORCA_BACKGROUND_LAUNCH=1`, `pnpm tc:web` for renderer changes, applicable mobile checks if changed, formatting, and `pnpm run check:code-quality:changed`. Follow `docs/reference/test-antipatterns.md`; do not add literal-source or call-only tests for the new prop plumbing. + +Rendered Orca checks require the repository's Electron skill and Playwright CDP on a rebuilt, hidden app with `ORCA_BACKGROUND_LAUNCH=1`. Capture a table-image click opening the actual pan/zoom dialog, its secondary action menu, and the unchanged ordinary-file link popup. Inspect light/dark, narrow viewport, keyboard focus, zoom/pan/fit, and a delayed remote read. Assert every native window stays hidden and unfocused; do not use Computer Use or activation calls. Use the repository's existing hidden Electron and Playwright CDP fixture with an isolated test profile if the named Electron skill is unavailable; it satisfies the same background, focus, and screenshot controls. Record the unavailable skill as a workflow limitation without introducing another approval step. Record platforms and remote routes actually exercised; source inspection is not a live SSH, Windows, Linux, or mobile result. + +## Review and validation record + +Independent implementation review found and verified fixes for mixed caption/image anchors, preservation of linked-image context-menu targets, relative image and action paths using the runtime owner's directory/environment, the visual-extension streaming guard, and toolbar/title spacing. No blocking findings remain for the scoped inline image workflow. + +`ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatMarkdown.image-preview.test.tsx` passed all 16 tests. Coverage includes table images under all three link-click settings, ordinary file-link behavior, keyboard focus and Escape, streaming continuity, mixed image/caption links, platform modifier routing, owner-qualified relative paths and file actions, failed decoding, nested-menu dismissal/action handoff, Close while the menu is open, pending remote reads followed by owner loss, chat visibility, compact trust checks, and GitHub attachment fallback. + +Remote reads are simulated at the runtime reader boundary. This record does not claim live SSH, native Windows/Linux, or native-mobile validation. The shared unavailable fallback remains visible while an image source is pending; this change does not add a separate loading state. + +The seven focused suites passed 66 tests; the final image/visual-extension rerun passed 23 tests. Renderer typechecking and the changed-code quality gate passed. GPT-6.1 Sol high agents authored and reviewed the design, implemented the change, and independently reviewed the code and fixes. + +GPT-5.6 Terra medium verified the rebuilt app through hidden Electron and Playwright CDP on macOS. The portable GFM table-image E2E passed and verified direct preview activation, unchanged ordinary-file popup behavior, image actions, zoom/fit, Enter/Space, Escape ordering, Close/focus restoration, and hidden/unfocused native windows. Light/dark screenshots and a 480px-wide view were inspected; screenshot evidence is attached to the PR rather than committed. + +An additional run of the existing image-copy E2E stopped before image interaction: macOS returned `posix_openpt` errno 6 because it could not allocate another PTY, leaving its composer disabled. The new inline-preview E2E passed in the same run. No user terminals were stopped to work around that environment limit. diff --git a/src/renderer/src/components/editor/PanZoomImageDialog.tsx b/src/renderer/src/components/editor/PanZoomImageDialog.tsx index c337b6f6a78..46d03e2647a 100644 --- a/src/renderer/src/components/editor/PanZoomImageDialog.tsx +++ b/src/renderer/src/components/editor/PanZoomImageDialog.tsx @@ -39,6 +39,8 @@ type PanZoomImageDialogProps = Omit< background?: string /** Shown instead of the image while it has no source or after it fails to load. */ fallback?: React.ReactNode + toolbarActions?: React.ReactNode + restoreFocus?: () => boolean } function ToolbarButton({ @@ -70,6 +72,8 @@ export function PanZoomImageDialog({ showTitle = false, background, fallback, + toolbarActions, + restoreFocus, ...contentProps }: PanZoomImageDialogProps): React.JSX.Element { const openerRef = useRef(null) @@ -96,6 +100,7 @@ export function PanZoomImageDialog({ if ( opener instanceof HTMLElement && opener.isConnected && + (restoreFocus?.() ?? true) && useAppStore.getState().activeModal === 'none' ) { opener.focus({ preventScroll: true }) @@ -111,6 +116,7 @@ export function PanZoomImageDialog({ showTitle={showTitle} background={background} fallback={fallback} + toolbarActions={toolbarActions} onClose={() => onOpenChange(false)} /> @@ -125,10 +131,11 @@ function PanZoomImageCanvas({ showTitle, background, fallback, + toolbarActions, onClose }: Pick< PanZoomImageDialogProps, - 'src' | 'size' | 'title' | 'showTitle' | 'background' | 'fallback' + 'src' | 'size' | 'title' | 'showTitle' | 'background' | 'fallback' | 'toolbarActions' > & { onClose: () => void }): React.JSX.Element { // Why: state, not a ref — the dialog portal mounts the canvas after this component's first effects. const [canvas, setCanvas] = useState(null) @@ -310,7 +317,10 @@ function PanZoomImageCanvas({ {showTitle ? ( {title} @@ -339,6 +349,7 @@ function PanZoomImageCanvas({ > + {toolbarActions} ({ + readRuntimeFilePreview: vi.fn(), + openDetectedFilePath: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-read-client', () => ({ + readRuntimeFilePreview: mocks.readRuntimeFilePreview +})) +vi.mock('@/components/terminal-pane/terminal-file-open-routing', async (importOriginal) => ({ + ...(await importOriginal()), + openDetectedFilePath: mocks.openDetectedFilePath +})) + +const localOwner = { + fileLinkContext: { + worktreeId: 'wt-1', + worktreePath: '/repo/worktree', + runtimeEnvironmentId: null, + sourceTabId: 'tab-1' + }, + runtimeContext: { + target: { kind: 'local' as const }, + worktreeId: 'wt-1', + worktreePath: '/repo/worktree', + expectedExecutionHostId: 'local' as const + }, + isVisible: true +} + +type ImageOwner = NonNullable< + ComponentProps['value'] +> + +function Transcript({ + content = '| Screenshot |\n| --- |\n| ![Chart](screenshots/chart.png) |', + owner = localOwner, + growing = false, + variant = 'document' +}: { + content?: string + owner?: ImageOwner + growing?: boolean + variant?: 'document' | 'compact' +}): React.JSX.Element { + const rootRef = useRef(null) + const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( + owner.fileLinkContext, + rootRef, + { sessionId: 'session-1', isVisible: owner.isVisible } + ) + return ( + + +
+ + +
+
+
+ ) +} + +beforeEach(() => { + resetLocalImageSrcStateForTests() + const worktree = worktreeFixture('wt-1', '/repo/worktree', { hostId: 'local' }) + useAppStore.setState({ + ...useAppStore.getInitialState(), + repos: [repoFixture()], + worktreesByRepo: { repo: [worktree] }, + activeWorktreeId: 'wt-1', + activeWorkspaceExecutionHostId: 'local' + }) + let sequence = 0 + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:image-${++sequence}`) + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + mocks.readRuntimeFilePreview.mockResolvedValue({ + content: 'AA==', + isBinary: true, + mimeType: 'image/png' + }) +}) + +afterEach(() => { + cleanup() + resetLocalImageSrcStateForTests() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('native chat Markdown image preview', () => { + it.each(['actions', 'open', 'none'] as const)( + 'opens a table image immediately with %s link behavior and leaves text links unchanged', + async (terminalLinkClickBehavior) => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalLinkClickBehavior } + }) + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' })) + const dialog = screen.getByRole('dialog', { name: 'Chart' }) + expect(screen.queryByText('Open file')).not.toBeInTheDocument() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + expect(await within(dialog).findByRole('img', { name: 'Chart' })).toHaveAttribute( + 'src', + 'blob:image-1' + ) + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })) + fireEvent.click(screen.getByRole('link', { name: 'Source' })) + if (terminalLinkClickBehavior === 'actions') { + expect(screen.getByText('Open file')).toBeInTheDocument() + } else if (terminalLinkClickBehavior === 'open') { + expect(mocks.openDetectedFilePath).toHaveBeenCalledWith( + '/repo/worktree/src/app.ts', + null, + null, + expect.anything() + ) + } else { + expect(screen.queryByText('Open file')).not.toBeInTheDocument() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + } + } + ) + + it('uses one keyboard trigger for a linked image and returns focus after Escape', async () => { + const user = userEvent.setup() + const { container } = render() + const trigger = screen.getByRole('button', { name: 'View image: Chart' }) + expect(container.querySelector('a button')).toBeNull() + await screen.findByRole('img', { name: 'Chart' }) + trigger.focus() + expect(trigger).toHaveFocus() + await user.keyboard(' ') + expect(screen.getByRole('dialog', { name: 'Chart' })).toHaveFocus() + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(trigger).toHaveFocus() + }) + + it('keeps a streamed image dialog mounted when later prose arrives', async () => { + const content = '![Chart](data:image/png;base64,AA==)\n\n' + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' })) + const dialog = screen.getByRole('dialog', { name: 'Chart' }) + rerender() + expect(screen.getByRole('dialog', { name: 'Chart' })).toBe(dialog) + }) + + it('keeps linked-image caption actions separate from preview and preserves the context-menu target', () => { + const { container } = render() + expect(container.querySelector('a button')).toBeNull() + const trigger = screen.getByRole('button', { name: 'View image: Chart' }) + expect(trigger.closest('[data-native-chat-link-href]')).toHaveAttribute( + 'data-native-chat-link-href', + 'details.md' + ) + fireEvent.click(trigger) + expect(screen.getByRole('dialog', { name: 'Chart' })).toBeInTheDocument() + expect(screen.queryByText('Open file')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + fireEvent.click(screen.getByRole('link', { name: 'caption' })) + expect(screen.getByText('/repo/worktree/details.md')).toBeInTheDocument() + }) + + it.each([ + ['Mac', { metaKey: true }], + ['Linux', { ctrlKey: true }], + ['Windows', { ctrlKey: true, shiftKey: true }] + ] as const)('preserves %s modified image actions', (platform, modifiers) => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform) + render() + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' }), modifiers) + expect(screen.queryByRole('dialog')).toBeNull() + expect(mocks.openDetectedFilePath).toHaveBeenCalledExactlyOnceWith( + '/repo/worktree/chart.png', + null, + null, + expect.objectContaining({ openWithSystemDefault: 'shiftKey' in modifiers }) + ) + }) + + it('uses the host-qualified directory for relative image bytes and file actions', async () => { + const user = userEvent.setup() + const owner: ImageOwner = { + ...localOwner, + runtimeContext: { + ...localOwner.runtimeContext, + target: { kind: 'environment', environmentId: 'remote-a' }, + worktreePath: '/remote/chat' + } + } + render() + await user.click(screen.getByRole('button', { name: 'View image: Chart' })) + expect(await within(screen.getByRole('dialog')).findByRole('img')).toHaveAttribute( + 'src', + 'blob:image-1' + ) + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledWith( + expect.objectContaining({ target: { kind: 'environment', environmentId: 'remote-a' } }), + '/remote/chat/chart.png', + { kind: 'chat-image' } + ) + await user.click(screen.getByRole('button', { name: 'File actions' })) + await user.click(screen.getByRole('menuitem', { name: 'Open file' })) + expect(mocks.openDetectedFilePath).toHaveBeenCalledExactlyOnceWith( + '/remote/chat/chart.png', + null, + null, + expect.objectContaining({ worktreePath: '/remote/chat', runtimeEnvironmentId: 'remote-a' }) + ) + }) + + it('retains a closable unavailable preview after image decoding fails', async () => { + render() + await screen.findByRole('img', { name: 'Chart' }) + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' })) + const dialog = screen.getByRole('dialog') + fireEvent.error(within(dialog).getByRole('img')) + expect(within(dialog).getByRole('status')).toHaveTextContent('Preview unavailable') + expect(within(dialog).getByRole('button', { name: 'File actions' })).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('dismisses the nested file menu before the dialog and runs an action after preview closes', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'View image: Chart' })) + const trigger = screen.getByRole('button', { name: 'File actions' }) + await user.click(trigger) + expect(screen.getByRole('menuitem', { name: 'Open file' })).toBeInTheDocument() + await user.keyboard('{Escape}') + expect(screen.queryByRole('menu')).toBeNull() + expect(screen.getByRole('dialog', { name: 'Chart' })).toBeInTheDocument() + expect(trigger).toHaveFocus() + await user.click(trigger) + mocks.openDetectedFilePath.mockImplementationOnce(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) + await user.click(screen.getByRole('menuitem', { name: 'Open file' })) + expect(mocks.openDetectedFilePath).toHaveBeenCalledExactlyOnceWith( + '/repo/worktree/screenshots/chart.png', + null, + null, + expect.objectContaining({ worktreeId: 'wt-1', sourceTabId: 'tab-1' }) + ) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('keeps Close usable while the file menu is open', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'View image: Chart' })) + await user.click(screen.getByRole('button', { name: 'File actions' })) + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('opens while a remote read is pending and clears image bytes and actions on owner loss', async () => { + const pendingRead = Promise.withResolvers<{ + content: string + isBinary: boolean + mimeType: string + }>() + mocks.readRuntimeFilePreview.mockReturnValueOnce(pendingRead.promise) + const owner: ImageOwner = { + ...localOwner, + runtimeContext: { + ...localOwner.runtimeContext, + target: { kind: 'environment', environmentId: 'remote-a' } + }, + fileLinkContext: { ...localOwner.fileLinkContext, runtimeEnvironmentId: 'remote-a' } + } + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' })) + expect(screen.getByRole('dialog', { name: 'Chart' })).toBeInTheDocument() + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledWith( + expect.objectContaining({ target: { kind: 'environment', environmentId: 'remote-a' } }), + '/repo/worktree/screenshots/chart.png', + { kind: 'chat-image' } + ) + rerender() + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' })) + expect(screen.queryByRole('button', { name: 'File actions' })).toBeNull() + await act(async () => + pendingRead.resolve({ content: 'AA==', isBinary: true, mimeType: 'image/png' }) + ) + expect(within(screen.getByRole('dialog')).queryByRole('img')).toBeNull() + expect(mocks.readRuntimeFilePreview).toHaveBeenCalledOnce() + }) + + it('closes the preview when the owning chat becomes hidden', () => { + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'View image: Chart' })) + rerender() + expect(screen.queryByRole('dialog')).toBeNull() + rerender() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('retains compact trust checks and GitHub attachment fallback', () => { + const { rerender } = render( + + ) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('link', { name: 'Chart' })).toBeInTheDocument() + rerender( + + ) + fireEvent.error(screen.getByRole('img', { name: 'Attachment' })) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('link', { name: 'Attachment' })).toHaveAttribute( + 'href', + 'https://github.com/user-attachments/assets/secret' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx index 2466caf9929..8a566b63aeb 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx @@ -1,10 +1,18 @@ -import { useEffect, useState, type ComponentProps } from 'react' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { Children, isValidElement, useEffect, useMemo, useState, type ComponentProps } from 'react' +import CommentMarkdown, { + type CommentMarkdownExtension, + type CommentMarkdownLinkClickHandler +} from '@/components/sidebar/CommentMarkdown' +import { createDocumentCommentMarkdownComponents } from '@/components/sidebar/comment-markdown-element-renderers' import { cn } from '@/lib/utils' import { withoutPendingNativeChatVisualDirectiveTail } from '../../../../shared/native-chat-visual-directive' import { NativeChatMarkdownRowKeyContext } from './native-chat-disclosure-store' import { useNativeChatVisualMarkdownExtension } from './native-chat-visual-markdown-extension' import { useNativeChatFileLinkExists } from './use-native-chat-file-link-existence' +import { + NativeChatMarkdownImage, + NativeChatMarkdownImageLinkContext +} from './NativeChatMarkdownImage' import './native-chat-markdown.css' /** Existing words stay opaque when a reply first starts growing. */ @@ -30,6 +38,42 @@ type NativeChatMarkdownProps = Omit, 'fil rowKey?: string } +function createNativeChatImageExtension( + visualExtension: CommentMarkdownExtension | undefined, + onLinkClick: CommentMarkdownLinkClickHandler | undefined +): CommentMarkdownExtension { + const Anchor = createDocumentCommentMarkdownComponents(onLinkClick).a + return { + remarkPlugins: visualExtension?.remarkPlugins ?? [], + sanitizeAttributes: visualExtension?.sanitizeAttributes ?? {}, + components: { + ...visualExtension?.components, + img: ({ src, alt }) => ( + + ), + a: (props) => { + if ( + props.node?.children.some((child) => child.type === 'element' && child.tagName === 'img') + ) { + return Children.map(props.children, (child) => + isValidElement<{ node?: { tagName: string } }>(child) && + child.props.node?.tagName === 'img' ? ( + + {child} + + ) : typeof Anchor === 'function' ? ( + {child} + ) : ( + {child} + ) + ) + } + return typeof Anchor === 'function' ? : + } + } + } +} + export function NativeChatMarkdown({ className, linkifyFilePaths = false, @@ -38,17 +82,30 @@ export function NativeChatMarkdown({ streaming = false, rowKey, content, + variant, + onLinkClick, ...props }: NativeChatMarkdownProps): React.JSX.Element { const wordFadeArmed = useWordFadeArmed(fadeWords) - const extension = useNativeChatVisualMarkdownExtension(visualMessageId) + const visualExtension = useNativeChatVisualMarkdownExtension(visualMessageId) + const extension = useMemo( + () => + variant === 'document' + ? createNativeChatImageExtension(visualExtension, onLinkClick) + : visualExtension, + [onLinkClick, variant, visualExtension] + ) const fileLinkExists = useNativeChatFileLinkExists(linkifyFilePaths, streaming) return ( (undefined) +const IMAGE_ACCESS = chatImageAccess() + +type MarkdownImageProps = { + src?: string + alt?: string + onLinkClick?: CommentMarkdownLinkClickHandler +} + +export function NativeChatMarkdownImage(props: MarkdownImageProps): React.JSX.Element | null { + const owner = useContext(NativeChatMarkdownImageOwnerContext) + if (!props.src) { + return props.alt ? {props.alt} : null + } + if (isGitHubUserAttachmentUrl(props.src)) { + return + } + const identity = owner?.runtimeContext + ? getLocalImageCacheKey( + props.src, + owner.runtimeContext.connectionId, + owner.runtimeContext, + IMAGE_ACCESS + ) + : 'unresolved' + return +} + +function NativeChatMarkdownImagePreview({ + src, + alt, + onLinkClick +}: MarkdownImageProps): React.JSX.Element { + const owner = useContext(NativeChatMarkdownImageOwnerContext) + const linkedHref = useContext(NativeChatMarkdownImageLinkContext) + const [open, setOpen] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [failed, setFailed] = useState(false) + const handingOff = useRef(false) + const runtimeContext = owner?.runtimeContext ?? null + const fileLinkContext = + runtimeContext?.worktreePath && owner?.fileLinkContext + ? { + ...owner.fileLinkContext, + worktreeId: runtimeContext.worktreeId ?? owner.fileLinkContext.worktreeId, + worktreePath: runtimeContext.worktreePath, + runtimeEnvironmentId: runtimeTargetEnvironmentId(runtimeContext.target) + } + : null + const route = routeNativeChatHref(src) + const imageHref = + route.kind === 'file' ? createNativeChatFileHref(route.pathText, 'literal') : src + const target = resolveNativeChatFileLink(imageHref, fileLinkContext) + const external = Boolean(src && /^(?:https?|data|blob):/i.test(src)) + const attachment = src ? jiraImageReference(src) !== null : false + const loadedSrc = useLocalImageSrc( + !external && !attachment ? target?.absolutePath : undefined, + target?.absolutePath ?? '', + runtimeContext?.connectionId, + runtimeContext, + IMAGE_ACCESS + ) + const displaySrc = failed ? undefined : external || attachment ? src : loadedSrc + const title = + alt?.trim() || + (target ? basename(target.absolutePath) : '') || + translate('auto.components.sidebar.MarkdownImageLightbox.image', 'Image') + const visible = owner?.isVisible ?? true + const [wasVisible, setWasVisible] = useState(visible) + if (wasVisible !== visible) { + setWasVisible(visible) + setOpen(false) + setMenuOpen(false) + } + const actions = + menuOpen && fileLinkContext && imageHref + ? nativeChatLinkActions(imageHref, fileLinkContext) + : null + const canOfferActions = Boolean(fileLinkContext && (target || /^https?:/i.test(src ?? ''))) + const runAction = (action: LinkAction): void => { + handingOff.current = true + flushSync(() => { + setMenuOpen(false) + setOpen(false) + }) + void action.run() + } + const fallback = ( + + {translate('components.native-chat.composer.imageUnavailable', 'Preview unavailable')} + + ) + const copyAttributes = { + 'data-native-chat-copy-image-src': copyableNativeChatImageSrc(displaySrc) + } + + return ( + { + if (nextOpen) { + handingOff.current = false + } + setOpen(nextOpen) + if (!nextOpen) { + setMenuOpen(false) + } + }} + triggerLabel={`${translate('components.native-chat.composer.viewAttachment', 'View image')}: ${title}`} + className="max-h-96 max-w-full rounded-md object-contain outline outline-1 outline-border/70" + onError={() => setFailed(true)} + triggerAttributes={copyAttributes} + onClick={(event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + onLinkClick?.(event, src) + event.preventDefault() + } + }} + onAuxClick={(event) => { + if (event.button === 1 && linkedHref) { + event.stopPropagation() + onLinkClick?.(event, linkedHref) + } + }} + dialogProps={{ + fallback, + restoreFocus: () => !handingOff.current, + onInteractOutside: keepPreviewOpenForChatMenu, + ...copyAttributes, + toolbarActions: canOfferActions ? ( + + + + + + + + + {target + ? translate('components.native-chat.imagePreview.fileActions', 'File actions') + : translate('components.native-chat.imagePreview.linkActions', 'Link actions')} + + + { + if (handingOff.current || !open) { + event.preventDefault() + } + }} + > + {actions + ? [actions.primary, actions.alternate, ...(actions.secondaryActions ?? [])].map( + (action) => + action ? ( + runAction(action)}> + {action.label} + + ) : null + ) + : null} + + + ) : null + }} + /> + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 8d90b650f73..cc7b78304c7 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -6,6 +6,7 @@ import type { NativeChatViewProps } from './native-chat-view-types' import { NativeChatPaneFileDropSurface } from './NativeChatPaneFileDropSurface' import { NativeChatVisualOwnerProvider } from './native-chat-visual-owner' import { NativeChatFileLinkExistenceProvider } from './use-native-chat-file-link-existence' +import { NativeChatMarkdownImageOwnerProvider } from './native-chat-markdown-image-owner' export type { NativeChatViewProps } from './native-chat-view-types' @@ -21,7 +22,9 @@ export default function NativeChatView(props: NativeChatViewProps): React.JSX.El tabId={props.tabId} > - + + + ) : ( @@ -58,20 +61,26 @@ function NativeChatBridgeView({ > {(resolution) => ( - + > + + )} diff --git a/src/renderer/src/components/native-chat/native-chat-markdown-image-owner.tsx b/src/renderer/src/components/native-chat/native-chat-markdown-image-owner.tsx new file mode 100644 index 00000000000..4f77dc3d7cc --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-markdown-image-owner.tsx @@ -0,0 +1,35 @@ +import { createContext, useMemo, type ReactNode } from 'react' +import type { NativeChatFileLinkContext } from './native-chat-file-link' +import { + useNativeChatImageRuntimeContext, + type NativeChatImageRuntimeContext +} from './native-chat-image-runtime-context' +import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' + +export const NativeChatMarkdownImageOwnerContext = createContext<{ + fileLinkContext: NativeChatFileLinkContext | null + runtimeContext: NativeChatImageRuntimeContext + isVisible: boolean +} | null>(null) + +export function NativeChatMarkdownImageOwnerProvider({ + tabId, + isVisible, + children +}: { + tabId: string + isVisible: boolean + children: ReactNode +}): React.JSX.Element { + const fileLinkContext = useNativeChatFileLinkContext(tabId) + const runtimeContext = useNativeChatImageRuntimeContext(tabId) + const value = useMemo( + () => ({ fileLinkContext, runtimeContext, isVisible }), + [fileLinkContext, runtimeContext, isVisible] + ) + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx b/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx index baaaac9706a..be0b72e0d79 100644 --- a/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx +++ b/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx @@ -4,11 +4,21 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' type ExpandableMarkdownImageProps = { - src: string + src: string | undefined alt?: string className?: string triggerClassName?: string onError?: () => void + open?: boolean + onOpenChange?: (open: boolean) => void + triggerLabel?: string + onClick?: React.MouseEventHandler + onAuxClick?: React.MouseEventHandler + triggerAttributes?: Record<`data-${string}`, string | undefined> + dialogProps?: Omit< + React.ComponentProps, + 'open' | 'onOpenChange' | 'src' | 'title' | 'showTitle' + > } /** @@ -20,9 +30,18 @@ export function ExpandableMarkdownImage({ alt, className, triggerClassName, - onError + onError, + open: controlledOpen, + onOpenChange, + triggerLabel, + onClick, + onAuxClick, + triggerAttributes, + dialogProps }: ExpandableMarkdownImageProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) + const [internalOpen, setInternalOpen] = React.useState(false) + const open = controlledOpen ?? internalOpen + const setOpen = onOpenChange ?? setInternalOpen const label = alt?.trim() || translate('auto.components.sidebar.MarkdownImageLightbox.image', 'Image') @@ -30,29 +49,44 @@ export function ExpandableMarkdownImage({ <> - + ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5602da57d26..8c5bbf8f255 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19049,7 +19049,8 @@ "minimal": "Min", "medium": "Med", "xhigh": "XHigh" - } + }, + "imageUnavailable": "Preview unavailable" }, "tool": { "exitCode": "exit {{value0}}", @@ -19488,6 +19489,10 @@ "checking": "Checking this host's chat permissions…", "outdatedHost": "This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.", "unverified": "Orca couldn't confirm the host's permissions for {{value0}}." + }, + "imagePreview": { + "fileActions": "File actions", + "linkActions": "Link actions" } }, "tab": { diff --git a/tests/e2e/native-chat-inline-image-preview.spec.ts b/tests/e2e/native-chat-inline-image-preview.spec.ts new file mode 100644 index 00000000000..9f2633ca28d --- /dev/null +++ b/tests/e2e/native-chat-inline-image-preview.spec.ts @@ -0,0 +1,160 @@ +import { randomUUID } from 'node:crypto' +import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { + claudeTranscriptLines, + enableNativeChatSetting, + seedClaudeProviderSession, + toggleTerminalTabToChatView +} from './helpers/native-chat-transcript-fixture' + +test.use({ orcaAppExtraEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +const REPO_IMAGE = path.resolve( + __dirname, + '..', + '..', + 'docs', + 'site', + 'public', + 'docs', + 'ways-to-run-run-on.png' +) +// Local artwork is only for the manually reviewed visual-evidence run; CI always uses the repo asset. +const sourceImage = process.env.ORCA_E2E_INLINE_IMAGE_VISUAL_SOURCE ?? REPO_IMAGE + +function transcript(sessionId: string, imagePath: string): string { + return claudeTranscriptLines({ + sessionId, + userText: 'Show the inline image preview.', + assistantText: [ + 'The table uses the actual local image source:', + '', + '| Preview | Details |', + '| --- | --- |', + `| ![Inline chat screenshot](${imagePath}) | [Ordinary file link](${imagePath}) |` + ].join('\n') + }) +} + +test('opens a native-chat Markdown table image in the image dialog without a file popup', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + expect(existsSync(sourceImage)).toBe(true) + const imagePath = path.join(testRepoPath, `inline-chat-preview-${randomUUID()}.png`) + const sessionId = `e2e-inline-image-${randomUUID()}` + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-inline-image-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + copyFileSync(sourceImage, imagePath) + + try { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + writeFileSync(transcriptPath, transcript(sessionId, imagePath)) + await enableNativeChatSetting(orcaPage) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalLinkClickBehavior: 'actions' }) + }) + await seedClaudeProviderSession(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + + const chat = orcaPage.locator('[data-native-chat-root="true"]') + const image = chat.getByRole('button', { name: 'View image: Inline chat screenshot' }) + await expect(image).toBeVisible({ timeout: 30_000 }) + const workspaceBoardToast = orcaPage.getByText('Workspace board moved to the bottom bar') + await orcaPage.waitForTimeout(5_000) + await expect(workspaceBoardToast).toHaveCount(0) + const ordinaryFileLink = chat.getByRole('link', { name: 'Ordinary file link' }) + await expect(ordinaryFileLink).toBeVisible() + await chat + .locator('table') + .screenshot({ path: testInfo.outputPath('01-table-image-light.png') }) + + await ordinaryFileLink.click() + const ordinaryFilePopup = orcaPage.locator('[data-link-action-popover]') + await expect(ordinaryFilePopup).toBeVisible() + await expect(ordinaryFilePopup).toContainText(imagePath) + await expect(ordinaryFilePopup.getByRole('button', { name: 'Open file' })).toBeVisible() + await ordinaryFilePopup.screenshot({ + path: testInfo.outputPath('02-ordinary-file-popup-light.png') + }) + await orcaPage.keyboard.press('Escape') + await expect(ordinaryFilePopup).toHaveCount(0) + await expect(ordinaryFileLink).toBeFocused() + + await image.click() + const dialog = orcaPage.getByRole('dialog', { name: 'Inline chat screenshot' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('img', { name: 'Inline chat screenshot' })).toBeVisible() + await expect(ordinaryFilePopup).toHaveCount(0) + await expect(orcaPage.getByRole('menu')).toHaveCount(0) + await dialog.screenshot({ path: testInfo.outputPath('03-image-dialog-light.png') }) + + const zoomIn = dialog.getByRole('button', { name: 'Zoom in' }) + await expect(zoomIn).toBeEnabled() + await zoomIn.click() + await expect(dialog.getByRole('button', { name: 'Fit to screen' })).toBeEnabled() + const actions = dialog.getByRole('button', { name: 'File actions' }) + await expect(actions).toBeVisible() + await actions.click() + await expect(orcaPage.getByRole('menu')).toBeVisible() + await dialog.screenshot({ path: testInfo.outputPath('04-image-dialog-actions-light.png') }) + await orcaPage.keyboard.press('Escape') + await expect(orcaPage.getByRole('menu')).toHaveCount(0) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await expect(dialog).toHaveCount(0) + await expect(image).toBeFocused() + + await image.focus() + await orcaPage.keyboard.press('Enter') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await expect(dialog).toHaveCount(0) + await expect(image).toBeFocused() + + await image.focus() + await orcaPage.keyboard.press('Space') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await expect(dialog).toHaveCount(0) + await expect(image).toBeFocused() + + await orcaPage.evaluate(() => document.documentElement.classList.add('dark')) + await image.click() + await expect(dialog).toBeVisible() + await dialog.screenshot({ path: testInfo.outputPath('05-image-dialog-dark.png') }) + await orcaPage.setViewportSize({ width: 480, height: 560 }) + await expect(dialog).toBeVisible() + await dialog.screenshot({ path: testInfo.outputPath('06-image-dialog-dark-narrow.png') }) + await orcaPage.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + + const windows = await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(windows.length).toBeGreaterThan(0) + expect(windows.every((window) => !window.visible && !window.focused)).toBe(true) + } finally { + rmSync(imagePath, { force: true }) + rmSync(scratchDir, { recursive: true, force: true }) + } +})