diff --git a/frontend/src/components/tasks/ConflictBanner.tsx b/frontend/src/components/tasks/ConflictBanner.tsx
index 58c7befb..cd94311b 100644
--- a/frontend/src/components/tasks/ConflictBanner.tsx
+++ b/frontend/src/components/tasks/ConflictBanner.tsx
@@ -3,67 +3,93 @@ import { Button } from '@/components/ui/button';
import type { ConflictOp } from 'shared/types';
import { displayConflictOpLabel } from '@/lib/conflicts';
-interface Props {
+export type Props = Readonly<{
attemptBranch: string | null;
baseBranch?: string;
- conflictedFiles: string[];
- isDraftLocked: boolean;
- isDraftReady: boolean;
+ conflictedFiles: readonly string[];
+ isEditable: boolean;
onOpenEditor: () => void;
onInsertInstructions: () => void;
onAbort: () => void;
op?: ConflictOp | null;
+}>;
+
+const MAX_VISIBLE_FILES = 8;
+
+function getOperationTitle(op?: ConflictOp | null): {
+ full: string;
+ lower: string;
+} {
+ const title = displayConflictOpLabel(op);
+ return { full: title, lower: title.toLowerCase() };
+}
+
+function getVisibleFiles(
+ files: readonly string[],
+ max = MAX_VISIBLE_FILES
+): { visible: string[]; total: number; hasMore: boolean } {
+ const visible = files.slice(0, max);
+ return {
+ visible,
+ total: files.length,
+ hasMore: files.length > visible.length,
+ };
}
export function ConflictBanner({
attemptBranch,
baseBranch,
conflictedFiles,
- isDraftLocked,
- isDraftReady,
+ isEditable,
onOpenEditor,
onInsertInstructions,
onAbort,
op,
}: Props) {
- const displayFiles = conflictedFiles.slice(0, 8);
- const opTitle = displayConflictOpLabel(op);
+ const { full: opTitle, lower: opTitleLower } = getOperationTitle(op);
+ const {
+ visible: visibleFiles,
+ total,
+ hasMore,
+ } = getVisibleFiles(conflictedFiles);
+
+ const heading = attemptBranch
+ ? `${opTitle} in progress: '${attemptBranch}' → '${baseBranch}'.`
+ : 'A Git operation with merge conflicts is in progress.';
+
return (
-
+
-
+
- {attemptBranch ? (
- <>
- {opTitle} in progress: '{attemptBranch}' → '{baseBranch}'.
- >
- ) : (
- <>A Git operation with merge conflicts is in progress.>
- )}{' '}
- Follow-ups are allowed; some actions may be temporarily unavailable
- until you resolve the conflicts or abort the {opTitle.toLowerCase()}.
- {displayFiles.length ? (
+
{heading} {' '}
+
+ Follow-ups are allowed; some actions may be temporarily unavailable
+ until you resolve the conflicts or abort the {opTitleLower}.
+
+ {visibleFiles.length > 0 && (
- Conflicted files ({displayFiles.length}
- {conflictedFiles.length > displayFiles.length
- ? ` of ${conflictedFiles.length}`
- : ''}
- ):
-
- {displayFiles.map((f) => (
+
+ Conflicted files ({visibleFiles.length}
+ {hasMore ? ` of ${total}` : ''}):
+
+
+ {visibleFiles.map((f) => (
{f}
))}
- ) : null}
+ )}
-
+
+
Open in Editor
+
Insert Resolve-Conflicts Instructions
+
(null);
+ const [sentNonce, setSentNonce] = useState(null);
+ const prevIsSendingRef = useRef(draft.isSending);
+
+ // Show "Draft saved" by bumping key to restart CSS animation
+ useEffect(() => {
+ if (save.state === 'saved') setSavedNonce(Date.now());
+ }, [save.state]);
+
+ // Show "Follow-up sent" on isSending rising edge
+ useEffect(() => {
+ const now = draft.isSending;
+ if (now && !prevIsSendingRef.current) {
+ setSentNonce(Date.now());
+ }
+ prevIsSendingRef.current = now;
+ }, [draft.isSending]);
+ return (
+
+
+ {save.state === 'saving' && save.isSaving ? (
+
+ Saving…
+
+ ) : save.state === 'offline' ? (
+
+ Offline — changes pending
+
+ ) : sentNonce ? (
+ setSentNonce(null)}
+ >
+ Follow-up sent
+
+ ) : savedNonce ? (
+ setSavedNonce(null)}
+ >
+ Draft saved
+
+ ) : null}
+
+
+ {queue.isUnqueuing ? (
+
+ Unlocking…
+
+ ) : !draft.isLoaded ? (
+
+ Loading draft…
+
+ ) : draft.isSending ? (
+
+ Sending follow-up…
+
+ ) : queue.isQueued ? (
+
+ Queued for next turn. Edits are
+ locked.
+
+ ) : null}
+
+
+ );
+}
+
+export const FollowUpStatusRow = memo(FollowUpStatusRowImpl);
diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx
index 2fe82d53..fafa4854 100644
--- a/frontend/src/components/tasks/TaskDetailsPanel.tsx
+++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx
@@ -203,7 +203,6 @@ export function TaskDetailsPanel({
task={task}
projectId={projectId}
selectedAttemptId={selectedAttempt?.id}
- selectedAttemptProfile={selectedAttempt?.executor}
jumpToLogsTab={jumpToLogsTab}
/>
>
@@ -247,7 +246,6 @@ export function TaskDetailsPanel({
task={task}
projectId={projectId}
selectedAttemptId={selectedAttempt?.id}
- selectedAttemptProfile={selectedAttempt?.executor}
jumpToLogsTab={jumpToLogsTab}
/>
>
diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx
index 43da8f33..4a0d25d6 100644
--- a/frontend/src/components/tasks/TaskFollowUpSection.tsx
+++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx
@@ -1,52 +1,41 @@
import {
- CheckCircle2,
- ChevronDown,
- Clock,
ImageIcon,
Loader2,
Send,
StopCircle,
- WifiOff,
AlertCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
import { Alert, AlertDescription } from '@/components/ui/alert';
-import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
+//
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- attemptsApi,
- imagesApi,
- type UpdateFollowUpDraftRequest,
-} from '@/lib/api.ts';
-import type {
- FollowUpDraft,
- ImageResponse,
- TaskWithAttemptStatus,
-} from 'shared/types';
+import { imagesApi } from '@/lib/api.ts';
+import type { TaskWithAttemptStatus } from 'shared/types';
import { useBranchStatus } from '@/hooks';
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
import { useUserSystem } from '@/components/config-provider';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
-import { useVariantCyclingShortcut } from '@/lib/keyboard-shortcuts';
+//
import { useReview } from '@/contexts/ReviewProvider';
-import { useJsonPatchStream } from '@/hooks/useJsonPatchStream';
-import { inIframe } from '@/vscode/bridge';
-import { buildResolveConflictsInstructions } from '@/lib/conflicts';
-import type { ConflictOp } from 'shared/types';
-import { ConflictBanner } from '@/components/tasks/ConflictBanner';
+//
+import { VariantSelector } from '@/components/tasks/VariantSelector';
+import { FollowUpStatusRow } from '@/components/tasks/FollowUpStatusRow';
+import { useAttemptBranch } from '@/hooks/useAttemptBranch';
+import { FollowUpConflictSection } from '@/components/tasks/follow-up/FollowUpConflictSection';
+import { FollowUpEditorCard } from '@/components/tasks/follow-up/FollowUpEditorCard';
+import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
+import { useDraftEdits } from '@/hooks/follow-up/useDraftEdits';
+import { useDraftImages } from '@/hooks/follow-up/useDraftImages';
+import { useDraftAutosave } from '@/hooks/follow-up/useDraftAutosave';
+import { useDraftQueue } from '@/hooks/follow-up/useDraftQueue';
+import { useFollowUpSend } from '@/hooks/follow-up/useFollowUpSend';
+import { useDefaultVariant } from '@/hooks/follow-up/useDefaultVariant';
interface TaskFollowUpSectionProps {
task: TaskWithAttemptStatus;
projectId: string;
selectedAttemptId?: string;
- selectedAttemptProfile?: string;
jumpToLogsTab: () => void;
}
@@ -54,149 +43,115 @@ export function TaskFollowUpSection({
task,
projectId,
selectedAttemptId,
- selectedAttemptProfile,
jumpToLogsTab,
}: TaskFollowUpSectionProps) {
- const {
- attemptData,
- isAttemptRunning,
- stopExecution,
- isStopping,
- processes,
- } = useAttemptExecution(selectedAttemptId, task.id);
+ const { isAttemptRunning, stopExecution, isStopping, processes } =
+ useAttemptExecution(selectedAttemptId, task.id);
const { data: branchStatus, refetch: refetchBranchStatus } =
useBranchStatus(selectedAttemptId);
- const [attemptBranch, setAttemptBranch] = useState(null);
+ const { branch: attemptBranch, refetch: refetchAttemptBranch } =
+ useAttemptBranch(selectedAttemptId);
const { profiles } = useUserSystem();
const { comments, generateReviewMarkdown, clearComments } = useReview();
- // Generate review markdown when comments change
- const reviewMarkdown = useMemo(() => {
- return generateReviewMarkdown();
- }, [generateReviewMarkdown, comments]);
-
- // Inline defaultFollowUpVariant logic
- const defaultFollowUpVariant = useMemo(() => {
- if (!processes.length) return null;
-
- // Find most recent coding agent process with variant
- const latestProfile = processes
- .filter((p) => p.run_reason === 'codingagent')
- .reverse()
- .map((process) => {
- if (
- process.executor_action?.typ.type === 'CodingAgentInitialRequest' ||
- process.executor_action?.typ.type === 'CodingAgentFollowUpRequest'
- ) {
- return process.executor_action?.typ.executor_profile_id;
- }
- return undefined;
- })
- .find(Boolean);
-
- if (latestProfile?.variant) {
- return latestProfile.variant;
- } else if (latestProfile) {
- return null;
- } else if (selectedAttemptProfile && profiles) {
- // No processes yet, check if profile has default variant
- const profile = profiles?.[selectedAttemptProfile];
- if (profile && Object.keys(profile).length > 0) {
- return Object.keys(profile)[0];
- }
- }
-
- return null;
- }, [processes, selectedAttemptProfile, profiles]);
-
- const [followUpMessage, setFollowUpMessage] = useState('');
- const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
- const [followUpError, setFollowUpError] = useState(null);
- const [selectedVariant, setSelectedVariant] = useState(
- defaultFollowUpVariant
+ const reviewMarkdown = useMemo(
+ () => generateReviewMarkdown(),
+ [generateReviewMarkdown, comments]
);
- const [isAnimating, setIsAnimating] = useState(false);
- const variantButtonRef = useRef(null);
+
+ // Draft stream and synchronization
+ const {
+ draft,
+ isDraftLoaded,
+ lastServerVersionRef,
+ suppressNextSaveRef,
+ forceNextApplyRef,
+ } = useDraftStream(selectedAttemptId);
+
+ // Editor state
+ const { message: followUpMessage, setMessage: setFollowUpMessage } =
+ useDraftEdits({
+ draft,
+ lastServerVersionRef,
+ suppressNextSaveRef,
+ forceNextApplyRef,
+ });
+
+ // Images manager
+ const {
+ images,
+ setImages,
+ newlyUploadedImageIds,
+ handleImageUploaded,
+ clearImagesAndUploads,
+ } = useDraftImages({ draft, taskId: task.id });
+
+ // Presentation-only: show/hide image upload panel
const [showImageUpload, setShowImageUpload] = useState(false);
- const [images, setImages] = useState([]);
- const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState(
- []
- );
- const wrapperRef = useRef(null);
- const [lockedMinHeight, setLockedMinHeight] = useState(null);
- // Fade-out overlay for clearing text when sending begins
- const [fadeOverlayText, setFadeOverlayText] = useState('');
- const [showFadeOverlay, setShowFadeOverlay] = useState(false);
- const [overlayFadeClass, setOverlayFadeClass] = useState('');
- const overlayFadeTimerRef = useRef(undefined);
- const overlayHideTimerRef = useRef(undefined);
- const [isQueued, setIsQueued] = useState(false);
- const [isDraftSending, setIsDraftSending] = useState(false);
+
+ // Variant selection (with keyboard cycling)
+ const { selectedVariant, setSelectedVariant, currentProfile } =
+ useDefaultVariant({ processes, profiles: profiles ?? null });
+
+ // Queue management (including derived lock flag)
+ const { onQueue, onUnqueue } = useDraftQueue({
+ attemptId: selectedAttemptId,
+ draft,
+ message: followUpMessage,
+ selectedVariant,
+ images,
+ suppressNextSaveRef,
+ lastServerVersionRef,
+ });
+
+ // Presentation-only queue state
const [isQueuing, setIsQueuing] = useState(false);
const [isUnqueuing, setIsUnqueuing] = useState(false);
- const [isDraftReady, setIsDraftReady] = useState(false);
- const saveTimeoutRef = useRef(undefined);
- const [isSaving, setIsSaving] = useState(false);
- const [saveStatus, setSaveStatus] = useState<
- 'idle' | 'saving' | 'saved' | 'offline' | 'sent'
- >('idle');
- const [isStatusFading, setIsStatusFading] = useState(false);
- const statusFadeTimerRef = useRef(undefined);
- const statusClearTimerRef = useRef(undefined);
- const lastSentRef = useRef('');
- const suppressNextSaveRef = useRef(false);
- const localDirtyRef = useRef(false);
- // We auto-resolve conflicts silently by adopting server state.
- const lastServerVersionRef = useRef(-1);
- const prevSendingRef = useRef(false);
+ // Local queued state override after server action completes; null = rely on server
+ const [queuedOptimistic, setQueuedOptimistic] = useState(
+ null
+ );
- // Helper to show a pleasant fade for transient "Draft saved" status
- const scheduleSavedStatus = useCallback(() => {
- // Clear pending timers
- if (statusFadeTimerRef.current)
- window.clearTimeout(statusFadeTimerRef.current);
- if (statusClearTimerRef.current)
- window.clearTimeout(statusClearTimerRef.current);
- setIsStatusFading(false);
- setSaveStatus('saved');
- // Fade out close to the end of visibility
- statusFadeTimerRef.current = window.setTimeout(
- () => setIsStatusFading(true),
- 1800
- );
- statusClearTimerRef.current = window.setTimeout(() => {
- setSaveStatus('idle');
- setIsStatusFading(false);
- }, 2000);
- }, []);
+ // Server + presentation derived flags (computed early so they are usable below)
+ const isQueued = !!draft?.queued;
+ const displayQueued = queuedOptimistic ?? isQueued;
- const scheduleSentStatus = useCallback(() => {
- if (statusFadeTimerRef.current)
- window.clearTimeout(statusFadeTimerRef.current);
- if (statusClearTimerRef.current)
- window.clearTimeout(statusClearTimerRef.current);
- setIsStatusFading(false);
- setSaveStatus('sent');
- statusFadeTimerRef.current = window.setTimeout(
- () => setIsStatusFading(true),
- 1800
- );
- statusClearTimerRef.current = window.setTimeout(() => {
- setSaveStatus('idle');
- setIsStatusFading(false);
- }, 2000);
- }, []);
+ // Autosave draft when editing
+ const { isSaving, saveStatus } = useDraftAutosave({
+ attemptId: selectedAttemptId,
+ draft,
+ message: followUpMessage,
+ selectedVariant,
+ images,
+ isQueuedUI: displayQueued,
+ isDraftSending: !!draft?.sending,
+ isQueuing: isQueuing,
+ isUnqueuing: isUnqueuing,
+ suppressNextSaveRef,
+ lastServerVersionRef,
+ forceNextApplyRef,
+ });
- // Get the profile from the attempt data
- const selectedProfile = selectedAttemptProfile;
+ // Send follow-up action
+ const { isSendingFollowUp, followUpError, setFollowUpError, onSendFollowUp } =
+ useFollowUpSend({
+ attemptId: selectedAttemptId,
+ message: followUpMessage,
+ reviewMarkdown,
+ selectedVariant,
+ images,
+ newlyUploadedImageIds,
+ clearComments,
+ jumpToLogsTab,
+ onAfterSendCleanup: clearImagesAndUploads,
+ setMessage: setFollowUpMessage,
+ });
+
+ // Profile/variant derived from processes only (see useDefaultVariant)
// Separate logic for when textarea should be disabled vs when send button should be disabled
const canTypeFollowUp = useMemo(() => {
- if (
- !selectedAttemptId ||
- attemptData.processes.length === 0 ||
- isSendingFollowUp
- ) {
+ if (!selectedAttemptId || processes.length === 0 || isSendingFollowUp) {
return false;
}
@@ -213,7 +168,7 @@ export function TaskFollowUpSection({
return true;
}, [
selectedAttemptId,
- attemptData.processes,
+ processes.length,
isSendingFollowUp,
branchStatus?.merges,
]);
@@ -226,561 +181,73 @@ export function TaskFollowUpSection({
// Allow sending if either review comments exist OR follow-up message is present
return Boolean(reviewMarkdown || followUpMessage.trim());
}, [canTypeFollowUp, reviewMarkdown, followUpMessage]);
- const currentProfile = useMemo(() => {
- if (!selectedProfile || !profiles) return null;
- return profiles?.[selectedProfile];
- }, [selectedProfile, profiles]);
+ // currentProfile is provided by useDefaultVariant
- // Update selectedVariant when defaultFollowUpVariant changes
- useEffect(() => {
- setSelectedVariant(defaultFollowUpVariant);
- }, [defaultFollowUpVariant]);
+ const isDraftLocked =
+ displayQueued || isQueuing || isUnqueuing || !!draft?.sending;
+ const isEditable = isDraftLoaded && !isDraftLocked;
- // Subscribe to follow-up draft SSE stream for this attempt
- type DraftStreamState = { follow_up_draft: FollowUpDraft };
- const draftStreamEndpoint = selectedAttemptId
- ? `/api/task-attempts/${selectedAttemptId}/follow-up-draft/stream`
- : undefined;
- const makeInitialDraftData = useCallback(
- (): DraftStreamState => ({
- follow_up_draft: {
- id: '',
- task_attempt_id: selectedAttemptId || '',
- prompt: '',
- queued: false,
- sending: false,
- variant: null,
- image_ids: [],
- // version used only for local comparison; server will patch real value
- version: 0n,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- },
- }),
- [selectedAttemptId]
- );
-
- const {
- data: draftStream,
- isConnected: draftStreamConnected,
- error: draftStreamError,
- } = useJsonPatchStream(
- draftStreamEndpoint,
- !!draftStreamEndpoint,
- makeInitialDraftData
- );
-
- // One-shot hydration via REST to avoid waiting on SSE, and to handle environments
- // where SSE connects but initial event is delayed or blocked.
- useEffect(() => {
- let cancelled = false;
- const hydrateOnce = async () => {
- if (!selectedAttemptId) return;
- try {
- const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
- if (cancelled) return;
- suppressNextSaveRef.current = true;
- const incomingVersion = Number((draft as FollowUpDraft).version ?? 0n);
- lastServerVersionRef.current = incomingVersion;
- setFollowUpMessage(draft.prompt || '');
- setIsQueued(!!draft.queued);
- if (draft.variant !== undefined && draft.variant !== null)
- setSelectedVariant(draft.variant);
- // Load images if present
- if (draft.image_ids && draft.image_ids.length > 0) {
- const all = await imagesApi.getTaskImages(task.id);
- if (cancelled) return;
- const wantIds = new Set(draft.image_ids);
- setImages(all.filter((img) => wantIds.has(img.id)));
- } else {
- setImages([]);
- }
- if (!isDraftReady) setIsDraftReady(true);
- } catch {
- // ignore, rely on SSE/poll fallback
- }
- // Also fetch attempt branch for UX context
- try {
- const attempt = await attemptsApi.get(selectedAttemptId);
- if (!cancelled) setAttemptBranch(attempt.branch ?? null);
- } catch {
- /* no-op */
- }
- };
- hydrateOnce();
- return () => {
- cancelled = true;
- };
- }, [selectedAttemptId]);
-
- // Cleanup timers on unmount
- useEffect(() => {
- return () => {
- if (statusFadeTimerRef.current)
- window.clearTimeout(statusFadeTimerRef.current);
- if (statusClearTimerRef.current)
- window.clearTimeout(statusClearTimerRef.current);
- };
- }, []);
-
- useEffect(() => {
- if (!draftStream) return;
- const d: FollowUpDraft = draftStream.follow_up_draft;
- // Ignore synthetic initial placeholder until real SSE snapshot arrives
- if (d.id === '') {
- return;
- }
- const incomingVersion = Number(d.version ?? 0n);
-
- // Always reflect queued/sending flags immediately
- setIsQueued(!!d.queued);
- const sendingNow = !!d.sending;
- setIsDraftSending(sendingNow);
-
- // If server indicates we're sending, ensure the editor is cleared for clarity.
- if (sendingNow) {
- // Edge trigger: show "Follow-up sent" pill once
- if (!prevSendingRef.current) {
- scheduleSentStatus();
- }
- // Show a quick fade-out of the prior content while clearing the actual textarea value
- if (followUpMessage !== '') {
- if (overlayFadeTimerRef.current)
- window.clearTimeout(overlayFadeTimerRef.current);
- if (overlayHideTimerRef.current)
- window.clearTimeout(overlayHideTimerRef.current);
- // Lock container height to avoid jump while autosize recomputes
- if (wrapperRef.current) {
- const h = wrapperRef.current.getBoundingClientRect().height;
- setLockedMinHeight(h);
- }
- setFadeOverlayText(followUpMessage);
- setShowFadeOverlay(true);
- // Start fully visible
- setOverlayFadeClass('opacity-100');
- // Clear textarea immediately under the overlay
- setFollowUpMessage('');
- // Trigger fast fade on next tick (no motion), then remove overlay shortly after
- overlayFadeTimerRef.current = window.setTimeout(
- () => setOverlayFadeClass('opacity-0'),
- 20
- );
- overlayHideTimerRef.current = window.setTimeout(() => {
- setShowFadeOverlay(false);
- setFadeOverlayText('');
- setOverlayFadeClass('');
- // Release height lock shortly after fade completes
- setLockedMinHeight(null);
- }, 180);
- }
- if (images.length > 0) setImages([]);
- if (newlyUploadedImageIds.length > 0) setNewlyUploadedImageIds([]);
- if (showImageUpload) setShowImageUpload(false);
- }
- prevSendingRef.current = sendingNow;
-
- // Skip if this is a duplicate of what we already processed
- if (incomingVersion === lastServerVersionRef.current) {
- if (!isDraftReady) setIsDraftReady(true);
- return;
- }
-
- // Mark that next local change shouldn't auto-save (we're syncing from server)
- suppressNextSaveRef.current = true;
-
- // Initial hydration: avoid clobbering locally typed text with empty server prompt
- if (lastServerVersionRef.current === -1) {
- if (!localDirtyRef.current && !sendingNow) {
- setFollowUpMessage(d.prompt || '');
- }
- if (d.variant !== undefined) setSelectedVariant(d.variant);
- lastServerVersionRef.current = incomingVersion;
- }
-
- // Real server-side change: adopt new prompt/variant
- if (incomingVersion > lastServerVersionRef.current) {
- // If sending, keep the editor clear regardless of server prompt value
- setFollowUpMessage(sendingNow ? '' : d.prompt || '');
- if (d.variant !== undefined) setSelectedVariant(d.variant);
- localDirtyRef.current = false;
- lastServerVersionRef.current = incomingVersion;
- }
- if (!d.image_ids || d.image_ids.length === 0) {
- setImages([]);
- setNewlyUploadedImageIds([]);
- setShowImageUpload(false);
- } else {
- // Load attached images for this draft by IDs
- const wantIds = new Set(d.image_ids);
- const haveIds = new Set(images.map((img) => img.id));
- let mismatch = false;
- if (images.length !== wantIds.size) mismatch = true;
- else
- for (const id of wantIds)
- if (!haveIds.has(id)) {
- mismatch = true;
- break;
- }
- if (mismatch) {
- imagesApi
- .getTaskImages(task.id)
- .then((all) => {
- setImages(all.filter((img) => wantIds.has(img.id)));
- setNewlyUploadedImageIds([]);
- })
- .catch(() => void 0);
- }
- }
- if (!isDraftReady) setIsDraftReady(true);
- }, [draftStream]);
-
- // Cleanup overlay timers
- useEffect(() => {
- return () => {
- if (overlayFadeTimerRef.current)
- window.clearTimeout(overlayFadeTimerRef.current);
- if (overlayHideTimerRef.current)
- window.clearTimeout(overlayHideTimerRef.current);
- };
- }, []);
-
- // Fallback: if running inside VSCode iframe and SSE isn't connected, poll the draft endpoint to keep UI in sync
- const pollTimerRef = useRef(undefined);
- useEffect(() => {
- if (!selectedAttemptId) return;
- const shouldPoll =
- inIframe() && (!draftStreamConnected || !!draftStreamError);
- if (!shouldPoll) {
- if (pollTimerRef.current) window.clearInterval(pollTimerRef.current);
- pollTimerRef.current = undefined;
- return;
- }
- const pollOnce = async () => {
- try {
- const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
- // Update immediate state, similar to SSE handler
- setIsQueued(!!draft.queued);
- // Polling response does not include 'sending'; preserve previous sending state
- const incomingVersion = Number((draft as FollowUpDraft).version ?? 0n);
- if (incomingVersion !== lastServerVersionRef.current) {
- suppressNextSaveRef.current = true;
- setFollowUpMessage(draft.prompt || '');
- if (draft.variant !== undefined && draft.variant !== null)
- setSelectedVariant(draft.variant);
- lastServerVersionRef.current = incomingVersion;
- // images not included in response type for polling; leave as-is
- }
- if (!isDraftReady) setIsDraftReady(true);
- } catch {
- // ignore
- }
- };
- // Prime once, then interval
- pollOnce();
- pollTimerRef.current = window.setInterval(pollOnce, 1000);
- return () => {
- if (pollTimerRef.current) window.clearInterval(pollTimerRef.current);
- pollTimerRef.current = undefined;
- };
- }, [selectedAttemptId, draftStreamConnected, draftStreamError]);
-
- // Debounced persist draft on message or variant change (only while not queued)
- useEffect(() => {
- if (!selectedAttemptId) return;
- // skip saving if currently sending follow-up; it will be cleared on success
- if (isSendingFollowUp) return;
- // also skip while server is sending a queued draft
- if (isDraftSending) return;
- // skip saving while queue/unqueue transitions are in-flight
- if (isQueuing || isUnqueuing) return;
- if (suppressNextSaveRef.current) {
- suppressNextSaveRef.current = false;
- return;
- }
- // Only save when not queued (edit mode)
- if (isQueued) return;
-
- const saveDraft = async () => {
- const d = draftStream?.follow_up_draft;
- const payload: Partial = {};
- // prompt change
- if (d && followUpMessage !== (d.prompt || '')) {
- payload.prompt = followUpMessage;
- }
- // variant change (string | null)
- if ((d?.variant ?? null) !== (selectedVariant ?? null)) {
- payload.variant = (selectedVariant ?? null) as string | null;
- }
- // images change (compare ids)
- const currentIds = images.map((img) => img.id);
- const serverIds = (d?.image_ids as string[] | undefined) ?? [];
- const idsEqual =
- currentIds.length === serverIds.length &&
- currentIds.every((id, i) => id === serverIds[i]);
- if (!idsEqual) {
- payload.image_ids = currentIds;
- }
-
- // If no field changed, skip network
- const keys = Object.keys(payload).filter((k) => k !== 'version');
- if (keys.length === 0) return;
- const payloadKey = JSON.stringify(payload);
- if (payloadKey === lastSentRef.current) return;
- lastSentRef.current = payloadKey;
- try {
- setIsSaving(true);
- setSaveStatus(navigator.onLine ? 'saving' : 'offline');
- await attemptsApi.saveFollowUpDraft(
- selectedAttemptId,
- payload as UpdateFollowUpDraftRequest
- );
- // pleasant linger + fade-out
- scheduleSavedStatus();
- } catch (e: unknown) {
- // On conflict or error, silently adopt server state
- try {
- const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
- suppressNextSaveRef.current = true;
- setFollowUpMessage(draft.prompt || '');
- setIsQueued(!!draft.queued);
- if (draft.variant !== undefined && draft.variant !== null) {
- setSelectedVariant(draft.variant);
- }
- if (draft.version !== undefined && draft.version !== null) {
- lastServerVersionRef.current = Number(draft.version ?? 0n);
- }
- } catch {
- /* empty */
- }
- setSaveStatus(navigator.onLine ? 'idle' : 'offline');
- } finally {
- setIsSaving(false);
- }
- };
-
- // debounce 400ms
- if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
- saveTimeoutRef.current = window.setTimeout(saveDraft, 400);
- return () => {
- if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
- };
- }, [
- followUpMessage,
- selectedVariant,
- isQueued,
- selectedAttemptId,
- isSendingFollowUp,
- isQueuing,
- isUnqueuing,
- ]);
-
- // Remove BroadcastChannel — SSE is authoritative
-
- // (removed duplicate SSE subscription block)
-
- const handleImageUploaded = useCallback((image: ImageResponse) => {
- const markdownText = ``;
- setFollowUpMessage((prev) => {
- if (prev.trim() === '') {
- return markdownText;
- } else {
- return prev + ' ' + markdownText;
- }
- });
-
- setImages((prev) => [...prev, image]);
- setNewlyUploadedImageIds((prev) => [...prev, image.id]);
- }, []);
-
- // Use the centralized keyboard shortcut hook for cycling through variants
- useVariantCyclingShortcut({
- currentProfile,
- selectedVariant,
- setSelectedVariant,
- setIsAnimating,
- });
-
- const onSendFollowUp = async () => {
- if (!task || !selectedAttemptId) return;
-
- // Combine review markdown and follow-up message
- const extraMessage = followUpMessage.trim();
- const finalPrompt = [reviewMarkdown, extraMessage]
- .filter(Boolean)
- .join('\n\n');
-
- if (!finalPrompt) return;
-
- try {
- setIsSendingFollowUp(true);
- setFollowUpError(null);
- // Use newly uploaded image IDs if available, otherwise use all image IDs
- const imageIds =
- newlyUploadedImageIds.length > 0
- ? newlyUploadedImageIds
- : images.length > 0
- ? images.map((img) => img.id)
- : null;
-
- await attemptsApi.followUp(selectedAttemptId, {
- prompt: finalPrompt,
- variant: selectedVariant,
- image_ids: imageIds,
- });
- setFollowUpMessage('');
- // Clear review comments and reset queue state after successful submission
- clearComments();
- setIsQueued(false);
- // Clear images and newly uploaded IDs after successful submission
- setImages([]);
- setNewlyUploadedImageIds([]);
- setShowImageUpload(false);
- jumpToLogsTab();
- // No need to manually refetch - React Query will handle this
- } catch (error: unknown) {
- // @ts-expect-error it is type ApiError
- setFollowUpError(`Failed to start follow-up execution: ${error.message}`);
- } finally {
- setIsSendingFollowUp(false);
- }
- };
-
- // Derived UI lock: disallow edits/actions while queued or transitioning
- const isDraftLocked = isQueued || isQueuing || isUnqueuing || isDraftSending;
- const isInputDisabled = isDraftLocked || !isDraftReady;
-
- // Quick helper to insert a conflict-resolution template into the draft
- const insertResolveConflictsTemplate = useCallback(() => {
- const op: ConflictOp | null = ((): ConflictOp | null => {
- const v = branchStatus?.conflict_op;
- if (
- v === 'rebase' ||
- v === 'merge' ||
- v === 'cherry_pick' ||
- v === 'revert'
- )
- return v;
- return null;
- })();
- const template = buildResolveConflictsInstructions(
- attemptBranch,
- branchStatus?.base_branch_name,
- branchStatus?.conflicted_files || [],
- op
- );
- setFollowUpMessage((prev) => {
+ const appendToFollowUpMessage = useCallback(
+ (text: string) => {
const sep =
- prev.trim().length === 0 ? '' : prev.endsWith('\n') ? '\n' : '\n\n';
- return prev + sep + template;
- });
- }, [
- attemptBranch,
- branchStatus?.base_branch_name,
- branchStatus?.conflicted_files,
- branchStatus?.conflict_op,
- ]);
+ followUpMessage.trim().length === 0
+ ? ''
+ : followUpMessage.endsWith('\n')
+ ? '\n'
+ : '\n\n';
+ setFollowUpMessage(followUpMessage + sep + text);
+ },
+ [followUpMessage, setFollowUpMessage]
+ );
// When a process completes (e.g., agent resolved conflicts), refresh branch status promptly
const prevRunningRef = useRef(isAttemptRunning);
useEffect(() => {
if (prevRunningRef.current && !isAttemptRunning && selectedAttemptId) {
refetchBranchStatus();
+ refetchAttemptBranch();
}
prevRunningRef.current = isAttemptRunning;
- }, [isAttemptRunning, selectedAttemptId, refetchBranchStatus]);
+ }, [
+ isAttemptRunning,
+ selectedAttemptId,
+ refetchBranchStatus,
+ refetchAttemptBranch,
+ ]);
- // Queue handler: ensure draft is persisted immediately, then toggle queued
- const onQueue = async () => {
- if (!selectedAttemptId) return;
- if (isQueuing || isQueued) return;
- const hasContent = followUpMessage.trim().length > 0;
- if (!hasContent) return;
- try {
- // Prevent any pending debounced save from racing
- if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
- suppressNextSaveRef.current = true;
- setIsQueuing(true);
- // Optimistically reflect queued state to block edits/buttons immediately
- setIsQueued(true);
- setIsSaving(true);
- setSaveStatus(navigator.onLine ? 'saving' : 'offline');
- // 1) Force-save current draft so the row exists and is up to date (no version to avoid conflicts)
- const immediatePayload: Partial = {
- // Do NOT send version here to avoid spurious 409; we'll use the returned version for queueing
- prompt: followUpMessage,
- };
- if (
- (draftStream?.follow_up_draft?.variant ?? null) !==
- (selectedVariant ?? null)
- ) {
- immediatePayload.variant = (selectedVariant ?? null) as string | null;
+ // When server indicates sending started, clear draft and images; hide upload panel
+ const prevSendingRef = useRef(!!draft?.sending);
+ useEffect(() => {
+ const now = !!draft?.sending;
+ if (now && !prevSendingRef.current) {
+ if (followUpMessage !== '') setFollowUpMessage('');
+ if (images.length > 0 || newlyUploadedImageIds.length > 0) {
+ clearImagesAndUploads();
}
- const currentIds = images.map((img) => img.id);
- const serverIds =
- (draftStream?.follow_up_draft?.image_ids as string[] | undefined) ?? [];
- const idsEqual =
- currentIds.length === serverIds.length &&
- currentIds.every((id, i) => id === serverIds[i]);
- if (!idsEqual) {
- immediatePayload.image_ids = currentIds;
- }
- await attemptsApi.saveFollowUpDraft(
- selectedAttemptId,
- immediatePayload as UpdateFollowUpDraftRequest
- );
-
- // 2) Queue with optimistic concurrency using latest version from save
- try {
- const resp = await attemptsApi.setFollowUpQueue(
- selectedAttemptId,
- true
- );
- // Immediate local sync to avoid waiting for SSE
- if (resp?.version !== undefined) {
- lastServerVersionRef.current = Number(resp.version ?? 0n);
- }
- setIsQueued(!!resp.queued);
- if (resp.variant !== undefined && resp.variant !== null) {
- setSelectedVariant(resp.variant);
- }
- } catch (err: unknown) {
- // On any error, silently adopt server state
- const latest = await attemptsApi.getFollowUpDraft(selectedAttemptId);
- suppressNextSaveRef.current = true;
- if (latest.version !== undefined && latest.version !== null) {
- lastServerVersionRef.current = Number(latest.version ?? 0n);
- }
- setIsQueued(!!latest.queued);
- if (latest.variant !== undefined && latest.variant !== null) {
- setSelectedVariant(latest.variant);
- }
- }
- // Do not show "Draft saved" for queue; right side shows Queued; a "Follow-up sent" pill will appear when sending starts
- setSaveStatus('idle');
- } catch (e: unknown) {
- // On any error, hard refresh to server truth
- try {
- const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
- suppressNextSaveRef.current = true;
- setFollowUpMessage(draft.prompt || '');
- setIsQueued(!!draft.queued);
- if (draft.variant !== undefined && draft.variant !== null) {
- setSelectedVariant(draft.variant);
- }
- if (draft.version !== undefined && draft.version !== null) {
- lastServerVersionRef.current = Number(draft.version ?? 0n);
- }
- } catch {
- /* empty */
- }
- setSaveStatus(navigator.onLine ? 'idle' : 'offline');
- } finally {
- setIsSaving(false);
- setIsQueuing(false);
+ if (showImageUpload) setShowImageUpload(false);
+ if (queuedOptimistic !== null) setQueuedOptimistic(null);
}
- };
+ prevSendingRef.current = now;
+ }, [
+ draft?.sending,
+ followUpMessage,
+ setFollowUpMessage,
+ images.length,
+ newlyUploadedImageIds.length,
+ clearImagesAndUploads,
+ showImageUpload,
+ queuedOptimistic,
+ ]);
- // (Removed) auto-unqueue logic — editing is explicit and guarded by a lock now
+ // On server queued state change, drop optimistic override and stop spinners accordingly
+ useEffect(() => {
+ setQueuedOptimistic(null);
+ if (isQueued) {
+ if (isQueuing) setIsQueuing(false);
+ } else {
+ if (isUnqueuing) setIsUnqueuing(false);
+ }
+ }, [isQueued]);
return (
selectedAttemptId && (
@@ -800,8 +267,16 @@ export function TaskFollowUpSection({
onImagesChange={setImages}
onUpload={imagesApi.upload}
onDelete={imagesApi.delete}
- onImageUploaded={handleImageUploaded}
- disabled={!canSendFollowUp || isDraftLocked || !isDraftReady}
+ onImageUploaded={(image) => {
+ handleImageUploaded(image);
+ const markdownText = ``;
+ const next =
+ followUpMessage.trim() === ''
+ ? markdownText
+ : followUpMessage + ' ' + markdownText;
+ setFollowUpMessage(next);
+ }}
+ disabled={!isEditable}
collapsible={false}
defaultExpanded={true}
/>
@@ -815,178 +290,64 @@ export function TaskFollowUpSection({
)}
- {/* Rebase conflict notice and actions */}
- {(branchStatus?.conflicted_files?.length ?? 0) > 0 &&
- isDraftReady && (
-
{
- if (!selectedAttemptId) return;
- try {
- const first = branchStatus?.conflicted_files?.[0];
- await attemptsApi.openEditor(
- selectedAttemptId,
- undefined,
- first
- );
- } catch (e) {
- console.error('Failed to open editor', e);
- }
- }}
- onInsertInstructions={insertResolveConflictsTemplate}
- onAbort={async () => {
- if (!selectedAttemptId) return;
- try {
- await attemptsApi.abortConflicts(selectedAttemptId);
- refetchBranchStatus();
- } catch (e) {
- console.error('Failed to abort conflicts', e);
- setFollowUpError(
- 'Failed to abort operation. Please try again in your editor.'
- );
- }
- }}
- />
- )}
+ {/* Conflict notice and actions (optional UI) */}
+
-
-
{
- setFollowUpMessage(value);
- localDirtyRef.current = true;
- if (followUpError) setFollowUpError(null);
- }}
- onKeyDown={(e) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
- e.preventDefault();
- if (canSendFollowUp && !isSendingFollowUp) {
- if (isAttemptRunning) {
- onQueue(); // Use queue when something is running
- } else {
- onSendFollowUp(); // Direct send when nothing is running
- }
+ value={followUpMessage}
+ onChange={(value) => {
+ setFollowUpMessage(value);
+ if (followUpError) setFollowUpError(null);
+ }}
+ onKeyDown={async (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ e.preventDefault();
+ if (canSendFollowUp && !isSendingFollowUp) {
+ if (isAttemptRunning) {
+ setIsQueuing(true);
+ const ok = await onQueue();
+ setIsQueuing(false);
+ if (ok) setQueuedOptimistic(true);
+ } else {
+ onSendFollowUp();
}
- } else if (e.key === 'Escape') {
- // Clear input and auto-cancel queue
- e.preventDefault();
- setFollowUpMessage('');
}
- }}
- className={cn(
- 'flex-1 min-h-[40px] resize-none',
- showFadeOverlay && 'placeholder-transparent'
- )}
- // Edits are disallowed while queued or in transition
- disabled={isInputDisabled}
- projectId={projectId}
- rows={1}
- maxRows={6}
- />
- {showFadeOverlay && fadeOverlayText && (
-
- {fadeOverlayText}
-
- )}
- {(isUnqueuing || !isDraftReady) && (
-
-
-
- )}
-
- {/* Status row: reserved space above action buttons to avoid layout shift */}
-
- {/* Left side: save state or conflicts */}
-
- {saveStatus === 'saving' ? (
-
- Saving…
-
- ) : saveStatus === 'offline' ? (
-
- Offline — changes pending
-
- ) : saveStatus === 'saved' ? (
-
- Draft saved
-
- ) : saveStatus === 'sent' ? (
-
- Follow-up sent
-
- ) : null}
-
- {/* Right side: queued/sending status */}
-
- {isUnqueuing ? (
-
- Unlocking…
-
- ) : !isDraftReady ? (
-
- Loading
- draft…
-
- ) : isDraftSending ? (
-
- Sending
- follow-up…
-
- ) : isQueued ? (
-
- Queued for next turn. Edits
- are locked.
-
- ) : null}
-
-
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ setFollowUpMessage('');
+ }
+ }}
+ disabled={!isEditable}
+ projectId={projectId}
+ rows={1}
+ maxRows={6}
+ showLoadingOverlay={isUnqueuing || !isDraftLoaded}
+ />
+
{/* Image button */}
@@ -994,9 +355,7 @@ export function TaskFollowUpSection({
variant="secondary"
size="sm"
onClick={() => setShowImageUpload(!showImageUpload)}
- disabled={
- !canSendFollowUp || isDraftLocked || !isDraftReady
- }
+ disabled={!isEditable}
>
- {/* Variant selector */}
- {(() => {
- const hasVariants =
- currentProfile && Object.keys(currentProfile).length > 0;
-
- if (hasVariants) {
- return (
-
-
-
-
- {selectedVariant || 'DEFAULT'}
-
-
-
-
-
- {Object.entries(currentProfile).map(
- ([variantLabel]) => (
-
- setSelectedVariant(variantLabel)
- }
- className={
- selectedVariant === variantLabel
- ? 'bg-accent'
- : ''
- }
- >
- {variantLabel}
-
- )
- )}
-
-
- );
- } else if (currentProfile) {
- // Show disabled button when profile exists but has no variants
- return (
-
-
- Default
-
-
- );
- }
- return null;
- })()}
+
{isAttemptRunning ? (
@@ -1104,7 +405,7 @@ export function TaskFollowUpSection({
disabled={
!canSendFollowUp ||
isDraftLocked ||
- !isDraftReady ||
+ !isDraftLoaded ||
isSendingFollowUp
}
size="sm"
@@ -1124,49 +425,10 @@ export function TaskFollowUpSection({
size="sm"
className="min-w-[180px] transition-all"
onClick={async () => {
- if (!selectedAttemptId) return;
+ setIsUnqueuing(true);
try {
- if (saveTimeoutRef.current)
- window.clearTimeout(saveTimeoutRef.current);
- suppressNextSaveRef.current = true;
- setIsUnqueuing(true);
- try {
- const resp = await attemptsApi.setFollowUpQueue(
- selectedAttemptId,
- false
- );
- if (resp?.version !== undefined) {
- lastServerVersionRef.current = Number(
- resp.version ?? 0n
- );
- }
- setIsQueued(!!resp.queued);
- } catch (err: unknown) {
- // On any error (including 409), hard refresh and adopt server state
- const latest =
- await attemptsApi.getFollowUpDraft(
- selectedAttemptId
- );
- suppressNextSaveRef.current = true;
- setFollowUpMessage(latest.prompt || '');
- setIsQueued(!!latest.queued);
- if (
- latest.variant !== undefined &&
- latest.variant !== null
- ) {
- setSelectedVariant(latest.variant);
- }
- if (
- latest.version !== undefined &&
- latest.version !== null
- ) {
- lastServerVersionRef.current = Number(
- latest.version ?? 0n
- );
- }
- }
- } catch (e) {
- console.error('Failed to unqueue for editing', e);
+ const ok = await onUnqueue();
+ if (ok) setQueuedOptimistic(false);
} finally {
setIsUnqueuing(false);
}
@@ -1189,71 +451,38 @@ export function TaskFollowUpSection({
{
- if (!selectedAttemptId) return;
- if (isQueued) {
+ if (displayQueued) {
+ setIsUnqueuing(true);
try {
- if (saveTimeoutRef.current)
- window.clearTimeout(saveTimeoutRef.current);
- suppressNextSaveRef.current = true;
- setIsUnqueuing(true);
- try {
- const resp = await attemptsApi.setFollowUpQueue(
- selectedAttemptId,
- false
- );
- if (resp?.version !== undefined) {
- lastServerVersionRef.current = Number(
- resp.version ?? 0n
- );
- }
- setIsQueued(!!resp.queued);
- } catch (err: unknown) {
- // On any error (including 409), hard refresh and adopt server state
- const latest =
- await attemptsApi.getFollowUpDraft(
- selectedAttemptId
- );
- suppressNextSaveRef.current = true;
- setFollowUpMessage(latest.prompt || '');
- setIsQueued(!!latest.queued);
- if (
- latest.variant !== undefined &&
- latest.variant !== null
- ) {
- setSelectedVariant(latest.variant);
- }
- if (
- latest.version !== undefined &&
- latest.version !== null
- ) {
- lastServerVersionRef.current = Number(
- latest.version ?? 0n
- );
- }
- }
- } catch (e) {
- console.error('Failed to unqueue for editing', e);
+ const ok = await onUnqueue();
+ if (ok) setQueuedOptimistic(false);
} finally {
setIsUnqueuing(false);
}
} else {
- await onQueue();
+ setIsQueuing(true);
+ try {
+ const ok = await onQueue();
+ if (ok) setQueuedOptimistic(true);
+ } finally {
+ setIsQueuing(false);
+ }
}
}}
disabled={
- isQueued
+ displayQueued
? isUnqueuing
: !canSendFollowUp ||
- !isDraftReady ||
+ !isDraftLoaded ||
isQueuing ||
isUnqueuing ||
- isDraftSending
+ !!draft?.sending
}
size="sm"
variant="default"
className="md:min-w-[180px] transition-all"
>
- {isQueued ? (
+ {displayQueued ? (
isUnqueuing ? (
<>
diff --git a/frontend/src/components/tasks/VariantSelector.tsx b/frontend/src/components/tasks/VariantSelector.tsx
new file mode 100644
index 00000000..648a94de
--- /dev/null
+++ b/frontend/src/components/tasks/VariantSelector.tsx
@@ -0,0 +1,91 @@
+import { memo, forwardRef, useEffect, useState } from 'react';
+import { ChevronDown } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib/utils';
+import type { ExecutorConfig } from 'shared/types';
+
+type Props = {
+ currentProfile: ExecutorConfig | null;
+ selectedVariant: string | null;
+ onChange: (variant: string | null) => void;
+ disabled?: boolean;
+ className?: string;
+};
+
+const VariantSelectorInner = forwardRef(
+ ({ currentProfile, selectedVariant, onChange, disabled, className }, ref) => {
+ // Bump-effect animation when cycling through variants
+ const [isAnimating, setIsAnimating] = useState(false);
+ useEffect(() => {
+ if (!currentProfile) return;
+ setIsAnimating(true);
+ const t = setTimeout(() => setIsAnimating(false), 300);
+ return () => clearTimeout(t);
+ }, [selectedVariant, currentProfile]);
+
+ const hasVariants =
+ currentProfile && Object.keys(currentProfile).length > 0;
+
+ if (!currentProfile) return null;
+
+ if (!hasVariants) {
+ return (
+
+ Default
+
+ );
+ }
+
+ return (
+
+
+
+
+ {selectedVariant || 'DEFAULT'}
+
+
+
+
+
+ {Object.entries(currentProfile).map(([variantLabel]) => (
+ onChange(variantLabel)}
+ className={selectedVariant === variantLabel ? 'bg-accent' : ''}
+ >
+ {variantLabel}
+
+ ))}
+
+
+ );
+ }
+);
+
+VariantSelectorInner.displayName = 'VariantSelector';
+export const VariantSelector = memo(VariantSelectorInner);
diff --git a/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx b/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx
new file mode 100644
index 00000000..8fa50739
--- /dev/null
+++ b/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx
@@ -0,0 +1,72 @@
+import { useCallback } from 'react';
+import { attemptsApi } from '@/lib/api';
+import { ConflictBanner } from '@/components/tasks/ConflictBanner';
+import { buildResolveConflictsInstructions } from '@/lib/conflicts';
+import type { BranchStatus } from 'shared/types';
+
+type Props = {
+ selectedAttemptId?: string;
+ attemptBranch: string | null;
+ branchStatus?: BranchStatus;
+ isEditable: boolean;
+ appendInstructions: (text: string) => void;
+ refetchBranchStatus: () => void;
+};
+
+export function FollowUpConflictSection({
+ selectedAttemptId,
+ attemptBranch,
+ branchStatus,
+ isEditable,
+ appendInstructions,
+ refetchBranchStatus,
+}: Props) {
+ const op = branchStatus?.conflict_op ?? null;
+ const handleInsertInstructions = useCallback(() => {
+ const template = buildResolveConflictsInstructions(
+ attemptBranch,
+ branchStatus?.base_branch_name,
+ branchStatus?.conflicted_files || [],
+ op
+ );
+ appendInstructions(template);
+ }, [
+ attemptBranch,
+ branchStatus?.base_branch_name,
+ branchStatus?.conflicted_files,
+ op,
+ appendInstructions,
+ ]);
+
+ const hasConflicts = (branchStatus?.conflicted_files?.length ?? 0) > 0;
+ if (!hasConflicts) return null;
+
+ return (
+ {
+ if (!selectedAttemptId) return;
+ try {
+ const first = branchStatus?.conflicted_files?.[0];
+ await attemptsApi.openEditor(selectedAttemptId, undefined, first);
+ } catch (e) {
+ console.error('Failed to open editor', e);
+ }
+ }}
+ onInsertInstructions={handleInsertInstructions}
+ onAbort={async () => {
+ if (!selectedAttemptId) return;
+ try {
+ await attemptsApi.abortConflicts(selectedAttemptId);
+ refetchBranchStatus();
+ } catch (e) {
+ console.error('Failed to abort conflicts', e);
+ }
+ }}
+ />
+ );
+}
diff --git a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
new file mode 100644
index 00000000..e099f929
--- /dev/null
+++ b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
@@ -0,0 +1,49 @@
+import { Loader2 } from 'lucide-react';
+import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
+import { cn } from '@/lib/utils';
+
+type Props = {
+ placeholder: string;
+ value: string;
+ onChange: (v: string) => void;
+ onKeyDown: (e: React.KeyboardEvent) => void;
+ disabled: boolean;
+ projectId: string;
+ rows?: number;
+ maxRows?: number;
+ // Loading overlay
+ showLoadingOverlay: boolean;
+};
+
+export function FollowUpEditorCard({
+ placeholder,
+ value,
+ onChange,
+ onKeyDown,
+ disabled,
+ projectId,
+ rows = 1,
+ maxRows = 6,
+ showLoadingOverlay,
+}: Props) {
+ return (
+
+
+ {showLoadingOverlay && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/follow-up/useDefaultVariant.ts b/frontend/src/hooks/follow-up/useDefaultVariant.ts
new file mode 100644
index 00000000..65883acb
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useDefaultVariant.ts
@@ -0,0 +1,69 @@
+import { useEffect, useMemo, useState } from 'react';
+import type {
+ ExecutorAction,
+ ExecutorConfig,
+ ExecutionProcess,
+ ExecutorProfileId,
+} from 'shared/types';
+import { useVariantCyclingShortcut } from '@/lib/keyboard-shortcuts';
+
+type Args = {
+ processes: ExecutionProcess[];
+ profiles?: Record | null;
+};
+
+export function useDefaultVariant({ processes, profiles }: Args) {
+ const latestProfileId = useMemo(() => {
+ if (!processes?.length) return null;
+
+ // Walk processes from newest to oldest and extract the first executor_profile_id
+ // from either the action itself or its next_action (when current is a ScriptRequest).
+ const extractProfile = (
+ action: ExecutorAction | null
+ ): ExecutorProfileId | null => {
+ let curr: ExecutorAction | null = action;
+ while (curr) {
+ const typ = curr.typ;
+ switch (typ.type) {
+ case 'CodingAgentInitialRequest':
+ case 'CodingAgentFollowUpRequest':
+ return typ.executor_profile_id;
+ case 'ScriptRequest':
+ curr = curr.next_action;
+ continue;
+ }
+ }
+ return null;
+ };
+ return (
+ processes
+ .slice()
+ .reverse()
+ .map((p) => extractProfile(p.executor_action ?? null))
+ .find((pid) => pid !== null) ?? null
+ );
+ }, [processes]);
+
+ const defaultFollowUpVariant = latestProfileId?.variant ?? null;
+
+ const [selectedVariant, setSelectedVariant] = useState(
+ defaultFollowUpVariant
+ );
+ useEffect(
+ () => setSelectedVariant(defaultFollowUpVariant),
+ [defaultFollowUpVariant]
+ );
+
+ const currentProfile = useMemo(() => {
+ if (!latestProfileId) return null;
+ return profiles?.[latestProfileId.executor] ?? null;
+ }, [latestProfileId, profiles]);
+
+ useVariantCyclingShortcut({
+ currentProfile,
+ selectedVariant,
+ setSelectedVariant,
+ });
+
+ return { selectedVariant, setSelectedVariant, currentProfile } as const;
+}
diff --git a/frontend/src/hooks/follow-up/useDraftAutosave.ts b/frontend/src/hooks/follow-up/useDraftAutosave.ts
new file mode 100644
index 00000000..166fea85
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useDraftAutosave.ts
@@ -0,0 +1,114 @@
+import { useEffect, useRef, useState } from 'react';
+import { attemptsApi, type UpdateFollowUpDraftRequest } from '@/lib/api';
+import type { FollowUpDraft, ImageResponse } from 'shared/types';
+
+export type SaveStatus = 'idle' | 'saving' | 'saved' | 'offline' | 'sent';
+
+type Args = {
+ attemptId?: string;
+ draft: FollowUpDraft | null;
+ message: string;
+ selectedVariant: string | null;
+ images: ImageResponse[];
+ isQueuedUI: boolean;
+ isDraftSending: boolean;
+ isQueuing: boolean;
+ isUnqueuing: boolean;
+ suppressNextSaveRef: React.MutableRefObject;
+ lastServerVersionRef: React.MutableRefObject;
+ forceNextApplyRef: React.MutableRefObject;
+};
+
+export function useDraftAutosave({
+ attemptId,
+ draft,
+ message,
+ selectedVariant,
+ images,
+ isQueuedUI,
+ isDraftSending,
+ isQueuing,
+ isUnqueuing,
+ suppressNextSaveRef,
+ lastServerVersionRef,
+ forceNextApplyRef,
+}: Args) {
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveStatus, setSaveStatus] = useState('idle');
+ // Presentation timers moved to FollowUpStatusRow; keep only raw status.
+
+ // debounced save
+ const lastSentRef = useRef('');
+ const saveTimeoutRef = useRef(undefined);
+ useEffect(() => {
+ if (!attemptId) return;
+ if (isDraftSending) return;
+ if (isQueuing || isUnqueuing) return;
+ if (suppressNextSaveRef.current) {
+ suppressNextSaveRef.current = false;
+ return;
+ }
+ if (isQueuedUI) return;
+
+ const saveDraft = async () => {
+ const payload: Partial = {};
+ if (draft && message !== (draft.prompt || '')) payload.prompt = message;
+ if ((draft?.variant ?? null) !== (selectedVariant ?? null))
+ payload.variant = (selectedVariant ?? null) as string | null;
+ const currentIds = images.map((img) => img.id);
+ const serverIds = (draft?.image_ids as string[] | undefined) ?? [];
+ const idsEqual =
+ currentIds.length === serverIds.length &&
+ currentIds.every((id, i) => id === serverIds[i]);
+ if (!idsEqual) payload.image_ids = currentIds;
+ const keys = Object.keys(payload);
+ if (keys.length === 0) return;
+ const payloadKey = JSON.stringify(payload);
+ if (payloadKey === lastSentRef.current) return;
+ lastSentRef.current = payloadKey;
+ try {
+ setIsSaving(true);
+ setSaveStatus(navigator.onLine ? 'saving' : 'offline');
+ await attemptsApi.saveFollowUpDraft(
+ attemptId,
+ payload as UpdateFollowUpDraftRequest
+ );
+ setSaveStatus('saved');
+ } catch {
+ try {
+ // Fetch latest server draft to ensure stream catches up,
+ // and force next apply to override local edits when it arrives.
+ await attemptsApi.getFollowUpDraft(attemptId);
+ suppressNextSaveRef.current = true;
+ forceNextApplyRef.current = true;
+ } catch {
+ /* ignore */
+ }
+ setSaveStatus(navigator.onLine ? 'idle' : 'offline');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+ if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = window.setTimeout(saveDraft, 400);
+ return () => {
+ if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
+ };
+ }, [
+ attemptId,
+ draft?.prompt,
+ draft?.variant,
+ draft?.image_ids,
+ message,
+ selectedVariant,
+ images,
+ isQueuedUI,
+ isDraftSending,
+ isQueuing,
+ isUnqueuing,
+ suppressNextSaveRef,
+ lastServerVersionRef,
+ ]);
+
+ return { isSaving, saveStatus } as const;
+}
diff --git a/frontend/src/hooks/follow-up/useDraftEdits.ts b/frontend/src/hooks/follow-up/useDraftEdits.ts
new file mode 100644
index 00000000..7e459e45
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useDraftEdits.ts
@@ -0,0 +1,48 @@
+import { useEffect, useRef, useState } from 'react';
+import type { FollowUpDraft } from 'shared/types';
+
+type Args = {
+ draft: FollowUpDraft | null;
+ lastServerVersionRef: React.MutableRefObject;
+ suppressNextSaveRef: React.MutableRefObject;
+ forceNextApplyRef: React.MutableRefObject;
+};
+
+export function useDraftEdits({
+ draft,
+ lastServerVersionRef,
+ suppressNextSaveRef,
+ forceNextApplyRef,
+}: Args) {
+ const [message, setMessage] = useState('');
+
+ const localDirtyRef = useRef(false);
+
+ useEffect(() => {
+ if (!draft) return;
+ const incomingVersion = Number(draft.version ?? 0n);
+
+ if (incomingVersion === lastServerVersionRef.current) return;
+ suppressNextSaveRef.current = true;
+ const isInitial = lastServerVersionRef.current === -1;
+ const shouldForce = forceNextApplyRef.current;
+ const allowApply = isInitial || shouldForce || !localDirtyRef.current;
+ if (allowApply && incomingVersion >= lastServerVersionRef.current) {
+ setMessage(draft.prompt || '');
+ localDirtyRef.current = false;
+ lastServerVersionRef.current = incomingVersion;
+ if (shouldForce) forceNextApplyRef.current = false;
+ } else if (incomingVersion > lastServerVersionRef.current) {
+ // Skip applying server changes while user is editing; still advance version to avoid loops
+ lastServerVersionRef.current = incomingVersion;
+ }
+ }, [draft]);
+
+ return {
+ message,
+ setMessage: (v: string) => {
+ localDirtyRef.current = true;
+ setMessage(v);
+ },
+ } as const;
+}
diff --git a/frontend/src/hooks/follow-up/useDraftImages.ts b/frontend/src/hooks/follow-up/useDraftImages.ts
new file mode 100644
index 00000000..2feae140
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useDraftImages.ts
@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { FollowUpDraft, ImageResponse } from 'shared/types';
+import { imagesApi } from '@/lib/api';
+
+type Args = {
+ draft: FollowUpDraft | null;
+ taskId: string;
+};
+
+export function useDraftImages({ draft, taskId }: Args) {
+ const [images, setImages] = useState([]);
+ const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState(
+ []
+ );
+ const imagesDirtyRef = useRef(false);
+
+ useEffect(() => {
+ if (!draft) return;
+ const serverIds = (draft.image_ids || []) as string[];
+ const wantIds = new Set(serverIds);
+ const haveIds = new Set(images.map((img) => img.id));
+ const equal =
+ haveIds.size === wantIds.size &&
+ Array.from(haveIds).every((id) => wantIds.has(id));
+
+ if (equal) {
+ // Server and UI are aligned; no longer locally dirty
+ imagesDirtyRef.current = false;
+ // Do not clear newlyUploadedImageIds automatically; keep until send/cleanup
+ return;
+ }
+
+ if (imagesDirtyRef.current) {
+ // Local edits pending; avoid clobbering UI with server list
+ return;
+ }
+
+ // Adopt server list (UI not dirty)
+ imagesApi
+ .getTaskImages(taskId)
+ .then((all) => {
+ const next = all.filter((img) => wantIds.has(img.id));
+ setImages(next);
+ // Clear newly uploaded IDs when adopting server list
+ setNewlyUploadedImageIds([]);
+ })
+ .catch(() => void 0);
+ }, [draft?.image_ids, taskId, images]);
+
+ const handleImageUploaded = useCallback((image: ImageResponse) => {
+ imagesDirtyRef.current = true;
+ setImages((prev) => [...prev, image]);
+ setNewlyUploadedImageIds((prev) => [...prev, image.id]);
+ }, []);
+
+ const clearImagesAndUploads = useCallback(() => {
+ imagesDirtyRef.current = false;
+ setImages([]);
+ setNewlyUploadedImageIds([]);
+ }, []);
+
+ return {
+ images,
+ setImages,
+ newlyUploadedImageIds,
+ handleImageUploaded,
+ clearImagesAndUploads,
+ } as const;
+}
diff --git a/frontend/src/hooks/follow-up/useDraftQueue.ts b/frontend/src/hooks/follow-up/useDraftQueue.ts
new file mode 100644
index 00000000..c57955fb
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useDraftQueue.ts
@@ -0,0 +1,100 @@
+import { useCallback } from 'react';
+import { attemptsApi, type UpdateFollowUpDraftRequest } from '@/lib/api';
+import type { FollowUpDraft, ImageResponse } from 'shared/types';
+
+type Args = {
+ attemptId?: string;
+ draft: FollowUpDraft | null;
+ message: string;
+ selectedVariant: string | null;
+ images: ImageResponse[];
+ suppressNextSaveRef: React.MutableRefObject;
+ lastServerVersionRef: React.MutableRefObject;
+};
+
+export function useDraftQueue({
+ attemptId,
+ draft,
+ message,
+ selectedVariant,
+ images,
+ suppressNextSaveRef,
+ lastServerVersionRef,
+}: Args) {
+ const onQueue = useCallback(async (): Promise => {
+ if (!attemptId) return false;
+ if (draft?.queued) return true;
+ if (message.trim().length === 0) return false;
+ try {
+ const immediatePayload: Partial = {
+ prompt: message,
+ };
+ if ((draft?.variant ?? null) !== (selectedVariant ?? null))
+ immediatePayload.variant = (selectedVariant ?? null) as string | null;
+ const currentIds = images.map((img) => img.id);
+ const serverIds = (draft?.image_ids as string[] | undefined) ?? [];
+ const idsEqual =
+ currentIds.length === serverIds.length &&
+ currentIds.every((id, i) => id === serverIds[i]);
+ if (!idsEqual) immediatePayload.image_ids = currentIds;
+ suppressNextSaveRef.current = true;
+ await attemptsApi.saveFollowUpDraft(
+ attemptId,
+ immediatePayload as UpdateFollowUpDraftRequest
+ );
+ try {
+ const resp = await attemptsApi.setFollowUpQueue(attemptId, true);
+ if (resp?.version !== undefined && resp?.version !== null) {
+ lastServerVersionRef.current = Number(resp.version ?? 0n);
+ }
+ return !!resp?.queued;
+ } catch {
+ /* adopt server on failure */
+ const latest = await attemptsApi.getFollowUpDraft(attemptId);
+ suppressNextSaveRef.current = true;
+ if (latest.version !== undefined && latest.version !== null) {
+ lastServerVersionRef.current = Number(latest.version ?? 0n);
+ }
+ return !!latest?.queued;
+ }
+ } finally {
+ // presentation-only state handled by caller
+ }
+ return false;
+ }, [
+ attemptId,
+ draft?.variant,
+ draft?.image_ids,
+ images,
+ message,
+ selectedVariant,
+ suppressNextSaveRef,
+ lastServerVersionRef,
+ ]);
+
+ const onUnqueue = useCallback(async (): Promise => {
+ if (!attemptId) return false;
+ try {
+ suppressNextSaveRef.current = true;
+ try {
+ const resp = await attemptsApi.setFollowUpQueue(attemptId, false);
+ if (resp?.version !== undefined && resp?.version !== null) {
+ lastServerVersionRef.current = Number(resp.version ?? 0n);
+ }
+ return !!resp && !resp.queued;
+ } catch {
+ const latest = await attemptsApi.getFollowUpDraft(attemptId);
+ suppressNextSaveRef.current = true;
+ if (latest.version !== undefined && latest.version !== null) {
+ lastServerVersionRef.current = Number(latest.version ?? 0n);
+ }
+ return !!latest && !latest.queued;
+ }
+ } finally {
+ // presentation-only state handled by caller
+ }
+ return false;
+ }, [attemptId, suppressNextSaveRef, lastServerVersionRef]);
+
+ return { onQueue, onUnqueue } as const;
+}
diff --git a/frontend/src/hooks/follow-up/useDraftStream.ts b/frontend/src/hooks/follow-up/useDraftStream.ts
new file mode 100644
index 00000000..8e0f701f
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useDraftStream.ts
@@ -0,0 +1,143 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useJsonPatchStream } from '@/hooks/useJsonPatchStream';
+import { attemptsApi } from '@/lib/api';
+import type { FollowUpDraft } from 'shared/types';
+import { inIframe } from '@/vscode/bridge';
+
+type DraftStreamState = { follow_up_draft: FollowUpDraft };
+
+export function useDraftStream(attemptId?: string) {
+ const [draft, setDraft] = useState(null);
+ const [isDraftLoaded, setIsDraftLoaded] = useState(false);
+ const lastServerVersionRef = useRef(-1);
+ const suppressNextSaveRef = useRef(false);
+ const forceNextApplyRef = useRef(false);
+
+ const endpoint = attemptId
+ ? `/api/task-attempts/${attemptId}/follow-up-draft/stream`
+ : undefined;
+
+ const makeInitial = useCallback(
+ (): DraftStreamState => ({
+ follow_up_draft: {
+ id: '',
+ task_attempt_id: attemptId || '',
+ prompt: '',
+ queued: false,
+ sending: false,
+ variant: null,
+ image_ids: [],
+ version: 0n,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ },
+ }),
+ [attemptId]
+ );
+
+ const { data, isConnected, error } = useJsonPatchStream(
+ endpoint,
+ !!endpoint,
+ makeInitial
+ );
+
+ // Quick initial draft loading from REST
+ useEffect(() => {
+ let cancelled = false;
+ const hydrate = async () => {
+ if (!attemptId) return;
+ try {
+ const d = await attemptsApi.getFollowUpDraft(attemptId);
+ if (cancelled) return;
+ suppressNextSaveRef.current = true;
+ setDraft({
+ id: 'rest',
+ task_attempt_id: d.task_attempt_id,
+ prompt: d.prompt || '',
+ queued: !!d.queued,
+ sending: false,
+ variant: (d.variant ?? null) as string | null,
+ image_ids: (d.image_ids ?? []) as string[],
+ version: (d.version ?? 0n) as unknown as bigint,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ });
+ if (!isDraftLoaded) setIsDraftLoaded(true);
+ } catch {
+ // ignore, rely on SSE
+ }
+ };
+ hydrate();
+ return () => {
+ cancelled = true;
+ };
+ }, [attemptId, isDraftLoaded]);
+
+ // Handle SSE stream
+ useEffect(() => {
+ if (!data) return;
+ const d = data.follow_up_draft;
+ if (d.id === '') return;
+ const incomingVersion = Number(d.version ?? 0n);
+ if (incomingVersion === lastServerVersionRef.current) {
+ if (!isDraftLoaded) setIsDraftLoaded(true);
+ return;
+ }
+ suppressNextSaveRef.current = true;
+ // Let consumers decide whether to apply or ignore based on local dirty/forceApply.
+ setDraft(d);
+ if (!isDraftLoaded) setIsDraftLoaded(true);
+ }, [data, isDraftLoaded]);
+
+ // VSCode iframe poll fallback
+ const pollTimerRef = useRef(undefined);
+ useEffect(() => {
+ if (!attemptId) return;
+ const shouldPoll = inIframe() && (!isConnected || !!error);
+ if (!shouldPoll) {
+ if (pollTimerRef.current) window.clearInterval(pollTimerRef.current);
+ pollTimerRef.current = undefined;
+ return;
+ }
+ const pollOnce = async () => {
+ try {
+ const d = await attemptsApi.getFollowUpDraft(attemptId);
+ const incomingVersion = Number((d as FollowUpDraft).version ?? 0n);
+ if (incomingVersion !== lastServerVersionRef.current) {
+ suppressNextSaveRef.current = true;
+ setDraft({
+ id: 'rest',
+ task_attempt_id: d.task_attempt_id,
+ prompt: d.prompt || '',
+ queued: !!d.queued,
+ sending: false,
+ variant: (d.variant ?? null) as string | null,
+ image_ids: (d.image_ids ?? []) as string[],
+ version: (d.version ?? 0n) as unknown as bigint,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ });
+ if (!isDraftLoaded) setIsDraftLoaded(true);
+ }
+ } catch {
+ // ignore
+ }
+ };
+ pollOnce();
+ pollTimerRef.current = window.setInterval(pollOnce, 1000);
+ return () => {
+ if (pollTimerRef.current) window.clearInterval(pollTimerRef.current);
+ pollTimerRef.current = undefined;
+ };
+ }, [attemptId, isConnected, error, isDraftLoaded]);
+
+ return {
+ draft,
+ isDraftLoaded,
+ isConnected,
+ error,
+ lastServerVersionRef,
+ suppressNextSaveRef,
+ forceNextApplyRef,
+ } as const;
+}
diff --git a/frontend/src/hooks/follow-up/useFollowUpSend.ts b/frontend/src/hooks/follow-up/useFollowUpSend.ts
new file mode 100644
index 00000000..41b0dd22
--- /dev/null
+++ b/frontend/src/hooks/follow-up/useFollowUpSend.ts
@@ -0,0 +1,85 @@
+import { useCallback, useState } from 'react';
+import { attemptsApi } from '@/lib/api';
+import type { ImageResponse } from 'shared/types';
+
+type Args = {
+ attemptId?: string;
+ message: string;
+ reviewMarkdown: string;
+ selectedVariant: string | null;
+ images: ImageResponse[];
+ newlyUploadedImageIds: string[];
+ clearComments: () => void;
+ jumpToLogsTab: () => void;
+ onAfterSendCleanup: () => void;
+ setMessage: (v: string) => void;
+};
+
+export function useFollowUpSend({
+ attemptId,
+ message,
+ reviewMarkdown,
+ selectedVariant,
+ images,
+ newlyUploadedImageIds,
+ clearComments,
+ jumpToLogsTab,
+ onAfterSendCleanup,
+ setMessage,
+}: Args) {
+ const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
+ const [followUpError, setFollowUpError] = useState(null);
+
+ const onSendFollowUp = useCallback(async () => {
+ if (!attemptId) return;
+ const extraMessage = message.trim();
+ const finalPrompt = [reviewMarkdown, extraMessage]
+ .filter(Boolean)
+ .join('\n\n');
+ if (!finalPrompt) return;
+ try {
+ setIsSendingFollowUp(true);
+ setFollowUpError(null);
+ const image_ids =
+ newlyUploadedImageIds.length > 0
+ ? newlyUploadedImageIds
+ : images.length > 0
+ ? images.map((img) => img.id)
+ : null;
+ await attemptsApi.followUp(attemptId, {
+ prompt: finalPrompt,
+ variant: selectedVariant,
+ image_ids,
+ });
+ setMessage('');
+ clearComments();
+ onAfterSendCleanup();
+ jumpToLogsTab();
+ } catch (error: unknown) {
+ const err = error as { message?: string };
+ setFollowUpError(
+ `Failed to start follow-up execution: ${err.message ?? 'Unknown error'}`
+ );
+ } finally {
+ setIsSendingFollowUp(false);
+ }
+ }, [
+ attemptId,
+ message,
+ reviewMarkdown,
+ newlyUploadedImageIds,
+ images,
+ selectedVariant,
+ clearComments,
+ jumpToLogsTab,
+ onAfterSendCleanup,
+ setMessage,
+ ]);
+
+ return {
+ isSendingFollowUp,
+ followUpError,
+ setFollowUpError,
+ onSendFollowUp,
+ } as const;
+}
diff --git a/frontend/src/hooks/useAttemptBranch.ts b/frontend/src/hooks/useAttemptBranch.ts
new file mode 100644
index 00000000..a7a72302
--- /dev/null
+++ b/frontend/src/hooks/useAttemptBranch.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { attemptsApi } from '@/lib/api';
+
+export function useAttemptBranch(attemptId?: string) {
+ const query = useQuery({
+ queryKey: ['attemptBranch', attemptId],
+ queryFn: async () => {
+ const attempt = await attemptsApi.get(attemptId!);
+ return attempt.branch ?? null;
+ },
+ enabled: !!attemptId,
+ });
+
+ return {
+ branch: query.data ?? null,
+ isLoading: query.isLoading,
+ refetch: query.refetch,
+ } as const;
+}
diff --git a/frontend/src/lib/keyboard-shortcuts.ts b/frontend/src/lib/keyboard-shortcuts.ts
index a2ed772d..1f40c8a6 100644
--- a/frontend/src/lib/keyboard-shortcuts.ts
+++ b/frontend/src/lib/keyboard-shortcuts.ts
@@ -271,12 +271,10 @@ export function useVariantCyclingShortcut({
currentProfile,
selectedVariant,
setSelectedVariant,
- setIsAnimating,
}: {
currentProfile: ExecutorConfig | null | undefined;
selectedVariant: string | null;
setSelectedVariant: (variant: string | null) => void;
- setIsAnimating: (animating: boolean) => void;
}) {
useEffect(() => {
if (!currentProfile || Object.keys(currentProfile).length === 0) {
@@ -300,14 +298,10 @@ export function useVariantCyclingShortcut({
const nextVariant = variantLabels[nextIndex];
setSelectedVariant(nextVariant);
-
- // Trigger animation
- setIsAnimating(true);
- setTimeout(() => setIsAnimating(false), 300);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
- }, [currentProfile, selectedVariant, setSelectedVariant, setIsAnimating]);
+ }, [currentProfile, selectedVariant, setSelectedVariant]);
}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 539db92a..1919b87d 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -134,10 +134,17 @@ module.exports = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
+ pill: {
+ '0%': { opacity: '0' },
+ '10%': { opacity: '1' },
+ '80%': { opacity: '1' },
+ '100%': { opacity: '0' },
+ },
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
+ pill: 'pill 2s ease-in-out forwards',
},
},
},