Consolidate Retry and Follow-up (#800)
This commit is contained in:
@@ -30,6 +30,7 @@ import RawLogText from '../common/RawLogText';
|
||||
import UserMessage from './UserMessage';
|
||||
import PendingApprovalEntry from './PendingApprovalEntry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRetryUi } from '@/contexts/RetryUiContext';
|
||||
|
||||
type Props = {
|
||||
entry: NormalizedEntry | ProcessStartPayload;
|
||||
@@ -612,14 +613,19 @@ function DisplayConversationEntry({
|
||||
entry: NormalizedEntry | ProcessStartPayload
|
||||
): entry is ProcessStartPayload => 'processId' in entry;
|
||||
|
||||
const { isProcessGreyed } = useRetryUi();
|
||||
const greyed = isProcessGreyed(executionProcessId);
|
||||
|
||||
if (isProcessStart(entry)) {
|
||||
const toolAction: any = entry.action ?? null;
|
||||
return (
|
||||
<ToolCallCard
|
||||
action={toolAction}
|
||||
expansionKey={expansionKey}
|
||||
content={toolAction?.message ?? toolAction?.summary ?? undefined}
|
||||
/>
|
||||
<div className={greyed ? 'opacity-50 pointer-events-none' : undefined}>
|
||||
<ToolCallCard
|
||||
action={toolAction}
|
||||
expansionKey={expansionKey}
|
||||
content={toolAction?.message ?? toolAction?.summary ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,7 +650,6 @@ function DisplayConversationEntry({
|
||||
}
|
||||
const renderToolUse = () => {
|
||||
if (!isNormalizedEntry(entry)) return null;
|
||||
|
||||
if (entryType.type !== 'tool_use') return null;
|
||||
const toolEntry = entryType;
|
||||
|
||||
@@ -698,7 +703,13 @@ function DisplayConversationEntry({
|
||||
);
|
||||
})();
|
||||
|
||||
const content = <div className="px-4 py-2 text-sm space-y-3">{body}</div>;
|
||||
const content = (
|
||||
<div
|
||||
className={`px-4 py-2 text-sm space-y-3 ${greyed ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPendingApprovalStatus(status)) {
|
||||
return (
|
||||
@@ -720,7 +731,9 @@ function DisplayConversationEntry({
|
||||
|
||||
if (isSystem || isError) {
|
||||
return (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
<div
|
||||
className={`px-4 py-2 text-sm ${greyed ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<CollapsibleEntry
|
||||
content={isNormalizedEntry(entry) ? entry.content : ''}
|
||||
markdown={shouldRenderMarkdown(entryType)}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FollowUpEditorCard } from '@/components/tasks/follow-up/FollowUpEditorCard';
|
||||
import { FollowUpStatusRow } from '@/components/tasks/FollowUpStatusRow';
|
||||
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { VariantSelector } from '@/components/tasks/VariantSelector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle, Image as ImageIcon, Send, X } from 'lucide-react';
|
||||
import { useDraftEditor } from '@/hooks/follow-up/useDraftEditor';
|
||||
import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
|
||||
import { useDraftAutosave } from '@/hooks/follow-up/useDraftAutosave';
|
||||
import {
|
||||
attemptsApi,
|
||||
imagesApi,
|
||||
executionProcessesApi,
|
||||
commitsApi,
|
||||
} from '@/lib/api';
|
||||
import type { DraftResponse, TaskAttempt } from 'shared/types';
|
||||
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
||||
import { useUserSystem } from '@/components/config-provider';
|
||||
import { useBranchStatus } from '@/hooks/useBranchStatus';
|
||||
import { showModal } from '@/lib/modals';
|
||||
import {
|
||||
shouldShowInLogs,
|
||||
isCodingAgent,
|
||||
PROCESS_RUN_REASONS,
|
||||
} from '@/constants/processes';
|
||||
import { appendImageMarkdown } from '@/utils/markdownImages';
|
||||
import type { RestoreLogsDialogResult } from '@/components/dialogs';
|
||||
|
||||
export function RetryEditorInline({
|
||||
attempt,
|
||||
executionProcessId,
|
||||
initialVariant,
|
||||
onCancelled,
|
||||
}: {
|
||||
attempt: TaskAttempt;
|
||||
executionProcessId: string;
|
||||
initialVariant: string | null;
|
||||
onCancelled?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['common']);
|
||||
const attemptId = attempt.id;
|
||||
const { retryDraft, isRetryLoaded } = useDraftStream(attemptId);
|
||||
const { isAttemptRunning, attemptData } = useAttemptExecution(attemptId);
|
||||
const { data: branchStatus } = useBranchStatus(attemptId);
|
||||
const { profiles } = useUserSystem();
|
||||
|
||||
// Errors are now reserved for send/cancel; creation occurs outside via useProcessRetry
|
||||
const [initError] = useState<string | null>(null);
|
||||
|
||||
const draft = useMemo<DraftResponse | null>(() => {
|
||||
if (!retryDraft || retryDraft.retry_process_id !== executionProcessId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...retryDraft,
|
||||
retry_process_id: executionProcessId,
|
||||
};
|
||||
}, [retryDraft, executionProcessId]);
|
||||
|
||||
const {
|
||||
message,
|
||||
setMessage,
|
||||
images,
|
||||
setImages,
|
||||
handleImageUploaded,
|
||||
clearImagesAndUploads,
|
||||
} = useDraftEditor({
|
||||
draft,
|
||||
taskId: attempt.task_id,
|
||||
});
|
||||
|
||||
// Presentation-only: show/hide image upload panel
|
||||
const [showImageUpload, setShowImageUpload] = useState(false);
|
||||
|
||||
// Variant selection: start with initialVariant or draft.variant
|
||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
||||
draft?.variant ?? initialVariant ?? null
|
||||
);
|
||||
useEffect(() => {
|
||||
if (draft?.variant !== undefined) setSelectedVariant(draft.variant ?? null);
|
||||
}, [draft?.variant]);
|
||||
|
||||
const { isSaving, saveStatus } = useDraftAutosave({
|
||||
draftType: 'retry',
|
||||
attemptId,
|
||||
serverDraft: draft,
|
||||
current: {
|
||||
prompt: message,
|
||||
variant: selectedVariant,
|
||||
image_ids: images.map((img) => img.id),
|
||||
retry_process_id: executionProcessId,
|
||||
},
|
||||
isDraftSending: false,
|
||||
});
|
||||
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Show overlay and keep UI disabled while waiting for server to clear retry_draft
|
||||
const [isFinalizing, setIsFinalizing] = useState<false | 'cancel' | 'send'>(
|
||||
false
|
||||
);
|
||||
const canSend = !isAttemptRunning && !!(message.trim() || images.length > 0);
|
||||
|
||||
const onCancel = async () => {
|
||||
setSendError(null);
|
||||
setIsFinalizing('cancel');
|
||||
try {
|
||||
await attemptsApi.deleteDraft(attemptId, 'retry');
|
||||
} catch (error: unknown) {
|
||||
setIsFinalizing(false);
|
||||
setSendError((error as Error)?.message || 'Failed to cancel retry');
|
||||
}
|
||||
};
|
||||
|
||||
// Safety net: if server provided a draft but local message is empty, force-apply once
|
||||
useEffect(() => {
|
||||
if (!isRetryLoaded || !draft) return;
|
||||
const serverPrompt = draft.prompt || '';
|
||||
if (message === '' && serverPrompt !== '') {
|
||||
setMessage(serverPrompt);
|
||||
if (import.meta.env.DEV) {
|
||||
// One-shot debug to validate hydration ordering in dev
|
||||
console.debug('[retry/hydrate] applied server prompt fallback', {
|
||||
attemptId,
|
||||
processId: executionProcessId,
|
||||
len: serverPrompt.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
attemptId,
|
||||
draft,
|
||||
executionProcessId,
|
||||
isRetryLoaded,
|
||||
message,
|
||||
setMessage,
|
||||
]);
|
||||
|
||||
const onSend = async () => {
|
||||
if (!canSend) return;
|
||||
setSendError(null);
|
||||
setIsSending(true);
|
||||
try {
|
||||
// Fetch process details and compute confirmation payload
|
||||
const proc = await executionProcessesApi.getDetails(executionProcessId);
|
||||
type WithBefore = { before_head_commit?: string | null };
|
||||
const before = (proc as WithBefore)?.before_head_commit || null;
|
||||
let targetSubject: string | null = null;
|
||||
let commitsToReset: number | null = null;
|
||||
let isLinear: boolean | null = null;
|
||||
if (before) {
|
||||
try {
|
||||
const info = await commitsApi.getInfo(attemptId, before);
|
||||
targetSubject = info.subject;
|
||||
const cmp = await commitsApi.compareToHead(attemptId, before);
|
||||
commitsToReset = cmp.is_linear ? cmp.ahead_from_head : null;
|
||||
isLinear = cmp.is_linear;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const head = branchStatus?.head_oid || null;
|
||||
const dirty = !!branchStatus?.has_uncommitted_changes;
|
||||
const needReset = !!(before && (before !== head || dirty));
|
||||
const canGitReset = needReset && !dirty;
|
||||
|
||||
// Compute later processes summary for UI
|
||||
const procs = (attemptData.processes || []).filter(
|
||||
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
|
||||
);
|
||||
const idx = procs.findIndex((p) => p.id === executionProcessId);
|
||||
const later = idx >= 0 ? procs.slice(idx + 1) : [];
|
||||
const laterCount = later.length;
|
||||
const laterCoding = later.filter((p) =>
|
||||
isCodingAgent(p.run_reason)
|
||||
).length;
|
||||
const laterSetup = later.filter(
|
||||
(p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT
|
||||
).length;
|
||||
const laterCleanup = later.filter(
|
||||
(p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT
|
||||
).length;
|
||||
|
||||
// Ask user for confirmation
|
||||
let modalResult: RestoreLogsDialogResult | undefined;
|
||||
try {
|
||||
modalResult = await showModal<RestoreLogsDialogResult>('restore-logs', {
|
||||
targetSha: before,
|
||||
targetSubject,
|
||||
commitsToReset,
|
||||
isLinear,
|
||||
laterCount,
|
||||
laterCoding,
|
||||
laterSetup,
|
||||
laterCleanup,
|
||||
needGitReset: needReset,
|
||||
canGitReset,
|
||||
hasRisk: dirty,
|
||||
uncommittedCount: branchStatus?.uncommitted_count ?? 0,
|
||||
untrackedCount: branchStatus?.untracked_count ?? 0,
|
||||
initialWorktreeResetOn: true,
|
||||
initialForceReset: false,
|
||||
});
|
||||
} catch {
|
||||
setIsSending(false);
|
||||
return; // dialog closed
|
||||
}
|
||||
if (!modalResult || modalResult.action !== 'confirmed') {
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await attemptsApi.followUp(attemptId, {
|
||||
prompt: message,
|
||||
variant: selectedVariant,
|
||||
image_ids: images.map((img) => img.id),
|
||||
retry_process_id: executionProcessId,
|
||||
force_when_dirty: modalResult.forceWhenDirty ?? false,
|
||||
perform_git_reset: modalResult.performGitReset ?? true,
|
||||
});
|
||||
clearImagesAndUploads();
|
||||
// Keep overlay up until stream clears the retry draft
|
||||
setIsFinalizing('send');
|
||||
} catch (error: unknown) {
|
||||
setSendError((error as Error)?.message || 'Failed to send retry');
|
||||
setIsSending(false);
|
||||
setIsFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Once server stream clears retry_draft, exit retry mode (both cancel and send)
|
||||
useEffect(() => {
|
||||
const stillRetrying = !!retryDraft?.retry_process_id;
|
||||
if ((isFinalizing || isSending) && !stillRetrying) {
|
||||
setIsFinalizing(false);
|
||||
setIsSending(false);
|
||||
onCancelled?.();
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
retryDraft?.retry_process_id,
|
||||
isFinalizing,
|
||||
isSending,
|
||||
onCancelled,
|
||||
attemptId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
{initError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{initError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FollowUpEditorCard
|
||||
placeholder="Edit and resend your message…"
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
onKeyDown={() => void 0}
|
||||
disabled={isSending || !!isFinalizing}
|
||||
showLoadingOverlay={isSending || !!isFinalizing}
|
||||
textareaClassName="bg-background"
|
||||
/>
|
||||
|
||||
{/* Draft save/load status (no queue/sending for retry) */}
|
||||
<FollowUpStatusRow
|
||||
status={{
|
||||
save: { state: isSaving ? 'saving' : saveStatus, isSaving },
|
||||
draft: { isLoaded: isRetryLoaded, isSending: false },
|
||||
queue: { isUnqueuing: false, isQueued: false },
|
||||
}}
|
||||
pillBgClass="bg-background"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Image button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowImageUpload((prev) => !prev)}
|
||||
disabled={isSending || !!isFinalizing}
|
||||
>
|
||||
<ImageIcon
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
(images.length > 0 || showImageUpload) && 'text-primary'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<VariantSelector
|
||||
selectedVariant={selectedVariant}
|
||||
onChange={setSelectedVariant}
|
||||
currentProfile={profiles?.[attempt.executor] ?? null}
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSending || !!isFinalizing}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />{' '}
|
||||
{t('buttons.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!canSend || isSending || !!isFinalizing}
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />{' '}
|
||||
{t('buttons.send', { ns: 'common', defaultValue: 'Send' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showImageUpload && (
|
||||
<div className="mb-2">
|
||||
<ImageUploadSection
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onUpload={(file) => imagesApi.uploadForTask(attempt.task_id, file)}
|
||||
onDelete={imagesApi.delete}
|
||||
onImageUploaded={(image) => {
|
||||
handleImageUploaded(image);
|
||||
setMessage((prev) => appendImageMarkdown(prev, image));
|
||||
}}
|
||||
disabled={isSending || !!isFinalizing}
|
||||
collapsible={false}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{sendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import MarkdownRenderer from '@/components/ui/markdown-renderer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, Send, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProcessRetry } from '@/hooks/useProcessRetry';
|
||||
import { TaskAttempt, type BaseAgentCapability } from 'shared/types';
|
||||
import { useUserSystem } from '@/components/config-provider';
|
||||
import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
|
||||
import { RetryEditorInline } from './RetryEditorInline';
|
||||
import { useRetryUi } from '@/contexts/RetryUiContext';
|
||||
|
||||
const UserMessage = ({
|
||||
content,
|
||||
@@ -17,9 +19,11 @@ const UserMessage = ({
|
||||
taskAttempt?: TaskAttempt;
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
const retryHook = useProcessRetry(taskAttempt);
|
||||
const { capabilities } = useUserSystem();
|
||||
const attemptId = taskAttempt?.id;
|
||||
const { retryDraft } = useDraftStream(attemptId);
|
||||
const { activeRetryProcessId, isProcessGreyed } = useRetryUi();
|
||||
|
||||
const canFork = !!(
|
||||
taskAttempt?.executor &&
|
||||
@@ -28,21 +32,53 @@ const UserMessage = ({
|
||||
)
|
||||
);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!executionProcessId) return;
|
||||
retryHook?.retryProcess(executionProcessId, editContent).then(() => {
|
||||
// Enter retry mode: create retry draft; actual editor will render inline
|
||||
const startRetry = async () => {
|
||||
if (!executionProcessId || !taskAttempt) return;
|
||||
setIsEditing(true);
|
||||
retryHook?.startRetry(executionProcessId, content).catch(() => {
|
||||
// rollback if server call fails
|
||||
setIsEditing(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Exit editing state once draft disappears (sent/cancelled)
|
||||
useEffect(() => {
|
||||
if (!retryDraft?.retry_process_id) setIsEditing(false);
|
||||
}, [retryDraft?.retry_process_id]);
|
||||
|
||||
// On reload or when server provides a retry_draft for this process, show editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
executionProcessId &&
|
||||
retryDraft?.retry_process_id &&
|
||||
retryDraft.retry_process_id === executionProcessId
|
||||
) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
}, [executionProcessId, retryDraft?.retry_process_id]);
|
||||
|
||||
const showRetryEditor =
|
||||
!!executionProcessId &&
|
||||
isEditing &&
|
||||
activeRetryProcessId === executionProcessId;
|
||||
const greyed =
|
||||
!!executionProcessId &&
|
||||
isProcessGreyed(executionProcessId) &&
|
||||
!showRetryEditor;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className={`py-2 ${greyed ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<div className="bg-background px-4 py-2 text-sm border-y border-dashed flex gap-2">
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
{showRetryEditor ? (
|
||||
<RetryEditorInline
|
||||
attempt={taskAttempt as TaskAttempt}
|
||||
executionProcessId={executionProcessId as string}
|
||||
initialVariant={null}
|
||||
onCancelled={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownRenderer
|
||||
@@ -51,24 +87,27 @@ const UserMessage = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{executionProcessId && canFork && (
|
||||
{executionProcessId && canFork && !showRetryEditor && (
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
variant="ghost"
|
||||
className="p-2"
|
||||
>
|
||||
{isEditing ? (
|
||||
<X className="w-3 h-3" />
|
||||
) : (
|
||||
<Pencil className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button onClick={handleEdit} variant="ghost" className="p-2">
|
||||
<Send className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{(() => {
|
||||
const state = executionProcessId
|
||||
? retryHook?.getRetryDisabledState(executionProcessId)
|
||||
: { disabled: true, reason: 'Missing process id' };
|
||||
const disabled = !!state?.disabled;
|
||||
const reason = state?.reason ?? undefined;
|
||||
return (
|
||||
<Button
|
||||
onClick={startRetry}
|
||||
variant="ghost"
|
||||
className="p-2"
|
||||
disabled={disabled}
|
||||
title={disabled && reason ? reason : undefined}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,9 @@ type Status = {
|
||||
queue: { isUnqueuing: boolean; isQueued: boolean };
|
||||
};
|
||||
|
||||
type Props = { status: Status };
|
||||
type Props = { status: Status; pillBgClass?: string };
|
||||
|
||||
function FollowUpStatusRowImpl({ status }: Props) {
|
||||
function FollowUpStatusRowImpl({ status, pillBgClass = 'bg-muted' }: Props) {
|
||||
const { save, draft, queue } = status;
|
||||
|
||||
// Nonce keys to retrigger CSS animation; no JS timers.
|
||||
@@ -39,21 +39,28 @@ function FollowUpStatusRowImpl({ status }: Props) {
|
||||
{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'
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
|
||||
'italic',
|
||||
pillBgClass
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-amber-700 animate-in fade-in-0',
|
||||
pillBgClass
|
||||
)}
|
||||
>
|
||||
<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'
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-emerald-700 animate-pill',
|
||||
pillBgClass
|
||||
)}
|
||||
onAnimationEnd={() => setSentNonce(null)}
|
||||
>
|
||||
@@ -63,7 +70,8 @@ function FollowUpStatusRowImpl({ status }: Props) {
|
||||
<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'
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-emerald-700 animate-pill',
|
||||
pillBgClass
|
||||
)}
|
||||
onAnimationEnd={() => setSavedNonce(null)}
|
||||
>
|
||||
@@ -73,19 +81,39 @@ function FollowUpStatusRowImpl({ status }: Props) {
|
||||
</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">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
|
||||
pillBgClass
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
|
||||
pillBgClass
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
|
||||
pillBgClass
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 animate-in fade-in-0',
|
||||
pillBgClass
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3" /> Queued for next turn. Edits are
|
||||
locked.
|
||||
</span>
|
||||
|
||||
@@ -15,6 +15,7 @@ import ProcessLogsViewer from './ProcessLogsViewer';
|
||||
import type { ExecutionProcessStatus, ExecutionProcess } from 'shared/types';
|
||||
|
||||
import { useProcessSelection } from '@/contexts/ProcessSelectionContext';
|
||||
import { useRetryUi } from '@/contexts/RetryUiContext';
|
||||
|
||||
interface ProcessesTabProps {
|
||||
attemptId?: string;
|
||||
@@ -127,6 +128,7 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) {
|
||||
? localProcessDetails[selectedProcessId] ||
|
||||
executionProcessesById[selectedProcessId]
|
||||
: null;
|
||||
const { isProcessGreyed } = useRetryUi();
|
||||
|
||||
if (!attemptId) {
|
||||
return (
|
||||
@@ -168,7 +170,9 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) {
|
||||
className={`border rounded-lg p-4 hover:bg-muted/30 cursor-pointer transition-colors ${
|
||||
loadingProcessId === process.id
|
||||
? 'opacity-50 cursor-wait'
|
||||
: ''
|
||||
: isProcessGreyed(process.id)
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleProcessClick(process)}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TabNavContext } from '@/contexts/TabNavigationContext';
|
||||
import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext';
|
||||
import { ReviewProvider } from '@/contexts/ReviewProvider';
|
||||
import { EntriesProvider } from '@/contexts/EntriesContext';
|
||||
import { RetryUiProvider } from '@/contexts/RetryUiContext';
|
||||
import { AttemptHeaderCard } from './AttemptHeaderCard';
|
||||
import { inIframe } from '@/vscode/bridge';
|
||||
import { TaskRelationshipViewer } from './TaskRelationshipViewer';
|
||||
@@ -165,31 +166,37 @@ export function TaskDetailsPanel({
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
{selectedAttempt && (
|
||||
<>
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
<RetryUiProvider attemptId={selectedAttempt.id}>
|
||||
<>
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab selectedAttempt={selectedAttempt} />
|
||||
) : activeTab === 'processes' ? (
|
||||
<ProcessesTab
|
||||
attemptId={selectedAttempt?.id}
|
||||
/>
|
||||
) : (
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
) : activeTab === 'processes' ? (
|
||||
<ProcessesTab
|
||||
attemptId={selectedAttempt?.id}
|
||||
/>
|
||||
) : (
|
||||
<LogsTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
</RetryUiProvider>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -225,14 +232,15 @@ export function TaskDetailsPanel({
|
||||
/>
|
||||
|
||||
{selectedAttempt && (
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
<RetryUiProvider attemptId={selectedAttempt.id}>
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</RetryUiProvider>
|
||||
)}
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,12 +25,14 @@ import { useAttemptBranch } from '@/hooks/useAttemptBranch';
|
||||
import { FollowUpConflictSection } from '@/components/tasks/follow-up/FollowUpConflictSection';
|
||||
import { FollowUpEditorCard } from '@/components/tasks/follow-up/FollowUpEditorCard';
|
||||
import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
|
||||
import { useDraftEdits } from '@/hooks/follow-up/useDraftEdits';
|
||||
import { useRetryUi } from '@/contexts/RetryUiContext';
|
||||
import { useDraftEditor } from '@/hooks/follow-up/useDraftEditor';
|
||||
import { useDraftAutosave } from '@/hooks/follow-up/useDraftAutosave';
|
||||
import { useDraftQueue } from '@/hooks/follow-up/useDraftQueue';
|
||||
import { useFollowUpSend } from '@/hooks/follow-up/useFollowUpSend';
|
||||
import { useDefaultVariant } from '@/hooks/follow-up/useDefaultVariant';
|
||||
import { buildResolveConflictsInstructions } from '@/lib/conflicts';
|
||||
import { appendImageMarkdown } from '@/utils/markdownImages';
|
||||
|
||||
interface TaskFollowUpSectionProps {
|
||||
task: TaskWithAttemptStatus;
|
||||
@@ -75,13 +77,7 @@ export function TaskFollowUpSection({
|
||||
]);
|
||||
|
||||
// Draft stream and synchronization
|
||||
const {
|
||||
draft,
|
||||
isDraftLoaded,
|
||||
lastServerVersionRef,
|
||||
suppressNextSaveRef,
|
||||
forceNextApplyRef,
|
||||
} = useDraftStream(selectedAttemptId);
|
||||
const { draft, isDraftLoaded } = useDraftStream(selectedAttemptId);
|
||||
|
||||
// Editor state
|
||||
const {
|
||||
@@ -92,11 +88,8 @@ export function TaskFollowUpSection({
|
||||
newlyUploadedImageIds,
|
||||
handleImageUploaded,
|
||||
clearImagesAndUploads,
|
||||
} = useDraftEdits({
|
||||
} = useDraftEditor({
|
||||
draft,
|
||||
lastServerVersionRef,
|
||||
suppressNextSaveRef,
|
||||
forceNextApplyRef,
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
@@ -114,8 +107,6 @@ export function TaskFollowUpSection({
|
||||
message: followUpMessage,
|
||||
selectedVariant,
|
||||
images,
|
||||
suppressNextSaveRef,
|
||||
lastServerVersionRef,
|
||||
});
|
||||
|
||||
// Presentation-only queue state
|
||||
@@ -130,6 +121,11 @@ export function TaskFollowUpSection({
|
||||
const isQueued = !!draft?.queued;
|
||||
const displayQueued = queuedOptimistic ?? isQueued;
|
||||
|
||||
// During retry, follow-up box is greyed/disabled (not hidden)
|
||||
// Use RetryUi context so optimistic retry immediately disables this box
|
||||
const { activeRetryProcessId } = useRetryUi();
|
||||
const isRetryActive = !!activeRetryProcessId;
|
||||
|
||||
// Autosave draft when editing
|
||||
const { isSaving, saveStatus } = useDraftAutosave({
|
||||
attemptId: selectedAttemptId,
|
||||
@@ -143,9 +139,6 @@ export function TaskFollowUpSection({
|
||||
isDraftSending: !!draft?.sending,
|
||||
isQueuing: isQueuing,
|
||||
isUnqueuing: isUnqueuing,
|
||||
suppressNextSaveRef,
|
||||
lastServerVersionRef,
|
||||
forceNextApplyRef,
|
||||
});
|
||||
|
||||
// Send follow-up action
|
||||
@@ -182,12 +175,14 @@ export function TaskFollowUpSection({
|
||||
}
|
||||
}
|
||||
|
||||
if (isRetryActive) return false; // disable typing while retry editor is active
|
||||
return true;
|
||||
}, [
|
||||
selectedAttemptId,
|
||||
processes.length,
|
||||
isSendingFollowUp,
|
||||
branchStatus?.merges,
|
||||
isRetryActive,
|
||||
]);
|
||||
|
||||
const canSendFollowUp = useMemo(() => {
|
||||
@@ -209,7 +204,7 @@ export function TaskFollowUpSection({
|
||||
|
||||
const isDraftLocked =
|
||||
displayQueued || isQueuing || isUnqueuing || !!draft?.sending;
|
||||
const isEditable = isDraftLoaded && !isDraftLocked;
|
||||
const isEditable = isDraftLoaded && !isDraftLocked && !isRetryActive;
|
||||
|
||||
// When a process completes (e.g., agent resolved conflicts), refresh branch status promptly
|
||||
const prevRunningRef = useRef<boolean>(isAttemptRunning);
|
||||
@@ -258,11 +253,16 @@ export function TaskFollowUpSection({
|
||||
} else {
|
||||
if (isUnqueuing) setIsUnqueuing(false);
|
||||
}
|
||||
}, [isQueued]);
|
||||
}, [isQueued, isQueuing, isUnqueuing]);
|
||||
|
||||
return (
|
||||
selectedAttemptId && (
|
||||
<div className="border-t p-4 focus-within:ring ring-inset">
|
||||
<div
|
||||
className={cn(
|
||||
'border-t p-4 focus-within:ring ring-inset',
|
||||
isRetryActive && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{followUpError && (
|
||||
<Alert variant="destructive">
|
||||
@@ -276,16 +276,13 @@ export function TaskFollowUpSection({
|
||||
<ImageUploadSection
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onUpload={imagesApi.upload}
|
||||
onUpload={(file) => imagesApi.uploadForTask(task.id, file)}
|
||||
onDelete={imagesApi.delete}
|
||||
onImageUploaded={(image) => {
|
||||
handleImageUploaded(image);
|
||||
const markdownText = ``;
|
||||
const next =
|
||||
followUpMessage.trim() === ''
|
||||
? markdownText
|
||||
: followUpMessage + ' ' + markdownText;
|
||||
setFollowUpMessage(next);
|
||||
setFollowUpMessage((prev) =>
|
||||
appendImageMarkdown(prev, image)
|
||||
);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
collapsible={false}
|
||||
@@ -394,6 +391,7 @@ export function TaskFollowUpSection({
|
||||
onClick={clearComments}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={!isEditable}
|
||||
>
|
||||
Clear Review Comments
|
||||
</Button>
|
||||
@@ -404,7 +402,8 @@ export function TaskFollowUpSection({
|
||||
!canSendFollowUp ||
|
||||
isDraftLocked ||
|
||||
!isDraftLoaded ||
|
||||
isSendingFollowUp
|
||||
isSendingFollowUp ||
|
||||
isRetryActive
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
@@ -476,7 +475,8 @@ export function TaskFollowUpSection({
|
||||
!isDraftLoaded ||
|
||||
isQueuing ||
|
||||
isUnqueuing ||
|
||||
!!draft?.sending
|
||||
!!draft?.sending ||
|
||||
isRetryActive
|
||||
}
|
||||
size="sm"
|
||||
variant="default"
|
||||
|
||||
@@ -13,16 +13,19 @@ type Props = {
|
||||
showLoadingOverlay: boolean;
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
textareaClassName?: string;
|
||||
};
|
||||
|
||||
export function FollowUpEditorCard({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
showLoadingOverlay,
|
||||
onCommandEnter,
|
||||
onCommandShiftEnter,
|
||||
textareaClassName,
|
||||
}: Props) {
|
||||
const { projectId } = useProject();
|
||||
return (
|
||||
@@ -31,7 +34,8 @@ export function FollowUpEditorCard({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={cn('flex-1 min-h-[40px] resize-none')}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn('flex-1 min-h-[40px] resize-none', textareaClassName)}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
rows={1}
|
||||
|
||||
@@ -126,7 +126,7 @@ export function ImageUploadSection({
|
||||
}
|
||||
}
|
||||
},
|
||||
[images, onImagesChange, onUpload, disabled]
|
||||
[images, onImagesChange, onImageUploaded, onUpload, disabled]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
65
frontend/src/contexts/RetryUiContext.tsx
Normal file
65
frontend/src/contexts/RetryUiContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useExecutionProcesses } from '@/hooks/useExecutionProcesses';
|
||||
import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
|
||||
|
||||
type RetryUiContextType = {
|
||||
activeRetryProcessId: string | null;
|
||||
processOrder: Record<string, number>;
|
||||
isProcessGreyed: (processId?: string) => boolean;
|
||||
};
|
||||
|
||||
const RetryUiContext = createContext<RetryUiContextType | null>(null);
|
||||
|
||||
export function RetryUiProvider({
|
||||
attemptId,
|
||||
children,
|
||||
}: {
|
||||
attemptId?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { executionProcesses } = useExecutionProcesses(attemptId ?? '', {
|
||||
showSoftDeleted: true,
|
||||
});
|
||||
const { retryDraft } = useDraftStream(attemptId);
|
||||
|
||||
const processOrder = useMemo(() => {
|
||||
const order: Record<string, number> = {};
|
||||
executionProcesses.forEach((p, idx) => {
|
||||
order[p.id] = idx;
|
||||
});
|
||||
return order;
|
||||
}, [executionProcesses]);
|
||||
|
||||
const activeRetryProcessId = retryDraft?.retry_process_id ?? null;
|
||||
const targetOrder = activeRetryProcessId
|
||||
? (processOrder[activeRetryProcessId] ?? -1)
|
||||
: -1;
|
||||
|
||||
const isProcessGreyed = (processId?: string) => {
|
||||
if (!activeRetryProcessId || !processId) return false;
|
||||
const idx = processOrder[processId];
|
||||
if (idx === undefined) return false;
|
||||
return idx >= targetOrder; // grey target and later
|
||||
};
|
||||
|
||||
const value: RetryUiContextType = {
|
||||
activeRetryProcessId,
|
||||
processOrder,
|
||||
isProcessGreyed,
|
||||
};
|
||||
|
||||
return (
|
||||
<RetryUiContext.Provider value={value}>{children}</RetryUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRetryUi() {
|
||||
const ctx = useContext(RetryUiContext);
|
||||
if (!ctx)
|
||||
return {
|
||||
activeRetryProcessId: null,
|
||||
processOrder: {},
|
||||
isProcessGreyed: () => false,
|
||||
} as RetryUiContextType;
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,113 +1,263 @@
|
||||
import {
|
||||
attemptsApi,
|
||||
type UpdateFollowUpDraftRequest,
|
||||
type UpdateRetryFollowUpDraftRequest,
|
||||
} from '@/lib/api';
|
||||
import type { Draft, DraftResponse } from 'shared/types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { attemptsApi, type UpdateFollowUpDraftRequest } from '@/lib/api';
|
||||
import type { FollowUpDraft } from 'shared/types';
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'offline' | 'sent';
|
||||
|
||||
type DraftData = Pick<FollowUpDraft, 'prompt' | 'variant' | 'image_ids'>;
|
||||
|
||||
type Args = {
|
||||
attemptId?: string;
|
||||
serverDraft: FollowUpDraft | null;
|
||||
current: DraftData;
|
||||
isQueuedUI: boolean;
|
||||
isDraftSending: boolean;
|
||||
isQueuing: boolean;
|
||||
isUnqueuing: boolean;
|
||||
suppressNextSaveRef: React.MutableRefObject<boolean>;
|
||||
lastServerVersionRef: React.MutableRefObject<number>;
|
||||
forceNextApplyRef: React.MutableRefObject<boolean>;
|
||||
// Small helper to diff common draft fields
|
||||
type BaseCurrent = {
|
||||
prompt: string;
|
||||
variant: string | null | undefined;
|
||||
image_ids: string[] | null | undefined;
|
||||
};
|
||||
type BaseServer = {
|
||||
prompt?: string | null;
|
||||
variant?: string | null;
|
||||
image_ids?: string[] | null;
|
||||
} | null;
|
||||
type BasePayload = {
|
||||
prompt?: string;
|
||||
variant?: string | null;
|
||||
image_ids?: string[];
|
||||
};
|
||||
|
||||
export function useDraftAutosave({
|
||||
function diffBaseDraft(current: BaseCurrent, server: BaseServer): BasePayload {
|
||||
const payload: BasePayload = {};
|
||||
const serverPrompt = (server?.prompt ?? '') || '';
|
||||
const serverVariant = server?.variant ?? null;
|
||||
const serverIds = (server?.image_ids as string[] | undefined) ?? [];
|
||||
|
||||
if (current.prompt !== serverPrompt) payload.prompt = current.prompt || '';
|
||||
if ((current.variant ?? null) !== serverVariant)
|
||||
payload.variant = (current.variant ?? null) as string | null;
|
||||
|
||||
const currIds = (current.image_ids as string[] | null) ?? [];
|
||||
const idsEqual =
|
||||
currIds.length === serverIds.length &&
|
||||
currIds.every((id, i) => id === serverIds[i]);
|
||||
if (!idsEqual) payload.image_ids = currIds;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function diffDraftPayload<
|
||||
TExtra extends Record<string, unknown> = Record<string, never>,
|
||||
>(
|
||||
current: BaseCurrent,
|
||||
server: BaseServer,
|
||||
extra?: TExtra,
|
||||
requireBaseChange: boolean = true
|
||||
): (BasePayload & TExtra) | null {
|
||||
const base = diffBaseDraft(current, server);
|
||||
const baseChanged = Object.keys(base).length > 0;
|
||||
if (!baseChanged && requireBaseChange) return null;
|
||||
return { ...(extra as object), ...base } as BasePayload & TExtra;
|
||||
}
|
||||
|
||||
// Private core
|
||||
function useDraftAutosaveCore<TServer, TCurrent, TPayload>({
|
||||
attemptId,
|
||||
serverDraft,
|
||||
current,
|
||||
isQueuedUI,
|
||||
isDraftSending,
|
||||
isQueuing,
|
||||
isUnqueuing,
|
||||
suppressNextSaveRef,
|
||||
lastServerVersionRef,
|
||||
forceNextApplyRef,
|
||||
}: Args) {
|
||||
skipConditions = [],
|
||||
buildPayload,
|
||||
saveDraft,
|
||||
fetchLatest,
|
||||
debugLabel,
|
||||
}: {
|
||||
attemptId?: string;
|
||||
serverDraft: TServer | null;
|
||||
current: TCurrent;
|
||||
isDraftSending: boolean;
|
||||
skipConditions?: boolean[];
|
||||
buildPayload: (
|
||||
current: TCurrent,
|
||||
serverDraft: TServer | null
|
||||
) => TPayload | null;
|
||||
saveDraft: (attemptId: string, payload: TPayload) => Promise<unknown>;
|
||||
fetchLatest?: (attemptId: string) => Promise<unknown>;
|
||||
debugLabel?: string;
|
||||
}) {
|
||||
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;
|
||||
if (isDraftSending) {
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] skip: draft is sending`, {
|
||||
attemptId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (skipConditions.some((c) => c)) {
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] skip: skipConditions`, {
|
||||
attemptId,
|
||||
skipConditions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isQueuedUI) return;
|
||||
|
||||
const saveDraft = async () => {
|
||||
const payload: Partial<UpdateFollowUpDraftRequest> = {};
|
||||
if (serverDraft && current.prompt !== (serverDraft.prompt || ''))
|
||||
payload.prompt = current.prompt || '';
|
||||
if ((serverDraft?.variant ?? null) !== (current.variant ?? null))
|
||||
payload.variant = (current.variant ?? null) as string | null;
|
||||
const currentIds = (current.image_ids as string[] | null) ?? [];
|
||||
const serverIds = (serverDraft?.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 doSave = async () => {
|
||||
const payload = buildPayload(current, serverDraft);
|
||||
if (!payload) {
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] no changes`, { attemptId });
|
||||
return;
|
||||
}
|
||||
const payloadKey = JSON.stringify(payload);
|
||||
if (payloadKey === lastSentRef.current) return;
|
||||
if (payloadKey === lastSentRef.current) {
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] deduped identical payload`, {
|
||||
attemptId,
|
||||
payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
lastSentRef.current = payloadKey;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setSaveStatus(navigator.onLine ? 'saving' : 'offline');
|
||||
await attemptsApi.saveFollowUpDraft(
|
||||
attemptId,
|
||||
payload as UpdateFollowUpDraftRequest
|
||||
);
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] saving`, {
|
||||
attemptId,
|
||||
payload,
|
||||
});
|
||||
await saveDraft(attemptId, payload);
|
||||
setSaveStatus('saved');
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] saved`, { attemptId });
|
||||
} 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 */
|
||||
if (import.meta.env.DEV)
|
||||
console.debug(`[autosave:${debugLabel}] error -> fetchLatest`, {
|
||||
attemptId,
|
||||
});
|
||||
if (fetchLatest) {
|
||||
try {
|
||||
await fetchLatest(attemptId);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
setSaveStatus(navigator.onLine ? 'idle' : 'offline');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = window.setTimeout(saveDraft, 400);
|
||||
saveTimeoutRef.current = window.setTimeout(doSave, 400);
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current);
|
||||
};
|
||||
}, [
|
||||
attemptId,
|
||||
serverDraft?.prompt,
|
||||
serverDraft?.variant,
|
||||
serverDraft?.image_ids,
|
||||
current.prompt,
|
||||
current.variant,
|
||||
current.image_ids,
|
||||
isQueuedUI,
|
||||
serverDraft,
|
||||
current,
|
||||
isDraftSending,
|
||||
isQueuing,
|
||||
isUnqueuing,
|
||||
suppressNextSaveRef,
|
||||
lastServerVersionRef,
|
||||
skipConditions,
|
||||
buildPayload,
|
||||
saveDraft,
|
||||
fetchLatest,
|
||||
debugLabel,
|
||||
]);
|
||||
|
||||
return { isSaving, saveStatus } as const;
|
||||
}
|
||||
|
||||
type DraftData = Pick<Draft, 'prompt' | 'variant' | 'image_ids'>;
|
||||
|
||||
type DraftArgs<TServer, TCurrent> = {
|
||||
attemptId?: string;
|
||||
serverDraft: TServer | null;
|
||||
current: TCurrent;
|
||||
isDraftSending: boolean;
|
||||
// Queue-related flags (used for follow_up; not used for retry)
|
||||
isQueuedUI?: boolean;
|
||||
isQueuing?: boolean;
|
||||
isUnqueuing?: boolean;
|
||||
// Discriminant
|
||||
draftType?: 'follow_up' | 'retry';
|
||||
};
|
||||
|
||||
type FollowUpAutosaveArgs = DraftArgs<Draft, DraftData> & {
|
||||
draftType?: 'follow_up';
|
||||
};
|
||||
type RetryAutosaveArgs = DraftArgs<RetryDraftResponse, RetryDraftData> & {
|
||||
draftType: 'retry';
|
||||
};
|
||||
|
||||
export function useDraftAutosave(
|
||||
args: FollowUpAutosaveArgs | RetryAutosaveArgs
|
||||
) {
|
||||
const skipConditions =
|
||||
args.draftType === 'retry'
|
||||
? [!args.serverDraft]
|
||||
: [!!args.isQueuing, !!args.isUnqueuing, !!args.isQueuedUI];
|
||||
|
||||
return useDraftAutosaveCore<
|
||||
Draft | RetryDraftResponse,
|
||||
DraftData | RetryDraftData,
|
||||
UpdateFollowUpDraftRequest | UpdateRetryFollowUpDraftRequest
|
||||
>({
|
||||
attemptId: args.attemptId,
|
||||
serverDraft: args.serverDraft as Draft | RetryDraftResponse | null,
|
||||
current: args.current as DraftData | RetryDraftData,
|
||||
isDraftSending: args.isDraftSending,
|
||||
skipConditions,
|
||||
debugLabel: (args.draftType ?? 'follow_up') as string,
|
||||
buildPayload: (current, serverDraft) => {
|
||||
if (args.draftType === 'retry') {
|
||||
const c = current as RetryDraftData;
|
||||
const s = serverDraft as RetryDraftResponse | null;
|
||||
return diffDraftPayload(
|
||||
c,
|
||||
s,
|
||||
{ retry_process_id: c.retry_process_id },
|
||||
true
|
||||
) as UpdateRetryFollowUpDraftRequest | null;
|
||||
}
|
||||
const c = current as DraftData;
|
||||
const s = serverDraft as Draft | null;
|
||||
return diffDraftPayload(c, s) as UpdateFollowUpDraftRequest | null;
|
||||
},
|
||||
saveDraft: (id, payload) => {
|
||||
if (args.draftType === 'retry') {
|
||||
return attemptsApi.saveDraft(
|
||||
id,
|
||||
'retry',
|
||||
payload as UpdateRetryFollowUpDraftRequest
|
||||
);
|
||||
}
|
||||
return attemptsApi.saveDraft(
|
||||
id,
|
||||
'follow_up',
|
||||
payload as UpdateFollowUpDraftRequest
|
||||
);
|
||||
},
|
||||
fetchLatest: (id) => {
|
||||
if (args.draftType === 'retry') return attemptsApi.getDraft(id, 'retry');
|
||||
return attemptsApi.getDraft(id, 'follow_up');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RetrySaveStatus = SaveStatus;
|
||||
|
||||
type RetryDraftResponse = DraftResponse;
|
||||
|
||||
type RetryDraftData = Pick<
|
||||
DraftResponse,
|
||||
'prompt' | 'variant' | 'image_ids'
|
||||
> & {
|
||||
retry_process_id: string;
|
||||
};
|
||||
|
||||
88
frontend/src/hooks/follow-up/useDraftEditor.ts
Normal file
88
frontend/src/hooks/follow-up/useDraftEditor.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { Draft, ImageResponse } from 'shared/types';
|
||||
import { imagesApi } from '@/lib/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
type PartialDraft = Pick<Draft, 'prompt' | 'image_ids'>;
|
||||
|
||||
type Args = {
|
||||
draft: PartialDraft | null;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export function useDraftEditor({ draft, taskId }: Args) {
|
||||
const [message, setMessageInner] = useState('');
|
||||
const [localImages, setLocalImages] = useState<ImageResponse[]>([]);
|
||||
const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const localDirtyRef = useRef<boolean>(false);
|
||||
const imagesDirtyRef = useRef<boolean>(false);
|
||||
|
||||
// Sync message with server when not locally dirty
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
const serverPrompt = draft.prompt || '';
|
||||
if (!localDirtyRef.current) {
|
||||
setMessageInner(serverPrompt);
|
||||
} else if (serverPrompt === message) {
|
||||
// When server catches up to local text, clear dirty
|
||||
localDirtyRef.current = false;
|
||||
}
|
||||
}, [draft, message]);
|
||||
|
||||
// Fetch images for task via react-query and map to the draft's image_ids
|
||||
const serverIds = (draft?.image_ids ?? []).filter(Boolean);
|
||||
const idsKey = serverIds.join(',');
|
||||
const imagesQuery = useQuery({
|
||||
queryKey: ['taskImagesForDraft', taskId, idsKey],
|
||||
enabled: !!taskId,
|
||||
queryFn: async () => {
|
||||
const all = await imagesApi.getTaskImages(taskId);
|
||||
const want = new Set(serverIds);
|
||||
return all.filter((img) => want.has(img.id));
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const images = imagesDirtyRef.current
|
||||
? localImages
|
||||
: (imagesQuery.data ?? []);
|
||||
|
||||
const setMessage = (v: React.SetStateAction<string>) => {
|
||||
localDirtyRef.current = true;
|
||||
if (typeof v === 'function') {
|
||||
setMessageInner((prev) => v(prev));
|
||||
} else {
|
||||
setMessageInner(v);
|
||||
}
|
||||
};
|
||||
|
||||
const setImages = (next: ImageResponse[]) => {
|
||||
imagesDirtyRef.current = true;
|
||||
setLocalImages(next);
|
||||
};
|
||||
|
||||
const handleImageUploaded = useCallback((image: ImageResponse) => {
|
||||
imagesDirtyRef.current = true;
|
||||
setLocalImages((prev) => [...prev, image]);
|
||||
setNewlyUploadedImageIds((prev) => [...prev, image.id]);
|
||||
}, []);
|
||||
|
||||
const clearImagesAndUploads = useCallback(() => {
|
||||
imagesDirtyRef.current = false;
|
||||
setLocalImages([]);
|
||||
setNewlyUploadedImageIds([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
message,
|
||||
setMessage,
|
||||
images,
|
||||
setImages,
|
||||
newlyUploadedImageIds,
|
||||
handleImageUploaded,
|
||||
clearImagesAndUploads,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { FollowUpDraft, ImageResponse } from 'shared/types';
|
||||
import { imagesApi } from '@/lib/api';
|
||||
|
||||
type Args = {
|
||||
draft: FollowUpDraft | null;
|
||||
lastServerVersionRef: React.MutableRefObject<number>;
|
||||
suppressNextSaveRef: React.MutableRefObject<boolean>;
|
||||
forceNextApplyRef: React.MutableRefObject<boolean>;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export function useDraftEdits({
|
||||
draft,
|
||||
lastServerVersionRef,
|
||||
suppressNextSaveRef,
|
||||
forceNextApplyRef,
|
||||
taskId,
|
||||
}: Args) {
|
||||
const [message, setMessageInner] = useState('');
|
||||
const [images, setImages] = useState<ImageResponse[]>([]);
|
||||
const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const localDirtyRef = useRef<boolean>(false);
|
||||
const imagesDirtyRef = 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) {
|
||||
setMessageInner(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]);
|
||||
|
||||
// Sync images from server when not locally dirty
|
||||
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) {
|
||||
imagesDirtyRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (imagesDirtyRef.current) return;
|
||||
|
||||
imagesApi
|
||||
.getTaskImages(taskId)
|
||||
.then((all) => {
|
||||
const next = all.filter((img) => wantIds.has(img.id));
|
||||
setImages(next);
|
||||
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 {
|
||||
message,
|
||||
setMessage: (v: React.SetStateAction<string>) => {
|
||||
localDirtyRef.current = true;
|
||||
if (typeof v === 'function') {
|
||||
setMessageInner((prev) => (v as (prev: string) => string)(prev));
|
||||
} else {
|
||||
setMessageInner(v);
|
||||
}
|
||||
},
|
||||
images,
|
||||
setImages,
|
||||
newlyUploadedImageIds,
|
||||
handleImageUploaded,
|
||||
clearImagesAndUploads,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { attemptsApi, type UpdateFollowUpDraftRequest } from '@/lib/api';
|
||||
import type { FollowUpDraft, ImageResponse } from 'shared/types';
|
||||
import type { Draft, ImageResponse } from 'shared/types';
|
||||
|
||||
type Args = {
|
||||
attemptId?: string;
|
||||
draft: FollowUpDraft | null;
|
||||
draft: Draft | null;
|
||||
message: string;
|
||||
selectedVariant: string | null;
|
||||
images: ImageResponse[];
|
||||
suppressNextSaveRef: React.MutableRefObject<boolean>;
|
||||
lastServerVersionRef: React.MutableRefObject<number>;
|
||||
};
|
||||
|
||||
export function useDraftQueue({
|
||||
@@ -18,8 +16,6 @@ export function useDraftQueue({
|
||||
message,
|
||||
selectedVariant,
|
||||
images,
|
||||
suppressNextSaveRef,
|
||||
lastServerVersionRef,
|
||||
}: Args) {
|
||||
const onQueue = useCallback(async (): Promise<boolean> => {
|
||||
if (!attemptId) return false;
|
||||
@@ -37,26 +33,13 @@ export function useDraftQueue({
|
||||
currentIds.length === serverIds.length &&
|
||||
currentIds.every((id, i) => id === serverIds[i]);
|
||||
if (!idsEqual) immediatePayload.image_ids = currentIds;
|
||||
suppressNextSaveRef.current = true;
|
||||
await attemptsApi.saveFollowUpDraft(
|
||||
await attemptsApi.saveDraft(
|
||||
attemptId,
|
||||
'follow_up',
|
||||
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;
|
||||
}
|
||||
const resp = await attemptsApi.setDraftQueue(attemptId, true);
|
||||
return !!resp?.queued;
|
||||
} finally {
|
||||
// presentation-only state handled by caller
|
||||
}
|
||||
@@ -65,36 +48,22 @@ export function useDraftQueue({
|
||||
attemptId,
|
||||
draft?.variant,
|
||||
draft?.image_ids,
|
||||
draft?.queued,
|
||||
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;
|
||||
}
|
||||
const resp = await attemptsApi.setDraftQueue(attemptId, false);
|
||||
return !!resp && !resp.queued;
|
||||
} finally {
|
||||
// presentation-only state handled by caller
|
||||
}
|
||||
return false;
|
||||
}, [attemptId, suppressNextSaveRef, lastServerVersionRef]);
|
||||
}, [attemptId]);
|
||||
|
||||
return { onQueue, onUnqueue } as const;
|
||||
}
|
||||
|
||||
@@ -1,143 +1,121 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useJsonPatchWsStream } from '@/hooks/useJsonPatchWsStream';
|
||||
import { attemptsApi } from '@/lib/api';
|
||||
import type { FollowUpDraft } from 'shared/types';
|
||||
import { inIframe } from '@/vscode/bridge';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { applyPatch } from 'rfc6902';
|
||||
import type { Operation } from 'rfc6902';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import type { Draft, DraftResponse } from 'shared/types';
|
||||
import { useProject } from '@/contexts/project-context';
|
||||
|
||||
type DraftStreamState = { follow_up_draft: FollowUpDraft };
|
||||
interface Drafts {
|
||||
[attemptId: string]: { follow_up: Draft; retry: DraftResponse | null };
|
||||
}
|
||||
|
||||
type DraftsContainer = {
|
||||
drafts: Drafts;
|
||||
};
|
||||
|
||||
type WsJsonPatchMsg = { JsonPatch: Operation[] };
|
||||
type WsFinishedMsg = { finished: boolean };
|
||||
type WsMsg = WsJsonPatchMsg | WsFinishedMsg;
|
||||
|
||||
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 { projectId } = useProject();
|
||||
const drafts = useDraftsStreamState(projectId);
|
||||
|
||||
const endpoint = attemptId
|
||||
? `/api/task-attempts/${attemptId}/follow-up-draft/stream/ws`
|
||||
: 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 } = useJsonPatchWsStream<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 stream
|
||||
}
|
||||
};
|
||||
hydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attemptId, isDraftLoaded]);
|
||||
|
||||
// Handle stream updates
|
||||
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]);
|
||||
const attemptDrafts = useMemo(() => {
|
||||
if (!attemptId || !drafts) return null;
|
||||
return drafts[attemptId] ?? null;
|
||||
}, [drafts, attemptId]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
isDraftLoaded,
|
||||
isConnected,
|
||||
error,
|
||||
lastServerVersionRef,
|
||||
suppressNextSaveRef,
|
||||
forceNextApplyRef,
|
||||
draft: attemptDrafts?.follow_up ?? null,
|
||||
retryDraft: attemptDrafts?.retry ?? null,
|
||||
isRetryLoaded: !!attemptDrafts,
|
||||
isDraftLoaded: !!attemptDrafts,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function useDraftsStreamState(projectId?: string): Drafts | undefined {
|
||||
const endpoint = useMemo(
|
||||
() =>
|
||||
projectId
|
||||
? `/api/drafts/stream/ws?project_id=${encodeURIComponent(projectId)}`
|
||||
: undefined,
|
||||
[projectId]
|
||||
);
|
||||
const wsUrl = useMemo(() => toWsUrl(endpoint), [endpoint]);
|
||||
const isStreamEnabled = !!endpoint && !!wsUrl;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const initialData = useCallback((): DraftsContainer => ({ drafts: {} }), []);
|
||||
const queryKey = useMemo(() => ['ws-json-patch', wsUrl], [wsUrl]);
|
||||
|
||||
const { data } = useQuery<DraftsContainer | undefined>({
|
||||
queryKey,
|
||||
enabled: isStreamEnabled,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
initialData: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreamEnabled) return;
|
||||
const current = queryClient.getQueryData<DraftsContainer | undefined>(
|
||||
queryKey
|
||||
);
|
||||
if (current === undefined) {
|
||||
queryClient.setQueryData<DraftsContainer>(queryKey, initialData());
|
||||
}
|
||||
}, [isStreamEnabled, queryClient, queryKey, initialData]);
|
||||
|
||||
const { getWebSocket } = useWebSocket(
|
||||
wsUrl ?? 'ws://invalid',
|
||||
{
|
||||
share: true,
|
||||
shouldReconnect: () => true,
|
||||
reconnectInterval: (attempt) =>
|
||||
Math.min(8000, 1000 * Math.pow(2, attempt)),
|
||||
retryOnError: true,
|
||||
onMessage: (event) => {
|
||||
try {
|
||||
const msg: WsMsg = JSON.parse(event.data);
|
||||
if ('JsonPatch' in msg) {
|
||||
const patches = msg.JsonPatch;
|
||||
if (!patches.length) return;
|
||||
queryClient.setQueryData<DraftsContainer | undefined>(
|
||||
queryKey,
|
||||
(prev) => {
|
||||
const base = prev ?? initialData();
|
||||
const next = structuredClone(base) as DraftsContainer;
|
||||
applyPatch(next, patches);
|
||||
return next;
|
||||
}
|
||||
);
|
||||
} else if ('finished' in msg) {
|
||||
try {
|
||||
getWebSocket()?.close();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to process WebSocket message:', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
isStreamEnabled
|
||||
);
|
||||
|
||||
return isStreamEnabled ? data?.drafts : undefined;
|
||||
}
|
||||
|
||||
function toWsUrl(endpoint?: string): string | undefined {
|
||||
if (!endpoint) return undefined;
|
||||
try {
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
url.protocol = url.protocol.replace('http', 'ws');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ export function useFollowUpSend({
|
||||
prompt: finalPrompt,
|
||||
variant: selectedVariant,
|
||||
image_ids,
|
||||
});
|
||||
retry_process_id: null,
|
||||
force_when_dirty: null,
|
||||
perform_git_reset: null,
|
||||
} as any);
|
||||
setMessage('');
|
||||
clearComments();
|
||||
onAfterSendCleanup();
|
||||
|
||||
@@ -2,29 +2,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
||||
import { useBranchStatus } from '@/hooks/useBranchStatus';
|
||||
import { showModal } from '@/lib/modals';
|
||||
import {
|
||||
shouldShowInLogs,
|
||||
isCodingAgent,
|
||||
PROCESS_RUN_REASONS,
|
||||
} from '@/constants/processes';
|
||||
import { attemptsApi, executionProcessesApi } from '@/lib/api';
|
||||
import type { ExecutionProcess, TaskAttempt } from 'shared/types';
|
||||
import type {
|
||||
ExecutorActionType,
|
||||
CodingAgentInitialRequest,
|
||||
CodingAgentFollowUpRequest,
|
||||
} from 'shared/types';
|
||||
|
||||
function isCodingAgentActionType(
|
||||
t: ExecutorActionType
|
||||
): t is
|
||||
| ({ type: 'CodingAgentInitialRequest' } & CodingAgentInitialRequest)
|
||||
| ({ type: 'CodingAgentFollowUpRequest' } & CodingAgentFollowUpRequest) {
|
||||
return (
|
||||
t.type === 'CodingAgentInitialRequest' ||
|
||||
t.type === 'CodingAgentFollowUpRequest'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook to retry a process given its executionProcessId and a new prompt.
|
||||
@@ -38,10 +17,8 @@ export function useProcessRetry(attempt: TaskAttempt | undefined) {
|
||||
const attemptId = attempt?.id;
|
||||
|
||||
// Fetch attempt + branch state the same way your component did
|
||||
const { attemptData, refetch: refetchAttempt } =
|
||||
useAttemptExecution(attemptId);
|
||||
const { data: branchStatus, refetch: refetchBranch } =
|
||||
useBranchStatus(attemptId);
|
||||
const { attemptData } = useAttemptExecution(attemptId);
|
||||
useBranchStatus(attemptId);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -79,149 +56,49 @@ export function useProcessRetry(attempt: TaskAttempt | undefined) {
|
||||
/**
|
||||
* Primary entrypoint: retry a process with a new prompt.
|
||||
*/
|
||||
const retryProcess = useCallback(
|
||||
// Initialize retry mode by creating a retry draft populated from the process
|
||||
const startRetry = useCallback(
|
||||
async (executionProcessId: string, newPrompt: string) => {
|
||||
if (!attemptId) return;
|
||||
|
||||
const proc = getProcessById(executionProcessId);
|
||||
if (!proc) return;
|
||||
|
||||
// Respect current disabled state
|
||||
const { disabled } = getRetryDisabledState(executionProcessId);
|
||||
if (disabled) return;
|
||||
|
||||
type WithBefore = { before_head_commit?: string | null };
|
||||
const before =
|
||||
(proc as WithBefore | undefined)?.before_head_commit || null;
|
||||
|
||||
// Try to gather comparison info (best-effort)
|
||||
let targetSubject: string | null = null;
|
||||
let commitsToReset: number | null = null;
|
||||
let isLinear: boolean | null = null;
|
||||
|
||||
if (before) {
|
||||
try {
|
||||
const { commitsApi } = await import('@/lib/api');
|
||||
const info = await commitsApi.getInfo(attemptId, before);
|
||||
targetSubject = info.subject;
|
||||
const cmp = await commitsApi.compareToHead(attemptId, before);
|
||||
commitsToReset = cmp.is_linear ? cmp.ahead_from_head : null;
|
||||
isLinear = cmp.is_linear;
|
||||
} catch {
|
||||
// ignore best-effort enrichments
|
||||
}
|
||||
}
|
||||
|
||||
const head = branchStatus?.head_oid || null;
|
||||
const dirty = !!branchStatus?.has_uncommitted_changes;
|
||||
const needReset = !!(before && (before !== head || dirty));
|
||||
const canGitReset = needReset && !dirty;
|
||||
|
||||
// Compute “later processes” context for the dialog
|
||||
const procs = (attemptData.processes || []).filter(
|
||||
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
|
||||
);
|
||||
const idx = procs.findIndex((p) => p.id === executionProcessId);
|
||||
const later = idx >= 0 ? procs.slice(idx + 1) : [];
|
||||
const laterCount = later.length;
|
||||
const laterCoding = later.filter((p) =>
|
||||
isCodingAgent(p.run_reason)
|
||||
).length;
|
||||
const laterSetup = later.filter(
|
||||
(p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT
|
||||
).length;
|
||||
const laterCleanup = later.filter(
|
||||
(p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT
|
||||
).length;
|
||||
|
||||
// Ask user for confirmation / reset options
|
||||
let modalResult:
|
||||
| {
|
||||
action: 'confirmed' | 'canceled';
|
||||
performGitReset?: boolean;
|
||||
forceWhenDirty?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
modalResult = await showModal<
|
||||
typeof modalResult extends infer T
|
||||
? T extends object
|
||||
? T
|
||||
: never
|
||||
: never
|
||||
>('restore-logs', {
|
||||
targetSha: before,
|
||||
targetSubject,
|
||||
commitsToReset,
|
||||
isLinear,
|
||||
laterCount,
|
||||
laterCoding,
|
||||
laterSetup,
|
||||
laterCleanup,
|
||||
needGitReset: needReset,
|
||||
canGitReset,
|
||||
hasRisk: dirty,
|
||||
uncommittedCount: branchStatus?.uncommitted_count ?? 0,
|
||||
untrackedCount: branchStatus?.untracked_count ?? 0,
|
||||
// Defaults
|
||||
initialWorktreeResetOn: true,
|
||||
initialForceReset: false,
|
||||
});
|
||||
} catch {
|
||||
// user closed dialog
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modalResult || modalResult.action !== 'confirmed') return;
|
||||
|
||||
let variant: string | null = null;
|
||||
|
||||
const typ = proc?.executor_action?.typ; // type: ExecutorActionType
|
||||
|
||||
if (typ && isCodingAgentActionType(typ)) {
|
||||
// executor_profile_id is ExecutorProfileId -> has `variant: string | null`
|
||||
variant = typ.executor_profile_id.variant;
|
||||
try {
|
||||
const details =
|
||||
await executionProcessesApi.getDetails(executionProcessId);
|
||||
const typ: any = details?.executor_action?.typ as any;
|
||||
if (
|
||||
typ &&
|
||||
(typ.type === 'CodingAgentInitialRequest' ||
|
||||
typ.type === 'CodingAgentFollowUpRequest')
|
||||
) {
|
||||
variant = (typ.executor_profile_id?.variant as string | null) ?? null;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Perform the replacement
|
||||
setBusy(true);
|
||||
try {
|
||||
setBusy(true);
|
||||
const { attemptsApi } = await import('@/lib/api');
|
||||
await attemptsApi.replaceProcess(attemptId, {
|
||||
process_id: executionProcessId,
|
||||
await attemptsApi.saveDraft(attemptId, 'retry', {
|
||||
retry_process_id: executionProcessId,
|
||||
prompt: newPrompt,
|
||||
variant,
|
||||
perform_git_reset: modalResult.performGitReset ?? true,
|
||||
force_when_dirty: modalResult.forceWhenDirty ?? false,
|
||||
image_ids: [],
|
||||
version: null as any,
|
||||
});
|
||||
|
||||
// Refresh local caches
|
||||
await refetchAttempt();
|
||||
await refetchBranch();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
attemptId,
|
||||
attemptData.processes,
|
||||
branchStatus?.head_oid,
|
||||
branchStatus?.has_uncommitted_changes,
|
||||
branchStatus?.uncommitted_count,
|
||||
branchStatus?.untracked_count,
|
||||
getProcessById,
|
||||
getRetryDisabledState,
|
||||
refetchAttempt,
|
||||
refetchBranch,
|
||||
]
|
||||
[attemptId, getProcessById, getRetryDisabledState]
|
||||
);
|
||||
|
||||
return {
|
||||
retryProcess,
|
||||
busy,
|
||||
anyRunning,
|
||||
/** Helpful for buttons/tooltips */
|
||||
startRetry,
|
||||
getRetryDisabledState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"buttons": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"send": "Send",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"buttons": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"send": "Enviar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"create": "Crear",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"buttons": {
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"send": "送信",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"create": "作成",
|
||||
|
||||
@@ -34,11 +34,12 @@ import {
|
||||
UpdateTaskTemplate,
|
||||
UserSystemInfo,
|
||||
GitHubServiceError,
|
||||
UpdateRetryFollowUpDraftRequest,
|
||||
McpServerQuery,
|
||||
UpdateMcpServersBody,
|
||||
GetMcpServerResponse,
|
||||
ImageResponse,
|
||||
FollowUpDraftResponse,
|
||||
DraftResponse,
|
||||
UpdateFollowUpDraftRequest,
|
||||
GitOperationError,
|
||||
ApprovalResponse,
|
||||
@@ -50,8 +51,8 @@ import {
|
||||
// Re-export types for convenience
|
||||
export type { RepositoryInfo } from 'shared/types';
|
||||
export type {
|
||||
FollowUpDraftResponse,
|
||||
UpdateFollowUpDraftRequest,
|
||||
UpdateRetryFollowUpDraftRequest,
|
||||
} from 'shared/types';
|
||||
|
||||
class ApiError<E = unknown> extends Error {
|
||||
@@ -377,38 +378,51 @@ export const attemptsApi = {
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
getFollowUpDraft: async (
|
||||
attemptId: string
|
||||
): Promise<FollowUpDraftResponse> => {
|
||||
getDraft: async (
|
||||
attemptId: string,
|
||||
type: 'follow_up' | 'retry'
|
||||
): Promise<DraftResponse> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/follow-up-draft`
|
||||
`/api/task-attempts/${attemptId}/draft?type=${encodeURIComponent(type)}`
|
||||
);
|
||||
return handleApiResponse<FollowUpDraftResponse>(response);
|
||||
return handleApiResponse<DraftResponse>(response);
|
||||
},
|
||||
|
||||
saveFollowUpDraft: async (
|
||||
saveDraft: async (
|
||||
attemptId: string,
|
||||
data: UpdateFollowUpDraftRequest
|
||||
): Promise<FollowUpDraftResponse> => {
|
||||
type: 'follow_up' | 'retry',
|
||||
data: UpdateFollowUpDraftRequest | UpdateRetryFollowUpDraftRequest
|
||||
): Promise<DraftResponse> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/follow-up-draft`,
|
||||
`/api/task-attempts/${attemptId}/draft?type=${encodeURIComponent(type)}`,
|
||||
{
|
||||
// Server expects PUT for saving/updating the draft
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
return handleApiResponse<FollowUpDraftResponse>(response);
|
||||
return handleApiResponse<DraftResponse>(response);
|
||||
},
|
||||
|
||||
setFollowUpQueue: async (
|
||||
deleteDraft: async (
|
||||
attemptId: string,
|
||||
type: 'follow_up' | 'retry'
|
||||
): Promise<void> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/draft?type=${encodeURIComponent(type)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
setDraftQueue: async (
|
||||
attemptId: string,
|
||||
queued: boolean,
|
||||
expectedQueued?: boolean,
|
||||
expectedVersion?: number
|
||||
): Promise<FollowUpDraftResponse> => {
|
||||
expectedVersion?: number,
|
||||
type: 'follow_up' | 'retry' = 'follow_up'
|
||||
): Promise<DraftResponse> => {
|
||||
const response = await makeRequest(
|
||||
`/api/task-attempts/${attemptId}/follow-up-draft/queue`,
|
||||
`/api/task-attempts/${attemptId}/draft/queue?type=${encodeURIComponent(type)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -418,7 +432,7 @@ export const attemptsApi = {
|
||||
}),
|
||||
}
|
||||
);
|
||||
return handleApiResponse<FollowUpDraftResponse>(response);
|
||||
return handleApiResponse<DraftResponse>(response);
|
||||
},
|
||||
|
||||
deleteFile: async (
|
||||
@@ -786,6 +800,28 @@ export const imagesApi = {
|
||||
return handleApiResponse<ImageResponse>(response);
|
||||
},
|
||||
|
||||
uploadForTask: async (taskId: string, file: File): Promise<ImageResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch(`/api/images/task/${taskId}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new ApiError(
|
||||
`Failed to upload image: ${errorText}`,
|
||||
response.status,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
return handleApiResponse<ImageResponse>(response);
|
||||
},
|
||||
|
||||
delete: async (imageId: string): Promise<void> => {
|
||||
const response = await makeRequest(`/api/images/${imageId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
15
frontend/src/utils/markdownImages.ts
Normal file
15
frontend/src/utils/markdownImages.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ImageResponse } from 'shared/types';
|
||||
|
||||
export function imageToMarkdown(image: ImageResponse): string {
|
||||
return ``;
|
||||
}
|
||||
|
||||
export function appendImageMarkdown(
|
||||
prev: string,
|
||||
image: ImageResponse
|
||||
): string {
|
||||
const markdownText = imageToMarkdown(image);
|
||||
if (prev.trim() === '') return markdownText + '\n';
|
||||
const needsNewline = !prev.endsWith('\n');
|
||||
return prev + (needsNewline ? '\n' : '') + markdownText + '\n';
|
||||
}
|
||||
Reference in New Issue
Block a user