import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateLocalTask, type LocalTask } from '../../api'; import { useDraft } from '../../hooks/useDraft'; import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize'; import { isComposerLocked } from '../../lib/composerLock'; import { readAttachments, type Attachment } from '../../lib/fileAttachments'; import { ChatComposerPanel } from './ChatComposerPanel'; import { appendFileSelectionToBody } from '../../lib/file-selection'; import { useFileSelection } from '../../lib/file-selection-context'; interface ChatComposerProps { /** * いま走っている LLM 呼び出しの実プロンプトサイズ(SSE の promptProgress.total)。 * 5 秒巡回の値は「直前の呼び出しの結果」なので、ツールコールが続く間ずっと * 動かない。次の呼び出しが始まった瞬間にこの値へ切り替える。 */ livePromptTokens?: number; task: LocalTask; commentsLength: number; onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>, opts?: { grounded?: boolean }) => Promise; onCancel?: () => Promise; /** 入力欄の下の行の左端に置く、このチャットそのものへの操作(issue #1433 原因 2 段 2)。 */ composerActions?: React.ReactNode; } /** チャット画面へのドロップを入力欄へ届けるための窓口。 */ export interface ChatComposerHandle { addFiles: (files: File[]) => Promise; } export const ChatComposer = forwardRef(function ChatComposer( { task, commentsLength, onSubmit, onCancel, livePromptTokens, composerActions }, ref, ) { const { t } = useTranslation('chat'); // 選んだ範囲(#1463 段 2)。送る瞬間に本文へ展開して、そのあと外す。 const { selection: fileSelection, clearSelection: clearFileSelection } = useFileSelection(); const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`); const [draft, setDraft] = useState(restoredDraft ?? ''); const [attachments, setAttachments] = useState([]); // 非同期の読み込み中に来た次のドロップからも「今の添付一覧」が見えるようにする控え const attachmentsRef = useRef([]); const updateAttachments = useCallback((next: Attachment[]) => { attachmentsRef.current = next; setAttachments(next); }, []); const [submitting, setSubmitting] = useState(false); const [cancelling, setCancelling] = useState(false); const [sendError, setSendError] = useState(null); const [showPromptCoach, setShowPromptCoach] = useState(false); const [grounded, setGrounded] = useState(false); // コンテキスト構成 (#1300)。タスクごとに覚える設定なので、画面の手元に控えを // 持たずタスクの値をそのまま映す。切り替えたらその場で保存し、再取得で戻す。 const qc = useQueryClient(); const compactContext = task.options?.compactContext === true; // 保存に失敗すると、画面のボタンは元の状態へ戻るだけになる。理由が出ないと // 「押しても効かない」としか見えないので、失敗はその場に文字で出す。 const [contextModeError, setContextModeError] = useState(null); const compactContextMutation = useMutation({ mutationFn: (next: boolean) => updateLocalTask(task.id, { options: { compactContext: next } }), onMutate: () => { setContextModeError(null); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['localTask', task.id] }); qc.invalidateQueries({ queryKey: ['localTasks'] }); }, onError: (e: unknown) => { setContextModeError( e instanceof Error && e.message ? e.message : t('pane.contextModeSaveFailed'), ); }, }); const composerRef = useRef(null); const fileInputRef = useRef(null); const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []); useEffect(() => { if (!needsAutosizeFallback) return; const el = composerRef.current; if (el) autosizeTextarea(el, 192); }, [draft, needsAutosizeFallback]); const submitBaselineRef = useRef(null); const submitTimeoutRef = useRef | 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 awaitingPolicyApproval = jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'policy_approval'; // 人の確認(CAPTCHA)待ち(#1199 段階 2)。カードで「解決した」か「スキップ」を // 押すまで、普通のメッセージは送れない(サーバーも 409 で断る)。 const awaitingCaptcha = jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'captcha'; const composerLocked = isComposerLocked(task.latestJob); 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); }; }, []); // ファイル選択・貼り付け・ドロップの 3 経路をここに集約する。 // 命名規則(image.png の日時化・重複回避)は新規タスク作成画面と共通。 const addFiles = useCallback(async (files: ArrayLike) => { if (files.length === 0) return; // 連続ドロップでも名前がぶつからないよう、最新の添付一覧は ref から読む const { added, failed } = await readAttachments(files, attachmentsRef.current); if (added.length > 0) updateAttachments([...attachmentsRef.current, ...added]); if (failed > 0) setSendError(t('pane.attachFailed', { count: failed })); }, [t, updateAttachments]); const handleFiles = async (files: FileList | null) => { if (files) await addFiles(files); }; useImperativeHandle(ref, () => ({ addFiles }), [addFiles]); const removeAttachment = (name: string) => { updateAttachments(attachmentsRef.current.filter(a => a.name !== name)); }; const handleSubmit = async () => { if ((!draft.trim() && attachments.length === 0) || submitting) return; setSendError(null); setSubmitting(true); submitBaselineRef.current = commentsLength; try { const currentAttachments = attachments.length > 0 ? attachments : undefined; /* 選んだ範囲は**送る瞬間に本文へ展開する**(#1463 段 2)。添付ファイルには しない——添付は中身が必須で、送るとワークスペースの `input` に実ファイルが 落ちる。選ぶたびに断片ファイルが溜まることになる。サーバの受け口は そのままで足りる。 */ const body = fileSelection ? appendFileSelectionToBody(draft, fileSelection, (key, vars) => t(key, vars ?? {}) as string) : draft; if (grounded) { await onSubmit(body, currentAttachments, { grounded: true }); } else { await onSubmit(body, currentAttachments); } setDraft(''); updateAttachments([]); setGrounded(false); // 送った札は外す。同じ範囲をもう一度添えたければ選び直す。 clearFileSelection(); 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(); await addFiles(files); }; 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 ( fileInputRef.current?.click()} onRemoveAttachment={removeAttachment} onFileChange={handleFiles} onPaste={handlePaste} onKeyDown={handleKeyDown} onDraftChange={value => { setDraft(value); saveDraft(value); }} showPromptCoach={showPromptCoach} onTogglePromptCoach={() => setShowPromptCoach(value => !value)} grounded={grounded} onToggleGrounded={() => setGrounded(value => !value)} compactContext={compactContext} onToggleCompactContext={() => compactContextMutation.mutate(!compactContext)} compactContextPending={compactContextMutation.isPending} contextModeError={contextModeError} onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }} onResend={handleSubmit} composerRef={composerRef} fileInputRef={fileInputRef} composerActions={composerActions} /> ); });