From 9f2435ff10a8d8082f177244d1dfff727bfd0fdd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:57:06 -0400 Subject: [PATCH 1/2] ship: checkpoint PR loading and deep-link fixes --- apps/desktop/src/renderer/browserMock.ts | 24 ++ .../src/renderer/components/app/App.tsx | 5 +- .../src/renderer/components/app/AppShell.tsx | 3 + .../components/chat/ChatGitToolbar.tsx | 3 + .../components/lanes/LaneWorkPane.tsx | 3 + .../components/lanes/LanesPage.test.ts | 23 +- .../renderer/components/lanes/LanesPage.tsx | 80 ++++- .../components/lanes/lanePageModel.ts | 14 +- .../src/renderer/components/prs/PRsPage.tsx | 102 ++++++- .../components/prs/detail/PrDetailPane.tsx | 7 +- .../prs/detail/PrDetailTimelineRails.test.tsx | 12 + .../prs/detail/PrDetailTimelineRails.tsx | 28 +- .../components/prs/prsRouteState.test.ts | 47 ++- .../renderer/components/prs/prsRouteState.ts | 78 +++++ .../components/prs/state/PrsContext.test.tsx | 2 +- .../components/prs/state/PrsContext.tsx | 22 +- .../components/prs/tabs/GitHubTab.test.tsx | 6 +- .../prs/tabs/GitHubTab.testHarness.tsx | 2 + .../components/prs/tabs/GitHubTab.tsx | 285 +++++++----------- .../prs/tabs/GitHubTabCreateLaneDialog.tsx | 22 +- .../prs/tabs/GitHubTabDeepLinks.test.tsx | 281 +++++++++++++++++ .../prs/tabs/GitHubTabRowsAndMapping.test.tsx | 19 +- .../components/prs/tabs/GitHubTabView.tsx | 7 +- .../components/prs/tabs/githubTabModel.ts | 98 +++++- .../prs/tabs/useGitHubTabListModel.ts | 12 +- .../prs/tabs/useGitHubTabSelection.ts | 186 ++++++++++++ .../prs/tabs/useGitHubTargetHistory.ts | 131 ++++++++ .../components/terminals/LanePrBadge.test.tsx | 2 + .../components/terminals/SessionCard.test.tsx | 4 +- .../components/terminals/useLanePrs.ts | 25 +- .../src/renderer/lib/lanePrBadge.test.ts | 7 +- apps/desktop/src/renderer/lib/lanePrBadge.ts | 10 +- 32 files changed, 1303 insertions(+), 247 deletions(-) create mode 100644 apps/desktop/src/renderer/components/prs/tabs/GitHubTabDeepLinks.test.tsx create mode 100644 apps/desktop/src/renderer/components/prs/tabs/useGitHubTabSelection.ts create mode 100644 apps/desktop/src/renderer/components/prs/tabs/useGitHubTargetHistory.ts diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 1d16b12ea..3c636ea90 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1457,6 +1457,20 @@ const ALL_PRS = USE_ADE_DB_SNAPSHOT : [] : [...NORMAL_PRS, ...INTEGRATION_PRS]; +function getAdeDbPrSnapshotByGithubCoordinates(args: any): any | null { + const repoOwner = String(args?.repoOwner ?? "").trim().toLowerCase(); + const repoName = String(args?.repoName ?? "").trim().toLowerCase(); + const githubPrNumber = Number(args?.githubPrNumber); + if (!repoOwner || !repoName || !Number.isInteger(githubPrNumber) || githubPrNumber <= 0) return null; + + const pr = ALL_PRS.find((candidate: any) => + String(candidate.repoOwner ?? "").trim().toLowerCase() === repoOwner + && String(candidate.repoName ?? "").trim().toLowerCase() === repoName + && Number(candidate.githubPrNumber) === githubPrNumber, + ); + return pr ? ADE_DB_PR_SNAPSHOT_BY_ID.get(String(pr.id)) ?? null : null; +} + // ── Merge Contexts ──────────────────────────────────────────── const BUILTIN_MOCK_MERGE_CONTEXTS: Record = { // Normal PRs — no group @@ -6065,6 +6079,16 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { MOCK_REVIEWS_BY_PR[prId] ?? [], getReviewThreads: resolvedArg([]), + getDetailByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.detail ?? null, + getFilesByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.files ?? [], + getCommitsByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.commits ?? [], + getActionRunsByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.actionRuns ?? [], + getActivityByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.activity ?? [], + getStatusByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.status ?? null, + getChecksByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.checks ?? [], + getReviewsByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.reviews ?? [], + getCommentsByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.comments ?? [], + getReviewThreadsByGithub: async (args: any) => getAdeDbPrSnapshotByGithubCoordinates(args)?.reviewThreads ?? [], updateDescription: resolvedArg(undefined), delete: resolvedArg({ deleted: true }), draftDescription: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 404d52cc3..cd9401415 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -72,9 +72,11 @@ const workRoute = createPreloadableRoute<{ active?: boolean }>(() => ); const TerminalsPage = workRoute.Component; const preloadTerminalsPage = workRoute.preload; -const PRsPage = React.lazy(() => +const prsRoute = createPreloadableRoute<{ active?: boolean }>(() => import("../prs/PRsPage").then((m) => ({ default: m.PRsPage })) ); +const PRsPage = prsRoute.Component; +const preloadPrsPage = prsRoute.preload; const ReviewPage = React.lazy(() => import("../review/ReviewPage").then((m) => ({ default: m.ReviewPage })) ); @@ -753,6 +755,7 @@ function ProjectTabHost() { void preloadTerminalsPage().catch(() => undefined); void preloadLanesPage().catch(() => undefined); void preloadFilesTab().catch(() => undefined); + void preloadPrsPage().catch(() => undefined); void preloadCtoPage().catch(() => undefined); }; const idleWindow = window as Window & { diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 0ad286900..d548e3490 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1609,6 +1609,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { const search = buildPrsRouteSearch({ activeTab: "normal", selectedPrId: toast.event.prId, + selectedPrNumber: toast.event.prNumber, + repoOwner: toast.event.repoOwner, + repoName: toast.event.repoName, selectedRebaseItemId: null, detailTab, }); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 09105c1d8..290638bc1 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -323,6 +323,9 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ localPath: `/prs${buildPrsRouteSearch({ activeTab: "normal", selectedPrId: pr.id, + selectedPrNumber: pr.githubPrNumber, + repoOwner: pr.repoOwner, + repoName: pr.repoName, selectedLaneId: laneId, selectedRebaseItemId: null, })}`, diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index acad5ae17..3a183b220 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -36,6 +36,9 @@ function LanePullRequestsSection({ lane, prs }: { lane: LaneSummary | null; prs: onClick={() => navigate(`/prs${buildPrsRouteSearch({ activeTab: "normal", selectedPrId: pr.id, + selectedPrNumber: pr.githubPrNumber, + repoOwner: pr.repoOwner, + repoName: pr.repoName, selectedLaneId: pr.laneId, selectedRebaseItemId: null, })}`)} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index 67e14a86b..5437b315c 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -20,7 +20,11 @@ import { shouldApplyLaneIdsDeepLink, sortLaneListRows, } from "./lanePageModel"; -import { buildLaneSplitColumnsKey, shouldMountGitActionsPane } from "./LanesPage"; +import { + buildLaneSplitColumnsKey, + shouldMountGitActionsPane, + shouldRetryLaneGithubSnapshotForceRefresh, +} from "./LanesPage"; import type { GitHubPrListItem, LaneSummary, @@ -94,6 +98,23 @@ function makeGitHubPr(overrides: Partial = {}): GitHubPrListIt }; } +describe("lane GitHub snapshot force refresh", () => { + it("retries after a transient forced refresh failure for the same project", () => { + expect(shouldRetryLaneGithubSnapshotForceRefresh({ + currentProjectRoot: "/project", + markedProjectRoot: "/project", + refreshSucceeded: false, + startedProjectRoot: "/project", + })).toBe(true); + expect(shouldRetryLaneGithubSnapshotForceRefresh({ + currentProjectRoot: "/project", + markedProjectRoot: "/project", + refreshSucceeded: true, + startedProjectRoot: "/project", + })).toBe(false); + }); +}); + describe("resolveCreateLaneRequest", () => { it("creates an independent lane from the selected primary branch", () => { expect( diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 01ced55a7..64750296b 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -83,7 +83,7 @@ import { DEFAULT_REBASE_SUGGESTIONS, type RebaseSuggestionDisplay, } from "../../../shared/types/config"; -import { getGitHubSnapshotCoalesced, listPrsCoalesced, refreshPrsCoalesced, warmPrSurfaceCoalesced } from "../../lib/prReadCache"; +import { getGitHubSnapshotCoalesced, listPrsCoalesced, refreshPrsCoalesced } from "../../lib/prReadCache"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; import { useLaneListInvalidation } from "../../hooks/useLaneListInvalidation"; @@ -131,6 +131,22 @@ export function shouldMountGitActionsPane({ return surface !== "inline" || !laneId || expandedGitActionsLaneId !== laneId; } +export function shouldRetryLaneGithubSnapshotForceRefresh({ + currentProjectRoot, + markedProjectRoot, + refreshSucceeded, + startedProjectRoot, +}: { + currentProjectRoot: string | null; + markedProjectRoot: string | null; + refreshSucceeded: boolean; + startedProjectRoot: string; +}): boolean { + return !refreshSucceeded + && currentProjectRoot === startedProjectRoot + && markedProjectRoot === startedProjectRoot; +} + type RebasePushReviewState = { runId: string; lanes: Array<{ laneId: string; laneName: string; selected: boolean }>; @@ -482,6 +498,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const laneGithubPrTagsRequestRef = useRef(0); const laneVisiblePrRefreshRequestedAtRef = useRef>(new Map()); const laneVisiblePrRefreshProjectRootRef = useRef(null); + const laneGithubSnapshotForceRefreshProjectRootRef = useRef(null); const [laneVisiblePrRefreshVisibilityToken, setLaneVisiblePrRefreshVisibilityToken] = useState(0); const hasActiveLaneRuntimeRef = useRef(false); const [autoRebaseEnabled, setAutoRebaseEnabled] = useState(false); @@ -911,7 +928,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { } }, [getActiveProjectRoot]); - const refreshLaneGithubPrTags = useCallback(async (options?: { force?: boolean }) => { + const refreshLaneGithubPrTags = useCallback(async (options?: { force?: boolean }): Promise => { const requestId = ++laneGithubPrTagsRequestRef.current; const startedRoot = getActiveProjectRoot(); try { @@ -919,13 +936,15 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { { force: options?.force === true }, { projectRoot: startedRoot }, ); - if (requestId !== laneGithubPrTagsRequestRef.current) return; - if (getActiveProjectRoot() !== startedRoot) return; + if (requestId !== laneGithubPrTagsRequestRef.current) return false; + if (getActiveProjectRoot() !== startedRoot) return false; setLaneGithubPrTags(snapshot.repoPullRequests); + return true; } catch { - if (requestId !== laneGithubPrTagsRequestRef.current) return; - if (getActiveProjectRoot() !== startedRoot) return; + if (requestId !== laneGithubPrTagsRequestRef.current) return false; + if (getActiveProjectRoot() !== startedRoot) return false; // Keep the last usable GitHub snapshot visible on transient refresh failures. + return false; } }, [getActiveProjectRoot]); @@ -1075,23 +1094,57 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { useEffect(() => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; + laneGithubSnapshotForceRefreshProjectRootRef.current = null; setLanePrTags([]); setLaneGithubPrTags([]); if (!active || !activeProjectRoot) { return; } - void refreshLanePrTags({ refreshMapped: true }); - void refreshLaneGithubPrTags({ force: true }); - void warmPrSurfaceCoalesced({ - projectRoot: activeProjectRoot, - includeGithubSnapshot: false, - }); + // Keep the lane surface local-first. Visible stale rows are refreshed by + // the debounced viewport pass below; a forced snapshot and per-lane PR + // refresh here made opening the next PR surface compete with GitHub work. + void refreshLanePrTags(); + void refreshLaneGithubPrTags(); return () => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; }; }, [active, refreshLanePrTags, refreshLaneGithubPrTags, activeProjectRoot, lanePrBranchSignature]); + useEffect(() => { + if (!active || !activeProjectRoot || document.visibilityState !== "visible") return; + if (laneGithubSnapshotForceRefreshProjectRootRef.current === activeProjectRoot) return; + const hasVisibleGithubOnlyPr = visibleLaneIds.some((laneId) => + lanePrTagsByLaneId.get(laneId)?.some((tag) => tag.source === "github" && !tag.linkedPrId) ?? false, + ); + if (!hasVisibleGithubOnlyPr) return; + + const startedRoot = activeProjectRoot; + const timer = window.setTimeout(() => { + if (getActiveProjectRoot() !== startedRoot) return; + laneGithubSnapshotForceRefreshProjectRootRef.current = startedRoot; + void refreshLaneGithubPrTags({ force: true }).then((refreshSucceeded) => { + if (shouldRetryLaneGithubSnapshotForceRefresh({ + currentProjectRoot: getActiveProjectRoot(), + markedProjectRoot: laneGithubSnapshotForceRefreshProjectRootRef.current, + refreshSucceeded, + startedProjectRoot: startedRoot, + })) { + laneGithubSnapshotForceRefreshProjectRootRef.current = null; + } + }); + }, 750); + return () => window.clearTimeout(timer); + }, [ + active, + activeProjectRoot, + getActiveProjectRoot, + lanePrTagsByLaneId, + laneVisiblePrRefreshVisibilityToken, + refreshLaneGithubPrTags, + visibleLaneIds, + ]); + useEffect(() => { if (!active) return; return window.ade.prs.onEvent((event) => { @@ -3289,6 +3342,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { navigate(`/prs${buildPrsRouteSearch({ activeTab: "normal", selectedPrId: target.linkedPrId, + selectedPrNumber: target.githubPrNumber, + repoOwner: target.repoOwner, + repoName: target.repoName, selectedRebaseItemId: null, })}`); return; diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index 533429bb5..00a0648f8 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -12,6 +12,7 @@ import type { import type { CreateLaneMode } from "./CreateLaneDialog"; import { mergeUnique } from "./laneUtils"; import { isTerminalPrState } from "../../lib/prState"; +import { prRouteCoordinatesMatch } from "../prs/prsRouteState"; type CreateLaneRequest = | { kind: "child"; args: { name: string; parentLaneId: string } } @@ -30,6 +31,9 @@ export type LaneTabPrTag = { linkedPrId: string | null; githubPrNumber: number; githubUrl: string; + /** Repository coordinates keep badge deep links usable before local hydration. */ + repoOwner: string; + repoName: string; title: string; state: PrSummary["state"]; /** A live PR follows the lane's current branch; previous PRs remain history on the lane. */ @@ -280,8 +284,10 @@ export function githubPrMatchesCurrentBranch( if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false; const headRepoOwner = pr.headRepoOwner?.trim(); const headRepoName = pr.headRepoName?.trim(); - if (headRepoOwner && pr.repoOwner && headRepoOwner.toLowerCase() !== pr.repoOwner.toLowerCase()) return false; - if (headRepoName && pr.repoName && headRepoName.toLowerCase() !== pr.repoName.toLowerCase()) return false; + if (!prRouteCoordinatesMatch( + { prNumber: null, repoOwner: headRepoOwner ?? null, repoName: headRepoName ?? null }, + { prNumber: null, repoOwner: pr.repoOwner, repoName: pr.repoName }, + )) return false; if (lane.laneType === "primary") { const baseBranch = normalizeLanePrBranch(lane.baseRef); if (laneBranch && baseBranch && laneBranch === baseBranch) return false; @@ -312,6 +318,8 @@ function toLaneTabPrTagFromPrSummary(pr: PrSummary, laneRole?: "active" | "previ linkedPrId: pr.id, githubPrNumber: pr.githubPrNumber, githubUrl: pr.githubUrl, + repoOwner: pr.repoOwner, + repoName: pr.repoName, title: pr.title, state: pr.state, laneRole, @@ -341,6 +349,8 @@ function toLaneTabPrTagFromGithubItem( linkedPrId, githubPrNumber: pr.githubPrNumber, githubUrl: pr.githubUrl, + repoOwner: pr.repoOwner, + repoName: pr.repoName, title: pr.title, state: pr.isDraft ? "draft" : pr.state, laneRole, diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index e74c6dc87..52d5efbd9 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -15,9 +15,13 @@ import { SANS_FONT } from "../lanes/laneDesignTokens"; import { buildPrsRouteSearch, parsePrsRouteState, + prRouteCoordinatesMatch, + prRouteSelectionTarget, + prRouteTargetsEqual, resolvePrsActiveTab, writeStoredPrsRoute, type PrDetailRouteTab, + type PrRouteSelectionTarget, } from "./prsRouteState"; import { resolveRouteRebaseSelection } from "./shared/rebaseNeedUtils"; import type { PrSummary } from "../../../shared/types"; @@ -131,6 +135,16 @@ function PRsPageInner() { const [integrationRefreshNonce, setIntegrationRefreshNonce] = React.useState(0); const [githubHeaderChrome, setGithubHeaderChrome] = React.useState(null); const lastRouteLocationKeyRef = React.useRef(null); + const [selectedPrTarget, setSelectedPrTarget] = React.useState(() => { + try { + return prRouteSelectionTarget(parsePrsRouteState({ + search: window.location.search, + hash: window.location.hash, + })); + } catch { + return null; + } + }); const [selectedDetailTab, setSelectedDetailTab] = React.useState(() => { try { return parsePrsRouteState({ search: window.location.search, hash: window.location.hash }).detailTab; @@ -162,6 +176,11 @@ function PRsPageInner() { if (!targeted) setIntegrationRefreshNonce((prev) => prev + 1); }, [refresh, refreshLanes]); + const handleSelectPr = React.useCallback((id: string | null, target: PrRouteSelectionTarget | null) => { + setSelectedPrId(id); + setSelectedPrTarget(target ? { ...target, prId: id ?? target.prId } : null); + }, [setSelectedPrId]); + const openCreatePr = React.useCallback((props?: Record) => { setCreatePrInitialValues(createInitialValuesFromDialogProps(props)); setCreatePrOpen(true); @@ -179,7 +198,13 @@ function PRsPageInner() { if (!first) return; setActiveTab("normal"); setSelectedPrId(first.id); - }, [handleRefresh, setActiveTab, setSelectedPrId]); + setSelectedPrTarget({ + prId: first.id, + prNumber: first.githubPrNumber, + repoOwner: first.repoOwner, + repoName: first.repoName, + }); + }, [handleRefresh, setActiveTab, setSelectedPrId, setSelectedPrTarget]); React.useEffect(() => { const syncFromLocation = () => { @@ -204,16 +229,37 @@ function PRsPageInner() { setActiveTab(resolved.activeTab); if (!resolved.isWorkflowRoute) { - const hasExplicitPrSelection = Boolean(routeState.prId) || routeState.prNumber != null; + const routeTarget = prRouteSelectionTarget(routeState); + const hasExplicitPrSelection = routeTarget !== null; const prNumberMatch = routeState.prNumber == null ? null - : prs.find((pr) => pr.githubPrNumber === routeState.prNumber)?.id ?? null; + : prs.find((pr) => prRouteCoordinatesMatch( + { + prNumber: pr.githubPrNumber, + repoOwner: pr.repoOwner, + repoName: pr.repoName, + }, + routeState, + ))?.id ?? null; + const localRouteId = routeState.prId && ( + loading || prs.some((pr) => pr.id === routeState.prId) + ) + ? routeState.prId + : null; if (hasExplicitPrSelection || locationChanged) { - setSelectedPrId(routeState.prId ?? prNumberMatch); + // A coordinate route is still valid without an ADE row. Keep the + // route target alive, but only hand a local id to PrsContext once + // that row is actually present. + setSelectedPrId(localRouteId ?? prNumberMatch); + setSelectedPrTarget((previous) => ( + prRouteTargetsEqual(previous, routeTarget) ? previous : routeTarget + )); } if (hasExplicitPrSelection || locationChanged || routeState.detailTab) { setSelectedDetailTab(routeState.detailTab); } + } else { + setSelectedPrTarget((previous) => previous === null ? previous : null); } if (resolved.effectiveWorkflow === "rebase") { setSelectedRebaseItemId(routeRebaseItemId); @@ -237,23 +283,43 @@ function PRsPageInner() { window.removeEventListener("popstate", syncFromLocation); window.removeEventListener("hashchange", syncFromLocation); }; - }, [location.search, prs, rebaseNeeds, setActiveTab, setSelectedPrId, setSelectedRebaseItemId]); + }, [location.hash, location.pathname, location.search, loading, prs, rebaseNeeds, setActiveTab, setSelectedPrId, setSelectedRebaseItemId]); React.useEffect(() => { - const current = parsePrsRouteState({ search: location.search }); + const current = parsePrsRouteState({ search: location.search, hash: location.hash }); + const localSelectedPr = selectedPrId ? prs.find((pr) => pr.id === selectedPrId) ?? null : null; + const target = selectedPrTarget ?? (localSelectedPr ? { + prId: localSelectedPr.id, + prNumber: localSelectedPr.githubPrNumber, + repoOwner: localSelectedPr.repoOwner, + repoName: localSelectedPr.repoName, + } : null); + const hasPrNumber = target?.prNumber != null; + const hasCoordinates = Boolean(target?.repoOwner && target.repoName && hasPrNumber); + // Coordinate targets deliberately omit a missing local id. This keeps the + // route addressable on the GitHub surface instead of sending a foreign or + // not-yet-hydrated ADE id back through PrsContext. + const routePrId = selectedPrId ?? (hasCoordinates ? null : target?.prId ?? null); // Preserve per-PR deep-link params (eventId/threadId/commitSha) as long as - // the URL still points at the currently selected PR. When the PR changes, - // stale deep-link params for the old PR get dropped. + // the URL still points at the same selected PR, including coordinate-only + // routes. When the PR changes, stale deep-link params get dropped. + const sameCoordinateTarget = Boolean( + target?.prNumber != null + && current.prNumber != null + && prRouteCoordinatesMatch(target, current), + ); const preserveDeepLinks = - activeTab === "normal" && - selectedPrId !== null && - current.prId === selectedPrId; + activeTab === "normal" + && (current.prId === routePrId || sameCoordinateTarget); const deepLinks = preserveDeepLinks ? { eventId: current.eventId, threadId: current.threadId, commitSha: current.commitSha } : { eventId: null, threadId: null, commitSha: null }; const nextSearch = buildPrsRouteSearch({ activeTab, - selectedPrId, + selectedPrId: routePrId, + selectedPrNumber: hasPrNumber ? target?.prNumber : localSelectedPr?.githubPrNumber ?? null, + repoOwner: hasCoordinates ? target?.repoOwner : localSelectedPr?.repoOwner ?? null, + repoName: hasCoordinates ? target?.repoName : localSelectedPr?.repoName ?? null, selectedRebaseItemId, detailTab: activeTab === "normal" ? selectedDetailTab : null, ...deepLinks, @@ -262,19 +328,24 @@ function PRsPageInner() { void navigate({ pathname: location.pathname, search: nextSearch }, { replace: true }); }, [ activeTab, + prs, selectedPrId, + selectedPrTarget, selectedRebaseItemId, selectedDetailTab, location.pathname, location.search, + location.hash, navigate, ]); React.useEffect(() => { if (location.pathname !== "/prs") return; if (readCreatePrRouteRequest({ search: location.search, hash: window.location.hash })) return; - writeStoredPrsRoute(`${location.pathname}${location.search}`, projectRoot); - }, [location.pathname, location.search, projectRoot]); + const hash = location.hash ?? ""; + const hashRoute = hash.startsWith("#/prs") ? hash.slice(1) : null; + writeStoredPrsRoute(hashRoute ?? `${location.pathname}${location.search}`, projectRoot); + }, [location.hash, location.pathname, location.search, projectRoot]); const activeMode: SurfaceMode = activeTab === "normal" ? "github" : "workflows"; @@ -489,7 +560,8 @@ function PRsPageInner() { lanes={visibleLanes} mergeMethod={mergeMethod} selectedPrId={selectedPrId} - onSelectPr={setSelectedPrId} + selectedPrTarget={selectedPrTarget} + onSelectPr={handleSelectPr} selectedDetailTab={selectedDetailTab} onDetailTabChange={setSelectedDetailTab} onRefreshAll={handleRefresh} diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index a05be068c..a2e2f24ea 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -371,6 +371,8 @@ type PrDetailPaneProps = { */ githubCoords?: PrGithubCoords | null; unmapped?: boolean; + /** The route has coordinates but GitHub has not resolved the row yet. */ + provisional?: boolean; /** * Create-lane / map-to-lane controls surfaced as an in-pane banner when the * PR is unmapped. Provided by GitHubTab so this pane stays presentational. @@ -498,6 +500,7 @@ export function PrDetailPane({ unmapBusy = false, githubCoords = null, unmapped = false, + provisional = false, unmappedAffordance = null, }: PrDetailPaneProps) { const { @@ -1456,7 +1459,9 @@ export function PrDetailPane({ {pr.title} - + {pr.laneId ? (