Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3552,6 +3552,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
>
<ChatHeader
activeThreadId={activeThread.id}
activeThreadBranch={activeThread.branch}
activeThreadTitle={activeThread.title}
activeProjectName={activeProject?.name}
isGitRepo={isGitRepo}
Expand Down Expand Up @@ -4255,6 +4256,7 @@ export default function ChatView({ threadId }: ChatViewProps) {

interface ChatHeaderProps {
activeThreadId: ThreadId;
activeThreadBranch: string | null;
activeThreadTitle: string;
activeProjectName: string | undefined;
isGitRepo: boolean;
Expand All @@ -4275,6 +4277,7 @@ interface ChatHeaderProps {

const ChatHeader = memo(function ChatHeader({
activeThreadId,
activeThreadBranch,
activeThreadTitle,
activeProjectName,
isGitRepo,
Expand Down Expand Up @@ -4332,7 +4335,13 @@ const ChatHeader = memo(function ChatHeader({
openInCwd={openInCwd}
/>
)}
{activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
{activeProjectName && (
<GitActionsControl
gitCwd={gitCwd}
activeThreadId={activeThreadId}
activeThreadBranch={activeThreadBranch}
/>
)}
<Tooltip>
<TooltipTrigger
render={
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ import {
gitStatusQueryOptions,
invalidateGitQueries,
} from "~/lib/gitReactQuery";
import { resolveThreadScopedGitStatus } from "~/lib/threadGitStatus";
import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
import { readNativeApi } from "~/nativeApi";

interface GitActionsControlProps {
gitCwd: string | null;
activeThreadId: ThreadId | null;
activeThreadBranch: string | null;
}

interface PendingDefaultBranchAction {
Expand Down Expand Up @@ -150,7 +152,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
return <InfoIcon className={iconClassName} />;
}

export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
export default function GitActionsControl({
gitCwd,
activeThreadId,
activeThreadBranch,
}: GitActionsControlProps) {
const threadToastData = useMemo(
() => (activeThreadId ? { threadId: activeThreadId } : undefined),
[activeThreadId],
Expand All @@ -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 }));

Expand Down Expand Up @@ -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({
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -369,9 +370,13 @@ export default function Sidebar() {
const map = new Map<ThreadId, ThreadPr>();
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]);
Expand Down
80 changes: 80 additions & 0 deletions apps/web/src/lib/threadGitStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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,
);
});
});
23 changes: 23 additions & 0 deletions apps/web/src/lib/threadGitStatus.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading