Draft saving and queuing support for followups (#646)
This commit is contained in:
@@ -372,7 +372,7 @@ const ToolCallCard: React.FC<{
|
||||
const HeaderWrapper: React.ElementType = hasExpandableDetails
|
||||
? 'button'
|
||||
: 'div';
|
||||
const headerProps: any = hasExpandableDetails
|
||||
const headerProps = hasExpandableDetails
|
||||
? {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
WifiOff,
|
||||
Clock,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ImageIcon,
|
||||
@@ -10,11 +13,19 @@ import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { attemptsApi, imagesApi } from '@/lib/api.ts';
|
||||
import type { ImageResponse, TaskWithAttemptStatus } from 'shared/types';
|
||||
import {
|
||||
attemptsApi,
|
||||
imagesApi,
|
||||
type UpdateFollowUpDraftRequest,
|
||||
} from '@/lib/api.ts';
|
||||
import type {
|
||||
ImageResponse,
|
||||
TaskWithAttemptStatus,
|
||||
FollowUpDraft,
|
||||
} from 'shared/types';
|
||||
import { useBranchStatus } from '@/hooks';
|
||||
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useUserSystem } from '@/components/config-provider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -25,6 +36,8 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useVariantCyclingShortcut } from '@/lib/keyboard-shortcuts';
|
||||
import { useReview } from '@/contexts/ReviewProvider';
|
||||
import { useJsonPatchStream } from '@/hooks/useJsonPatchStream';
|
||||
import { inIframe } from '@/vscode/bridge';
|
||||
|
||||
interface TaskFollowUpSectionProps {
|
||||
task: TaskWithAttemptStatus;
|
||||
@@ -104,6 +117,70 @@ export function TaskFollowUpSection({
|
||||
const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
||||
// Fade-out overlay for clearing text when sending begins
|
||||
const [fadeOverlayText, setFadeOverlayText] = useState('');
|
||||
const [showFadeOverlay, setShowFadeOverlay] = useState(false);
|
||||
const [overlayFadeClass, setOverlayFadeClass] = useState('');
|
||||
const overlayFadeTimerRef = useRef<number | undefined>(undefined);
|
||||
const overlayHideTimerRef = useRef<number | undefined>(undefined);
|
||||
const [isQueued, setIsQueued] = useState(false);
|
||||
const [isDraftSending, setIsDraftSending] = useState(false);
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
const [isUnqueuing, setIsUnqueuing] = useState(false);
|
||||
const [isDraftReady, setIsDraftReady] = useState(false);
|
||||
const saveTimeoutRef = useRef<number | undefined>(undefined);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<
|
||||
'idle' | 'saving' | 'saved' | 'offline' | 'sent'
|
||||
>('idle');
|
||||
const [isStatusFading, setIsStatusFading] = useState(false);
|
||||
const statusFadeTimerRef = useRef<number | undefined>(undefined);
|
||||
const statusClearTimerRef = useRef<number | undefined>(undefined);
|
||||
const lastSentRef = useRef<string>('');
|
||||
const suppressNextSaveRef = useRef<boolean>(false);
|
||||
const localDirtyRef = useRef<boolean>(false);
|
||||
// We auto-resolve conflicts silently by adopting server state.
|
||||
const lastServerVersionRef = useRef<number>(-1);
|
||||
const prevSendingRef = useRef<boolean>(false);
|
||||
|
||||
// Helper to show a pleasant fade for transient "Saved" status
|
||||
const scheduleSavedStatus = useCallback(() => {
|
||||
// Clear pending timers
|
||||
if (statusFadeTimerRef.current)
|
||||
window.clearTimeout(statusFadeTimerRef.current);
|
||||
if (statusClearTimerRef.current)
|
||||
window.clearTimeout(statusClearTimerRef.current);
|
||||
setIsStatusFading(false);
|
||||
setSaveStatus('saved');
|
||||
// Fade out close to the end of visibility
|
||||
statusFadeTimerRef.current = window.setTimeout(
|
||||
() => setIsStatusFading(true),
|
||||
1800
|
||||
);
|
||||
statusClearTimerRef.current = window.setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
setIsStatusFading(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
const scheduleSentStatus = useCallback(() => {
|
||||
if (statusFadeTimerRef.current)
|
||||
window.clearTimeout(statusFadeTimerRef.current);
|
||||
if (statusClearTimerRef.current)
|
||||
window.clearTimeout(statusClearTimerRef.current);
|
||||
setIsStatusFading(false);
|
||||
setSaveStatus('sent');
|
||||
statusFadeTimerRef.current = window.setTimeout(
|
||||
() => setIsStatusFading(true),
|
||||
1800
|
||||
);
|
||||
statusClearTimerRef.current = window.setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
setIsStatusFading(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
// Get the profile from the attempt data
|
||||
const selectedProfile = selectedAttemptProfile;
|
||||
@@ -154,6 +231,347 @@ export function TaskFollowUpSection({
|
||||
setSelectedVariant(defaultFollowUpVariant);
|
||||
}, [defaultFollowUpVariant]);
|
||||
|
||||
// Subscribe to follow-up draft SSE stream for this attempt
|
||||
type DraftStreamState = { follow_up_draft: FollowUpDraft };
|
||||
const draftStreamEndpoint = selectedAttemptId
|
||||
? `/api/task-attempts/${selectedAttemptId}/follow-up-draft/stream`
|
||||
: undefined;
|
||||
const makeInitialDraftData = useCallback(
|
||||
() => ({
|
||||
follow_up_draft: {
|
||||
id: '',
|
||||
task_attempt_id: selectedAttemptId || '',
|
||||
prompt: '',
|
||||
queued: false,
|
||||
sending: false,
|
||||
variant: null,
|
||||
image_ids: [],
|
||||
// version used only for local comparison; server will patch real value
|
||||
version: 0 as unknown,
|
||||
created_at: new Date().toISOString() as unknown,
|
||||
updated_at: new Date().toISOString() as unknown,
|
||||
} as any,
|
||||
}),
|
||||
[selectedAttemptId]
|
||||
);
|
||||
|
||||
const {
|
||||
data: draftStream,
|
||||
isConnected: draftStreamConnected,
|
||||
error: draftStreamError,
|
||||
} = useJsonPatchStream<DraftStreamState>(
|
||||
draftStreamEndpoint,
|
||||
!!draftStreamEndpoint,
|
||||
makeInitialDraftData
|
||||
);
|
||||
|
||||
// One-shot hydration via REST to avoid waiting on SSE, and to handle environments
|
||||
// where SSE connects but initial event is delayed or blocked.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const hydrateOnce = async () => {
|
||||
if (!selectedAttemptId) return;
|
||||
try {
|
||||
const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
|
||||
if (cancelled) return;
|
||||
suppressNextSaveRef.current = true;
|
||||
const incomingVersion = draft?.version
|
||||
? Number(draft.version as unknown)
|
||||
: 0;
|
||||
lastServerVersionRef.current = incomingVersion;
|
||||
setFollowUpMessage(draft.prompt || '');
|
||||
setIsQueued(!!draft.queued);
|
||||
if (draft.variant !== undefined && draft.variant !== null)
|
||||
setSelectedVariant(draft.variant);
|
||||
// Load images if present
|
||||
if (draft.image_ids && draft.image_ids.length > 0) {
|
||||
const all = await imagesApi.getTaskImages(task.id);
|
||||
if (cancelled) return;
|
||||
const wantIds = new Set(draft.image_ids);
|
||||
setImages(all.filter((img) => wantIds.has(img.id)));
|
||||
} else {
|
||||
setImages([]);
|
||||
}
|
||||
if (!isDraftReady) setIsDraftReady(true);
|
||||
} catch {
|
||||
// ignore, rely on SSE/poll fallback
|
||||
}
|
||||
};
|
||||
hydrateOnce();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedAttemptId]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (statusFadeTimerRef.current)
|
||||
window.clearTimeout(statusFadeTimerRef.current);
|
||||
if (statusClearTimerRef.current)
|
||||
window.clearTimeout(statusClearTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftStream) return;
|
||||
const d = draftStream.follow_up_draft as FollowUpDraft;
|
||||
// Ignore synthetic initial placeholder until real SSE snapshot arrives
|
||||
if ((d as any).id === '') {
|
||||
return;
|
||||
}
|
||||
const incomingVersion = d?.version ? Number(d.version as unknown) : 0;
|
||||
|
||||
// Always reflect queued/sending flags immediately
|
||||
setIsQueued(!!d.queued);
|
||||
const sendingNow = !!(d as any).sending;
|
||||
setIsDraftSending(sendingNow);
|
||||
|
||||
// If server indicates we're sending, ensure the editor is cleared for clarity.
|
||||
if (sendingNow) {
|
||||
// Edge trigger: show Sent pill once
|
||||
if (!prevSendingRef.current) {
|
||||
scheduleSentStatus();
|
||||
}
|
||||
// Show a quick fade-out of the prior content while clearing the actual textarea value
|
||||
if (followUpMessage !== '') {
|
||||
if (overlayFadeTimerRef.current)
|
||||
window.clearTimeout(overlayFadeTimerRef.current);
|
||||
if (overlayHideTimerRef.current)
|
||||
window.clearTimeout(overlayHideTimerRef.current);
|
||||
// Lock container height to avoid jump while autosize recomputes
|
||||
if (wrapperRef.current) {
|
||||
const h = wrapperRef.current.getBoundingClientRect().height;
|
||||
setLockedMinHeight(h);
|
||||
}
|
||||
setFadeOverlayText(followUpMessage);
|
||||
setShowFadeOverlay(true);
|
||||
// Start fully visible
|
||||
setOverlayFadeClass('opacity-100');
|
||||
// Clear textarea immediately under the overlay
|
||||
setFollowUpMessage('');
|
||||
// Trigger fast fade on next tick (no motion), then remove overlay shortly after
|
||||
overlayFadeTimerRef.current = window.setTimeout(
|
||||
() => setOverlayFadeClass('opacity-0'),
|
||||
20
|
||||
);
|
||||
overlayHideTimerRef.current = window.setTimeout(() => {
|
||||
setShowFadeOverlay(false);
|
||||
setFadeOverlayText('');
|
||||
setOverlayFadeClass('');
|
||||
// Release height lock shortly after fade completes
|
||||
setLockedMinHeight(null);
|
||||
}, 180);
|
||||
}
|
||||
if (images.length > 0) setImages([]);
|
||||
if (newlyUploadedImageIds.length > 0) setNewlyUploadedImageIds([]);
|
||||
if (showImageUpload) setShowImageUpload(false);
|
||||
}
|
||||
prevSendingRef.current = sendingNow;
|
||||
|
||||
// Skip if this is a duplicate of what we already processed
|
||||
if (incomingVersion === lastServerVersionRef.current) {
|
||||
if (!isDraftReady) setIsDraftReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that next local change shouldn't auto-save (we're syncing from server)
|
||||
suppressNextSaveRef.current = true;
|
||||
|
||||
// Initial hydration: avoid clobbering locally typed text with empty server prompt
|
||||
if (lastServerVersionRef.current === -1) {
|
||||
if (!localDirtyRef.current && !sendingNow) {
|
||||
setFollowUpMessage(d.prompt || '');
|
||||
}
|
||||
if (d.variant !== undefined) setSelectedVariant(d.variant);
|
||||
lastServerVersionRef.current = incomingVersion;
|
||||
}
|
||||
|
||||
// Real server-side change: adopt new prompt/variant
|
||||
if (incomingVersion > lastServerVersionRef.current) {
|
||||
// If sending, keep the editor clear regardless of server prompt value
|
||||
setFollowUpMessage(sendingNow ? '' : d.prompt || '');
|
||||
if (d.variant !== undefined) setSelectedVariant(d.variant);
|
||||
localDirtyRef.current = false;
|
||||
lastServerVersionRef.current = incomingVersion;
|
||||
}
|
||||
if (!d.image_ids || d.image_ids.length === 0) {
|
||||
setImages([]);
|
||||
setNewlyUploadedImageIds([]);
|
||||
setShowImageUpload(false);
|
||||
} else {
|
||||
// Load attached images for this draft by IDs
|
||||
const wantIds = new Set(d.image_ids);
|
||||
const haveIds = new Set(images.map((img) => img.id));
|
||||
let mismatch = false;
|
||||
if (images.length !== wantIds.size) mismatch = true;
|
||||
else
|
||||
for (const id of wantIds)
|
||||
if (!haveIds.has(id)) {
|
||||
mismatch = true;
|
||||
break;
|
||||
}
|
||||
if (mismatch) {
|
||||
imagesApi
|
||||
.getTaskImages(task.id)
|
||||
.then((all) => {
|
||||
setImages(all.filter((img) => wantIds.has(img.id)));
|
||||
setNewlyUploadedImageIds([]);
|
||||
})
|
||||
.catch(() => void 0);
|
||||
}
|
||||
}
|
||||
if (!isDraftReady) setIsDraftReady(true);
|
||||
}, [draftStream]);
|
||||
|
||||
// Cleanup overlay timers
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (overlayFadeTimerRef.current)
|
||||
window.clearTimeout(overlayFadeTimerRef.current);
|
||||
if (overlayHideTimerRef.current)
|
||||
window.clearTimeout(overlayHideTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fallback: if running inside VSCode iframe and SSE isn't connected, poll the draft endpoint to keep UI in sync
|
||||
const pollTimerRef = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!selectedAttemptId) return;
|
||||
const shouldPoll =
|
||||
inIframe() && (!draftStreamConnected || !!draftStreamError);
|
||||
if (!shouldPoll) {
|
||||
if (pollTimerRef.current) window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
|
||||
// Update immediate state, similar to SSE handler
|
||||
setIsQueued(!!draft.queued);
|
||||
// Polling response does not include 'sending'; preserve previous sending state
|
||||
const incomingVersion = draft?.version
|
||||
? Number(draft.version as unknown)
|
||||
: 0;
|
||||
if (incomingVersion !== lastServerVersionRef.current) {
|
||||
suppressNextSaveRef.current = true;
|
||||
setFollowUpMessage(draft.prompt || '');
|
||||
if (draft.variant !== undefined && draft.variant !== null)
|
||||
setSelectedVariant(draft.variant);
|
||||
lastServerVersionRef.current = incomingVersion;
|
||||
// images not included in response type for polling; leave as-is
|
||||
}
|
||||
if (!isDraftReady) setIsDraftReady(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
// Prime once, then interval
|
||||
pollOnce();
|
||||
pollTimerRef.current = window.setInterval(pollOnce, 1000);
|
||||
return () => {
|
||||
if (pollTimerRef.current) window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = undefined;
|
||||
};
|
||||
}, [selectedAttemptId, draftStreamConnected, draftStreamError]);
|
||||
|
||||
// Debounced persist draft on message or variant change (only while not queued)
|
||||
useEffect(() => {
|
||||
if (!selectedAttemptId) return;
|
||||
// skip saving if currently sending follow-up; it will be cleared on success
|
||||
if (isSendingFollowUp) return;
|
||||
// also skip while server is sending a queued draft
|
||||
if (isDraftSending) return;
|
||||
// skip saving while queue/unqueue transitions are in-flight
|
||||
if (isQueuing || isUnqueuing) return;
|
||||
if (suppressNextSaveRef.current) {
|
||||
suppressNextSaveRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Only save when not queued (edit mode)
|
||||
if (isQueued) return;
|
||||
|
||||
const saveDraft = async () => {
|
||||
const d = draftStream?.follow_up_draft;
|
||||
const payload: any = {} as UpdateFollowUpDraftRequest;
|
||||
// prompt change
|
||||
if (d && followUpMessage !== (d.prompt || '')) {
|
||||
payload.prompt = followUpMessage;
|
||||
}
|
||||
// variant change (string | null)
|
||||
if ((d?.variant ?? null) !== (selectedVariant ?? null)) {
|
||||
payload.variant = selectedVariant as any; // may be null
|
||||
}
|
||||
// images change (compare ids)
|
||||
const currentIds = images.map((img) => img.id);
|
||||
const serverIds = (d?.image_ids as string[] | undefined) ?? [];
|
||||
const idsEqual =
|
||||
currentIds.length === serverIds.length &&
|
||||
currentIds.every((id, i) => id === serverIds[i]);
|
||||
if (!idsEqual) {
|
||||
payload.image_ids = currentIds;
|
||||
}
|
||||
|
||||
// If no field changed, skip network
|
||||
const keys = Object.keys(payload).filter((k) => k !== 'version');
|
||||
if (keys.length === 0) return;
|
||||
const payloadKey = JSON.stringify(payload);
|
||||
if (payloadKey === lastSentRef.current) return;
|
||||
lastSentRef.current = payloadKey;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setSaveStatus(navigator.onLine ? 'saving' : 'offline');
|
||||
await attemptsApi.saveFollowUpDraft(selectedAttemptId, payload);
|
||||
// pleasant linger + fade-out
|
||||
scheduleSavedStatus();
|
||||
} catch (e: unknown) {
|
||||
// On conflict or error, silently adopt server state
|
||||
try {
|
||||
const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
|
||||
suppressNextSaveRef.current = true;
|
||||
setFollowUpMessage(draft.prompt || '');
|
||||
setIsQueued(!!draft.queued);
|
||||
if (draft.variant !== undefined && draft.variant !== null) {
|
||||
setSelectedVariant(draft.variant);
|
||||
}
|
||||
if (draft.version !== undefined && draft.version !== null) {
|
||||
lastServerVersionRef.current = Number(draft.version as unknown);
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
setSaveStatus(navigator.onLine ? 'idle' : 'offline');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// debounce 400ms
|
||||
if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = (
|
||||
window.setTimeout as unknown as (
|
||||
handler: () => void,
|
||||
timeout?: number
|
||||
) => number
|
||||
)(saveDraft, 400);
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
|
||||
};
|
||||
}, [
|
||||
followUpMessage,
|
||||
selectedVariant,
|
||||
isQueued,
|
||||
selectedAttemptId,
|
||||
isSendingFollowUp,
|
||||
isQueuing,
|
||||
isUnqueuing,
|
||||
]);
|
||||
|
||||
// Remove BroadcastChannel — SSE is authoritative
|
||||
|
||||
// (removed duplicate SSE subscription block)
|
||||
|
||||
const handleImageUploaded = useCallback((image: ImageResponse) => {
|
||||
const markdownText = ``;
|
||||
setFollowUpMessage((prev) => {
|
||||
@@ -204,7 +622,9 @@ export function TaskFollowUpSection({
|
||||
image_ids: imageIds,
|
||||
});
|
||||
setFollowUpMessage('');
|
||||
clearComments(); // Clear review comments after successful submission
|
||||
// Clear review comments and reset queue state after successful submission
|
||||
clearComments();
|
||||
setIsQueued(false);
|
||||
// Clear images and newly uploaded IDs after successful submission
|
||||
setImages([]);
|
||||
setNewlyUploadedImageIds([]);
|
||||
@@ -219,6 +639,100 @@ export function TaskFollowUpSection({
|
||||
}
|
||||
};
|
||||
|
||||
// Derived UI lock: disallow edits/actions while queued or transitioning
|
||||
const isDraftLocked = isQueued || isQueuing || isUnqueuing || isDraftSending;
|
||||
const isInputDisabled = isDraftLocked || !isDraftReady;
|
||||
|
||||
// Queue handler: ensure draft is persisted immediately, then toggle queued
|
||||
const onQueue = async () => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (isQueuing || isQueued) return;
|
||||
const hasContent = followUpMessage.trim().length > 0;
|
||||
if (!hasContent) return;
|
||||
try {
|
||||
// Prevent any pending debounced save from racing
|
||||
if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
|
||||
suppressNextSaveRef.current = true;
|
||||
setIsQueuing(true);
|
||||
// Optimistically reflect queued state to block edits/buttons immediately
|
||||
setIsQueued(true);
|
||||
setIsSaving(true);
|
||||
setSaveStatus(navigator.onLine ? 'saving' : 'offline');
|
||||
// 1) Force-save current draft so the row exists and is up to date (no version to avoid conflicts)
|
||||
const immediatePayload: any = {
|
||||
// Do NOT send version here to avoid spurious 409; we'll use the returned version for queueing
|
||||
prompt: followUpMessage,
|
||||
} as UpdateFollowUpDraftRequest;
|
||||
if (
|
||||
(draftStream?.follow_up_draft?.variant ?? null) !==
|
||||
(selectedVariant ?? null)
|
||||
) {
|
||||
immediatePayload.variant = selectedVariant as any;
|
||||
}
|
||||
const currentIds = images.map((img) => img.id);
|
||||
const serverIds =
|
||||
(draftStream?.follow_up_draft?.image_ids as string[] | undefined) ?? [];
|
||||
const idsEqual =
|
||||
currentIds.length === serverIds.length &&
|
||||
currentIds.every((id, i) => id === serverIds[i]);
|
||||
if (!idsEqual) {
|
||||
immediatePayload.image_ids = currentIds;
|
||||
}
|
||||
await attemptsApi.saveFollowUpDraft(selectedAttemptId, immediatePayload);
|
||||
|
||||
// 2) Queue with optimistic concurrency using latest version from save
|
||||
try {
|
||||
const resp = await attemptsApi.setFollowUpQueue(
|
||||
selectedAttemptId,
|
||||
true
|
||||
);
|
||||
// Immediate local sync to avoid waiting for SSE
|
||||
if (resp?.version !== undefined) {
|
||||
lastServerVersionRef.current = Number(resp.version as unknown);
|
||||
}
|
||||
setIsQueued(!!resp.queued);
|
||||
if (resp.variant !== undefined && resp.variant !== null) {
|
||||
setSelectedVariant(resp.variant);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// On any error, silently adopt server state
|
||||
const latest = await attemptsApi.getFollowUpDraft(selectedAttemptId);
|
||||
suppressNextSaveRef.current = true;
|
||||
if (latest.version !== undefined && latest.version !== null) {
|
||||
lastServerVersionRef.current = Number(latest.version as unknown);
|
||||
}
|
||||
setIsQueued(!!latest.queued);
|
||||
if (latest.variant !== undefined && latest.variant !== null) {
|
||||
setSelectedVariant(latest.variant);
|
||||
}
|
||||
}
|
||||
// Do not show "Saved" for queue; right side shows Queued; a "Sent" pill will appear when sending starts
|
||||
setSaveStatus('idle');
|
||||
} catch (e: unknown) {
|
||||
// On any error, hard refresh to server truth
|
||||
try {
|
||||
const draft = await attemptsApi.getFollowUpDraft(selectedAttemptId);
|
||||
suppressNextSaveRef.current = true;
|
||||
setFollowUpMessage(draft.prompt || '');
|
||||
setIsQueued(!!draft.queued);
|
||||
if (draft.variant !== undefined && draft.variant !== null) {
|
||||
setSelectedVariant(draft.variant);
|
||||
}
|
||||
if (draft.version !== undefined && draft.version !== null) {
|
||||
lastServerVersionRef.current = Number(draft.version as unknown);
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
setSaveStatus(navigator.onLine ? 'idle' : 'offline');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsQueuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// (Removed) auto-unqueue logic — editing is explicit and guarded by a lock now
|
||||
|
||||
return (
|
||||
selectedAttemptId && (
|
||||
<div className="border-t p-4 focus-within:ring ring-inset">
|
||||
@@ -238,7 +752,7 @@ export function TaskFollowUpSection({
|
||||
onUpload={imagesApi.upload}
|
||||
onDelete={imagesApi.delete}
|
||||
onImageUploaded={handleImageUploaded}
|
||||
disabled={!canSendFollowUp}
|
||||
disabled={!canSendFollowUp || isDraftLocked || !isDraftReady}
|
||||
collapsible={false}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
@@ -253,16 +767,27 @@ export function TaskFollowUpSection({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative"
|
||||
style={
|
||||
lockedMinHeight
|
||||
? ({ minHeight: lockedMinHeight } as any)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FileSearchTextarea
|
||||
placeholder={
|
||||
reviewMarkdown
|
||||
? '(Optional) Add additional instructions... Type @ to search files.'
|
||||
: 'Continue working on this task attempt... Type @ to search files.'
|
||||
isQueued
|
||||
? 'Type your follow-up… It will auto-send when ready.'
|
||||
: reviewMarkdown
|
||||
? '(Optional) Add additional instructions... Type @ to search files.'
|
||||
: 'Continue working on this task attempt... Type @ to search files.'
|
||||
}
|
||||
value={followUpMessage}
|
||||
onChange={(value) => {
|
||||
setFollowUpMessage(value);
|
||||
localDirtyRef.current = true;
|
||||
if (followUpError) setFollowUpError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -271,23 +796,110 @@ export function TaskFollowUpSection({
|
||||
if (canSendFollowUp && !isSendingFollowUp) {
|
||||
onSendFollowUp();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// Clear input and auto-cancel queue
|
||||
e.preventDefault();
|
||||
setFollowUpMessage('');
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-h-[40px] resize-none"
|
||||
disabled={!canTypeFollowUp}
|
||||
className={cn(
|
||||
'flex-1 min-h-[40px] resize-none',
|
||||
showFadeOverlay && 'placeholder-transparent'
|
||||
)}
|
||||
// Edits are disallowed while queued or in transition
|
||||
disabled={isInputDisabled}
|
||||
projectId={projectId}
|
||||
rows={1}
|
||||
maxRows={6}
|
||||
/>
|
||||
{showFadeOverlay && fadeOverlayText && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none select-none absolute inset-0 px-3 py-2 text-sm whitespace-pre-wrap text-foreground/70 transition-opacity duration-150 ease-out z-10',
|
||||
overlayFadeClass
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{fadeOverlayText}
|
||||
</div>
|
||||
)}
|
||||
{(isUnqueuing || !isDraftReady) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/60 z-20">
|
||||
<Loader2 className="animate-spin h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
{/* Status row: reserved space above action buttons to avoid layout shift */}
|
||||
<div className="flex items-center justify-between text-xs min-h-6 h-6 px-0.5">
|
||||
{/* Left side: save state or conflicts */}
|
||||
<div className="text-muted-foreground">
|
||||
{saveStatus === 'saving' ? (
|
||||
<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',
|
||||
isSaving && 'italic'
|
||||
)}
|
||||
>
|
||||
<Loader2 className="animate-spin h-3 w-3" /> Saving…
|
||||
</span>
|
||||
) : saveStatus === '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>
|
||||
) : saveStatus === 'saved' ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted text-emerald-700 transition-opacity duration-200 animate-in fade-in-0',
|
||||
isStatusFading && 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" /> Saved
|
||||
</span>
|
||||
) : saveStatus === 'sent' ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 bg-muted text-emerald-700 transition-opacity duration-200 animate-in fade-in-0',
|
||||
isStatusFading && 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<Send className="h-3 w-3" /> Sent
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Right side: queued/sending status */}
|
||||
<div className="text-muted-foreground">
|
||||
{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>
|
||||
) : !isDraftReady ? (
|
||||
<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>
|
||||
) : isDraftSending ? (
|
||||
<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>
|
||||
) : 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>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-1 flex gap-2">
|
||||
{/* Image button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowImageUpload(!showImageUpload)}
|
||||
disabled={!canSendFollowUp}
|
||||
disabled={
|
||||
!canSendFollowUp || isDraftLocked || !isDraftReady
|
||||
}
|
||||
>
|
||||
<ImageIcon
|
||||
className={cn(
|
||||
@@ -314,6 +926,7 @@ export function TaskFollowUpSection({
|
||||
'w-24 px-2 flex items-center justify-between transition-all',
|
||||
isAnimating && 'scale-105 bg-accent'
|
||||
)}
|
||||
disabled={isDraftLocked || !isDraftReady}
|
||||
>
|
||||
<span className="text-xs truncate flex-1 text-left">
|
||||
{selectedVariant || 'DEFAULT'}
|
||||
@@ -361,40 +974,48 @@ export function TaskFollowUpSection({
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{comments.length > 0 && (
|
||||
<Button
|
||||
onClick={clearComments}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Clear Review Comments
|
||||
</Button>
|
||||
)}
|
||||
{isAttemptRunning ? (
|
||||
<Button
|
||||
onClick={stopExecution}
|
||||
disabled={isStopping}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{isStopping ? (
|
||||
<Loader size={16} className="mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<StopCircle className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
{/* (removed) old inline notices now replaced by the status row above */}
|
||||
|
||||
{isAttemptRunning ? (
|
||||
<Button
|
||||
onClick={stopExecution}
|
||||
disabled={isStopping}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{isStopping ? (
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<StopCircle className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{comments.length > 0 && (
|
||||
<Button
|
||||
onClick={clearComments}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Clear Review Comments
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSendFollowUp}
|
||||
disabled={!canSendFollowUp || isSendingFollowUp}
|
||||
disabled={
|
||||
!canSendFollowUp ||
|
||||
isDraftLocked ||
|
||||
!isDraftReady ||
|
||||
!followUpMessage.trim() ||
|
||||
isSendingFollowUp
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{isSendingFollowUp ? (
|
||||
<Loader size={16} className="mr-2" />
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
@@ -402,8 +1023,162 @@ export function TaskFollowUpSection({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isQueued && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="min-w-[180px] transition-all"
|
||||
onClick={async () => {
|
||||
if (!selectedAttemptId) return;
|
||||
try {
|
||||
if (saveTimeoutRef.current)
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
suppressNextSaveRef.current = true;
|
||||
setIsUnqueuing(true);
|
||||
try {
|
||||
const resp = await attemptsApi.setFollowUpQueue(
|
||||
selectedAttemptId,
|
||||
false
|
||||
);
|
||||
if (resp?.version !== undefined) {
|
||||
lastServerVersionRef.current = Number(
|
||||
resp.version as unknown
|
||||
);
|
||||
}
|
||||
setIsQueued(!!resp.queued);
|
||||
} catch (err: unknown) {
|
||||
// On any error (including 409), hard refresh and adopt server state
|
||||
const latest =
|
||||
await attemptsApi.getFollowUpDraft(
|
||||
selectedAttemptId
|
||||
);
|
||||
suppressNextSaveRef.current = true;
|
||||
setFollowUpMessage(latest.prompt || '');
|
||||
setIsQueued(!!latest.queued);
|
||||
if (
|
||||
latest.variant !== undefined &&
|
||||
latest.variant !== null
|
||||
) {
|
||||
setSelectedVariant(latest.variant);
|
||||
}
|
||||
if (
|
||||
latest.version !== undefined &&
|
||||
latest.version !== null
|
||||
) {
|
||||
lastServerVersionRef.current = Number(
|
||||
latest.version as unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to unqueue for editing', e);
|
||||
} finally {
|
||||
setIsUnqueuing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isUnqueuing}
|
||||
>
|
||||
{isUnqueuing ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
||||
Unqueuing…
|
||||
</>
|
||||
) : (
|
||||
'Edit'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isAttemptRunning && (
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!selectedAttemptId) return;
|
||||
if (isQueued) {
|
||||
try {
|
||||
if (saveTimeoutRef.current)
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
suppressNextSaveRef.current = true;
|
||||
setIsUnqueuing(true);
|
||||
try {
|
||||
const resp = await attemptsApi.setFollowUpQueue(
|
||||
selectedAttemptId,
|
||||
false
|
||||
);
|
||||
if (resp?.version !== undefined) {
|
||||
lastServerVersionRef.current = Number(
|
||||
resp.version as unknown
|
||||
);
|
||||
}
|
||||
setIsQueued(!!resp.queued);
|
||||
} catch (err: unknown) {
|
||||
// On any error (including 409), hard refresh and adopt server state
|
||||
const latest =
|
||||
await attemptsApi.getFollowUpDraft(
|
||||
selectedAttemptId
|
||||
);
|
||||
suppressNextSaveRef.current = true;
|
||||
setFollowUpMessage(latest.prompt || '');
|
||||
setIsQueued(!!latest.queued);
|
||||
if (
|
||||
latest.variant !== undefined &&
|
||||
latest.variant !== null
|
||||
) {
|
||||
setSelectedVariant(latest.variant);
|
||||
}
|
||||
if (
|
||||
latest.version !== undefined &&
|
||||
latest.version !== null
|
||||
) {
|
||||
lastServerVersionRef.current = Number(
|
||||
latest.version as unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to unqueue for editing', e);
|
||||
} finally {
|
||||
setIsUnqueuing(false);
|
||||
}
|
||||
} else {
|
||||
await onQueue();
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isQueued
|
||||
? isUnqueuing
|
||||
: !canSendFollowUp ||
|
||||
!isDraftReady ||
|
||||
!followUpMessage.trim() ||
|
||||
isQueuing ||
|
||||
isUnqueuing ||
|
||||
isDraftSending
|
||||
}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="min-w-[180px] transition-all"
|
||||
>
|
||||
{isQueued ? (
|
||||
isUnqueuing ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
||||
Unqueuing…
|
||||
</>
|
||||
) : (
|
||||
'Edit'
|
||||
)
|
||||
) : isQueuing ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
||||
Queuing…
|
||||
</>
|
||||
) : (
|
||||
'Queue for next turn'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,20 @@ export const useJsonPatchStream = <T>(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const dataRef = useRef<T | undefined>(undefined);
|
||||
const retryTimerRef = useRef<number | null>(null);
|
||||
const retryAttemptsRef = useRef<number>(0);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (retryTimerRef.current) return; // already scheduled
|
||||
// Exponential backoff with cap: 1s, 2s, 4s, 8s (max), then stay at 8s
|
||||
const attempt = retryAttemptsRef.current;
|
||||
const delay = Math.min(8000, 1000 * Math.pow(2, attempt));
|
||||
retryTimerRef.current = window.setTimeout(() => {
|
||||
retryTimerRef.current = null;
|
||||
setRetryNonce((n) => n + 1);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !endpoint) {
|
||||
@@ -41,6 +55,11 @@ export const useJsonPatchStream = <T>(
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (retryTimerRef.current) {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
retryAttemptsRef.current = 0;
|
||||
setData(undefined);
|
||||
setIsConnected(false);
|
||||
setError(null);
|
||||
@@ -67,6 +86,12 @@ export const useJsonPatchStream = <T>(
|
||||
eventSource.onopen = () => {
|
||||
setError(null);
|
||||
setIsConnected(true);
|
||||
// Reset backoff on successful connection
|
||||
retryAttemptsRef.current = 0;
|
||||
if (retryTimerRef.current) {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener('json_patch', (event) => {
|
||||
@@ -96,12 +121,23 @@ export const useJsonPatchStream = <T>(
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsConnected(false);
|
||||
// Treat finished as terminal and schedule reconnect; servers may rotate
|
||||
retryAttemptsRef.current += 1;
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setError('Connection failed');
|
||||
// Close and schedule reconnect
|
||||
try {
|
||||
eventSource.close();
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
eventSourceRef.current = null;
|
||||
setIsConnected(false);
|
||||
retryAttemptsRef.current += 1;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
eventSourceRef.current = eventSource;
|
||||
@@ -112,6 +148,10 @@ export const useJsonPatchStream = <T>(
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (retryTimerRef.current) {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
dataRef.current = undefined;
|
||||
setData(undefined);
|
||||
};
|
||||
@@ -121,6 +161,7 @@ export const useJsonPatchStream = <T>(
|
||||
initialData,
|
||||
options.injectInitialEntry,
|
||||
options.deduplicatePatches,
|
||||
retryNonce,
|
||||
]);
|
||||
|
||||
return { data, isConnected, error };
|
||||
|
||||
@@ -38,10 +38,16 @@ import {
|
||||
ImageResponse,
|
||||
RestoreAttemptRequest,
|
||||
RestoreAttemptResult,
|
||||
FollowUpDraftResponse,
|
||||
UpdateFollowUpDraftRequest,
|
||||
} from 'shared/types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { RepositoryInfo } from 'shared/types';
|
||||
export type {
|
||||
FollowUpDraftResponse,
|
||||
UpdateFollowUpDraftRequest,
|
||||
} from 'shared/types';
|
||||
|
||||
export class ApiError<E = unknown> extends Error {
|
||||
public status?: number;
|
||||
@@ -358,6 +364,50 @@ export const attemptsApi = {
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
getFollowUpDraft: async (
|
||||
attemptId: string
|
||||
): Promise<FollowUpDraftResponse> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/follow-up-draft`
|
||||
);
|
||||
return handleApiResponse<FollowUpDraftResponse>(response);
|
||||
},
|
||||
|
||||
saveFollowUpDraft: async (
|
||||
attemptId: string,
|
||||
data: UpdateFollowUpDraftRequest
|
||||
): Promise<FollowUpDraftResponse> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/follow-up-draft`,
|
||||
{
|
||||
// Server expects PUT for saving/updating the draft
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
return handleApiResponse<FollowUpDraftResponse>(response);
|
||||
},
|
||||
|
||||
setFollowUpQueue: async (
|
||||
attemptId: string,
|
||||
queued: boolean,
|
||||
expectedQueued?: boolean,
|
||||
expectedVersion?: number
|
||||
): Promise<FollowUpDraftResponse> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/follow-up-draft/queue`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
queued,
|
||||
expected_queued: expectedQueued,
|
||||
expected_version: expectedVersion,
|
||||
}),
|
||||
}
|
||||
);
|
||||
return handleApiResponse<FollowUpDraftResponse>(response);
|
||||
},
|
||||
|
||||
deleteFile: async (
|
||||
attemptId: string,
|
||||
fileToDelete: string
|
||||
|
||||
@@ -173,7 +173,7 @@ export function useKanbanKeyboardNavigation({
|
||||
focusedStatus: string | null;
|
||||
setFocusedStatus: (status: string | null) => void;
|
||||
groupedTasks: Record<string, any[]>;
|
||||
filteredTasks: any[];
|
||||
filteredTasks: unknown[];
|
||||
allTaskStatuses: string[];
|
||||
onViewTaskDetails?: (task: any) => void;
|
||||
preserveIndexOnColumnSwitch?: boolean;
|
||||
|
||||
@@ -67,12 +67,12 @@ export function AgentSettings() {
|
||||
}, [serverProfilesContent, serverParsedProfiles, isDirty]);
|
||||
|
||||
// Sync raw profiles with parsed profiles
|
||||
const syncRawProfiles = (profiles: any) => {
|
||||
const syncRawProfiles = (profiles: unknown) => {
|
||||
setLocalProfilesContent(JSON.stringify(profiles, null, 2));
|
||||
};
|
||||
|
||||
// Mark profiles as dirty
|
||||
const markDirty = (nextProfiles: any) => {
|
||||
const markDirty = (nextProfiles: unknown) => {
|
||||
setLocalParsedProfiles(nextProfiles);
|
||||
syncRawProfiles(nextProfiles);
|
||||
setIsDirty(true);
|
||||
@@ -216,7 +216,7 @@ export function AgentSettings() {
|
||||
// Show success
|
||||
setProfilesSuccess(true);
|
||||
setTimeout(() => setProfilesSuccess(false), 3000);
|
||||
} catch (saveError: any) {
|
||||
} catch (saveError: unknown) {
|
||||
console.error('Failed to save deletion to backend:', saveError);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -256,7 +256,7 @@ export function AgentSettings() {
|
||||
if (useFormEditor && localParsedProfiles) {
|
||||
setLocalProfilesContent(contentToSave);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save profiles:', err);
|
||||
}
|
||||
};
|
||||
@@ -264,7 +264,7 @@ export function AgentSettings() {
|
||||
const handleExecutorConfigChange = (
|
||||
executorType: string,
|
||||
configuration: string,
|
||||
formData: any
|
||||
formData: unknown
|
||||
) => {
|
||||
if (!localParsedProfiles || !localParsedProfiles.executors) return;
|
||||
|
||||
@@ -285,7 +285,7 @@ export function AgentSettings() {
|
||||
markDirty(updatedProfiles);
|
||||
};
|
||||
|
||||
const handleExecutorConfigSave = async (formData: any) => {
|
||||
const handleExecutorConfigSave = async (formData: unknown) => {
|
||||
if (!localParsedProfiles || !localParsedProfiles.executors) return;
|
||||
|
||||
// Update the parsed profiles with the saved config
|
||||
@@ -316,7 +316,7 @@ export function AgentSettings() {
|
||||
|
||||
// Update the local content as well
|
||||
setLocalProfilesContent(contentToSave);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save profiles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user