From e1463944d66d7feef68bcbe4c6396f46424dc7f9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:05:33 -0400 Subject: [PATCH 01/11] ship: checkpoint before automate/finalize --- .../main/services/lanes/autoRebaseService.ts | 24 +-- .../services/prs/prRebaseResolver.test.ts | 2 +- .../src/main/services/prs/prRebaseResolver.ts | 1 - .../src/main/services/prs/prService.ts | 6 +- .../lanes/LaneGitActionsPane.test.tsx | 4 +- .../components/lanes/LaneGitActionsPane.tsx | 8 +- .../components/lanes/LaneRebaseBanner.tsx | 8 +- .../renderer/components/lanes/LanesPage.tsx | 4 +- .../renderer/components/prs/CreatePrModal.tsx | 9 +- .../src/renderer/components/prs/PRsPage.tsx | 22 +-- .../components/prs/prsRouteState.test.ts | 45 ++++- .../renderer/components/prs/prsRouteState.ts | 36 ++++ .../components/prs/state/PrsContext.test.tsx | 54 ++++++ .../components/prs/state/PrsContext.tsx | 52 ++++-- .../components/prs/tabs/IntegrationTab.tsx | 7 +- .../renderer/components/prs/tabs/QueueTab.tsx | 6 +- .../components/prs/tabs/WorkflowsTab.tsx | 2 +- .../settings/LaneBehaviorSection.tsx | 6 +- .../terminals/useWorkSessions.test.ts | 154 +++++++++++++++++- .../components/terminals/useWorkSessions.ts | 23 ++- 20 files changed, 392 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index 39cccb963..308b1c924 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -112,17 +112,17 @@ function blockedMessage( laneId: string | null, reason: "conflict" | "manual" | "lookup" | "failed" | "unavailable" | null, ): string { - if (!laneId) return "Pending: auto-rebase stopped at an earlier lane. Open the Rebase tab to continue."; + if (!laneId) return "Pending: auto-rebase stopped at an earlier lane. Open the Rebase/Merge tab to continue."; if (reason === "manual") { - return `Pending: ancestor lane '${laneId}' has a fixed PR base. Rebase that lane manually from the Rebase tab before descendants can continue.`; + return `Pending: ancestor lane '${laneId}' has a fixed PR base. Rebase that lane manually from the Rebase/Merge tab before descendants can continue.`; } if (reason === "lookup" || reason === "unavailable") { - return `Pending: ancestor lane '${laneId}' needs review before descendants can continue. Open the Rebase tab to inspect it.`; + return `Pending: ancestor lane '${laneId}' needs review before descendants can continue. Open the Rebase/Merge tab to inspect it.`; } if (reason === "failed") { - return `Pending: ancestor lane '${laneId}' failed automatic rebase. Open the Rebase tab to retry.`; + return `Pending: ancestor lane '${laneId}' failed automatic rebase. Open the Rebase/Merge tab to retry.`; } - return `Pending: ancestor lane '${laneId}' has unresolved rebase conflicts. Open the Rebase tab to continue.`; + return `Pending: ancestor lane '${laneId}' has unresolved rebase conflicts. Open the Rebase/Merge tab to continue.`; } function resolveAffectedChainLaneId( @@ -459,7 +459,7 @@ export function createAutoRebaseService(args: { parentHeadSha: null, state: "rebasePending", conflictCount: 0, - message: "Pending: parent lane is unavailable. Open the Rebase tab to review the lane." + message: "Pending: parent lane is unavailable. Open the Rebase/Merge tab to review the lane." }); blocked = true; blockedLaneId = lane.id; @@ -522,7 +522,7 @@ export function createAutoRebaseService(args: { parentHeadSha, state: "rebaseConflict", conflictCount: Math.max(1, need.conflictingFiles.length), - message: `Auto-rebase blocked: ${Math.max(1, need.conflictingFiles.length)} conflict(s) expected. Open the Rebase tab to resolve and publish.` + message: `Auto-rebase blocked: ${Math.max(1, need.conflictingFiles.length)} conflict(s) expected. Open the Rebase/Merge tab to resolve and publish.` }); continue; } @@ -541,7 +541,7 @@ export function createAutoRebaseService(args: { parentHeadSha, state: "rebasePending", conflictCount: 0, - message: "PR carries an immutable base — drift detected. Rebase manually from the Rebase tab when ready." + message: "PR carries an immutable base — drift detected. Rebase manually from the Rebase/Merge tab when ready." }); continue; } @@ -572,8 +572,8 @@ export function createAutoRebaseService(args: { state: conflictHint ? "rebaseConflict" : "rebaseFailed", conflictCount: conflictHint ? 1 : 0, message: conflictHint - ? "Auto-rebase stopped due to conflicts. Open the Rebase tab to resolve, then publish." - : `Auto-rebase failed: ${rebaseRun.run.error}. Open the Rebase tab to retry.` + ? "Auto-rebase stopped due to conflicts. Open the Rebase/Merge tab to resolve, then publish." + : `Auto-rebase failed: ${rebaseRun.run.error}. Open the Rebase/Merge tab to retry.` }); continue; } @@ -625,8 +625,8 @@ export function createAutoRebaseService(args: { state: "rebaseFailed", conflictCount: 0, message: rollbackError - ? `Auto-push failed: ${pushError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase tab to retry.` - : `Auto-push failed: ${pushError}. The lane was restored to its pre-rebase state. Open the Rebase tab to retry.` + ? `Auto-push failed: ${pushError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase/Merge tab to retry.` + : `Auto-push failed: ${pushError}. The lane was restored to its pre-rebase state. Open the Rebase/Merge tab to retry.` }); } } diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts index 0349af8c7..bb6e38853 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts @@ -141,10 +141,10 @@ describe("launchRebaseResolutionChat", () => { expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ sessionId: "session-rebase-1", - displayText: "Rebase feature/rebase-target onto main", reasoningEffort: "high", }), ); + expect(sendMessage.mock.calls[0]?.[0]).not.toHaveProperty("displayText"); expect(result).toEqual({ sessionId: "session-rebase-1", laneId: lane.id, diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.ts index c9cbd3cdf..f38a6bb33 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.ts @@ -158,7 +158,6 @@ export async function launchRebaseResolutionChat( await deps.agentChatService.sendMessage({ sessionId: session.id, text: prompt, - displayText: title, ...(reasoningEffort ? { reasoningEffort } : {}), }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 2d29ecb20..22841aa4e 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -871,7 +871,7 @@ export function createPrService({ parentHeadSha: null, state: "rebaseFailed", conflictCount: 0, - message: `Auto-rebase failed after '${args.landedLaneName}' merged because ADE could not find a new parent lane. Open the Rebase tab to recover this lane.`, + message: `Auto-rebase failed after '${args.landedLaneName}' merged because ADE could not find a new parent lane. Open the Rebase/Merge tab to recover this lane.`, }, child.id); } return { @@ -979,8 +979,8 @@ export function createPrService({ state: "rebaseFailed", conflictCount: 0, message: rollbackError - ? `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase tab to recover this lane.` - : `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. The lane was restored to its pre-rebase state. Open the Rebase tab to recover this lane.`, + ? `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase/Merge tab to recover this lane.` + : `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. The lane was restored to its pre-rebase state. Open the Rebase/Merge tab to recover this lane.`, }, child.id); failedLaneIds.push(child.id); } diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index 8fabc39ab..d9fa62791 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -328,7 +328,7 @@ describe("LaneGitActionsPane rescue action", () => { expect(syncButton.getAttribute("title")).toMatch(/before rebasing and pushing/i); }); - it("treats auto-rebase conflicts as failures and links to the Rebase tab", async () => { + it("treats auto-rebase conflicts as failures and links to the Rebase/Merge tab", async () => { const user = userEvent.setup(); const resolveRebaseConflict = vi.fn(); mockAutoRebaseStatuses = [ @@ -345,7 +345,7 @@ describe("LaneGitActionsPane rescue action", () => { renderPane({ onResolveRebaseConflict: resolveRebaseConflict }); - const rebaseTabButton = await screen.findByRole("button", { name: /open rebase tab/i }); + const rebaseTabButton = await screen.findByRole("button", { name: /open rebase\/merge tab/i }); screen.getByText("AUTO-REBASE FAILED"); screen.getByText(/auto-rebase failed\. files need follow-up before this lane can be pushed\./i); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 60cafb46a..e05b49ff1 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -1546,7 +1546,7 @@ export function LaneGitActionsPane({ onResolveRebaseConflict(laneId, rebaseConflictParentLaneId); return; } - const search = new URLSearchParams({ tab: "rebase", laneId }); + const search = new URLSearchParams({ tab: "workflows", workflow: "rebase", laneId }); if (rebaseConflictParentLaneId) search.set("parentLaneId", rebaseConflictParentLaneId); navigate(`/prs?${search.toString()}`); }; @@ -1571,8 +1571,8 @@ export function LaneGitActionsPane({ {autoRebaseStatus.state !== "autoRebased" ? ( isAutoRebaseFailure ? ( ) : ( diff --git a/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx b/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx index c5462aa72..11ff13f9d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx @@ -50,13 +50,13 @@ export function LaneRebaseBanner({
- + @@ -120,13 +120,13 @@ export function LaneRebaseBanner({
- + diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 15fd9cba8..2fbc521eb 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1101,7 +1101,7 @@ export function LanesPage() { const failedLane = start.run.failedLaneId ? lanesById.get(start.run.failedLaneId)?.name ?? start.run.failedLaneId : null; const detail = start.run.error ?? "Rebase failed."; setRebaseSuggestionError(`Rebase needs attention${failedLane ? ` for ${failedLane}` : ""}. ${detail}`); - navigate("/prs?tab=rebase"); + navigate("/prs?tab=workflows&workflow=rebase"); return; } @@ -1121,7 +1121,7 @@ export function LanesPage() { } catch (err) { const message = err instanceof Error ? err.message : String(err); setRebaseSuggestionError(message); - navigate("/prs?tab=rebase"); + navigate("/prs?tab=workflows&workflow=rebase"); } }, [lanesById, navigate, refreshLanes, requestPushSelection, requestRebaseScope]); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index a078a9f7f..71bde9132 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; import * as Dialog from "@radix-ui/react-dialog"; import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, GitBranch, Sparkle, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash, ArrowUp, ArrowDown } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; @@ -446,7 +447,7 @@ function LaneWarningPanel({ cursor: "pointer", }} > - Open Rebase Tab + Open Rebase/Merge Tab Review rebase status before PR creation{rebaseLaneIds.length > 1 ? ` (${rebaseLaneIds.length} lanes)` : ""}. @@ -470,6 +471,7 @@ export function CreatePrModal({ onOpenChange: (open: boolean) => void; onCreated?: (created: PrSummary[]) => void | Promise; }) { + const navigate = useNavigate(); const lanes = useAppStore((s) => s.lanes); const primaryLane = React.useMemo(() => lanes.find((l) => l.laneType === "primary") ?? null, [lanes]); @@ -596,8 +598,9 @@ export function CreatePrModal({ const openRebaseTab = React.useCallback((laneId: string) => { onOpenChange(false); - window.location.hash = `#/prs?tab=rebase&laneId=${encodeURIComponent(laneId)}`; - }, [onOpenChange]); + const search = new URLSearchParams({ tab: "workflows", workflow: "rebase", laneId }); + navigate({ pathname: "/prs", search: `?${search.toString()}` }); + }, [navigate, onOpenChange]); // Reset on close React.useEffect(() => { diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index e10366d45..ec8f5ba97 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -11,7 +11,7 @@ import { GitHubTab } from "./tabs/GitHubTab"; import { WorkflowsTab, type WorkflowCategory } from "./tabs/WorkflowsTab"; import { SANS_FONT } from "../lanes/laneDesignTokens"; import { isMissionLaneHiddenByDefault } from "../lanes/laneUtils"; -import { buildPrsRouteSearch, parsePrsRouteState } from "./prsRouteState"; +import { buildPrsRouteSearch, parsePrsRouteState, resolvePrsActiveTab } from "./prsRouteState"; import { resolveRouteRebaseSelection } from "./shared/rebaseNeedUtils"; import type { PrSummary } from "../../../shared/types"; @@ -84,31 +84,21 @@ function PRsPageInner() { search: location.search, hash: window.location.hash, }); - const tab = routeState.tab; - const workflowTab = routeState.workflowTab; + const resolved = resolvePrsActiveTab(routeState); const routeRebaseItemId = resolveRouteRebaseSelection({ rebaseNeeds, routeItemId: routeState.laneId, }); - if (tab === "github" || tab === "normal") { - setActiveTab("normal"); - } else if (tab === "workflows") { - const nextWorkflowTab = workflowTab === "queue" || workflowTab === "integration" || workflowTab === "rebase" - ? workflowTab - : "integration"; - setActiveTab(nextWorkflowTab); - } else if (tab === "queue" || tab === "integration" || tab === "rebase") { - setActiveTab(tab); - } + setActiveTab(resolved.activeTab); - if (tab === "normal" || tab === "github") { + if (!resolved.isWorkflowRoute && (routeState.tab === "normal" || routeState.tab === "github")) { setSelectedPrId(routeState.prId ?? null); } - if (tab === "queue" || workflowTab === "queue") { + if (resolved.effectiveWorkflow === "queue") { setSelectedQueueGroupId(routeState.queueGroupId ?? null); } - if (tab === "rebase" || workflowTab === "rebase") { + if (resolved.effectiveWorkflow === "rebase") { setSelectedRebaseItemId(routeRebaseItemId); } } catch { diff --git a/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts b/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts index 421bd9c11..1e91c6339 100644 --- a/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts +++ b/apps/desktop/src/renderer/components/prs/prsRouteState.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildPrsRouteSearch, parsePrsRouteState } from "./prsRouteState"; +import { buildPrsRouteSearch, parsePrsRouteState, resolvePrsActiveTab } from "./prsRouteState"; describe("prsRouteState", () => { it("parses route state from search params and falls back to hash params when needed", () => { @@ -98,3 +98,46 @@ describe("prsRouteState", () => { ).toBe("?tab=workflows&workflow=rebase&laneId=lane-456"); }); }); + +describe("resolvePrsActiveTab", () => { + it("routes a stale tab=normal + hash workflow=rebase to the rebase workflow", () => { + const parsed = parsePrsRouteState({ + search: "?tab=normal", + hash: "#/prs?tab=workflows&workflow=rebase&laneId=lane-1", + }); + const resolved = resolvePrsActiveTab(parsed); + expect(resolved.isWorkflowRoute).toBe(true); + expect(resolved.effectiveWorkflow).toBe("rebase"); + expect(resolved.activeTab).toBe("rebase"); + }); + + it("falls back to integration when tab=workflows has no workflow param", () => { + const parsed = parsePrsRouteState({ search: "?tab=workflows" }); + const resolved = resolvePrsActiveTab(parsed); + expect(resolved.isWorkflowRoute).toBe(true); + expect(resolved.effectiveWorkflow).toBeNull(); + expect(resolved.activeTab).toBe("integration"); + }); + + it("treats a workflow-alias tab (tab=queue) as a workflow route", () => { + const parsed = parsePrsRouteState({ search: "?tab=queue&queueGroupId=g-1" }); + const resolved = resolvePrsActiveTab(parsed); + expect(resolved.isWorkflowRoute).toBe(true); + expect(resolved.effectiveWorkflow).toBe("queue"); + expect(resolved.activeTab).toBe("queue"); + }); + + it("keeps tab=normal on the normal tab when no workflow signal is present", () => { + const parsed = parsePrsRouteState({ search: "?tab=normal&prId=pr-1" }); + const resolved = resolvePrsActiveTab(parsed); + expect(resolved.isWorkflowRoute).toBe(false); + expect(resolved.activeTab).toBe("normal"); + }); + + it("returns normal when the route has no tab or workflow signal", () => { + const parsed = parsePrsRouteState({ search: "" }); + const resolved = resolvePrsActiveTab(parsed); + expect(resolved.isWorkflowRoute).toBe(false); + expect(resolved.activeTab).toBe("normal"); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/prsRouteState.ts b/apps/desktop/src/renderer/components/prs/prsRouteState.ts index ecdea45a5..3a1db1960 100644 --- a/apps/desktop/src/renderer/components/prs/prsRouteState.ts +++ b/apps/desktop/src/renderer/components/prs/prsRouteState.ts @@ -60,6 +60,42 @@ export function parsePrsRouteState(args: { search?: string | null; hash?: string }; } +export type ResolvedPrsRoute = { + isWorkflowRoute: boolean; + effectiveWorkflow: PrWorkflowTab | null; + activeTab: "normal" | PrWorkflowTab; +}; + +/** + * Collapse a parsed route into a single activeTab decision. + * + * Routing bounce-back guard: the presence of a `workflow=` param, or a + * workflow-alias `tab=` value (queue/integration/rebase), is treated as + * authoritative evidence of a workflow route. This prevents a stale + * `?tab=normal` in the outer search (BrowserRouter mock mode) from shadowing + * a hash-based workflow URL. + */ +export function resolvePrsActiveTab(route: ParsedPrsRouteState): ResolvedPrsRoute { + const workflowAlias: PrWorkflowTab | null = + route.tab === "queue" || route.tab === "integration" || route.tab === "rebase" + ? route.tab + : null; + const effectiveWorkflow = route.workflowTab ?? workflowAlias; + const isWorkflowRoute = Boolean(effectiveWorkflow) || route.tab === "workflows"; + if (isWorkflowRoute) { + return { + isWorkflowRoute: true, + effectiveWorkflow, + activeTab: effectiveWorkflow ?? "integration", + }; + } + return { + isWorkflowRoute: false, + effectiveWorkflow: null, + activeTab: "normal", + }; +} + export function buildPrsRouteSearch(args: { activeTab: PrActiveTab; selectedPrId: string | null; diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index df0557a5a..7d68f33c1 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -23,6 +23,18 @@ function Harness() { ); } +function RouteHarness() { + const { activeTab, selectedPrId, selectedQueueGroupId, selectedRebaseItemId } = usePrs(); + return ( +
+
{activeTab}
+
{selectedPrId ?? ""}
+
{selectedQueueGroupId ?? ""}
+
{selectedRebaseItemId ?? ""}
+
+ ); +} + describe("PrsContext refresh", () => { beforeEach(() => { const refreshedNeed: RebaseNeed = { @@ -78,6 +90,7 @@ describe("PrsContext refresh", () => { afterEach(() => { cleanup(); globalThis.window.ade = originalAde; + window.location.hash = ""; }); it("refreshes rebase needs and auto-rebase statuses without waiting for events", async () => { @@ -102,6 +115,47 @@ describe("PrsContext refresh", () => { expect(screen.getByTestId("auto-count").textContent).toBe("1"); }); }); + + it("hydrates the Rebase/Merge workflow selection from the initial hash route", async () => { + window.location.hash = "#/prs?tab=workflows&workflow=rebase&laneId=lane-1"; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("active-tab").textContent).toBe("rebase"); + }); + expect(screen.getByTestId("selected-pr-id").textContent).toBe(""); + expect(screen.getByTestId("selected-queue-group-id").textContent).toBe(""); + expect(screen.getByTestId("selected-rebase-item-id").textContent).toBe("lane-1"); + + }); + + it("does not bounce off the rebase workflow when a stale tab=normal shadows the hash", async () => { + // BrowserRouter mock mode can leave a stale `?tab=normal` in the outer + // search while the hash advances to a workflow URL. The initial route + // resolver must treat the hash workflow as authoritative. + window.history.replaceState(null, "", "/?tab=normal#/prs?tab=workflows&workflow=rebase&laneId=lane-1"); + expect(window.location.search).toBe("?tab=normal"); + expect(window.location.hash).toBe("#/prs?tab=workflows&workflow=rebase&laneId=lane-1"); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("active-tab").textContent).toBe("rebase"); + }); + expect(screen.getByTestId("selected-pr-id").textContent).toBe(""); + expect(screen.getByTestId("selected-rebase-item-id").textContent).toBe("lane-1"); + + window.history.replaceState(null, "", "/"); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 2e8f014c2..36c8e74cb 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -34,6 +34,7 @@ import type { PrTimelineFilters } from "../shared/PrTimeline"; import { DEFAULT_PR_TIMELINE_FILTERS } from "../shared/PrTimeline"; import { buildPrAiResolutionContextKey } from "../../../../shared/types"; import { getModelById } from "../../../../shared/modelRegistry"; +import { parsePrsRouteState, resolvePrsActiveTab } from "../prsRouteState"; type PrTab = "normal" | "queue" | "integration" | "rebase"; @@ -210,22 +211,36 @@ function readPersistedReasoningLevel(): string { return "medium"; } -function readInitialTab(): PrTab { - try { - const params = new URLSearchParams(window.location.search); - const tab = params.get("tab"); - if (tab === "normal" || tab === "queue" || tab === "integration" || tab === "rebase") return tab; - } catch { /* ignore */ } - return "normal"; -} - -function readInitialPrId(): string | null { +function readInitialRouteState(): { + activeTab: PrTab; + selectedPrId: string | null; + selectedQueueGroupId: string | null; + selectedRebaseItemId: string | null; +} { try { - const params = new URLSearchParams(window.location.search); - const prId = params.get("prId"); - if (prId && prId.trim().length > 0) return prId.trim(); + const route = parsePrsRouteState({ + search: window.location.search, + hash: window.location.hash, + }); + const resolved = resolvePrsActiveTab(route); + const activeTab: PrTab = resolved.isWorkflowRoute + ? (resolved.effectiveWorkflow ?? "integration") + : "normal"; + return { + activeTab, + selectedPrId: !resolved.isWorkflowRoute && (route.tab === "normal" || route.tab === "github") + ? route.prId + : null, + selectedQueueGroupId: resolved.effectiveWorkflow === "queue" ? route.queueGroupId : null, + selectedRebaseItemId: resolved.effectiveWorkflow === "rebase" ? route.laneId : null, + }; } catch { /* ignore */ } - return null; + return { + activeTab: "normal", + selectedPrId: null, + selectedQueueGroupId: null, + selectedRebaseItemId: null, + }; } function requirePrId(prId: string): string { @@ -269,13 +284,14 @@ function diffPrIds(prev: PrWithConflicts[], next: PrWithConflicts[]): string[] { } export function PrsProvider({ children }: { children: React.ReactNode }) { - const [activeTab, setActiveTab] = useState(readInitialTab); + const initialRouteState = readInitialRouteState(); + const [activeTab, setActiveTab] = useState(initialRouteState.activeTab); const [prs, setPrs] = useState([]); const [lanes, setLanes] = useState([]); const [mergeContextByPrId, setMergeContextByPrId] = useState>({}); - const [selectedPrId, setSelectedPrId] = useState(readInitialPrId); - const [selectedQueueGroupId, setSelectedQueueGroupId] = useState(null); - const [selectedRebaseItemId, setSelectedRebaseItemId] = useState(null); + const [selectedPrId, setSelectedPrId] = useState(initialRouteState.selectedPrId); + const [selectedQueueGroupId, setSelectedQueueGroupId] = useState(initialRouteState.selectedQueueGroupId); + const [selectedRebaseItemId, setSelectedRebaseItemId] = useState(initialRouteState.selectedRebaseItemId); const [mergeMethod, setMergeMethod] = useState("squash"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx index 90a49eb84..afcc3a4c9 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; import { GitMerge, GitBranch, Lightning, Eye, Sparkle, Trash, ArrowRight, ArrowSquareOut, CheckCircle, Warning, XCircle, Clock, GithubLogo, CircleNotch, ArrowsClockwise, CaretDown, CaretRight, Robot, Gear } from "@phosphor-icons/react"; import type { IntegrationProposal, @@ -120,6 +121,7 @@ function RebaseGuidancePanel({ onResimulate?: () => void; resimulateLabel?: string; }) { + const navigate = useNavigate(); return (
{ - window.location.hash = `#/prs?tab=rebase&laneId=${encodeURIComponent(laneId)}`; + const search = new URLSearchParams({ tab: "workflows", workflow: "rebase", laneId }); + navigate({ pathname: "/prs", search: `?${search.toString()}` }); }} > - OPEN REBASE TAB + OPEN REBASE/MERGE TAB {onResimulate ? (
@@ -1447,7 +1447,7 @@ export function QueueTab({ ) : null}
- Batch rebases stop at the first failure so the queue does not quietly drift. Use the Rebase tab for detailed lane history, aborts, rollbacks, and any follow-up after a partial run. + Batch rebases stop at the first failure so the queue does not quietly drift. Use the Rebase/Merge tab for detailed lane history, aborts, rollbacks, and any follow-up after a partial run.
@@ -1502,7 +1502,7 @@ export function QueueTab({ cursor: "pointer", }} > - Open failed lane in rebase tab + Open failed lane in Rebase/Merge tab ) : null} diff --git a/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx index c0e4c0074..6947f0690 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/WorkflowsTab.tsx @@ -762,7 +762,7 @@ export function WorkflowsTab({ {([ { id: "integration" as WorkflowCategory, label: "Integration", icon: GitBranch }, { id: "queue" as WorkflowCategory, label: "Queue", icon: CaretRight }, - { id: "rebase" as WorkflowCategory, label: "Rebase", icon: Sparkle }, + { id: "rebase" as WorkflowCategory, label: "Rebase/Merge", icon: Sparkle }, ]).map((category) => { const selected = activeCategory === category.id; const catTheme = CATEGORY_THEMES[category.id]; diff --git a/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx b/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx index fd4f42df2..485d4b563 100644 --- a/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx @@ -224,15 +224,15 @@ export function LaneBehaviorSection() { )} - {/* Save + Open Rebase tab */} + {/* Save + Open Rebase/Merge tab */}
Review rebase status before PR creation{rebaseLaneIds.length > 1 ? ` (${rebaseLaneIds.length} lanes)` : ""}. diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 36c8e74cb..a0e46a057 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -35,6 +35,7 @@ import { DEFAULT_PR_TIMELINE_FILTERS } from "../shared/PrTimeline"; import { buildPrAiResolutionContextKey } from "../../../../shared/types"; import { getModelById } from "../../../../shared/modelRegistry"; import { parsePrsRouteState, resolvePrsActiveTab } from "../prsRouteState"; +import { resolveRouteRebaseSelection } from "../shared/rebaseNeedUtils"; type PrTab = "normal" | "queue" | "integration" | "rebase"; @@ -232,7 +233,14 @@ function readInitialRouteState(): { ? route.prId : null, selectedQueueGroupId: resolved.effectiveWorkflow === "queue" ? route.queueGroupId : null, - selectedRebaseItemId: resolved.effectiveWorkflow === "rebase" ? route.laneId : null, + // Mirror PRsPage's resolver so the shape of this id matches what the + // rebase UI later expects. rebaseNeeds are empty at provider mount, so + // this returns the bare lane id; PRsPage's syncFromLocation effect runs + // the same resolver again once needs load and upgrades it to the + // canonical need-item key. + selectedRebaseItemId: resolved.effectiveWorkflow === "rebase" + ? resolveRouteRebaseSelection({ rebaseNeeds: [], routeItemId: route.laneId }) + : null, }; } catch { /* ignore */ } return { @@ -284,7 +292,11 @@ function diffPrIds(prev: PrWithConflicts[], next: PrWithConflicts[]): string[] { } export function PrsProvider({ children }: { children: React.ReactNode }) { - const initialRouteState = readInitialRouteState(); + // Compute initial route state exactly once per provider mount. Reading + // window.location + running parsePrsRouteState/resolvePrsActiveTab on every + // render would be wasteful; useMemo with empty deps captures it once so all + // four useState calls below share a single computation. + const initialRouteState = useMemo(() => readInitialRouteState(), []); const [activeTab, setActiveTab] = useState(initialRouteState.activeTab); const [prs, setPrs] = useState([]); const [lanes, setLanes] = useState([]); diff --git a/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx index afcc3a4c9..1d7aedc59 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/IntegrationTab.tsx @@ -161,7 +161,7 @@ function RebaseGuidancePanel({ navigate({ pathname: "/prs", search: `?${search.toString()}` }); }} > - OPEN REBASE/MERGE TAB + Open Rebase/Merge tab {onResimulate ? (