From e9edef6e89ac150b2213ef0dbd935563d33ef7a9 Mon Sep 17 00:00:00 2001 From: Solomon Date: Tue, 16 Sep 2025 22:45:12 +0100 Subject: [PATCH] Refactor: TaskFollowUpSection.tsx (#744) --- .../src/components/tasks/ConflictBanner.tsx | 95 +- .../components/tasks/FollowUpStatusRow.tsx | 98 ++ .../src/components/tasks/TaskDetailsPanel.tsx | 2 - .../components/tasks/TaskFollowUpSection.tsx | 1273 ++++------------- .../src/components/tasks/VariantSelector.tsx | 91 ++ .../follow-up/FollowUpConflictSection.tsx | 72 + .../tasks/follow-up/FollowUpEditorCard.tsx | 49 + .../src/hooks/follow-up/useDefaultVariant.ts | 69 + .../src/hooks/follow-up/useDraftAutosave.ts | 114 ++ frontend/src/hooks/follow-up/useDraftEdits.ts | 48 + .../src/hooks/follow-up/useDraftImages.ts | 69 + frontend/src/hooks/follow-up/useDraftQueue.ts | 100 ++ .../src/hooks/follow-up/useDraftStream.ts | 143 ++ .../src/hooks/follow-up/useFollowUpSend.ts | 85 ++ frontend/src/hooks/useAttemptBranch.ts | 19 + frontend/src/lib/keyboard-shortcuts.ts | 8 +- frontend/tailwind.config.js | 7 + 17 files changed, 1278 insertions(+), 1064 deletions(-) create mode 100644 frontend/src/components/tasks/FollowUpStatusRow.tsx create mode 100644 frontend/src/components/tasks/VariantSelector.tsx create mode 100644 frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx create mode 100644 frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx create mode 100644 frontend/src/hooks/follow-up/useDefaultVariant.ts create mode 100644 frontend/src/hooks/follow-up/useDraftAutosave.ts create mode 100644 frontend/src/hooks/follow-up/useDraftEdits.ts create mode 100644 frontend/src/hooks/follow-up/useDraftImages.ts create mode 100644 frontend/src/hooks/follow-up/useDraftQueue.ts create mode 100644 frontend/src/hooks/follow-up/useDraftStream.ts create mode 100644 frontend/src/hooks/follow-up/useFollowUpSend.ts create mode 100644 frontend/src/hooks/useAttemptBranch.ts 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} + )}
-
+ +
+ +
)} - {/* 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 ( - - - - - - {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 ( - - ); - } - 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({
+ ); + } + + return ( + + + + + + {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', }, }, },