Refactor: TaskFollowUpSection.tsx (#744)
This commit is contained in:
@@ -3,67 +3,93 @@ import { Button } from '@/components/ui/button';
|
|||||||
import type { ConflictOp } from 'shared/types';
|
import type { ConflictOp } from 'shared/types';
|
||||||
import { displayConflictOpLabel } from '@/lib/conflicts';
|
import { displayConflictOpLabel } from '@/lib/conflicts';
|
||||||
|
|
||||||
interface Props {
|
export type Props = Readonly<{
|
||||||
attemptBranch: string | null;
|
attemptBranch: string | null;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
conflictedFiles: string[];
|
conflictedFiles: readonly string[];
|
||||||
isDraftLocked: boolean;
|
isEditable: boolean;
|
||||||
isDraftReady: boolean;
|
|
||||||
onOpenEditor: () => void;
|
onOpenEditor: () => void;
|
||||||
onInsertInstructions: () => void;
|
onInsertInstructions: () => void;
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
op?: ConflictOp | null;
|
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({
|
export function ConflictBanner({
|
||||||
attemptBranch,
|
attemptBranch,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
conflictedFiles,
|
conflictedFiles,
|
||||||
isDraftLocked,
|
isEditable,
|
||||||
isDraftReady,
|
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
onInsertInstructions,
|
onInsertInstructions,
|
||||||
onAbort,
|
onAbort,
|
||||||
op,
|
op,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const displayFiles = conflictedFiles.slice(0, 8);
|
const { full: opTitle, lower: opTitleLower } = getOperationTitle(op);
|
||||||
const opTitle = displayConflictOpLabel(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 (
|
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">
|
<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">
|
<div className="text-sm leading-relaxed">
|
||||||
{attemptBranch ? (
|
<span>{heading}</span>{' '}
|
||||||
<>
|
<span>
|
||||||
{opTitle} in progress: '{attemptBranch}' → '{baseBranch}'.
|
Follow-ups are allowed; some actions may be temporarily unavailable
|
||||||
</>
|
until you resolve the conflicts or abort the {opTitleLower}.
|
||||||
) : (
|
</span>
|
||||||
<>A Git operation with merge conflicts is in progress.</>
|
{visibleFiles.length > 0 && (
|
||||||
)}{' '}
|
|
||||||
Follow-ups are allowed; some actions may be temporarily unavailable
|
|
||||||
until you resolve the conflicts or abort the {opTitle.toLowerCase()}.
|
|
||||||
{displayFiles.length ? (
|
|
||||||
<div className="mt-1 text-xs text-yellow-800">
|
<div className="mt-1 text-xs text-yellow-800">
|
||||||
Conflicted files ({displayFiles.length}
|
<div className="font-medium">
|
||||||
{conflictedFiles.length > displayFiles.length
|
Conflicted files ({visibleFiles.length}
|
||||||
? ` of ${conflictedFiles.length}`
|
{hasMore ? ` of ${total}` : ''}):
|
||||||
: ''}
|
</div>
|
||||||
):
|
<div className="mt-1 grid grid-cols-1 gap-0.5">
|
||||||
<div
|
{visibleFiles.map((f) => (
|
||||||
className="mt-1 grid gap-0.5"
|
|
||||||
style={{ gridTemplateColumns: '1fr' }}
|
|
||||||
>
|
|
||||||
{displayFiles.map((f) => (
|
|
||||||
<div key={f} className="truncate">
|
<div key={f} className="truncate">
|
||||||
{f}
|
{f}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -72,15 +98,18 @@ export function ConflictBanner({
|
|||||||
>
|
>
|
||||||
Open in Editor
|
Open in Editor
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-yellow-300 text-yellow-800 hover:bg-yellow-100"
|
className="border-yellow-300 text-yellow-800 hover:bg-yellow-100"
|
||||||
onClick={onInsertInstructions}
|
onClick={onInsertInstructions}
|
||||||
disabled={isDraftLocked || !isDraftReady}
|
disabled={!isEditable}
|
||||||
|
aria-disabled={!isEditable}
|
||||||
>
|
>
|
||||||
Insert Resolve-Conflicts Instructions
|
Insert Resolve-Conflicts Instructions
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
98
frontend/src/components/tasks/FollowUpStatusRow.tsx
Normal file
98
frontend/src/components/tasks/FollowUpStatusRow.tsx
Normal 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);
|
||||||
@@ -203,7 +203,6 @@ export function TaskDetailsPanel({
|
|||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
selectedAttemptId={selectedAttempt?.id}
|
selectedAttemptId={selectedAttempt?.id}
|
||||||
selectedAttemptProfile={selectedAttempt?.executor}
|
|
||||||
jumpToLogsTab={jumpToLogsTab}
|
jumpToLogsTab={jumpToLogsTab}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -247,7 +246,6 @@ export function TaskDetailsPanel({
|
|||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
selectedAttemptId={selectedAttempt?.id}
|
selectedAttemptId={selectedAttempt?.id}
|
||||||
selectedAttemptProfile={selectedAttempt?.executor}
|
|
||||||
jumpToLogsTab={jumpToLogsTab}
|
jumpToLogsTab={jumpToLogsTab}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
91
frontend/src/components/tasks/VariantSelector.tsx
Normal file
91
frontend/src/components/tasks/VariantSelector.tsx
Normal 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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/hooks/follow-up/useDefaultVariant.ts
Normal file
69
frontend/src/hooks/follow-up/useDefaultVariant.ts
Normal 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;
|
||||||
|
}
|
||||||
114
frontend/src/hooks/follow-up/useDraftAutosave.ts
Normal file
114
frontend/src/hooks/follow-up/useDraftAutosave.ts
Normal 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;
|
||||||
|
}
|
||||||
48
frontend/src/hooks/follow-up/useDraftEdits.ts
Normal file
48
frontend/src/hooks/follow-up/useDraftEdits.ts
Normal 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;
|
||||||
|
}
|
||||||
69
frontend/src/hooks/follow-up/useDraftImages.ts
Normal file
69
frontend/src/hooks/follow-up/useDraftImages.ts
Normal 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;
|
||||||
|
}
|
||||||
100
frontend/src/hooks/follow-up/useDraftQueue.ts
Normal file
100
frontend/src/hooks/follow-up/useDraftQueue.ts
Normal 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;
|
||||||
|
}
|
||||||
143
frontend/src/hooks/follow-up/useDraftStream.ts
Normal file
143
frontend/src/hooks/follow-up/useDraftStream.ts
Normal 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;
|
||||||
|
}
|
||||||
85
frontend/src/hooks/follow-up/useFollowUpSend.ts
Normal file
85
frontend/src/hooks/follow-up/useFollowUpSend.ts
Normal 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;
|
||||||
|
}
|
||||||
19
frontend/src/hooks/useAttemptBranch.ts
Normal file
19
frontend/src/hooks/useAttemptBranch.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -271,12 +271,10 @@ export function useVariantCyclingShortcut({
|
|||||||
currentProfile,
|
currentProfile,
|
||||||
selectedVariant,
|
selectedVariant,
|
||||||
setSelectedVariant,
|
setSelectedVariant,
|
||||||
setIsAnimating,
|
|
||||||
}: {
|
}: {
|
||||||
currentProfile: ExecutorConfig | null | undefined;
|
currentProfile: ExecutorConfig | null | undefined;
|
||||||
selectedVariant: string | null;
|
selectedVariant: string | null;
|
||||||
setSelectedVariant: (variant: string | null) => void;
|
setSelectedVariant: (variant: string | null) => void;
|
||||||
setIsAnimating: (animating: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentProfile || Object.keys(currentProfile).length === 0) {
|
if (!currentProfile || Object.keys(currentProfile).length === 0) {
|
||||||
@@ -300,14 +298,10 @@ export function useVariantCyclingShortcut({
|
|||||||
const nextVariant = variantLabels[nextIndex];
|
const nextVariant = variantLabels[nextIndex];
|
||||||
|
|
||||||
setSelectedVariant(nextVariant);
|
setSelectedVariant(nextVariant);
|
||||||
|
|
||||||
// Trigger animation
|
|
||||||
setIsAnimating(true);
|
|
||||||
setTimeout(() => setIsAnimating(false), 300);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [currentProfile, selectedVariant, setSelectedVariant, setIsAnimating]);
|
}, [currentProfile, selectedVariant, setSelectedVariant]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,10 +134,17 @@ module.exports = {
|
|||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
|
pill: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'10%': { opacity: '1' },
|
||||||
|
'80%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
pill: 'pill 2s ease-in-out forwards',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user