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:
Jinjing
2026-09-02 13:05:24 -07:00
committed by GitHub
parent 084dbbc3b3
commit ae1dab40d6
26 changed files with 335 additions and 437 deletions
@@ -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,
+8 -4
View File
@@ -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": {
+8 -4
View File
@@ -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": {
+8 -4
View File
@@ -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": {
+8 -4
View File
@@ -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": {
+8 -4
View File
@@ -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": {
@@ -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()