feat(chat): Mission Control UI — tabbed sidebar with Console/Tasks/Research, dark theme, resizable textarea

- TasksPanel: rewritten with Mission Control dark theme (cyan/green/amber/red neon accents, monospace fonts, glowing borders)
- Chat: added tabbed right sidebar (Console, Tasks, Web Research panels)
- Added ConsolePanel component showing tool call output in real-time
- Replaced single-line Input with auto-resizing textarea (Shift+Enter for newline)
- Auto-switches to Console tab when sending messages
- Consistent neon color scheme: cyan=#00D4FF, green=#00FF88, amber=#FFB800, red=#FF3366
This commit is contained in:
¨NW¨
2026-04-09 06:46:16 +01:00
parent 7c01bc4272
commit 4023f912de
3 changed files with 547 additions and 252 deletions

5
.gitignore vendored
View File

@@ -112,6 +112,11 @@ temp/
# Manus version file (auto-generated, not part of source)
client/public/__manus__/version.json
# Secrets — NEVER commit
.deploy-secrets
deploy-secrets
*.secret
# Kilocode config and working directories
.kilo/
.manus/

View File

@@ -1,23 +1,28 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { AlertCircle, CheckCircle2, Clock, Trash2, Plus } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
AlertCircle,
CheckCircle2,
Clock,
Trash2,
Plus,
ChevronDown,
ChevronRight,
Zap,
Loader2,
Terminal,
} from "lucide-react";
export interface TasksPanelProps {
agentId?: number;
conversationId?: string;
}
/**
* TasksPanel — правая панель для отображения и управления задачами
*/
export function TasksPanel({
agentId,
conversationId,
}: TasksPanelProps) {
export function TasksPanel({ agentId, conversationId }: TasksPanelProps) {
const [tasks, setTasks] = useState<any[]>([]);
const [expandedTaskId, setExpandedTaskId] = useState<number | null>(null);
@@ -50,11 +55,8 @@ export function TasksPanel({
...(newStatus === "completed" && { completedAt: new Date() }),
...(newStatus === "in_progress" && { startedAt: new Date() }),
});
if (result) {
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? result : t))
);
setTasks(prev => prev.map(t => (t.id === taskId ? result : t)));
}
} catch (error) {
console.error("Failed to update task:", error);
@@ -65,7 +67,7 @@ export function TasksPanel({
try {
const success = await deleteTaskMutation.mutateAsync({ taskId });
if (success) {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
setTasks(prev => prev.filter(t => t.id !== taskId));
}
} catch (error) {
console.error("Failed to delete task:", error);
@@ -75,173 +77,204 @@ export function TasksPanel({
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
return <CheckCircle2 className="w-3.5 h-3.5 text-[#00FF88]" />;
case "failed":
return <AlertCircle className="w-4 h-4 text-red-500" />;
return <AlertCircle className="w-3.5 h-3.5 text-[#FF3366]" />;
case "in_progress":
return <Clock className="w-4 h-4 text-blue-500" />;
return <Loader2 className="w-3.5 h-3.5 text-[#00D4FF] animate-spin" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
return <Clock className="w-3.5 h-3.5 text-[#FFB800]" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
case "failed":
return "bg-red-100 text-red-800";
case "in_progress":
return "bg-blue-100 text-blue-800";
case "blocked":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
completed: "bg-[#00FF88]/10 text-[#00FF88] border-[#00FF88]/30",
failed: "bg-[#FF3366]/10 text-[#FF3366] border-[#FF3366]/30",
in_progress: "bg-[#00D4FF]/10 text-[#00D4FF] border-[#00D4FF]/30",
blocked: "bg-[#FFB800]/10 text-[#FFB800] border-[#FFB800]/30",
pending: "bg-[#FFB800]/10 text-[#FFB800]/70 border-[#FFB800]/30",
};
return (
styles[status] ?? "bg-[#FFB800]/10 text-[#FFB800]/70 border-[#FFB800]/30"
);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case "critical":
return "bg-red-100 text-red-800";
case "high":
return "bg-orange-100 text-orange-800";
case "medium":
return "bg-yellow-100 text-yellow-800";
default:
return "bg-gray-100 text-gray-800";
}
const getPriorityBadge = (priority: string) => {
const styles: Record<string, string> = {
critical: "bg-[#FF3366]/10 text-[#FF3366] border-[#FF3366]/30",
high: "bg-[#FFB800]/10 text-[#FFB800] border-[#FFB800]/30",
medium: "bg-[#00D4FF]/10 text-[#00D4FF]/70 border-[#00D4FF]/30",
low: "bg-muted/20 text-muted-foreground border-border/30",
};
return (
styles[priority] ?? "bg-muted/20 text-muted-foreground border-border/30"
);
};
return (
<div className="w-full h-full flex flex-col bg-white border-l border-gray-200">
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-gray-900">Tasks</h2>
<Badge variant="outline" className="text-xs">
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-3 py-2.5 border-b border-border/30 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-[#FFB800]" />
<span className="text-xs font-mono font-semibold text-foreground">
TASKS
</span>
<Badge
variant="outline"
className="text-[9px] h-4 px-1.5 font-mono border-[#FFB800]/30 text-[#FFB800]/80"
>
{tasks.length}
</Badge>
</div>
<p className="text-sm text-gray-500">
{agentId ? `Agent #${agentId}` : conversationId ? "Conversation" : "No selection"}
</p>
<span className="text-[9px] font-mono text-muted-foreground/60">
{agentId ? `Agent #${agentId}` : conversationId ? "Session" : ""}
</span>
</div>
<div className="flex-1 overflow-y-auto">
{/* Task List */}
<ScrollArea className="flex-1">
{tasks.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
No tasks yet
<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] font-mono text-muted-foreground/40">
No tasks yet
</p>
<p className="text-[9px] font-mono text-muted-foreground/25">
Tasks will appear as the orchestrator works
</p>
</div>
) : (
<div className="space-y-2 p-4">
{tasks.map((task) => (
<Card
<div className="p-2 space-y-1.5">
{tasks.map(task => (
<div
key={task.id}
className="p-3 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() =>
setExpandedTaskId(
expandedTaskId === task.id ? null : task.id
)
}
className="rounded border border-border/30 bg-secondary/20 hover:bg-secondary/30 transition-colors overflow-hidden"
>
<div className="flex items-start gap-3">
<Checkbox
checked={task.status === "completed"}
onCheckedChange={(checked) => {
handleStatusChange(
task.id,
checked ? "completed" : "pending"
);
}}
onClick={(e) => e.stopPropagation()}
className="mt-1"
/>
<button
className="w-full flex items-start gap-2 px-2.5 py-2 text-left"
onClick={() =>
setExpandedTaskId(
expandedTaskId === task.id ? null : task.id
)
}
>
<div
className="mt-0.5 shrink-0"
onClick={e => e.stopPropagation()}
>
<Checkbox
checked={task.status === "completed"}
onCheckedChange={checked => {
handleStatusChange(
task.id,
checked ? "completed" : "pending"
);
}}
className="h-3.5 w-3.5 border-border/50 data-[state=checked]:bg-[#00FF88]/20 data-[state=checked]:border-[#00FF88]/50"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
<p
className={`text-[11px] font-mono truncate ${
task.status === "completed"
? "text-muted-foreground line-through"
: "text-foreground/90"
}`}
>
{task.title}
</p>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-1.5 mt-1">
{getStatusIcon(task.status)}
<Badge
variant="secondary"
className={`text-xs ${getStatusColor(task.status)}`}
variant="outline"
className={`text-[8px] h-3.5 px-1 font-mono ${getStatusBadge(task.status)}`}
>
{task.status.replace("_", " ")}
</Badge>
{task.priority && (
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
variant="outline"
className={`text-[8px] h-3.5 px-1 font-mono ${getPriorityBadge(task.priority)}`}
>
{task.priority}
</Badge>
)}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteTask(task.id);
}}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={e => {
e.stopPropagation();
handleDeleteTask(task.id);
}}
className="p-0.5 text-muted-foreground/40 hover:text-[#FF3366] transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
{expandedTaskId === task.id ? (
<ChevronDown className="w-3 h-3 text-muted-foreground/50" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
)}
</div>
</button>
{expandedTaskId === task.id && (
<div className="mt-3 pt-3 border-t border-gray-200 space-y-2">
<div className="px-2.5 pb-2.5 pt-0 space-y-1.5 border-t border-border/20">
{task.description && (
<div>
<p className="text-xs text-gray-600 font-medium">
<div className="mt-2">
<p className="text-[8px] font-mono text-[#00D4FF]/60 uppercase tracking-wider">
Description
</p>
<p className="text-xs text-gray-700 mt-1">
<p className="text-[10px] font-mono text-foreground/60 mt-0.5">
{task.description}
</p>
</div>
)}
{task.result && (
<div>
<p className="text-xs text-gray-600 font-medium">
<p className="text-[8px] font-mono text-[#00FF88]/60 uppercase tracking-wider">
Result
</p>
<p className="text-xs text-gray-700 mt-1 max-h-20 overflow-y-auto">
<pre className="text-[9px] font-mono text-foreground/50 bg-background/50 rounded p-1.5 max-h-20 overflow-auto whitespace-pre-wrap">
{task.result}
</p>
</pre>
</div>
)}
{task.errorMessage && (
<div>
<p className="text-xs text-red-600 font-medium">
<p className="text-[8px] font-mono text-[#FF3366]/60 uppercase tracking-wider">
Error
</p>
<p className="text-xs text-red-700 mt-1">
<p className="text-[9px] font-mono text-[#FF3366]/80">
{task.errorMessage}
</p>
</div>
)}
{task.createdAt && (
<div className="text-xs text-gray-500">
Created: {new Date(task.createdAt).toLocaleString()}
</div>
<p className="text-[8px] font-mono text-muted-foreground/30">
{new Date(task.createdAt).toLocaleString()}
</p>
)}
</div>
)}
</Card>
</div>
))}
</div>
)}
</div>
</ScrollArea>
<div className="p-4 border-t border-gray-200">
{/* Footer */}
<div className="px-2.5 py-2 border-t border-border/30 shrink-0">
<Button
variant="outline"
size="sm"
className="w-full"
className="w-full h-7 text-[10px] font-mono border-dashed border-[#00D4FF]/30 text-[#00D4FF]/60 hover:text-[#00D4FF] hover:border-[#00D4FF]/60 hover:bg-[#00D4FF]/5 bg-transparent"
disabled={!agentId && !conversationId}
>
<Plus className="w-4 h-4 mr-2" />
<Plus className="w-3 h-3 mr-1" />
New Task
</Button>
</div>

View File

@@ -1,13 +1,13 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { Streamdown } from "streamdown";
import { motion, AnimatePresence } from "framer-motion";
import { trpc } from "@/lib/trpc";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { TasksPanel } from "@/components/TasksPanel";
import { WebResearchPanel } from "@/components/WebResearchPanel";
import {
Terminal,
Send,
@@ -28,6 +28,8 @@ import {
Shell,
Network,
Database,
Search,
ListChecks,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -49,10 +51,16 @@ interface ChatMessage {
timestamp: string;
toolCalls?: ToolCallStep[];
model?: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
isError?: boolean;
}
type SidebarTab = "console" | "tasks" | "research";
// ─── Tool Icon Map ─────────────────────────────────────────────────────────────
function ToolIcon({ tool }: { tool: string }) {
@@ -68,7 +76,11 @@ function ToolIcon({ tool }: { tool: string }) {
install_skill: <Zap className="w-3.5 h-3.5" />,
docker_exec: <Code className="w-3.5 h-3.5" />,
};
return <span className="text-primary">{icons[tool] ?? <Wrench className="w-3.5 h-3.5" />}</span>;
return (
<span className="text-primary">
{icons[tool] ?? <Wrench className="w-3.5 h-3.5" />}
</span>
);
}
function toolLabel(tool: string): string {
@@ -94,10 +106,14 @@ function ToolCallCard({ step, index }: { step: ToolCallStep; index: number }) {
const argsSummary = () => {
if (step.tool === "shell_exec") return step.args.command?.slice(0, 60);
if (step.tool === "file_read" || step.tool === "file_write") return step.args.path;
if (step.tool === "http_request") return `${step.args.method || "GET"} ${step.args.url?.slice(0, 50)}`;
if (step.tool === "delegate_to_agent") return `Agent #${step.args.agentId}: ${step.args.message?.slice(0, 40)}`;
if (step.tool === "docker_exec") return `docker ${step.args.command?.slice(0, 50)}`;
if (step.tool === "file_read" || step.tool === "file_write")
return step.args.path;
if (step.tool === "http_request")
return `${step.args.method || "GET"} ${step.args.url?.slice(0, 50)}`;
if (step.tool === "delegate_to_agent")
return `Agent #${step.args.agentId}: ${step.args.message?.slice(0, 40)}`;
if (step.tool === "docker_exec")
return `docker ${step.args.command?.slice(0, 50)}`;
return JSON.stringify(step.args).slice(0, 60);
};
@@ -113,14 +129,20 @@ function ToolCallCard({ step, index }: { step: ToolCallStep; index: number }) {
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-secondary/40 transition-colors"
>
<ToolIcon tool={step.tool} />
<span className="text-xs font-mono text-primary font-medium">{toolLabel(step.tool)}</span>
<span className="text-xs font-mono text-muted-foreground flex-1 truncate">{argsSummary()}</span>
<span className="text-xs font-mono text-primary font-medium">
{toolLabel(step.tool)}
</span>
<span className="text-xs font-mono text-muted-foreground flex-1 truncate">
{argsSummary()}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-muted-foreground">{step.durationMs}ms</span>
<span className="text-[10px] font-mono text-muted-foreground">
{step.durationMs}ms
</span>
{step.success ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<CheckCircle className="w-3.5 h-3.5 text-[#00FF88]" />
) : (
<XCircle className="w-3.5 h-3.5 text-red-500" />
<XCircle className="w-3.5 h-3.5 text-[#FF3366]" />
)}
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
@@ -133,13 +155,17 @@ function ToolCallCard({ step, index }: { step: ToolCallStep; index: number }) {
{expanded && (
<div className="px-3 pb-3 space-y-2 border-t border-border/30">
<div>
<p className="text-[10px] font-mono text-muted-foreground mt-2 mb-1">INPUT</p>
<p className="text-[10px] font-mono text-[#00D4FF]/50 mt-2 mb-1">
INPUT
</p>
<pre className="text-[10px] font-mono text-foreground/80 bg-background/50 rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap">
{JSON.stringify(step.args, null, 2)}
</pre>
</div>
<div>
<p className="text-[10px] font-mono text-muted-foreground mb-1">OUTPUT</p>
<p className="text-[10px] font-mono text-[#00FF88]/50 mb-1">
OUTPUT
</p>
<pre className="text-[10px] font-mono text-foreground/80 bg-background/50 rounded p-2 overflow-auto max-h-48 whitespace-pre-wrap">
{step.success
? typeof step.result === "string"
@@ -172,26 +198,35 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
isUser
? "bg-primary/15 border-primary/30"
: isSystem
? "bg-yellow-500/10 border-yellow-500/30"
: "bg-cyan-500/10 border-cyan-500/30"
? "bg-[#FFB800]/10 border-[#FFB800]/30"
: "bg-[#00D4FF]/10 border-[#00D4FF]/30"
}`}
>
{isUser ? (
<User className="w-3.5 h-3.5 text-primary" />
) : isSystem ? (
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<Zap className="w-3.5 h-3.5 text-[#FFB800]" />
) : (
<Bot className="w-3.5 h-3.5 text-cyan-400" />
<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`}>
<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" : ""}`}>
<span className="text-[10px] font-mono text-muted-foreground">{msg.timestamp}</span>
<div
className={`flex items-center gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
<span className="text-[10px] font-mono text-muted-foreground">
{msg.timestamp}
</span>
{msg.model && (
<Badge variant="outline" className="text-[9px] h-4 px-1.5 font-mono border-primary/30 text-primary">
<Badge
variant="outline"
className="text-[9px] h-4 px-1.5 font-mono border-[#00D4FF]/30 text-[#00D4FF]/80"
>
{msg.model}
</Badge>
)}
@@ -207,7 +242,8 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
<div className="w-full space-y-1 mb-1">
<p className="text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<Wrench className="w-3 h-3" />
{msg.toolCalls.length} tool call{msg.toolCalls.length > 1 ? "s" : ""}
{msg.toolCalls.length} tool call
{msg.toolCalls.length > 1 ? "s" : ""}
</p>
{msg.toolCalls.map((step, i) => (
<ToolCallCard key={i} step={step} index={i} />
@@ -222,14 +258,16 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
isUser
? "bg-primary/15 border border-primary/20 text-foreground"
: isSystem
? "bg-yellow-500/10 border border-yellow-500/20 text-yellow-200"
: msg.isError
? "bg-red-500/10 border border-red-500/20 text-red-300"
: "bg-secondary/50 border border-border/40 text-foreground"
? "bg-[#FFB800]/10 border border-[#FFB800]/20 text-[#FFB800]"
: msg.isError
? "bg-[#FF3366]/10 border border-[#FF3366]/20 text-[#FF3366]"
: "bg-secondary/50 border border-border/40 text-foreground"
}`}
>
{isUser || isSystem || msg.isError ? (
<pre className="font-mono text-xs whitespace-pre-wrap break-words">{msg.content}</pre>
<pre className="font-mono text-xs whitespace-pre-wrap break-words">
{msg.content}
</pre>
) : (
<div className="text-sm prose prose-invert prose-sm max-w-none">
<Streamdown>{msg.content}</Streamdown>
@@ -242,6 +280,139 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
);
}
// ─── Console Panel ────────────────────────────────────────────────────────────
function ConsolePanel({ messages }: { messages: ChatMessage[] }) {
const consoleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (consoleRef.current) {
consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
}
}, [messages]);
const consoleEntries = messages.filter(
m => m.toolCalls && m.toolCalls.length > 0
);
return (
<div className="flex flex-col h-full">
<div className="px-3 py-2.5 border-b border-border/30 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<Terminal className="w-3.5 h-3.5 text-[#00FF88]" />
<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>
</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 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">
No tool output yet
</p>
<p className="text-[9px] text-muted-foreground/25">
Tool calls will appear here
</p>
</div>
) : (
consoleEntries.map(msg =>
msg.toolCalls?.map((step, i) => (
<div
key={`${msg.id}-tool-${i}`}
className="rounded border border-border/30 bg-secondary/20 overflow-hidden"
>
<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">
{toolLabel(step.tool)}
</span>
<span className="text-muted-foreground/50 flex-1 truncate">
{step.args.command || step.args.path || ""}
</span>
<span className="text-muted-foreground/40">
{step.durationMs}ms
</span>
{step.success ? (
<CheckCircle className="w-3 h-3 text-[#00FF88]" />
) : (
<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">
{step.success
? typeof step.result === "string"
? step.result.slice(0, 500)
: JSON.stringify(step.result, null, 2).slice(0, 500)
: `ERROR: ${step.result?.error ?? "Unknown"}`}
</pre>
</div>
))
)
)}
</div>
</div>
);
}
// ─── Sidebar Tab Button ──────────────────────────────────────────────────────
function SidebarTabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
count?: number;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-mono uppercase tracking-wider transition-colors border-b-2 ${
active
? "text-foreground border-[#00D4FF] bg-[#00D4FF]/5"
: "text-muted-foreground/50 border-transparent hover:text-muted-foreground hover:border-border/50"
}`}
>
{icon}
{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"
}`}
>
{count}
</span>
)}
</button>
);
}
// ─── Main Chat Component ──────────────────────────────────────────────────────
export default function Chat() {
@@ -252,16 +423,21 @@ export default function Chat() {
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 [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 inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const agentsQuery = trpc.agents.list.useQuery(undefined, { refetchInterval: 30000 });
const agentsQuery = trpc.agents.list.useQuery(undefined, {
refetchInterval: 30000,
});
const orchestratorMutation = trpc.orchestrator.chat.useMutation();
const orchestratorConfigQuery = trpc.orchestrator.getConfig.useQuery();
// Initialize welcome message with orchestrator name from DB
useEffect(() => {
if (orchestratorConfigQuery.data && messages.length === 0) {
const cfg = orchestratorConfigQuery.data;
@@ -270,7 +446,10 @@ export default function Chat() {
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" }),
timestamp: new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
}),
},
]);
}
@@ -282,8 +461,24 @@ export default function Chat() {
}
}, [messages]);
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
}
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
const getTs = () =>
new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const sendMessage = async () => {
if (!input.trim() || isThinking) return;
@@ -291,14 +486,13 @@ export default function Chat() {
const userContent = input.trim();
const ts = getTs();
// Add user message
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: userContent,
timestamp: ts,
};
setMessages((prev) => [...prev, userMsg]);
setMessages(prev => [...prev, userMsg]);
const newHistory = [
...conversationHistory,
@@ -308,9 +502,11 @@ export default function Chat() {
setInput("");
setIsThinking(true);
// Add thinking indicator
// Switch to console tab when sending a message
setActiveTab("console");
const thinkingId = `thinking-${Date.now()}`;
setMessages((prev) => [
setMessages(prev => [
...prev,
{
id: thinkingId,
@@ -323,28 +519,23 @@ export default function Chat() {
try {
const result = await orchestratorMutation.mutateAsync({
messages: newHistory,
// model is loaded from DB config — do not override here
maxIterations: 10,
});
// Remove thinking indicator
setMessages((prev) => prev.filter((m) => m.id !== thinkingId));
setMessages(prev => prev.filter(m => m.id !== thinkingId));
const respTs = getTs();
// Clear error state on success
setLastError(null);
setRetryAttempt(0);
if (result.success) {
// Update conversation history
setConversationHistory((prev) => [
setConversationHistory(prev => [
...prev,
{ role: "assistant" as const, content: result.response },
]);
// Add assistant message with tool calls
setMessages((prev) => [
setMessages(prev => [
...prev,
{
id: `resp-${Date.now()}`,
@@ -357,7 +548,7 @@ export default function Chat() {
},
]);
} else {
setMessages((prev) => [
setMessages(prev => [
...prev,
{
id: `err-${Date.now()}`,
@@ -369,14 +560,17 @@ export default function Chat() {
]);
}
} catch (err: any) {
setMessages((prev) => prev.filter((m) => m.id !== thinkingId));
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");
const isRetryable =
errorMsg.includes("timeout") ||
errorMsg.includes("unavailable") ||
errorMsg.includes("ECONNREFUSED");
setLastError({ message: errorMsg, isRetryable });
setRetryAttempt((prev) => prev + 1);
setMessages((prev) => [
setRetryAttempt(prev => prev + 1);
setMessages(prev => [
...prev,
{
id: `err-${Date.now()}`,
@@ -386,29 +580,46 @@ export default function Chat() {
isError: true,
},
]);
// Auto-retry if retryable and under max attempts
if (isRetryable && retryAttempt < 2) {
setTimeout(() => {
sendMessage();
}, 1000 * Math.pow(2, retryAttempt));
setTimeout(
() => {
sendMessage();
},
1000 * Math.pow(2, retryAttempt)
);
}
} finally {
setIsThinking(false);
setTimeout(() => inputRef.current?.focus(), 100);
} };
setTimeout(() => textareaRef.current?.focus(), 100);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const agents = agentsQuery.data ?? [];
const activeAgents = agents.filter((a) => a.isActive && !(a as any).isOrchestrator);
const activeAgents = agents.filter(
a => a.isActive && !(a as any).isOrchestrator
);
const orchConfig = orchestratorConfigQuery.data;
const toolCallCount = messages.reduce(
(acc, m) => acc + (m.toolCalls?.length ?? 0),
0
);
return (
<div className="h-full flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-cyan-500/15 border border-cyan-500/30 flex items-center justify-center">
<Bot className="w-4 h-4 text-cyan-400" />
<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]" />
</div>
<div>
<h2 className="text-lg font-bold text-foreground">
@@ -417,8 +628,10 @@ export default function Chat() {
<p className="text-[11px] font-mono text-muted-foreground">
{orchConfig ? (
<span>
<span className="text-cyan-400/80">{orchConfig.model}</span>
{" · "}{activeAgents.length} agents · {ORCHESTRATOR_TOOLS_COUNT} tools
<span className="text-[#00D4FF]/80">{orchConfig.model}</span>
{" · "}
{activeAgents.length} agents · {ORCHESTRATOR_TOOLS_COUNT}{" "}
tools
</span>
) : (
`Main AI · ${activeAgents.length} agents · ${ORCHESTRATOR_TOOLS_COUNT} tools`
@@ -427,25 +640,30 @@ export default function Chat() {
</div>
</div>
{/* Active agents badges + Configure link */}
<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) => (
{activeAgents.slice(0, 3).map(agent => (
<Badge
key={agent.id}
variant="outline"
className="text-[9px] h-5 px-1.5 font-mono border-border/50 text-muted-foreground"
>
{agent.role === "browser" && <Globe className="w-2.5 h-2.5 mr-1 text-cyan-400" />}
{agent.role === "tool_builder" && <Wrench className="w-2.5 h-2.5 mr-1 text-orange-400" />}
{agent.role === "agent_compiler" && <Cpu className="w-2.5 h-2.5 mr-1 text-purple-400" />}
{agent.role === "browser" && (
<Globe className="w-2.5 h-2.5 mr-1 text-[#00D4FF]" />
)}
{agent.role === "tool_builder" && (
<Wrench className="w-2.5 h-2.5 mr-1 text-[#FFB800]" />
)}
{agent.role === "agent_compiler" && (
<Cpu className="w-2.5 h-2.5 mr-1 text-purple-400" />
)}
{agent.name}
</Badge>
))}
</div>
<a
href="/agents"
className="text-[10px] font-mono px-2 py-1 rounded border border-cyan-500/30 text-cyan-400/70 hover:text-cyan-400 hover:border-cyan-500/60 transition-colors bg-cyan-500/5 shrink-0"
className="text-[10px] font-mono px-2 py-1 rounded border border-[#00D4FF]/30 text-[#00D4FF]/70 hover:text-[#00D4FF] hover:border-[#00D4FF]/60 transition-colors bg-[#00D4FF]/5 shrink-0"
>
Configure
</a>
@@ -457,85 +675,124 @@ export default function Chat() {
{/* Chat area */}
<Card className="flex-1 bg-card border-border/50 overflow-hidden">
<CardContent className="p-0 h-full flex flex-col">
<ScrollArea className="flex-1">
<div ref={scrollRef} className="p-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map((msg) => (
<MessageBubble key={msg.id} msg={msg} />
))}
</AnimatePresence>
<ScrollArea className="flex-1">
<div ref={scrollRef} className="p-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
</AnimatePresence>
{/* Thinking indicator */}
{isThinking && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 text-cyan-400 font-mono text-xs pl-10"
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span className="text-muted-foreground">Orchestrator thinking...</span>
</motion.div>
)}
</div>
</ScrollArea>
{/* 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">
{[
"Список агентов",
"Покажи файлы проекта",
"Статус Docker",
"Создай инструмент",
"Скомпилируй агента",
].map((cmd) => (
<button
key={cmd}
onClick={() => setInput(cmd)}
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors bg-secondary/20"
>
{cmd}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-cyan-400 font-mono text-sm shrink-0">$</span>
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
placeholder={isThinking ? "Ожидание ответа оркестратора..." : "Введите команду или вопрос..."}
disabled={isThinking}
className="bg-transparent border-none text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0 h-8"
/>
<Button
size="sm"
onClick={sendMessage}
disabled={isThinking || !input.trim()}
className="bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 h-8 w-8 p-0 shrink-0"
>
{isThinking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
{isThinking && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 text-[#00D4FF] font-mono text-xs pl-10"
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span className="text-muted-foreground">
Orchestrator thinking...
</span>
</motion.div>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
{/* Tasks Panel */}
<div className="w-80 border-l border-border/30 bg-secondary/5 rounded-lg overflow-hidden">
<TasksPanel conversationId={conversationId} />
{/* 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">
{[
"Список агентов",
"Покажи файлы проекта",
"Статус Docker",
"Создай инструмент",
"Скомпилируй агента",
].map(cmd => (
<button
key={cmd}
onClick={() => setInput(cmd)}
className="text-[10px] font-mono px-2 py-0.5 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-[#00D4FF]/40 transition-colors bg-secondary/20"
>
{cmd}
</button>
))}
</div>
<div className="flex items-end gap-2">
<span className="text-[#00D4FF] font-mono text-sm shrink-0 pb-1">
$
</span>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isThinking
? "Ожидание ответа оркестратора..."
: "Введите команду или вопрос... (Shift+Enter для новой строки)"
}
disabled={isThinking}
rows={1}
className="flex-1 bg-transparent text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus:outline-none resize-none min-h-[32px] max-h-[150px] py-1.5"
/>
<Button
size="sm"
onClick={sendMessage}
disabled={isThinking || !input.trim()}
className="bg-[#00D4FF]/15 text-[#00D4FF] border border-[#00D4FF]/30 hover:bg-[#00D4FF]/25 h-8 w-8 p-0 shrink-0"
>
{isThinking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
</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 */}
<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}
/>
<SidebarTabButton
active={activeTab === "tasks"}
onClick={() => setActiveTab("tasks")}
icon={<ListChecks className="w-3 h-3" />}
label="Tasks"
/>
<SidebarTabButton
active={activeTab === "research"}
onClick={() => setActiveTab("research")}
icon={<Search className="w-3 h-3" />}
label="Research"
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{activeTab === "console" && <ConsolePanel messages={messages} />}
{activeTab === "tasks" && (
<TasksPanel conversationId={conversationId} />
)}
{activeTab === "research" && (
<WebResearchPanel conversationId={conversationId} />
)}
</div>
</div>
</div>
</div>
);
}
// Count of orchestrator tools (used in header)
const ORCHESTRATOR_TOOLS_COUNT = 10;