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;
+}