maestro/ui/src/components/chat/ChatPane.tsx
oss-sync d41ff0f658
Some checks failed
CI / build-and-test (push) Failing after 6m23s
sync: update from private repo (d3780b00)
2026-07-09 00:12:24 +00:00

616 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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">&times;</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>
);
}