diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index cfbeb3aa7f72..07aa53750271 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -7624,6 +7624,7 @@ export default function ChatView(props: ChatViewProps) {
: "page"
}
composerDraftTarget={composerDraftTarget}
+ onOpenPullRequest={openThreadPullRequest}
{...(linkedThreadPullRequest === null
? { onStateChange: handlePullRequestTabStatusChange }
: {})}
diff --git a/apps/web/src/components/pullRequest/PullRequestDependencyNavigator.tsx b/apps/web/src/components/pullRequest/PullRequestDependencyNavigator.tsx
new file mode 100644
index 000000000000..54fa805f94da
--- /dev/null
+++ b/apps/web/src/components/pullRequest/PullRequestDependencyNavigator.tsx
@@ -0,0 +1,470 @@
+import {
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ CircleDashedIcon,
+ GitForkIcon,
+ GitMergeIcon,
+ GitPullRequestClosedIcon,
+ GitPullRequestDraftIcon,
+ LayersIcon,
+ LoaderCircleIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { useLayoutEffect, useRef } from "react";
+
+import { Button } from "../ui/button";
+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import type { DependencyChip, DependencyNavigation } from "./pullRequestDependencyNavigation.logic";
+
+function stateIcon(chip: DependencyChip) {
+ if (chip.isDraft) return ;
+ if (chip.state === "merged") return ;
+ if (chip.state === "closed") return ;
+ return null;
+}
+function chipLabel(chip: DependencyChip) {
+ const suffix = chip.isDraft
+ ? " (draft)"
+ : chip.state && chip.state !== "open"
+ ? ` (${chip.state})`
+ : "";
+ return `Open #${chip.number}${chip.title ? `: ${chip.title}` : ""}${suffix}`;
+}
+const Arrow = () => ;
+const Dot = () => (
+
+ ·
+
+);
+const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;
+
+function DependencyChipButton({
+ chip,
+ onOpen,
+}: {
+ readonly chip: DependencyChip;
+ readonly onOpen: (number: number) => void;
+}) {
+ return (
+
+ onOpen(chip.number)}
+ >
+ {stateIcon(chip)}#{chip.number}
+
+ }
+ />
+ {chip.title ?? "Details not loaded"}
+
+ );
+}
+
+function DependencyChoiceChip({
+ label,
+ items,
+ onOpen,
+}: {
+ readonly label: string;
+ readonly items: ReadonlyArray;
+ readonly onOpen: (number: number) => void;
+}) {
+ return (
+
+ );
+}
+
+function NavButton({
+ direction,
+ target,
+ disabledReason,
+ disabled,
+ onOpen,
+}: {
+ readonly direction: "parent" | "child";
+ readonly target: number | null;
+ readonly disabledReason: string;
+ readonly disabled: boolean;
+ readonly onOpen: (number: number) => void;
+}) {
+ const Icon = direction === "parent" ? ArrowLeftIcon : ArrowRightIcon;
+ const label = target === null ? disabledReason : `Open ${direction} #${target}`;
+ return (
+
+
+
+
+ }
+ />
+ {label}
+
+ );
+}
+
+export function PullRequestDependencyNavButtons({
+ navigation,
+ refreshing,
+ onOpenPullRequest,
+}: {
+ readonly navigation: DependencyNavigation;
+ readonly refreshing: boolean;
+ readonly onOpenPullRequest: (number: number) => void;
+}) {
+ if (navigation.status !== "ready") return null;
+ const show =
+ navigation.parent !== null ||
+ navigation.child !== null ||
+ navigation.children.length > 0 ||
+ navigation.possibleParents.length > 0 ||
+ navigation.parentAmbiguous;
+ if (!show) return null;
+ return (
+ <>
+ 0 || navigation.parentAmbiguous
+ ? "Parent not confirmed"
+ : "No confirmed parent"
+ }
+ disabled={refreshing}
+ onOpen={onOpenPullRequest}
+ />
+ 0 && navigation.focusIndex === navigation.path.length - 1
+ ? "Choose a child"
+ : "No confirmed child"
+ }
+ disabled={refreshing}
+ onOpen={onOpenPullRequest}
+ />
+ >
+ );
+}
+
+export function PullRequestDependencyRow({
+ navigation,
+ hostLabel,
+ refreshing,
+ onOpenPullRequest,
+ onRetry,
+}: {
+ readonly navigation: DependencyNavigation;
+ readonly hostLabel: string;
+ readonly refreshing: boolean;
+ readonly onOpenPullRequest: (number: number) => void;
+ readonly onRetry?: () => void;
+}) {
+ const trackRef = useRef(null);
+ const focusRef = useRef(null);
+ const focusNumber =
+ navigation.status === "ready" ? navigation.path[navigation.focusIndex]?.number : null;
+ useLayoutEffect(() => {
+ if (focusNumber === null) return;
+ const track = trackRef.current;
+ const chip = focusRef.current;
+ if (!track || !chip) return;
+ track.scrollLeft = chip.offsetLeft - (track.clientWidth - chip.offsetWidth) / 2;
+ }, [focusNumber]);
+ if (navigation.status === "hidden" || navigation.status === "pending") return null;
+ const rowClass = "mt-2 flex min-w-0 items-center gap-2 text-xs text-muted-foreground";
+ const retry = onRetry ? (
+
+ ) : null;
+ if (navigation.status === "partial-empty")
+ return (
+
+ );
+ if (navigation.status === "unavailable")
+ return (
+
+ );
+ const first = navigation.path[0]!;
+ const last = navigation.path.at(-1)!;
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 3ed0c61139d3..65d7b4aefca1 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -74,7 +74,7 @@ import {
useSharedPullRequestSummary,
} from "~/state/pullRequests";
import { useAtomCommand } from "~/state/use-atom-command";
-import { vcsEnvironment } from "~/state/vcs";
+import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
import { formatRelativeTimeLabel } from "~/timestampFormat";
import {
@@ -122,7 +122,6 @@ import {
handoffPrompt,
handoffReviewComments,
latestPullRequestReviewOutcomes,
- isStackedPullRequestBase,
pullRequestActionMenuHasGroup,
pullRequestActionNeedsHostRefresh,
pullRequestComposerTarget,
@@ -144,6 +143,11 @@ import {
type PickableEnvironment,
} from "./pullRequestProjectAssignment.logic";
import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
+import {
+ PullRequestDependencyNavButtons,
+ PullRequestDependencyRow,
+} from "./PullRequestDependencyNavigator";
+import { pullRequestDependencyNavigation } from "./pullRequestDependencyNavigation.logic";
import {
PullRequestActorLabel,
PullRequestDiffStat,
@@ -460,6 +464,7 @@ export function PullRequestDetailPanel({
onActed,
onClose,
onStateChange,
+ onOpenPullRequest,
context = "page",
composerDraftTarget,
}: {
@@ -489,6 +494,8 @@ export function PullRequestDetailPanel({
onClose?: () => void;
/** Keeps surrounding inferred thread state in step with refreshed host state. */
onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void;
+ /** Opens a same-repository dependency in the existing PR surface owner. */
+ onOpenPullRequest?: (number: number) => void;
/**
* Beside a thread, the checkout affordance disappears: the panel is showing that thread's
* own pull request, so the branch is already under the reader's feet — and checking it out
@@ -674,28 +681,36 @@ export function PullRequestDetailPanel({
detail.headRepositoryNameWithOwner,
)
: null;
- const branchRefsQuery = useEnvironmentQuery(
- detail === null
+ const dependencyContextQuery = useEnvironmentQuery(
+ detail === null || detail.capabilities.dependencies?.branchRelationships !== true
? null
- : vcsEnvironment.listRefs({
- environmentId,
- input: {
- cwd: detail.workspaceRoot,
- includeMatchingRemoteRefs: true,
- // listRefs keeps the current ref first and a known default second.
- limit: 2,
- },
- }),
+ : pullRequestEnvironment.dependencyContext({ environmentId, input: reference }),
);
- const isStackedPullRequest =
- detail !== null &&
- isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
+ const dependencyNavigation = pullRequestDependencyNavigation({
+ supported: detail?.capabilities.dependencies?.branchRelationships === true,
+ context: dependencyContextQuery.data ?? null,
+ pending: dependencyContextQuery.isPending,
+ failed: dependencyContextQuery.error !== null,
+ });
+ const dependencyHostLabel = detail
+ ? getSourceControlPresentationForKind(detail.provider).providerName
+ : "host";
+ const condensedDependencyIndicator =
+ dependencyNavigation.status === "ready" &&
+ (dependencyNavigation.path.length > 1 ||
+ dependencyNavigation.children.length > 0 ||
+ dependencyNavigation.native.status === "present");
+ const condensedDependencyTooltip =
+ dependencyNavigation.status !== "ready" || dependencyNavigation.path.length <= 1
+ ? "Stacked pull request"
+ : `Stacked pull request · ${dependencyNavigation.focusIndex + 1} of ${dependencyNavigation.path.length}${dependencyNavigation.coverage === "partial" ? " · partial" : ""}`;
const activityPending = activityQuery.isPending && activity === null;
const activityError = activity === null ? activityQuery.error : null;
const refreshDetail = useCallback(() => {
detailQuery.refresh();
activityQuery.refresh();
- }, [activityQuery.refresh, detailQuery.refresh]);
+ dependencyContextQuery.refresh();
+ }, [activityQuery.refresh, dependencyContextQuery.refresh, detailQuery.refresh]);
const [refreshToken, setRefreshToken] = useState(0);
const codeRefreshToken = refreshToken + (turnRefresh ?? 0);
const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
@@ -724,6 +739,7 @@ export function PullRequestDetailPanel({
useLiveRefresh(
() => {
detailQuery.refresh();
+ dependencyContextQuery.refresh();
setRefreshToken((token) => token + 1);
},
{ key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}` },
@@ -733,6 +749,10 @@ export function PullRequestDetailPanel({
// invalidation goes first so the re-reads miss that cache; if it fails, the reads still run
// and at worst answer from it.
const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
+ const retryDependencies = useCallback(async () => {
+ await invalidate({ environmentId, input: { reference } });
+ dependencyContextQuery.refresh();
+ }, [dependencyContextQuery.refresh, environmentId, invalidate, reference]);
const refreshFromHost = useCallback(async () => {
await invalidate({ environmentId, input: { reference } });
refreshDetail();
@@ -1903,19 +1923,16 @@ export function PullRequestDetailPanel({
- {isStackedPullRequest ? (
-
+ {condensedDependencyIndicator ? (
+
) : null}
{detail.baseBranch}
}
/>
- {isStackedPullRequest
- ? `Stacked on ${detail.baseBranch}`
+ {condensedDependencyIndicator
+ ? condensedDependencyTooltip
: detail.baseBranch}
@@ -1942,6 +1959,13 @@ export function PullRequestDetailPanel({
+ {onOpenPullRequest ? (
+
+ ) : null}
- {isStackedPullRequest ? (
-
- ) : null}
{detail.baseBranch}
}
/>
-
- {isStackedPullRequest
- ? `Stacked on ${detail.baseBranch}`
- : detail.baseBranch}
-
+ {detail.baseBranch}
{freshness ? (
+ {onOpenPullRequest ? (
+
+ ) : null}
) : null}
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
index c60b1d228d2d..bf1c65fc1ec7 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts
@@ -18,7 +18,6 @@ import {
handoffPrompt,
handoffReviewComments,
isPullRequestVerdictStale,
- isStackedPullRequestBase,
isThreadOwnPullRequest,
latestPullRequestReviewOutcomes,
newestPullRequestCommitAt,
@@ -236,47 +235,6 @@ describe("pull request composer target", () => {
});
});
-describe("stacked pull request classification", () => {
- it("requires a known default branch", () => {
- expect(isStackedPullRequestBase("main", [{ name: "main", isDefault: false }])).toBe(false);
- });
-
- it("recognizes local and remote forms of the default branch", () => {
- expect(
- isStackedPullRequestBase("main", [{ name: "main", isDefault: true, isRemote: false }]),
- ).toBe(false);
- expect(
- isStackedPullRequestBase("main", [
- { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" },
- ]),
- ).toBe(false);
- });
-
- it("classifies a non-default base as stacked once the default is known", () => {
- expect(
- isStackedPullRequestBase("feature-base", [
- { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" },
- ]),
- ).toBe(true);
- });
-
- it("does not mistake a nested branch suffix for the default branch", () => {
- expect(
- isStackedPullRequestBase("main", [
- {
- name: "origin/feature/main",
- isDefault: true,
- isRemote: true,
- remoteName: "origin",
- },
- ]),
- ).toBe(true);
- expect(
- isStackedPullRequestBase("1.0", [{ name: "release/1.0", isDefault: true, isRemote: false }]),
- ).toBe(true);
- });
-});
-
describe("ordering comments", () => {
it("reverses the chronological list for newest first, and leaves oldest first alone", () => {
const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
index 494a1ce9285c..be21f06138b3 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
@@ -16,7 +16,6 @@ import {
type PullRequestState,
type PullRequestUpdateMethod,
type SourceControlProviderKind,
- type VcsRef,
} from "@t3tools/contracts";
import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
@@ -176,20 +175,6 @@ export function pullRequestActionMenuHasGroup(
return showsDraftToggle || showsAutoMerge || showsMergeMethods;
}
-export function isStackedPullRequestBase(
- baseBranch: string,
- refs: ReadonlyArray>,
-): boolean {
- const defaultRef = refs.find((refName) => refName.isDefault);
- if (!defaultRef) return false;
- if (defaultRef.isRemote !== true) return defaultRef.name !== baseBranch;
- const remotePrefix = `${defaultRef.remoteName ?? defaultRef.name.split("/")[0]}/`;
- const defaultBranch = defaultRef.name.startsWith(remotePrefix)
- ? defaultRef.name.slice(remotePrefix.length)
- : defaultRef.name;
- return defaultBranch !== baseBranch;
-}
-
/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */
export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {
if (state === "merged") return "Merged";
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx
index bcc22daa4a9d..89059fb10013 100644
--- a/apps/web/src/routes/_chat.pull-requests.tsx
+++ b/apps/web/src/routes/_chat.pull-requests.tsx
@@ -1984,6 +1984,22 @@ function PullRequestsRouteView() {
authoredQuery.refresh();
reviewingQuery.refresh();
}}
+ onOpenPullRequest={(number) => {
+ if (rightPanelRef === null) return;
+ const nextSelection = {
+ environmentId: panelEnvironmentId,
+ projectId: renderedPullRequestSurface.projectId as ProjectId,
+ repository: renderedPullRequestSurface.repository,
+ number,
+ };
+ useRightPanelStore.getState().openPullRequest(rightPanelRef, nextSelection);
+ updateSearch({
+ repository: nextSelection.repository,
+ number: nextSelection.number,
+ selectedProjectId: nextSelection.projectId,
+ selectedEnvironmentId: nextSelection.environmentId,
+ });
+ }}
/>
) : null}
diff --git a/docs/user/source-control.md b/docs/user/source-control.md
index 853ef9a63689..beb33374b59d 100644
--- a/docs/user/source-control.md
+++ b/docs/user/source-control.md
@@ -107,6 +107,11 @@ Open **Pull requests** to review changes and comments, request reviewers, check
or merge. You can edit review titles and descriptions and your own comments where the host allows it.
GitLab calls these merge requests.
+When the host can confirm that one pull request targets another pull request's branch, its review
+panel shows the dependency chain. Select a related pull request there to open its usual review
+panel. T3 marks incomplete discovery instead of guessing whether a release branch or another
+unrelated non-default branch is part of a stack.
+
GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also
supports approving waiting fork workflows and opening a revert pull request for a merged change.