fix(app): stop PR detection from stealing explorer pane focus

observePullRequest used to reveal and focus the explorer pane, and the persisted focus leak then made ambient tab opens (sidebar agent clicks, reconcile auto-open) insert agent tabs into the explorer pane while the main pane sat empty.

PR detection now only background-adds the PR tab to the explorer pane, with no reveal and no focus change, so a tab the user already opened elsewhere stays where it is.

Placement policy is centralized in workspace-layout-actions.ts (resolvePlacementPane / insertNewTabIntoPane): explicit paneId wins, and ambient entity opens (agent, provider_subagent, terminal, draft, browser) reroute from the explorer pane to the primary pane. explorerPaneId is a required input so callers cannot silently fail open.

Pane-rail + affordances thread explicit paneId instead of focus-then-open.
This commit is contained in:
Mohamed Boudra
2026-08-19 08:50:31 +02:00
parent e9373c026b
commit b638f7d3ee
7 changed files with 588 additions and 157 deletions
@@ -103,6 +103,10 @@ interface OpenTabInLayoutInput {
layout: WorkspaceLayout;
target: WorkspaceTabTarget;
now: number;
/** Explicit placement from a pane-local affordance. Wins over every other rule. */
paneId?: string | null;
/** Required so a new caller cannot silently opt out of the explorer placement rule. */
explorerPaneId: string | null;
}
interface OpenTabInLayoutResult {
@@ -198,6 +202,7 @@ export interface WorkspaceTabReconcileState {
pinnedAgentIds?: ReadonlySet<string> | null;
pendingAgentIds?: ReadonlySet<string> | null;
hiddenAgentIds?: ReadonlySet<string> | null;
explorerPaneId: string | null;
}
export interface WorkspaceTabSnapshot {
@@ -1119,18 +1124,61 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
}).root;
}
function insertNewTabIntoFocusedPane(input: {
layout: WorkspaceLayout;
// The explorer pane is a background surface for the PR, files, and assistant output.
// It can hold focus without the user ever having looked at it, so an ambient open of
// something the user works *in* must not land there just because focus leaked.
const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
"agent",
"provider_subagent",
"terminal",
"draft",
"browser",
]);
function resolvePlacementPane(input: {
layout: { root: SplitNode; focusedPaneId: string | null };
target: WorkspaceTabTarget;
now: number;
focus: boolean;
}): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
paneId: string | null | undefined;
explorerPaneId: string | null;
}): SplitPane {
const requestedPane = findPaneById(input.layout.root, input.paneId);
if (requestedPane) {
return requestedPane;
}
const focusedPane =
findPaneById(layout.root, layout.focusedPaneId) ??
collectAllPanes(layout.root)[0] ??
findPaneById(input.layout.root, input.layout.focusedPaneId) ??
collectAllPanes(input.layout.root)[0] ??
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
invariant(focusedPane, "Workspace layout must always have a pane");
if (
focusedPane.id !== input.explorerPaneId ||
!EXPLORER_EXCLUDED_TAB_KINDS.has(input.target.kind)
) {
return focusedPane;
}
// `collectAllPanes` skips hidden panes, so the chain falls back to the explorer
// pane itself only when it is the sole visible pane. The explorer is the only pane
// anything ever hides, so today that means "the explorer is the only pane".
const panes = collectAllPanes(input.layout.root);
return (
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ??
panes.find((pane) => pane.id !== focusedPane.id) ??
focusedPane
);
}
function insertNewTabIntoPane(
input: OpenTabInLayoutInput & { focus: boolean },
): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
const targetPane = resolvePlacementPane({
layout,
target: input.target,
paneId: input.paneId,
explorerPaneId: input.explorerPaneId,
});
const tabId = buildDeterministicWorkspaceTabId(input.target);
const nextTab: WorkspaceTab = {
@@ -1139,17 +1187,17 @@ function insertNewTabIntoFocusedPane(input: {
createdAt: input.now,
};
const preservedFocusTabId = focusedPane.focusedTabId ?? tabId;
const preservedFocusTabId = targetPane.focusedTabId ?? tabId;
return {
tabId,
layout: withNormalizedParentTabMap({
root: insertTabIntoPane(layout.root, {
paneId: focusedPane.id,
paneId: targetPane.id,
tab: nextTab,
focusTabId: input.focus ? tabId : preservedFocusTabId,
}),
focusedPaneId: input.focus ? focusedPane.id : layout.focusedPaneId,
focusedPaneId: input.focus ? targetPane.id : layout.focusedPaneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId,
}),
};
@@ -1197,7 +1245,7 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
};
}
return insertNewTabIntoFocusedPane({ ...input, focus: true });
return insertNewTabIntoPane({ ...input, focus: true });
}
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
@@ -1210,7 +1258,7 @@ export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabI
};
}
return insertNewTabIntoFocusedPane({ ...input, focus: false });
return insertNewTabIntoPane({ ...input, focus: false });
}
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
@@ -1719,31 +1767,18 @@ function isTerminalTab(
return tab.target.kind === "terminal";
}
function openEntityTabWithoutFocusing(
layout: WorkspaceLayout,
target: WorkspaceTabTarget,
): WorkspaceLayout {
const internalLayout = asInternalLayout(layout);
const focusedPane =
findPaneById(internalLayout.root, internalLayout.focusedPaneId) ??
collectAllPanes(internalLayout.root)[0] ??
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
invariant(focusedPane, "Workspace layout must always have a pane");
const tabId = buildDeterministicWorkspaceTabId(target);
return withNormalizedParentTabMap({
root: insertTabIntoPane(internalLayout.root, {
paneId: focusedPane.id,
tab: {
tabId,
target,
createdAt: Date.now(),
},
focusTabId: focusedPane.focusedTabId ?? tabId,
}),
focusedPaneId: internalLayout.focusedPaneId,
parentTabIdByTabId: layout.parentTabIdByTabId,
});
function openEntityTabWithoutFocusing(input: {
layout: WorkspaceLayout;
target: WorkspaceTabTarget;
explorerPaneId: string | null;
}): WorkspaceLayout {
return insertNewTabIntoPane({
layout: input.layout,
target: input.target,
now: Date.now(),
explorerPaneId: input.explorerPaneId,
focus: false,
}).layout;
}
interface EntityTabGroup {
@@ -1832,12 +1867,14 @@ function addMissingEntityTabs(input: {
representedAgentIds: Set<string>;
standaloneTerminalIds: Set<string>;
hasActivePendingDraftCreate: boolean;
explorerPaneId: string | null;
}): WorkspaceLayout {
const {
autoOpenAgentIds,
representedAgentIds,
standaloneTerminalIds,
hasActivePendingDraftCreate,
explorerPaneId,
} = input;
let nextLayout = input.layout;
const currentEntityTabs = collectAllTabs(nextLayout.root);
@@ -1856,9 +1893,10 @@ function addMissingEntityTabs(input: {
if (hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) {
continue;
}
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
kind: "agent",
agentId,
nextLayout = openEntityTabWithoutFocusing({
layout: nextLayout,
target: { kind: "agent", agentId },
explorerPaneId,
});
currentAgentIds.add(agentId);
}
@@ -1868,9 +1906,10 @@ function addMissingEntityTabs(input: {
if (currentTerminalIds.has(terminalId)) {
continue;
}
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
kind: "terminal",
terminalId,
nextLayout = openEntityTabWithoutFocusing({
layout: nextLayout,
target: { kind: "terminal", terminalId },
explorerPaneId,
});
currentTerminalIds.add(terminalId);
}
@@ -1961,6 +2000,7 @@ export function reconcileWorkspaceTabs(
representedAgentIds,
standaloneTerminalIds,
hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false,
explorerPaneId: state.explorerPaneId,
});
if (reconciledFocusedTabId) {