Consolidate Retry and Follow-up (#800)

This commit is contained in:
Solomon
2025-09-30 13:09:50 +01:00
committed by GitHub
parent 71bfe9ac0b
commit f9878e9183
55 changed files with 3644 additions and 2294 deletions

View File

@@ -54,6 +54,7 @@
"react-virtuoso": "^4.14.0",
"react-window": "^1.8.11",
"rfc6902": "^5.1.2",
"react-use-websocket": "^4.7.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"vibe-kanban-web-companion": "^0.0.4",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = `![${image.original_name}](${image.file_path})`;
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"

View File

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

View File

@@ -126,7 +126,7 @@ export function ImageUploadSection({
}
}
},
[images, onImagesChange, onUpload, disabled]
[images, onImagesChange, onImageUploaded, onUpload, disabled]
);
const handleDrop = useCallback(

View File

@@ -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: {

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -2,6 +2,7 @@
"buttons": {
"save": "Save",
"cancel": "Cancel",
"send": "Send",
"delete": "Delete",
"edit": "Edit",
"create": "Create",

View File

@@ -2,6 +2,7 @@
"buttons": {
"save": "Guardar",
"cancel": "Cancelar",
"send": "Enviar",
"delete": "Eliminar",
"edit": "Editar",
"create": "Crear",

View File

@@ -2,6 +2,7 @@
"buttons": {
"save": "保存",
"cancel": "キャンセル",
"send": "送信",
"delete": "削除",
"edit": "編集",
"create": "作成",

View File

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

View File

@@ -0,0 +1,15 @@
import type { ImageResponse } from 'shared/types';
export function imageToMarkdown(image: ImageResponse): string {
return `![${image.original_name}](${image.file_path})`;
}
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';
}