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" } } },