Open existing agent chats and retained panes from their rows (#34650)

* fix(agents): open existing chat and retained pane destinations

* fix: keep agent navigation scoped to the current destination owner

* test: initialize dashboard tabs without a type assertion

* fix: complete navigation test and English catalog contracts
This commit is contained in:
Brennan Benson
2026-10-28 01:39:00 -07:00
committed by GitHub
parent cd1fe07182
commit 612febdadd
18 changed files with 711 additions and 88 deletions
@@ -4337,9 +4337,6 @@
"dashboardPopout.card.you": {
"ko": "나"
},
"dashboardPopout.terminal.closed": {
"ko": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다."
},
"dashboardPopout.terminal.focusWorktree": {
"ko": "워크트리 열기"
},
@@ -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(
<AgentTerminalDialog
card={card({ ptyId: null, hostKind: 'local' })}
@@ -111,7 +111,9 @@ describe('AgentTerminalDialog', () => {
/>
)
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', () => {
@@ -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(<AgentTerminalPreview ptyId="pty-gone" />)
await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument())
await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument())
view.rerender(<AgentTerminalPreview ptyId="pty-live" />)
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(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument())
await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument())
})
})
@@ -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.'
)
}
@@ -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<typeof RuntimeRpcClient>()),
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<typeof WebRuntimeSessionEnvironment>()),
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(
<TooltipProvider>
<AgentKanbanBoard
snapshot={{ ...snapshot, cards: [card] }}
onAckAgent={() => {}}
onRevealAgent={revealDashboardAgent}
/>
</TooltipProvider>
)
fireEvent.click(screen.getByRole('button', { name: /Open this conversation/ }))
fireEvent.click(screen.getByRole('button', { name: 'Open worktree' }))
const assertDestination = async (): Promise<void> => {
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(
<TooltipProvider>
<WorktreeCardAgents worktreeId={worktree.id} />
</TooltipProvider>
)
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(
<TooltipProvider>
<WorktreeCardAgents worktreeId={worktree.id} />
</TooltipProvider>
)
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')
})
})
@@ -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<Tab>()
}))
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()
})
})
@@ -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,
@@ -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<string, { id: string }[]> = {}
let mockTerminalLayoutsByTabId: Record<string, unknown> = {}
let mockPtyIdsByTabId: Record<string, string[]> = {}
let mockUnifiedTabs: Tab[] = []
let mockLayouts: Record<string, TerminalLayoutSnapshot> = {}
let mockPtyIds: Record<string, string[]> = {}
let mockStructuredTabIds = new Set<string>()
let mockAgentStatusByPaneKey: Record<string, { worktreeId?: string }> = {}
let mockActiveTabId: string | null = null
@@ -85,8 +88,9 @@ function buildMockStoreState(): Record<string, unknown> {
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<string, unknown> {
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<void> {
mockAgentActivityDisplayMode = mode
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
if (mode === 'full') {
renderToStaticMarkup(<WorktreeCardAgents worktreeId={workspaceId} />)
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(
<TooltipProvider>
<WorktreeCardAgents worktreeId={workspaceId} />
</TooltipProvider>
)
)
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(<WorktreeCardAgents worktreeId="wt-1" />)
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'
@@ -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)
@@ -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)
}
}
+1 -1
View File
@@ -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"
-1
View File
@@ -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."
-1
View File
@@ -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."
-1
View File
@@ -18831,7 +18831,6 @@
"subagents_other": "{{count}} 個のサブ Agent"
},
"terminal": {
"closed": "実行中のターミナルがありません。この Agent のペインは閉じられています。",
"focusWorktree": "ワークツリーを開く",
"close": "閉じる",
"remotePreviewUnavailable": "このリモートセッションにはプレビューがありません。ワークスペースを開いてターミナルを表示してください。"
-1
View File
@@ -18831,7 +18831,6 @@
"subagents_other": "서브에이전트 {{count}}개"
},
"terminal": {
"closed": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다.",
"focusWorktree": "워크트리 열기",
"close": "닫기",
"remotePreviewUnavailable": "이 원격 세션에 대한 미리 보기가 없습니다. 터미널을 보려면 워크스페이스를 엽니다."
-1
View File
@@ -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"
-1
View File
@@ -18832,7 +18832,6 @@
"subagents_other": "{{count}} 个子智能体"
},
"terminal": {
"closed": "没有活动的终端 — 该智能体的窗格已关闭。",
"focusWorktree": "聚焦工作树",
"close": "关闭",
"remotePreviewUnavailable": "此远程会话没有预览 - 打开工作区以查看终端。"
+1 -1
View File
@@ -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