diff --git a/src/main/runtime/browser-tab-create-folder-workspace.test.ts b/src/main/runtime/browser-tab-create-folder-workspace.test.ts new file mode 100644 index 00000000000..23166d34976 --- /dev/null +++ b/src/main/runtime/browser-tab-create-folder-workspace.test.ts @@ -0,0 +1,59 @@ +/** + * A server-hosted browser tab for a folder workspace (#21353). Only the client-hosted branch used + * the folder-aware resolver, so `id:folder:…` failed with selector_not_found on the server path. + */ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' + +vi.mock('electron', () => ({ + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn() } +})) + +vi.mock('../ipc/browser-tab-registration-wait', () => ({ + waitForTabRegistration: vi.fn(), + waitForWorktreeTabRegistration: vi.fn() +})) + +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: { + getDefaultProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }), + getProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }), + resolveKnownPartition: () => 'persist:orca-browser' + } +})) + +const FOLDER = 'folder:folder-1' + +function createHost(overrides: Record): RuntimeBrowserCommandHost { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test host implements only the members browserTabCreate reads. + return { + resolveWorktreeSelector: vi.fn(async () => { + throw new Error('selector_not_found') + }), + resolveBrowserWorkspace: vi.fn(async () => ({ id: FOLDER })), + getAgentBrowserBridge: () => null, + getAvailableAuthoritativeWindow: vi.fn(() => null), + ...overrides + } as unknown as RuntimeBrowserCommandHost +} + +describe('browser.tabCreate on a folder workspace', () => { + it('creates a server-hosted page for the folder workspace', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const createTab = vi.fn(async () => ({ browserPageId: 'page-folder' })) + const host = createHost({ + getOffscreenBrowserBackend: vi.fn(() => ({ createTab })) + }) + + await expect( + new RuntimeBrowserCommands(host).browserTabCreate( + { worktree: `id:${FOLDER}`, url: 'about:blank' }, + { pairedDeviceId: 'device-a', clientKind: 'runtime' } + ) + ).resolves.toEqual({ browserPageId: 'page-folder' }) + + expect(host.resolveBrowserWorkspace).toHaveBeenCalledWith(`id:${FOLDER}`) + expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ worktreeId: FOLDER })) + }) +}) diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts index 67c39c7f5b7..cab26fb987c 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts +++ b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts @@ -39,10 +39,9 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo navigation: params.navigation, clientKind: caller?.clientKind }) + // Why: every placement must accept folder workspaces, not only client-hosted pages (#21353). const worktree = params.worktree - ? params.placement?.kind === 'client' - ? await this.host.resolveBrowserWorkspace(params.worktree) - : await this.host.resolveWorktreeSelector(params.worktree) + ? await this.host.resolveBrowserWorkspace(params.worktree) : undefined const worktreeId = worktree?.id const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) diff --git a/src/renderer/src/components/automations/automation-run-open-target.test.ts b/src/renderer/src/components/automations/automation-run-open-target.test.ts index 9416437e2ad..40e23c904ed 100644 --- a/src/renderer/src/components/automations/automation-run-open-target.test.ts +++ b/src/renderer/src/components/automations/automation-run-open-target.test.ts @@ -3,7 +3,6 @@ import type { AutomationRun } from '../../../../shared/automations-types' import { automationRunMatchesPaneKey, buildAutomationRunOpenLayout, - canOpenAutomationRunOpenTarget, resolveAutomationRunOpenTarget } from './automation-run-open-target' @@ -17,6 +16,8 @@ const runLeafLayout = { expandedLeafId: null } const livePtyIds = ['pty-run'] +const canOpen = (args: Parameters[0]): boolean => + resolveAutomationRunOpenTarget(args) !== null function run(overrides: Partial = {}): AutomationRun { return { @@ -55,7 +56,7 @@ describe('automation run open target', () => { it('requires exact pane identity before treating a run terminal as openable', () => { expect( - canOpenAutomationRunOpenTarget({ + canOpen({ run: run({ terminalPaneKey: null, terminalPtyId: null }), terminalTabExists: true, currentLayout: runLeafLayout, @@ -63,7 +64,7 @@ describe('automation run open target', () => { }) ).toBe(false) expect( - canOpenAutomationRunOpenTarget({ + canOpen({ run: run({ terminalPaneKey: `other-tab:${leafId}` }), terminalTabExists: true, currentLayout: runLeafLayout, @@ -71,7 +72,7 @@ describe('automation run open target', () => { }) ).toBe(true) expect( - canOpenAutomationRunOpenTarget({ + canOpen({ run: run(), terminalTabExists: true, currentLayout: runLeafLayout, @@ -82,7 +83,7 @@ describe('automation run open target', () => { it('requires the run PTY to be live for View run', () => { expect( - canOpenAutomationRunOpenTarget({ + canOpen({ run: run(), terminalTabExists: true, currentLayout: runLeafLayout, @@ -90,7 +91,7 @@ describe('automation run open target', () => { }) ).toBe(true) expect( - canOpenAutomationRunOpenTarget({ + canOpen({ run: run(), terminalTabExists: true, currentLayout: runLeafLayout, @@ -115,7 +116,7 @@ describe('automation run open target', () => { it('rejects a run without an exact PTY identity', () => { expect( - canOpenAutomationRunOpenTarget({ + canOpen({ run: run({ terminalPtyId: null }), terminalTabExists: true, currentLayout: runLeafLayout, diff --git a/src/renderer/src/components/automations/automation-run-open-target.ts b/src/renderer/src/components/automations/automation-run-open-target.ts index 4036ac54d0c..4a24aecbe22 100644 --- a/src/renderer/src/components/automations/automation-run-open-target.ts +++ b/src/renderer/src/components/automations/automation-run-open-target.ts @@ -1,7 +1,15 @@ import type { AutomationRun } from '../../../../shared/automations-types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { + isWebTerminalSurfaceTabId, + toWebTerminalSurfaceTabId +} from '../../../../shared/terminal-surface-id' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { resolveOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' export type AutomationRunPaneTarget = { tabId: string @@ -10,9 +18,7 @@ export type AutomationRunPaneTarget = { ptyId: string } -export function getAutomationRunOpenTabId( - run: Pick -): string | null { +function getAutomationRunOpenTabId(run: Pick): string | null { return parsePaneKey(run.terminalPaneKey ?? '')?.tabId ?? null } @@ -56,13 +62,56 @@ export function resolveAutomationRunOpenTarget({ } } -export function canOpenAutomationRunOpenTarget(args: { - run: AutomationRun - terminalTabExists: boolean - currentLayout: TerminalLayoutSnapshot | null | undefined - livePtyIds: readonly string[] -}): boolean { - return resolveAutomationRunOpenTarget(args) !== null +export type AutomationRunTerminalState = { + hasTerminalTab: (tabId: string) => boolean + terminalLayoutsByTabId: Readonly> + ptyIdsByTabId: Readonly> +} + +/** + * The run's pane in this client. A run on a paired server names the server's tab, pane and PTY; + * this client shows that pane as a mirrored tab whose PTY id is the server's terminal handle, so + * the server's PTY id cannot be compared and the mirrored pane must carry a PTY from that server. + */ +export function resolveAutomationRunTerminalTarget( + run: AutomationRun, + terminals: AutomationRunTerminalState, + ownerEnvironmentId: string | null +): AutomationRunPaneTarget | null { + const tabId = getAutomationRunOpenTabId(run) + if (!tabId) { + return null + } + const local = resolveAutomationRunOpenTarget({ + run, + terminalTabExists: terminals.hasTerminalTab(tabId), + currentLayout: terminals.terminalLayoutsByTabId[tabId], + livePtyIds: terminals.ptyIdsByTabId[tabId] ?? [] + }) + const parsed = parsePaneKey(run.terminalPaneKey ?? '') + if (local || !ownerEnvironmentId || !parsed || isWebTerminalSurfaceTabId(tabId)) { + return local + } + const mirroredTabId = toWebTerminalSurfaceTabId(tabId) + const layout = terminals.terminalLayoutsByTabId[mirroredTabId] + const mirroredPtyId = layout?.ptyIdsByLeafId?.[parsed.leafId] + if ( + !run.terminalPtyId || + !terminals.hasTerminalTab(mirroredTabId) || + !layout?.root || + !mirroredPtyId || + !terminalLayoutContainsLeaf(layout.root, parsed.leafId) || + parseRemoteRuntimePtyId(mirroredPtyId)?.environmentId !== ownerEnvironmentId || + !(terminals.ptyIdsByTabId[mirroredTabId] ?? []).includes(mirroredPtyId) + ) { + return null + } + return { + tabId: mirroredTabId, + paneKey: makePaneKey(mirroredTabId, parsed.leafId), + leafId: parsed.leafId, + ptyId: mirroredPtyId + } } export function buildAutomationRunOpenLayout({ @@ -82,3 +131,18 @@ export function buildAutomationRunOpenLayout({ } } } + +/** The paired server that owns the run's workspace, or null when this client runs it itself. */ +export function getAutomationRunOwnerEnvironmentId( + state: WorktreeOperationRouteState, + workspaceId: string | null | undefined, + hostId?: ExecutionHostId +): string | null { + if (!workspaceId) { + return null + } + const owner = resolveOwner(state, { workspaceId, ...(hostId ? { hostId } : {}) }) + return owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment' + ? owner.owner.endpoint.environmentId + : null +} diff --git a/src/renderer/src/components/automations/automation-run-view-state.ts b/src/renderer/src/components/automations/automation-run-view-state.ts index 56f55dec088..44fbb7d1ccb 100644 --- a/src/renderer/src/components/automations/automation-run-view-state.ts +++ b/src/renderer/src/components/automations/automation-run-view-state.ts @@ -51,11 +51,14 @@ export function canRerunAutomationRun({ export function getAutomationRunViewState({ run, workspaceExists, - terminalTargetExists + terminalTargetExists, + terminalOnPairedServer = false }: { run: AutomationRun workspaceExists: boolean terminalTargetExists: boolean + /** The workspace's paired server holds the terminal; only it can say whether it still lives. */ + terminalOnPairedServer?: boolean }): AutomationRunViewState { const hasTerminalIdentity = Boolean(run.terminalPaneKey && run.terminalPtyId) if (run.workspaceId && workspaceExists && terminalTargetExists) { @@ -67,6 +70,15 @@ export function getAutomationRunViewState({ } } + if (run.workspaceId && workspaceExists && hasTerminalIdentity && terminalOnPairedServer) { + return { + availability: 'terminal', + actionLabel: 'View run', + statusLabel: 'Run terminal is on the paired server.', + canOpen: true + } + } + if (run.workspaceId && workspaceExists && hasTerminalIdentity) { return { availability: 'terminal', diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts b/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts new file mode 100644 index 00000000000..8f3770ff365 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts @@ -0,0 +1,145 @@ +/** + * A run that executed on a paired server (#21213) names the server's tab, pane and PTY. This client + * shows that pane as a mirrored tab keyed by the server's terminal handle, so matching the server's + * PTY id against local state always failed and the run read "Run terminal is unavailable.". + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' + +const mocks = vi.hoisted(() => { + const state: Record = {} + return { + state, + toastError: vi.fn(), + toastMessage: vi.fn(), + activateAndRevealWorktree: vi.fn(() => true), + activateWebRuntimeSessionTab: vi.fn(async () => true), + activateTerminalTabOnOwner: vi.fn() + } +}) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: mocks.toastMessage } })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab +})) +vi.mock('@/lib/terminal-tab-owner-activation', () => ({ + activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner +})) + +import { createAutomationRunWorkspaceAction } from './automation-run-workspace-action' +import type { AutomationsPageActionContext } from './automations-page-action-context' + +const ENV = 'env-a' +const WT = 'repo-a::/srv/app' +const LEAF = '11111111-1111-4111-8111-111111111111' +const HOST_TAB = 'host-tab-1' +const MIRRORED_TAB = toWebTerminalSurfaceTabId(HOST_TAB) +const MIRRORED_PTY = toRemoteRuntimePtyId('term-handle-1', ENV) +const worktree = { id: WT, repoId: 'repo-a', hostId: `runtime:${ENV}` as const } + +const run: AutomationRun = { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'completed', + trigger: 'manual', + workspaceId: WT, + workspaceDisplayName: 'app', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: HOST_TAB, + terminalPaneKey: `${HOST_TAB}:${LEAF}`, + // The server's own PTY id, which this client never sees. + terminalPtyId: 'pty-on-server-7', + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 1, + dispatchedAt: 1, + createdAt: 1 +} + +function storeState(mirrored: boolean): Record { + return { + // Focus is on this computer: owner routing must not depend on it. + settings: { activeRuntimeEnvironmentId: null }, + activeWorktreeId: null, + repos: [{ id: 'repo-a', executionHostId: `runtime:${ENV}` }], + worktreesByRepo: { 'repo-a': [{ ...worktree, runtimeOwnerEnvironmentId: ENV }] }, + getTab: (tabId: string) => (mirrored && tabId === MIRRORED_TAB ? { id: tabId } : undefined), + terminalLayoutsByTabId: mirrored + ? { + [MIRRORED_TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: MIRRORED_PTY } + } + } + : {}, + ptyIdsByTabId: mirrored ? { [MIRRORED_TAB]: [MIRRORED_PTY] } : {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + setTabLayout: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn() + } +} + +function openRun(): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action reads only these members. + const context = { + store: { repoForRow: () => undefined, worktreeForRow: () => worktree }, + list: { selectedRow: { key: 'row' } } + } as unknown as AutomationsPageActionContext + createAutomationRunWorkspaceAction(context)(run) +} + +describe('opening a paired-server automation run', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the mirrored pane of the run', () => { + mocks.state = storeState(true) + + openRun() + + expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.state.setActiveTab).toHaveBeenCalledWith(MIRRORED_TAB) + expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith(WT, MIRRORED_TAB, LEAF) + }) + + it('asks the server to open the pane when the mirror has not loaded it yet', async () => { + mocks.state = storeState(false) + + openRun() + await vi.waitFor(() => expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalled()) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(WT) + expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: WT, tabId: HOST_TAB, environmentId: ENV, leafId: LEAF }) + ) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('says the terminal is unavailable only when the server has no such pane', async () => { + mocks.state = storeState(false) + mocks.activateWebRuntimeSessionTab.mockResolvedValueOnce(false) + + openRun() + + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith('Run terminal is unavailable.') + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.ts b/src/renderer/src/components/automations/automation-run-workspace-action.ts index 3ceed04977a..ee72c9ff048 100644 --- a/src/renderer/src/components/automations/automation-run-workspace-action.ts +++ b/src/renderer/src/components/automations/automation-run-workspace-action.ts @@ -3,10 +3,14 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' +import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation' +import { activateWebRuntimeSessionTab } from '@/runtime/web-runtime-session' +import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import { buildAutomationRunOpenLayout, - getAutomationRunOpenTabId, - resolveAutomationRunOpenTarget + getAutomationRunOwnerEnvironmentId, + resolveAutomationRunTerminalTarget } from './automation-run-open-target' import { getAutomationRunViewState } from './automation-run-view-state' import type { AutomationsPageActionContext } from './automations-page-action-context' @@ -21,27 +25,43 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP ? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null) : null const appStore = useAppStore.getState() - const openTabId = getAutomationRunOpenTabId(run) - const terminalTabExists = openTabId ? Boolean(appStore.getTab(openTabId)) : false - const currentLayout = openTabId ? appStore.terminalLayoutsByTabId[openTabId] : null - const livePtyIds = openTabId ? (appStore.ptyIdsByTabId[openTabId] ?? []) : [] - const terminalTarget = resolveAutomationRunOpenTarget({ + const ownerEnvironmentId = runWorktree + ? getAutomationRunOwnerEnvironmentId(appStore, run.workspaceId, runWorktree.hostId) + : null + const terminalTarget = resolveAutomationRunTerminalTarget( run, - terminalTabExists, - currentLayout, - livePtyIds - }) + { + hasTerminalTab: (tabId) => Boolean(appStore.getTab(tabId)), + terminalLayoutsByTabId: appStore.terminalLayoutsByTabId, + ptyIdsByTabId: appStore.ptyIdsByTabId + }, + ownerEnvironmentId + ) + const currentLayout = terminalTarget + ? appStore.terminalLayoutsByTabId[terminalTarget.tabId] + : null const runViewState = getAutomationRunViewState({ run, workspaceExists: Boolean(runWorktree), - terminalTargetExists: terminalTarget !== null + terminalTargetExists: terminalTarget !== null, + terminalOnPairedServer: ownerEnvironmentId !== null }) if (!run.workspaceId || !runWorktree || !runViewState.canOpen) { toast.error(runViewState.statusLabel) return } + const paneRef = parsePaneKey(run.terminalPaneKey ?? '') if (runViewState.availability === 'terminal' && !terminalTarget) { - toast.error(runViewState.statusLabel) + if (!ownerEnvironmentId || !paneRef) { + toast.error(runViewState.statusLabel) + return + } + if (!activateAndRevealWorktree(run.workspaceId)) { + toast.error(workspaceUnavailableMessage()) + return + } + // Why: the mirror may not hold the pane yet; the server focuses it when it arrives. + void openRunTerminalOnOwner(run.workspaceId, ownerEnvironmentId, paneRef) return } if (terminalTarget && currentLayout) { @@ -52,20 +72,42 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP if (activateAndRevealWorktree(run.workspaceId)) { appStore.setActiveTab(terminalTarget.tabId) appStore.setActiveTabType('terminal', run.workspaceId) + activateTerminalTabOnOwner(run.workspaceId, terminalTarget.tabId, terminalTarget.leafId) return } } if (!activateAndRevealWorktree(run.workspaceId)) { - toast.error( - translate( - 'auto.components.automations.AutomationsPage.e1bf9b1512', - 'Workspace is not available.' - ) - ) + toast.error(workspaceUnavailableMessage()) return } toast.message(runViewState.statusLabel) } } +function workspaceUnavailableMessage(): string { + return translate( + 'auto.components.automations.AutomationsPage.e1bf9b1512', + 'Workspace is not available.' + ) +} + +async function openRunTerminalOnOwner( + worktreeId: string, + environmentId: string, + paneRef: { tabId: string; leafId: string } +): Promise { + const opened = await activateWebRuntimeSessionTab({ + worktreeId, + tabId: paneRef.tabId, + environmentId, + leafId: paneRef.leafId, + expectedCurrentLocalTabId: resolveWebSessionVisibleTabId(useAppStore.getState(), worktreeId) + }) + if (!opened) { + toast.error( + translate('components.automations.runTerminalUnavailable', 'Run terminal is unavailable.') + ) + } +} + export type AutomationRunWorkspaceAction = ReturnType diff --git a/src/renderer/src/components/automations/use-automation-run-page-state.ts b/src/renderer/src/components/automations/use-automation-run-page-state.ts index ef970dff5a7..dc80ac2fca3 100644 --- a/src/renderer/src/components/automations/use-automation-run-page-state.ts +++ b/src/renderer/src/components/automations/use-automation-run-page-state.ts @@ -2,9 +2,10 @@ import { useEffect, useMemo } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { getAutomationHostTargetKey, getAutomationTargetFromHostId } from './automation-host-client' +import { useAppStore } from '@/store' import { - canOpenAutomationRunOpenTarget, - getAutomationRunOpenTabId + getAutomationRunOwnerEnvironmentId, + resolveAutomationRunTerminalTarget } from './automation-run-open-target' import { canRerunAutomationRun, getAutomationRunViewState } from './automation-run-view-state' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' @@ -213,25 +214,30 @@ export function useAutomationRunPageState({ worktree: selectedAutomationRunPageWorktree }) : null - const selectedAutomationRunPageOpenTabId = selectedAutomationRunPage - ? getAutomationRunOpenTabId(selectedAutomationRunPage) - : null + const selectedAutomationRunPageOwnerEnvironmentId = useAppStore((state) => + selectedAutomationRunPageWorktree + ? getAutomationRunOwnerEnvironmentId( + state, + selectedAutomationRunPage?.workspaceId, + selectedAutomationRunPageWorktree.hostId + ) + : null + ) const selectedAutomationRunPageViewState = selectedAutomationRunPage ? getAutomationRunViewState({ run: selectedAutomationRunPage, workspaceExists: Boolean(selectedAutomationRunPageWorktree), - terminalTargetExists: canOpenAutomationRunOpenTarget({ - run: selectedAutomationRunPage, - terminalTabExists: selectedAutomationRunPageOpenTabId - ? activeTerminalTabIds.has(selectedAutomationRunPageOpenTabId) - : false, - currentLayout: selectedAutomationRunPageOpenTabId - ? terminalLayoutsByTabId[selectedAutomationRunPageOpenTabId] - : null, - livePtyIds: selectedAutomationRunPageOpenTabId - ? (ptyIdsByTabId[selectedAutomationRunPageOpenTabId] ?? []) - : [] - }) + terminalTargetExists: + resolveAutomationRunTerminalTarget( + selectedAutomationRunPage, + { + hasTerminalTab: (tabId) => activeTerminalTabIds.has(tabId), + terminalLayoutsByTabId, + ptyIdsByTabId + }, + selectedAutomationRunPageOwnerEnvironmentId + ) !== null, + terminalOnPairedServer: selectedAutomationRunPageOwnerEnvironmentId !== null }) : null const canRerunSelectedAutomationRunPage = diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 24099141772..4a9905c0285 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -126,7 +126,7 @@ export function installAppLifetimeIpcEvents( registerUpdaterStatusIpcBridge(unsubs) registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive) registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive) - registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerBrowserRequestIpcBridge(unsubs) registerTabLifecycleIpcBridge(unsubs) registerRateLimitIpcBridge(unsubs) registerDirectSshStateIpcBridge(unsubs, directSshRuntime) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts new file mode 100644 index 00000000000..915bfcd6042 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type CreateRequest = { requestId: string; worktreeId?: string; url: string } +type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string } + +const mocks = vi.hoisted(() => { + const listeners: { + createListener: ((data: CreateRequest) => void) | null + closeListener: ((data: CloseRequest) => void) | null + } = { createListener: null, closeListener: null } + return { + ...listeners, + getState: vi.fn(), + replyTabCreate: vi.fn(), + replyTabClose: vi.fn(), + createBrowserTab: vi.fn(), + closeBrowserTab: vi.fn() + } +}) + +vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({ + destroyPersistentWebview: vi.fn() +})) +vi.mock('../../store', () => ({ useAppStore: { getState: mocks.getState } })) +vi.mock('./browser-automation-bootstrap-lease', () => ({ + acquireBrowserAutomationBootstrapLease: vi.fn() +})) +vi.mock('../../store/pinned-tab-close-guard', () => ({ + guardPinnedTabClose: ({ onClose }: { onClose: () => void }) => onClose(), + isUnifiedTabPinned: vi.fn(() => false), + resolvePinnedTabLabel: vi.fn() +})) + +import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' + +const LOCAL_WT = 'repo-local::/Users/me/app' +const REMOTE_WT = 'repo-remote::/srv/app' + +function storeState(activeWorktreeId: string) { + return { + // The Active Server is a paired server; neither request below may read it. + settings: { activeRuntimeEnvironmentId: 'env-remote' }, + activeWorktreeId, + repos: [ + { id: 'repo-local', executionHostId: 'local' }, + { id: 'repo-remote', executionHostId: 'runtime:env-remote' } + ], + worktreesByRepo: { + 'repo-local': [{ id: LOCAL_WT, repoId: 'repo-local', hostId: 'local' }], + 'repo-remote': [ + { + id: REMOTE_WT, + repoId: 'repo-remote', + hostId: 'runtime:env-remote', + runtimeOwnerEnvironmentId: 'env-remote' + } + ] + }, + browserTabsByWorktree: { + [LOCAL_WT]: [{ id: 'ws-local' }], + [REMOTE_WT]: [{ id: 'ws-remote' }] + }, + browserPagesByWorkspace: { + 'ws-local': [{ id: 'page-local' }], + 'ws-remote': [{ id: 'page-remote' }] + }, + activeBrowserTabIdByWorktree: {}, + unifiedTabsByWorktree: {}, + createBrowserTab: mocks.createBrowserTab.mockImplementation(() => ({ id: 'ws-new' })), + closeBrowserTab: mocks.closeBrowserTab, + closeBrowserPage: vi.fn() + } +} + +describe('browser requests from this desktop follow the workspace owner (#22275)', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + onRequestTabCreate: (listener: typeof mocks.createListener) => { + mocks.createListener = listener + return () => {} + }, + replyTabCreate: mocks.replyTabCreate, + onRequestTabSetProfile: () => () => {}, + replyTabSetProfile: vi.fn(), + onRequestTabClose: (listener: typeof mocks.closeListener) => { + mocks.closeListener = listener + return () => {} + }, + replyTabClose: mocks.replyTabClose + } + } + }) + registerBrowserRequestIpcBridge([]) + }) + + it('creates a tab for an explicit local worktree while a paired server is the Active Server', () => { + mocks.getState.mockReturnValue(storeState(REMOTE_WT)) + + mocks.createListener?.({ requestId: 'r1', worktreeId: LOCAL_WT, url: 'about:blank' }) + + expect(mocks.createBrowserTab).toHaveBeenCalledWith(LOCAL_WT, 'about:blank', expect.anything()) + expect(mocks.replyTabCreate).toHaveBeenCalledWith({ requestId: 'r1', browserPageId: 'ws-new' }) + }) + + it("refuses to fall back to a paired server's active workspace", () => { + mocks.getState.mockReturnValue(storeState(REMOTE_WT)) + + mocks.createListener?.({ requestId: 'r2', url: 'about:blank' }) + + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.replyTabCreate).toHaveBeenCalledWith({ + requestId: 'r2', + error: expect.stringContaining('paired server') + }) + }) + + it("closes a local page but refuses a paired server's page", () => { + mocks.getState.mockReturnValue(storeState(LOCAL_WT)) + + mocks.closeListener?.({ requestId: 'c1', tabId: 'ws-local', worktreeId: LOCAL_WT }) + mocks.closeListener?.({ requestId: 'c2', tabId: 'page-remote' }) + + expect(mocks.closeBrowserTab).toHaveBeenCalledExactlyOnceWith('ws-local') + expect(mocks.replyTabClose).toHaveBeenCalledWith({ requestId: 'c1' }) + expect(mocks.replyTabClose).toHaveBeenCalledWith({ + requestId: 'c2', + error: expect.stringContaining('paired server') + }) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts index c062b2258c3..a2454797ee6 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts @@ -75,7 +75,7 @@ describe('browser profile request teardown', () => { }) it('keeps document-preview guests while rebuilding URL siblings for a profile change', () => { - registerBrowserRequestIpcBridge([], () => false) + registerBrowserRequestIpcBridge([]) mocks.profileListener?.({ requestId: 'request-1', diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts index 39eb3032e27..55ba1d906e7 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -7,25 +7,24 @@ import { import { translate } from '@/i18n/i18n' import { useAppStore } from '../../store' import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' +import { + findBrowserTabWorktreeId, + isBrowserWorkspaceOwnedByPairedServer +} from './browser-request-workspace-owner' -export function registerBrowserRequestIpcBridge( - unsubs: (() => void)[], - isRuntimeEnvironmentActive: () => boolean -): void { +function pairedServerWorkspaceError(): string { + return translate( + 'browser.request.pairedServerWorkspace', + "This workspace runs on a paired server. Use that server's browser tabs." + ) +} + +// Why owner, not the Active Server setting: these requests come from this desktop's own runtime, +// which only names workspaces it runs, so focus on a paired server must not refuse them (#22275). +export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { unsubs.push( window.api.ui.onRequestTabCreate((data) => { try { - if (isRuntimeEnvironmentActive()) { - // Why: browser automation targets client-local Electron webviews that runtime agents can't see or control. - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } const store = useAppStore.getState() const worktreeId = data.worktreeId ?? store.activeWorktreeId if (!worktreeId) { @@ -35,6 +34,14 @@ export function registerBrowserRequestIpcBridge( }) return } + // Why only the fallback: main resolved an explicit id against its own catalog already. + if (!data.worktreeId && isBrowserWorkspaceOwnedByPairedServer(store, worktreeId)) { + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: pairedServerWorkspaceError() + }) + return + } // Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group. const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId] const activeBrowserUnifiedTab = activeBrowserTabId @@ -72,17 +79,15 @@ export function registerBrowserRequestIpcBridge( unsubs.push( window.api.ui.onRequestTabSetProfile((data) => { try { - if (isRuntimeEnvironmentActive()) { + const store = useAppStore.getState() + const pageWorktreeId = findBrowserTabWorktreeId(store, data.browserPageId) + if (pageWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, pageWorktreeId)) { window.api.ui.replyTabSetProfile({ requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.f45fa2b03c', - 'Browser profiles are unavailable while a remote runtime is active' - ) + error: pairedServerWorkspaceError() }) return } - const store = useAppStore.getState() const owningWorkspace = Object.values(store.browserTabsByWorktree) .flat() .find((workspace) => { @@ -129,16 +134,6 @@ export function registerBrowserRequestIpcBridge( unsubs.push( window.api.ui.onRequestTabClose((data) => { try { - if (isRuntimeEnvironmentActive()) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } const store = useAppStore.getState() const explicitTargetId = data.tabId ?? null const replyBrowserTabNotFound = (tabId: string): void => { @@ -186,6 +181,14 @@ export function registerBrowserRequestIpcBridge( }) return } + const closeWorktreeId = findBrowserTabWorktreeId(store, tabToClose) + if (closeWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, closeWorktreeId)) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: pairedServerWorkspaceError() + }) + return + } // Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id. // Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace. const isWorkspaceId = Object.values(store.browserTabsByWorktree) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts new file mode 100644 index 00000000000..78a819fbeae --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts @@ -0,0 +1,37 @@ +import { resolveOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import type { AppState } from '../../store/types' + +/** + * Whether a paired server owns (or may own) `worktreeId`. This desktop's runtime must not create or close browser + * tabs there: that server's own page registry and snapshot are authoritative for them. + */ +export function isBrowserWorkspaceOwnedByPairedServer( + state: WorktreeOperationRouteState, + worktreeId: string +): boolean { + const owner = resolveOwner(state, { workspaceId: worktreeId }) + // Why ambiguous refuses: rows on several hosts cannot prove this desktop owns the tab. + return ( + owner.kind === 'ambiguous' || + (owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment') + ) +} + +/** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */ +export function findBrowserTabWorktreeId( + state: Pick, + tabOrPageId: string +): string | null { + for (const [worktreeId, tabs] of Object.entries(state.browserTabsByWorktree)) { + for (const tab of tabs) { + if ( + tab.id === tabOrPageId || + (state.browserPagesByWorkspace[tab.id] ?? []).some((page) => page.id === tabOrPageId) + ) { + return worktreeId + } + } + } + return null +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index aa1f747aa65..7d74e1392f6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -154,6 +154,9 @@ "description": "Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.", "title": "Browser identity is now app-wide", "openSettings": "Open Settings" + }, + "request": { + "pairedServerWorkspace": "This workspace runs on a paired server. Use that server's browser tabs." } }, "editor": { @@ -1205,8 +1208,6 @@ "0e3cf53060": "Browser tab {{value0}} not found", "2f6637fe6c": "Browser tab {{value0}} is pinned", "a8d2bf8e9e": "No active browser tab to close", - "291c8ed902": "Browser tabs are unavailable while a remote runtime is active", - "f45fa2b03c": "Browser profiles are unavailable while a remote runtime is active", "f000b2ff76": "No active worktree", "56d3ec4203": "Failed to create untitled markdown file.", "f6300deb8b": "New Browser Tab", @@ -19079,6 +19080,9 @@ "important": "Important", "warning": "Warning", "caution": "Caution" + }, + "automations": { + "runTerminalUnavailable": "Run terminal is unavailable." } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6bd67efeffc..171dddaaf12 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1037,8 +1037,6 @@ "0e3cf53060": "Pestaña del navegador {{value0}} no encontrada", "2f6637fe6c": "La pestaña del navegador {{value0}} está fijada", "a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar", - "291c8ed902": "Las pestañas del navegador no están disponibles mientras haya un host remoto activo", - "f45fa2b03c": "Los perfiles del navegador no están disponibles mientras haya un host remoto activo", "f000b2ff76": "No hay ningún worktree activo", "56d3ec4203": "No se pudo crear el archivo Markdown sin título.", "f6300deb8b": "Nueva pestaña del navegador", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index fdde7948b74..90bbf539e45 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1138,8 +1138,6 @@ "0e3cf53060": "Onglet de navigateur {{value0}} introuvable", "2f6637fe6c": "L'onglet de navigateur {{value0}} est épinglé", "a8d2bf8e9e": "Aucun onglet de navigateur actif à fermer", - "291c8ed902": "Les onglets de navigateur sont indisponibles tant qu'un runtime distant est actif", - "f45fa2b03c": "Les profils de navigateur sont indisponibles tant qu'un runtime distant est actif", "f000b2ff76": "Aucun worktree actif", "56d3ec4203": "Échec de la création du fichier markdown sans titre.", "f6300deb8b": "Nouvel onglet de navigateur", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0365d1005f6..b82025a0a62 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1037,8 +1037,6 @@ "0e3cf53060": "ブラウザタブ {{value0}} が見つかりません", "2f6637fe6c": "ブラウザタブ {{value0}} はピン留めされています", "a8d2bf8e9e": "閉じるためのアクティブなブラウザタブがありません", - "291c8ed902": "リモートランタイムがアクティブな間はブラウザタブは使用できません", - "f45fa2b03c": "リモートランタイムがアクティブな間はブラウザプロファイルを使用できません", "f000b2ff76": "アクティブなワークツリーがありません", "56d3ec4203": "無題の markdown ファイルの作成に失敗しました。", "f6300deb8b": "新規ブラウザタブ", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ca2b28edb82..534106d4df0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1037,8 +1037,6 @@ "0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다", "2f6637fe6c": "브라우저 탭 {{value0}}이(가) 고정되어 있습니다", "a8d2bf8e9e": "닫을 활성 브라우저 탭이 없습니다.", - "291c8ed902": "원격 런타임이 활성화된 동안에는 브라우저 탭을 사용할 수 없습니다.", - "f45fa2b03c": "원격 런타임이 활성화된 동안에는 브라우저 프로필을 사용할 수 없습니다.", "f000b2ff76": "활성 작업 트리 없음", "56d3ec4203": "제목 없는 markdown 파일을 생성하지 못했습니다.", "f6300deb8b": "새 브라우저 탭", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9bb6262403e..5da9ec3bb78 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1037,8 +1037,6 @@ "0e3cf53060": "未找到浏览器选项卡 {{value0}}", "2f6637fe6c": "浏览器选项卡 {{value0}} 已固定", "a8d2bf8e9e": "没有可关闭的活动浏览器选项卡", - "291c8ed902": "当远程运行时处于活动状态时,浏览器选项卡不可用", - "f45fa2b03c": "当远程运行时处于活动状态时,浏览器配置文件不可用", "f000b2ff76": "没有活动的工作树", "56d3ec4203": "无法创建无标题 Markdown 文件。", "f6300deb8b": "新浏览器选项卡",