From 4b258b487bbe14dad8d0b0630f9bd3ba6406f195 Mon Sep 17 00:00:00 2001 From: fabricedge Date: Sat, 10 Oct 2026 02:17:19 -0300 Subject: [PATCH] feat(tabs): copy stable tab references (#26881) Add Copy Tab ID to terminal, editor and browser context menus. Copy the clicked tab's existing workspace ID as "orcaTabId: " so agents can distinguish tab references from content IDs. Keep inactive and pinned tabs identifiable without activating, renaming or closing them. Reuse the native clipboard API and show translated success or failure feedback after the write completes. Cover clipboard failures, inactive and pinned tabs, separate editor views, and browser content IDs that differ from workspace tab IDs. Co-authored-by: Neil --- .../components/tab-bar/BrowserTab.test.tsx | 23 ++++++- .../src/components/tab-bar/BrowserTab.tsx | 2 + .../tab-bar/CopyTabIdMenuItem.test.tsx | 60 +++++++++++++++++++ .../components/tab-bar/CopyTabIdMenuItem.tsx | 22 +++++++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 21 +++++++ .../tab-bar/EditorFileTabContextMenu.tsx | 2 + .../tab-bar/SortableTabContextMenu.test.tsx | 33 ++++++++++ .../tab-bar/SortableTabContextMenu.tsx | 2 + src/renderer/src/i18n/locales/en.json | 5 ++ 9 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx create mode 100644 src/renderer/src/components/tab-bar/CopyTabIdMenuItem.tsx diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 88f1544543b..11c7c931089 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { isValidElement } from 'react' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' const reactHookRuntime = vi.hoisted(() => ({ @@ -173,7 +174,7 @@ function baseBrowserTab(overrides: Partial = {}): BrowserTabSta } } -async function renderBrowserTab(tab: BrowserTabState): Promise { +async function renderBrowserTab(tab: BrowserTabState, unifiedTabId = tab.id): Promise { reactHookRuntime.index = 0 const module = await import('./BrowserTab') return module.default({ @@ -194,7 +195,7 @@ async function renderBrowserTab(tab: BrowserTabState): Promise { kind: 'tab', worktreeId: tab.worktreeId, groupId: 'group-1', - unifiedTabId: tab.id, + unifiedTabId, visibleTabId: tab.id, tabType: 'browser', label: tab.title @@ -256,6 +257,24 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => { vi.clearAllMocks() }) + it('passes the workspace ID to Copy Tab ID instead of the browser content ID', async () => { + const { CopyTabIdMenuItem } = await import('./CopyTabIdMenuItem') + const tree = await renderBrowserTab(baseBrowserTab(), 'workspace-browser-1') + const visit = (node: unknown): ReactElementLike | undefined => { + if (Array.isArray(node)) { + return node.map(visit).find(Boolean) + } + if (!isValidElement<{ children?: unknown; unifiedTabId?: string }>(node)) { + return undefined + } + if (node.type === CopyTabIdMenuItem) { + return { type: node.type, props: node.props } + } + return visit(node.props.children) + } + expect(visit(tree)?.props.unifiedTabId).toBe('workspace-browser-1') + }) + it('renders the favicon image when faviconUrl is present', async () => { const iconUrl = 'https://example.com/favicon.ico' const element = await renderExpandedBrowserTab(baseBrowserTab({ faviconUrl: iconUrl })) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index aad35e166b3..820462c6d0c 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -40,6 +40,7 @@ import { useTabStripPointerActivation } from './tab-strip-pointer-activation' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' import { cn } from '@/lib/utils' import { BrowserFavicon } from '@/components/browser-favicon' +import { CopyTabIdMenuItem } from './CopyTabIdMenuItem' export function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -283,6 +284,7 @@ export default function BrowserTab({ ? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab') : translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')} + !isPinned && onClose()} disabled={isPinned}> diff --git a/src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx b/src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx new file mode 100644 index 00000000000..8e1d716d200 --- /dev/null +++ b/src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx @@ -0,0 +1,60 @@ +/** @vitest-environment happy-dom */ +import { act } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { CopyTabIdMenuItem } from './CopyTabIdMenuItem' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + onSelect + }: { + children: React.ReactNode + onSelect: () => void + }) => +})) + +const writeClipboardText = vi.fn<(text: string) => Promise>() + +beforeEach(() => { + vi.clearAllMocks() + writeClipboardText.mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { writeClipboardText } }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('CopyTabIdMenuItem', () => { + it('copies the labeled workspace ID and confirms only after the write succeeds', async () => { + let finishWrite: (() => void) | undefined + writeClipboardText.mockImplementation( + () => + new Promise((resolve) => { + finishWrite = resolve + }) + ) + render() + fireEvent.click(screen.getByRole('button', { name: 'Copy Tab ID' })) + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('orcaTabId: workspace-tab-123') + expect(toast.success).not.toHaveBeenCalled() + await act(async () => finishWrite?.()) + expect(toast.success).toHaveBeenCalledExactlyOnceWith('Tab ID copied') + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reports a failed write without exposing native error details or claiming success', async () => { + writeClipboardText.mockRejectedValue(new Error('private native clipboard details')) + render() + fireEvent.click(screen.getByRole('button', { name: 'Copy Tab ID' })) + await waitFor(() => + expect(toast.error).toHaveBeenCalledExactlyOnceWith('Could not copy tab ID') + ) + expect(toast.success).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/CopyTabIdMenuItem.tsx b/src/renderer/src/components/tab-bar/CopyTabIdMenuItem.tsx new file mode 100644 index 00000000000..9988cea7092 --- /dev/null +++ b/src/renderer/src/components/tab-bar/CopyTabIdMenuItem.tsx @@ -0,0 +1,22 @@ +import { Copy } from 'lucide-react' +import { toast } from 'sonner' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' + +export function CopyTabIdMenuItem({ unifiedTabId }: { unifiedTabId: string }): React.JSX.Element { + const copyTabId = async (): Promise => { + try { + await window.api.ui.writeClipboardText(`orcaTabId: ${unifiedTabId}`) + toast.success(translate('components.tab.bar.CopyTabIdMenuItem.copied', 'Tab ID copied')) + } catch { + toast.error(translate('components.tab.bar.CopyTabIdMenuItem.failed', 'Could not copy tab ID')) + } + } + + return ( + void copyTabId()}> + + {translate('components.tab.bar.CopyTabIdMenuItem.copy', 'Copy Tab ID')} + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index b01282439b7..02897d2380d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -209,6 +209,7 @@ async function renderMenu( runtimeEnvironmentId?: string | null externalSshTargetId?: string mode?: 'edit' | 'check-details' | 'chat-visual' + unifiedTabId?: string } = {} ): Promise { const { runtimeEnvironmentId, externalSshTargetId, mode = 'edit', ...props } = overrides @@ -287,6 +288,26 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) + it.each(['editor-view-1', 'editor-view-2'])( + 'copies the individual editor view ID %s', + async (unifiedTabId) => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + const onActivate = vi.fn() + vi.stubGlobal('window', { api: { ui: { writeClipboardText } } }) + const tree = expandNode(await renderMenu({ unifiedTabId, onActivate })) + const item = findElementsByType(tree, 'DropdownMenuItem').find( + (candidate) => extractText(candidate.props.children) === 'Copy Tab ID' + ) + const onSelect = item?.props.onSelect + if (typeof onSelect !== 'function') { + throw new Error('Missing Copy Tab ID action') + } + onSelect() + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith(`orcaTabId: ${unifiedTabId}`) + expect(onActivate).not.toHaveBeenCalled() + } + ) + it('opens rename only after menu close releases focus and consumes the request once', async () => { const onActivate = vi.fn() const onOpenRenameInput = vi.fn() diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 6af8f91c20e..68b675c50da 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -31,6 +31,7 @@ import { } from '@/lib/reveal-in-file-manager' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' +import { CopyTabIdMenuItem } from './CopyTabIdMenuItem' import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-content-type' type EditorFileTabContextMenuProps = { @@ -155,6 +156,7 @@ export function EditorFileTabContextMenu({ ? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab') : translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')} + !isPinned && onClose()} disabled={isPinned}> diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 2751f0f162f..69413bb51f1 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -237,6 +237,39 @@ describe('requestActiveTerminalPaneSplit', () => { }) describe('SortableTabContextMenu', () => { + it.each([false, true])( + 'copies the workspace ID of an inactive tab (pinned: %s)', + async (isPinned) => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + const onClose = vi.fn() + const onRenameOpen = vi.fn() + const apiDescriptor = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { writeClipboardText } } + }) + try { + const { container, onActivate } = renderMenu({ + isActive: false, + isPinned, + onClose, + onRenameOpen + }) + await act(async () => getButton(container, 'Copy Tab ID').click()) + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('orcaTabId: tab-1') + expect(onActivate).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(onRenameOpen).not.toHaveBeenCalled() + } finally { + if (apiDescriptor) { + Object.defineProperty(window, 'api', apiDescriptor) + } else { + Reflect.deleteProperty(window, 'api') + } + } + } + ) + it('does not expose a native/terminal view switch', () => { const { container } = renderMenu() diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index d688350a673..94b715d1838 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -23,6 +23,7 @@ import { translate } from '@/i18n/i18n' import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' import { TabSessionSurfaceSwitchMenuItems } from './TabSessionSurfaceSwitchMenuItems' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' +import { CopyTabIdMenuItem } from './CopyTabIdMenuItem' const TAB_COLORS = [ { @@ -206,6 +207,7 @@ export function SortableTabContextMenu({ ? translate('auto.components.tab.bar.SortableTabContextMenu.417722e9c2', 'Unpin Tab') : translate('auto.components.tab.bar.SortableTabContextMenu.60f958ec75', 'Pin Tab')} + !isPinned && onClose(tab.id)} disabled={isPinned}> diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a44690dd353..96fd94e8ded 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18730,6 +18730,11 @@ "EditorFileTabContextMenu": { "closeOthers": "Close Others", "closeTabsToLeft": "Close Tabs To The Left" + }, + "CopyTabIdMenuItem": { + "copied": "Tab ID copied", + "failed": "Could not copy tab ID", + "copy": "Copy Tab ID" } } },