feat(chat): 3-panel layout with conversation list, agent activity tracker, and persistence

- Restored chatStore.ts: localStorage-persisted conversations that survive page navigation
- Left panel: conversation list with create/delete/switch, message counts, last message preview
- Center: chat messages with original Mission Control dark theme
- Right panel: tabbed Console/Tasks/Research with agent activity tracking
- Console tab now shows active agents with live elapsed timer, container ID, and status
- AgentActivity cards with real-time pulse animation and ping indicator
- Research agent tracking: auto-creates activity card when web research is running
- Conversations persist across page navigation (localStorage goclaw-conversations-v4)
- New conversations auto-title from first user message
This commit is contained in:
¨NW¨
2026-04-09 13:40:57 +01:00
parent 4023f912de
commit 13acc93a1e
2 changed files with 721 additions and 229 deletions

359
client/src/lib/chatStore.ts Normal file
View File

@@ -0,0 +1,359 @@
/**
* chatStore — Global singleton for chat conversations.
*
* Stores conversations in localStorage, supports:
* - Create/delete/switch conversations
* - Persist messages across navigation
* - Uses synchronous orchestrator.chat tRPC endpoint
*/
import { nanoid } from "nanoid";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ToolCallStep {
tool: string;
args: Record<string, any>;
result: any;
success: boolean;
durationMs: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: string;
toolCalls?: ToolCallStep[];
model?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
isError?: boolean;
}
export interface Conversation {
id: string;
title: string;
createdAt: number;
messages: ChatMessage[];
history: Array<{ role: "user" | "assistant" | "system"; content: string }>;
}
// ─── Active Agent Tracking ───────────────────────────────────────────────────
export interface AgentActivity {
id: string;
agentName: string;
agentRole: string;
containerId?: string;
startedAt: number;
status: "running" | "done" | "error";
result?: string;
error?: string;
}
type StoreEvent = "update";
type UpdateHandler = () => void;
// ─── Persistence ──────────────────────────────────────────────────────────────
const STORAGE_KEY = "goclaw-conversations-v4";
function loadConversations(): Conversation[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function persistConversations(convs: Conversation[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(convs.slice(0, 50)));
} catch {}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getTs(): string {
return new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// ─── Store ────────────────────────────────────────────────────────────────────
class ChatStore {
private conversations: Conversation[] = loadConversations();
private activeId: string = "";
private isThinking = false;
private activeAgents: AgentActivity[] = [];
private listeners = new Set<UpdateHandler>();
constructor() {
if (this.conversations.length > 0) {
this.activeId = this.conversations[0].id;
}
}
// ─── Subscriptions ──────────────────────────────────────────────────────────
on(_event: "update", handler: UpdateHandler): void {
this.listeners.add(handler);
}
off(_event: "update", handler: UpdateHandler): void {
this.listeners.delete(handler);
}
private emit() {
this.listeners.forEach(h => h());
}
// ─── Selectors ──────────────────────────────────────────────────────────────
getConversations(): Conversation[] {
return this.conversations;
}
getActiveId(): string {
return this.activeId;
}
getActive(): Conversation | null {
return this.conversations.find(c => c.id === this.activeId) ?? null;
}
getIsThinking(): boolean {
return this.isThinking;
}
// ─── Mutations ──────────────────────────────────────────────────────────────
setActiveId(id: string) {
this.activeId = id;
this.emit();
}
createConversation(orchName = "GoClaw Orchestrator"): string {
const id = `conv-${nanoid(8)}`;
const welcome: ChatMessage = {
id: "welcome",
role: "system",
content: `${orchName} ready.\nI have access to all agents, tools, and skills.\nType a command or ask anything.`,
timestamp: getTs(),
};
const conv: Conversation = {
id,
title: "New Chat",
createdAt: Date.now(),
messages: [welcome],
history: [],
};
this.conversations = [conv, ...this.conversations];
this.activeId = id;
persistConversations(this.conversations);
this.emit();
return id;
}
deleteConversation(id: string) {
this.conversations = this.conversations.filter(c => c.id !== id);
if (this.activeId === id) {
if (this.conversations.length > 0) {
this.activeId = this.conversations[0].id;
} else {
this.createConversation();
return;
}
}
persistConversations(this.conversations);
this.emit();
}
setThinking(v: boolean) {
this.isThinking = v;
this.emit();
}
/** Add a user message to the active conversation. Returns the conv ID. */
addUserMessage(
content: string,
convId?: string
): {
convId: string;
userMsg: ChatMessage;
newHistory: Array<{
role: "user" | "assistant" | "system";
content: string;
}>;
} {
let id = convId ?? this.activeId;
if (!id || !this.conversations.find(c => c.id === id)) {
id = this.createConversation();
}
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: content.trim(),
timestamp: getTs(),
};
const conv = this.conversations.find(c => c.id === id);
const newHistory = [
...(conv?.history ?? []),
{ role: "user" as const, content: content.trim() },
];
this.conversations = this.conversations.map(c =>
c.id === id
? {
...c,
title:
c.history.length === 0
? content.trim().slice(0, 40) + (content.length > 40 ? "…" : "")
: c.title,
messages: [...c.messages, userMsg],
history: newHistory,
}
: c
);
persistConversations(this.conversations);
this.emit();
return { convId: id, userMsg, newHistory };
}
/** Add a thinking/processing placeholder. Returns thinkingMsgId. */
addThinkingMessage(convId: string): string {
const thinkingId = `thinking-${Date.now()}`;
const thinkingMsg: ChatMessage = {
id: thinkingId,
role: "system",
content: "Orchestrator is processing...",
timestamp: getTs(),
};
this.conversations = this.conversations.map(c =>
c.id === convId ? { ...c, messages: [...c.messages, thinkingMsg] } : c
);
persistConversations(this.conversations);
this.emit();
return thinkingId;
}
/** Remove the thinking placeholder. */
removeThinkingMessage(convId: string, thinkingId: string) {
this.conversations = this.conversations.map(c =>
c.id === convId
? { ...c, messages: c.messages.filter(m => m.id !== thinkingId) }
: c
);
this.emit();
}
/** Add assistant response after successful chat. */
addAssistantMessage(
convId: string,
response: string,
toolCalls: any[] | undefined,
model: string | undefined,
usage: any | undefined,
history: Array<{ role: "user" | "assistant" | "system"; content: string }>
) {
const msg: ChatMessage = {
id: `resp-${Date.now()}`,
role: "assistant",
content: response,
timestamp: getTs(),
toolCalls,
model,
usage,
};
this.conversations = this.conversations.map(c =>
c.id === convId
? { ...c, messages: [...c.messages, msg], history: history }
: c
);
persistConversations(this.conversations);
this.isThinking = false;
this.emit();
}
/** Add error message on failed chat. */
addErrorMessage(convId: string, errorMsg: string) {
const msg: ChatMessage = {
id: `err-${Date.now()}`,
role: "assistant",
content: errorMsg,
timestamp: getTs(),
isError: true,
};
this.conversations = this.conversations.map(c =>
c.id === convId ? { ...c, messages: [...c.messages, msg] } : c
);
persistConversations(this.conversations);
this.isThinking = false;
this.emit();
}
// ─── Active Agent Tracking ──────────────────────────────────────────────────
getActiveAgents(): AgentActivity[] {
return this.activeAgents;
}
/** Start tracking an agent activity. Returns the activity id. */
startAgentActivity(
agentName: string,
agentRole: string,
containerId?: string
): string {
const id = `agent-${nanoid(6)}`;
const activity: AgentActivity = {
id,
agentName,
agentRole,
containerId,
startedAt: Date.now(),
status: "running",
};
this.activeAgents = [...this.activeAgents, activity];
this.emit();
return id;
}
/** Update agent activity status. */
updateAgentActivity(
activityId: string,
updates: Partial<
Pick<AgentActivity, "status" | "result" | "error" | "containerId">
>
) {
this.activeAgents = this.activeAgents.map(a =>
a.id === activityId ? { ...a, ...updates } : a
);
this.emit();
}
/** Remove a completed agent activity. */
removeAgentActivity(activityId: string) {
this.activeAgents = this.activeAgents.filter(a => a.id !== activityId);
this.emit();
}
/** Clear all agent activities. */
clearAgentActivities() {
this.activeAgents = [];
this.emit();
}
}
// Singleton
export const chatStore = new ChatStore();

View File

@@ -2,6 +2,12 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { Streamdown } from "streamdown";
import { motion, AnimatePresence } from "framer-motion";
import { trpc } from "@/lib/trpc";
import {
chatStore,
type ChatMessage,
type Conversation,
type AgentActivity,
} from "@/lib/chatStore";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -21,46 +27,65 @@ import {
ChevronRight,
CheckCircle,
XCircle,
Clock,
Zap,
Code,
FileText,
Shell,
Network,
Database,
Plus,
Trash2,
MessageSquare,
Search,
ListChecks,
Radio,
Clock,
Activity,
Container,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface ToolCallStep {
tool: string;
args: Record<string, any>;
result: any;
success: boolean;
durationMs: number;
}
type MessageRole = "user" | "assistant" | "system";
interface ChatMessage {
id: string;
role: MessageRole;
content: string;
timestamp: string;
toolCalls?: ToolCallStep[];
model?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
isError?: boolean;
}
type SidebarTab = "console" | "tasks" | "research";
// ─── useChatStore hook ────────────────────────────────────────────────────────
function useChatStore() {
const [, forceRender] = useState(0);
useEffect(() => {
const handler = () => forceRender(n => n + 1);
chatStore.on("update", handler);
return () => chatStore.off("update", handler);
}, []);
return {
conversations: chatStore.getConversations(),
activeId: chatStore.getActiveId(),
active: chatStore.getActive(),
isThinking: chatStore.getIsThinking(),
activeAgents: chatStore.getActiveAgents(),
};
}
// ─── Live Timer Hook ──────────────────────────────────────────────────────────
function useElapsedTime(startedAt: number, status: string): string {
const [elapsed, setElapsed] = useState(() =>
status === "running" ? Math.floor((Date.now() - startedAt) / 1000) : 0
);
useEffect(() => {
if (status !== "running") return;
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(interval);
}, [startedAt, status]);
if (status !== "running") return "";
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
// ─── Tool Icon Map ─────────────────────────────────────────────────────────────
function ToolIcon({ tool }: { tool: string }) {
@@ -101,7 +126,7 @@ function toolLabel(tool: string): string {
// ─── Tool Call Card ───────────────────────────────────────────────────────────
function ToolCallCard({ step, index }: { step: ToolCallStep; index: number }) {
function ToolCallCard({ step, index }: { step: any; index: number }) {
const [expanded, setExpanded] = useState(false);
const argsSummary = () => {
@@ -151,7 +176,6 @@ function ToolCallCard({ step, index }: { step: ToolCallStep; index: number }) {
)}
</div>
</button>
{expanded && (
<div className="px-3 pb-3 space-y-2 border-t border-border/30">
<div>
@@ -192,7 +216,6 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
animate={{ opacity: 1, y: 0 }}
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
>
{/* Avatar */}
<div
className={`shrink-0 w-7 h-7 rounded-md flex items-center justify-center border ${
isUser
@@ -210,12 +233,9 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
<Bot className="w-3.5 h-3.5 text-[#00D4FF]" />
)}
</div>
{/* Content */}
<div
className={`flex-1 min-w-0 ${isUser ? "items-end" : "items-start"} flex flex-col gap-1`}
>
{/* Meta */}
<div
className={`flex items-center gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
@@ -236,8 +256,6 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
</span>
)}
</div>
{/* Tool calls */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="w-full space-y-1 mb-1">
<p className="text-[10px] font-mono text-muted-foreground flex items-center gap-1">
@@ -250,8 +268,6 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
))}
</div>
)}
{/* Message text */}
{msg.content && (
<div
className={`rounded-lg px-3 py-2 max-w-[85%] ${
@@ -280,16 +296,76 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
);
}
// ─── Console Panel ────────────────────────────────────────────────────────────
// ─── Agent Activity Card ──────────────────────────────────────────────────────
function ConsolePanel({ messages }: { messages: ChatMessage[] }) {
function AgentActivityCard({ activity }: { activity: AgentActivity }) {
const elapsed = useElapsedTime(activity.startedAt, activity.status);
return (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="rounded border border-[#00D4FF]/30 bg-[#00D4FF]/5 overflow-hidden"
>
<div className="flex items-center gap-2 px-2.5 py-2">
<Radio
className={`w-3 h-3 ${activity.status === "running" ? "text-[#00FF88] animate-pulse" : activity.status === "done" ? "text-[#00FF88]" : "text-[#FF3366]"}`}
/>
<span className="text-[10px] font-mono font-semibold text-foreground flex-1">
{activity.agentName}
</span>
<Badge
variant="outline"
className="text-[8px] h-3.5 px-1 font-mono border-[#00D4FF]/30 text-[#00D4FF]/80"
>
{activity.agentRole}
</Badge>
</div>
<div className="px-2.5 pb-2 flex items-center gap-2">
{activity.containerId && (
<span className="text-[9px] font-mono text-muted-foreground/50 flex items-center gap-1">
<Container className="w-2.5 h-2.5" />
{activity.containerId.slice(0, 12)}
</span>
)}
{activity.status === "running" && (
<span className="text-[9px] font-mono text-[#00FF88]/70 flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{elapsed}
</span>
)}
{activity.status === "running" && (
<span className="text-[9px] font-mono text-[#00FF88] flex items-center gap-1">
<Activity className="w-2.5 h-2.5 animate-pulse" />
ping...
</span>
)}
{activity.status === "done" && (
<span className="text-[9px] font-mono text-[#00FF88]">completed</span>
)}
{activity.status === "error" && (
<span className="text-[9px] font-mono text-[#FF3366]">error</span>
)}
</div>
</motion.div>
);
}
// ─── Console Panel ─────────────────────────────────────────────────────────────
function ConsolePanel({
messages,
activeAgents,
}: {
messages: ChatMessage[];
activeAgents: AgentActivity[];
}) {
const consoleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (consoleRef.current) {
if (consoleRef.current)
consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
}
}, [messages]);
}, [messages, activeAgents]);
const consoleEntries = messages.filter(
m => m.toolCalls && m.toolCalls.length > 0
@@ -303,34 +379,39 @@ function ConsolePanel({ messages }: { messages: ChatMessage[] }) {
<span className="text-xs font-mono font-semibold text-foreground">
CONSOLE
</span>
<Badge
variant="outline"
className="text-[9px] h-4 px-1.5 font-mono border-[#00FF88]/30 text-[#00FF88]/80"
>
{consoleEntries.reduce(
(acc, m) => acc + (m.toolCalls?.length ?? 0),
0
)}
</Badge>
{activeAgents.filter(a => a.status === "running").length > 0 && (
<Badge
variant="outline"
className="text-[8px] h-3.5 px-1 font-mono border-[#00FF88]/30 text-[#00FF88] animate-pulse"
>
{activeAgents.filter(a => a.status === "running").length} active
</Badge>
)}
</div>
<span className="text-[9px] font-mono text-muted-foreground/60">
tool output
</span>
</div>
<div
ref={consoleRef}
className="flex-1 overflow-y-auto p-2 space-y-1.5 font-mono text-[10px]"
>
{consoleEntries.length === 0 ? (
<div ref={consoleRef} className="flex-1 overflow-y-auto p-2 space-y-1.5">
{/* Active agents section */}
{activeAgents.length > 0 && (
<div className="mb-2">
<p className="text-[8px] font-mono text-[#00D4FF]/50 uppercase tracking-wider mb-1.5 px-1">
Active Agents
</p>
<div className="space-y-1">
{activeAgents.map(a => (
<AgentActivityCard key={a.id} activity={a} />
))}
</div>
</div>
)}
{/* Tool output */}
{consoleEntries.length === 0 && activeAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full py-8 gap-2">
<Terminal className="w-6 h-6 text-muted-foreground/20" />
<p className="text-[10px] text-muted-foreground/40">
<p className="text-[10px] font-mono text-muted-foreground/40">
No tool output yet
</p>
<p className="text-[9px] text-muted-foreground/25">
Tool calls will appear here
</p>
</div>
) : (
consoleEntries.map(msg =>
@@ -341,13 +422,13 @@ function ConsolePanel({ messages }: { messages: ChatMessage[] }) {
>
<div className="flex items-center gap-2 px-2 py-1.5 bg-secondary/30">
<ToolIcon tool={step.tool} />
<span className="text-[#00D4FF]/80 font-medium">
<span className="text-[#00D4FF]/80 font-medium text-[10px] font-mono">
{toolLabel(step.tool)}
</span>
<span className="text-muted-foreground/50 flex-1 truncate">
<span className="text-muted-foreground/50 flex-1 truncate text-[9px] font-mono">
{step.args.command || step.args.path || ""}
</span>
<span className="text-muted-foreground/40">
<span className="text-muted-foreground/40 text-[9px] font-mono">
{step.durationMs}ms
</span>
{step.success ? (
@@ -356,7 +437,7 @@ function ConsolePanel({ messages }: { messages: ChatMessage[] }) {
<XCircle className="w-3 h-3 text-[#FF3366]" />
)}
</div>
<pre className="px-2 py-1.5 text-foreground/60 bg-background/30 max-h-20 overflow-auto whitespace-pre-wrap break-words">
<pre className="px-2 py-1.5 text-foreground/60 bg-background/30 max-h-20 overflow-auto whitespace-pre-wrap break-words text-[9px] font-mono">
{step.success
? typeof step.result === "string"
? step.result.slice(0, 500)
@@ -372,7 +453,7 @@ function ConsolePanel({ messages }: { messages: ChatMessage[] }) {
);
}
// ─── Sidebar Tab Button ──────────────────────────────────────────────────────
// ─── Sidebar Tab Button ──────────────────────────────────────────────────────
function SidebarTabButton({
active,
@@ -400,11 +481,7 @@ function SidebarTabButton({
{label}
{count !== undefined && count > 0 && (
<span
className={`ml-0.5 text-[8px] px-1 rounded-full ${
active
? "bg-[#00D4FF]/20 text-[#00D4FF]"
: "bg-muted/30 text-muted-foreground/40"
}`}
className={`ml-0.5 text-[8px] px-1 rounded-full ${active ? "bg-[#00D4FF]/20 text-[#00D4FF]" : "bg-muted/30 text-muted-foreground/40"}`}
>
{count}
</span>
@@ -413,21 +490,82 @@ function SidebarTabButton({
);
}
// ─── Conversation List Item ────────────────────────────────────────────────────
function ConversationItem({
conv,
isActive,
onClick,
onDelete,
}: {
conv: Conversation;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
}) {
const lastMsg = conv.messages[conv.messages.length - 1];
const lastContent =
lastMsg?.role === "user"
? lastMsg.content
: lastMsg?.role === "assistant"
? lastMsg.content.slice(0, 30)
: "";
const msgCount = conv.messages.filter(m => m.role !== "system").length;
return (
<div
onClick={onClick}
className={`group flex items-start gap-1.5 px-2 py-1.5 rounded cursor-pointer transition-colors ${
isActive
? "bg-[#00D4FF]/10 border border-[#00D4FF]/20"
: "hover:bg-secondary/30 border border-transparent"
}`}
>
<MessageSquare
className={`w-3 h-3 mt-0.5 shrink-0 ${isActive ? "text-[#00D4FF]" : "text-muted-foreground/40"}`}
/>
<div className="flex-1 min-w-0">
<p
className={`text-[10px] font-mono truncate ${isActive ? "text-foreground" : "text-foreground/70"}`}
>
{conv.title}
</p>
{lastContent && (
<p className="text-[8px] font-mono text-muted-foreground/40 truncate">
{lastContent}
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[8px] font-mono text-muted-foreground/30">
{msgCount} msg
</span>
<span className="text-[8px] font-mono text-muted-foreground/20">
{new Date(conv.createdAt).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
})}
</span>
</div>
</div>
<button
onClick={e => {
e.stopPropagation();
onDelete();
}}
className="opacity-0 group-hover:opacity-100 p-0.5 text-muted-foreground/30 hover:text-[#FF3366] transition-all shrink-0"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
);
}
// ─── Main Chat Component ──────────────────────────────────────────────────────
export default function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [conversationHistory, setConversationHistory] = useState<
Array<{ role: "user" | "assistant" | "system"; content: string }>
>([]);
const { conversations, activeId, active, isThinking, activeAgents } =
useChatStore();
const [input, setInput] = useState("");
const [isThinking, setIsThinking] = useState(false);
const [retryAttempt, setRetryAttempt] = useState(0);
const [lastError, setLastError] = useState<{
message: string;
isRetryable: boolean;
} | null>(null);
const [conversationId] = useState(`conv-${Date.now()}`);
const [activeTab, setActiveTab] = useState<SidebarTab>("console");
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -437,29 +575,22 @@ export default function Chat() {
});
const orchestratorMutation = trpc.orchestrator.chat.useMutation();
const orchestratorConfigQuery = trpc.orchestrator.getConfig.useQuery();
const researchMutation = trpc.research.search.useMutation();
// Initialize first conversation if empty
useEffect(() => {
if (orchestratorConfigQuery.data && messages.length === 0) {
const cfg = orchestratorConfigQuery.data;
setMessages([
{
id: "welcome",
role: "system",
content: `${cfg.name} ready. Model: ${cfg.model}\nI have access to all agents, tools, and skills.\nType a command or ask anything.`,
timestamp: new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
}),
},
]);
if (orchestratorConfigQuery.data && conversations.length === 0) {
chatStore.createConversation(
orchestratorConfigQuery.data.name ?? "GoClaw Orchestrator"
);
}
}, [orchestratorConfigQuery.data]);
}, [orchestratorConfigQuery.data, conversations.length]);
// Auto-scroll
useEffect(() => {
if (scrollRef.current) {
if (scrollRef.current)
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
}, [active?.messages]);
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
@@ -473,124 +604,86 @@ export default function Chat() {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
const getTs = () =>
new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const messages = active?.messages ?? [];
const convId = activeId;
// Track research activity
useEffect(() => {
if (researchMutation.isPending && activeId) {
const activityId = chatStore.startAgentActivity(
"Researcher",
"browser",
"goclaw-agent-1"
);
return () => {
if (researchMutation.isSuccess) {
chatStore.updateAgentActivity(activityId, {
status: "done",
result: "Research completed",
});
setTimeout(() => chatStore.removeAgentActivity(activityId), 3000);
} else if (researchMutation.isError) {
chatStore.updateAgentActivity(activityId, {
status: "error",
error: "Research failed",
});
setTimeout(() => chatStore.removeAgentActivity(activityId), 5000);
}
};
}
}, [
researchMutation.isPending,
researchMutation.isSuccess,
researchMutation.isError,
activeId,
]);
const sendMessage = async () => {
if (!input.trim() || isThinking) return;
const userContent = input.trim();
const ts = getTs();
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: userContent,
timestamp: ts,
};
setMessages(prev => [...prev, userMsg]);
const newHistory = [
...conversationHistory,
{ role: "user" as const, content: userContent },
];
setConversationHistory(newHistory);
const { convId: cid, newHistory } = chatStore.addUserMessage(
input.trim(),
convId
);
setInput("");
setIsThinking(true);
// Switch to console tab when sending a message
chatStore.setThinking(true);
setActiveTab("console");
const thinkingId = `thinking-${Date.now()}`;
setMessages(prev => [
...prev,
{
id: thinkingId,
role: "system" as const,
content: "Orchestrator is processing...",
timestamp: getTs(),
},
]);
const thinkingId = chatStore.addThinkingMessage(cid);
try {
const result = await orchestratorMutation.mutateAsync({
messages: newHistory,
maxIterations: 10,
});
setMessages(prev => prev.filter(m => m.id !== thinkingId));
const respTs = getTs();
setLastError(null);
setRetryAttempt(0);
chatStore.removeThinkingMessage(cid, thinkingId);
if (result.success) {
setConversationHistory(prev => [
...prev,
const updatedHistory = [
...newHistory,
{ role: "assistant" as const, content: result.response },
]);
setMessages(prev => [
...prev,
{
id: `resp-${Date.now()}`,
role: "assistant" as const,
content: result.response,
timestamp: respTs,
toolCalls: result.toolCalls,
model: result.model,
usage: result.usage,
},
]);
];
chatStore.addAssistantMessage(
cid,
result.response,
result.toolCalls,
result.model,
result.usage,
updatedHistory
);
} else {
setMessages(prev => [
...prev,
{
id: `err-${Date.now()}`,
role: "assistant" as const,
content: `Error: ${result.error || "Unknown error"}`,
timestamp: respTs,
isError: true,
},
]);
}
} catch (err: any) {
setMessages(prev => prev.filter(m => m.id !== thinkingId));
const errorMsg = err.message || "Unknown error";
const isRetryable =
errorMsg.includes("timeout") ||
errorMsg.includes("unavailable") ||
errorMsg.includes("ECONNREFUSED");
setLastError({ message: errorMsg, isRetryable });
setRetryAttempt(prev => prev + 1);
setMessages(prev => [
...prev,
{
id: `err-${Date.now()}`,
role: "assistant" as const,
content: `Network Error (Attempt ${retryAttempt + 1}): ${errorMsg}${isRetryable ? "\n\nRetrying automatically..." : ""}`,
timestamp: getTs(),
isError: true,
},
]);
if (isRetryable && retryAttempt < 2) {
setTimeout(
() => {
sendMessage();
},
1000 * Math.pow(2, retryAttempt)
chatStore.addErrorMessage(
cid,
`Error: ${result.error || "Unknown error"}`
);
}
} catch (err: any) {
chatStore.removeThinkingMessage(cid, thinkingId);
chatStore.addErrorMessage(
cid,
`Network Error: ${err.message || "Unknown error"}`
);
} finally {
setIsThinking(false);
setTimeout(() => textareaRef.current?.focus(), 100);
}
};
@@ -603,20 +696,22 @@ export default function Chat() {
};
const agents = agentsQuery.data ?? [];
const activeAgents = agents.filter(
a => a.isActive && !(a as any).isOrchestrator
const activeAgentsList = agents.filter(
(a: any) => a.isActive && !a.isOrchestrator
);
const orchConfig = orchestratorConfigQuery.data;
const toolCallCount = messages.reduce(
(acc, m) => acc + (m.toolCalls?.length ?? 0),
0
);
const runningAgentCount = activeAgents.filter(
a => a.status === "running"
).length;
return (
<div className="h-full flex flex-col gap-3">
<div className="h-full flex flex-col gap-0 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center justify-between shrink-0 px-1 pb-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-[#00D4FF]/15 border border-[#00D4FF]/30 flex items-center justify-center">
<Bot className="w-4 h-4 text-[#00D4FF]" />
@@ -627,22 +722,26 @@ export default function Chat() {
</h2>
<p className="text-[11px] font-mono text-muted-foreground">
{orchConfig ? (
<span>
<>
<span className="text-[#00D4FF]/80">{orchConfig.model}</span>
{" · "}
{activeAgents.length} agents · {ORCHESTRATOR_TOOLS_COUNT}{" "}
{activeAgentsList.length} agents · {ORCHESTRATOR_TOOLS_COUNT}{" "}
tools
</span>
</>
) : (
`Main AI · ${activeAgents.length} agents · ${ORCHESTRATOR_TOOLS_COUNT} tools`
`Main AI · ${activeAgentsList.length} agents · ${ORCHESTRATOR_TOOLS_COUNT} tools`
)}
{runningAgentCount > 0 && (
<span className="ml-2 text-[#00FF88]">
{runningAgentCount} running
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{activeAgents.slice(0, 3).map(agent => (
{activeAgentsList.slice(0, 3).map((agent: any) => (
<Badge
key={agent.id}
variant="outline"
@@ -670,10 +769,44 @@ export default function Chat() {
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex gap-3 min-h-0">
{/* Chat area */}
<Card className="flex-1 bg-card border-border/50 overflow-hidden">
{/* Main 3-panel layout */}
<div className="flex-1 flex gap-2 min-h-0">
{/* ─── Left Panel — Conversations ─── */}
<div className="w-48 shrink-0 flex flex-col gap-2 min-h-0">
<div className="flex items-center justify-between shrink-0">
<span className="text-[10px] font-mono text-muted-foreground/60 uppercase tracking-wider">
Chats
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
chatStore.createConversation(
orchConfig?.name ?? "GoClaw Orchestrator"
)
}
className="h-5 w-5 p-0 text-muted-foreground/40 hover:text-[#00D4FF]"
>
<Plus className="w-3 h-3" />
</Button>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-0.5 pr-0.5">
{conversations.map(c => (
<ConversationItem
key={c.id}
conv={c}
isActive={c.id === activeId}
onClick={() => chatStore.setActiveId(c.id)}
onDelete={() => chatStore.deleteConversation(c.id)}
/>
))}
</div>
</ScrollArea>
</div>
{/* ─── Center — Chat Area ─── */}
<Card className="flex-1 bg-card border-border/50 overflow-hidden min-w-0">
<CardContent className="p-0 h-full flex flex-col">
<ScrollArea className="flex-1">
<div ref={scrollRef} className="p-4 space-y-4">
@@ -682,7 +815,6 @@ export default function Chat() {
<MessageBubble key={msg.id} msg={msg} />
))}
</AnimatePresence>
{isThinking && (
<motion.div
initial={{ opacity: 0 }}
@@ -693,6 +825,12 @@ export default function Chat() {
<span className="text-muted-foreground">
Orchestrator thinking...
</span>
{runningAgentCount > 0 && (
<span className="text-[#00FF88] text-[10px]">
({runningAgentCount} agent
{runningAgentCount > 1 ? "s" : ""} active)
</span>
)}
</motion.div>
)}
</div>
@@ -700,7 +838,6 @@ export default function Chat() {
{/* Input area */}
<div className="border-t border-border/50 p-3 bg-secondary/10 shrink-0">
{/* Quick commands */}
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
{[
"Список агентов",
@@ -718,7 +855,6 @@ export default function Chat() {
</button>
))}
</div>
<div className="flex items-end gap-2">
<span className="text-[#00D4FF] font-mono text-sm shrink-0 pb-1">
$
@@ -730,8 +866,8 @@ export default function Chat() {
onKeyDown={handleKeyDown}
placeholder={
isThinking
? "Ожидание ответа оркестратора..."
: "Введите команду или вопрос... (Shift+Enter для новой строки)"
? "Ожидание ответа..."
: "Введите команду... (Shift+Enter для новой строки)"
}
disabled={isThinking}
rows={1}
@@ -754,16 +890,15 @@ export default function Chat() {
</CardContent>
</Card>
{/* Right Sidebar — Tabbed Panel */}
<div className="w-80 border border-border/30 bg-card rounded-lg overflow-hidden flex flex-col">
{/* Tab bar */}
{/* ─── Right Sidebar — Tabbed Panel ─── */}
<div className="w-80 border border-border/30 bg-card rounded-lg overflow-hidden flex flex-col shrink-0">
<div className="flex border-b border-border/30 shrink-0">
<SidebarTabButton
active={activeTab === "console"}
onClick={() => setActiveTab("console")}
icon={<Terminal className="w-3 h-3" />}
label="Console"
count={toolCallCount}
count={toolCallCount + runningAgentCount}
/>
<SidebarTabButton
active={activeTab === "tasks"}
@@ -778,15 +913,13 @@ export default function Chat() {
label="Research"
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{activeTab === "console" && <ConsolePanel messages={messages} />}
{activeTab === "tasks" && (
<TasksPanel conversationId={conversationId} />
{activeTab === "console" && (
<ConsolePanel messages={messages} activeAgents={activeAgents} />
)}
{activeTab === "tasks" && <TasksPanel conversationId={convId} />}
{activeTab === "research" && (
<WebResearchPanel conversationId={conversationId} />
<WebResearchPanel conversationId={convId ?? ""} />
)}
</div>
</div>