Skip to content

Commit 7cc27c1

Browse files
committed
feat(code): warn on local task branch mismatch
1 parent b6365c0 commit 7cc27c1

File tree

7 files changed

+362
-8
lines changed

7 files changed

+362
-8
lines changed

apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function ModeAndBranchRow({
136136
interface MessageEditorProps {
137137
sessionId: string;
138138
placeholder?: string;
139+
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
139140
onSubmit?: (text: string) => void;
140141
onBashCommand?: (command: string) => void;
141142
onBashModeChange?: (isBashMode: boolean) => void;
@@ -154,6 +155,7 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
154155
{
155156
sessionId,
156157
placeholder = "Type a message... @ to mention files, ! for bash mode, / for skills",
158+
onBeforeSubmit,
157159
onSubmit,
158160
onBashCommand,
159161
onBashModeChange,
@@ -213,6 +215,7 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
213215
context: { taskId, repoPath },
214216
getPromptHistory,
215217
capabilities: { bashMode: !isCloud },
218+
onBeforeSubmit,
216219
onSubmit,
217220
onBashCommand,
218221
onBashModeChange,

apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface UseTiptapEditorOptions {
2929
};
3030
clearOnSubmit?: boolean;
3131
getPromptHistory?: () => string[];
32+
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
3233
onSubmit?: (text: string) => void;
3334
onBashCommand?: (command: string) => void;
3435
onBashModeChange?: (isBashMode: boolean) => void;
@@ -84,6 +85,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
8485
capabilities = {},
8586
clearOnSubmit = true,
8687
getPromptHistory,
88+
onBeforeSubmit,
8789
onSubmit,
8890
onBashCommand,
8991
onBashModeChange,
@@ -99,6 +101,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
99101
} = capabilities;
100102

101103
const callbackRefs = useRef({
104+
onBeforeSubmit,
102105
onSubmit,
103106
onBashCommand,
104107
onBashModeChange,
@@ -107,6 +110,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
107110
onBlur,
108111
});
109112
callbackRefs.current = {
113+
onBeforeSubmit,
110114
onSubmit,
111115
onBashCommand,
112116
onBashModeChange,
@@ -450,6 +454,15 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
450454

451455
const text = editor.getText().trim();
452456

457+
const doClear = () => {
458+
if (!clearOnSubmit) return;
459+
editor.commands.clearContent();
460+
prevBashModeRef.current = false;
461+
pasteCountRef.current = 0;
462+
setAttachments([]);
463+
draft.clearDraft();
464+
};
465+
453466
if (enableBashMode && text.startsWith("!")) {
454467
// Bash mode requires immediate execution, can't be queued
455468
if (isLoading) {
@@ -459,17 +472,19 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
459472
const command = text.slice(1).trim();
460473
if (command) callbackRefs.current.onBashCommand?.(command);
461474
} else {
475+
const serialized = contentToXml(content);
476+
477+
if (
478+
callbackRefs.current.onBeforeSubmit?.(serialized, doClear) === false
479+
) {
480+
return;
481+
}
482+
462483
// Normal prompts can be queued when loading
463-
callbackRefs.current.onSubmit?.(contentToXml(content));
484+
callbackRefs.current.onSubmit?.(serialized);
464485
}
465486

466-
if (clearOnSubmit) {
467-
editor.commands.clearContent();
468-
prevBashModeRef.current = false;
469-
pasteCountRef.current = 0;
470-
setAttachments([]);
471-
draft.clearDraft();
472-
}
487+
doClear();
473488
}, [
474489
editor,
475490
disabled,

apps/code/src/renderer/features/sessions/components/SessionView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ interface SessionViewProps {
3838
isRunning: boolean;
3939
isPromptPending?: boolean | null;
4040
promptStartedAt?: number | null;
41+
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
4142
onSendPrompt: (text: string) => void;
4243
onBashCommand?: (command: string) => void;
4344
onCancelPrompt: () => void;
@@ -73,6 +74,7 @@ export function SessionView({
7374
isRunning,
7475
isPromptPending = false,
7576
promptStartedAt,
77+
onBeforeSubmit,
7678
onSendPrompt,
7779
onBashCommand,
7880
onCancelPrompt,
@@ -538,6 +540,7 @@ export function SessionView({
538540
ref={editorRef}
539541
sessionId={sessionId}
540542
placeholder="Type a message... @ to mention files, ! for bash mode, / for skills"
543+
onBeforeSubmit={onBeforeSubmit}
541544
onSubmit={handleSubmit}
542545
onBashCommand={onBashCommand}
543546
onCancel={onCancelPrompt}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { GitBranch, Warning } from "@phosphor-icons/react";
2+
import {
3+
AlertDialog,
4+
Button,
5+
Callout,
6+
Code,
7+
Flex,
8+
Text,
9+
} from "@radix-ui/themes";
10+
11+
interface BranchMismatchDialogProps {
12+
open: boolean;
13+
linkedBranch: string;
14+
currentBranch: string;
15+
hasUncommittedChanges: boolean;
16+
switchError: string | null;
17+
onSwitch: () => void;
18+
onContinue: () => void;
19+
onCancel: () => void;
20+
isSwitching?: boolean;
21+
}
22+
23+
function BranchLabel({ name }: { name: string }) {
24+
return (
25+
<Code
26+
size="2"
27+
variant="ghost"
28+
truncate
29+
style={{
30+
maxWidth: "100%",
31+
display: "inline-flex",
32+
alignItems: "center",
33+
gap: "4px",
34+
}}
35+
>
36+
<GitBranch size={12} style={{ flexShrink: 0 }} />
37+
<span
38+
style={{
39+
overflow: "hidden",
40+
textOverflow: "ellipsis",
41+
whiteSpace: "nowrap",
42+
}}
43+
>
44+
{name}
45+
</span>
46+
</Code>
47+
);
48+
}
49+
50+
export function BranchMismatchDialog({
51+
open,
52+
linkedBranch,
53+
currentBranch,
54+
hasUncommittedChanges,
55+
switchError,
56+
onSwitch,
57+
onContinue,
58+
onCancel,
59+
isSwitching,
60+
}: BranchMismatchDialogProps) {
61+
return (
62+
<AlertDialog.Root open={open}>
63+
<AlertDialog.Content maxWidth="420px" size="2">
64+
<AlertDialog.Title size="3">
65+
<Flex align="center" gap="2">
66+
<Warning size={18} weight="fill" color="var(--orange-9)" />
67+
Wrong branch
68+
</Flex>
69+
</AlertDialog.Title>
70+
<AlertDialog.Description size="2">
71+
This task is linked to a different branch than the one you're
72+
currently on. The agent will make changes on the current branch.
73+
</AlertDialog.Description>
74+
<Flex direction="column" gap="1" mt="3" style={{ minWidth: 0 }}>
75+
<Flex align="center" gap="2" style={{ minWidth: 0 }}>
76+
<Text
77+
size="1"
78+
color="gray"
79+
style={{ flexShrink: 0, width: "64px" }}
80+
>
81+
Linked
82+
</Text>
83+
<BranchLabel name={linkedBranch} />
84+
</Flex>
85+
<Flex align="center" gap="2" style={{ minWidth: 0 }}>
86+
<Text
87+
size="1"
88+
color="gray"
89+
style={{ flexShrink: 0, width: "64px" }}
90+
>
91+
Current
92+
</Text>
93+
<BranchLabel name={currentBranch} />
94+
</Flex>
95+
</Flex>
96+
97+
{hasUncommittedChanges && !switchError && (
98+
<Callout.Root size="1" color="gray" mt="3">
99+
<Callout.Text size="1">
100+
You have uncommitted changes on your current branch. If needed,
101+
commit or stash them first.
102+
</Callout.Text>
103+
</Callout.Root>
104+
)}
105+
106+
{switchError && (
107+
<Callout.Root size="1" color="red" mt="3">
108+
<Callout.Text size="1">{switchError}</Callout.Text>
109+
</Callout.Root>
110+
)}
111+
112+
<Flex justify="end" gap="2" mt="4">
113+
<AlertDialog.Cancel>
114+
<Button
115+
variant="soft"
116+
color="gray"
117+
size="1"
118+
onClick={onCancel}
119+
disabled={isSwitching}
120+
>
121+
Cancel
122+
</Button>
123+
</AlertDialog.Cancel>
124+
125+
<Button
126+
variant="soft"
127+
size="1"
128+
onClick={onContinue}
129+
disabled={isSwitching}
130+
>
131+
Continue anyway
132+
</Button>
133+
134+
<AlertDialog.Action>
135+
<Button
136+
variant="solid"
137+
size="1"
138+
onClick={onSwitch}
139+
loading={isSwitching}
140+
>
141+
Switch branch
142+
</Button>
143+
</AlertDialog.Action>
144+
</Flex>
145+
</AlertDialog.Content>
146+
</AlertDialog.Root>
147+
);
148+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import { useSessionConnection } from "@features/sessions/hooks/useSessionConnect
1515
import { useSessionViewState } from "@features/sessions/hooks/useSessionViewState";
1616
import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask";
1717
import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds";
18+
import { BranchMismatchDialog } from "@features/task-detail/components/BranchMismatchDialog";
1819
import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt";
20+
import { useBranchMismatchDialog } from "@features/workspace/hooks/useBranchMismatchDialog";
1921
import {
2022
useCreateWorkspace,
2123
useWorkspaceLoaded,
@@ -81,6 +83,12 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
8183
handleBashCommand,
8284
} = useSessionCallbacks({ taskId, task, session, repoPath });
8385

86+
const { handleBeforeSubmit, dialogProps } = useBranchMismatchDialog({
87+
taskId,
88+
repoPath,
89+
onSendPrompt: handleSendPrompt,
90+
});
91+
8492
const cloudOutput = session?.cloudOutput ?? null;
8593
const prUrl =
8694
isCloud && cloudOutput?.pr_url ? (cloudOutput.pr_url as string) : null;
@@ -147,6 +155,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
147155
isRestoring={isRestoring}
148156
isPromptPending={isPromptPending}
149157
promptStartedAt={promptStartedAt}
158+
onBeforeSubmit={handleBeforeSubmit}
150159
onSendPrompt={handleSendPrompt}
151160
onBashCommand={isCloud ? undefined : handleBashCommand}
152161
onCancelPrompt={handleCancelPrompt}
@@ -165,6 +174,8 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
165174
</ErrorBoundary>
166175
</Box>
167176
</Flex>
177+
178+
{dialogProps && <BranchMismatchDialog {...dialogProps} />}
168179
</BackgroundWrapper>
169180
);
170181
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useCallback, useEffect, useRef } from "react";
2+
import { create } from "zustand";
3+
import { useWorkspace } from "./useWorkspace";
4+
5+
interface BranchWarningState {
6+
dismissed: Record<string, boolean>;
7+
dismiss: (taskId: string) => void;
8+
reset: (taskId: string) => void;
9+
}
10+
11+
export const useBranchWarningStore = create<BranchWarningState>()((set) => ({
12+
dismissed: {},
13+
dismiss: (taskId) =>
14+
set((state) => ({
15+
dismissed: { ...state.dismissed, [taskId]: true },
16+
})),
17+
reset: (taskId) =>
18+
set((state) => ({
19+
dismissed: { ...state.dismissed, [taskId]: false },
20+
})),
21+
}));
22+
23+
export function useBranchMismatch(taskId: string) {
24+
const workspace = useWorkspace(taskId);
25+
const linkedBranch = workspace?.linkedBranch ?? null;
26+
const currentBranch = workspace?.branchName ?? null;
27+
const isMismatch =
28+
!!linkedBranch && !!currentBranch && linkedBranch !== currentBranch;
29+
30+
const branchWarningDismissed = useBranchWarningStore(
31+
(s) => s.dismissed[taskId] ?? false,
32+
);
33+
const reset = useBranchWarningStore((s) => s.reset);
34+
35+
const prevBranchRef = useRef(currentBranch);
36+
useEffect(() => {
37+
if (prevBranchRef.current !== currentBranch) {
38+
prevBranchRef.current = currentBranch;
39+
reset(taskId);
40+
}
41+
}, [currentBranch, taskId, reset]);
42+
43+
const shouldWarn = isMismatch && !branchWarningDismissed;
44+
45+
return {
46+
linkedBranch,
47+
currentBranch,
48+
isMismatch,
49+
shouldWarn,
50+
};
51+
}
52+
53+
export function useBranchMismatchGuard(taskId: string) {
54+
const { shouldWarn, linkedBranch, currentBranch } = useBranchMismatch(taskId);
55+
const dismiss = useBranchWarningStore((s) => s.dismiss);
56+
57+
const dismissWarning = useCallback(() => {
58+
dismiss(taskId);
59+
}, [dismiss, taskId]);
60+
61+
return { shouldWarn, linkedBranch, currentBranch, dismissWarning };
62+
}

0 commit comments

Comments
 (0)