refactor: TaskfollowupSection followup (#762)

This commit is contained in:
Solomon
2025-09-17 21:38:24 +01:00
committed by GitHub
parent 023e52e555
commit 904827e44b
6 changed files with 107 additions and 137 deletions

View File

@@ -201,7 +201,6 @@ export function TaskDetailsPanel({
<TaskFollowUpSection
task={task}
projectId={projectId}
selectedAttemptId={selectedAttempt?.id}
jumpToLogsTab={jumpToLogsTab}
/>
@@ -244,7 +243,6 @@ export function TaskDetailsPanel({
<TaskFollowUpSection
task={task}
projectId={projectId}
selectedAttemptId={selectedAttempt?.id}
jumpToLogsTab={jumpToLogsTab}
/>

View File

@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
import { Alert, AlertDescription } from '@/components/ui/alert';
//
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { imagesApi } from '@/lib/api.ts';
import type { TaskWithAttemptStatus } from 'shared/types';
import { useBranchStatus } from '@/hooks';
@@ -26,7 +26,6 @@ import { FollowUpConflictSection } from '@/components/tasks/follow-up/FollowUpCo
import { FollowUpEditorCard } from '@/components/tasks/follow-up/FollowUpEditorCard';
import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
import { useDraftEdits } from '@/hooks/follow-up/useDraftEdits';
import { useDraftImages } from '@/hooks/follow-up/useDraftImages';
import { useDraftAutosave } from '@/hooks/follow-up/useDraftAutosave';
import { useDraftQueue } from '@/hooks/follow-up/useDraftQueue';
import { useFollowUpSend } from '@/hooks/follow-up/useFollowUpSend';
@@ -34,14 +33,12 @@ import { useDefaultVariant } from '@/hooks/follow-up/useDefaultVariant';
interface TaskFollowUpSectionProps {
task: TaskWithAttemptStatus;
projectId: string;
selectedAttemptId?: string;
jumpToLogsTab: () => void;
}
export function TaskFollowUpSection({
task,
projectId,
selectedAttemptId,
jumpToLogsTab,
}: TaskFollowUpSectionProps) {
@@ -69,22 +66,21 @@ export function TaskFollowUpSection({
} = useDraftStream(selectedAttemptId);
// Editor state
const { message: followUpMessage, setMessage: setFollowUpMessage } =
useDraftEdits({
draft,
lastServerVersionRef,
suppressNextSaveRef,
forceNextApplyRef,
});
// Images manager
const {
message: followUpMessage,
setMessage: setFollowUpMessage,
images,
setImages,
newlyUploadedImageIds,
handleImageUploaded,
clearImagesAndUploads,
} = useDraftImages({ draft, taskId: task.id });
} = useDraftEdits({
draft,
lastServerVersionRef,
suppressNextSaveRef,
forceNextApplyRef,
taskId: task.id,
});
// Presentation-only: show/hide image upload panel
const [showImageUpload, setShowImageUpload] = useState(false);
@@ -119,10 +115,12 @@ export function TaskFollowUpSection({
// Autosave draft when editing
const { isSaving, saveStatus } = useDraftAutosave({
attemptId: selectedAttemptId,
draft,
message: followUpMessage,
selectedVariant,
images,
serverDraft: draft,
current: {
prompt: followUpMessage,
variant: selectedVariant,
image_ids: images.map((img) => img.id),
},
isQueuedUI: displayQueued,
isDraftSending: !!draft?.sending,
isQueuing: isQueuing,
@@ -187,18 +185,13 @@ export function TaskFollowUpSection({
displayQueued || isQueuing || isUnqueuing || !!draft?.sending;
const isEditable = isDraftLoaded && !isDraftLocked;
const appendToFollowUpMessage = useCallback(
(text: string) => {
const appendToFollowUpMessage = (text: string) => {
setFollowUpMessage((prev) => {
const sep =
followUpMessage.trim().length === 0
? ''
: followUpMessage.endsWith('\n')
? '\n'
: '\n\n';
setFollowUpMessage(followUpMessage + sep + text);
},
[followUpMessage, setFollowUpMessage]
);
prev.trim().length === 0 ? '' : prev.endsWith('\n') ? '\n' : '\n\n';
return prev + sep + text;
});
};
// When a process completes (e.g., agent resolved conflicts), refresh branch status promptly
const prevRunningRef = useRef<boolean>(isAttemptRunning);
@@ -333,9 +326,6 @@ export function TaskFollowUpSection({
}
}}
disabled={!isEditable}
projectId={projectId}
rows={1}
maxRows={6}
showLoadingOverlay={isUnqueuing || !isDraftLoaded}
/>
<FollowUpStatusRow

View File

@@ -1,6 +1,7 @@
import { Loader2 } from 'lucide-react';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { cn } from '@/lib/utils';
import { useProject } from '@/contexts/project-context';
type Props = {
placeholder: string;
@@ -8,9 +9,6 @@ type Props = {
onChange: (v: string) => void;
onKeyDown: (e: React.KeyboardEvent<Element>) => void;
disabled: boolean;
projectId: string;
rows?: number;
maxRows?: number;
// Loading overlay
showLoadingOverlay: boolean;
};
@@ -21,11 +19,9 @@ export function FollowUpEditorCard({
onChange,
onKeyDown,
disabled,
projectId,
rows = 1,
maxRows = 6,
showLoadingOverlay,
}: Props) {
const { projectId } = useProject();
return (
<div className="relative">
<FileSearchTextarea
@@ -36,8 +32,8 @@ export function FollowUpEditorCard({
className={cn('flex-1 min-h-[40px] resize-none')}
disabled={disabled}
projectId={projectId}
rows={rows}
maxRows={maxRows}
rows={1}
maxRows={6}
/>
{showLoadingOverlay && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/60">

View File

@@ -1,15 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { attemptsApi, type UpdateFollowUpDraftRequest } from '@/lib/api';
import type { FollowUpDraft, ImageResponse } from 'shared/types';
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;
draft: FollowUpDraft | null;
message: string;
selectedVariant: string | null;
images: ImageResponse[];
serverDraft: FollowUpDraft | null;
current: DraftData;
isQueuedUI: boolean;
isDraftSending: boolean;
isQueuing: boolean;
@@ -21,10 +21,8 @@ type Args = {
export function useDraftAutosave({
attemptId,
draft,
message,
selectedVariant,
images,
serverDraft,
current,
isQueuedUI,
isDraftSending,
isQueuing,
@@ -52,11 +50,12 @@ export function useDraftAutosave({
const saveDraft = async () => {
const payload: Partial<UpdateFollowUpDraftRequest> = {};
if (draft && message !== (draft.prompt || '')) payload.prompt = message;
if ((draft?.variant ?? null) !== (selectedVariant ?? null))
payload.variant = (selectedVariant ?? null) as string | null;
const currentIds = images.map((img) => img.id);
const serverIds = (draft?.image_ids as string[] | undefined) ?? [];
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]);
@@ -96,12 +95,12 @@ export function useDraftAutosave({
};
}, [
attemptId,
draft?.prompt,
draft?.variant,
draft?.image_ids,
message,
selectedVariant,
images,
serverDraft?.prompt,
serverDraft?.variant,
serverDraft?.image_ids,
current.prompt,
current.variant,
current.image_ids,
isQueuedUI,
isDraftSending,
isQueuing,

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import type { FollowUpDraft } from 'shared/types';
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({
@@ -13,10 +15,16 @@ export function useDraftEdits({
lastServerVersionRef,
suppressNextSaveRef,
forceNextApplyRef,
taskId,
}: Args) {
const [message, setMessage] = useState('');
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;
@@ -28,7 +36,7 @@ export function useDraftEdits({
const shouldForce = forceNextApplyRef.current;
const allowApply = isInitial || shouldForce || !localDirtyRef.current;
if (allowApply && incomingVersion >= lastServerVersionRef.current) {
setMessage(draft.prompt || '');
setMessageInner(draft.prompt || '');
localDirtyRef.current = false;
lastServerVersionRef.current = incomingVersion;
if (shouldForce) forceNextApplyRef.current = false;
@@ -38,11 +46,59 @@ export function useDraftEdits({
}
}, [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: string) => {
setMessage: (v: React.SetStateAction<string>) => {
localDirtyRef.current = true;
setMessage(v);
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,69 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FollowUpDraft, ImageResponse } from 'shared/types';
import { imagesApi } from '@/lib/api';
type Args = {
draft: FollowUpDraft | null;
taskId: string;
};
export function useDraftImages({ draft, taskId }: Args) {
const [images, setImages] = useState<ImageResponse[]>([]);
const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState<string[]>(
[]
);
const imagesDirtyRef = useRef<boolean>(false);
useEffect(() => {
if (!draft) return;
const serverIds = (draft.image_ids || []) as string[];
const wantIds = new Set(serverIds);
const haveIds = new Set(images.map((img) => img.id));
const equal =
haveIds.size === wantIds.size &&
Array.from(haveIds).every((id) => wantIds.has(id));
if (equal) {
// Server and UI are aligned; no longer locally dirty
imagesDirtyRef.current = false;
// Do not clear newlyUploadedImageIds automatically; keep until send/cleanup
return;
}
if (imagesDirtyRef.current) {
// Local edits pending; avoid clobbering UI with server list
return;
}
// Adopt server list (UI not dirty)
imagesApi
.getTaskImages(taskId)
.then((all) => {
const next = all.filter((img) => wantIds.has(img.id));
setImages(next);
// Clear newly uploaded IDs when adopting server list
setNewlyUploadedImageIds([]);
})
.catch(() => void 0);
}, [draft?.image_ids, taskId, images]);
const handleImageUploaded = useCallback((image: ImageResponse) => {
imagesDirtyRef.current = true;
setImages((prev) => [...prev, image]);
setNewlyUploadedImageIds((prev) => [...prev, image.id]);
}, []);
const clearImagesAndUploads = useCallback(() => {
imagesDirtyRef.current = false;
setImages([]);
setNewlyUploadedImageIds([]);
}, []);
return {
images,
setImages,
newlyUploadedImageIds,
handleImageUploaded,
clearImagesAndUploads,
} as const;
}