Skip to content

Commit da32088

Browse files
committed
feat(code): warn on local task branch mismatch
1 parent ab59a63 commit da32088

File tree

6 files changed

+283
-2
lines changed

6 files changed

+283
-2
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) => 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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export interface UseTiptapEditorOptions {
2929
};
3030
clearOnSubmit?: boolean;
3131
getPromptHistory?: () => string[];
32+
/**
33+
* Called before submit with the serialized text. Return `false` to block
34+
* the submit — `onSubmit` will not fire and the editor will not clear.
35+
*/
36+
onBeforeSubmit?: (text: string) => boolean;
3237
onSubmit?: (text: string) => void;
3338
onBashCommand?: (command: string) => void;
3439
onBashModeChange?: (isBashMode: boolean) => void;
@@ -84,6 +89,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
8489
capabilities = {},
8590
clearOnSubmit = true,
8691
getPromptHistory,
92+
onBeforeSubmit,
8793
onSubmit,
8894
onBashCommand,
8995
onBashModeChange,
@@ -99,6 +105,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
99105
} = capabilities;
100106

101107
const callbackRefs = useRef({
108+
onBeforeSubmit,
102109
onSubmit,
103110
onBashCommand,
104111
onBashModeChange,
@@ -107,6 +114,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
107114
onBlur,
108115
});
109116
callbackRefs.current = {
117+
onBeforeSubmit,
110118
onSubmit,
111119
onBashCommand,
112120
onBashModeChange,
@@ -459,8 +467,15 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
459467
const command = text.slice(1).trim();
460468
if (command) callbackRefs.current.onBashCommand?.(command);
461469
} else {
470+
const serialized = contentToXml(content);
471+
472+
// Allow callers to block submit (e.g., branch mismatch warning)
473+
if (callbackRefs.current.onBeforeSubmit?.(serialized) === false) {
474+
return;
475+
}
476+
462477
// Normal prompts can be queued when loading
463-
callbackRefs.current.onSubmit?.(contentToXml(content));
478+
callbackRefs.current.onSubmit?.(serialized);
464479
}
465480

466481
if (clearOnSubmit) {

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) => 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: 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: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@ 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 { useBranchMismatchGuard } from "@features/workspace/hooks/useBranchMismatch";
1921
import {
2022
useCreateWorkspace,
2123
useWorkspaceLoaded,
2224
} from "@features/workspace/hooks/useWorkspace";
2325
import { Box, Flex } from "@radix-ui/themes";
26+
import { trpcClient } from "@renderer/trpc/client";
2427
import type { Task } from "@shared/types";
28+
import { logger } from "@utils/logger";
2529
import { getTaskRepository } from "@utils/repository";
26-
import { useCallback, useEffect, useMemo } from "react";
30+
import { useCallback, useEffect, useMemo, useState } from "react";
2731

2832
interface TaskLogsPanelProps {
2933
taskId: string;
@@ -81,6 +85,61 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
8185
handleBashCommand,
8286
} = useSessionCallbacks({ taskId, task, session, repoPath });
8387

88+
// Branch mismatch guard
89+
const { shouldWarn, linkedBranch, currentBranch, dismissWarning } =
90+
useBranchMismatchGuard(taskId);
91+
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
92+
const [isSwitchingBranch, setIsSwitchingBranch] = useState(false);
93+
94+
const handleBeforeSubmit = useCallback(
95+
(text: string): boolean => {
96+
if (shouldWarn) {
97+
setPendingMessage(text);
98+
return false;
99+
}
100+
return true;
101+
},
102+
[shouldWarn],
103+
);
104+
105+
const handleMismatchSwitch = useCallback(async () => {
106+
if (!linkedBranch || !repoPath) return;
107+
setIsSwitchingBranch(true);
108+
try {
109+
await trpcClient.git.checkoutBranch.mutate({
110+
directoryPath: repoPath,
111+
branchName: linkedBranch,
112+
});
113+
dismissWarning();
114+
if (pendingMessage) {
115+
handleSendPrompt(pendingMessage);
116+
}
117+
} catch (error) {
118+
logger.scope("task-logs-panel").error("Failed to switch branch", error);
119+
} finally {
120+
setIsSwitchingBranch(false);
121+
setPendingMessage(null);
122+
}
123+
}, [
124+
linkedBranch,
125+
repoPath,
126+
dismissWarning,
127+
pendingMessage,
128+
handleSendPrompt,
129+
]);
130+
131+
const handleMismatchContinue = useCallback(() => {
132+
dismissWarning();
133+
if (pendingMessage) {
134+
handleSendPrompt(pendingMessage);
135+
}
136+
setPendingMessage(null);
137+
}, [dismissWarning, pendingMessage, handleSendPrompt]);
138+
139+
const handleMismatchCancel = useCallback(() => {
140+
setPendingMessage(null);
141+
}, []);
142+
84143
const cloudOutput = session?.cloudOutput ?? null;
85144
const prUrl =
86145
isCloud && cloudOutput?.pr_url ? (cloudOutput.pr_url as string) : null;
@@ -147,6 +206,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
147206
isRestoring={isRestoring}
148207
isPromptPending={isPromptPending}
149208
promptStartedAt={promptStartedAt}
209+
onBeforeSubmit={handleBeforeSubmit}
150210
onSendPrompt={handleSendPrompt}
151211
onBashCommand={isCloud ? undefined : handleBashCommand}
152212
onCancelPrompt={handleCancelPrompt}
@@ -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
}

0 commit comments

Comments
 (0)