616 lines
31 KiB
TypeScript
616 lines
31 KiB
TypeScript
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<string> {
|
||
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<void>;
|
||
onCancel?: () => Promise<void>;
|
||
/** 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<Array<{ name: string; contentBase64: string }>>([]);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [cancelling, setCancelling] = useState(false);
|
||
const [sendError, setSendError] = useState<string | null>(null);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const composerRef = useRef<HTMLTextAreaElement>(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<number | null>(null);
|
||
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLPreElement | null>(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 (
|
||
<div className="relative flex flex-col h-full overflow-hidden">
|
||
{/* 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. */}
|
||
<ChatPetOverlay
|
||
taskId={task.id}
|
||
taskStatus={task.latestJob?.status ?? null}
|
||
currentActivity={task.latestJob?.currentActivity ?? null}
|
||
workerId={task.latestJob?.workerId ?? null}
|
||
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
||
/>
|
||
{/* Header */}
|
||
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2>
|
||
<span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span>
|
||
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
|
||
{(task.latestJob?.attempt ?? 1) > 1 && jobStatus !== 'succeeded' && (
|
||
<div
|
||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15"
|
||
title={task.latestJob?.abortReason
|
||
? t('pane.attemptBadgeTitle', { reason: task.latestJob.abortReason })
|
||
: undefined}
|
||
data-testid="attempt-badge"
|
||
>
|
||
<span className="text-[10px] font-medium text-amber-700 dark:text-amber-300">
|
||
{t('pane.attemptBadge', { attempt: task.latestJob?.attempt, max: task.latestJob?.maxAttempts ?? 3 })}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{isBusy && (
|
||
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
|
||
isWaitingSubtasks
|
||
? 'border-indigo-100 dark:border-indigo-500/30 bg-indigo-50 dark:bg-indigo-500/15'
|
||
: 'border-emerald-100 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15'
|
||
}`}>
|
||
<span className={`w-1.5 h-1.5 rounded-full animate-pulse ${
|
||
isWaitingSubtasks ? 'bg-indigo-500' : 'bg-emerald-500'
|
||
}`} />
|
||
<span className={`text-[10px] font-medium ${
|
||
isWaitingSubtasks ? 'text-indigo-700 dark:text-indigo-300' : 'text-emerald-700 dark:text-emerald-300'
|
||
}`}>
|
||
{isWaitingSubtasks ? 'subtasks' : 'running'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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 && (
|
||
<div className="mt-2 flex flex-wrap gap-x-1.5 gap-y-1">
|
||
{detailTabs.map(tab => {
|
||
const active = tab.id === activeDetailTab;
|
||
const live = tab.id === 'browser' || tab.id === 'ssh';
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => onSelectDetailTab(tab.id)}
|
||
aria-pressed={active}
|
||
style={live ? ({ '--flow-color': tab.id === 'ssh' ? '#34d399' : '#38bdf8' } as CSSProperties) : undefined}
|
||
className={`whitespace-nowrap px-2 h-7 inline-flex items-center rounded-md text-2xs font-medium transition-colors ${
|
||
live ? 'live-flow-border' : 'border border-hairline'
|
||
} ${
|
||
active ? 'text-slate-900 font-semibold bg-surface' : 'text-slate-600 bg-canvas hover:bg-surface'
|
||
}`}
|
||
>
|
||
{dt(tab.labelKey)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||
{comments.length === 0 && (
|
||
<div className="text-center text-slate-400 text-[13px] py-8">
|
||
{t('pane.empty')}
|
||
</div>
|
||
)}
|
||
{(() => {
|
||
let commentIdx = 0;
|
||
return groupedItems.map((item, gi) => {
|
||
if (item.type === 'movement') {
|
||
const startIdx = commentIdx;
|
||
commentIdx += item.inner.length;
|
||
return (
|
||
<MovementGroupExpanded
|
||
key={`mg-${gi}`}
|
||
item={item}
|
||
taskId={task.id}
|
||
isLast={gi === groupedItems.length - 1}
|
||
isRunning={isBusy}
|
||
animatingIdx={animatingIdx}
|
||
startIdx={startIdx}
|
||
/>
|
||
);
|
||
}
|
||
const idx = commentIdx;
|
||
commentIdx++;
|
||
return (
|
||
<ChatMessage
|
||
key={item.comment.id}
|
||
comment={item.comment}
|
||
taskId={task.id}
|
||
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||
/>
|
||
);
|
||
});
|
||
})()}
|
||
|
||
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||
<SubtaskInlineCard
|
||
subtasks={task.subtasks}
|
||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||
/>
|
||
)}
|
||
|
||
{isBusy && !isWaitingSubtasks && (
|
||
<div className="flex justify-start">
|
||
{promptProgress ? (
|
||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span>{t('pane.processing')}</span>
|
||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||
</div>
|
||
<div className="w-full bg-slate-200 rounded-full h-1">
|
||
<div
|
||
className="bg-emerald-500 h-1 rounded-full transition-all duration-300"
|
||
style={{ width: `${promptProgress.percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : streamingText ? (
|
||
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||
{streamingText}
|
||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||
</div>
|
||
) : liveToolContent ? (
|
||
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
||
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||
{liveToolContent.text}
|
||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||
</pre>
|
||
</div>
|
||
) : (
|
||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||
</svg>
|
||
{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')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||
<div className="flex justify-start mt-1.5">
|
||
<DelegateLiveConsole streams={delegateStreams} />
|
||
</div>
|
||
)}
|
||
|
||
{/* General rotating tips to make the wait useful (running only). */}
|
||
{isBusy && <RotatingTips />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scroll-to-bottom button */}
|
||
{!isAtBottom && (
|
||
<button
|
||
onClick={scrollToBottom}
|
||
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5 bg-canvas border border-slate-200 rounded-full shadow-md text-xs text-slate-600 hover:bg-slate-50 transition-colors z-10"
|
||
>
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M4 6l4 4 4-4" />
|
||
</svg>
|
||
{newMessageCount > 0 ? (
|
||
<span className="text-blue-600 font-medium">{t('pane.newMessages', { count: newMessageCount })}</span>
|
||
) : (
|
||
<span>{t('pane.toLatest')}</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Composer — カード+ツールバー型。上段 textarea(自動拡張)、下段に
|
||
添付・コンテキスト残量・送信系を集約。旧: 独立ゲージ行+2行 textarea で
|
||
約114px → 待機時約85px。 */}
|
||
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
|
||
{hasActiveJob && (
|
||
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
||
canInterject
|
||
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
|
||
: isPending
|
||
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
|
||
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
||
}`}>
|
||
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||
</svg>
|
||
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
||
</div>
|
||
)}
|
||
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||
{sendError && !isBusy && (
|
||
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
|
||
<span className="truncate">⚠ {sendError}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleSubmit()}
|
||
disabled={submitting}
|
||
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||
>
|
||
{t('pane.resend')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className={`rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
|
||
<textarea
|
||
ref={composerRef}
|
||
value={draft}
|
||
onChange={e => setDraft(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={e => void handlePaste(e)}
|
||
rows={1}
|
||
disabled={composerLocked}
|
||
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||
className="block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
|
||
/>
|
||
<div className="flex flex-wrap items-center gap-1.5 px-2 pb-2">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
|
||
/>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={composerLocked || submitting}
|
||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||
title={t('pane.attachFile')}
|
||
aria-label={t('pane.attachFile')}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||
</svg>
|
||
</button>
|
||
{attachments.length > 0 && (
|
||
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
|
||
{attachments.map(a => (
|
||
<span key={a.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
|
||
{a.name}
|
||
<button onClick={() => removeAttachment(a.name)} className="ml-0.5 text-slate-400 hover:text-slate-700">×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||
{/* コンテキスト残量: 入力しながら「あとどれくらい書けるか」を把握
|
||
できる位置に常時表示(issue #009 の趣旨を維持)。 */}
|
||
<ContextUsageGauge
|
||
inline
|
||
promptTokens={task.latestJob?.contextPromptTokens}
|
||
limitTokens={task.latestJob?.contextLimitTokens}
|
||
jobStatus={task.latestJob?.status}
|
||
/>
|
||
{isBusy && onCancel ? (
|
||
<div className="flex gap-1.5">
|
||
{canInterject && (
|
||
<button
|
||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||
onClick={handleSubmit}
|
||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||
>
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<polyline points="15 10 20 15 15 20" />
|
||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||
</svg>
|
||
{t('pane.interject')}
|
||
</button>
|
||
)}
|
||
<button
|
||
disabled={cancelling}
|
||
onClick={() => void handleCancel()}
|
||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||
title={t('pane.stopAgent')}
|
||
>
|
||
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
||
</svg>
|
||
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
||
</button>
|
||
</div>
|
||
) : isPending ? (
|
||
<button
|
||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||
onClick={handleSubmit}
|
||
title={t('pane.addToQueuedHint')}
|
||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||
>
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<polyline points="15 10 20 15 15 20" />
|
||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||
</svg>
|
||
{t('pane.addToQueued')}
|
||
</button>
|
||
) : (
|
||
<button
|
||
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
|
||
onClick={handleSubmit}
|
||
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||
>
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<path d="M22 2 11 13" />
|
||
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
||
</svg>
|
||
{t('pane.send')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|