Skip to content

Commit ab9c711

Browse files
committed
feat(code): warn on local task branch mismatch
1 parent 0b99da5 commit ab9c711

3 files changed

Lines changed: 263 additions & 3 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { GitBranch, Warning } from "@phosphor-icons/react";
2+
import { AlertDialog, Button, Code, Flex, Text } from "@radix-ui/themes";
3+
4+
interface BranchMismatchDialogProps {
5+
open: boolean;
6+
linkedBranch: string;
7+
currentBranch: string;
8+
onSwitch: () => void;
9+
onContinue: () => void;
10+
onCancel: () => void;
11+
isSwitching?: boolean;
12+
}
13+
14+
function BranchLabel({ name }: { name: string }) {
15+
return (
16+
<Code
17+
size="2"
18+
variant="ghost"
19+
truncate
20+
style={{
21+
maxWidth: "100%",
22+
display: "inline-flex",
23+
alignItems: "center",
24+
gap: "4px",
25+
}}
26+
>
27+
<GitBranch size={12} style={{ flexShrink: 0 }} />
28+
<span
29+
style={{
30+
overflow: "hidden",
31+
textOverflow: "ellipsis",
32+
whiteSpace: "nowrap",
33+
}}
34+
>
35+
{name}
36+
</span>
37+
</Code>
38+
);
39+
}
40+
41+
export function BranchMismatchDialog({
42+
open,
43+
linkedBranch,
44+
currentBranch,
45+
onSwitch,
46+
onContinue,
47+
onCancel,
48+
isSwitching,
49+
}: BranchMismatchDialogProps) {
50+
return (
51+
<AlertDialog.Root open={open}>
52+
<AlertDialog.Content maxWidth="420px" size="2">
53+
<AlertDialog.Title size="3">
54+
<Flex align="center" gap="2">
55+
<Warning size={18} weight="fill" color="var(--orange-9)" />
56+
Wrong branch
57+
</Flex>
58+
</AlertDialog.Title>
59+
<AlertDialog.Description size="2">
60+
This task is linked to a different branch than the one you're
61+
currently on. The agent will make changes on the current branch.
62+
</AlertDialog.Description>
63+
<Flex direction="column" gap="1" mt="3" style={{ minWidth: 0 }}>
64+
<Flex align="center" gap="2" style={{ minWidth: 0 }}>
65+
<Text
66+
size="1"
67+
color="gray"
68+
style={{ flexShrink: 0, width: "64px" }}
69+
>
70+
Linked
71+
</Text>
72+
<BranchLabel name={linkedBranch} />
73+
</Flex>
74+
<Flex align="center" gap="2" style={{ minWidth: 0 }}>
75+
<Text
76+
size="1"
77+
color="gray"
78+
style={{ flexShrink: 0, width: "64px" }}
79+
>
80+
Current
81+
</Text>
82+
<BranchLabel name={currentBranch} />
83+
</Flex>
84+
</Flex>
85+
86+
<Flex justify="end" gap="2" mt="4">
87+
<AlertDialog.Cancel>
88+
<Button
89+
variant="soft"
90+
color="gray"
91+
size="1"
92+
onClick={onCancel}
93+
disabled={isSwitching}
94+
>
95+
Cancel
96+
</Button>
97+
</AlertDialog.Cancel>
98+
99+
<Button
100+
variant="soft"
101+
size="1"
102+
onClick={onContinue}
103+
disabled={isSwitching}
104+
>
105+
Continue anyway
106+
</Button>
107+
108+
<AlertDialog.Action>
109+
<Button
110+
variant="solid"
111+
size="1"
112+
onClick={onSwitch}
113+
loading={isSwitching}
114+
>
115+
Switch branch
116+
</Button>
117+
</AlertDialog.Action>
118+
</Flex>
119+
</AlertDialog.Content>
120+
</AlertDialog.Root>
121+
);
122+
}

apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@features/git-interaction/hooks/useGitQueries";
88
import { computeDiffStats } from "@features/git-interaction/utils/diffStats";
99
import { useDraftStore } from "@features/message-editor/stores/draftStore";
10+
import type { EditorContent } from "@features/message-editor/utils/content";
1011
import { ProvisioningView } from "@features/provisioning/components/ProvisioningView";
1112
import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore";
1213
import { SessionView } from "@features/sessions/components/SessionView";
@@ -15,15 +16,19 @@ import { useSessionConnection } from "@features/sessions/hooks/useSessionConnect
1516
import { useSessionViewState } from "@features/sessions/hooks/useSessionViewState";
1617
import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask";
1718
import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds";
19+
import { BranchMismatchDialog } from "@features/task-detail/components/BranchMismatchDialog";
1820
import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt";
21+
import { useBranchMismatchGuard } from "@features/workspace/hooks/useBranchMismatch";
1922
import {
2023
useCreateWorkspace,
2124
useWorkspaceLoaded,
2225
} from "@features/workspace/hooks/useWorkspace";
2326
import { Box, Flex } from "@radix-ui/themes";
27+
import { trpcClient } from "@renderer/trpc/client";
2428
import type { Task } from "@shared/types";
29+
import { logger } from "@utils/logger";
2530
import { getTaskRepository } from "@utils/repository";
26-
import { useCallback, useEffect, useMemo } from "react";
31+
import { useCallback, useEffect, useMemo, useState } from "react";
2732

2833
interface TaskLogsPanelProps {
2934
taskId: string;
@@ -47,7 +52,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
4752

4853
const isProvisioning = useProvisioningStore((s) => s.activeTasks.has(taskId));
4954

50-
const { requestFocus } = useDraftStore((s) => s.actions);
55+
const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions);
5156

5257
const {
5358
session,
@@ -74,13 +79,68 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
7479
});
7580

7681
const {
77-
handleSendPrompt,
82+
handleSendPrompt: rawSendPrompt,
7883
handleCancelPrompt,
7984
handleRetry,
8085
handleNewSession,
8186
handleBashCommand,
8287
} = useSessionCallbacks({ taskId, task, session, repoPath });
8388

89+
// Branch mismatch guard
90+
const { shouldWarn, linkedBranch, currentBranch, dismissWarning } =
91+
useBranchMismatchGuard(taskId);
92+
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
93+
const [isSwitchingBranch, setIsSwitchingBranch] = useState(false);
94+
95+
const handleSendPrompt = useCallback(
96+
(text: string) => {
97+
if (shouldWarn) {
98+
setPendingMessage(text);
99+
return;
100+
}
101+
rawSendPrompt(text);
102+
},
103+
[shouldWarn, rawSendPrompt],
104+
);
105+
106+
const handleMismatchSwitch = useCallback(async () => {
107+
if (!linkedBranch || !repoPath) return;
108+
setIsSwitchingBranch(true);
109+
try {
110+
await trpcClient.git.checkoutBranch.mutate({
111+
directoryPath: repoPath,
112+
branchName: linkedBranch,
113+
});
114+
dismissWarning();
115+
if (pendingMessage) {
116+
rawSendPrompt(pendingMessage);
117+
}
118+
} catch (error) {
119+
logger.scope("task-logs-panel").error("Failed to switch branch", error);
120+
} finally {
121+
setIsSwitchingBranch(false);
122+
setPendingMessage(null);
123+
}
124+
}, [linkedBranch, repoPath, dismissWarning, pendingMessage, rawSendPrompt]);
125+
126+
const handleMismatchContinue = useCallback(() => {
127+
dismissWarning();
128+
if (pendingMessage) {
129+
rawSendPrompt(pendingMessage);
130+
}
131+
setPendingMessage(null);
132+
}, [dismissWarning, pendingMessage, rawSendPrompt]);
133+
134+
const handleMismatchCancel = useCallback(() => {
135+
if (pendingMessage) {
136+
const content: EditorContent = {
137+
segments: [{ type: "text", text: pendingMessage }],
138+
};
139+
setPendingContent(taskId, content);
140+
}
141+
setPendingMessage(null);
142+
}, [pendingMessage, setPendingContent, taskId]);
143+
84144
const cloudOutput = session?.cloudOutput ?? null;
85145
const prUrl =
86146
isCloud && cloudOutput?.pr_url ? (cloudOutput.pr_url as string) : null;
@@ -165,6 +225,18 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
165225
</ErrorBoundary>
166226
</Box>
167227
</Flex>
228+
229+
{linkedBranch && currentBranch && (
230+
<BranchMismatchDialog
231+
open={pendingMessage !== null}
232+
linkedBranch={linkedBranch}
233+
currentBranch={currentBranch}
234+
onSwitch={handleMismatchSwitch}
235+
onContinue={handleMismatchContinue}
236+
onCancel={handleMismatchCancel}
237+
isSwitching={isSwitchingBranch}
238+
/>
239+
)}
168240
</BackgroundWrapper>
169241
);
170242
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useCallback, useEffect, useRef } from "react";
2+
import { create } from "zustand";
3+
import { useWorkspace } from "./useWorkspace";
4+
5+
interface BranchWarningState {
6+
/** Per-task flag: true if the user dismissed the branch mismatch warning. */
7+
dismissed: Record<string, boolean>;
8+
dismiss: (taskId: string) => void;
9+
reset: (taskId: string) => void;
10+
}
11+
12+
export const useBranchWarningStore = create<BranchWarningState>()((set) => ({
13+
dismissed: {},
14+
dismiss: (taskId) =>
15+
set((state) => ({
16+
dismissed: { ...state.dismissed, [taskId]: true },
17+
})),
18+
reset: (taskId) =>
19+
set((state) => ({
20+
dismissed: { ...state.dismissed, [taskId]: false },
21+
})),
22+
}));
23+
24+
export function useBranchMismatch(taskId: string) {
25+
const workspace = useWorkspace(taskId);
26+
const linkedBranch = workspace?.linkedBranch ?? null;
27+
const currentBranch = workspace?.branchName ?? null;
28+
const isMismatch =
29+
!!linkedBranch && !!currentBranch && linkedBranch !== currentBranch;
30+
const isLinked = !!linkedBranch;
31+
32+
const branchWarningDismissed = useBranchWarningStore(
33+
(s) => s.dismissed[taskId] ?? false,
34+
);
35+
const reset = useBranchWarningStore((s) => s.reset);
36+
37+
// Reset dismissed state when currentBranch changes
38+
const prevBranchRef = useRef(currentBranch);
39+
useEffect(() => {
40+
if (prevBranchRef.current !== currentBranch) {
41+
prevBranchRef.current = currentBranch;
42+
reset(taskId);
43+
}
44+
}, [currentBranch, taskId, reset]);
45+
46+
const shouldWarn = isMismatch && !branchWarningDismissed;
47+
48+
return {
49+
linkedBranch,
50+
currentBranch,
51+
isMismatch,
52+
isLinked,
53+
shouldWarn,
54+
};
55+
}
56+
57+
export function useBranchMismatchGuard(taskId: string) {
58+
const { shouldWarn, linkedBranch, currentBranch } = useBranchMismatch(taskId);
59+
const dismiss = useBranchWarningStore((s) => s.dismiss);
60+
61+
const dismissWarning = useCallback(() => {
62+
dismiss(taskId);
63+
}, [dismiss, taskId]);
64+
65+
return { shouldWarn, linkedBranch, currentBranch, dismissWarning };
66+
}

0 commit comments

Comments
 (0)