diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 7d0b0a59f4..2ecf842671 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -30,7 +30,10 @@ never read or modify that width. `packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or -Explorer sidebar setting. Callers request the content and never choose the shell. +Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without +interrupting the user's work. Closing the tab opts that workspace out of future automatic opens, +even for a different PR; moving or reordering it remains the user's choice. +Callers request the content and never choose the shell. The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then routes later presses to the working diff through the shared diff preference. diff --git a/packages/app/src/panels/pull-request/index.tsx b/packages/app/src/panels/pull-request/index.tsx index 8759ae561f..148da86914 100644 --- a/packages/app/src/panels/pull-request/index.tsx +++ b/packages/app/src/panels/pull-request/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; @@ -14,11 +14,14 @@ import { import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { useSettings } from "@/hooks/use-settings"; +import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; +import { autoOpenWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; + import { getPullRequestIdentity, resolvePullRequestContentState } from "./state"; /** - * Whether this workspace's branch has a pull request. Detection only offers the - * pull request as something the user can open; it never opens a tab on its own. + * Whether this workspace's branch has a pull request. */ export function useHasPullRequest(input: { serverId: string; @@ -33,6 +36,21 @@ export function useHasPullRequest(input: { return getPullRequestIdentity(status) !== null; } +/** Connects desktop PR detection to the persisted layout after hydration. */ +export function usePullRequestAutoAdd(input: { + workspaceKey: string | null; + hasPullRequest: boolean; + enabled: boolean; +}): void { + const hydrated = useWorkspaceLayoutStoreHydrated(); + const destination = useSettings((settings) => settings.pullRequestOpenLocation); + useEffect(() => { + if (hydrated && input.enabled && input.hasPullRequest) { + autoOpenWorkspacePullRequest({ workspaceKey: input.workspaceKey, destination }); + } + }, [hydrated, input.enabled, input.hasPullRequest, input.workspaceKey, destination]); +} + export function PullRequestContent(input: { serverId: string; workspaceId?: string | null; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a898fc0472..d94b7e5e82 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -206,7 +206,7 @@ import { } from "@/workspace/file-open"; import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; -import { useHasPullRequest } from "@/panels/pull-request"; +import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; @@ -1760,10 +1760,16 @@ function WorkspaceScreenContent({ workspace: workspaceDescriptor, checkoutState: workspaceHeaderCheckoutState, }); + const canDetectPullRequestTab = canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile); const hasPullRequest = useHasPullRequest({ serverId: normalizedServerId, cwd: workspaceDirectory, - enabled: canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile), + enabled: canDetectPullRequestTab, + }); + usePullRequestAutoAdd({ + workspaceKey: persistenceKey, + hasPullRequest, + enabled: canDetectPullRequestTab, }); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index fe6ab77811..5e87c2eb65 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -84,6 +84,7 @@ interface DetachTabFromTreeResult { } interface InsertTabIntoPaneInput { + insertionPosition?: WorkspaceTabInsertionPosition; paneId: string; tab: WorkspaceTab; focusTabId?: string | null; @@ -124,7 +125,12 @@ export type WorkspaceTabPlacement = export const FOCUSED_PANE_PLACEMENT: WorkspaceTabPlacement = { mode: "focused" }; export const AMBIENT_PLACEMENT: WorkspaceTabPlacement = { mode: "ambient" }; +export interface WorkspaceTabInsertionPosition { + afterTabId: string; +} + interface OpenTabInLayoutInput { + insertionPosition?: WorkspaceTabInsertionPosition; layout: WorkspaceLayout; target: WorkspaceTabTarget; now: number; @@ -848,7 +854,11 @@ function insertTabIntoPane( } else if (existingIndex >= 0) { nextTabs = node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)); } else { - nextTabs = [...node.pane.tabs, input.tab]; + nextTabs = [...node.pane.tabs]; + const afterIndex = nextTabs.findIndex( + (tab) => tab.tabId === input.insertionPosition?.afterTabId, + ); + nextTabs.splice(afterIndex >= 0 ? afterIndex + 1 : nextTabs.length, 0, input.tab); } return { kind: "pane", @@ -1380,6 +1390,7 @@ function insertNewTabIntoPane( root: insertTabIntoPane(layout.root, { paneId: targetPane.id, tab: nextTab, + insertionPosition: input.insertionPosition, focusTabId: input.focus ? tabId : preservedFocusTabId, }), focusedPaneId: input.focus ? targetPane.id : layout.focusedPaneId, diff --git a/packages/app/src/stores/workspace-layout-storage.ts b/packages/app/src/stores/workspace-layout-storage.ts index ae916188c5..c105d40a06 100644 --- a/packages/app/src/stores/workspace-layout-storage.ts +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -108,6 +108,7 @@ export const WorkspaceLayoutPersistedStateSchema = z.strictObject({ explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), + pullRequestTabAutoOpenedByWorkspace: z.record(z.string(), z.literal(true)).optional(), // COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted // and ignored so upgrading does not discard the layout. Remove after 2027-08-20. acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(), diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 37076a4a72..6605ecec3b 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -498,6 +498,7 @@ describe("workspace-layout-store version 2 migration", () => { "explorerSidebarWidthByWorkspace", "layoutByWorkspace", "pinnedAgentIdsByWorkspace", + "pullRequestTabAutoOpenedByWorkspace", "sidePaneIdByWorkspace", "splitSizesByWorkspace", ]); @@ -3091,6 +3092,7 @@ describe("workspace-layout-store actions", () => { splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: {}, + pullRequestTabAutoOpenedByWorkspace: currentState.pullRequestTabAutoOpenedByWorkspace, sidePaneIdByWorkspace: currentState.sidePaneIdByWorkspace, }); expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ @@ -3319,6 +3321,7 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ + pullRequestTabAutoOpenedByWorkspace: {}, layoutByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: {}, @@ -4359,3 +4362,25 @@ describe("workspace-layout-store actions", () => { expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); }); }); + +it("persists the once-only PR add after closing, and clears it when purging the workspace", async () => { + await AsyncStorage.removeItem("workspace-layout-state"); + const source = createWorkspaceLayoutStore(workspaceLayoutIds); + await source.persist.rehydrate(); + const workspaceKey = "server-1:pr-once"; + const placement = () => ({ placement: { mode: "prefer" as const, paneId: "explorer" } }); + source.getState().autoOpenPullRequestTab(workspaceKey, placement); + source.getState().closeTab(workspaceKey, "pull_request"); + const restored = createWorkspaceLayoutStore(workspaceLayoutIds); + await restored.persist.rehydrate(); + expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBe(true); + expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBeNull(); + expect( + collectAllTabs(restored.getState().layoutByWorkspace[workspaceKey].root).map( + (tab) => tab.target.kind, + ), + ).not.toContain("pull_request"); + restored.getState().purgeWorkspace(workspaceKey); + expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined(); + expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request"); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 31fa370ca7..6fe51580ad 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -54,6 +54,7 @@ import { type SplitNode, type SplitPane, type WorkspaceTabPlacement, + type WorkspaceTabInsertionPosition, type WorkspaceTabReconcileState, type WorkspaceTabSnapshot, type WorkspaceLayout, @@ -99,6 +100,7 @@ export interface OpenWorkspaceTabInput { /** Keeps an explicitly opened agent visible even when it is archived. */ pin?: boolean; placement?: WorkspaceTabPlacement; + insertionPosition?: WorkspaceTabInsertionPosition; parentTabId?: string; state?: JsonValue; } @@ -112,12 +114,19 @@ interface WorkspaceLayoutStore { focusRestorationByWorkspace: Record; explorerSidebarPaneIdByWorkspace: Record; sidePaneIdByWorkspace: Record; + /** Workspaces where PR detection already added its tab once; a closed tab never returns. */ + pullRequestTabAutoOpenedByWorkspace: Record; openTab: (input: OpenWorkspaceTabInput) => string | null; + /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ + autoOpenPullRequestTab: ( + workspaceKey: string, + resolvePlacement: () => Pick, + ) => string | null; /** Reveals the Explorer sidebar without selecting a view. Returns its pane id. */ showExplorerSidebar: (workspaceKey: string) => string | null; hideExplorerSidebar: (workspaceKey: string) => void; /** Returns the ordinary right-side workspace pane, creating it when absent. */ - ensureSidePane: (workspaceKey: string) => string | null; + ensureSidePane: (workspaceKey: string, options?: { focus: boolean }) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void; @@ -648,6 +657,25 @@ export function createWorkspaceLayoutStore( return create()( persist( (set, get) => ({ + autoOpenPullRequestTab: (workspaceKey, resolvePlacement) => { + const key = trimNonEmpty(workspaceKey); + if (!key || get().pullRequestTabAutoOpenedByWorkspace[key]) return null; + const tabId = get().openTab({ + workspaceKey: key, + target: { kind: "pull_request" }, + intent: "background", + ...resolvePlacement(), + }); + if (tabId) { + set((state) => ({ + pullRequestTabAutoOpenedByWorkspace: { + ...state.pullRequestTabAutoOpenedByWorkspace, + [key]: true, + }, + })); + } + return tabId; + }, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -656,6 +684,7 @@ export function createWorkspaceLayoutStore( focusRestorationByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, sidePaneIdByWorkspace: {}, + pullRequestTabAutoOpenedByWorkspace: {}, openTab: (input) => { const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); const normalizedTarget = normalizeWorkspaceTabTarget(input.target); @@ -672,6 +701,7 @@ export function createWorkspaceLayoutStore( if (input.intent === "new") { result = createTabInLayout({ ...placement, + insertionPosition: input.insertionPosition, target: normalizedTarget, now: Date.now(), createTabId: createWorkspaceTabInstanceId, @@ -680,12 +710,14 @@ export function createWorkspaceLayoutStore( } else if (input.intent === "background") { result = openTabInLayoutBackground({ ...placement, + insertionPosition: input.insertionPosition, target: normalizedTarget, now: Date.now(), }); } else { result = revealTargetInLayout({ ...placement, + insertionPosition: input.insertionPosition, target: normalizedTarget, now: Date.now(), createTabId: createWorkspaceTabInstanceId, @@ -798,7 +830,7 @@ export function createWorkspaceLayoutStore( }; }); }, - ensureSidePane: (workspaceKey) => { + ensureSidePane: (workspaceKey, options) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { return null; @@ -829,7 +861,10 @@ export function createWorkspaceLayoutStore( ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, + [normalizedWorkspaceKey]: + options?.focus === false + ? { ...result.layout, focusedPaneId: layout.focusedPaneId } + : result.layout, }, sidePaneIdByWorkspace: { ...state.sidePaneIdByWorkspace, @@ -1625,10 +1660,15 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || - normalizedWorkspaceKey in state.sidePaneIdByWorkspace; + normalizedWorkspaceKey in state.sidePaneIdByWorkspace || + normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace; if (!hasAny) { return state; } + const { + [normalizedWorkspaceKey]: _autoOpened, + ...pullRequestTabAutoOpenedByWorkspace + } = state.pullRequestTabAutoOpenedByWorkspace; const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } = state.layoutByWorkspace; const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } = @@ -1650,6 +1690,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { + pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, explorerSidebarWidthByWorkspace, @@ -1689,6 +1730,7 @@ export function createWorkspaceLayoutStore( explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, sidePaneIdByWorkspace: state.sidePaneIdByWorkspace, + pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace, }; }, merge: (persistedState, currentState) => { @@ -1741,6 +1783,8 @@ export function createWorkspaceLayoutStore( ), explorerSidebarPaneIdByWorkspace, sidePaneIdByWorkspace: result.data.sidePaneIdByWorkspace ?? {}, + pullRequestTabAutoOpenedByWorkspace: + result.data.pullRequestTabAutoOpenedByWorkspace ?? {}, }; }, }, diff --git a/packages/app/src/workspace-tabs/open-supporting-view.test.ts b/packages/app/src/workspace-tabs/open-supporting-view.test.ts index c0fa1c5927..b3e6f4c976 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.test.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.test.ts @@ -16,6 +16,7 @@ import { useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; import { + autoOpenWorkspacePullRequest, openComposerChanges, openWorkspaceChanges, openWorkspacePullRequest, @@ -31,6 +32,7 @@ beforeEach(() => { explorerTabByCheckout: {}, }); useWorkspaceLayoutStore.setState({ + pullRequestTabAutoOpenedByWorkspace: {}, layoutByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, sidePaneIdByWorkspace: {}, @@ -178,3 +180,152 @@ describe("openWorkspacePullRequest", () => { expect(sidePane?.tabIds).toContain(pullRequestTab?.tabId); }); }); + +describe("autoOpenWorkspacePullRequest", () => { + it("silently adds the PR after Changes once, and never restores it after closing", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "terminal-1" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + const input = { workspaceKey: WORKSPACE_KEY, destination: "explorer" as const }; + autoOpenWorkspacePullRequest(input); + const after = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + const explorer = findPaneById(after.root, "explorer")!; + expect(explorer.tabIds).toEqual([ + "files", + "changes_tree", + "pull_request", + "terminal_terminal-1", + ]); + expect(explorer.hidden).toBe(true); + expect(explorer.focusedTabId).toBe(findPaneById(before.root, "explorer")!.focusedTabId); + expect(after.focusedPaneId).toBe(before.focusedPaneId); + store.closeTab(WORKSPACE_KEY, "pull_request"); + autoOpenWorkspacePullRequest(input); + expect( + collectAllTabs(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root).map( + (tab) => tab.target.kind, + ), + ).not.toContain("pull_request"); + }); +}); + +describe("automatic PR placement", () => { + it.each(["main", "side"] as const)( + "silently appends to the configured %s pane", + (destination) => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const paneId = destination === "main" ? "main" : store.ensureSidePane(WORKSPACE_KEY)!; + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "terminal-1" }, + intent: "background", + placement: { mode: "prefer", paneId }, + }); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination }); + const after = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + expect(findPaneById(after.root, paneId)!.tabIds).toEqual([ + ...findPaneById(before.root, paneId)!.tabIds, + "pull_request", + ]); + expect(findPaneById(after.root, paneId)!.focusedTabId).toBe( + findPaneById(before.root, paneId)!.focusedTabId, + ); + expect(after.focusedPaneId).toBe(before.focusedPaneId); + expect(findPaneById(after.root, "explorer")!.hidden).toBe(true); + }, + ); + + it("creates a side pane without stealing workspace focus", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "side" }); + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + expect(findPaneById(layout.root, state.sidePaneIdByWorkspace[WORKSPACE_KEY])!.tabIds).toEqual([ + "pull_request", + ]); + expect(layout.focusedPaneId).toBe("main"); + }); + + it.each(["main", "side", "explorer"] as const)( + "leaves a manually moved PR in main when detection prefers %s", + (destination) => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "pull_request" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); + store.moveTabToPane(WORKSPACE_KEY, "pull_request", "main"); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination }); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toEqual(before); + expect( + useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY], + ).toBeUndefined(); + store.closeTab(WORKSPACE_KEY, "pull_request"); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination }); + expect( + collectAllTabs( + useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root, + ).map((tab) => tab.target.kind), + ).not.toContain("pull_request"); + expect( + useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY], + ).toBeUndefined(); + }, + ); + + it("preserves reordering before and after detection", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "pull_request" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); + store.reorderTabsInPane(WORKSPACE_KEY, "explorer", ["pull_request", "files", "changes_tree"]); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "explorer" }); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "side" }); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toEqual(before); + }); + + it("appends in Explorer when Changes was closed", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.closeTab(WORKSPACE_KEY, "changes_tree"); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "explorer" }); + expect( + findPaneById( + useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root, + "explorer", + )!.tabIds, + ).toEqual(["files", "pull_request"]); + }); +}); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index 047cdd3f97..b91bf732c9 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -1,3 +1,12 @@ +import { + collectAllPanes, + collectAllTabs, + createWorkspaceLayoutWithExplorerSidebar, + DEFAULT_PANE_ID, + findPaneContainingTab, + resolveExplorerSidebarPaneId, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; import type { OpenInSidePanePreferences, PullRequestOpenLocation } from "@/hooks/use-settings"; import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { @@ -67,3 +76,47 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): location: input.destination, }); } + +/** Adds a detected desktop PR once without revealing or selecting its destination. */ +export function autoOpenWorkspacePullRequest(input: { + workspaceKey: string | null; + destination: PullRequestOpenLocation; +}): string | null { + if (!input.workspaceKey) return null; + const workspaceKey = input.workspaceKey; + const store = useWorkspaceLayoutStore.getState(); + return store.autoOpenPullRequestTab(workspaceKey, () => { + const layout = + store.layoutByWorkspace[workspaceKey] ?? createWorkspaceLayoutWithExplorerSidebar(); + // A background open finds an existing PR tab wherever the user left it. + if (collectAllTabs(layout.root).some((tab) => tab.target.kind === "pull_request")) { + return {}; + } + const explorerPaneId = resolveExplorerSidebarPaneId( + layout, + store.explorerSidebarPaneIdByWorkspace[workspaceKey], + ); + let paneId = explorerPaneId; + if (input.destination === "side") { + paneId = store.ensureSidePane(workspaceKey, { focus: false }); + } else if (input.destination === "main") { + const panes = collectAllPanes(layout.root); + const mainPane = + panes.find((pane) => pane.id === DEFAULT_PANE_ID) ?? + panes.find((pane) => pane.id !== explorerPaneId && !pane.hidden); + paneId = mainPane?.id ?? null; + } + const changes = + input.destination === "explorer" + ? collectAllTabs(layout.root).find( + (tab) => + tab.target.kind === "changes_tree" && + findPaneContainingTab(layout.root, tab.tabId)?.id === paneId, + ) + : undefined; + return { + placement: paneId ? { mode: "prefer", paneId } : undefined, + insertionPosition: changes ? { afterTabId: changes.tabId } : undefined, + }; + }); +}