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: <id>"
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 <neil@stably.ai>
This commit is contained in:
fabricedge
2026-10-09 22:17:19 -07:00
committed by GitHub
co-authored by Neil
parent 3822ad8b3a
commit 4b258b487b
9 changed files with 168 additions and 2 deletions
@@ -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<BrowserTabState> = {}): BrowserTabSta
}
}
async function renderBrowserTab(tab: BrowserTabState): Promise<unknown> {
async function renderBrowserTab(tab: BrowserTabState, unifiedTabId = tab.id): Promise<unknown> {
reactHookRuntime.index = 0
const module = await import('./BrowserTab')
return module.default({
@@ -194,7 +195,7 @@ async function renderBrowserTab(tab: BrowserTabState): Promise<unknown> {
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 }))
@@ -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')}
</DropdownMenuItem>
<CopyTabIdMenuItem unifiedTabId={dragData.unifiedTabId} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
<X className="size-3.5" />
@@ -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
}) => <button onClick={onSelect}>{children}</button>
}))
const writeClipboardText = vi.fn<(text: string) => Promise<void>>()
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<void>((resolve) => {
finishWrite = resolve
})
)
render(<CopyTabIdMenuItem unifiedTabId="workspace-tab-123" />)
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(<CopyTabIdMenuItem unifiedTabId="workspace-tab-123" />)
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()
})
})
@@ -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<void> => {
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 (
<DropdownMenuItem onSelect={() => void copyTabId()}>
<Copy className="size-3.5" />
{translate('components.tab.bar.CopyTabIdMenuItem.copy', 'Copy Tab ID')}
</DropdownMenuItem>
)
}
@@ -209,6 +209,7 @@ async function renderMenu(
runtimeEnvironmentId?: string | null
externalSshTargetId?: string
mode?: 'edit' | 'check-details' | 'chat-visual'
unifiedTabId?: string
} = {}
): Promise<unknown> {
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()
@@ -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')}
</DropdownMenuItem>
<CopyTabIdMenuItem unifiedTabId={unifiedTabId} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
<X className="size-3.5" />
@@ -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()
@@ -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')}
</DropdownMenuItem>
<CopyTabIdMenuItem unifiedTabId={unifiedTabId} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose(tab.id)} disabled={isPinned}>
<X className="size-3.5" />
+5
View File
@@ -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"
}
}
},