Skip to content

Commit 8fda684

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

6 files changed

Lines changed: 332 additions & 2 deletions

File tree

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: 22 additions & 1 deletion
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,
@@ -459,8 +463,25 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
459463
const command = text.slice(1).trim();
460464
if (command) callbackRefs.current.onBashCommand?.(command);
461465
} else {
466+
const serialized = contentToXml(content);
467+
468+
const clearEditor = () => {
469+
if (clearOnSubmit) {
470+
editor.commands.clearContent();
471+
prevBashModeRef.current = false;
472+
pasteCountRef.current = 0;
473+
setAttachments([]);
474+
draft.clearDraft();
475+
}
476+
};
477+
if (
478+
callbackRefs.current.onBeforeSubmit?.(serialized, clearEditor) === 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

466487
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, 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: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useFolders } from "@features/folders/hooks/useFolders";
44
import {
55
useCloudBranchChangedFiles,
66
useCloudPrChangedFiles,
7+
useGitQueries,
78
} from "@features/git-interaction/hooks/useGitQueries";
89
import { computeDiffStats } from "@features/git-interaction/utils/diffStats";
910
import { useDraftStore } from "@features/message-editor/stores/draftStore";
@@ -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, useRef, useState } from "react";
2732

2833
interface TaskLogsPanelProps {
2934
taskId: string;
@@ -81,6 +86,77 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
8186
handleBashCommand,
8287
} = useSessionCallbacks({ taskId, task, session, repoPath });
8388

89+
const { shouldWarn, linkedBranch, currentBranch, dismissWarning } =
90+
useBranchMismatchGuard(taskId);
91+
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
92+
const pendingClearEditorRef = useRef<(() => void) | null>(null);
93+
const [isSwitchingBranch, setIsSwitchingBranch] = useState(false);
94+
const [switchError, setSwitchError] = useState<string | null>(null);
95+
const { hasChanges: hasUncommittedChanges } = useGitQueries(
96+
repoPath ?? undefined,
97+
);
98+
99+
const handleBeforeSubmit = useCallback(
100+
(text: string, clearEditor: () => void): boolean => {
101+
if (shouldWarn) {
102+
setPendingMessage(text);
103+
pendingClearEditorRef.current = clearEditor;
104+
return false;
105+
}
106+
return true;
107+
},
108+
[shouldWarn],
109+
);
110+
111+
const handleMismatchSwitch = useCallback(async () => {
112+
if (!linkedBranch || !repoPath) return;
113+
setIsSwitchingBranch(true);
114+
setSwitchError(null);
115+
try {
116+
await trpcClient.git.checkoutBranch.mutate({
117+
directoryPath: repoPath,
118+
branchName: linkedBranch,
119+
});
120+
dismissWarning();
121+
pendingClearEditorRef.current?.();
122+
pendingClearEditorRef.current = null;
123+
if (pendingMessage) {
124+
handleSendPrompt(pendingMessage);
125+
}
126+
setPendingMessage(null);
127+
} catch (error) {
128+
logger.scope("task-logs-panel").error("Failed to switch branch", error);
129+
const message =
130+
error instanceof Error ? error.message : "Failed to switch branch";
131+
setSwitchError(message);
132+
} finally {
133+
setIsSwitchingBranch(false);
134+
}
135+
}, [
136+
linkedBranch,
137+
repoPath,
138+
dismissWarning,
139+
pendingMessage,
140+
handleSendPrompt,
141+
]);
142+
143+
const handleMismatchContinue = useCallback(() => {
144+
dismissWarning();
145+
pendingClearEditorRef.current?.();
146+
pendingClearEditorRef.current = null;
147+
if (pendingMessage) {
148+
handleSendPrompt(pendingMessage);
149+
}
150+
setPendingMessage(null);
151+
setSwitchError(null);
152+
}, [dismissWarning, pendingMessage, handleSendPrompt]);
153+
154+
const handleMismatchCancel = useCallback(() => {
155+
setPendingMessage(null);
156+
pendingClearEditorRef.current = null;
157+
setSwitchError(null);
158+
}, []);
159+
84160
const cloudOutput = session?.cloudOutput ?? null;
85161
const prUrl =
86162
isCloud && cloudOutput?.pr_url ? (cloudOutput.pr_url as string) : null;
@@ -147,6 +223,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
147223
isRestoring={isRestoring}
148224
isPromptPending={isPromptPending}
149225
promptStartedAt={promptStartedAt}
226+
onBeforeSubmit={handleBeforeSubmit}
150227
onSendPrompt={handleSendPrompt}
151228
onBashCommand={isCloud ? undefined : handleBashCommand}
152229
onCancelPrompt={handleCancelPrompt}
@@ -165,6 +242,20 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
165242
</ErrorBoundary>
166243
</Box>
167244
</Flex>
245+
246+
{linkedBranch && currentBranch && (
247+
<BranchMismatchDialog
248+
open={pendingMessage !== null}
249+
linkedBranch={linkedBranch}
250+
currentBranch={currentBranch}
251+
hasUncommittedChanges={hasUncommittedChanges}
252+
switchError={switchError}
253+
onSwitch={handleMismatchSwitch}
254+
onContinue={handleMismatchContinue}
255+
onCancel={handleMismatchCancel}
256+
isSwitching={isSwitchingBranch}
257+
/>
258+
)}
168259
</BackgroundWrapper>
169260
);
170261
}

0 commit comments

Comments
 (0)