sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { LocalTask } from '../../api';
|
||||
import { useDraft } from '../../hooks/useDraft';
|
||||
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
||||
import { toBase64 } from '../../lib/fileAttachments';
|
||||
import { ChatComposerPanel } from './ChatComposerPanel';
|
||||
|
||||
interface ChatComposerProps {
|
||||
task: LocalTask;
|
||||
commentsLength: number;
|
||||
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onCancel?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
|
||||
const [draft, setDraft] = useState(restoredDraft ?? '');
|
||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [showPromptCoach, setShowPromptCoach] = useState(false);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsAutosizeFallback) return;
|
||||
const el = composerRef.current;
|
||||
if (el) autosizeTextarea(el, 192);
|
||||
}, [draft, needsAutosizeFallback]);
|
||||
|
||||
const submitBaselineRef = useRef<number | null>(null);
|
||||
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
||||
const hasActiveJob = isBusy || isPending;
|
||||
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
||||
const inputLocked = jobStatus === 'dispatching';
|
||||
const awaitingToolApproval =
|
||||
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
||||
const composerLocked = inputLocked || awaitingToolApproval;
|
||||
|
||||
const releaseSubmitting = () => {
|
||||
if (submitTimeoutRef.current) {
|
||||
clearTimeout(submitTimeoutRef.current);
|
||||
submitTimeoutRef.current = null;
|
||||
}
|
||||
submitBaselineRef.current = null;
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitting) return;
|
||||
const baseline = submitBaselineRef.current;
|
||||
if (baseline === null) return;
|
||||
if (commentsLength > baseline && hasActiveJob) {
|
||||
releaseSubmitting();
|
||||
}
|
||||
}, [submitting, commentsLength, hasActiveJob]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const converted = await Promise.all(
|
||||
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) })),
|
||||
);
|
||||
setAttachments(prev => [...prev, ...converted]);
|
||||
};
|
||||
|
||||
const removeAttachment = (name: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.name !== name));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
||||
setSendError(null);
|
||||
setSubmitting(true);
|
||||
submitBaselineRef.current = commentsLength;
|
||||
try {
|
||||
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
|
||||
setDraft('');
|
||||
setAttachments([]);
|
||||
clearDraft();
|
||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
|
||||
releaseSubmitting();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
const converted = await Promise.all(
|
||||
files.map(async f => {
|
||||
const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name;
|
||||
return { name, contentBase64: await toBase64(f) };
|
||||
}),
|
||||
);
|
||||
setAttachments(prev => [...prev, ...converted]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!onCancel || cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancel();
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatComposerPanel
|
||||
task={task}
|
||||
draft={draft}
|
||||
attachments={attachments}
|
||||
submitting={submitting}
|
||||
cancelling={cancelling}
|
||||
sendError={sendError}
|
||||
isBusy={isBusy}
|
||||
isPending={isPending}
|
||||
canInterject={canInterject}
|
||||
inputLocked={inputLocked}
|
||||
awaitingToolApproval={awaitingToolApproval}
|
||||
composerLocked={composerLocked}
|
||||
hasActiveJob={hasActiveJob}
|
||||
jobStatus={jobStatus}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel ? handleCancel : undefined}
|
||||
onAttachClick={() => fileInputRef.current?.click()}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onFileChange={handleFiles}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDraftChange={value => { setDraft(value); saveDraft(value); }}
|
||||
showPromptCoach={showPromptCoach}
|
||||
onTogglePromptCoach={() => setShowPromptCoach(value => !value)}
|
||||
onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }}
|
||||
onResend={handleSubmit}
|
||||
composerRef={composerRef}
|
||||
fileInputRef={fileInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user