diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 9aed41a0cdc..8e939401a47 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -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 {}, onCopyTerminalId: () => {}, onCopyPaneId: () => {}, + canCopyAgentSessionId: false, + onCopyAgentSessionId: () => {}, canClosePane: false, onClosePane: () => {} } @@ -219,6 +223,15 @@ export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatCont 'Set Title…' )} + {actions.canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} {translate( diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index e51c599f669..9d3e506e3f1 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -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') - }) - }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index b298072f9f6..53b31012d39 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -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 ? {renameShortcut} : null} -
{translate('auto.components.tab.bar.SortableTabContextMenu.35e8892fd0', 'Tab Color')} diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx deleted file mode 100644 index da857ec57bf..00000000000 --- a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx +++ /dev/null @@ -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 - }) => ( - - ) -})) - -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()) - 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') - ) - }) -}) diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx deleted file mode 100644 index 73f3f128e5d..00000000000 --- a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx +++ /dev/null @@ -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 { - 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 ( - { - void copySessionId(sessionId) - }} - > - - {label} - - ) -} diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts deleted file mode 100644 index 2f06f0a8db7..00000000000 --- a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts +++ /dev/null @@ -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 { - return { - state: 'done', - prompt: '', - updatedAt: 1, - stateStartedAt: 1, - paneKey: `tab-1:${LEAF_A}`, - agentType: 'claude', - stateHistory: [], - ...overrides - } -} - -function state(overrides: Partial = {}): 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() - }) - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.ts deleted file mode 100644 index e0d804bc4c5..00000000000 --- a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts +++ /dev/null @@ -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 - terminalLayoutsByTabId?: Record - paneForegroundAgentByPaneKey?: Record -} - -/** 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 -} diff --git a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx index 54f762d4611..c81eae348dd 100644 --- a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx @@ -13,7 +13,6 @@ import { const TAB_MENU_SOURCES = [ 'EditorFileTabContextMenu.tsx', 'SortableTabContextMenu.tsx', - 'TabAgentSessionIdMenuItem.tsx', 'BrowserTab.tsx', 'TabWorkspaceLayoutMenuSection.tsx', 'TerminalTabSplitMenuSection.tsx' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index bd2ca330f5c..d8dc54c954b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -92,6 +92,8 @@ function renderMenu(overrides: Record = {}): 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)' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 178ab0a3247..8311fb3df9d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -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} ) : null} + {canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} {translate( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index 73c4220dec5..1b5e94b4a11 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -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) }} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index fff316dabce..fc33cab3f18 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -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()} /> { + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts b/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts new file mode 100644 index 00000000000..59ab6f38e86 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts @@ -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 + sleepingAgentSessionsByPaneKey: Record + paneForegroundAgentByPaneKey: Record +} + +/** 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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts index 6debb3e3d5f..9a13d026d08 100644 --- a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts +++ b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts @@ -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 onSuccess: () => void onError: () => void focus: () => void }): Promise { try { - await args.writeClipboardText(args.paneKey) + await args.writeClipboardText(args.text) args.onSuccess() } catch { args.onError() diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts index d6aac273ed8..1b14b379f2a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts index 206a31e7177..c42bbd31919 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts index 62051f84f26..417f920af5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts @@ -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 => { 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 => { + 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() + }) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index a6f04fca5f3..155482f2163 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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 @@ -57,6 +61,7 @@ type TerminalMenuState = { onSelectAll: () => void onCopyTerminalId: () => Promise onCopyPaneId: () => Promise + onCopyAgentSessionId: () => Promise onPaste: () => Promise onSplitRight: () => void onSplitDown: () => void @@ -170,6 +175,14 @@ export function useTerminalPaneContextMenu({ const onCopyTerminalId = async (): Promise => copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId) + const onCopyAgentSessionId = async (): Promise => { + const pane = resolveMenuPane() + const sessionId = pane + ? resolvePaneAgentSessionId(useAppStore.getState(), makePaneKey(tabId, pane.leafId)) + : null + return copyTerminalPaneMenuAgentSessionId(pane, sessionId) + } + const onPaste = async (): Promise => pasteResolvedPane('context-menu') const onEqualizePaneSizes = (): void => { @@ -275,6 +288,7 @@ export function useTerminalPaneContextMenu({ onSelectAll, onCopyTerminalId, onCopyPaneId, + onCopyAgentSessionId, onPaste, onSplitRight, onSplitDown, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index 3e4ff6f6efc..ea179b6925e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -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, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7d621d77751..f1489d6754d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4736cdbbbd4..79731f3b096 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 315e5775442..6afc506b12b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4a196c62304..c2836df4c40 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index edbd96e2623..77f56e805ae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/tests/e2e/tab-context-menu-session-id.spec.ts b/tests/e2e/terminal-context-menu-session-id.spec.ts similarity index 59% rename from tests/e2e/tab-context-menu-session-id.spec.ts rename to tests/e2e/terminal-context-menu-session-id.spec.ts index cab18bace32..dc58567cefa 100644 --- a/tests/e2e/tab-context-menu-session-id.spec.ts +++ b/tests/e2e/terminal-context-menu-session-id.spec.ts @@ -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()