Refactor: TaskFollowUpSection.tsx (#744)

This commit is contained in:
Solomon
2025-09-16 22:45:12 +01:00
committed by GitHub
parent 09d2710a34
commit e9edef6e89
17 changed files with 1278 additions and 1064 deletions

View File

@@ -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 (
<div className="rounded-md border border-yellow-300 bg-yellow-50 text-yellow-900 p-3 flex flex-col gap-2">
<div
className="flex flex-col gap-2 rounded-md border border-yellow-300 bg-yellow-50 p-3 text-yellow-900"
role="status"
aria-live="polite"
>
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 text-yellow-700" />
<AlertCircle className="mt-0.5 h-4 w-4 text-yellow-700" aria-hidden />
<div className="text-sm leading-relaxed">
{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 ? (
<span>{heading}</span>{' '}
<span>
Follow-ups are allowed; some actions may be temporarily unavailable
until you resolve the conflicts or abort the {opTitleLower}.
</span>
{visibleFiles.length > 0 && (
<div className="mt-1 text-xs text-yellow-800">
Conflicted files ({displayFiles.length}
{conflictedFiles.length > displayFiles.length
? ` of ${conflictedFiles.length}`
: ''}
):
<div
className="mt-1 grid gap-0.5"
style={{ gridTemplateColumns: '1fr' }}
>
{displayFiles.map((f) => (
<div className="font-medium">
Conflicted files ({visibleFiles.length}
{hasMore ? ` of ${total}` : ''}):
</div>
<div className="mt-1 grid grid-cols-1 gap-0.5">
{visibleFiles.map((f) => (
<div key={f} className="truncate">
{f}
</div>
))}
</div>
</div>
) : null}
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
@@ -72,15 +98,18 @@ export function ConflictBanner({
>
Open in Editor
</Button>
<Button
size="sm"
variant="outline"
className="border-yellow-300 text-yellow-800 hover:bg-yellow-100"
onClick={onInsertInstructions}
disabled={isDraftLocked || !isDraftReady}
disabled={!isEditable}
aria-disabled={!isEditable}
>
Insert Resolve-Conflicts Instructions
</Button>
<Button
size="sm"
variant="outline"

View File

@@ -0,0 +1,98 @@
import { memo, useEffect, useRef, useState } from 'react';
import { CheckCircle2, Clock, Loader2, Send, WifiOff } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'offline' | 'sent';
type Status = {
save: { state: SaveStatus; isSaving: boolean };
draft: { isLoaded: boolean; isSending: boolean };
queue: { isUnqueuing: boolean; isQueued: boolean };
};
type Props = { status: Status };
function FollowUpStatusRowImpl({ status }: Props) {
const { save, draft, queue } = status;
// Nonce keys to retrigger CSS animation; no JS timers.
const [savedNonce, setSavedNonce] = useState<number | null>(null);
const [sentNonce, setSentNonce] = useState<number | null>(null);
const prevIsSendingRef = useRef<boolean>(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 (
<div className="flex items-center justify-between text-xs min-h-6 h-6 px-0.5">
<div className="text-muted-foreground">
{save.state === 'saving' && save.isSaving ? (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted animate-in fade-in-0',
'italic'
)}
>
<Loader2 className="animate-spin h-3 w-3" /> Saving
</span>
) : save.state === 'offline' ? (
<span className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted text-amber-700 animate-in fade-in-0">
<WifiOff className="h-3 w-3" /> Offline changes pending
</span>
) : sentNonce ? (
<span
key={sentNonce}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted text-emerald-700 animate-pill'
)}
onAnimationEnd={() => setSentNonce(null)}
>
<Send className="h-3 w-3" /> Follow-up sent
</span>
) : savedNonce ? (
<span
key={savedNonce}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted text-emerald-700 animate-pill'
)}
onAnimationEnd={() => setSavedNonce(null)}
>
<CheckCircle2 className="h-3 w-3" /> Draft saved
</span>
) : null}
</div>
<div className="text-muted-foreground">
{queue.isUnqueuing ? (
<span className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted animate-in fade-in-0">
<Loader2 className="animate-spin h-3 w-3" /> Unlocking
</span>
) : !draft.isLoaded ? (
<span className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted animate-in fade-in-0">
<Loader2 className="animate-spin h-3 w-3" /> Loading draft
</span>
) : draft.isSending ? (
<span className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted animate-in fade-in-0">
<Loader2 className="animate-spin h-3 w-3" /> Sending follow-up
</span>
) : queue.isQueued ? (
<span className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted animate-in fade-in-0">
<Clock className="h-3 w-3" /> Queued for next turn. Edits are
locked.
</span>
) : null}
</div>
</div>
);
}
export const FollowUpStatusRow = memo(FollowUpStatusRowImpl);

View File

@@ -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}
/>
</>

File diff suppressed because it is too large Load Diff

View File

@@ -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<HTMLButtonElement, Props>(
({ 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 (
<Button
ref={ref}
variant="outline"
size="sm"
className={cn(
'h-10 w-24 px-2 flex items-center justify-between',
className
)}
disabled
>
<span className="text-xs truncate flex-1 text-left">Default</span>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={ref}
variant="secondary"
size="sm"
className={cn(
'w-18 md:w-24 px-2 flex items-center justify-between transition-all',
isAnimating && 'scale-105 bg-accent',
className
)}
disabled={disabled}
>
<span className="text-xs truncate flex-1 text-left">
{selectedVariant || 'DEFAULT'}
</span>
<ChevronDown className="h-3 w-3 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.entries(currentProfile).map(([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() => onChange(variantLabel)}
className={selectedVariant === variantLabel ? 'bg-accent' : ''}
>
{variantLabel}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
);
VariantSelectorInner.displayName = 'VariantSelector';
export const VariantSelector = memo(VariantSelectorInner);

View File

@@ -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 (
<ConflictBanner
attemptBranch={attemptBranch}
baseBranch={branchStatus?.base_branch_name}
conflictedFiles={branchStatus?.conflicted_files || []}
isEditable={isEditable}
op={op}
onOpenEditor={async () => {
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);
}
}}
/>
);
}

View File

@@ -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<Element>) => 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 (
<div className="relative">
<FileSearchTextarea
placeholder={placeholder}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
className={cn('flex-1 min-h-[40px] resize-none')}
disabled={disabled}
projectId={projectId}
rows={rows}
maxRows={maxRows}
/>
{showLoadingOverlay && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/60">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
);
}

View File

@@ -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<string, ExecutorConfig> | null;
};
export function useDefaultVariant({ processes, profiles }: Args) {
const latestProfileId = useMemo<ExecutorProfileId | null>(() => {
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<string | null>(
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;
}

View File

@@ -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<boolean>;
lastServerVersionRef: React.MutableRefObject<number>;
forceNextApplyRef: React.MutableRefObject<boolean>;
};
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<SaveStatus>('idle');
// Presentation timers moved to FollowUpStatusRow; keep only raw status.
// debounced save
const lastSentRef = useRef<string>('');
const saveTimeoutRef = useRef<number | undefined>(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<UpdateFollowUpDraftRequest> = {};
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;
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react';
import type { FollowUpDraft } from 'shared/types';
type Args = {
draft: FollowUpDraft | null;
lastServerVersionRef: React.MutableRefObject<number>;
suppressNextSaveRef: React.MutableRefObject<boolean>;
forceNextApplyRef: React.MutableRefObject<boolean>;
};
export function useDraftEdits({
draft,
lastServerVersionRef,
suppressNextSaveRef,
forceNextApplyRef,
}: Args) {
const [message, setMessage] = useState('');
const localDirtyRef = useRef<boolean>(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;
}

View File

@@ -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<ImageResponse[]>([]);
const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState<string[]>(
[]
);
const imagesDirtyRef = useRef<boolean>(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;
}

View File

@@ -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<boolean>;
lastServerVersionRef: React.MutableRefObject<number>;
};
export function useDraftQueue({
attemptId,
draft,
message,
selectedVariant,
images,
suppressNextSaveRef,
lastServerVersionRef,
}: Args) {
const onQueue = useCallback(async (): Promise<boolean> => {
if (!attemptId) return false;
if (draft?.queued) return true;
if (message.trim().length === 0) return false;
try {
const immediatePayload: Partial<UpdateFollowUpDraftRequest> = {
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<boolean> => {
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;
}

View File

@@ -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<FollowUpDraft | null>(null);
const [isDraftLoaded, setIsDraftLoaded] = useState(false);
const lastServerVersionRef = useRef<number>(-1);
const suppressNextSaveRef = useRef<boolean>(false);
const forceNextApplyRef = useRef<boolean>(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<DraftStreamState>(
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<number | undefined>(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;
}

View File

@@ -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<string | null>(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;
}

View File

@@ -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;
}

View File

@@ -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]);
}

View File

@@ -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',
},
},
},