mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user