Files
clawgo/webui/src/pages/Chat.tsx

730 lines
29 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Paperclip, Send, MessageSquare, RefreshCw } from 'lucide-react';
import { motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { ChatItem } from '../types';
type StreamItem = {
kind?: string;
at?: number;
task_id?: string;
label?: string;
agent_id?: string;
event_type?: string;
message?: string;
message_type?: string;
content?: string;
from_agent?: string;
to_agent?: string;
reply_to?: string;
message_id?: string;
status?: string;
};
type RenderedChatItem = ChatItem & {
id: string;
actorKey?: string;
actorName?: string;
avatarText?: string;
avatarClassName?: string;
metaLine?: string;
isReadonlyGroup?: boolean;
};
type RegistryAgent = {
agent_id?: string;
display_name?: string;
role?: string;
enabled?: boolean;
transport?: string;
};
type RuntimeTask = {
id?: string;
agent_id?: string;
status?: string;
updated?: number;
created?: number;
waiting_for_reply?: boolean;
};
type AgentRuntimeBadge = {
status: 'running' | 'waiting' | 'failed' | 'completed' | 'idle';
text: string;
};
function formatAgentName(agentID?: string): string {
const normalized = String(agentID || '').trim();
if (!normalized) return 'Unknown Agent';
if (normalized === 'main') return 'Main Agent';
return normalized
.split(/[-_.:]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function avatarSeed(key?: string): string {
const palette = [
'bg-emerald-600/80 text-white',
'bg-sky-600/80 text-white',
'bg-violet-600/80 text-white',
'bg-amber-600/80 text-white',
'bg-rose-600/80 text-white',
'bg-cyan-600/80 text-white',
'bg-fuchsia-600/80 text-white',
];
const source = String(key || 'agent');
let hash = 0;
for (let i = 0; i < source.length; i += 1) {
hash = (hash * 31 + source.charCodeAt(i)) | 0;
}
return palette[Math.abs(hash) % palette.length];
}
function avatarText(name?: string): string {
const parts = String(name || '')
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) return 'A';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
}
function messageActorKey(item: StreamItem): string {
return String(item.from_agent || item.agent_id || item.to_agent || 'subagent').trim() || 'subagent';
}
function collectActors(items: StreamItem[]): string[] {
const set = new Set<string>();
items.forEach((item) => {
[item.agent_id, item.from_agent, item.to_agent].forEach((value) => {
const v = String(value || '').trim();
if (v) set.add(v);
});
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
const Chat: React.FC = () => {
const { t } = useTranslation();
const { q, sessions } = useAppContext();
const ui = useUI();
const [mainChat, setMainChat] = useState<RenderedChatItem[]>([]);
const [subagentStream, setSubagentStream] = useState<StreamItem[]>([]);
const [registryAgents, setRegistryAgents] = useState<RegistryAgent[]>([]);
const [msg, setMsg] = useState('');
const [fileSelected, setFileSelected] = useState(false);
const [chatTab, setChatTab] = useState<'main' | 'subagents'>('main');
const [sessionKey, setSessionKey] = useState('');
const [selectedStreamAgents, setSelectedStreamAgents] = useState<string[]>([]);
const [dispatchAgentID, setDispatchAgentID] = useState('');
const [dispatchTask, setDispatchTask] = useState('');
const [dispatchLabel, setDispatchLabel] = useState('');
const [runtimeTasks, setRuntimeTasks] = useState<RuntimeTask[]>([]);
const chatEndRef = useRef<HTMLDivElement>(null);
const chatScrollRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const historyRequestRef = useRef(0);
const isNearBottom = () => {
const container = chatScrollRef.current;
if (!container) return true;
const threshold = 64;
return container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
};
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
chatEndRef.current?.scrollIntoView({ behavior });
};
useEffect(() => {
if (shouldAutoScrollRef.current) {
scrollToBottom(chatTab === 'main' ? 'smooth' : 'auto');
}
}, [chatTab, mainChat, subagentStream]);
const loadHistory = async (requestedSessionKey?: string) => {
const targetSessionKey = String(requestedSessionKey || sessionKey || '').trim();
if (!targetSessionKey) return;
const requestID = ++historyRequestRef.current;
try {
shouldAutoScrollRef.current = isNearBottom() || chatTab !== 'main';
const qs = q ? `${q}&session=${encodeURIComponent(targetSessionKey)}` : `?session=${encodeURIComponent(targetSessionKey)}`;
const r = await fetch(`/webui/api/chat/history${qs}`);
if (!r.ok) return;
const j = await r.json();
if (requestID !== historyRequestRef.current) return;
const arr = Array.isArray(j.messages) ? j.messages : [];
const mapped: RenderedChatItem[] = arr.map((m: any, index: number) => {
const baseRole = String(m.role || 'assistant');
let role: ChatItem['role'] = 'assistant';
if (baseRole === 'user') role = 'user';
else if (baseRole === 'tool') role = 'tool';
else if (baseRole === 'system') role = 'system';
let text = m.content || '';
let label = role === 'user' ? t('user') : role === 'tool' ? t('exec') : role === 'system' ? t('system') : t('agent');
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
role = 'exec';
label = t('exec');
text = `${text}\n[tool calls: ${m.tool_calls.map((x: any) => x?.function?.name || x?.name).filter(Boolean).join(', ')}]`;
}
if (baseRole === 'tool') {
text = `[${t('toolOutput')}]\n${text}`;
}
const actorName = role === 'user' ? t('user') : role === 'tool' || role === 'exec' ? t('exec') : role === 'system' ? t('system') : t('agent');
const avatarClassName = role === 'user'
? 'bg-indigo-600/90 text-white'
: role === 'tool' || role === 'exec'
? 'bg-amber-600/80 text-white'
: role === 'system'
? 'bg-zinc-700 text-zinc-100'
: 'bg-emerald-600/80 text-white';
return {
id: `${targetSessionKey}-${index}`,
role,
text,
label,
actorName,
avatarText: role === 'user' ? 'U' : role === 'tool' || role === 'exec' ? 'E' : role === 'system' ? 'S' : 'A',
avatarClassName,
};
});
setMainChat(mapped);
} catch (e) {
console.error(e);
}
};
const loadSubagentGroup = async () => {
try {
shouldAutoScrollRef.current = isNearBottom() || chatTab !== 'subagents';
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'stream_all', limit: 300, task_limit: 36 }),
});
if (!r.ok) return;
const j = await r.json();
const arr = Array.isArray(j?.result?.items) ? j.result.items : [];
setSubagentStream(arr);
} catch (e) {
console.error(e);
}
};
const loadRegistryAgents = async () => {
try {
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'registry' }),
});
if (!r.ok) return;
const j = await r.json();
const items = Array.isArray(j?.result?.items) ? j.result.items : [];
const filtered = items.filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false);
setRegistryAgents(filtered);
if (!dispatchAgentID && filtered.length > 0) {
setDispatchAgentID(String(filtered[0].agent_id || ''));
}
} catch (e) {
console.error(e);
}
};
const loadRuntimeTasks = async () => {
try {
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'list' }),
});
if (!r.ok) return;
const j = await r.json();
const items = Array.isArray(j?.result?.items) ? j.result.items : [];
setRuntimeTasks(items);
} catch (e) {
console.error(e);
}
};
async function send() {
if (!msg.trim() && !fileSelected) return;
let media = '';
const input = document.getElementById('file') as HTMLInputElement | null;
const f = input?.files?.[0];
if (f) {
const fd = new FormData();
fd.append('file', f);
try {
const ur = await fetch(`/webui/api/upload${q}`, { method: 'POST', body: fd });
const uj = await ur.json();
media = uj.path || '';
} catch (e) {
console.error('L0053', e);
}
}
const userText = msg + (media ? `\n[Attached File: ${f?.name}]` : '');
shouldAutoScrollRef.current = true;
setMainChat((prev) => [...prev, {
id: `local-user-${Date.now()}`,
role: 'user',
text: userText,
label: t('user'),
actorName: t('user'),
avatarText: 'U',
avatarClassName: 'bg-indigo-600/90 text-white',
}]);
const currentMsg = msg;
setMsg('');
setFileSelected(false);
if (input) input.value = '';
try {
const response = await fetch(`/webui/api/chat/stream${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: sessionKey, message: currentMsg, media }),
});
if (!response.ok || !response.body) throw new Error('Chat request failed');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantText = '';
setMainChat((prev) => [...prev, {
id: `local-assistant-${Date.now()}`,
role: 'assistant',
text: '',
label: t('agent'),
actorName: t('agent'),
avatarText: 'A',
avatarClassName: 'bg-emerald-600/80 text-white',
}]);
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
assistantText += chunk;
setMainChat((prev) => {
const next = [...prev];
next[next.length - 1] = {
...next[next.length - 1],
text: assistantText,
};
return next;
});
}
loadHistory();
} catch (e) {
setMainChat((prev) => [...prev, {
id: `local-system-${Date.now()}`,
role: 'system',
text: t('chatServerError'),
label: t('system'),
actorName: t('system'),
avatarText: 'S',
avatarClassName: 'bg-zinc-700 text-zinc-100',
}]);
}
}
useEffect(() => {
shouldAutoScrollRef.current = true;
if (chatTab === 'main') {
if (sessionKey) {
loadHistory(sessionKey);
}
return;
}
loadSubagentGroup();
loadRegistryAgents();
loadRuntimeTasks();
}, [q, chatTab, sessionKey]);
useEffect(() => {
if (chatTab !== 'subagents') return;
const timer = window.setInterval(() => {
loadSubagentGroup();
loadRuntimeTasks();
}, 5000);
return () => window.clearInterval(timer);
}, [q, chatTab]);
const userSessions = (sessions || []).filter((s: any) => !String(s?.key || '').startsWith('subagent:'));
useEffect(() => {
if (chatTab !== 'main') return;
if (!userSessions.length) return;
if (!sessionKey || !userSessions.some((s: any) => s.key === sessionKey)) {
setSessionKey(userSessions[0].key);
}
}, [chatTab, sessionKey, userSessions]);
const streamActors = useMemo(() => collectActors(subagentStream), [subagentStream]);
useEffect(() => {
setSelectedStreamAgents((prev) => prev.filter((agent) => streamActors.includes(agent)));
}, [streamActors]);
const renderedSubagentChat = useMemo<RenderedChatItem[]>(() => {
const replyIndex = new Map<string, { actor: string; messageType: string }>();
subagentStream.forEach((item) => {
const messageID = String(item.message_id || '').trim();
if (!messageID) return;
replyIndex.set(messageID, {
actor: formatAgentName(item.from_agent || item.agent_id),
messageType: String(item.message_type || 'message'),
});
});
return subagentStream
.filter((item) => {
if (selectedStreamAgents.length === 0) return true;
const actors = [item.agent_id, item.from_agent, item.to_agent]
.map((value) => String(value || '').trim())
.filter(Boolean);
return actors.some((actor) => selectedStreamAgents.includes(actor));
})
.map((item, index) => {
const actorKey = messageActorKey(item);
const actorName = formatAgentName(actorKey);
let metaLine = '';
if (item.kind === 'message') {
const replyMeta = String(item.reply_to || '').trim() ? replyIndex.get(String(item.reply_to || '').trim()) : null;
if (replyMeta) {
metaLine = `${t('replyTo')}: ${replyMeta.actor}`;
} else if (item.from_agent && item.to_agent && item.from_agent === item.to_agent) {
metaLine = t('selfRefresh');
} else if (item.to_agent) {
metaLine = `${t('toAgent')}: ${formatAgentName(item.to_agent)}`;
}
if (item.message_type) {
metaLine = metaLine ? `${metaLine} · ${item.message_type}` : String(item.message_type);
}
} else {
metaLine = item.event_type === 'resumed' || item.event_type === 'recovered'
? t('selfRefresh')
: String(item.event_type || t('internalEvent'));
}
const text = item.kind === 'event' ? (item.message || '') : (item.content || '');
const label = item.kind === 'event'
? `${actorName} · ${item.event_type || t('internalEvent')}`
: actorName;
return {
id: `${item.kind || 'item'}-${item.message_id || item.task_id || item.at || index}-${index}`,
role: 'assistant',
text: text || t('empty'),
label,
actorKey,
actorName,
avatarText: avatarText(actorName),
avatarClassName: avatarSeed(actorKey),
metaLine,
isReadonlyGroup: true,
};
});
}, [selectedStreamAgents, subagentStream, t]);
const displayedChat = chatTab === 'main' ? mainChat : renderedSubagentChat;
const runtimeBadgeByAgent = useMemo<Record<string, AgentRuntimeBadge>>(() => {
const latest = new Map<string, RuntimeTask>();
runtimeTasks.forEach((task) => {
const agentID = String(task.agent_id || '').trim();
if (!agentID) return;
const current = latest.get(agentID);
const taskTime = Math.max(Number(task.updated || 0), Number(task.created || 0));
const currentTime = current ? Math.max(Number(current.updated || 0), Number(current.created || 0)) : 0;
if (!current || taskTime >= currentTime) {
latest.set(agentID, task);
}
});
const out: Record<string, AgentRuntimeBadge> = {};
latest.forEach((task, agentID) => {
if (task.waiting_for_reply) {
out[agentID] = { status: 'waiting', text: t('statusWaiting') };
return;
}
switch (String(task.status || '').trim()) {
case 'running':
out[agentID] = { status: 'running', text: t('statusRunning') };
break;
case 'failed':
out[agentID] = { status: 'failed', text: t('statusError') };
break;
case 'completed':
out[agentID] = { status: 'completed', text: t('statusSuccess') };
break;
default:
out[agentID] = { status: 'idle', text: t('idle') };
}
});
return out;
}, [runtimeTasks, t]);
const dispatchSubagentTask = async () => {
const task = dispatchTask.trim();
const agentID = dispatchAgentID.trim();
if (!task || !agentID) return;
await ui.withLoading(async () => {
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'spawn',
agent_id: agentID,
task,
label: dispatchLabel.trim(),
origin_channel: 'webui',
origin_chat_id: 'subagent-group',
}),
});
if (!r.ok) {
throw new Error(await r.text());
}
setDispatchTask('');
setDispatchLabel('');
await Promise.all([loadSubagentGroup(), loadRegistryAgents(), loadRuntimeTasks()]);
await ui.notify({ title: t('saved'), message: t('subagentTaskDispatched') });
}, t('creating')).catch(async (err) => {
await ui.notify({ title: t('requestFailed'), message: err instanceof Error ? err.message : String(err) });
});
};
const toggleStreamAgent = (agent: string) => {
setSelectedStreamAgents((prev) => (
prev.includes(agent) ? prev.filter((item) => item !== agent) : [...prev, agent]
));
};
return (
<div className="flex h-full min-w-0">
<div className="flex-1 flex flex-col brand-card rounded-[30px] border border-zinc-800/80 overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between gap-3 flex-wrap bg-zinc-900/15">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<button
onClick={() => setChatTab('main')}
className={`px-3 py-1.5 rounded-xl text-xs ${chatTab === 'main' ? 'brand-button text-white' : 'bg-zinc-900/70 border border-zinc-700 text-zinc-300'}`}
>
{t('mainChat')}
</button>
<button
onClick={() => setChatTab('subagents')}
className={`px-3 py-1.5 rounded-xl text-xs ${chatTab === 'subagents' ? 'bg-amber-600 text-white' : 'bg-zinc-900/70 border border-zinc-700 text-zinc-300'}`}
>
{t('subagentGroup')}
</button>
{chatTab === 'main' && (
<select value={sessionKey} onChange={(e) => setSessionKey(e.target.value)} className="max-w-full bg-zinc-900/70 border border-zinc-700 rounded-xl px-2.5 py-1.5 text-xs text-zinc-200">
{userSessions.map((s: any) => <option key={s.key} value={s.key}>{s.title || s.key}</option>)}
</select>
)}
</div>
<button onClick={chatTab === 'main' ? loadHistory : loadSubagentGroup} className="flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-xl bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3" />{t('reloadHistory')}</button>
</div>
{chatTab === 'subagents' && (
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-950/20 flex flex-wrap gap-2">
<button
onClick={() => setSelectedStreamAgents([])}
className={`px-2.5 py-1 rounded-full text-xs border ${selectedStreamAgents.length === 0 ? 'bg-amber-600 text-white border-amber-500' : 'bg-zinc-900/70 border-zinc-700 text-zinc-300'}`}
>
{t('allAgents')}
</button>
{streamActors.map((agent) => (
<button
key={agent}
onClick={() => toggleStreamAgent(agent)}
className={`px-2.5 py-1 rounded-full text-xs border ${selectedStreamAgents.includes(agent) ? 'bg-zinc-100 text-zinc-950 border-zinc-100' : 'bg-zinc-900/70 border-zinc-700 text-zinc-300'}`}
>
{formatAgentName(agent)}
</button>
))}
</div>
)}
<div className="flex-1 min-h-0 flex flex-col xl:flex-row">
{chatTab === 'subagents' && (
<div className="w-full xl:w-[320px] xl:shrink-0 border-b xl:border-b-0 xl:border-r border-zinc-800 bg-zinc-950/28 p-4 flex flex-col gap-4 max-h-[46vh] xl:max-h-none overflow-y-auto">
<div>
<div className="text-xs uppercase tracking-wider text-zinc-500 mb-1">{t('subagentDispatch')}</div>
<div className="text-sm text-zinc-300">{t('subagentDispatchHint')}</div>
</div>
<div className="space-y-3">
<select
value={dispatchAgentID}
onChange={(e) => setDispatchAgentID(e.target.value)}
className="w-full bg-zinc-900/70 border border-zinc-800 rounded-2xl px-3 py-2.5 text-sm text-zinc-200"
>
{registryAgents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{formatAgentName(agent.display_name || agent.agent_id)} · {agent.role || '-'}
</option>
))}
</select>
<textarea
value={dispatchTask}
onChange={(e) => setDispatchTask(e.target.value)}
placeholder={t('subagentTaskPlaceholder')}
className="w-full min-h-[180px] resize-none bg-zinc-900/70 border border-zinc-800 rounded-2xl px-3 py-3 text-sm text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500/20"
/>
<input
value={dispatchLabel}
onChange={(e) => setDispatchLabel(e.target.value)}
placeholder={t('subagentLabelPlaceholder')}
className="w-full bg-zinc-900/70 border border-zinc-800 rounded-2xl px-3 py-2.5 text-sm text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500/20"
/>
<button
onClick={dispatchSubagentTask}
disabled={!dispatchAgentID.trim() || !dispatchTask.trim()}
className="w-full px-3 py-2.5 rounded-2xl bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white text-sm font-medium shadow-[0_12px_30px_rgba(217,119,6,0.22)]"
>
{t('dispatchToSubagent')}
</button>
</div>
<div className="border-t border-zinc-800 pt-4 min-h-0 flex flex-col">
<div className="text-xs uppercase tracking-wider text-zinc-500 mb-2">{t('agents')}</div>
<div className="overflow-y-auto space-y-2 min-h-0">
{registryAgents.map((agent) => {
const active = dispatchAgentID === agent.agent_id;
const badge = runtimeBadgeByAgent[String(agent.agent_id || '')];
const badgeClass = badge?.status === 'running'
? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30'
: badge?.status === 'waiting'
? 'bg-amber-500/15 text-amber-300 border-amber-500/30'
: badge?.status === 'failed'
? 'bg-rose-500/15 text-rose-300 border-rose-500/30'
: badge?.status === 'completed'
? 'bg-sky-500/15 text-sky-300 border-sky-500/30'
: 'bg-zinc-800 text-zinc-400 border-zinc-700';
return (
<button
key={agent.agent_id}
onClick={() => setDispatchAgentID(String(agent.agent_id || ''))}
className={`w-full text-left rounded-2xl border px-3 py-2.5 ${active ? 'border-amber-500 bg-amber-500/10' : 'border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900/70'}`}
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-zinc-100">{formatAgentName(agent.display_name || agent.agent_id)}</div>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] ${badgeClass}`}>{badge?.text || t('idle')}</span>
</div>
<div className="text-xs text-zinc-500">{agent.agent_id} · {agent.role || '-'}</div>
</button>
);
})}
</div>
</div>
</div>
)}
<div
ref={chatScrollRef}
onScroll={() => {
shouldAutoScrollRef.current = isNearBottom();
}}
className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4 sm:space-y-6 min-w-0"
>
{displayedChat.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 space-y-4">
<div className="w-16 h-16 rounded-[24px] brand-card-subtle flex items-center justify-center border border-zinc-800">
<MessageSquare className="w-8 h-8 text-zinc-600" />
</div>
<p className="text-sm font-medium">{chatTab === 'main' ? t('startConversation') : t('noSubagentStream')}</p>
</div>
) : (
displayedChat.map((m, i) => {
const isUser = m.role === 'user';
const isExec = m.role === 'tool' || m.role === 'exec';
const isSystem = m.role === 'system';
const bubbleClass = isUser
? 'bg-indigo-600 text-white rounded-br-sm'
: isExec
? 'bg-amber-500/10 text-amber-100 rounded-bl-sm border border-amber-500/30'
: isSystem
? 'bg-zinc-700/40 text-zinc-100 rounded-bl-sm border border-zinc-600/40'
: m.isReadonlyGroup
? 'bg-zinc-900/85 text-zinc-200 rounded-bl-sm border border-zinc-700/60'
: 'bg-zinc-800/80 text-zinc-200 rounded-bl-sm border border-zinc-700/50';
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
key={m.id || i}
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex items-start gap-2 max-w-full sm:max-w-[96%] ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`w-9 h-9 mt-1 rounded-full text-[11px] font-bold flex items-center justify-center shrink-0 ${m.avatarClassName || (isUser ? 'bg-indigo-600/90 text-white' : 'bg-emerald-600/80 text-white')}`}>{m.avatarText || (isUser ? 'U' : 'A')}</div>
<div className={`max-w-[calc(100vw-6rem)] sm:max-w-[92%] rounded-[24px] px-4 py-3 shadow-sm ${bubbleClass}`}>
<div className="flex items-center justify-between gap-3 mb-1">
<div className="text-[11px] opacity-85">{m.actorName || m.label || (isUser ? t('user') : isExec ? t('exec') : isSystem ? t('system') : t('agent'))}</div>
{m.metaLine && <div className="text-[11px] text-zinc-400">{m.metaLine}</div>}
</div>
{m.label && m.actorName && m.label !== m.actorName && (
<div className="text-[11px] text-zinc-500 mb-2">{m.label}</div>
)}
<p className="whitespace-pre-wrap text-[14px] leading-relaxed">{m.text}</p>
</div>
</div>
</motion.div>
);
})
)}
<div ref={chatEndRef} />
</div>
</div>
<div className="p-3 sm:p-4 bg-zinc-950/20 border-t border-zinc-800">
<div className="w-full relative flex items-center">
<input
type="file"
id="file"
className="hidden"
onChange={(e) => setFileSelected(!!e.target.files?.[0])}
/>
<label
htmlFor="file"
className={`absolute left-3 p-2 rounded-full cursor-pointer transition-colors ${fileSelected ? 'text-indigo-400 bg-indigo-500/10' : 'text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-200'}`}
>
<Paperclip className="w-5 h-5" />
</label>
<input
value={msg}
onChange={(e) => setMsg(e.target.value)}
onKeyDown={(e) => chatTab === 'main' && e.key === 'Enter' && send()}
placeholder={chatTab === 'main' ? t('typeMessage') : t('subagentGroupReadonly')}
disabled={chatTab !== 'main'}
className="w-full bg-zinc-900/75 border border-zinc-800 rounded-full pl-14 pr-14 py-3.5 text-[15px] focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 transition-all placeholder:text-zinc-500 shadow-sm disabled:opacity-60"
/>
<button
onClick={send}
disabled={chatTab !== 'main' || (!msg.trim() && !fileSelected)}
className="absolute right-2 p-2.5 brand-button disabled:opacity-50 text-white rounded-full transition-colors"
>
<Send className="w-4 h-4 ml-0.5" />
</button>
</div>
</div>
</div>
</div>
);
};
export default Chat;