diff --git a/AGENTS.md b/AGENTS.md index 67bcd37a538..58698e46a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. +## Electron UI Validation + +Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation. + # Style ## Concise/Brief Non-obviosu comments ONLY * DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW") diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx index 197f5da8269..e4fab4bedb4 100644 --- a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx @@ -1,13 +1,20 @@ // @vitest-environment happy-dom import type { ReactNode } from 'react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import type { TerminalLinkActionRequest } from './terminal-link-action-request' const mocks = vi.hoisted(() => ({ openSettingsPage: vi.fn(), - openSettingsTarget: vi.fn() + openSettingsTarget: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + writeClipboardText: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError, success: mocks.toastSuccess } })) vi.mock('@/store', () => ({ @@ -141,6 +148,102 @@ describe('TerminalLinkActionPopover', () => { ).toBeTruthy() }) + it('copies the resolved URL without closing the popover', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } }) + mocks.writeClipboardText.mockResolvedValue(undefined) + const onClose = vi.fn() + const focusTerminal = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com/hidden-destination', + kind: 'url', + primary: { label: 'Open link', run: vi.fn() }, + focusTerminal + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })) + + await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledWith(request.destination)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()) + expect(mocks.toastSuccess).toHaveBeenCalledWith('Copied link') + expect(onClose).not.toHaveBeenCalled() + expect(focusTerminal).not.toHaveBeenCalled() + }) + + it('ignores duplicate copy clicks while the clipboard write is in flight', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } }) + let resolveWrite: (() => void) | undefined + mocks.writeClipboardText.mockReturnValue( + new Promise((resolve) => { + resolveWrite = resolve + }) + ) + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com/hidden-destination', + kind: 'url', + primary: { label: 'Open link', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + const copyButton = screen.getByRole('button', { name: 'Copy link' }) + fireEvent.click(copyButton) + fireEvent.click(copyButton) + + expect(mocks.writeClipboardText).toHaveBeenCalledOnce() + resolveWrite?.() + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce()) + fireEvent.click(copyButton) + await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledTimes(2)) + }) + + it('shows a failure toast when copying fails', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } }) + mocks.writeClipboardText.mockRejectedValue(new Error('denied')) + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com/hidden-destination', + kind: 'url', + primary: { label: 'Open link', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('Failed to copy link')) + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })) + await waitFor(() => expect(mocks.writeClipboardText).toHaveBeenCalledTimes(2)) + }) + + it('does not offer copy link for non-URL destinations', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: '/tmp/example.ts', + kind: 'file', + primary: { label: 'Open file', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + + expect(screen.queryByRole('button', { name: 'Copy link' })).toBeNull() + }) + it('opens the terminal link setting from the compact settings button', () => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) const onClose = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx index c313edb545a..4eab243241a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx @@ -1,9 +1,11 @@ -import { useMemo } from 'react' -import { ExternalLink, Globe, Settings } from 'lucide-react' +import { useMemo, useRef } from 'react' +import { Check, Copy, ExternalLink, Globe, Settings } from 'lucide-react' +import { toast } from 'sonner' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { Button } from '@/components/ui/button' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback' import { translate } from '@/i18n/i18n' import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { useAppStore } from '@/store' @@ -48,6 +50,9 @@ export function TerminalLinkActionPopover({ }: TerminalLinkActionPopoverProps): React.JSX.Element { const openSettingsPage = useAppStore((state) => state.openSettingsPage) const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const copyableDestination = request?.kind === 'url' ? request.destination : '' + const { copyText, status: copyStatus } = useClipboardTextCopyFeedback(copyableDestination) + const copyInFlightRef = useRef(false) const virtualRef = useMemo( () => ({ current: { @@ -67,6 +72,36 @@ export function TerminalLinkActionPopover({ 'auto.components.terminal.pane.TerminalLinkActionPopover.terminalLinkSettings', 'Terminal link settings' ) + const copyLabel = + copyStatus === 'copied' + ? translate('auto.components.terminal.pane.TerminalLinkActionPopover.copied', 'Copied') + : translate('auto.components.terminal.pane.TerminalLinkActionPopover.copyLink', 'Copy link') + + const copyDestination = async (): Promise => { + if (copyInFlightRef.current) { + return + } + copyInFlightRef.current = true + try { + if (await copyText()) { + toast.success( + translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.copiedLink', + 'Copied link' + ) + ) + return + } + toast.error( + translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.copyLinkFailed', + 'Failed to copy link' + ) + ) + } finally { + copyInFlightRef.current = false + } + } const openTerminalLinkSettings = (): void => { onClose() @@ -104,6 +139,24 @@ export function TerminalLinkActionPopover({ > {request.destination} + {request.kind === 'url' ? ( + + + + + + {copyLabel} + + + ) : null}