From 92c1aab95d7107f3671f88151f783a35a0aca443 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 20:11:10 +1100 Subject: [PATCH] fix(web): scope PR actions to thread branch --- apps/web/src/components/ChatView.tsx | 11 ++- apps/web/src/components/GitActionsControl.tsx | 28 ++++++- apps/web/src/components/Sidebar.tsx | 11 ++- apps/web/src/lib/threadGitStatus.test.ts | 80 +++++++++++++++++++ apps/web/src/lib/threadGitStatus.ts | 23 ++++++ 5 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/threadGitStatus.test.ts create mode 100644 apps/web/src/lib/threadGitStatus.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..911931f11 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3552,6 +3552,7 @@ export default function ChatView({ threadId }: ChatViewProps) { > )} - {activeProjectName && } + {activeProjectName && ( + + )} ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadId, + activeThreadBranch, +}: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -168,15 +174,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const isRepo = branchList?.isRepo ?? true; const hasOriginRemote = branchList?.hasOriginRemote ?? false; const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; + const threadScopedGitStatus = useMemo( + () => + resolveThreadScopedGitStatus({ + gitStatus, + threadBranch: activeThreadBranch, + }), + [activeThreadBranch, gitStatus], + ); const isGitStatusOutOfSync = - !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; + !!threadScopedGitStatus?.branch && + !!currentBranch && + threadScopedGitStatus.branch !== currentBranch; + const threadBranchMismatch = + activeThreadBranch !== null && !!gitStatus?.branch && gitStatus.branch !== activeThreadBranch; useEffect(() => { if (!isGitStatusOutOfSync) return; void invalidateGitQueries(queryClient); }, [isGitStatusOutOfSync, queryClient]); - const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; + const gitStatusForActions = isGitStatusOutOfSync ? null : threadScopedGitStatus; const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); @@ -206,7 +224,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled - ? (quickAction.hint ?? "This action is currently unavailable.") + ? threadBranchMismatch + ? `This thread is pinned to "${activeThreadBranch}" but the current branch is "${gitStatus?.branch}".` + : (quickAction.hint ?? "This action is currently unavailable.") : null; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..a8f7d2b78 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -83,6 +83,7 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; +import { resolveThreadScopedPr } from "../lib/threadGitStatus"; import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -369,9 +370,13 @@ export default function Sidebar() { const map = new Map(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); + map.set( + target.threadId, + resolveThreadScopedPr({ + gitStatus: status ?? null, + threadBranch: target.branch, + }), + ); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); diff --git a/apps/web/src/lib/threadGitStatus.test.ts b/apps/web/src/lib/threadGitStatus.test.ts new file mode 100644 index 000000000..bc250dd9f --- /dev/null +++ b/apps/web/src/lib/threadGitStatus.test.ts @@ -0,0 +1,80 @@ +import type { GitStatusResult } from "@t3tools/contracts"; +import { assert, describe, it } from "vitest"; +import { resolveThreadScopedGitStatus, resolveThreadScopedPr } from "./threadGitStatus"; + +const openPr = { + number: 42, + title: "Existing PR", + url: "https://example.com/pr/42", + baseBranch: "main", + headBranch: "feature/test", + state: "open" as const, +}; + +function status(overrides: Partial = {}): GitStatusResult { + return { + branch: "feature/test", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: openPr, + ...overrides, + }; +} + +describe("resolveThreadScopedGitStatus", () => { + it("strips PR metadata for branchless threads", () => { + assert.deepEqual( + resolveThreadScopedGitStatus({ + gitStatus: status({ aheadCount: 3 }), + threadBranch: null, + }), + status({ aheadCount: 3, pr: null }), + ); + }); + + it("keeps matching branch status intact", () => { + assert.deepEqual( + resolveThreadScopedGitStatus({ + gitStatus: status(), + threadBranch: "feature/test", + }), + status(), + ); + }); + + it("returns null when a branch-bound thread drifts onto another branch", () => { + assert.equal( + resolveThreadScopedGitStatus({ + gitStatus: status({ branch: "main" }), + threadBranch: "feature/test", + }), + null, + ); + }); +}); + +describe("resolveThreadScopedPr", () => { + it("only returns a PR for a matching thread branch", () => { + assert.equal( + resolveThreadScopedPr({ + gitStatus: status(), + threadBranch: null, + }), + null, + ); + assert.deepEqual( + resolveThreadScopedPr({ + gitStatus: status(), + threadBranch: "feature/test", + }), + openPr, + ); + }); +}); diff --git a/apps/web/src/lib/threadGitStatus.ts b/apps/web/src/lib/threadGitStatus.ts new file mode 100644 index 000000000..1a146fdfd --- /dev/null +++ b/apps/web/src/lib/threadGitStatus.ts @@ -0,0 +1,23 @@ +import type { GitStatusResult } from "@t3tools/contracts"; + +interface ThreadScopedGitStatusInput { + gitStatus: GitStatusResult | null; + threadBranch: string | null; +} + +export function resolveThreadScopedGitStatus({ + gitStatus, + threadBranch, +}: ThreadScopedGitStatusInput): GitStatusResult | null { + if (!gitStatus) return null; + + if (threadBranch === null) { + return gitStatus.pr === null ? gitStatus : { ...gitStatus, pr: null }; + } + + return gitStatus.branch === threadBranch ? gitStatus : null; +} + +export function resolveThreadScopedPr(input: ThreadScopedGitStatusInput): GitStatusResult["pr"] { + return resolveThreadScopedGitStatus(input)?.pr ?? null; +}