Phase 1: streaming UI + thinking blocks in ChatPanel

- Agent streaming: real-time text deltas in chat
- Thinking/reasoning: collapsible block with agent's thinking process
- Tool call indicators: shows running/completed tools
- Streaming bubble with pulse animation while agent generates
- Message type updated with 'thinking' field
This commit is contained in:
Markov 2026-02-27 06:57:51 +01:00
parent 92db42113d
commit d5eaa13b21
2 changed files with 102 additions and 1 deletions

View File

@ -12,6 +12,14 @@ const AUTHOR_ICON: Record<string, string> = {
system: "⚙️",
};
/** Streaming state for agent that is currently generating */
interface StreamingState {
agentSlug: string;
text: string;
thinking: string;
tools: Array<{ tool: string; status: string }>;
}
interface Props {
chatId: string;
projectSlug?: string;
@ -35,6 +43,7 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
const [hasMore, setHasMore] = useState(true);
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const [streaming, setStreaming] = useState<StreamingState | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -62,10 +71,54 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
// Clear streaming when final message arrives
setStreaming(null);
});
return () => { unsub?.(); };
}, [chatId]);
// WS: agent streaming events
useEffect(() => {
const unsubStart = wsClient.on("agent.stream.start", (data: any) => {
if (data.chat_id !== chatId) return;
setStreaming({ agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] });
});
const unsubDelta = wsClient.on("agent.stream.delta", (data: any) => {
if (data.chat_id !== chatId) return;
setStreaming((prev) => {
if (!prev) return { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
if (data.block_type === "thinking") {
return { ...prev, thinking: prev.thinking + (data.text || "") };
}
return { ...prev, text: prev.text + (data.text || "") };
});
});
const unsubTool = wsClient.on("agent.stream.tool", (data: any) => {
if (data.chat_id !== chatId) return;
setStreaming((prev) => {
if (!prev) return null;
const tools = [...prev.tools];
const existing = tools.findIndex((t) => t.tool === data.tool);
if (existing >= 0) {
tools[existing] = { tool: data.tool, status: data.status };
} else {
tools.push({ tool: data.tool, status: data.status });
}
return { ...prev, tools };
});
});
const unsubEnd = wsClient.on("agent.stream.end", (data: any) => {
if (data.chat_id !== chatId) return;
// Don't clear immediately — wait for message.new with final content
});
return () => {
unsubStart?.();
unsubDelta?.();
unsubTool?.();
unsubEnd?.();
};
}, [chatId]);
// Auto-scroll on new messages (only if near bottom)
useEffect(() => {
if (isInitial.current) {
@ -230,6 +283,16 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
<span>·</span>
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
{msg.thinking && (
<details className="ml-5 mb-1">
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
💭 Размышления
</summary>
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
{msg.thinking}
</div>
</details>
)}
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
{renderAttachments(msg.attachments)}
</div>
@ -250,9 +313,46 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
<div className="text-center text-xs text-[var(--muted)] py-2"> начало истории </div>
)}
{messages.map(renderMessage)}
{messages.length === 0 && (
{messages.length === 0 && !streaming && (
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
)}
{/* Streaming indicator */}
{streaming && (
<div className="text-sm" style={{ backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>🤖</span>
<span className="font-medium">{streaming.agentSlug}</span>
<span className="animate-pulse"></span>
</div>
{streaming.thinking && (
<details className="ml-5 mb-1" open>
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
💭 Размышления
</summary>
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
{streaming.thinking}
</div>
</details>
)}
{streaming.tools.length > 0 && (
<div className="ml-5 mb-1 text-xs text-[var(--muted)]">
{streaming.tools.map((t, i) => (
<span key={i} className="mr-2">
{t.status === "running" ? "🔧" : "✅"} {t.tool}
</span>
))}
</div>
)}
{streaming.text && (
<div className="ml-5 whitespace-pre-wrap">{streaming.text}</div>
)}
{!streaming.text && streaming.tools.length === 0 && !streaming.thinking && (
<div className="ml-5 text-[var(--muted)] italic">Думает...</div>
)}
</div>
)}
<div ref={bottomRef} />
</div>

View File

@ -146,6 +146,7 @@ export interface Message {
author_id: string | null;
author: MemberBrief | null;
content: string;
thinking: string | null;
mentions: string[];
voice_url: string | null;
attachments: Attachment[];