From 92f928cf896823996caea526cdd8acccfe3e31be Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:58:07 -0700
Subject: [PATCH] feat(terminal): add copy action to link popover (#13857)
---
AGENTS.md | 4 +
.../TerminalLinkActionPopover.test.tsx | 107 +++++++++++++++++-
.../TerminalLinkActionPopover.tsx | 57 +++++++++-
src/renderer/src/i18n/locales/en.json | 6 +-
4 files changed, 169 insertions(+), 5 deletions(-)
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}