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 { 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"
|
||||
|
||||
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}
|
||||
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
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,
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user