Skip to content

Commit 3a9bfbe

Browse files
authored
feat(code): warn on local task branch mismatch (#1596)
## Problem we want task isolation, but we can only do so much with local tasks, since they are simply not in an isolated environment downstack #1594 + #1595 add automatic branch linking to tasks, so when an agent touches a file, or an agent/human creates a branch from posthog code, we associate that branch to that task now, we need a way to keep the user in the loop on what's happening, and steer them in the right direction <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## Changes adds a UI warning if you send a prompt in a task that has a linked branch, but that branch is not currently checked out details: - hooks into tiptap with a new `onBeforeSend`so prompt is never lost if you cancel - the warning is "blocking" in the sense that you have to make a decision. you can: - "continue anyway" -> ignore the warning, agent works in your current branch - automatic branch linking will still occur and maybe overwrite the current link, per the standard rules (see #1595 ) - "switch branch" - checks out the task's linked branch - warns if there are uncommitted changes on current branch. this does not block, and surfaces errors from the switch (if any) in the dialog <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? manually <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. -->
1 parent 0cd0eaa commit 3a9bfbe

11 files changed

Lines changed: 854 additions & 9 deletions

File tree

apps/code/src/main/services/workspace/service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,8 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
369369
branchName,
370370
});
371371
trackAppEvent("branch_linked", {
372+
task_id: taskId,
373+
branch_name: branchName,
372374
source: source ?? "unknown",
373375
});
374376
log.info("Linked branch to task", { taskId, branchName, source });
@@ -381,6 +383,7 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
381383
branchName: null,
382384
});
383385
trackAppEvent("branch_unlinked", {
386+
task_id: taskId,
384387
source: source ?? "unknown",
385388
});
386389
log.info("Unlinked branch from task", { taskId, source });

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface PromptInputProps {
4545
// prompt history provider
4646
getPromptHistory?: () => string[];
4747
// callbacks
48+
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
4849
onSubmit?: (text: string) => void;
4950
onBashCommand?: (command: string) => void;
5051
onBashModeChange?: (isBashMode: boolean) => void;
@@ -80,6 +81,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
8081
reasoningSelector,
8182
tourHighlightSubmit = false,
8283
getPromptHistory,
84+
onBeforeSubmit,
8385
onSubmit,
8486
onBashCommand,
8587
onBashModeChange,
@@ -127,6 +129,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
127129
commands: enableCommands,
128130
},
129131
getPromptHistory,
132+
onBeforeSubmit,
130133
onSubmit,
131134
onBashCommand,
132135
onBashModeChange,

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface UseTiptapEditorOptions {
3030
};
3131
clearOnSubmit?: boolean;
3232
getPromptHistory?: () => string[];
33+
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
3334
onSubmit?: (text: string) => void;
3435
onBashCommand?: (command: string) => void;
3536
onBashModeChange?: (isBashMode: boolean) => void;
@@ -85,6 +86,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
8586
capabilities = {},
8687
clearOnSubmit = true,
8788
getPromptHistory,
89+
onBeforeSubmit,
8890
onSubmit,
8991
onBashCommand,
9092
onBashModeChange,
@@ -100,6 +102,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
100102
} = capabilities;
101103

102104
const callbackRefs = useRef({
105+
onBeforeSubmit,
103106
onSubmit,
104107
onBashCommand,
105108
onBashModeChange,
@@ -108,6 +111,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
108111
onBlur,
109112
});
110113
callbackRefs.current = {
114+
onBeforeSubmit,
111115
onSubmit,
112116
onBashCommand,
113117
onBashModeChange,
@@ -450,26 +454,39 @@ 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("!")) {
454-
// Bash mode requires immediate execution, can't be queued
467+
// Bash mode requires immediate execution, can't be queued.
468+
// Intentionally bypasses onBeforeSubmit — bash commands run inline and
469+
// cannot be deferred the way normal prompts can.
455470
if (isLoading) {
456471
toast.error("Cannot run shell commands while agent is generating");
457472
return;
458473
}
459474
const command = text.slice(1).trim();
460475
if (command) callbackRefs.current.onBashCommand?.(command);
461476
} else {
477+
const serialized = contentToXml(content);
478+
479+
if (callbackRefs.current.onBeforeSubmit) {
480+
if (!callbackRefs.current.onBeforeSubmit(serialized, doClear)) {
481+
return;
482+
}
483+
}
484+
462485
// Normal prompts can be queued when loading
463-
callbackRefs.current.onSubmit?.(contentToXml(content));
486+
callbackRefs.current.onSubmit?.(serialized);
464487
}
465488

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

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface SessionViewProps {
3939
isRunning: boolean;
4040
isPromptPending?: boolean | null;
4141
promptStartedAt?: number | null;
42+
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
4243
onSendPrompt: (text: string) => void;
4344
onBashCommand?: (command: string) => void;
4445
onCancelPrompt: () => void;
@@ -69,6 +70,7 @@ export function SessionView({
6970
isRunning,
7071
isPromptPending = false,
7172
promptStartedAt,
73+
onBeforeSubmit,
7274
onSendPrompt,
7375
onBashCommand,
7476
onCancelPrompt,
@@ -531,6 +533,7 @@ export function SessionView({
531533
disabled={!isRunning}
532534
/>
533535
}
536+
onBeforeSubmit={onBeforeSubmit}
534537
onSubmit={handleSubmit}
535538
onBashCommand={onBashCommand}
536539
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
63+
open={open}
64+
onOpenChange={(isOpen) => {
65+
if (!isOpen) onCancel();
66+
}}
67+
>
68+
<AlertDialog.Content maxWidth="420px" size="2">
69+
<AlertDialog.Title size="3">
70+
<Flex align="center" gap="2">
71+
<Warning size={18} weight="fill" color="var(--orange-9)" />
72+
Wrong branch
73+
</Flex>
74+
</AlertDialog.Title>
75+
<AlertDialog.Description size="2">
76+
This task is linked to a different branch than the one you're
77+
currently on. The agent will make changes on the current branch.
78+
</AlertDialog.Description>
79+
<Flex direction="column" gap="1" mt="3" style={{ minWidth: 0 }}>
80+
<Flex align="center" gap="2" style={{ minWidth: 0 }}>
81+
<Text
82+
size="1"
83+
color="gray"
84+
style={{ flexShrink: 0, width: "64px" }}
85+
>
86+
Linked
87+
</Text>
88+
<BranchLabel name={linkedBranch} />
89+
</Flex>
90+
<Flex align="center" gap="2" style={{ minWidth: 0 }}>
91+
<Text
92+
size="1"
93+
color="gray"
94+
style={{ flexShrink: 0, width: "64px" }}
95+
>
96+
Current
97+
</Text>
98+
<BranchLabel name={currentBranch} />
99+
</Flex>
100+
</Flex>
101+
102+
{hasUncommittedChanges && !switchError && (
103+
<Callout.Root size="1" color="gray" mt="3">
104+
<Callout.Text size="1">
105+
You have uncommitted changes on your current branch. If needed,
106+
commit or stash them first.
107+
</Callout.Text>
108+
</Callout.Root>
109+
)}
110+
111+
{switchError && (
112+
<Callout.Root size="1" color="red" mt="3">
113+
<Callout.Text size="1">{switchError}</Callout.Text>
114+
</Callout.Root>
115+
)}
116+
117+
<Flex justify="end" gap="2" mt="4">
118+
<AlertDialog.Cancel>
119+
<Button variant="soft" color="gray" size="1" disabled={isSwitching}>
120+
Cancel
121+
</Button>
122+
</AlertDialog.Cancel>
123+
124+
<Button
125+
variant="soft"
126+
color="orange"
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
@@ -10,7 +10,9 @@ import { useSessionConnection } from "@features/sessions/hooks/useSessionConnect
1010
import { useSessionViewState } from "@features/sessions/hooks/useSessionViewState";
1111
import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask";
1212
import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds";
13+
import { BranchMismatchDialog } from "@features/task-detail/components/BranchMismatchDialog";
1314
import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt";
15+
import { useBranchMismatchDialog } from "@features/workspace/hooks/useBranchMismatchDialog";
1416
import {
1517
useCreateWorkspace,
1618
useWorkspaceLoaded,
@@ -76,6 +78,12 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
7678
handleBashCommand,
7779
} = useSessionCallbacks({ taskId, task, session, repoPath });
7880

81+
const { handleBeforeSubmit, dialogProps } = useBranchMismatchDialog({
82+
taskId,
83+
repoPath,
84+
onSendPrompt: handleSendPrompt,
85+
});
86+
7987
const slackThreadUrl =
8088
typeof task.latest_run?.state?.slack_thread_url === "string"
8189
? task.latest_run.state.slack_thread_url
@@ -126,6 +134,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
126134
isRestoring={isRestoring}
127135
isPromptPending={isPromptPending}
128136
promptStartedAt={promptStartedAt}
137+
onBeforeSubmit={handleBeforeSubmit}
129138
onSendPrompt={handleSendPrompt}
130139
onBashCommand={isCloud ? undefined : handleBashCommand}
131140
onCancelPrompt={handleCancelPrompt}
@@ -143,6 +152,8 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
143152
</ErrorBoundary>
144153
</Box>
145154
</Flex>
155+
156+
{dialogProps && <BranchMismatchDialog {...dialogProps} />}
146157
</BackgroundWrapper>
147158
);
148159
}

0 commit comments

Comments
 (0)