From da32088dffdf9d70263aaaae7ff354ee4d8099ee Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 9 Apr 2026 11:02:05 -0700 Subject: [PATCH] feat(code): warn on local task branch mismatch --- .../components/MessageEditor.tsx | 3 + .../message-editor/tiptap/useTiptapEditor.ts | 17 ++- .../sessions/components/SessionView.tsx | 3 + .../components/BranchMismatchDialog.tsx | 122 ++++++++++++++++++ .../task-detail/components/TaskLogsPanel.tsx | 74 ++++++++++- .../workspace/hooks/useBranchMismatch.ts | 66 ++++++++++ 6 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 apps/code/src/renderer/features/task-detail/components/BranchMismatchDialog.tsx create mode 100644 apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts diff --git a/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx index 19973c11a..e0ddb9adb 100644 --- a/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/code/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -136,6 +136,7 @@ function ModeAndBranchRow({ interface MessageEditorProps { sessionId: string; placeholder?: string; + onBeforeSubmit?: (text: string) => boolean; onSubmit?: (text: string) => void; onBashCommand?: (command: string) => void; onBashModeChange?: (isBashMode: boolean) => void; @@ -154,6 +155,7 @@ export const MessageEditor = forwardRef( { sessionId, placeholder = "Type a message... @ to mention files, ! for bash mode, / for skills", + onBeforeSubmit, onSubmit, onBashCommand, onBashModeChange, @@ -213,6 +215,7 @@ export const MessageEditor = forwardRef( context: { taskId, repoPath }, getPromptHistory, capabilities: { bashMode: !isCloud }, + onBeforeSubmit, onSubmit, onBashCommand, onBashModeChange, diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index a5752157a..7d0523867 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -29,6 +29,11 @@ export interface UseTiptapEditorOptions { }; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; + /** + * Called before submit with the serialized text. Return `false` to block + * the submit — `onSubmit` will not fire and the editor will not clear. + */ + onBeforeSubmit?: (text: string) => boolean; onSubmit?: (text: string) => void; onBashCommand?: (command: string) => void; onBashModeChange?: (isBashMode: boolean) => void; @@ -84,6 +89,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { capabilities = {}, clearOnSubmit = true, getPromptHistory, + onBeforeSubmit, onSubmit, onBashCommand, onBashModeChange, @@ -99,6 +105,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } = capabilities; const callbackRefs = useRef({ + onBeforeSubmit, onSubmit, onBashCommand, onBashModeChange, @@ -107,6 +114,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { onBlur, }); callbackRefs.current = { + onBeforeSubmit, onSubmit, onBashCommand, onBashModeChange, @@ -459,8 +467,15 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const command = text.slice(1).trim(); if (command) callbackRefs.current.onBashCommand?.(command); } else { + const serialized = contentToXml(content); + + // Allow callers to block submit (e.g., branch mismatch warning) + if (callbackRefs.current.onBeforeSubmit?.(serialized) === false) { + return; + } + // Normal prompts can be queued when loading - callbackRefs.current.onSubmit?.(contentToXml(content)); + callbackRefs.current.onSubmit?.(serialized); } if (clearOnSubmit) { diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 5ce4716fc..7c1b8dff1 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -38,6 +38,7 @@ interface SessionViewProps { isRunning: boolean; isPromptPending?: boolean | null; promptStartedAt?: number | null; + onBeforeSubmit?: (text: string) => boolean; onSendPrompt: (text: string) => void; onBashCommand?: (command: string) => void; onCancelPrompt: () => void; @@ -73,6 +74,7 @@ export function SessionView({ isRunning, isPromptPending = false, promptStartedAt, + onBeforeSubmit, onSendPrompt, onBashCommand, onCancelPrompt, @@ -538,6 +540,7 @@ export function SessionView({ ref={editorRef} sessionId={sessionId} placeholder="Type a message... @ to mention files, ! for bash mode, / for skills" + onBeforeSubmit={onBeforeSubmit} onSubmit={handleSubmit} onBashCommand={onBashCommand} onCancel={onCancelPrompt} diff --git a/apps/code/src/renderer/features/task-detail/components/BranchMismatchDialog.tsx b/apps/code/src/renderer/features/task-detail/components/BranchMismatchDialog.tsx new file mode 100644 index 000000000..532b130c1 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/BranchMismatchDialog.tsx @@ -0,0 +1,122 @@ +import { GitBranch, Warning } from "@phosphor-icons/react"; +import { AlertDialog, Button, Code, Flex, Text } from "@radix-ui/themes"; + +interface BranchMismatchDialogProps { + open: boolean; + linkedBranch: string; + currentBranch: string; + onSwitch: () => void; + onContinue: () => void; + onCancel: () => void; + isSwitching?: boolean; +} + +function BranchLabel({ name }: { name: string }) { + return ( + + + + {name} + + + ); +} + +export function BranchMismatchDialog({ + open, + linkedBranch, + currentBranch, + onSwitch, + onContinue, + onCancel, + isSwitching, +}: BranchMismatchDialogProps) { + return ( + + + + + + Wrong branch + + + + This task is linked to a different branch than the one you're + currently on. The agent will make changes on the current branch. + + + + + Linked + + + + + + Current + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx index 1347c7347..cb008b14f 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx @@ -15,15 +15,19 @@ import { useSessionConnection } from "@features/sessions/hooks/useSessionConnect import { useSessionViewState } from "@features/sessions/hooks/useSessionViewState"; import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask"; import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; +import { BranchMismatchDialog } from "@features/task-detail/components/BranchMismatchDialog"; import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt"; +import { useBranchMismatchGuard } from "@features/workspace/hooks/useBranchMismatch"; import { useCreateWorkspace, useWorkspaceLoaded, } from "@features/workspace/hooks/useWorkspace"; import { Box, Flex } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; +import { logger } from "@utils/logger"; import { getTaskRepository } from "@utils/repository"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; interface TaskLogsPanelProps { taskId: string; @@ -81,6 +85,61 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { handleBashCommand, } = useSessionCallbacks({ taskId, task, session, repoPath }); + // Branch mismatch guard + const { shouldWarn, linkedBranch, currentBranch, dismissWarning } = + useBranchMismatchGuard(taskId); + const [pendingMessage, setPendingMessage] = useState(null); + const [isSwitchingBranch, setIsSwitchingBranch] = useState(false); + + const handleBeforeSubmit = useCallback( + (text: string): boolean => { + if (shouldWarn) { + setPendingMessage(text); + return false; + } + return true; + }, + [shouldWarn], + ); + + const handleMismatchSwitch = useCallback(async () => { + if (!linkedBranch || !repoPath) return; + setIsSwitchingBranch(true); + try { + await trpcClient.git.checkoutBranch.mutate({ + directoryPath: repoPath, + branchName: linkedBranch, + }); + dismissWarning(); + if (pendingMessage) { + handleSendPrompt(pendingMessage); + } + } catch (error) { + logger.scope("task-logs-panel").error("Failed to switch branch", error); + } finally { + setIsSwitchingBranch(false); + setPendingMessage(null); + } + }, [ + linkedBranch, + repoPath, + dismissWarning, + pendingMessage, + handleSendPrompt, + ]); + + const handleMismatchContinue = useCallback(() => { + dismissWarning(); + if (pendingMessage) { + handleSendPrompt(pendingMessage); + } + setPendingMessage(null); + }, [dismissWarning, pendingMessage, handleSendPrompt]); + + const handleMismatchCancel = useCallback(() => { + setPendingMessage(null); + }, []); + const cloudOutput = session?.cloudOutput ?? null; const prUrl = isCloud && cloudOutput?.pr_url ? (cloudOutput.pr_url as string) : null; @@ -147,6 +206,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { isRestoring={isRestoring} isPromptPending={isPromptPending} promptStartedAt={promptStartedAt} + onBeforeSubmit={handleBeforeSubmit} onSendPrompt={handleSendPrompt} onBashCommand={isCloud ? undefined : handleBashCommand} onCancelPrompt={handleCancelPrompt} @@ -165,6 +225,18 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { + + {linkedBranch && currentBranch && ( + + )} ); } diff --git a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts b/apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts new file mode 100644 index 000000000..1a930d263 --- /dev/null +++ b/apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef } from "react"; +import { create } from "zustand"; +import { useWorkspace } from "./useWorkspace"; + +interface BranchWarningState { + /** Per-task flag: true if the user dismissed the branch mismatch warning. */ + dismissed: Record; + dismiss: (taskId: string) => void; + reset: (taskId: string) => void; +} + +export const useBranchWarningStore = create()((set) => ({ + dismissed: {}, + dismiss: (taskId) => + set((state) => ({ + dismissed: { ...state.dismissed, [taskId]: true }, + })), + reset: (taskId) => + set((state) => ({ + dismissed: { ...state.dismissed, [taskId]: false }, + })), +})); + +export function useBranchMismatch(taskId: string) { + const workspace = useWorkspace(taskId); + const linkedBranch = workspace?.linkedBranch ?? null; + const currentBranch = workspace?.branchName ?? null; + const isMismatch = + !!linkedBranch && !!currentBranch && linkedBranch !== currentBranch; + const isLinked = !!linkedBranch; + + const branchWarningDismissed = useBranchWarningStore( + (s) => s.dismissed[taskId] ?? false, + ); + const reset = useBranchWarningStore((s) => s.reset); + + // Reset dismissed state when currentBranch changes + const prevBranchRef = useRef(currentBranch); + useEffect(() => { + if (prevBranchRef.current !== currentBranch) { + prevBranchRef.current = currentBranch; + reset(taskId); + } + }, [currentBranch, taskId, reset]); + + const shouldWarn = isMismatch && !branchWarningDismissed; + + return { + linkedBranch, + currentBranch, + isMismatch, + isLinked, + shouldWarn, + }; +} + +export function useBranchMismatchGuard(taskId: string) { + const { shouldWarn, linkedBranch, currentBranch } = useBranchMismatch(taskId); + const dismiss = useBranchWarningStore((s) => s.dismiss); + + const dismissWarning = useCallback(() => { + dismiss(taskId); + }, [dismiss, taskId]); + + return { shouldWarn, linkedBranch, currentBranch, dismissWarning }; +}