refactor: TaskfollowupSection followup (#762)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user