feat(agents): restore agent-worker container architecture + fix chat scroll and parallel chats

- Restore agent-worker from commit 153399f: autonomous HTTP server per agent
  (main.go 597 lines, main_test.go 438 lines, Dockerfile.agent-worker)
- Add container fields to agents table (serviceName, servicePort, containerImage, containerStatus)
- Update executor.go: real delegateToAgent() with HTTP POST to agent containers
- Update db.go: GetAgentByID, UpdateContainerStatus, GetAgentHistory, SaveHistory
- Update orchestrator.go: inject DB into executor for container address resolution
- Add tRPC endpoints: agents.deployContainer, agents.stopContainer, agents.containerStatus
- Add Docker Swarm deploy/stop logic in server/agents.ts
- Add Start/Stop container buttons to Agents.tsx with status badges
- Fix chat auto-scroll: replace ScrollArea with overflow-y-auto for direct scrollTop control
- Fix parallel chats: make isThinking per-conversation (thinkingConvId) instead of global
  so switching between chats works while one is processing
This commit is contained in:
¨NW¨
2026-04-10 15:43:33 +01:00
parent 42a4f2d01d
commit 0f23dffc26
14 changed files with 2583 additions and 473 deletions

View File

@@ -93,7 +93,7 @@ function getTs(): string {
class ChatStore {
private conversations: Conversation[] = loadConversations();
private activeId: string = "";
private isThinking = false;
private thinkingConvId: string | null = null; // per-conversation thinking lock
private activeAgents: AgentActivity[] = [];
private listeners = new Set<UpdateHandler>();
@@ -129,7 +129,14 @@ class ChatStore {
return this.conversations.find(c => c.id === this.activeId) ?? null;
}
getIsThinking(): boolean {
return this.isThinking;
return this.thinkingConvId !== null;
}
getIsConversationThinking(convId: string): boolean {
return this.thinkingConvId === convId;
}
/** Which conversation is currently thinking, or null */
getThinkingConvId(): string | null {
return this.thinkingConvId;
}
// ─── Mutations ──────────────────────────────────────────────────────────────
@@ -175,8 +182,12 @@ class ChatStore {
this.emit();
}
setThinking(v: boolean) {
this.isThinking = v;
setThinking(v: boolean, convId?: string) {
if (v) {
this.thinkingConvId = convId ?? this.activeId;
} else {
this.thinkingConvId = null;
}
this.emit();
}
@@ -281,7 +292,7 @@ class ChatStore {
: c
);
persistConversations(this.conversations);
this.isThinking = false;
this.thinkingConvId = null;
this.emit();
}
@@ -299,7 +310,7 @@ class ChatStore {
c.id === convId ? { ...c, messages: [...c.messages, msg] } : c
);
persistConversations(this.conversations);
this.isThinking = false;
this.thinkingConvId = null;
this.emit();
}

View File

@@ -41,6 +41,8 @@ import {
Shield,
Wrench,
Code2,
Container,
Square,
} from "lucide-react";
import { motion } from "framer-motion";
import { useState } from "react";
@@ -74,6 +76,21 @@ function getStatusConfig(status: string) {
}
}
function getContainerStatusConfig(status: string | undefined | null) {
switch (status) {
case "running":
return { label: "RUNNING", color: "text-neon-green", bg: "bg-neon-green/15", border: "border-neon-green/30", icon: Container };
case "deploying":
return { label: "DEPLOYING", color: "text-neon-amber", bg: "bg-neon-amber/15", border: "border-neon-amber/30", icon: Loader2 };
case "error":
return { label: "ERROR", color: "text-neon-red", bg: "bg-neon-red/15", border: "border-neon-red/30", icon: AlertCircle };
case "stopped":
default:
return { label: "STOPPED", color: "text-muted-foreground", bg: "bg-muted/50", border: "border-border", icon: Square };
}
}
}
export default function Agents() {
const [selectedAgent, setSelectedAgent] = useState<any>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -97,6 +114,37 @@ export default function Agents() {
},
});
const deployMutation = trpc.agents.deployContainer.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success(`Agent container deployed: ${data.serviceName}`);
} else {
toast.error(`Deploy failed: ${data.error}`);
}
refetch();
},
onError: (error) => {
toast.error(`Deploy error: ${error.message}`);
},
});
const stopMutation = trpc.agents.stopContainer.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success("Agent container stopped");
} else {
toast.error(`Stop failed: ${data.error}`);
}
refetch();
},
onError: (error) => {
toast.error(`Stop error: ${error.message}`);
},
});
const [deployingAgentId, setDeployingAgentId] = useState<number | null>(null);
const [stoppingAgentId, setStoppingAgentId] = useState<number | null>(null);
const handleDeleteClick = (agentId: number) => {
setAgentToDelete(agentId);
setDeleteConfirmOpen(true);
@@ -139,7 +187,8 @@ export default function Agents() {
<div>
<h2 className="text-xl font-bold text-foreground">Agent Fleet</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
{agents.filter((a: any) => !a.isOrchestrator).length} agents · {agents.filter((a: any) => a.isOrchestrator).length} orchestrator
{agents.filter((a: any) => !a.isOrchestrator).length} agents ·{" "}
{agents.filter((a: any) => a.isOrchestrator).length} orchestrator
</p>
</div>
<Button
@@ -157,7 +206,9 @@ export default function Agents() {
<Card className="bg-card border-border/50">
<CardContent className="p-12 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground mb-2">No Agents Deployed</h3>
<h3 className="text-lg font-semibold text-foreground mb-2">
No Agents Deployed
</h3>
<p className="text-sm text-muted-foreground mb-6">
Start by deploying your first AI agent to the cluster.
</p>
@@ -173,98 +224,144 @@ export default function Agents() {
) : (
<div className="space-y-6">
{/* Orchestrator Section */}
{agents.filter((a: any) => a.isOrchestrator).map((agent: any) => {
const temperature = typeof agent.temperature === "string" ? parseFloat(agent.temperature) : (agent.temperature ?? 0.7);
return (
<motion.div key={agent.id} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<Card className="bg-card border-cyan-500/40 hover:border-cyan-500/70 transition-all glow-cyan">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-cyan-500/15 border border-cyan-500/40 flex items-center justify-center">
<Crown className="w-6 h-6 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-foreground">{agent.name}</h3>
<Badge className="text-[9px] font-mono bg-cyan-500/20 text-cyan-300 border-cyan-500/40 px-1.5 py-0">
<Crown className="w-2.5 h-2.5 mr-1" /> ORCHESTRATOR
</Badge>
<Badge className="text-[9px] font-mono bg-amber-500/20 text-amber-300 border-amber-500/40 px-1.5 py-0">
<Shield className="w-2.5 h-2.5 mr-1" /> SYSTEM
</Badge>
{agents
.filter((a: any) => a.isOrchestrator)
.map((agent: any) => {
const temperature =
typeof agent.temperature === "string"
? parseFloat(agent.temperature)
: (agent.temperature ?? 0.7);
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="bg-card border-cyan-500/40 hover:border-cyan-500/70 transition-all glow-cyan">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-cyan-500/15 border border-cyan-500/40 flex items-center justify-center">
<Crown className="w-6 h-6 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-foreground">
{agent.name}
</h3>
<Badge className="text-[9px] font-mono bg-cyan-500/20 text-cyan-300 border-cyan-500/40 px-1.5 py-0">
<Crown className="w-2.5 h-2.5 mr-1" />{" "}
ORCHESTRATOR
</Badge>
<Badge className="text-[9px] font-mono bg-amber-500/20 text-amber-300 border-amber-500/40 px-1.5 py-0">
<Shield className="w-2.5 h-2.5 mr-1" /> SYSTEM
</Badge>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{agent.description ||
"Main orchestrator — controls all agents, tools and system resources"}
</p>
</div>
</div>
<Badge
variant="outline"
className="text-[10px] font-mono bg-neon-green/15 text-neon-green border-neon-green/30"
>
<span className="w-1.5 h-1.5 rounded-full bg-neon-green mr-1.5 pulse-indicator" />
ACTIVE
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-cyan-500/10 border border-cyan-500/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">
MODEL
</div>
<div className="text-xs font-mono font-bold text-cyan-400">
{agent.model}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{agent.provider}
</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">
TEMPERATURE
</div>
<div className="text-xs font-mono font-bold text-foreground">
{temperature.toFixed(2)}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Tokens: {agent.maxTokens}
</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">
TOOLS
</div>
<div className="text-xs font-mono font-bold text-foreground">
{agent.allowedTools?.length ?? 0} tools
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Full access
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{agent.description || "Main orchestrator — controls all agents, tools and system resources"}</p>
</div>
</div>
<Badge variant="outline" className="text-[10px] font-mono bg-neon-green/15 text-neon-green border-neon-green/30">
<span className="w-1.5 h-1.5 rounded-full bg-neon-green mr-1.5 pulse-indicator" />
ACTIVE
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-cyan-500/10 border border-cyan-500/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">MODEL</div>
<div className="text-xs font-mono font-bold text-cyan-400">{agent.model}</div>
<div className="text-[10px] font-mono text-muted-foreground">{agent.provider}</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">TEMPERATURE</div>
<div className="text-xs font-mono font-bold text-foreground">{temperature.toFixed(2)}</div>
<div className="text-[10px] font-mono text-muted-foreground">Tokens: {agent.maxTokens}</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="text-[10px] text-muted-foreground font-mono mb-1">TOOLS</div>
<div className="text-xs font-mono font-bold text-foreground">
{agent.allowedTools?.length ?? 0} tools
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span
key={tool}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/10 text-cyan-400 border border-cyan-500/20"
>
{tool}
</span>
))}
</div>
</div>
<div className="text-[10px] font-mono text-muted-foreground">Full access</div>
</div>
</div>
)}
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span key={tool} className="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{tool}
</span>
))}
</div>
<div className="flex items-center gap-2 pt-3 border-t border-cyan-500/20">
<Button
size="sm"
className="h-7 text-[11px] bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25"
onClick={e => {
e.stopPropagation();
navigate("/chat");
}}
>
<MessageSquare className="w-3 h-3 mr-1" /> Open Chat
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={e => {
e.stopPropagation();
handleEditAgent(agent);
}}
>
Configure
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={e => {
e.stopPropagation();
navigate(`/agents/${agent.id}/metrics`);
}}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t border-cyan-500/20">
<Button
size="sm"
className="h-7 text-[11px] bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25"
onClick={(e) => { e.stopPropagation(); navigate("/chat"); }}
>
<MessageSquare className="w-3 h-3 mr-1" /> Open Chat
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={(e) => { e.stopPropagation(); handleEditAgent(agent); }}
>
Configure
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${agent.id}/metrics`); }}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</CardContent>
</Card>
</motion.div>
);
})}
{/* Specialized Agents Section */}
{agents.filter((a: any) => !a.isOrchestrator).length > 0 && (
@@ -273,126 +370,226 @@ export default function Agents() {
<Bot className="w-4 h-4" /> SPECIALIZED AGENTS
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{agents.filter((a: any) => !a.isOrchestrator).map((agent: any, i: number) => {
const sc = getStatusConfig(agent.status || "idle");
const Icon = ROLE_ICONS[agent.role] || Bot;
const temperature = typeof agent.temperature === "string" ? parseFloat(agent.temperature) : (agent.temperature ?? 0.7);
{agents
.filter((a: any) => !a.isOrchestrator)
.map((agent: any, i: number) => {
const sc = getStatusConfig(agent.status || "idle");
const csc = getContainerStatusConfig(agent.containerStatus);
const ContainerIcon = csc.icon;
const Icon = ROLE_ICONS[agent.role] || Bot;
const temperature =
typeof agent.temperature === "string"
? parseFloat(agent.temperature)
: (agent.temperature ?? 0.7);
const isDeploying = deployingAgentId === agent.id;
const isStopping = stoppingAgentId === agent.id;
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<Card className={`bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer ${sc.glow}`} onClick={() => handleEditAgent(agent)}>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 border border-border/50 flex items-center justify-center">
<Icon className={`w-5 h-5 ${sc.color}`} />
</div>
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">{agent.name}</h3>
{agent.isSystem && (
<Badge className="text-[9px] font-mono bg-amber-500/15 text-amber-400 border-amber-500/30 px-1 py-0">
SYS
</Badge>
)}
return (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<Card
className={`bg-card border-border/50 hover:border-primary/30 transition-all cursor-pointer ${sc.glow}`}
onClick={() => handleEditAgent(agent)}
>
<CardContent className="p-5">
{/* Top row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 border border-border/50 flex items-center justify-center">
<Icon className={`w-5 h-5 ${sc.color}`} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{agent.description || "No description"}</p>
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{agent.name}
</h3>
{agent.isSystem && (
<Badge className="text-[9px] font-mono bg-amber-500/15 text-amber-400 border-amber-500/30 px-1 py-0">
SYS
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{agent.description || "No description"}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Badge
variant="outline"
className={`text-[10px] font-mono ${sc.badge}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${sc.bg} mr-1.5 ${agent.isActive ? "pulse-indicator" : ""}`}
/>
{agent.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
<Badge
variant="outline"
className={`text-[9px] font-mono ${csc.bg} ${csc.color} ${csc.border}`}
>
<ContainerIcon className={`w-2.5 h-2.5 mr-1 ${isDeploying || isStopping ? "animate-spin" : ""}`} />
{csc.label}
</Badge>
</div>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.bg} mr-1.5 ${agent.isActive ? "pulse-indicator" : ""}`} />
{agent.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
</div>
{/* Model & Config */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Brain className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">MODEL</span>
</div>
<div className="text-xs font-mono font-medium text-primary">{agent.model}</div>
<div className="text-[10px] font-mono text-muted-foreground">{agent.provider}</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">CONFIG</span>
</div>
<div className="text-xs font-mono font-medium text-foreground">T: {temperature.toFixed(2)}</div>
<div className="text-[10px] font-mono text-muted-foreground">Tokens: {agent.maxTokens}</div>
</div>
</div>
{/* Role & Date */}
<div className="flex items-center gap-4 mb-4 text-[10px] font-mono">
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
<span className="text-muted-foreground">Role:</span>
<span className="text-foreground font-medium capitalize">{agent.role}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-primary" />
<span className="text-muted-foreground">Created:</span>
<span className="text-foreground font-medium">{new Date(agent.createdAt).toLocaleDateString()}</span>
</div>
</div>
{/* Tools */}
{agent.allowedTools && agent.allowedTools.length > 0 && (
<div className="mb-4">
<span className="text-[10px] text-muted-foreground font-mono block mb-1.5">TOOLS</span>
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span key={tool} className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20">
{tool}
{/* Model & Config */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Brain className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">
MODEL
</span>
))}
</div>
<div className="text-xs font-mono font-medium text-primary">
{agent.model}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{agent.provider}
</div>
</div>
<div className="p-2.5 rounded-md bg-secondary/30 border border-border/20">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-primary" />
<span className="text-[10px] text-muted-foreground font-mono">
CONFIG
</span>
</div>
<div className="text-xs font-mono font-medium text-foreground">
T: {temperature.toFixed(2)}
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Tokens: {agent.maxTokens}
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={(e) => { e.stopPropagation(); handleEditAgent(agent); }}
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${agent.id}/metrics`); }}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
{!agent.isSystem && (
{/* Role & Date */}
<div className="flex items-center gap-4 mb-4 text-[10px] font-mono">
<div className="flex items-center gap-1">
<Zap className="w-3 h-3 text-neon-amber" />
<span className="text-muted-foreground">
Role:
</span>
<span className="text-foreground font-medium capitalize">
{agent.role}
</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-primary" />
<span className="text-muted-foreground">
Created:
</span>
<span className="text-foreground font-medium">
{new Date(
agent.createdAt
).toLocaleDateString()}
</span>
</div>
</div>
{/* Tools */}
{agent.allowedTools &&
agent.allowedTools.length > 0 && (
<div className="mb-4">
<span className="text-[10px] text-muted-foreground font-mono block mb-1.5">
TOOLS
</span>
<div className="flex flex-wrap gap-1.5">
{agent.allowedTools.map((tool: string) => (
<span
key={tool}
className="px-2 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary border border-primary/20"
>
{tool}
</span>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/30">
{agent.containerStatus === "running" ? (
<Button
size="sm"
className="h-7 text-[11px] bg-neon-red/15 text-neon-red border border-neon-red/30 hover:bg-neon-red/25"
disabled={isStopping}
onClick={e => {
e.stopPropagation();
setStoppingAgentId(agent.id);
stopMutation.mutate({ agentId: agent.id }, {
onSettled: () => setStoppingAgentId(null),
});
}}
>
{isStopping ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Square className="w-3 h-3 mr-1" />}
Stop
</Button>
) : (
<Button
size="sm"
className="h-7 text-[11px] bg-neon-green/15 text-neon-green border border-neon-green/30 hover:bg-neon-green/25"
disabled={isDeploying || agent.containerStatus === "deploying"}
onClick={e => {
e.stopPropagation();
setDeployingAgentId(agent.id);
deployMutation.mutate({ agentId: agent.id }, {
onSettled: () => setDeployingAgentId(null),
});
}}
>
{isDeploying || agent.containerStatus === "deploying" ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Play className="w-3 h-3 mr-1" />}
{isDeploying || agent.containerStatus === "deploying" ? "Deploying" : "Deploy"}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10 ml-auto"
onClick={(e) => { e.stopPropagation(); handleDeleteClick(agent.id); }}
className="h-7 text-[11px] text-primary border-primary/30 hover:bg-primary/10"
onClick={e => {
e.stopPropagation();
handleEditAgent(agent);
}}
>
<Trash2 className="w-3 h-3 mr-1" /> Delete
Edit
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={e => {
e.stopPropagation();
navigate(`/agents/${agent.id}/metrics`);
}}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
{!agent.isSystem && (
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-red border-neon-red/30 hover:bg-neon-red/10 ml-auto"
onClick={e => {
e.stopPropagation();
handleDeleteClick(agent.id);
}}
>
<Trash2 className="w-3 h-3 mr-1" /> Delete
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</div>
)}
@@ -419,7 +616,8 @@ export default function Agents() {
<AlertDialogHeader>
<AlertDialogTitle>Delete Agent</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this agent? This action cannot be undone.
Are you sure you want to delete this agent? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3">

View File

@@ -60,6 +60,9 @@ function useChatStore() {
activeId: chatStore.getActiveId(),
active: chatStore.getActive(),
isThinking: chatStore.getIsThinking(),
isThinkingConvId: chatStore.getThinkingConvId(),
isConversationThinking: (convId: string) =>
chatStore.getIsConversationThinking(convId),
activeAgents: chatStore.getActiveAgents(),
};
}
@@ -509,11 +512,13 @@ function ConversationItem({
isActive,
onClick,
onDelete,
isThinking,
}: {
conv: Conversation;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
isThinking?: boolean;
}) {
const lastMsg = conv.messages[conv.messages.length - 1];
const lastContent =
@@ -530,12 +535,19 @@ function ConversationItem({
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"
: isThinking
? "bg-[#FFB800]/5 border border-[#FFB800]/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="relative">
<MessageSquare
className={`w-3 h-3 mt-0.5 shrink-0 ${isActive ? "text-[#00D4FF]" : isThinking ? "text-[#FFB800]" : "text-muted-foreground/40"}`}
/>
{isThinking && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[#FFB800] animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-[10px] font-mono truncate ${isActive ? "text-foreground" : "text-foreground/70"}`}
@@ -575,8 +587,15 @@ function ConversationItem({
// ─── Main Chat Component ──────────────────────────────────────────────────────
export default function Chat() {
const { conversations, activeId, active, isThinking, activeAgents } =
useChatStore();
const {
conversations,
activeId,
active,
isThinking,
isThinkingConvId,
isConversationThinking,
activeAgents,
} = useChatStore();
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState<SidebarTab>("console");
const scrollRef = useRef<HTMLDivElement>(null);
@@ -598,11 +617,21 @@ export default function Chat() {
}
}, [orchestratorConfigQuery.data, conversations.length]);
// Auto-scroll
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current)
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [active?.messages]);
const el = scrollRef.current;
if (!el) return;
const scrollToBottom = () => {
el.scrollTop = el.scrollHeight;
};
requestAnimationFrame(scrollToBottom);
const t1 = setTimeout(scrollToBottom, 50);
const t2 = setTimeout(scrollToBottom, 200);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [active?.messages, isCurrentConvThinking, activeAgents]);
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
@@ -718,15 +747,19 @@ export default function Chat() {
activeId,
]);
const isCurrentConvThinking = activeId
? isConversationThinking(activeId)
: false;
const sendMessage = async () => {
if (!input.trim() || isThinking) return;
if (!input.trim() || isCurrentConvThinking) return;
const { convId: cid, newHistory } = chatStore.addUserMessage(
input.trim(),
convId
);
setInput("");
chatStore.setThinking(true);
chatStore.setThinking(true, cid);
setActiveTab("console");
const thinkingId = chatStore.addThinkingMessage(cid);
@@ -877,6 +910,7 @@ export default function Chat() {
key={c.id}
conv={c}
isActive={c.id === activeId}
isThinking={isConversationThinking(c.id)}
onClick={() => chatStore.setActiveId(c.id)}
onDelete={() => chatStore.deleteConversation(c.id)}
/>
@@ -888,14 +922,14 @@ export default function Chat() {
{/* ─── 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">
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
</AnimatePresence>
{isThinking && (
{isCurrentConvThinking && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -914,7 +948,7 @@ export default function Chat() {
</motion.div>
)}
</div>
</ScrollArea>
</div>
{/* Input area */}
<div className="border-t border-border/50 p-3 bg-secondary/10 shrink-0">
@@ -945,21 +979,21 @@ export default function Chat() {
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isThinking
isCurrentConvThinking
? "Ожидание ответа..."
: "Введите команду... (Shift+Enter для новой строки)"
}
disabled={isThinking}
disabled={isCurrentConvThinking}
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()}
disabled={isCurrentConvThinking || !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 ? (
{isCurrentConvThinking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />