mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Sta 6308 add copy session id option to terminal tab context menu (#18070)
* Move Copy Session ID from tab to terminal pane context menu - Relocates session ID copy to the exact pane that owns it, not the tab's active pane - Adds support for durable sleeping agent sessions as fallback for cleared live status - Generalizes copy-rejection guards to handle any identity type, not just pane IDs - Updates e2e test to verify pane-specific session ID copying * Gate session ID liveness by shell foreground state Once OSC 133;D proves a pane is back at the shell, don't return the session ID even if a durable record survived the exit. This prevents treating exited sessions as still active when the user is typing at the prompt. * Update hook order parity test for session-ID projection hook The pane session-ID projection adds a render hook to TerminalPane. Update the expected hook count from 229 to 230 and the corresponding SHA256 hash.
This commit is contained in:
@@ -56,6 +56,8 @@ export type NativeChatContextMenuActions = {
|
||||
onSetTitle: () => void
|
||||
onCopyTerminalId: () => void
|
||||
onCopyPaneId: () => void
|
||||
canCopyAgentSessionId: boolean
|
||||
onCopyAgentSessionId: () => void
|
||||
canClosePane: boolean
|
||||
onClosePane: () => void
|
||||
}
|
||||
@@ -75,6 +77,8 @@ export const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuAction
|
||||
onSetTitle: () => {},
|
||||
onCopyTerminalId: () => {},
|
||||
onCopyPaneId: () => {},
|
||||
canCopyAgentSessionId: false,
|
||||
onCopyAgentSessionId: () => {},
|
||||
canClosePane: false,
|
||||
onClosePane: () => {}
|
||||
}
|
||||
@@ -219,6 +223,15 @@ export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatCont
|
||||
'Set Title…'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{actions.canCopyAgentSessionId ? (
|
||||
<DropdownMenuItem onSelect={actions.onCopyAgentSessionId}>
|
||||
<Copy />
|
||||
{translate(
|
||||
'components.terminalPane.TerminalContextMenu.copySessionId',
|
||||
'Copy Session ID'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={actions.onCopyTerminalId}>
|
||||
<Copy />
|
||||
{translate(
|
||||
|
||||
@@ -292,60 +292,4 @@ describe('SortableTabContextMenu', () => {
|
||||
expect(container.textContent).not.toContain('Move Tab to Split')
|
||||
expect(container.textContent).toContain('Split terminal right')
|
||||
})
|
||||
|
||||
describe('copy session id', () => {
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function withLiveAgent(sessionId: string | null): void {
|
||||
storeMock.state = {
|
||||
...storeMock.state,
|
||||
terminalLayoutsByTabId: {
|
||||
'term-1': { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF }
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[`term-1:${LEAF}`]: {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: `term-1:${LEAF}`,
|
||||
agentType: 'claude',
|
||||
stateHistory: [],
|
||||
...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {})
|
||||
}
|
||||
},
|
||||
paneForegroundAgentByPaneKey: {}
|
||||
}
|
||||
}
|
||||
|
||||
it('omits the item for a tab with no agent', () => {
|
||||
const { container } = renderMenu()
|
||||
|
||||
expect(container.textContent).not.toContain('Copy Session ID')
|
||||
})
|
||||
|
||||
it('omits the item until the active agent reports a session id', () => {
|
||||
withLiveAgent(null)
|
||||
const { container } = renderMenu()
|
||||
|
||||
expect(container.textContent).not.toContain('Copy Session ID')
|
||||
})
|
||||
|
||||
it('copies the active pane session id', async () => {
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(window, { api: { ui: { writeClipboardText } } })
|
||||
withLiveAgent('session-abc')
|
||||
const { container } = renderMenu()
|
||||
|
||||
act(() => getButton(container, 'Copy Session ID').click())
|
||||
await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('session-abc'))
|
||||
})
|
||||
|
||||
it('does not resolve a session id while the menu is closed', () => {
|
||||
withLiveAgent('session-abc')
|
||||
const { container } = renderMenu({ open: false })
|
||||
|
||||
expect(container.textContent).not.toContain('Copy Session ID')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,6 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem'
|
||||
import { resolveTabAgentSessionId } from './tab-agent-session-id'
|
||||
import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection'
|
||||
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
|
||||
|
||||
@@ -123,10 +121,6 @@ export function SortableTabContextMenu({
|
||||
onTogglePin
|
||||
}: SortableTabContextMenuProps): React.JSX.Element {
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
// The id is a primitive, so unchanged sessions stay referentially stable without a cache.
|
||||
const agentSessionId = useAppStore((state) =>
|
||||
open ? resolveTabAgentSessionId(state, tab.id) : null
|
||||
)
|
||||
const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings)
|
||||
const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings)
|
||||
|
||||
@@ -194,7 +188,6 @@ export function SortableTabContextMenu({
|
||||
{translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')}
|
||||
{renameShortcut ? <DropdownMenuShortcut>{renameShortcut}</DropdownMenuShortcut> : null}
|
||||
</DropdownMenuItem>
|
||||
<TabAgentSessionIdMenuItem sessionId={agentSessionId} />
|
||||
<div className="px-2 pt-1.5 pb-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{translate('auto.components.tab.bar.SortableTabContextMenu.35e8892fd0', 'Tab Color')}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
disabled,
|
||||
onSelect,
|
||||
'aria-label': ariaLabel
|
||||
}: {
|
||||
children?: ReactNode
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
'aria-label'?: string
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} aria-label={ariaLabel} onClick={() => onSelect?.()}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({ Copy: () => null }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('sonner', () => ({ toast: toastMock }))
|
||||
|
||||
const mounted: { container: HTMLDivElement; root: Root }[] = []
|
||||
|
||||
function render(sessionId: string | null): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => root.render(<TabAgentSessionIdMenuItem sessionId={sessionId} />))
|
||||
mounted.push({ container, root })
|
||||
return container
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { container, root } of mounted.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
}
|
||||
toastMock.success.mockReset()
|
||||
toastMock.error.mockReset()
|
||||
})
|
||||
|
||||
describe('TabAgentSessionIdMenuItem', () => {
|
||||
it('renders nothing when no session id is available', () => {
|
||||
expect(render(null).textContent).toBe('')
|
||||
})
|
||||
|
||||
it('copies on select when an id is known', async () => {
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(window, { api: { ui: { writeClipboardText } } })
|
||||
const container = render('abc-123')
|
||||
|
||||
const button = container.querySelector('button')
|
||||
expect(button?.disabled).toBe(false)
|
||||
act(() => button?.click())
|
||||
await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('abc-123'))
|
||||
})
|
||||
|
||||
it('reports clipboard failures', async () => {
|
||||
const writeClipboardText = vi.fn().mockRejectedValue(new Error('clipboard unavailable'))
|
||||
Object.assign(window, { api: { ui: { writeClipboardText } } })
|
||||
const button = render('abc-123').querySelector('button')
|
||||
|
||||
act(() => button?.click())
|
||||
await vi.waitFor(() =>
|
||||
expect(toastMock.error).toHaveBeenCalledWith('Failed to copy Session ID')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
async function copySessionId(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(sessionId)
|
||||
toast.success(
|
||||
translate(
|
||||
'components.tab.bar.SortableTabContextMenu.copySessionIdSuccess',
|
||||
'Session ID copied'
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.tab.bar.SortableTabContextMenu.copySessionIdError',
|
||||
'Failed to copy Session ID'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the active pane's provider session id when one is available. */
|
||||
export function TabAgentSessionIdMenuItem({
|
||||
sessionId
|
||||
}: {
|
||||
sessionId: string | null
|
||||
}): React.JSX.Element | null {
|
||||
if (sessionId === null) {
|
||||
return null
|
||||
}
|
||||
const label = translate(
|
||||
'components.tab.bar.SortableTabContextMenu.copySessionId',
|
||||
'Copy Session ID'
|
||||
)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void copySessionId(sessionId)
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { resolveTabAgentSessionId, type TabAgentSessionIdState } from './tab-agent-session-id'
|
||||
|
||||
const LEAF_A = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function entry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
|
||||
return {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: `tab-1:${LEAF_A}`,
|
||||
agentType: 'claude',
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function state(overrides: Partial<TabAgentSessionIdState> = {}): TabAgentSessionIdState {
|
||||
return {
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_A },
|
||||
activeLeafId: LEAF_A,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
agentStatusByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveTabAgentSessionId', () => {
|
||||
it('is absent when the pane has no agent row', () => {
|
||||
expect(resolveTabAgentSessionId(state(), 'tab-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('is absent for a tab with no layout', () => {
|
||||
expect(resolveTabAgentSessionId(state(), 'tab-missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the id reported by the active pane', () => {
|
||||
const resolved = resolveTabAgentSessionId(
|
||||
state({
|
||||
agentStatusByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } })
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(resolved).toBe('abc-123')
|
||||
})
|
||||
|
||||
it('is absent until the agent reports an id', () => {
|
||||
const resolved = resolveTabAgentSessionId(
|
||||
state({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: entry() } }),
|
||||
'tab-1'
|
||||
)
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
describe('liveness', () => {
|
||||
it('is absent for a hydrated row with no live hook since restore', () => {
|
||||
const resolved = resolveTabAgentSessionId(
|
||||
state({
|
||||
agentStatusByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: entry({
|
||||
restoredUnconfirmed: true,
|
||||
providerSession: { key: 'session_id', id: 'abc-123' }
|
||||
})
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('is absent once the pane is proven back at the shell', () => {
|
||||
const resolved = resolveTabAgentSessionId(
|
||||
state({
|
||||
agentStatusByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } })
|
||||
},
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: { agent: null, shellForeground: true }
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a session whose foreground evidence is only that an agent runs', () => {
|
||||
const resolved = resolveTabAgentSessionId(
|
||||
state({
|
||||
agentStatusByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } })
|
||||
},
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: { agent: 'claude', shellForeground: false }
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(resolved).toBe('abc-123')
|
||||
})
|
||||
|
||||
it('keeps a working session that reported a session boundary', () => {
|
||||
// Why: sessionBoundary marks a resume/clear landing idle — a session start,
|
||||
// not a session end, and exactly when the first id arrives.
|
||||
const resolved = resolveTabAgentSessionId(
|
||||
state({
|
||||
agentStatusByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: entry({
|
||||
sessionBoundary: true,
|
||||
providerSession: { key: 'session_id', id: 'fresh-1' }
|
||||
})
|
||||
}
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
expect(resolved).toBe('fresh-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('split tabs', () => {
|
||||
const splitState = (activeLeafId: string): TabAgentSessionIdState =>
|
||||
state({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_A },
|
||||
second: { type: 'leaf', leafId: LEAF_B }
|
||||
},
|
||||
activeLeafId,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'left' } }),
|
||||
[`tab-1:${LEAF_B}`]: entry({ providerSession: { key: 'session_id', id: 'right' } })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the active pane, not a sibling', () => {
|
||||
expect(resolveTabAgentSessionId(splitState(LEAF_B), 'tab-1')).toBe('right')
|
||||
})
|
||||
|
||||
it('is absent when the active leaf id no longer exists in the layout', () => {
|
||||
const stale = '33333333-3333-4333-8333-333333333333'
|
||||
expect(resolveTabAgentSessionId(splitState(stale), 'tab-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
|
||||
import { resolveNativeChatActiveLayoutLeafId } from '../native-chat/native-chat-leaf-routing'
|
||||
|
||||
export type TabAgentSessionIdState = {
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
|
||||
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
|
||||
}
|
||||
|
||||
/** Returns the active pane's provider session id when its agent is still live. */
|
||||
export function resolveTabAgentSessionId(
|
||||
state: TabAgentSessionIdState,
|
||||
tabId: string
|
||||
): string | null {
|
||||
const leafId = resolveNativeChatActiveLayoutLeafId(state.terminalLayoutsByTabId?.[tabId])
|
||||
if (!leafId) {
|
||||
return null
|
||||
}
|
||||
const paneKey = `${tabId}:${leafId}`
|
||||
const entry = state.agentStatusByPaneKey?.[paneKey]
|
||||
// Hydrated rows may describe a session that ended while no receiver was up.
|
||||
if (!entry?.agentType || entry.restoredUnconfirmed === true) {
|
||||
return null
|
||||
}
|
||||
// OSC 133;D proves the pane is back at the shell, regardless of the last hook state.
|
||||
if (state.paneForegroundAgentByPaneKey?.[paneKey]?.shellForeground === true) {
|
||||
return null
|
||||
}
|
||||
return entry.providerSession?.id ?? null
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
const TAB_MENU_SOURCES = [
|
||||
'EditorFileTabContextMenu.tsx',
|
||||
'SortableTabContextMenu.tsx',
|
||||
'TabAgentSessionIdMenuItem.tsx',
|
||||
'BrowserTab.tsx',
|
||||
'TabWorkspaceLayoutMenuSection.tsx',
|
||||
'TerminalTabSplitMenuSection.tsx'
|
||||
|
||||
@@ -92,6 +92,8 @@ function renderMenu(overrides: Record<string, unknown> = {}): string {
|
||||
canClearPaneTitle: false,
|
||||
onCopyTerminalId: vi.fn(),
|
||||
onCopyPaneId: vi.fn(),
|
||||
canCopyAgentSessionId: false,
|
||||
onCopyAgentSessionId: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
return renderToStaticMarkup(React.createElement(TerminalContextMenu, props))
|
||||
@@ -146,6 +148,29 @@ describe('TerminalContextMenu', () => {
|
||||
expect(items.list.some((item) => childrenText(item.children).includes('Switch to'))).toBe(false)
|
||||
})
|
||||
|
||||
it('shows Copy Session ID only for panes with provider identity', () => {
|
||||
const onCopyAgentSessionId = vi.fn()
|
||||
renderMenu({ canCopyAgentSessionId: true, onCopyAgentSessionId })
|
||||
|
||||
const item = items.list.find(
|
||||
(candidate) => childrenText(candidate.children) === 'Copy Session ID'
|
||||
)
|
||||
expect(item).toBeDefined()
|
||||
expect(
|
||||
items.list
|
||||
.map((candidate) => childrenText(candidate.children))
|
||||
.filter((label) => ['Copy Session ID', 'Copy Terminal ID', 'Copy Pane ID'].includes(label))
|
||||
).toEqual(['Copy Session ID', 'Copy Terminal ID', 'Copy Pane ID'])
|
||||
item?.onSelect?.()
|
||||
expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1)
|
||||
|
||||
items.list = []
|
||||
renderMenu({ canCopyAgentSessionId: false })
|
||||
expect(
|
||||
items.list.some((candidate) => childrenText(candidate.children) === 'Copy Session ID')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows one shortcut per terminal menu action on Windows', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
@@ -66,6 +66,8 @@ type TerminalContextMenuProps = {
|
||||
canClearPaneTitle: boolean
|
||||
onCopyTerminalId: () => void
|
||||
onCopyPaneId: () => void
|
||||
canCopyAgentSessionId: boolean
|
||||
onCopyAgentSessionId: () => void
|
||||
}
|
||||
|
||||
export default function TerminalContextMenu({
|
||||
@@ -101,7 +103,9 @@ export default function TerminalContextMenu({
|
||||
onClearPaneTitle,
|
||||
canClearPaneTitle,
|
||||
onCopyTerminalId,
|
||||
onCopyPaneId
|
||||
onCopyPaneId,
|
||||
canCopyAgentSessionId,
|
||||
onCopyAgentSessionId
|
||||
}: TerminalContextMenuProps): React.JSX.Element {
|
||||
// Why: one primary binding prevents Windows/Linux shortcut labels from forcing row wraps.
|
||||
const shortcuts = useMemo(
|
||||
@@ -276,6 +280,15 @@ export default function TerminalContextMenu({
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canCopyAgentSessionId ? (
|
||||
<DropdownMenuItem onSelect={onCopyAgentSessionId}>
|
||||
<Copy />
|
||||
{translate(
|
||||
'components.terminalPane.TerminalContextMenu.copySessionId',
|
||||
'Copy Session ID'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={onCopyTerminalId}>
|
||||
<Copy />
|
||||
{translate(
|
||||
|
||||
@@ -3,6 +3,8 @@ import NativeChatView from '../native-chat/NativeChatView'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation'
|
||||
import type { TerminalPaneController } from './use-terminal-pane-controller'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolvePaneAgentSessionId } from './pane-agent-session-id'
|
||||
|
||||
export function TerminalPaneNativeChatPortal({
|
||||
controller
|
||||
@@ -30,6 +32,11 @@ export function TerminalPaneNativeChatPortal({
|
||||
tabId,
|
||||
unifiedTabId
|
||||
} = controller
|
||||
const chatPaneSessionId = useAppStore((state) =>
|
||||
effectiveChatViewMode && chatPane
|
||||
? resolvePaneAgentSessionId(state, makePaneKey(tabId, chatPane.leafId))
|
||||
: null
|
||||
)
|
||||
if (!effectiveChatViewMode || !chatPane?.container) {
|
||||
return null
|
||||
}
|
||||
@@ -78,6 +85,9 @@ export function TerminalPaneNativeChatPortal({
|
||||
onCopyTerminalId: () =>
|
||||
void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId),
|
||||
onCopyPaneId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId),
|
||||
canCopyAgentSessionId: chatPaneSessionId !== null,
|
||||
onCopyAgentSessionId: () =>
|
||||
void contextMenu.runForPane(chatPane.id, contextMenu.onCopyAgentSessionId),
|
||||
canClosePane: managedPanes.length > 1,
|
||||
onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane)
|
||||
}}
|
||||
|
||||
@@ -59,6 +59,7 @@ export function TerminalPaneSurface({
|
||||
keybindings,
|
||||
managedPanes,
|
||||
managerRef,
|
||||
menuAgentSessionId,
|
||||
menuPaneHasCustomTitle,
|
||||
openDiskSpaceAnalyzer,
|
||||
openQuickCommandEditor,
|
||||
@@ -247,6 +248,8 @@ export function TerminalPaneSurface({
|
||||
canClearPaneTitle={menuPaneHasCustomTitle}
|
||||
onCopyTerminalId={() => void contextMenu.onCopyTerminalId()}
|
||||
onCopyPaneId={contextMenu.onCopyPaneId}
|
||||
canCopyAgentSessionId={menuAgentSessionId !== null}
|
||||
onCopyAgentSessionId={() => void contextMenu.onCopyAgentSessionId()}
|
||||
/>
|
||||
<TerminalLinkActionPopover
|
||||
request={terminalLinkActionRequest}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import { resolvePaneAgentSessionId, type PaneAgentSessionIdState } from './pane-agent-session-id'
|
||||
|
||||
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function state(
|
||||
live?: AgentStatusEntry,
|
||||
sleeping?: SleepingAgentSessionRecord,
|
||||
shellForeground = false
|
||||
): PaneAgentSessionIdState {
|
||||
return {
|
||||
agentStatusByPaneKey: live ? { [PANE_KEY]: live } : {},
|
||||
sleepingAgentSessionsByPaneKey: sleeping ? { [PANE_KEY]: sleeping } : {},
|
||||
paneForegroundAgentByPaneKey: { [PANE_KEY]: { agent: 'claude', shellForeground } }
|
||||
}
|
||||
}
|
||||
|
||||
function live(sessionId?: string, restoredUnconfirmed = false): AgentStatusEntry {
|
||||
return {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: 2,
|
||||
stateStartedAt: 2,
|
||||
paneKey: PANE_KEY,
|
||||
agentType: 'claude',
|
||||
stateHistory: [],
|
||||
...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {}),
|
||||
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function sleeping(sessionId: string): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: sessionId },
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live'
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolvePaneAgentSessionId', () => {
|
||||
it('returns the live provider session for the exact pane', () => {
|
||||
expect(resolvePaneAgentSessionId(state(live('live-session')), PANE_KEY)).toBe('live-session')
|
||||
})
|
||||
|
||||
it('returns the pane-owned durable session after its live status row is cleared', () => {
|
||||
expect(
|
||||
resolvePaneAgentSessionId(state(undefined, sleeping('sleeping-session')), PANE_KEY)
|
||||
).toBe('sleeping-session')
|
||||
})
|
||||
|
||||
it('does not reuse an older durable session while a newer live row lacks identity', () => {
|
||||
expect(resolvePaneAgentSessionId(state(live(), sleeping('old-session')), PANE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back from an unconfirmed restored row to durable pane identity', () => {
|
||||
expect(
|
||||
resolvePaneAgentSessionId(
|
||||
state(live('unconfirmed-session', true), sleeping('confirmed-session')),
|
||||
PANE_KEY
|
||||
)
|
||||
).toBe('confirmed-session')
|
||||
})
|
||||
|
||||
describe('liveness', () => {
|
||||
it('is absent once the pane is proven back at the shell', () => {
|
||||
expect(
|
||||
resolvePaneAgentSessionId(state(live('live-session'), undefined, true), PANE_KEY)
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('is absent at the shell even when a durable record survives the exit', () => {
|
||||
expect(
|
||||
resolvePaneAgentSessionId(state(undefined, sleeping('sleeping-session'), true), PANE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a session whose foreground evidence is only that an agent runs', () => {
|
||||
expect(
|
||||
resolvePaneAgentSessionId(state(live('live-session'), undefined, false), PANE_KEY)
|
||||
).toBe('live-session')
|
||||
})
|
||||
|
||||
it('keeps a session for a pane with no foreground evidence at all', () => {
|
||||
expect(
|
||||
resolvePaneAgentSessionId(
|
||||
{
|
||||
agentStatusByPaneKey: { [PANE_KEY]: live('live-session') },
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {}
|
||||
},
|
||||
PANE_KEY
|
||||
)
|
||||
).toBe('live-session')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read identity from a sibling pane', () => {
|
||||
const sibling = 'tab-1:22222222-2222-4222-8222-222222222222'
|
||||
expect(resolvePaneAgentSessionId(state(undefined, sleeping('session-1')), sibling)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
|
||||
|
||||
export type PaneAgentSessionIdState = {
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
|
||||
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord | undefined>
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry | undefined>
|
||||
}
|
||||
|
||||
/** Resolves the provider session owned by one exact terminal pane, while its agent is still live. */
|
||||
export function resolvePaneAgentSessionId(
|
||||
state: PaneAgentSessionIdState,
|
||||
paneKey: string
|
||||
): string | null {
|
||||
// OSC 133;D proves the pane is back at the shell. The durable record outlives that exit on
|
||||
// purpose (cold restore resumes from it), so gate it here too — otherwise the gate would only
|
||||
// hold for panes whose agent has no resumable record.
|
||||
if (state.paneForegroundAgentByPaneKey[paneKey]?.shellForeground === true) {
|
||||
return null
|
||||
}
|
||||
const live = state.agentStatusByPaneKey[paneKey]
|
||||
if (live && live.restoredUnconfirmed !== true) {
|
||||
return live.providerSession?.id ?? null
|
||||
}
|
||||
return state.sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id ?? null
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Why: writeClipboardText resolved unconditionally in the web client until the
|
||||
// insecure-context copy fallback landed. It can now reject (insecure origin with
|
||||
// no live user gesture), so these two menu actions need explicit outcomes:
|
||||
// the copy must never leave the pane unfocused, and Copy Pane ID must not toast
|
||||
// no live user gesture), so identity-copy actions need explicit outcomes:
|
||||
// the copy must never leave the pane unfocused, and identity actions must not toast
|
||||
// success for a copy that did not happen. Extracted so both are testable without
|
||||
// mounting the whole context-menu hook.
|
||||
|
||||
@@ -22,15 +22,15 @@ export async function runTerminalCopy(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCopyPaneId(args: {
|
||||
paneKey: string
|
||||
export async function runTerminalIdentityCopy(args: {
|
||||
text: string
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
onSuccess: () => void
|
||||
onError: () => void
|
||||
focus: () => void
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await args.writeClipboardText(args.paneKey)
|
||||
await args.writeClipboardText(args.text)
|
||||
args.onSuccess()
|
||||
} catch {
|
||||
args.onError()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runTerminalCopy, runCopyPaneId } from './terminal-copy-rejection-guards'
|
||||
import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards'
|
||||
|
||||
// Why this file exists: web-preload-api's writeClipboardText used to resolve
|
||||
// unconditionally, so the terminal copy surfaces call it without a rejection
|
||||
@@ -42,15 +42,15 @@ describe('runTerminalCopy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCopyPaneId', () => {
|
||||
describe('runTerminalIdentityCopy', () => {
|
||||
it('reports failure instead of claiming success when the write rejects', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const focus = vi.fn()
|
||||
|
||||
await expect(
|
||||
runCopyPaneId({
|
||||
paneKey: 'tab:leaf',
|
||||
runTerminalIdentityCopy({
|
||||
text: 'tab:leaf',
|
||||
writeClipboardText: vi.fn().mockRejectedValue(REJECTION),
|
||||
onSuccess,
|
||||
onError,
|
||||
@@ -68,8 +68,8 @@ describe('runCopyPaneId', () => {
|
||||
const onError = vi.fn()
|
||||
const focus = vi.fn()
|
||||
|
||||
await runCopyPaneId({
|
||||
paneKey: 'tab:leaf',
|
||||
await runTerminalIdentityCopy({
|
||||
text: 'tab:leaf',
|
||||
writeClipboardText: vi.fn().mockResolvedValue(undefined),
|
||||
onSuccess,
|
||||
onError,
|
||||
|
||||
@@ -8,9 +8,9 @@ import { describe, expect, it } from 'vitest'
|
||||
const TERMINAL_PANE_HOOK_SOURCE_PATTERN =
|
||||
/^(?:TerminalPane\.tsx|use-terminal-pane-(?:chat-state|close-actions|context-actions|controller|foundation|global-listeners|layout-bindings|layout-persistence|lifecycle-stage|mobile-actions|paste-listeners|process-exit-actions|projection|reconciliation|startup-actions|store-bindings|title-effects|title-state)\.ts)$/
|
||||
// Rebased onto main after the workbench surface-per-workspace and deferred
|
||||
// split-cwd changes; this hash is from that main's pre-split TerminalPane (229 hooks).
|
||||
// split-cwd changes; the pane session-ID projection adds one render hook (230 hooks).
|
||||
const PRE_REFACTOR_HOOK_ORDER_SHA256 =
|
||||
'be2366fffb992e082fd9e4641b7543a0db75cb7bc4ef6e0eb0deda41beaac358'
|
||||
'77adcf8272ddc6f903920f12b2b652ec26c570987f452076ae6460c67d3741f3'
|
||||
|
||||
const sourceFiles = readdirSync(__dirname)
|
||||
.filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name))
|
||||
@@ -75,7 +75,7 @@ function readFlattenedHookOrder(): string[] {
|
||||
describe('TerminalPane refactor hook parity', () => {
|
||||
it('preserves the recursively flattened render hook order', () => {
|
||||
const hooks = readFlattenedHookOrder()
|
||||
expect(hooks).toHaveLength(229)
|
||||
expect(hooks).toHaveLength(230)
|
||||
expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(4)
|
||||
expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe(
|
||||
PRE_REFACTOR_HOOK_ORDER_SHA256
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { copyTerminalHandleForPane } from './terminal-handle-copy'
|
||||
import { runCopyPaneId, runTerminalCopy } from './terminal-copy-rejection-guards'
|
||||
import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards'
|
||||
|
||||
export const copyTerminalPaneMenuSelection = async (pane: ManagedPane | null): Promise<void> => {
|
||||
if (!pane) {
|
||||
@@ -27,10 +27,10 @@ export const copyTerminalPaneMenuPaneId = async (
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
await runCopyPaneId({
|
||||
await runTerminalIdentityCopy({
|
||||
// Why: orchestration targets use ORCA_PANE_KEY, which survives renderer
|
||||
// remounts; the numeric PaneManager id is only a local runtime handle.
|
||||
paneKey: makePaneKey(tabId, pane.leafId),
|
||||
text: makePaneKey(tabId, pane.leafId),
|
||||
writeClipboardText: window.api.ui.writeTerminalClipboardText,
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
@@ -83,3 +83,35 @@ export const copyTerminalPaneMenuTerminalId = async (
|
||||
pane.terminal.focus()
|
||||
}
|
||||
}
|
||||
|
||||
export const copyTerminalPaneMenuAgentSessionId = async (
|
||||
pane: ManagedPane | null,
|
||||
sessionId: string | null
|
||||
): Promise<void> => {
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
if (!sessionId) {
|
||||
pane.terminal.focus()
|
||||
return
|
||||
}
|
||||
await runTerminalIdentityCopy({
|
||||
text: sessionId,
|
||||
writeClipboardText: window.api.ui.writeTerminalClipboardText,
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
translate(
|
||||
'components.terminalPane.TerminalContextMenu.copySessionIdSuccess',
|
||||
'Session ID copied'
|
||||
)
|
||||
),
|
||||
onError: () =>
|
||||
toast.error(
|
||||
translate(
|
||||
'components.terminalPane.TerminalContextMenu.copySessionIdError',
|
||||
'Unable to copy session ID'
|
||||
)
|
||||
),
|
||||
focus: () => pane.terminal.focus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
|
||||
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
|
||||
import { pasteTerminalPaneMenuClipboard } from './terminal-pane-menu-paste'
|
||||
import {
|
||||
copyTerminalPaneMenuAgentSessionId,
|
||||
copyTerminalPaneMenuPaneId,
|
||||
copyTerminalPaneMenuSelection,
|
||||
copyTerminalPaneMenuTerminalId
|
||||
@@ -22,6 +23,9 @@ import {
|
||||
} from './terminal-pane-menu-agent-session-actions'
|
||||
import { useTerminalPaneSplitActions } from './use-terminal-pane-split-actions'
|
||||
import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigger'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { resolvePaneAgentSessionId } from './pane-agent-session-id'
|
||||
|
||||
type UseTerminalPaneContextMenuDeps = {
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
@@ -57,6 +61,7 @@ type TerminalMenuState = {
|
||||
onSelectAll: () => void
|
||||
onCopyTerminalId: () => Promise<void>
|
||||
onCopyPaneId: () => Promise<void>
|
||||
onCopyAgentSessionId: () => Promise<void>
|
||||
onPaste: () => Promise<void>
|
||||
onSplitRight: () => void
|
||||
onSplitDown: () => void
|
||||
@@ -170,6 +175,14 @@ export function useTerminalPaneContextMenu({
|
||||
const onCopyTerminalId = async (): Promise<void> =>
|
||||
copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId)
|
||||
|
||||
const onCopyAgentSessionId = async (): Promise<void> => {
|
||||
const pane = resolveMenuPane()
|
||||
const sessionId = pane
|
||||
? resolvePaneAgentSessionId(useAppStore.getState(), makePaneKey(tabId, pane.leafId))
|
||||
: null
|
||||
return copyTerminalPaneMenuAgentSessionId(pane, sessionId)
|
||||
}
|
||||
|
||||
const onPaste = async (): Promise<void> => pasteResolvedPane('context-menu')
|
||||
|
||||
const onEqualizePaneSizes = (): void => {
|
||||
@@ -275,6 +288,7 @@ export function useTerminalPaneContextMenu({
|
||||
onSelectAll,
|
||||
onCopyTerminalId,
|
||||
onCopyPaneId,
|
||||
onCopyAgentSessionId,
|
||||
onPaste,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from '../native-chat/native-chat-leaf-routing'
|
||||
import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation'
|
||||
import type { TerminalPaneMobileController } from './use-terminal-pane-mobile-actions'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { resolvePaneAgentSessionId } from './pane-agent-session-id'
|
||||
|
||||
export function useTerminalPaneProjection(controller: TerminalPaneMobileController) {
|
||||
const {
|
||||
@@ -40,6 +43,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll
|
||||
shouldMeasureHiddenStartup,
|
||||
structuredSessionAgent,
|
||||
structuredSessionId,
|
||||
tabId,
|
||||
sshReconnectOwnsTerminalErrors,
|
||||
systemPrefersDark,
|
||||
tabAgentTypeByLeaf,
|
||||
@@ -100,6 +104,11 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll
|
||||
)
|
||||
const menuPaneHasCustomTitle =
|
||||
contextMenu.menuPaneId !== null && Boolean(paneTitles[contextMenu.menuPaneId])
|
||||
const menuAgentSessionId = useAppStore((state) =>
|
||||
contextMenu.open && contextMenuLeafId
|
||||
? resolvePaneAgentSessionId(state, makePaneKey(tabId, contextMenuLeafId))
|
||||
: null
|
||||
)
|
||||
const chatLeafStillMounted = chatLeafId
|
||||
? managedPanes.some((pane) => pane.leafId === chatLeafId)
|
||||
: false
|
||||
@@ -181,6 +190,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll
|
||||
showSshReconnectOverlay,
|
||||
visibleTerminalError,
|
||||
menuPaneHasCustomTitle,
|
||||
menuAgentSessionId,
|
||||
chatLeafStillMounted,
|
||||
chatPane,
|
||||
chatPanePtyId,
|
||||
|
||||
@@ -16781,10 +16781,7 @@
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "Switch to terminal view",
|
||||
"switchToChatView": "Switch to chat view",
|
||||
"closeTabsToLeft": "Close Tabs To The Left",
|
||||
"copySessionId": "Copy Session ID",
|
||||
"copySessionIdSuccess": "Session ID copied",
|
||||
"copySessionIdError": "Failed to copy Session ID"
|
||||
"closeTabsToLeft": "Close Tabs To The Left"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "Close Others",
|
||||
@@ -17073,6 +17070,13 @@
|
||||
"days": "{{value0}}d",
|
||||
"underOneMinute": "<1m"
|
||||
}
|
||||
},
|
||||
"terminalPane": {
|
||||
"TerminalContextMenu": {
|
||||
"copySessionId": "Copy Session ID",
|
||||
"copySessionIdSuccess": "Session ID copied",
|
||||
"copySessionIdError": "Unable to copy session ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -14652,10 +14652,7 @@
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "Cambiar a la vista de terminal",
|
||||
"switchToChatView": "Cambiar a vista de chat",
|
||||
"closeTabsToLeft": "Cerrar pestañas a la izquierda",
|
||||
"copySessionId": "Copiar ID de sesión",
|
||||
"copySessionIdSuccess": "ID de sesión copiado",
|
||||
"copySessionIdError": "No se pudo copiar el ID de sesión"
|
||||
"closeTabsToLeft": "Cerrar pestañas a la izquierda"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "Cerrar otras",
|
||||
@@ -14703,6 +14700,13 @@
|
||||
"sent": "El contexto de la sesión se envió a {{agent}} en una sesión nueva.",
|
||||
"deliveryFailed": "La nueva sesión de {{agent}} se inició, pero no se pudo enviar su contexto.",
|
||||
"launchFailed": "No se pudo iniciar una sesión nueva de {{agent}}."
|
||||
},
|
||||
"terminalPane": {
|
||||
"TerminalContextMenu": {
|
||||
"copySessionId": "Copiar ID de sesión",
|
||||
"copySessionIdSuccess": "ID de sesión copiado",
|
||||
"copySessionIdError": "No se pudo copiar el ID de sesión"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -14652,10 +14652,7 @@
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "ターミナルビューに切り替える",
|
||||
"switchToChatView": "チャットビューに切り替える",
|
||||
"closeTabsToLeft": "左側のタブを閉じる",
|
||||
"copySessionId": "セッション ID をコピー",
|
||||
"copySessionIdSuccess": "セッション ID をコピーしました",
|
||||
"copySessionIdError": "セッション ID のコピーに失敗しました"
|
||||
"closeTabsToLeft": "左側のタブを閉じる"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "その他を閉じる",
|
||||
@@ -14703,6 +14700,13 @@
|
||||
"sent": "セッションコンテキストを新規 {{agent}} セッションに送信しました。",
|
||||
"deliveryFailed": "新規 {{agent}} セッションは開始しましたが、コンテキストを送信できませんでした。",
|
||||
"launchFailed": "新規 {{agent}} セッションを開始できませんでした。"
|
||||
},
|
||||
"terminalPane": {
|
||||
"TerminalContextMenu": {
|
||||
"copySessionId": "セッション ID をコピー",
|
||||
"copySessionIdSuccess": "セッション ID をコピーしました",
|
||||
"copySessionIdError": "セッション ID のコピーに失敗しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -14708,10 +14708,7 @@
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "terminal 보기로 전환",
|
||||
"switchToChatView": "채팅 보기로 전환",
|
||||
"closeTabsToLeft": "왼쪽으로 탭 닫기",
|
||||
"copySessionId": "세션 ID 복사",
|
||||
"copySessionIdSuccess": "세션 ID를 복사했습니다",
|
||||
"copySessionIdError": "세션 ID를 복사하지 못했습니다"
|
||||
"closeTabsToLeft": "왼쪽으로 탭 닫기"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "다른 탭 닫기",
|
||||
@@ -14820,6 +14817,13 @@
|
||||
"sent": "세션 컨텍스트를 새 {{agent}} 세션으로 보냈습니다.",
|
||||
"deliveryFailed": "새 {{agent}} 세션은 시작되었지만 컨텍스트를 보내지 못했습니다.",
|
||||
"launchFailed": "새 {{agent}} 세션을 시작할 수 없습니다."
|
||||
},
|
||||
"terminalPane": {
|
||||
"TerminalContextMenu": {
|
||||
"copySessionId": "세션 ID 복사",
|
||||
"copySessionIdSuccess": "세션 ID를 복사했습니다",
|
||||
"copySessionIdError": "세션 ID를 복사하지 못했습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -14708,10 +14708,7 @@
|
||||
"SortableTabContextMenu": {
|
||||
"switchToTerminalView": "切换到终端视图",
|
||||
"switchToChatView": "切换到聊天视图",
|
||||
"closeTabsToLeft": "关闭左侧的选项卡",
|
||||
"copySessionId": "复制会话 ID",
|
||||
"copySessionIdSuccess": "已复制会话 ID",
|
||||
"copySessionIdError": "复制会话 ID 失败"
|
||||
"closeTabsToLeft": "关闭左侧的选项卡"
|
||||
},
|
||||
"BrowserTab": {
|
||||
"closeOthers": "关闭其他",
|
||||
@@ -14820,6 +14817,13 @@
|
||||
"sent": "已将会话上下文发送到新的 {{agent}} 会话。",
|
||||
"deliveryFailed": "新的 {{agent}} 会话已启动,但无法发送上下文。",
|
||||
"launchFailed": "无法启动新的 {{agent}} 会话。"
|
||||
},
|
||||
"terminalPane": {
|
||||
"TerminalContextMenu": {
|
||||
"copySessionId": "复制会话 ID",
|
||||
"copySessionIdSuccess": "已复制会话 ID",
|
||||
"copySessionIdError": "复制会话 ID 失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
+20
-17
@@ -1,7 +1,4 @@
|
||||
/**
|
||||
* E2E coverage for copying an agent provider session ID from a terminal tab's
|
||||
* context menu.
|
||||
*/
|
||||
/** E2E coverage for copying provider identity from the exact terminal pane. */
|
||||
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
@@ -11,10 +8,11 @@ import {
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import { waitForPaneIdentitySnapshot } from './helpers/terminal'
|
||||
import { openTerminalContextMenu } from './helpers/terminal-pane-title-actions'
|
||||
|
||||
const SESSION_ID = 'e2e-terminal-tab-session'
|
||||
const SESSION_ID = 'e2e-terminal-pane-session'
|
||||
|
||||
test('terminal tab context menu copies the active agent session ID', async ({ orcaPage }) => {
|
||||
test('terminal pane context menu copies its agent session ID', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
@@ -30,21 +28,20 @@ test('terminal tab context menu copies the active agent session ID', async ({ or
|
||||
}
|
||||
const paneKey = `${tabId}:${leafId}`
|
||||
|
||||
// Seed the same renderer state a live agent hook produces while keeping the
|
||||
// test independent of an installed provider CLI.
|
||||
// Keep this independent of an installed provider CLI while exercising the
|
||||
// durable pane identity used when transient live status has been cleared.
|
||||
await orcaPage.evaluate(
|
||||
({ paneKey, tabId, worktreeId, sessionId }) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
state.setAgentStatus(
|
||||
state.recordAgentProviderSession(
|
||||
paneKey,
|
||||
{ state: 'working', prompt: 'copy session id', agentType: 'claude' },
|
||||
'Claude',
|
||||
'claude',
|
||||
{ key: 'session_id', id: sessionId },
|
||||
undefined,
|
||||
{ tabId, worktreeId },
|
||||
{ providerSession: { key: 'session_id', id: sessionId } }
|
||||
{ tabId, worktreeId }
|
||||
)
|
||||
},
|
||||
{ paneKey, tabId, worktreeId, sessionId: SESSION_ID }
|
||||
@@ -55,16 +52,22 @@ test('terminal tab context menu copies the active agent session ID', async ({ or
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ paneKey }) =>
|
||||
window.__store?.getState().agentStatusByPaneKey[paneKey]?.providerSession?.id,
|
||||
window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id,
|
||||
{ paneKey }
|
||||
),
|
||||
{ timeout: 3_000 }
|
||||
)
|
||||
.toBe(SESSION_ID)
|
||||
|
||||
const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click({ button: 'right' })
|
||||
await openTerminalContextMenu(orcaPage)
|
||||
|
||||
const identityItems = await orcaPage.getByRole('menuitem').allInnerTexts()
|
||||
const sessionIdIndex = identityItems.indexOf('Copy Session ID')
|
||||
expect(identityItems.slice(sessionIdIndex, sessionIdIndex + 3)).toEqual([
|
||||
'Copy Session ID',
|
||||
'Copy Terminal ID',
|
||||
'Copy Pane ID'
|
||||
])
|
||||
|
||||
const copyItem = orcaPage.getByRole('menuitem', { name: 'Copy Session ID', exact: true })
|
||||
await expect(copyItem).toBeVisible()
|
||||
Reference in New Issue
Block a user