diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 467a38ffec4..5a6f18751bb 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -4337,9 +4337,6 @@ "dashboardPopout.card.you": { "ko": "나" }, - "dashboardPopout.terminal.closed": { - "ko": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다." - }, "dashboardPopout.terminal.focusWorktree": { "ko": "워크트리 열기" }, diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 7dfaa783d10..8983bb116b4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -102,7 +102,7 @@ describe('AgentTerminalDialog', () => { expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument() }) - it('still reports a closed pane for a local card with no live pty', () => { + it('keeps a local agent openable without claiming its missing preview means closure', () => { render( { /> ) - expect(screen.getByText(/pane has closed/)).toBeInTheDocument() + expect(screen.getByText(/No preview available/)).toBeInTheDocument() + expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open worktree' })).toBeEnabled() }) it('labels acknowledged completions idle without review or pin controls', () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 25b7b7c1f65..97217ab9f21 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -669,7 +669,7 @@ describe('AgentTerminalPreview', () => { expect(terminalHarness.instances).toHaveLength(1) expect(terminal.dispose).not.toHaveBeenCalled() expect(terminal.reset).not.toHaveBeenCalled() - expect(view.queryByText(/No live terminal/)).not.toBeInTheDocument() + expect(view.queryByText(/No preview available/)).not.toBeInTheDocument() await act(async () => { resolveRefresh({ @@ -681,7 +681,7 @@ describe('AgentTerminalPreview', () => { expect(terminal.resize).toHaveBeenCalledWith(100, 30) expect(terminalHarness.instances).toHaveLength(1) expect(terminal.dispose).not.toHaveBeenCalled() - expect(view.queryByText(/No live terminal/)).not.toBeInTheDocument() + expect(view.queryByText(/No preview available/)).not.toBeInTheDocument() }) it('disposes a stale terminal when resync confirms the pty is gone', async () => { @@ -697,7 +697,7 @@ describe('AgentTerminalPreview', () => { act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' })) - await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument()) + await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument()) expect(terminal.dispose).toHaveBeenCalledTimes(1) expect(terminalHarness.userInputDispose).toHaveBeenCalledTimes(1) expect(unsubscribe).toHaveBeenCalledWith('pty-1') @@ -717,13 +717,13 @@ describe('AgentTerminalPreview', () => { replay: [] }) const view = render() - await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument()) + await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument()) view.rerender() await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) expect(connect).toHaveBeenLastCalledWith('pty-live', { scrollbackRows: 24 }) - expect(view.queryByText(/No live terminal/)).not.toBeInTheDocument() + expect(view.queryByText(/No preview available/)).not.toBeInTheDocument() }) it('claims a grid sized to the dialog box and never re-requests an unchanged target', async () => { @@ -810,6 +810,6 @@ describe('AgentTerminalPreview', () => { Object.assign(window.api.terminalPreview, { connect: async () => undefined }) const view = render() - await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument()) + await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument()) }) }) diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts index 9140c1614f9..eb0529f1044 100644 --- a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts +++ b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts @@ -19,7 +19,7 @@ export function terminalPreviewUnavailableMessage(source: { 'No preview for this remote session — open the workspace to view the terminal.' ) : translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." + 'dashboardPopout.terminal.previewUnavailable', + 'No preview available — open the workspace to view this agent.' ) } diff --git a/src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx b/src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx new file mode 100644 index 00000000000..1d2b288c136 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + makeCreatedAgentWorktree, + seedAlreadyActiveWorktree +} from '@/lib/worktree-activation-created-agent.test-fixture' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Tab } from '../../../../shared/tab-types' +import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' +import type * as WebRuntimeSessionEnvironment from '@/runtime/web-runtime-session-environment' +import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' +import WorktreeCardAgents from '../sidebar/WorktreeCardAgents' +import { TooltipProvider } from '@/components/ui/tooltip' +import { buildDashboardSnapshot } from './build-dashboard-snapshot' +import { revealDashboardAgent } from './reveal-dashboard-agent' +import { activateRetainedAgentRow } from '../sidebar/activate-retained-agent-row' + +const effects = vi.hoisted(() => ({ + rpc: vi.fn(async () => ({ ok: true })), + composerFocus: vi.fn(), + environmentCall: vi.fn(async (_environmentId: string, _args: unknown) => ({ + id: 'navigation-test', + ok: false as const, + error: { code: 'unavailable', message: 'Unit-test transport boundary' } + })) +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc: effects.rpc +})) +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ + requestNativeChatComposerFocus: effects.composerFocus +})) +vi.mock('../dashboard-popout/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null })) +vi.mock('@/runtime/web-runtime-session-environment', async (importOriginal) => ({ + ...(await importOriginal()), + captureRuntimeEnvironmentCall: (environmentId: string) => (args: unknown) => + effects.environmentCall(environmentId, args) +})) + +const initialState = useAppStore.getState() +const LEAF = '11111111-1111-4111-8111-111111111111' +const WORKSPACE_HOST = 'runtime:host-A' as const +const TAB_HOST = 'runtime:host-B' as const +const NOW = 1_000_000_000 + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.clearAllMocks() +}) + +describe('Dashboard and retained sidebar destination contract', () => { + it.each([ + ['worktree', 'native'], + ['folder', 'native'], + ['worktree', 'terminal'], + ['folder', 'terminal'] + ] as const)( + 'opens the exact %s %s destination and preserves reveal failure', + async (scope, surface) => { + const native = surface === 'native' + const owner = native ? TAB_HOST : WORKSPACE_HOST + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: WORKSPACE_HOST, + ...(scope === 'folder' ? { id: folderWorkspaceKey('folder-1') } : {}) + } + seedAlreadyActiveWorktree(worktree) + const seeded = useAppStore.getState() + const terminal = { ...seeded.tabsByWorktree[worktree.id][0], executionHostId: owner } + const tabId = native ? 'native-B' : terminal.id + const paneKey = makePaneKey(tabId, LEAF) + const group = seeded.groupsByWorktree[worktree.id][0] + const nativeTab: Tab = { + id: tabId, + worktreeId: worktree.id, + executionHostId: TAB_HOST, + contentType: 'agent-session', + entityId: 'session-B', + groupId: group.id, + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: NOW + } + const folder = { + id: 'folder-1', + projectGroupId: 'project-1', + name: 'Notes', + folderPath: '/workspace/notes', + executionHostId: WORKSPACE_HOST, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + createdAt: NOW, + updatedAt: NOW + } + useAppStore.setState({ + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + activeTabType: 'editor', + agentActivityDisplayMode: 'compact', + repos: scope === 'folder' ? [] : seeded.repos, + worktreesByRepo: + scope === 'folder' + ? {} + : { + [worktree.repoId]: [ + worktree, + ...(native ? [{ ...worktree, hostId: TAB_HOST }] : []) + ] + }, + folderWorkspaces: + scope === 'folder' + ? [folder, ...(native ? [{ ...folder, executionHostId: TAB_HOST }] : [])] + : [], + getFreshFolderWorkspacePathStatus: () => ({ path: folder.folderPath, exists: true }), + tabsByWorktree: { [worktree.id]: [terminal] }, + unifiedTabsByWorktree: { + [worktree.id]: [ + ...seeded.unifiedTabsByWorktree[worktree.id], + ...(native ? [nativeTab] : []) + ] + }, + groupsByWorktree: { + [worktree.id]: [{ ...group, tabOrder: [...group.tabOrder, ...(native ? [tabId] : [])] }] + }, + terminalLayoutsByTabId: native + ? {} + : { + [tabId]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null + } + }, + retainedAgentsByPaneKey: { + [paneKey]: { + entry: { + paneKey, + tabId, + worktreeId: worktree.id, + state: 'done', + agentType: 'codex', + prompt: 'Open this conversation', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [] + }, + worktreeId: worktree.id, + tab: { ...terminal, id: tabId, ptyId: native ? null : terminal.ptyId }, + agentType: 'codex', + startedAt: NOW + } + } + }) + + const snapshot = buildDashboardSnapshot( + { ...useAppStore.getState(), chatNames: new Map() }, + NOW + ) + const card = snapshot.cards.find((candidate) => candidate.executionHostId === WORKSPACE_HOST) + expect(card).toMatchObject({ worktreeId: worktree.id, tabId, ptyId: null }) + if (!card) { + throw new Error('Expected a card from the production snapshot builder') + } + const view = render( + + {}} + onRevealAgent={revealDashboardAgent} + /> + + ) + fireEvent.click(screen.getByRole('button', { name: /Open this conversation/ })) + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + + const assertDestination = async (): Promise => { + const state = useAppStore.getState() + expect(state.activeWorktreeId).toBe(worktree.id) + expect(getExecutionHostIdForWorktree(state, worktree.id)).toBe(owner) + expect(state.activeTabType).toBe(native ? 'agent-session' : 'terminal') + if (!native) { + expect(state.activeTabId).toBe(tabId) + } + expect(state.getActiveTab(worktree.id)?.id).toBe(tabId) + expect(state.groupsByWorktree[worktree.id][0].activeTabId).toBe(tabId) + if (native) { + expect(effects.composerFocus).toHaveBeenLastCalledWith(tabId) + expect(effects.rpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'host-B' }, + 'session.tabs.activate', + expect.objectContaining({ tabId: 'agent-session:session-B' }) + ) + } else { + await vi.waitFor(() => + expect(effects.environmentCall).toHaveBeenCalledWith( + 'host-A', + expect.objectContaining({ + method: 'session.tabs.activate', + params: expect.objectContaining({ leafId: LEAF }) + }) + ) + ) + } + } + await assertDestination() + view.unmount() + + useAppStore.setState({ + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + activeTabType: 'editor', + activeTabId: null + }) + const sidebar = render( + + + + ) + const row = sidebar.container.querySelector('.compact-agent-row') + if (!row) { + throw new Error('Expected the retained sidebar row') + } + effects.environmentCall.mockClear() + fireEvent.click(row) + await assertDestination() + + // Keep the exact tab alive while its workspace disappears: neither entry may focus or notify it. + cleanup() + useAppStore.setState({ + worktreesByRepo: {}, + folderWorkspaces: [], + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + activeTabType: 'editor', + activeTabId: null + }) + effects.rpc.mockClear() + effects.composerFocus.mockClear() + effects.environmentCall.mockClear() + expect(revealDashboardAgent(card)).toBe(false) + const missing = render( + + + + ) + const missingRow = missing.container.querySelector('.compact-agent-row') + if (!missingRow) { + throw new Error('Expected the retained row after workspace removal') + } + fireEvent.click(missingRow) + expect(useAppStore.getState()).toMatchObject({ + activeWorktreeId: null, + activeTabId: null, + activeTabType: 'editor' + }) + expect(effects.composerFocus).not.toHaveBeenCalled() + expect(effects.rpc).not.toHaveBeenCalled() + expect(effects.environmentCall).not.toHaveBeenCalled() + } + ) + + it('notifies the exact live retained terminal owner instead of the workspace default', async () => { + const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST } + seedAlreadyActiveWorktree(worktree, { + worktreesByRepo: { [worktree.repoId]: [worktree, { ...worktree, hostId: TAB_HOST }] }, + activeWorkspaceExecutionHostId: WORKSPACE_HOST, + ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' } + } + } + }) + + activateRetainedAgentRow(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF)) + expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(TAB_HOST) + await vi.waitFor(() => + expect(effects.environmentCall).toHaveBeenCalledWith( + 'host-B', + expect.objectContaining({ + method: 'session.tabs.activate', + params: expect.objectContaining({ leafId: LEAF }) + }) + ) + ) + expect( + effects.environmentCall.mock.calls.every(([environmentId]) => environmentId === 'host-B') + ).toBe(true) + expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe('tab-1') + }) +}) diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts index f1a8c8c8378..a6bffe19112 100644 --- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts +++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts @@ -1,57 +1,179 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +// @vitest-environment happy-dom + +import { createElement } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' const mocks = vi.hoisted(() => ({ activateAndRevealWorkspace: vi.fn(), activateTabAndFocusPane: vi.fn(), - activateTerminalTabOnOwner: vi.fn() + activateTerminalTabOnOwner: vi.fn(), + callRuntimeRpc: vi.fn(async () => ({ ok: true })), + requestNativeChatComposerFocus: vi.fn(), + focusGroup: vi.fn(), + activateTab: vi.fn(), + setActiveTabType: vi.fn(), + tabs: new Array() })) - +const getState = () => ({ + unifiedTabsByWorktree: { 'wt-1': mocks.tabs }, + focusGroup: mocks.focusGroup, + activateTab: mocks.activateTab, + setActiveTabType: mocks.setActiveTabType +}) +vi.mock('@/store', () => ({ useAppStore: { getState: () => getState() } })) +vi.mock('@/store/index', () => ({ useAppStore: { getState: () => getState() } })) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + runtimeEnvironmentIdOrLocalUnlessTwin: () => 'host-A' +})) vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: mocks.activateTabAndFocusPane })) vi.mock('@/lib/terminal-tab-owner-activation', () => ({ activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ + requestNativeChatComposerFocus: mocks.requestNativeChatComposerFocus +})) +vi.mock('@/runtime/runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (id: string) => `id:${id}` +})) +vi.mock('../dashboard-popout/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null })) + +import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' +import { TooltipProvider } from '@/components/ui/tooltip' import { revealDashboardAgent } from './reveal-dashboard-agent' const args = { repoId: 'repo-1', worktreeId: 'wt-1', - executionHostId: 'runtime:env-1' as const, - tabId: 'web-terminal-host-tab', + executionHostId: 'runtime:host-A' as const, + tabId: 'agent-tab', leafId: '11111111-1111-4111-8111-111111111111' } +function nativeTab(agent: 'claude' | 'codex'): Tab { + return { + id: args.tabId, + worktreeId: args.worktreeId, + executionHostId: 'runtime:host-B', + contentType: 'agent-session', + entityId: 'session-1', + groupId: 'group-1', + label: agent, + agentSessionAgent: agent, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +afterEach(cleanup) describe('revealDashboardAgent', () => { beforeEach(() => { vi.clearAllMocks() + mocks.tabs = [] + mocks.activateAndRevealWorkspace.mockReturnValue(true) }) - it("focuses the card's own tab on the workspace's owner", () => { - mocks.activateAndRevealWorkspace.mockReturnValue(true) + it.each(['claude', 'codex'] as const)( + 'opens an existing %s chat without a preview PTY on its own host', + (agent) => { + mocks.tabs = [nativeTab(agent)] + expect(revealDashboardAgent({ ...args, leafId: null })).toBe(true) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { + executionHostId: 'runtime:host-B' + }) + expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') + expect(mocks.activateTab).toHaveBeenCalledWith(args.tabId, { worktreeId: 'wt-1' }) + expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session', 'wt-1') + expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith(args.tabId) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'host-B' }, + 'session.tabs.activate', + { worktree: 'id:wt-1', tabId: 'agent-session:session-1' } + ) + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTab.mock.invocationCallOrder[0] + ) + } + ) + it('opens a native destination through the rendered board and dialog action', () => { + mocks.tabs = [nativeTab('codex')] + const card = { + ...args, + leafId: null, + paneKey: 'agent-tab:leaf', + ptyId: null, + agentType: 'codex' as const, + bucket: 'working' as const, + dotState: 'working' as const, + task: 'Open this conversation', + repoName: 'Repo', + worktreeName: 'Workspace', + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false + } + render( + createElement( + TooltipProvider, + null, + createElement(AgentKanbanBoard, { + snapshot: { generatedAt: 0, cards: [card] }, + onAckAgent: () => {}, + onRevealAgent: revealDashboardAgent + }) + ) + ) + fireEvent.click(screen.getByRole('button', { name: /Open this conversation/ })) + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session', 'wt-1') + expect(mocks.activateTab).toHaveBeenCalledWith(args.tabId, { worktreeId: 'wt-1' }) + expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith(args.tabId) + expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() + }) + + it('does not fall back to a terminal when the current native owner has no runtime', () => { + mocks.tabs = [{ ...nativeTab('codex'), executionHostId: 'ssh:host-C' }] + expect(revealDashboardAgent(args)).toBe(false) + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('keeps the terminal route when a native card was closed before its click', () => { + mocks.tabs = [nativeTab('codex')] + mocks.tabs = [] expect(revealDashboardAgent(args)).toBe(true) - expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith('wt-1', args.tabId, args.leafId) expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith(args.tabId, args.leafId, { flashFocusedPane: true }) - // Why: the owner call captures the visible tab, so it must follow the local activation. + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() expect(mocks.activateTabAndFocusPane.mock.invocationCallOrder[0]).toBeLessThan( mocks.activateTerminalTabOnOwner.mock.invocationCallOrder[0] ) }) - it('touches no tab when the workspace cannot be revealed', () => { + it.each([false, true])('touches no tab when workspace reveal fails (native=%s)', (native) => { + mocks.tabs = native ? [nativeTab('codex')] : [] mocks.activateAndRevealWorkspace.mockReturnValue(false) - expect(revealDashboardAgent(args)).toBe(false) - expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts index 415d6777dbb..5bd062be10c 100644 --- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts @@ -1,7 +1,37 @@ import { revealTerminalPane } from '@/lib/reveal-terminal-pane' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' +import { + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForTab +} from '@/runtime/structured-agent-session-owner' +import { useAppStore } from '@/store' import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot' export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean { + const state = useAppStore.getState() + const tab = state.unifiedTabsByWorktree[args.worktreeId]?.find( + (candidate) => + candidate.id === args.tabId && + candidate.worktreeId === args.worktreeId && + candidate.contentType === 'agent-session' + ) + if (tab) { + const owner = structuredAgentSessionOwnerForTab(state, tab) + const target = structuredAgentSessionTargetForTab(state, tab) + if (!target) { + return false + } + if ( + activateAndRevealWorkspace( + args.worktreeId, + owner ? { executionHostId: owner } : undefined + ) === false + ) { + return false + } + return activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id, target }) + } return revealTerminalPane({ worktreeId: args.worktreeId, tabId: args.tabId, diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 422a7089432..24307ae0a7f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { TooltipProvider } from '@/components/ui/tooltip' import type * as ActivateTabAndFocusPaneModule from '@/lib/activate-tab-and-focus-pane' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { makePaneKey } from '../../../../shared/stable-pane-id' const LEAF_A = '11111111-1111-4111-8111-111111111111' @@ -51,8 +53,9 @@ function mockAgent({ let mockAgents: DashboardAgentRowData[] = [] let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined let mockTabsByWorktree: Record = {} -let mockTerminalLayoutsByTabId: Record = {} -let mockPtyIdsByTabId: Record = {} +let mockUnifiedTabs: Tab[] = [] +let mockLayouts: Record = {} +let mockPtyIds: Record = {} let mockStructuredTabIds = new Set() let mockAgentStatusByPaneKey: Record = {} let mockActiveTabId: string | null = null @@ -85,8 +88,9 @@ function buildMockStoreState(): Record { setActiveTab: mockSetActiveTab, setActiveTabType: mockSetActiveTabType, tabsByWorktree: mockTabsByWorktree, - terminalLayoutsByTabId: mockTerminalLayoutsByTabId, - ptyIdsByTabId: mockPtyIdsByTabId, + unifiedTabsByWorktree: { 'wt-1': mockUnifiedTabs }, + terminalLayoutsByTabId: mockLayouts, + ptyIdsByTabId: mockPtyIds, runtimePaneTitlesByTabId: {}, sendPromptToSidebarAgentTarget: vi.fn(), settings: { @@ -98,6 +102,7 @@ function buildMockStoreState(): Record { const activationMocks = vi.hoisted(() => ({ activateAndRevealWorktree: vi.fn(), + activateAndRevealWorkspace: vi.fn(), activateTabAndFocusPane: vi.fn() })) @@ -123,7 +128,8 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: activationMocks.activateAndRevealWorktree + activateAndRevealWorktree: activationMocks.activateAndRevealWorktree, + activateAndRevealWorkspace: activationMocks.activateAndRevealWorkspace })) vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ @@ -173,10 +179,12 @@ describe('WorktreeCardAgents activation', () => { activationMocks.activateAndRevealWorktree.mockImplementation(() => undefined) activationMocks.activateTabAndFocusPane.mockImplementation(() => undefined) mockAgents = [] + mockUnifiedTabs = [] + mockLayouts = {} + mockPtyIds = {} + activationMocks.activateAndRevealWorkspace.mockReturnValue(true) mockAgentActivityDisplayMode = undefined mockTabsByWorktree = {} - mockTerminalLayoutsByTabId = {} - mockPtyIdsByTabId = {} mockStructuredTabIds = new Set() mockAgentStatusByPaneKey = {} mockActiveTabId = null @@ -187,54 +195,179 @@ describe('WorktreeCardAgents activation', () => { ) }) - it.each([ - ['opens a retained row whose exact pane still has a live PTY', ['pty-a'], true], - ['keeps a retained row inert when its pane PTY is no longer live', [], false] - ])('%s (#20803)', async (_name, livePtyIds, shouldActivate) => { - mockAgentActivityDisplayMode = 'full' - const tabId = 'retained-tab' - const paneKey = makePaneKey(tabId, LEAF_A) + async function clickRetained(mode: 'compact' | 'full', workspaceId = 'wt-1'): Promise { + mockAgentActivityDisplayMode = mode + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + if (mode === 'full') { + renderToStaticMarkup() + capturedRowActivations[0].onActivate('retained-tab', makePaneKey('retained-tab', LEAF_A)) + return + } + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + try { + await act(async () => + root.render( + + + + ) + ) + const row = host.querySelector('.compact-agent-row') + expect(row).toBeInstanceOf(HTMLElement) + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + } finally { + act(() => root.unmount()) + host.remove() + } + } + + function retainedRow(): void { mockAgents = [ { - ...mockAgent({ paneKey, tabId, agentType: 'codex', prompt: 'Idle', worktreeId: 'wt-1' }), + ...mockAgent({ + paneKey: makePaneKey('retained-tab', LEAF_A), + tabId: 'retained-tab', + agentType: 'codex', + prompt: 'Retained', + worktreeId: 'wt-1' + }), rowSource: 'retained', state: 'done' } ] - mockTabsByWorktree = { 'wt-1': [{ id: tabId }] } - mockTerminalLayoutsByTabId = { - [tabId]: { + mockTabsByWorktree = { 'wt-1': [{ id: 'retained-tab' }] } + mockLayouts = { + 'retained-tab': { + root: { type: 'leaf', leafId: LEAF_A }, + activeLeafId: LEAF_A, + expandedLeafId: null + } + } + } + + it.each([ + ['compact', false], + ['compact', true], + ['full', false], + ['full', true] + ] as const)( + 'opens a retained remote pane without status in %s mode (split=%s)', + async (mode, split) => { + retainedRow() + mockPtyIds = { 'retained-tab': ['remote:host-B@@pty-1'] } + if (split) { + mockLayouts['retained-tab'].root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + } + mockLayouts['retained-tab'].activeLeafId = LEAF_B + mockLayouts['retained-tab'].ptyIdsByLeafId = { + [LEAF_A]: 'remote:host-B@@pty-1', + [LEAF_B]: 'remote:host-B@@pty-2' + } + mockPtyIds['retained-tab'].push('remote:host-B@@pty-2') + } + await clickRetained(mode) + expect(activationMocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { + executionHostId: 'runtime:host-B' + }) + expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(activationMocks.activateTabAndFocusPane).toHaveBeenCalledWith( + 'retained-tab', + LEAF_A, + expect.objectContaining({ ackPaneKeyOnSuccess: makePaneKey('retained-tab', LEAF_A) }) + ) + expect(ownerActivationMocks.activateTerminalTabOnOwner).toHaveBeenCalledWith( + 'wt-1', + 'retained-tab', + LEAF_A + ) + } + ) + + it.each([ + 'missing', + 'sleeping', + 'stale', + 'other-split', + 'foreign-owner', + 'reveal-failed' + ] as const)('keeps a retained %s destination inert', async (shape) => { + retainedRow() + mockPtyIds = { 'retained-tab': ['live-pty'] } + if (shape === 'missing') { + mockTabsByWorktree = {} + } + if (shape === 'sleeping') { + mockPtyIds = {} + mockLayouts['retained-tab'].ptyIdsByLeafId = { [LEAF_A]: 'persisted-pty' } + } + if (shape === 'stale') { + mockLayouts['retained-tab'].ptyIdsByLeafId = { [LEAF_A]: 'exited-pty' } + } + if (shape === 'other-split') { + mockLayouts['retained-tab'] = { root: { type: 'split', - direction: 'vertical', + direction: 'horizontal', first: { type: 'leaf', leafId: LEAF_A }, second: { type: 'leaf', leafId: LEAF_B } }, activeLeafId: LEAF_B, expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } + ptyIdsByLeafId: { [LEAF_A]: 'exited-pty', [LEAF_B]: 'live-pty' } } } - mockPtyIdsByTabId = { [tabId]: [...livePtyIds, 'pty-b'] } - const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') - - renderToStaticMarkup() - capturedRowActivations[0].onActivate(tabId, paneKey) - - if (shouldActivate) { - expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') - expect(activationMocks.activateTabAndFocusPane).toHaveBeenCalledWith( - tabId, - LEAF_A, - expect.anything() - ) - } else { - expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + if (shape === 'foreign-owner') { + mockPtyIds = { 'retained-tab': ['remote:unscoped-handle'] } } + if (shape === 'reveal-failed') { + activationMocks.activateAndRevealWorkspace.mockReturnValue(false) + } + await clickRetained('full') + if (shape !== 'reveal-failed') { + expect(activationMocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + } + expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(ownerActivationMocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() expect(staleAgentRowMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled() }) + it('opens a retained native tab without a PTY using its explicit owner', async () => { + retainedRow() + mockTabsByWorktree = {} + mockUnifiedTabs = [ + { + id: 'retained-tab', + entityId: 'session-1', + contentType: 'agent-session', + worktreeId: 'wt-1', + executionHostId: 'runtime:host-B', + groupId: 'g1', + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + mockStructuredTabIds.add('retained-tab') + await clickRetained('full') + expect(structuredActivationMocks.activateStructuredAgentSessionTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'retained-tab', + target: { kind: 'environment', environmentId: 'host-B' } + }) + expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(ownerActivationMocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() + }) + it('activates a projected structured session row through the unified tab path', async () => { mockAgentActivityDisplayMode = 'full' const tabId = 'structured-tab' diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index d10162348cc..d3389422e9a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -28,8 +28,8 @@ import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' +import { activateRetainedAgentRow } from './activate-retained-agent-row' import { selectAgentRowsUnread } from './worktree-card-agent-ack-inputs' -import { stablePaneHasLivePty } from '@/lib/sleeping-agent-pane-ownership' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -187,24 +187,8 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [worktreeId] ) const handleActivateRetainedAgent = useCallback( - (tabId: string, paneKey: string) => { - const parsed = parsePaneKey(paneKey) - const state = useAppStore.getState() - // Retained rows are completion evidence; only open a still-live pane so a sleeping target pane is never resumed. - if ( - parsed?.tabId === tabId && - state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) && - stablePaneHasLivePty( - tabId, - parsed.leafId, - state.ptyIdsByTabId, - state.terminalLayoutsByTabId[tabId] - ) - ) { - handleActivateAgentTab(tabId, paneKey) - } - }, - [handleActivateAgentTab, worktreeId] + (tabId: string, paneKey: string) => activateRetainedAgentRow(worktreeId, tabId, paneKey), + [worktreeId] ) const now = useNow(30_000) diff --git a/src/renderer/src/components/sidebar/activate-retained-agent-row.ts b/src/renderer/src/components/sidebar/activate-retained-agent-row.ts new file mode 100644 index 00000000000..9b335088379 --- /dev/null +++ b/src/renderer/src/components/sidebar/activate-retained-agent-row.ts @@ -0,0 +1,53 @@ +import { useAppStore } from '@/store' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation' +import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' +import { + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForTab +} from '@/runtime/structured-agent-session-owner' +import { stablePaneHasLivePty } from '@/lib/sleeping-agent-pane-ownership' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { getPtyExecutionHost } from '../../../../shared/terminal-execution-host' + +export function activateRetainedAgentRow(worktreeId: string, tabId: string, paneKey: string): void { + const parsed = parsePaneKey(paneKey) + if (!parsed || parsed.tabId !== tabId) { + return + } + const state = useAppStore.getState() + const nativeTab = state.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => + tab.id === tabId && tab.worktreeId === worktreeId && tab.contentType === 'agent-session' + ) + const target = nativeTab ? structuredAgentSessionTargetForTab(state, nativeTab) : null + const layout = state.terminalLayoutsByTabId[tabId] + const hasLiveTerminal = + state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) && + stablePaneHasLivePty(tabId, parsed.leafId, state.ptyIdsByTabId, layout) + const owner = nativeTab + ? structuredAgentSessionOwnerForTab(state, nativeTab) + : getPtyExecutionHost( + layout?.ptyIdsByLeafId?.[parsed.leafId] ?? state.ptyIdsByTabId[tabId]?.[0] + ) + if ((nativeTab ? !target : !hasLiveTerminal) || owner === 'foreign') { + return + } + // Verify an existing destination before workspace activation can resume sleeping panes. + if ( + activateAndRevealWorkspace(worktreeId, owner ? { executionHostId: owner } : undefined) === false + ) { + return + } + if (nativeTab && target) { + activateStructuredAgentSessionTab({ worktreeId, tabId, target }) + } else { + activateTabAndFocusPane(tabId, parsed.leafId, { + ackPaneKeyOnSuccess: paneKey, + flashFocusedPane: true, + scrollToBottomIfOutputSinceLastView: true + }) + activateTerminalTabOnOwner(worktreeId, tabId, parsed.leafId) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 05711e13dda..1579037d638 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19128,7 +19128,7 @@ "subagents_other": "{{count}} subagents" }, "terminal": { - "closed": "No live terminal — this agent's pane has closed.", + "previewUnavailable": "No preview available — open the workspace to view this agent.", "remotePreviewUnavailable": "No preview for this remote session — open the workspace to view the terminal.", "focusWorktree": "Open worktree", "close": "Close" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index dfac89a0a32..3008a57f3c0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18832,7 +18832,6 @@ "subagents_other": "{{count}} subagentes" }, "terminal": { - "closed": "No hay terminal activa: el panel de este agente se ha cerrado.", "focusWorktree": "Open worktree", "close": "Close", "remotePreviewUnavailable": "No hay vista previa para esta sesión remota: abre el espacio de trabajo para ver la terminal." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 95b95aa9617..ee8f539dbbc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19011,7 +19011,6 @@ "subagents_other": "{{count}} sous-agents" }, "terminal": { - "closed": "Pas de terminal en direct — le volet de cet agent est fermé.", "focusWorktree": "Ouvrir le worktree", "close": "Fermer", "remotePreviewUnavailable": "Aucun aperçu pour cette session à distance — ouvrez l'espace de travail pour afficher le terminal." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 548f8ee96e2..9854b50f3e6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18831,7 +18831,6 @@ "subagents_other": "{{count}} 個のサブ Agent" }, "terminal": { - "closed": "実行中のターミナルがありません。この Agent のペインは閉じられています。", "focusWorktree": "ワークツリーを開く", "close": "閉じる", "remotePreviewUnavailable": "このリモートセッションにはプレビューがありません。ワークスペースを開いてターミナルを表示してください。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2119e9ca565..0f9d1138edf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18831,7 +18831,6 @@ "subagents_other": "서브에이전트 {{count}}개" }, "terminal": { - "closed": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다.", "focusWorktree": "워크트리 열기", "close": "닫기", "remotePreviewUnavailable": "이 원격 세션에 대한 미리 보기가 없습니다. 터미널을 보려면 워크스페이스를 엽니다." diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index a9d3f5b4df4..7d9256c7566 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -509,7 +509,6 @@ "subagents_other": "{{count}} subagentes" }, "terminal": { - "closed": "Nenhum terminal ativo — o painel deste agente foi encerrado.", "remotePreviewUnavailable": "Nenhuma pré-visualização para esta sessão remota — abra o espaço de trabalho para ver o terminal.", "focusWorktree": "Abrir worktree", "close": "Fechar" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 228657abf77..90105605224 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18832,7 +18832,6 @@ "subagents_other": "{{count}} 个子智能体" }, "terminal": { - "closed": "没有活动的终端 — 该智能体的窗格已关闭。", "focusWorktree": "聚焦工作树", "close": "关闭", "remotePreviewUnavailable": "此远程会话没有预览 - 打开工作区以查看终端。" diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 16612395ce9..227425c0d69 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -74,7 +74,7 @@ export type DashboardWorkspace = { export type DashboardCard = { /** Stable identity for React keys. */ paneKey: string - /** Null when retained/done cards have no live pane; otherwise resolve the live preview PTY. */ + /** Live preview PTY; null does not imply that the agent's destination is closed. */ ptyId: string | null agentType: AgentType bucket: DashboardBucket