import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { LocalTask, LocalTaskComment } from '../../api'; import { ChatMessage } from './ChatMessage'; import { isThinkingComment, hasTrailingThinking } from './thinkingUtils'; import { ChatPetOverlay } from '../pets/ChatPetOverlay'; import { ContextUsageGauge } from '../detail/ContextUsageGauge'; import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup'; import { SubtaskInlineCard } from './SubtaskInlineCard'; import RotatingTips from './RotatingTips'; import { ToolRequestApproval } from './ToolRequestApproval'; import { PackageRequestApproval } from './PackageRequestApproval'; import { DelegateLiveConsole } from './DelegateLiveConsole'; import { useJobStream } from '../../hooks/useJobStream'; import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract'; import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize'; async function toBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = String(reader.result ?? ''); resolve(result.includes(',') ? result.split(',')[1]! : result); }; reader.onerror = () => reject(reader.error ?? new Error('file read error')); reader.readAsDataURL(file); }); } interface ChatPaneProps { task: LocalTask; comments: LocalTaskComment[]; onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise; onCancel?: () => Promise; /** Tablet: the detail tabs. Tapping one opens the detail drawer to that tab, * replacing the single "詳細" button so you jump straight where you want. */ detailTabs?: Array<{ id: string; labelKey: string }>; activeDetailTab?: string; onSelectDetailTab?: (id: string) => void; } export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) { const { t } = useTranslation('chat'); const { t: dt } = useTranslation('detail'); const [draft, setDraft] = useState(''); const [attachments, setAttachments] = useState>([]); const [submitting, setSubmitting] = useState(false); const [cancelling, setCancelling] = useState(false); const [sendError, setSendError] = useState(null); const scrollRef = useRef(null); const fileInputRef = useRef(null); const composerRef = useRef(null); // Firefox など field-sizing 未対応環境だけ JS で高さ追従(判定は初回のみ)。 const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []); useEffect(() => { if (!needsAutosizeFallback) return; const el = composerRef.current; if (el) autosizeTextarea(el, 192); // 192px ≈ 8行 (max-h-48 と一致させる) }, [draft, needsAutosizeFallback]); // Snapshot of comments.length at submit start. We hold "submitting" until // (a) the new user comment is reflected in the list AND (b) the job is // visibly busy (= picked up by a worker). Without this, the gap between the // POST resolving and the worker dispatching the job lets the user fire // multiple sends in a row. const submitBaselineRef = useRef(null); const submitTimeoutRef = useRef | null>(null); const [isAtBottom, setIsAtBottom] = useState(true); const [newMessageCount, setNewMessageCount] = useState(0); const prevCommentCountRef = useRef(comments.length); const checkIfAtBottom = useCallback(() => { const el = scrollRef.current; if (!el) return true; return el.scrollHeight - el.scrollTop - el.clientHeight < 80; }, []); const scrollToBottom = useCallback(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; setIsAtBottom(true); setNewMessageCount(0); } }, []); useEffect(() => { const el = scrollRef.current; if (!el) return; const handler = () => setIsAtBottom(checkIfAtBottom()); el.addEventListener('scroll', handler, { passive: true }); return () => el.removeEventListener('scroll', handler); }, [checkIfAtBottom]); // タスクを開いたとき(ChatPane はチャット切替で remount される)は、最新の // メッセージが見える位置で開けるよう、初回描画前に最下部へジャンプする。 // useLayoutEffect にすることで一番上→最下部のちらつきを避ける。以降の追従は // 上の comments.length 監視エフェクトが担う。 useLayoutEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const delta = comments.length - prevCommentCountRef.current; prevCommentCountRef.current = comments.length; if (delta <= 0) return; if (isAtBottom) { requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }); } else { setNewMessageCount(prev => prev + delta); } }, [comments.length, isAtBottom]); 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 releaseSubmitting = () => { if (submitTimeoutRef.current) { clearTimeout(submitTimeoutRef.current); submitTimeoutRef.current = null; } submitBaselineRef.current = null; setSubmitting(false); }; const handleSubmit = async () => { if ((!draft.trim() && attachments.length === 0) || submitting) return; setSendError(null); setSubmitting(true); submitBaselineRef.current = comments.length; try { await onSubmit(draft, attachments.length > 0 ? attachments : undefined); setDraft(''); setAttachments([]); // Hold the lock until the agent is visibly responding (see effect below). // Safety net: if the worker never picks the job up (queue stuck, server // crash, etc.), release the lock after 10s so the user isn't trapped. 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: React.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: React.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); } }; const jobStatus = task.latestJob?.status; const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus); // Most-recent content-field tool with decoded content to show live. const liveToolContent = useMemo(() => { const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD); for (let k = entries.length - 1; k >= 0; k--) { const text = extractStreamingField(entries[k].name, entries[k].rawArgs); if (text) return { name: entries[k].name, text }; } return null; }, [toolCallStream]); const liveToolRef = useRef(null); useEffect(() => { if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight; }, [liveToolContent?.text]); const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks'; const isWaitingSubtasks = jobStatus === 'waiting_subtasks'; const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks'; const inputLocked = jobStatus === 'dispatching'; // A job is already accepted but not yet running. The task is NOT idle: a new // message must fold into this pending job (server reuses it, no duplicate), // so the composer presents it as an addition rather than a fresh request. const isPending = jobStatus === 'queued' || jobStatus === 'retry'; const hasActiveJob = isBusy || isPending; // Tool-request mechanism: while the agent is paused waiting for the user to // approve/deny a requested tool, lock the normal composer. Sending a regular // message here would create a SECOND job (a waiting_human reply) that runs in // parallel with the original once it's resumed by the approval → duplicate // execution. Gate on the JOB's waitReason (available immediately from the // task data) rather than the async tool-requests fetch, so there is no // unlocked gap right after the task flips to waiting_human. A disabled // textarea also blocks the Ctrl+Enter submit path. const awaitingToolApproval = jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request'; const composerLocked = inputLocked || awaitingToolApproval; // Release the submit lock once the agent is visibly responding: the new user // comment is reflected in the list AND the job has been picked up by a worker // (isBusy=true). This bridges the queued->dispatching gap where a stale // re-enabled send button would otherwise allow a double submit. useEffect(() => { if (!submitting) return; const baseline = submitBaselineRef.current; if (baseline === null) return; if (comments.length > baseline && hasActiveJob) { releaseSubmitting(); } }, [submitting, comments.length, hasActiveJob]); // Clear the safety-net timeout on unmount. useEffect(() => { return () => { if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current); }; }, []); // During an active run, suppress the trailing thinking comment so the // live SSE preview is the single source of truth for in-flight text. // We keep the comment in history (MovementGroup will render it once the // movement completes). On SSE disconnect we keep it visible as fallback. const visibleComments = useMemo(() => { if (!isBusy) return comments; if (!connected) return comments; // SSE disconnect fallback if (!hasTrailingThinking(comments)) return comments; return comments.slice(0, -1); }, [comments, isBusy, connected]); const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]); const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1; return (
{/* Shown at every breakpoint. The overlay is absolutely positioned at the bottom-right of the chat pane with pointer-events:none, so it never blocks the mobile composer — keep it visible on narrow screens too. */} {/* Header */}

{task.title}

#{task.id} · {task.pieceName}
{(task.latestJob?.attempt ?? 1) > 1 && jobStatus !== 'succeeded' && (
{t('pane.attemptBadge', { attempt: task.latestJob?.attempt, max: task.latestJob?.maxAttempts ?? 3 })}
)} {isBusy && (
{isWaitingSubtasks ? 'subtasks' : 'running'}
)}
{/* Tablet: detail tabs as direct buttons (instead of one "詳細" button) so a tap opens the drawer straight to the chosen tab. */} {detailTabs && detailTabs.length > 0 && onSelectDetailTab && (
{detailTabs.map(tab => { const active = tab.id === activeDetailTab; const live = tab.id === 'browser' || tab.id === 'ssh'; return ( ); })}
)}
{/* Messages */}
{comments.length === 0 && (
{t('pane.empty')}
)} {(() => { let commentIdx = 0; return groupedItems.map((item, gi) => { if (item.type === 'movement') { const startIdx = commentIdx; commentIdx += item.inner.length; return ( ); } const idx = commentIdx; commentIdx++; return ( ); }); })()} {isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && ( )} {isBusy && !isWaitingSubtasks && (
{promptProgress ? (
{t('pane.processing')} {promptProgress.percent}%
) : streamingText ? (
{streamingText}
) : liveToolContent ? (
{t('pane.generating', { name: liveToolContent.name })}
                    {liveToolContent.text}
                    
                  
) : (
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() }) : llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' }) : llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' }) : llmState?.phase === 'waiting' ? t('pane.llmWaiting') : t('pane.agentResponding')}
)}
)} {isBusy && Object.keys(delegateStreams).length > 0 && (
)} {/* General rotating tips to make the wait useful (running only). */} {isBusy && }
{/* Scroll-to-bottom button */} {!isAtBottom && ( )}
{/* Composer — カード+ツールバー型。上段 textarea(自動拡張)、下段に 添付・コンテキスト残量・送信系を集約。旧: 独立ゲージ行+2行 textarea で 約114px → 待機時約85px。 */}
{hasActiveJob && (
{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}
)} {sendError && !isBusy && (
⚠ {sendError}
)}