authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 17:00:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 17:00:19+02:00
log724887717f1b5ded5b60bd5abe7e41a9ce5b47e5
tree0e068a20abb251a1a0bcc26fe95b0c1d8a27f003
parentbdf99c17bf7ae634728481677cf1bca8db65d00e
signature Signed by PGP key B5690EEEBB952194

fix(web): scope PR state to the thread branch (#4460)

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 57 insertions(+), 22 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+30
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 resolveDraftEnvModeAfterBranchChange,9 resolveDraftEnvModeAfterBranchChange,
10 resolveEffectiveEnvMode,10 resolveEffectiveEnvMode,
11 resolveEnvModeLabel,11 resolveEnvModeLabel,
12 resolveBranchToolbarPrBranch,
12 resolveBranchToolbarValue,13 resolveBranchToolbarValue,
13 resolveLockedWorkspaceLabel,14 resolveLockedWorkspaceLabel,
14 resolveLocalCheckoutBranchMismatch,15 resolveLocalCheckoutBranchMismatch,
...@@ -173,6 +174,35 @@ describe("resolveBranchToolbarValue", () => {...@@ -173,6 +174,35 @@ describe("resolveBranchToolbarValue", () => {
173 });174 });
174});175});
175176
177describe("resolveBranchToolbarPrBranch", () => {
178 it("uses the explicit thread branch when it matches the displayed branch", () => {
179 expect(
180 resolveBranchToolbarPrBranch({
181 activeThreadBranch: "feature/current",
182 resolvedActiveBranch: "feature/current",
183 }),
184 ).toBe("feature/current");
185 });
186
187 it("hides PR state while an optimistic branch switch is in flight", () => {
188 expect(
189 resolveBranchToolbarPrBranch({
190 activeThreadBranch: "feature/current",
191 resolvedActiveBranch: "feature/next",
192 }),
193 ).toBeNull();
194 });
195
196 it("does not infer PR state without an explicit thread branch", () => {
197 expect(
198 resolveBranchToolbarPrBranch({
199 activeThreadBranch: null,
200 resolvedActiveBranch: "feature/current",
201 }),
202 ).toBeNull();
203 });
204});
205
176describe("resolveLocalCheckoutBranchMismatch", () => {206describe("resolveLocalCheckoutBranchMismatch", () => {
177 it("detects when a local thread is associated with a different branch than the checkout", () => {207 it("detects when a local thread is associated with a different branch than the checkout", () => {
178 expect(208 expect(
apps/web/src/components/BranchToolbar.logic.ts+7
...@@ -156,6 +156,13 @@ export function resolveBranchToolbarValue(input: {...@@ -156,6 +156,13 @@ export function resolveBranchToolbarValue(input: {
156 return currentGitBranch ?? activeThreadBranch;156 return currentGitBranch ?? activeThreadBranch;
157}157}
158158
159export function resolveBranchToolbarPrBranch(input: {
160 activeThreadBranch: string | null;
161 resolvedActiveBranch: string | null;
162}): string | null {
163 return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null;
164}
165
159export function resolveLocalCheckoutBranchMismatch(input: {166export function resolveLocalCheckoutBranchMismatch(input: {
160 effectiveEnvMode: EnvMode;167 effectiveEnvMode: EnvMode;
161 activeWorktreePath: string | null;168 activeWorktreePath: string | null;
apps/web/src/components/BranchToolbarBranchSelector.tsx+5-2
...@@ -35,6 +35,7 @@ import { parsePullRequestReference } from "../pullRequestReference";...@@ -35,6 +35,7 @@ import { parsePullRequestReference } from "../pullRequestReference";
35import { getSourceControlPresentation } from "../sourceControlPresentation";35import { getSourceControlPresentation } from "../sourceControlPresentation";
36import {36import {
37 deriveLocalBranchNameFromRemoteRef,37 deriveLocalBranchNameFromRemoteRef,
38 resolveBranchToolbarPrBranch,
38 resolveBranchSelectionTarget,39 resolveBranchSelectionTarget,
39 resolveBranchToolbarValue,40 resolveBranchToolbarValue,
40 resolveDraftEnvModeAfterBranchChange,41 resolveDraftEnvModeAfterBranchChange,
...@@ -591,9 +592,11 @@ export function BranchToolbarBranchSelector({...@@ -591,9 +592,11 @@ export function BranchToolbarBranchSelector({
591592
592 // PR pill shown next to the branch selector when the active branch has one.593 // PR pill shown next to the branch selector when the active branch has one.
593 const branchPr = resolveThreadPr({594 const branchPr = resolveThreadPr({
594 threadBranch: resolvedActiveBranch,595 threadBranch: resolveBranchToolbarPrBranch({
596 activeThreadBranch,
597 resolvedActiveBranch,
598 }),
595 gitStatus: branchStatusQuery.data ?? null,599 gitStatus: branchStatusQuery.data ?? null,
596 hasDedicatedWorktree: activeWorktreePath !== null,
597 });600 });
598 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);601 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
599 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's602 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
apps/web/src/components/ChatView.tsx-1
...@@ -3874,7 +3874,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3874,7 +3874,6 @@ function ChatViewContent(props: ChatViewProps) {
3874 const activeThreadPr = resolveThreadPr({3874 const activeThreadPr = resolveThreadPr({
3875 threadBranch: activeThread?.branch ?? null,3875 threadBranch: activeThread?.branch ?? null,
3876 gitStatus: gitStatusQuery.data ?? null,3876 gitStatus: gitStatusQuery.data ?? null,
3877 hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null,
3878 });3877 });
3879 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;3878 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
3880 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;3879 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
apps/web/src/components/Sidebar.tsx-1
...@@ -452,7 +452,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -452,7 +452,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
452 const pr = resolveThreadPr({452 const pr = resolveThreadPr({
453 threadBranch: thread.branch,453 threadBranch: thread.branch,
454 gitStatus: gitStatus.data,454 gitStatus: gitStatus.data,
455 hasDedicatedWorktree: thread.worktreePath !== null,
456 });455 });
457 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);456 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
458 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);457 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
apps/web/src/components/SidebarV2.tsx-1
...@@ -502,7 +502,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -502,7 +502,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
502 const pr = resolveThreadPr({502 const pr = resolveThreadPr({
503 threadBranch: thread.branch,503 threadBranch: thread.branch,
504 gitStatus: gitStatus.data,504 gitStatus: gitStatus.data,
505 hasDedicatedWorktree: thread.worktreePath !== null,
506 });505 });
507 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);506 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
508 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;507 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
apps/web/src/components/ThreadStatusIndicators.test.ts+14-10
...@@ -36,31 +36,35 @@ describe("resolveThreadPr", () => {...@@ -36,31 +36,35 @@ describe("resolveThreadPr", () => {
36 resolveThreadPr({36 resolveThreadPr({
37 threadBranch: "feature/other",37 threadBranch: "feature/other",
38 gitStatus: status(),38 gitStatus: status(),
39 hasDedicatedWorktree: false,
40 }),39 }),
41 ).toBeNull();40 ).toBeNull();
42 });41 });
4342
44 it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => {43 it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => {
45 const gitStatus = status();44 expect(
45 resolveThreadPr({
46 threadBranch: "stack/base",
47 gitStatus: status(),
48 }),
49 ).toBeNull();
50 });
4651
52 it("hides PR indicators when thread branch metadata is missing", () => {
47 expect(53 expect(
48 resolveThreadPr({54 resolveThreadPr({
49 threadBranch: "feature/old-name",55 threadBranch: null,
50 gitStatus,56 gitStatus: status(),
51 hasDedicatedWorktree: true,
52 }),57 }),
53 ).toBe(gitStatus.pr);58 ).toBeNull();
54 });59 });
5560
56 it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => {61 it("shows the PR when the live checkout matches the stored thread branch", () => {
57 const gitStatus = status();62 const gitStatus = status();
5863
59 expect(64 expect(
60 resolveThreadPr({65 resolveThreadPr({
61 threadBranch: null,66 threadBranch: "feature/current",
62 gitStatus,67 gitStatus,
63 hasDedicatedWorktree: true,
64 }),68 }),
65 ).toBe(gitStatus.pr);69 ).toBe(gitStatus.pr);
66 });70 });
apps/web/src/components/ThreadStatusIndicators.tsx+1-7
...@@ -113,17 +113,12 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }...@@ -113,17 +113,12 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }
113export function resolveThreadPr(input: {113export function resolveThreadPr(input: {
114 threadBranch: string | null;114 threadBranch: string | null;
115 gitStatus: VcsStatusResult | null;115 gitStatus: VcsStatusResult | null;
116 hasDedicatedWorktree: boolean;
117}): ThreadPr | null {116}): ThreadPr | null {
118 const { threadBranch, gitStatus, hasDedicatedWorktree } = input;117 const { threadBranch, gitStatus } = input;
119 if (gitStatus === null) {118 if (gitStatus === null) {
120 return null;119 return null;
121 }120 }
122121
123 if (hasDedicatedWorktree) {
124 return gitStatus.pr ?? null;
125 }
126
127 if (threadBranch === null || gitStatus.refName !== threadBranch) {122 if (threadBranch === null || gitStatus.refName !== threadBranch) {
128 return null;123 return null;
129 }124 }
...@@ -258,7 +253,6 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -258,7 +253,6 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
258 const pr = resolveThreadPr({253 const pr = resolveThreadPr({
259 threadBranch: thread.branch,254 threadBranch: thread.branch,
260 gitStatus: gitStatus.data,255 gitStatus: gitStatus.data,
261 hasDedicatedWorktree: thread.worktreePath !== null,
262 });256 });
263 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);257 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
264 const threadStatus = resolveThreadStatusPill({258 const threadStatus = resolveThreadStatusPill({