3075 lines
149 KiB
TypeScript
3075 lines
149 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import {
|
|
Github,
|
|
Settings,
|
|
CheckCircle,
|
|
XCircle,
|
|
Loader2,
|
|
GitBranch,
|
|
GitCommit,
|
|
FolderSearch,
|
|
FileCode,
|
|
Zap,
|
|
Bot,
|
|
ExternalLink,
|
|
Sparkles,
|
|
Play,
|
|
Eye,
|
|
Paperclip,
|
|
X,
|
|
FileText,
|
|
Upload,
|
|
RotateCcw,
|
|
History,
|
|
Monitor,
|
|
Rocket,
|
|
RefreshCw,
|
|
Smartphone,
|
|
Tablet,
|
|
Globe,
|
|
Code2,
|
|
Layers,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ImageIcon,
|
|
Layout,
|
|
LayoutGrid,
|
|
Clock,
|
|
ArrowRight,
|
|
ThumbsUp,
|
|
ThumbsDown,
|
|
Cpu,
|
|
Shield,
|
|
Brain,
|
|
Undo2,
|
|
BookOpen,
|
|
Gauge,
|
|
ShieldCheck,
|
|
AlertTriangle,
|
|
CheckSquare,
|
|
Square,
|
|
Fingerprint,
|
|
PanelLeft,
|
|
Save,
|
|
Info
|
|
} from "lucide-react";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import DevHistory from "@/components/DevHistory";
|
|
|
|
// ============================================================================
|
|
// PHASE 6: Agent Factory Tabs (Design, Assemble, Deploy, Galeria)
|
|
// ============================================================================
|
|
|
|
type AgentDef = {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
spec: { mode: "markdown" | "typescript" | "visual"; content: string } | null;
|
|
status: "draft" | "assembling" | "ready" | "deployed";
|
|
version: number;
|
|
lastTaskId: number | null;
|
|
tenantId: string | null;
|
|
userId: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
function AgentFactoryTabs() {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
// ---- Design tab state ----
|
|
const [selectedDefId, setSelectedDefId] = useState<number | null>(null);
|
|
const [editorMode, setEditorMode] = useState<"markdown" | "typescript" | "visual">("markdown");
|
|
const [editorContent, setEditorContent] = useState("");
|
|
const [defName, setDefName] = useState("");
|
|
const [defDescription, setDefDescription] = useState("");
|
|
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
|
|
|
// ---- Assemble tab state ----
|
|
const [assemblePollingId, setAssemblePollingId] = useState<number | null>(null);
|
|
const [assembleTaskStatus, setAssembleTaskStatus] = useState<Record<number, string>>({});
|
|
|
|
// ---- Galeria filter + detail ----
|
|
const [galFilter, setGalFilter] = useState("");
|
|
const [galDetailDef, setGalDetailDef] = useState<(typeof defs)[0] | null>(null);
|
|
|
|
const { data: defsData, isLoading: defsLoading } = useQuery<{ success: boolean; data: AgentDef[] }>({
|
|
queryKey: ["/api/agent-defs"],
|
|
refetchInterval: assemblePollingId ? 3000 : false,
|
|
});
|
|
const defs = defsData?.data || [];
|
|
|
|
// Poll task status for assembling defs
|
|
useEffect(() => {
|
|
const assembling = defs.filter(d => d.status === "assembling" && d.lastTaskId);
|
|
if (assembling.length === 0) return;
|
|
|
|
const interval = setInterval(async () => {
|
|
for (const def of assembling) {
|
|
if (!def.lastTaskId) continue;
|
|
try {
|
|
const res = await fetch(`/api/blackboard/task/${def.lastTaskId}`, { credentials: "include" });
|
|
const data = await res.json();
|
|
const taskStatus = data?.task?.status;
|
|
if (taskStatus === "completed" || taskStatus === "failed") {
|
|
// Mark agent as ready or draft
|
|
await fetch(`/api/agent-defs/${def.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ status: taskStatus === "completed" ? "ready" : "draft" }),
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
}
|
|
setAssembleTaskStatus(prev => ({ ...prev, [def.id]: taskStatus || "unknown" }));
|
|
} catch { /* ignore */ }
|
|
}
|
|
}, 3000);
|
|
return () => clearInterval(interval);
|
|
}, [defs, queryClient]);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await apiRequest("POST", "/api/agent-defs", {
|
|
name: defName,
|
|
description: defDescription,
|
|
spec: { mode: editorMode, content: editorContent },
|
|
});
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
setSelectedDefId(data.data.id);
|
|
setIsCreatingNew(false);
|
|
toast({ title: "Agente criado", description: data.data.name });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("PATCH", `/api/agent-defs/${id}`, {
|
|
name: defName,
|
|
description: defDescription,
|
|
spec: { mode: editorMode, content: editorContent },
|
|
});
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
toast({ title: "Salvo" });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro ao salvar", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const assembleMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("POST", `/api/agent-defs/${id}/assemble`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: (data, id) => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
setAssemblePollingId(id);
|
|
toast({ title: "Montagem iniciada", description: `Task #${data.taskId}` });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const deployMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("POST", `/api/agent-defs/${id}/deploy`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
toast({ title: "Deploy realizado!" });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro no deploy", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const redraftMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("POST", `/api/agent-defs/${id}/redraft`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
toast({ title: "Volta para rascunho" });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const runMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("POST", `/api/agent-defs/${id}/run`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({ title: "Execução iniciada", description: `Task #${data.taskId}` });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro ao executar", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const forkMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("POST", `/api/agent-defs/${id}/fork`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
toast({ title: "Fork criado", description: data.data.name });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro ao fazer fork", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const res = await apiRequest("DELETE", `/api/agent-defs/${id}`, undefined);
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/agent-defs"] });
|
|
setSelectedDefId(null);
|
|
toast({ title: "Excluído" });
|
|
},
|
|
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
|
|
});
|
|
|
|
// Quando seleciona um def existente, carrega os campos
|
|
const selectDef = (def: AgentDef) => {
|
|
setSelectedDefId(def.id);
|
|
setDefName(def.name);
|
|
setDefDescription(def.description || "");
|
|
const spec = def.spec || { mode: "markdown", content: "" };
|
|
setEditorMode(spec.mode || "markdown");
|
|
setEditorContent(spec.content || "");
|
|
setIsCreatingNew(false);
|
|
};
|
|
|
|
const startNew = () => {
|
|
setSelectedDefId(null);
|
|
setDefName("");
|
|
setDefDescription("");
|
|
setEditorMode("markdown");
|
|
setEditorContent("");
|
|
setIsCreatingNew(true);
|
|
};
|
|
|
|
const statusColor: Record<string, string> = {
|
|
draft: "bg-zinc-500",
|
|
assembling: "bg-blue-500",
|
|
ready: "bg-green-500",
|
|
deployed: "bg-purple-500",
|
|
};
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
draft: "Rascunho",
|
|
assembling: "Montando...",
|
|
ready: "Pronto",
|
|
deployed: "Implantado",
|
|
};
|
|
|
|
const galDefs = defs.filter(d =>
|
|
d.status === "deployed" &&
|
|
(d.name.toLowerCase().includes(galFilter.toLowerCase()) ||
|
|
(d.description || "").toLowerCase().includes(galFilter.toLowerCase()))
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* ---- TAB: DESIGN ---- */}
|
|
<TabsContent value="design" className="space-y-4">
|
|
<div className="grid grid-cols-[260px_1fr] gap-4 h-[calc(100vh-240px)]">
|
|
{/* Lista de defs */}
|
|
<Card className="flex flex-col overflow-hidden">
|
|
<CardHeader className="border-b py-3 px-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<Layers className="w-4 h-4 text-purple-500" /> Agentes
|
|
</CardTitle>
|
|
<Button size="sm" variant="ghost" onClick={startNew} className="h-7 px-2 text-xs">
|
|
+ Novo
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<ScrollArea className="flex-1">
|
|
{defsLoading ? (
|
|
<div className="flex items-center justify-center h-24">
|
|
<Loader2 className="w-4 h-4 animate-spin text-zinc-400" />
|
|
</div>
|
|
) : defs.length === 0 ? (
|
|
<p className="text-xs text-zinc-500 text-center py-8">Nenhum agente ainda.<br />Clique em "+ Novo" para criar.</p>
|
|
) : (
|
|
<div className="divide-y">
|
|
{defs.map(def => (
|
|
<button
|
|
key={def.id}
|
|
onClick={() => selectDef(def)}
|
|
className={`w-full text-left px-4 py-3 hover:bg-zinc-50 transition-colors ${selectedDefId === def.id ? "bg-purple-50 border-l-2 border-purple-500" : ""}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium truncate">{def.name}</span>
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white shrink-0 ${statusColor[def.status] || "bg-zinc-400"}`}>
|
|
{statusLabel[def.status] || def.status}
|
|
</span>
|
|
</div>
|
|
{def.description && (
|
|
<p className="text-xs text-zinc-500 truncate mt-0.5">{def.description}</p>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</Card>
|
|
|
|
{/* Editor */}
|
|
<Card className="flex flex-col overflow-hidden">
|
|
{(selectedDefId || isCreatingNew) ? (
|
|
<>
|
|
<CardHeader className="border-b py-3 px-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex-1 space-y-1">
|
|
<Input
|
|
value={defName}
|
|
onChange={e => setDefName(e.target.value)}
|
|
placeholder="Nome do agente"
|
|
className="h-7 text-sm font-medium"
|
|
/>
|
|
<Input
|
|
value={defDescription}
|
|
onChange={e => setDefDescription(e.target.value)}
|
|
placeholder="Descrição (opcional)"
|
|
className="h-6 text-xs text-zinc-500"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{(["markdown", "typescript", "visual"] as const).map(m => (
|
|
<Button
|
|
key={m}
|
|
size="sm"
|
|
variant={editorMode === m ? "default" : "outline"}
|
|
onClick={() => setEditorMode(m)}
|
|
className="h-7 px-2 text-xs capitalize"
|
|
>
|
|
{m === "markdown" ? "Markdown" : m === "typescript" ? "TypeScript" : "Visual"}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{isCreatingNew ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => createMutation.mutate()}
|
|
disabled={!defName.trim() || createMutation.isPending}
|
|
className="h-7 px-3 text-xs"
|
|
>
|
|
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3 mr-1" />}
|
|
Criar
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => saveMutation.mutate(selectedDefId!)}
|
|
disabled={saveMutation.isPending}
|
|
className="h-7 px-3 text-xs"
|
|
>
|
|
{saveMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3 mr-1" />}
|
|
Salvar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => deleteMutation.mutate(selectedDefId!)}
|
|
disabled={deleteMutation.isPending}
|
|
className="h-7 px-2 text-xs text-red-500 hover:text-red-600"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
{editorMode === "visual" ? (
|
|
<ScrollArea className="h-full p-4">
|
|
<p className="text-xs text-zinc-500 mb-3">Defina os nós do agente em JSON (name, type, connections):</p>
|
|
<Textarea
|
|
value={editorContent}
|
|
onChange={e => setEditorContent(e.target.value)}
|
|
placeholder={`[\n { "name": "Coletar dados", "type": "skill", "skill": "/skill/consulta_vendas" },\n { "name": "Analisar", "type": "skill", "skill": "/skill:system/analise_tendencia" },\n { "name": "Notificar", "type": "action", "tool": "/tool/whatsapp_send" }\n]`}
|
|
className="min-h-[400px] font-mono text-xs resize-none border-zinc-200"
|
|
/>
|
|
</ScrollArea>
|
|
) : (
|
|
<Textarea
|
|
value={editorContent}
|
|
onChange={e => setEditorContent(e.target.value)}
|
|
placeholder={
|
|
editorMode === "markdown"
|
|
? "# Nome do Agente\n\n## Objetivo\nDescreva o que este agente faz...\n\n## Skills utilizadas\n- /skill/consulta_vendas\n- /skill:system/analise_tendencia\n\n## Fluxo\n1. Coletar dados de vendas\n2. Analisar tendências\n3. Notificar gestor se necessário\n\n## Triggers\n- Schedule: toda segunda às 9h\n- Manual: via Dev Center"
|
|
: "// Especificação TypeScript do agente\nimport { BaseAgent } from '@arcadia/agent-sdk';\n\nexport class MeuAgente extends BaseAgent {\n // ...\n}"
|
|
}
|
|
className="h-full w-full resize-none border-0 rounded-none font-mono text-xs focus-visible:ring-0"
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-3">
|
|
<Layers className="w-12 h-12 opacity-30" />
|
|
<p className="text-sm">Selecione um agente ou crie um novo</p>
|
|
<Button size="sm" onClick={startNew} className="text-xs">
|
|
+ Novo Agente
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ---- TAB: ASSEMBLE ---- */}
|
|
<TabsContent value="assemble" className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="border-b py-4 px-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-100 rounded-full">
|
|
<Cpu className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-base">Linha de Montagem</CardTitle>
|
|
<CardDescription className="text-xs">Agentes em rascunho prontos para montagem via Blackboard</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{defsLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-zinc-400" />
|
|
</div>
|
|
) : defs.filter(d => d.status === "draft" || d.status === "assembling").length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-400 gap-2">
|
|
<CheckCircle className="w-10 h-10 opacity-30" />
|
|
<p className="text-sm">Nenhum agente em rascunho.</p>
|
|
<p className="text-xs">Crie agentes na aba Design e monte-os aqui.</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{defs.filter(d => d.status === "draft" || d.status === "assembling").map(def => {
|
|
const taskStatus = assembleTaskStatus[def.id];
|
|
return (
|
|
<div key={def.id} className="flex items-center gap-4 px-6 py-4 hover:bg-zinc-50">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm">{def.name}</span>
|
|
<Badge className={`text-[10px] px-1.5 py-0.5 text-white ${statusColor[def.status]}`}>
|
|
{statusLabel[def.status]}
|
|
</Badge>
|
|
{def.status === "assembling" && (
|
|
<Loader2 className="w-3 h-3 animate-spin text-blue-500" />
|
|
)}
|
|
</div>
|
|
{def.description && <p className="text-xs text-zinc-500 truncate mt-0.5">{def.description}</p>}
|
|
{def.lastTaskId && (
|
|
<p className="text-xs text-zinc-400 mt-0.5">
|
|
Task #{def.lastTaskId}
|
|
{taskStatus && ` — ${taskStatus}`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 shrink-0">
|
|
{def.status === "draft" && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => assembleMutation.mutate(def.id)}
|
|
disabled={assembleMutation.isPending}
|
|
className="text-xs h-8"
|
|
>
|
|
{assembleMutation.isPending ? (
|
|
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
|
) : (
|
|
<Zap className="w-3 h-3 mr-1" />
|
|
)}
|
|
Montar
|
|
</Button>
|
|
)}
|
|
{def.status === "assembling" && (
|
|
<span className="text-xs text-blue-500 flex items-center gap-1">
|
|
<Loader2 className="w-3 h-3 animate-spin" /> Processando...
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* ---- TAB: DEPLOY ---- */}
|
|
<TabsContent value="deploy" className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="border-b py-4 px-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-green-100 rounded-full">
|
|
<Rocket className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-base">Orchestrate Center</CardTitle>
|
|
<CardDescription className="text-xs">Agentes prontos para deploy e implantados</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{defsLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-zinc-400" />
|
|
</div>
|
|
) : defs.filter(d => d.status === "ready" || d.status === "deployed").length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-400 gap-2">
|
|
<Rocket className="w-10 h-10 opacity-30" />
|
|
<p className="text-sm">Nenhum agente pronto para deploy.</p>
|
|
<p className="text-xs">Monte um agente na aba Montar para vê-lo aqui.</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{defs.filter(d => d.status === "ready" || d.status === "deployed").map(def => (
|
|
<div key={def.id} className="flex items-center gap-4 px-6 py-4 hover:bg-zinc-50">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm">{def.name}</span>
|
|
<Badge className={`text-[10px] px-1.5 py-0.5 text-white ${statusColor[def.status]}`}>
|
|
{statusLabel[def.status]}
|
|
</Badge>
|
|
<span className="text-[10px] text-zinc-400">v{def.version}</span>
|
|
</div>
|
|
{def.description && <p className="text-xs text-zinc-500 truncate mt-0.5">{def.description}</p>}
|
|
{def.lastTaskId && (
|
|
<p className="text-xs text-zinc-400 mt-0.5">Último artefato: Task #{def.lastTaskId}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 shrink-0">
|
|
{def.status === "ready" && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => deployMutation.mutate(def.id)}
|
|
disabled={deployMutation.isPending}
|
|
className="text-xs h-8 bg-purple-600 hover:bg-purple-700"
|
|
>
|
|
<Rocket className="w-3 h-3 mr-1" /> Deploy
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => redraftMutation.mutate(def.id)}
|
|
disabled={redraftMutation.isPending}
|
|
className="text-xs h-8"
|
|
>
|
|
<RotateCcw className="w-3 h-3 mr-1" /> Re-montar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* ---- TAB: GALERIA ---- */}
|
|
<TabsContent value="galeria" className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Input
|
|
value={galFilter}
|
|
onChange={e => setGalFilter(e.target.value)}
|
|
placeholder="Buscar agentes..."
|
|
className="h-9 text-sm pl-8"
|
|
/>
|
|
<FolderSearch className="w-4 h-4 absolute left-2.5 top-2.5 text-zinc-400" />
|
|
</div>
|
|
<p className="text-xs text-zinc-500">{galDefs.length} agente{galDefs.length !== 1 ? "s" : ""} implantado{galDefs.length !== 1 ? "s" : ""}</p>
|
|
</div>
|
|
|
|
{defsLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-zinc-400" />
|
|
</div>
|
|
) : galDefs.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-16 text-zinc-400 gap-3">
|
|
<Brain className="w-12 h-12 opacity-30" />
|
|
<p className="text-sm font-medium">Nenhum agente implantado</p>
|
|
<p className="text-xs">Crie um agente na aba Design e faça o deploy para vê-lo aqui.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{galDefs.map(def => (
|
|
<Card key={def.id} className="flex flex-col hover:shadow-md transition-shadow">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<CardTitle className="text-sm truncate">{def.name}</CardTitle>
|
|
{def.description && (
|
|
<CardDescription className="text-xs mt-1 line-clamp-2">{def.description}</CardDescription>
|
|
)}
|
|
</div>
|
|
<Badge className="text-[10px] px-1.5 py-0.5 text-white bg-purple-500 shrink-0">
|
|
v{def.version}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 pb-4 mt-auto flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => runMutation.mutate(def.id)}
|
|
disabled={runMutation.isPending}
|
|
className="flex-1 text-xs h-8"
|
|
>
|
|
<Play className="w-3 h-3 mr-1" /> Executar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => forkMutation.mutate(def.id)}
|
|
disabled={forkMutation.isPending}
|
|
className="text-xs h-8 px-3"
|
|
title="Fork"
|
|
>
|
|
<GitBranch className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setGalDetailDef(def)}
|
|
className="text-xs h-8 px-3"
|
|
title="Ver spec"
|
|
>
|
|
<Info className="w-3 h-3" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dialog de detalhes do agente */}
|
|
<Dialog open={!!galDetailDef} onOpenChange={open => { if (!open) setGalDetailDef(null); }}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Brain className="w-4 h-4 text-purple-400" />
|
|
{galDetailDef?.name}
|
|
<Badge className="text-[10px] px-1.5 py-0.5 bg-purple-500 text-white ml-1">v{galDetailDef?.version}</Badge>
|
|
</DialogTitle>
|
|
{galDetailDef?.description && (
|
|
<DialogDescription>{galDetailDef.description}</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
<div className="space-y-3 mt-2">
|
|
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{(galDetailDef?.spec as any)?.mode ?? "markdown"}
|
|
</Badge>
|
|
<span>·</span>
|
|
<span>Criado em {galDetailDef ? new Date(galDetailDef.createdAt).toLocaleDateString("pt-BR") : ""}</span>
|
|
</div>
|
|
<ScrollArea className="h-64 rounded-md border border-zinc-800 bg-zinc-950 p-3">
|
|
<pre className="text-xs text-zinc-300 whitespace-pre-wrap font-mono">
|
|
{(galDetailDef?.spec as any)?.content ?? JSON.stringify(galDetailDef?.spec, null, 2)}
|
|
</pre>
|
|
</ScrollArea>
|
|
<div className="flex gap-2 pt-1">
|
|
<Button size="sm" className="flex-1 text-xs h-8" onClick={() => { if (galDetailDef) runMutation.mutate(galDetailDef.id); setGalDetailDef(null); }}>
|
|
<Play className="w-3 h-3 mr-1" /> Executar
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="text-xs h-8 px-3" onClick={() => { if (galDetailDef) forkMutation.mutate(galDetailDef.id); setGalDetailDef(null); }}>
|
|
<GitBranch className="w-3 h-3 mr-1" /> Fork
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</TabsContent>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
|
|
const FILE_TO_ROUTE_MAP: Record<string, string> = {
|
|
"Cockpit": "/",
|
|
"Home": "/",
|
|
"Agent": "/agent",
|
|
"Admin": "/admin",
|
|
"Chat": "/chat",
|
|
"WhatsApp": "/whatsapp",
|
|
"XosInbox": "/comunicacao",
|
|
"Automations": "/automations",
|
|
"BiWorkspace": "/insights",
|
|
"ProcessCompass": "/compass",
|
|
"Crm": "/crm",
|
|
"Production": "/production",
|
|
"Support": "/support",
|
|
"Valuation": "/valuation",
|
|
"Canvas": "/canvas",
|
|
"IDE": "/ide",
|
|
"Scientist": "/scientist",
|
|
"Knowledge": "/knowledge",
|
|
"CentralApis": "/central-apis",
|
|
"ApiTesterPage": "/api-tester",
|
|
"ApiHub": "/api-hub",
|
|
"Fisco": "/fisco",
|
|
"People": "/people",
|
|
"Contabil": "/contabil",
|
|
"SOE": "/soe",
|
|
"Financeiro": "/financeiro",
|
|
"Communities": "/communities",
|
|
"QualityModule": "/quality",
|
|
"CommercialEnv": "/commercial-env",
|
|
"FieldOperations": "/field-ops",
|
|
"TechnicalModule": "/technical",
|
|
"SuppliersPortal": "/suppliers",
|
|
"NPSSurvey": "/nps",
|
|
"EngineeringHub": "/engineering",
|
|
"DevelopmentModule": "/development",
|
|
"ArcadiaRetail": "/retail",
|
|
"Plus": "/plus",
|
|
"SuperAdmin": "/super-admin",
|
|
"Marketplace": "/marketplace",
|
|
"LMS": "/lms",
|
|
"AppCenter": "/apps",
|
|
"XosCentral": "/xos",
|
|
"XosCrm": "/xos/crm",
|
|
"XosTickets": "/xos/tickets",
|
|
"XosCampaigns": "/xos/campaigns",
|
|
"XosAutomations": "/xos/automations",
|
|
"XosSites": "/xos/sites",
|
|
"DocTypeBuilder": "/doctype-builder",
|
|
"PageBuilder": "/page-builder",
|
|
"Migration": "/migration",
|
|
"DevCenter": "/dev-center",
|
|
};
|
|
|
|
function getRouteFromFilePath(filePath: string): string | null {
|
|
const fileName = filePath.split("/").pop()?.replace(/\.(tsx|ts|jsx|js)$/, "") || "";
|
|
if (FILE_TO_ROUTE_MAP[fileName]) return FILE_TO_ROUTE_MAP[fileName];
|
|
|
|
const pathParts = filePath.toLowerCase();
|
|
if (pathParts.includes("/xos/")) {
|
|
if (pathParts.includes("crm")) return "/xos/crm";
|
|
if (pathParts.includes("inbox")) return "/comunicacao";
|
|
if (pathParts.includes("ticket")) return "/xos/tickets";
|
|
if (pathParts.includes("campaign")) return "/xos/campaigns";
|
|
if (pathParts.includes("automation")) return "/xos/automations";
|
|
if (pathParts.includes("site")) return "/xos/sites";
|
|
return "/xos";
|
|
}
|
|
if (pathParts.includes("pages/")) {
|
|
const match = Object.entries(FILE_TO_ROUTE_MAP).find(([key]) =>
|
|
fileName.toLowerCase().includes(key.toLowerCase())
|
|
);
|
|
if (match) return match[1];
|
|
}
|
|
if (pathParts.includes("components/")) {
|
|
const parentFolder = filePath.split("/").slice(-2, -1)[0];
|
|
if (parentFolder && parentFolder !== "components") {
|
|
const match = Object.entries(FILE_TO_ROUTE_MAP).find(([key]) =>
|
|
parentFolder.toLowerCase().includes(key.toLowerCase())
|
|
);
|
|
if (match) return match[1];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const SYSTEM_MODULES = [
|
|
{ category: "Core", modules: [
|
|
{ name: "Cockpit", path: "/", icon: "🏠", desc: "Painel principal" },
|
|
{ name: "Manus (Agente IA)", path: "/agent", icon: "🤖", desc: "Assistente autônomo" },
|
|
{ name: "Admin", path: "/admin", icon: "⚙️", desc: "Administração geral" },
|
|
{ name: "Super Admin", path: "/super-admin", icon: "🔐", desc: "Administração avançada" },
|
|
]},
|
|
{ category: "Comunicação", modules: [
|
|
{ name: "Chat Interno", path: "/chat", icon: "💬", desc: "Mensagens internas" },
|
|
{ name: "WhatsApp", path: "/whatsapp", icon: "📱", desc: "Integração WhatsApp" },
|
|
{ name: "XOS Inbox", path: "/comunicacao", icon: "📥", desc: "Caixa de entrada unificada" },
|
|
{ name: "XOS CRM", path: "/xos/crm", icon: "👥", desc: "Gestão de relacionamentos" },
|
|
{ name: "XOS Tickets", path: "/xos/tickets", icon: "🎫", desc: "Tickets de suporte" },
|
|
{ name: "XOS Campanhas", path: "/xos/campaigns", icon: "📣", desc: "Campanhas de marketing" },
|
|
]},
|
|
{ category: "Negócios", modules: [
|
|
{ name: "SOE", path: "/soe", icon: "🏢", desc: "Sistema Operacional Empresarial" },
|
|
{ name: "Financeiro", path: "/financeiro", icon: "💰", desc: "Gestão financeira" },
|
|
{ name: "Fisco", path: "/fisco", icon: "📋", desc: "Motor fiscal NF-e" },
|
|
{ name: "Contábil", path: "/contabil", icon: "📊", desc: "Contabilidade" },
|
|
{ name: "Retail", path: "/retail", icon: "🛒", desc: "Varejo e PDV" },
|
|
{ name: "Plus (ERP Laravel)", path: "/plus", icon: "🔷", desc: "ERP completo" },
|
|
{ name: "Fornecedores", path: "/suppliers", icon: "🏭", desc: "Portal de fornecedores" },
|
|
]},
|
|
{ category: "Pessoas & Operações", modules: [
|
|
{ name: "People (RH)", path: "/people", icon: "👤", desc: "Recursos humanos" },
|
|
{ name: "Qualidade", path: "/quality", icon: "✅", desc: "Gestão de qualidade" },
|
|
{ name: "Produção", path: "/production", icon: "🔧", desc: "Produção industrial" },
|
|
{ name: "Operações de Campo", path: "/field-ops", icon: "📍", desc: "Operações externas" },
|
|
{ name: "Engenharia", path: "/engineering", icon: "⚡", desc: "Hub de engenharia" },
|
|
{ name: "Técnico", path: "/technical", icon: "🔩", desc: "Módulo técnico" },
|
|
]},
|
|
{ category: "Inteligência & BI", modules: [
|
|
{ name: "Insights (BI)", path: "/insights", icon: "📈", desc: "Business Intelligence" },
|
|
{ name: "Scientist", path: "/scientist", icon: "🔬", desc: "Análise de dados com IA" },
|
|
{ name: "Knowledge Graph", path: "/knowledge", icon: "🧠", desc: "Grafo de conhecimento" },
|
|
{ name: "Valuation", path: "/valuation", icon: "💎", desc: "Avaliação empresarial" },
|
|
{ name: "NPS", path: "/nps", icon: "⭐", desc: "Pesquisa de satisfação" },
|
|
]},
|
|
{ category: "Plataforma", modules: [
|
|
{ name: "IDE", path: "/ide", icon: "🖥️", desc: "Ambiente de desenvolvimento" },
|
|
{ name: "Dev Center", path: "/dev-center", icon: "🚀", desc: "Centro de desenvolvimento" },
|
|
{ name: "Pipeline XOS", path: "/xos/pipeline", icon: "🔄", desc: "Pipeline autônomo" },
|
|
{ name: "Governança XOS", path: "/xos/governance", icon: "🛡️", desc: "Políticas e auditoria" },
|
|
{ name: "Automações", path: "/automations", icon: "⚡", desc: "Motor de automações" },
|
|
{ name: "XOS Automações", path: "/xos/automations", icon: "🤖", desc: "Automações XOS" },
|
|
{ name: "Central APIs", path: "/central-apis", icon: "🔌", desc: "Central de APIs" },
|
|
{ name: "API Hub", path: "/api-hub", icon: "🌐", desc: "Hub de APIs" },
|
|
{ name: "API Tester", path: "/api-tester", icon: "🧪", desc: "Testador de APIs" },
|
|
]},
|
|
{ category: "Apps & Marketplace", modules: [
|
|
{ name: "App Center", path: "/apps", icon: "📦", desc: "Central de aplicativos" },
|
|
{ name: "Marketplace", path: "/marketplace", icon: "🏪", desc: "Marketplace" },
|
|
{ name: "LMS", path: "/lms", icon: "📚", desc: "Plataforma de ensino" },
|
|
{ name: "Comunidades", path: "/communities", icon: "🌍", desc: "Comunidades" },
|
|
{ name: "Suporte", path: "/support", icon: "🎧", desc: "Central de suporte" },
|
|
]},
|
|
{ category: "Construtores", modules: [
|
|
{ name: "Page Builder", path: "/page-builder", icon: "🎨", desc: "Construtor de páginas" },
|
|
{ name: "DocType Builder", path: "/doctype-builder", icon: "📝", desc: "Construtor de doctypes" },
|
|
{ name: "Canvas", path: "/canvas", icon: "🖼️", desc: "Canvas visual" },
|
|
{ name: "Compass", path: "/compass", icon: "🧭", desc: "Bússola de processos" },
|
|
{ name: "XOS Sites", path: "/xos/sites", icon: "🌐", desc: "Construtor de sites" },
|
|
]},
|
|
];
|
|
|
|
function SystemOverviewTab() {
|
|
const [selectedModule, setSelectedModule] = useState<string>("/");
|
|
const [previewDevice, setPreviewDevice] = useState<"desktop" | "tablet" | "mobile">("desktop");
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(SYSTEM_MODULES.map(c => c.category)));
|
|
|
|
const toggleCategory = (cat: string) => {
|
|
setExpandedCategories(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const currentModule = SYSTEM_MODULES.flatMap(c => c.modules).find(m => m.path === selectedModule);
|
|
|
|
return (
|
|
<div className="flex gap-4 h-[calc(100vh-220px)] min-h-[500px]">
|
|
<Card className="w-80 flex-shrink-0 flex flex-col">
|
|
<CardHeader className="py-3 border-b bg-gradient-to-r from-slate-800 to-slate-700">
|
|
<CardTitle className="text-sm text-white flex items-center gap-2">
|
|
<LayoutGrid className="w-4 h-4" /> Mapa do Sistema
|
|
</CardTitle>
|
|
<CardDescription className="text-white/60 text-xs">
|
|
{SYSTEM_MODULES.reduce((acc, c) => acc + c.modules.length, 0)} módulos disponíveis
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-2 space-y-1">
|
|
{SYSTEM_MODULES.map((cat) => (
|
|
<div key={cat.category}>
|
|
<button
|
|
onClick={() => toggleCategory(cat.category)}
|
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50 rounded"
|
|
data-testid={`category-${cat.category}`}
|
|
>
|
|
{expandedCategories.has(cat.category) ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
{cat.category}
|
|
<Badge variant="outline" className="ml-auto text-[9px] h-4">{cat.modules.length}</Badge>
|
|
</button>
|
|
{expandedCategories.has(cat.category) && (
|
|
<div className="ml-2 space-y-0.5">
|
|
{cat.modules.map((mod) => (
|
|
<button
|
|
key={mod.path}
|
|
onClick={() => setSelectedModule(mod.path)}
|
|
className={`w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded transition-colors ${
|
|
selectedModule === mod.path
|
|
? "bg-blue-100 text-blue-800 font-medium"
|
|
: "text-slate-700 hover:bg-slate-50"
|
|
}`}
|
|
data-testid={`module-${mod.path.replace(/\//g, '-')}`}
|
|
>
|
|
<span className="text-sm">{mod.icon}</span>
|
|
<div className="flex-1 text-left min-w-0">
|
|
<div className="truncate">{mod.name}</div>
|
|
<div className="text-[10px] text-slate-400 truncate">{mod.desc}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</Card>
|
|
|
|
<Card className="flex-1 flex flex-col">
|
|
<CardHeader className="py-3 border-b">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<Monitor className="w-4 h-4" />
|
|
{currentModule ? `${currentModule.icon} ${currentModule.name}` : "Preview do Sistema"}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{currentModule?.desc || "Selecione um módulo para visualizar"} — <span className="font-mono text-blue-600">{selectedModule}</span>
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center bg-muted rounded-lg p-1 gap-1">
|
|
<Button variant={previewDevice === "desktop" ? "secondary" : "ghost"} size="sm" className="h-7 w-7 p-0" onClick={() => setPreviewDevice("desktop")} data-testid="btn-sys-desktop">
|
|
<Monitor className="w-3 h-3" />
|
|
</Button>
|
|
<Button variant={previewDevice === "tablet" ? "secondary" : "ghost"} size="sm" className="h-7 w-7 p-0" onClick={() => setPreviewDevice("tablet")} data-testid="btn-sys-tablet">
|
|
<Tablet className="w-3 h-3" />
|
|
</Button>
|
|
<Button variant={previewDevice === "mobile" ? "secondary" : "ghost"} size="sm" className="h-7 w-7 p-0" onClick={() => setPreviewDevice("mobile")} data-testid="btn-sys-mobile">
|
|
<Smartphone className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
<Button variant="outline" size="sm" className="h-7" onClick={() => {
|
|
const iframe = document.getElementById("system-preview-iframe") as HTMLIFrameElement;
|
|
if (iframe) iframe.src = iframe.src;
|
|
}} data-testid="btn-sys-refresh">
|
|
<RefreshCw className="w-3 h-3" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-7" onClick={() => window.open(selectedModule, "_blank")} data-testid="btn-sys-external">
|
|
<ExternalLink className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 p-2">
|
|
<div className={`h-full bg-gray-100 rounded-lg overflow-hidden border mx-auto transition-all ${
|
|
previewDevice === "desktop" ? "w-full" :
|
|
previewDevice === "tablet" ? "max-w-[768px]" :
|
|
"max-w-[375px]"
|
|
}`}>
|
|
<iframe
|
|
id="system-preview-iframe"
|
|
src={selectedModule}
|
|
className="w-full h-full bg-white"
|
|
title="System Preview"
|
|
data-testid="iframe-system-preview"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StagingPreviewTab() {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [expandedCode, setExpandedCode] = useState<string | null>(null);
|
|
const [previewUrl, setPreviewUrl] = useState("/");
|
|
const [previewDevice, setPreviewDevice] = useState<"desktop" | "tablet" | "mobile">("desktop");
|
|
const [activeFileForPreview, setActiveFileForPreview] = useState<string | null>(null);
|
|
|
|
const { data: stagedData, isLoading: loadingStaged, refetch: refetchStaged } = useQuery({
|
|
queryKey: ["/api/blackboard/staged"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/blackboard/staged", { credentials: "include" });
|
|
if (!res.ok) throw new Error("Falha ao carregar staging");
|
|
return res.json();
|
|
},
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
const publishMutation = useMutation({
|
|
mutationFn: async (taskId: number) => {
|
|
const res = await apiRequest("POST", `/api/blackboard/publish/${taskId}`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({
|
|
title: "Publicado com sucesso!",
|
|
description: `${data.applied?.length || 0} arquivo(s) aplicados ao projeto`,
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["/api/blackboard/staged"] });
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erro ao publicar",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const discardMutation = useMutation({
|
|
mutationFn: async (taskId: number) => {
|
|
const res = await apiRequest("POST", `/api/blackboard/discard/${taskId}`, {});
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Descartado",
|
|
description: "As alterações foram descartadas",
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["/api/blackboard/staged"] });
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erro ao descartar",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const stagedItems = stagedData?.staged || [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Card className="border-purple-200 bg-purple-50">
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-full bg-purple-100">
|
|
<Sparkles className="w-4 h-4 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-purple-900">1. Desenvolver</p>
|
|
<p className="text-xs text-purple-700">Os agentes criam e validam o código</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-blue-200 bg-blue-50">
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-full bg-blue-100">
|
|
<Eye className="w-4 h-4 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-blue-900">2. Revisar & Testar</p>
|
|
<p className="text-xs text-blue-700">Veja os arquivos e teste no preview</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-green-200 bg-green-50">
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-full bg-green-100">
|
|
<Rocket className="w-4 h-4 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-green-900">3. Publicar</p>
|
|
<p className="text-xs text-green-700">Aprove para aplicar ao projeto</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3 border-b bg-gradient-to-r from-amber-50 to-orange-50">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Layers className="w-5 h-5 text-amber-600" />
|
|
Alterações Pendentes
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{stagedItems.length === 0
|
|
? "Nenhuma alteração aguardando aprovação"
|
|
: `${stagedItems.length} alteração(ões) aguardando sua aprovação`}
|
|
</CardDescription>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => refetchStaged()} data-testid="btn-refresh-staged">
|
|
<RefreshCw className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
{loadingStaged ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-sm text-muted-foreground">Carregando...</span>
|
|
</div>
|
|
) : stagedItems.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<CheckCircle className="w-12 h-12 text-green-400 mb-3" />
|
|
<h3 className="font-semibold text-lg mb-1">Tudo limpo!</h3>
|
|
<p className="text-sm text-muted-foreground max-w-md">
|
|
Não há alterações pendentes. Use a aba "Desenvolver" para criar novas funcionalidades — elas aparecerão aqui para sua aprovação.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{stagedItems.map((item: any) => (
|
|
<Card key={item.taskId} className="border-l-4 border-l-amber-400" data-testid={`staged-item-${item.taskId}`}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-semibold text-sm">{item.title}</h4>
|
|
{item.description && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{item.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Badge variant="outline" className="text-[10px]">
|
|
<FileCode className="w-3 h-3 mr-1" />
|
|
{item.files?.length || 0} arquivo(s)
|
|
</Badge>
|
|
{item.validationScore && (
|
|
<Badge variant="outline" className={`text-[10px] ${
|
|
item.validationScore >= 80 ? "border-green-300 text-green-700" : "border-amber-300 text-amber-700"
|
|
}`}>
|
|
Score: {item.validationScore}
|
|
</Badge>
|
|
)}
|
|
{item.blockedFiles?.length > 0 && (
|
|
<Badge variant="outline" className="text-[10px] border-red-300 text-red-700">
|
|
{item.blockedFiles.length} protegido(s)
|
|
</Badge>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{item.stagedAt ? new Date(item.stagedAt).toLocaleString("pt-BR") : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 ml-3 shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-xs border-red-300 text-red-600 hover:bg-red-50"
|
|
disabled={discardMutation.isPending}
|
|
onClick={() => discardMutation.mutate(item.taskId)}
|
|
data-testid={`btn-discard-${item.taskId}`}
|
|
>
|
|
{discardMutation.isPending ? (
|
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
|
) : (
|
|
<XCircle className="w-3 h-3 mr-1" />
|
|
)}
|
|
Descartar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-8 text-xs bg-green-600 hover:bg-green-700"
|
|
disabled={publishMutation.isPending}
|
|
onClick={() => publishMutation.mutate(item.taskId)}
|
|
data-testid={`btn-publish-${item.taskId}`}
|
|
>
|
|
{publishMutation.isPending ? (
|
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
|
) : (
|
|
<Rocket className="w-3 h-3 mr-1" />
|
|
)}
|
|
Publicar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{item.files?.length > 0 && (
|
|
<div className="mt-3 space-y-1">
|
|
{item.files.map((file: any) => {
|
|
const detectedRoute = getRouteFromFilePath(file.path);
|
|
const fileKey = `${item.taskId}-${file.artifactId}`;
|
|
const isExpanded = expandedCode === fileKey;
|
|
return (
|
|
<div key={file.artifactId} className={`border rounded-lg overflow-hidden transition-all ${activeFileForPreview === fileKey ? "ring-2 ring-blue-400 border-blue-300" : ""}`}>
|
|
<div className="flex items-center bg-slate-50 hover:bg-slate-100 transition-colors">
|
|
<button
|
|
className="flex-1 flex items-center justify-between px-3 py-2 text-left"
|
|
onClick={() => {
|
|
const newKey = isExpanded ? null : fileKey;
|
|
setExpandedCode(newKey);
|
|
if (newKey) {
|
|
setActiveFileForPreview(newKey);
|
|
if (detectedRoute) {
|
|
setPreviewUrl(detectedRoute);
|
|
const iframe = document.getElementById("preview-iframe") as HTMLIFrameElement;
|
|
if (iframe) iframe.src = detectedRoute;
|
|
}
|
|
const previewSection = document.getElementById("preview-section");
|
|
if (previewSection) {
|
|
previewSection.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
} else {
|
|
setActiveFileForPreview(null);
|
|
}
|
|
}}
|
|
data-testid={`btn-toggle-file-${file.artifactId}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FileCode className="w-3.5 h-3.5 text-blue-500" />
|
|
<span className="text-xs font-mono font-medium">{file.path}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="text-[10px]">{file.lines} linhas</Badge>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
{detectedRoute && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className="px-2 py-2 text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors mr-1"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setPreviewUrl(detectedRoute);
|
|
setActiveFileForPreview(fileKey);
|
|
const iframe = document.getElementById("preview-iframe") as HTMLIFrameElement;
|
|
if (iframe) iframe.src = detectedRoute;
|
|
const previewSection = document.getElementById("preview-section");
|
|
if (previewSection) {
|
|
previewSection.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
}}
|
|
data-testid={`btn-preview-file-${file.artifactId}`}
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left">
|
|
<p className="text-xs">Ver no preview: {detectedRoute}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
{isExpanded && (
|
|
<div>
|
|
{detectedRoute ? (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 border-t border-blue-100">
|
|
<Eye className="w-3 h-3 text-blue-500" />
|
|
<span className="text-[10px] text-blue-700">Preview navegou para <span className="font-mono font-bold">{detectedRoute}</span></span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 border-t border-slate-200">
|
|
<Code2 className="w-3 h-3 text-slate-400" />
|
|
<span className="text-[10px] text-slate-500">Arquivo de suporte - preview mantido na rota atual</span>
|
|
</div>
|
|
)}
|
|
<ScrollArea className="max-h-[300px]">
|
|
<pre className="p-3 text-[11px] bg-slate-900 text-slate-100 overflow-x-auto">
|
|
<code>{file.content}</code>
|
|
</pre>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{item.blockedFiles?.length > 0 && (
|
|
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-[10px] font-medium text-red-700 mb-1">Arquivos protegidos (não serão alterados):</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{item.blockedFiles.map((f: string) => (
|
|
<Badge key={f} variant="outline" className="text-[9px] border-red-300 text-red-600 font-mono">
|
|
{f}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card id="preview-section">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Monitor className="w-5 h-5" /> Preview ao Vivo
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{activeFileForPreview
|
|
? <>Navegando para <span className="font-mono font-bold text-blue-600">{previewUrl}</span></>
|
|
: "Visualize e teste a aplicação atual"
|
|
}
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center bg-muted rounded-lg p-1 gap-1">
|
|
<Button
|
|
variant={previewDevice === "desktop" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => setPreviewDevice("desktop")}
|
|
data-testid="button-device-desktop"
|
|
>
|
|
<Monitor className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant={previewDevice === "tablet" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => setPreviewDevice("tablet")}
|
|
data-testid="button-device-tablet"
|
|
>
|
|
<Tablet className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant={previewDevice === "mobile" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => setPreviewDevice("mobile")}
|
|
data-testid="button-device-mobile"
|
|
>
|
|
<Smartphone className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const iframe = document.getElementById("preview-iframe") as HTMLIFrameElement;
|
|
if (iframe) iframe.src = iframe.src;
|
|
}}
|
|
data-testid="button-refresh-preview"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 flex items-center gap-2 px-3 py-2 bg-muted rounded-lg">
|
|
<Globe className="w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
value={previewUrl}
|
|
onChange={(e: any) => setPreviewUrl(e.target.value)}
|
|
className="border-0 bg-transparent p-0 h-auto focus-visible:ring-0"
|
|
placeholder="/"
|
|
data-testid="input-preview-url"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => window.open(previewUrl, "_blank")}
|
|
data-testid="button-open-external"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div
|
|
className={`relative bg-gray-100 rounded-lg overflow-hidden border-2 border-dashed border-gray-300 mx-auto transition-all ${
|
|
previewDevice === "desktop" ? "w-full h-[500px]" :
|
|
previewDevice === "tablet" ? "w-[768px] h-[500px]" :
|
|
"w-[375px] h-[667px]"
|
|
}`}
|
|
>
|
|
<iframe
|
|
id="preview-iframe"
|
|
src={previewUrl}
|
|
className="w-full h-full bg-white"
|
|
title="Preview"
|
|
data-testid="iframe-preview"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const PHASE_LABELS: Record<string, { label: string; icon: any; color: string }> = {
|
|
design: { label: "Arquitetura", icon: Cpu, color: "text-blue-400" },
|
|
codegen: { label: "Geração de Código", icon: FileCode, color: "text-green-400" },
|
|
validation: { label: "Validação", icon: Shield, color: "text-yellow-400" },
|
|
staging: { label: "Staging", icon: Eye, color: "text-purple-400" },
|
|
evolution: { label: "Evolução", icon: Brain, color: "text-pink-400" },
|
|
};
|
|
|
|
const STATUS_BADGES: Record<string, { label: string; color: string }> = {
|
|
queued: { label: "Na Fila", color: "bg-gray-600" },
|
|
running: { label: "Executando", color: "bg-blue-600" },
|
|
staging_review: { label: "Aguardando Aprovação", color: "bg-yellow-600" },
|
|
completed: { label: "Concluído", color: "bg-green-600" },
|
|
failed: { label: "Falhou", color: "bg-red-600" },
|
|
};
|
|
|
|
function BudgetMeter({ budget }: { budget: any }) {
|
|
if (!budget) return null;
|
|
const timePercent = Math.min(100, Math.round((budget.usedTimeMs / budget.maxTimeMs) * 100));
|
|
const callsPercent = Math.min(100, Math.round((budget.usedCalls / budget.maxCalls) * 100));
|
|
return (
|
|
<Card className="bg-zinc-900 border-zinc-800" data-testid="budget-meter">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm text-zinc-400 flex items-center gap-2">
|
|
<Gauge className="w-4 h-4" /> Budget
|
|
{budget.exceeded && <Badge className="bg-red-600 text-[10px]">Excedido</Badge>}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div>
|
|
<div className="flex justify-between text-xs text-zinc-400 mb-1">
|
|
<span>Tempo</span>
|
|
<span>{Math.round(budget.usedTimeMs / 1000)}s / {Math.round(budget.maxTimeMs / 1000)}s</span>
|
|
</div>
|
|
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all ${timePercent > 80 ? "bg-red-500" : timePercent > 50 ? "bg-yellow-500" : "bg-green-500"}`} style={{ width: `${timePercent}%` }} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-xs text-zinc-400 mb-1">
|
|
<span>Chamadas</span>
|
|
<span>{budget.usedCalls} / {budget.maxCalls}</span>
|
|
</div>
|
|
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all ${callsPercent > 80 ? "bg-red-500" : callsPercent > 50 ? "bg-yellow-500" : "bg-green-500"}`} style={{ width: `${callsPercent}%` }} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function RunbookView({ pipelineId }: { pipelineId: number }) {
|
|
const { data } = useQuery<any>({ queryKey: [`/api/xos/pipeline/${pipelineId}/runbook`], refetchInterval: 10000 });
|
|
const runbook = data?.runbook;
|
|
if (!runbook) return <p className="text-sm text-zinc-500 text-center py-4">Nenhum runbook disponível ainda</p>;
|
|
return (
|
|
<div className="space-y-4" data-testid="runbook-view">
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardHeader className="pb-2"><CardTitle className="text-sm text-zinc-400">Contexto</CardTitle></CardHeader>
|
|
<CardContent><p className="text-sm text-zinc-300">{runbook.context}</p></CardContent>
|
|
</Card>
|
|
{runbook.decisions?.length > 0 && (
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardHeader className="pb-2"><CardTitle className="text-sm text-zinc-400">Decisões ({runbook.decisions.length})</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-48">
|
|
<div className="space-y-2">
|
|
{runbook.decisions.map((d: any, idx: number) => (
|
|
<div key={idx} className="flex items-start gap-2 p-2 bg-zinc-800/50 rounded text-xs">
|
|
<Badge variant="outline" className="text-[10px] shrink-0">{d.phase}</Badge>
|
|
<span className="text-zinc-500">{d.agent}:</span>
|
|
<span className="text-zinc-300 flex-1">{d.decision}</span>
|
|
<span className="text-zinc-600 text-[10px] shrink-0">{new Date(d.timestamp).toLocaleTimeString("pt-BR")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{runbook.validations && (
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm text-zinc-400 flex items-center gap-2">
|
|
<ShieldCheck className="w-4 h-4" /> Validação
|
|
<Badge className={runbook.validations.valid ? "bg-green-600" : "bg-red-600"}>Score: {runbook.validations.score}</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{runbook.validations.gates && (
|
|
<div className="flex gap-2 flex-wrap">
|
|
{Object.entries(runbook.validations.gates).map(([gate, passed]: [string, any]) => (
|
|
<Badge key={gate} className={passed ? "bg-green-600/20 text-green-400 border border-green-600/50" : "bg-red-600/20 text-red-400 border border-red-600/50"}>
|
|
{passed ? <CheckCircle className="w-3 h-3 mr-1" /> : <XCircle className="w-3 h-3 mr-1" />}{gate}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{runbook.approval && (
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardHeader className="pb-2"><CardTitle className="text-sm text-zinc-400">Aprovação</CardTitle></CardHeader>
|
|
<CardContent className="text-xs text-zinc-300 space-y-1">
|
|
<p>Revisado por: {runbook.approval.reviewedBy}</p>
|
|
<p>Arquivos aplicados: {runbook.approval.applied?.length || 0}</p>
|
|
{runbook.approval.errors?.length > 0 && <p className="text-red-400">Erros: {runbook.approval.errors.join(", ")}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PipelineTimeline({ phases }: { phases: Record<string, any> | null }) {
|
|
if (!phases) return null;
|
|
const phaseOrder = ["design", "codegen", "validation", "staging", "evolution"];
|
|
return (
|
|
<div className="flex items-center gap-1 w-full" data-testid="pipeline-timeline">
|
|
{phaseOrder.map((phase, idx) => {
|
|
const data = phases[phase] || { status: "pending" };
|
|
const config = PHASE_LABELS[phase];
|
|
const Icon = config.icon;
|
|
const isActive = data.status === "running";
|
|
const isComplete = data.status === "completed";
|
|
const isFailed = data.status === "failed";
|
|
return (
|
|
<div key={phase} className="flex items-center flex-1">
|
|
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg flex-1 transition-all ${isActive ? "bg-blue-500/20 border border-blue-500/50 shadow-lg shadow-blue-500/10" : ""} ${isComplete ? "bg-green-500/10 border border-green-500/30" : ""} ${isFailed ? "bg-red-500/10 border border-red-500/30" : ""} ${data.status === "pending" ? "bg-zinc-800/50 border border-zinc-700/30" : ""}`} data-testid={`phase-${phase}`}>
|
|
{isActive ? <Loader2 className={`w-4 h-4 ${config.color} animate-spin`} /> : isComplete ? <CheckCircle className="w-4 h-4 text-green-400" /> : isFailed ? <XCircle className="w-4 h-4 text-red-400" /> : <Icon className="w-4 h-4 text-zinc-500" />}
|
|
<span className={`text-xs font-medium ${isActive ? config.color : isComplete ? "text-green-400" : "text-zinc-500"}`}>{config.label}</span>
|
|
</div>
|
|
{idx < phaseOrder.length - 1 && <ChevronRight className={`w-4 h-4 mx-1 shrink-0 ${isComplete ? "text-green-400" : "text-zinc-600"}`} />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InlinePipelineTracker({ pipelineId, onExpand }: { pipelineId: number; onExpand?: () => void }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const { data: details, isLoading, isError } = useQuery<any>({ queryKey: [`/api/xos/pipeline/${pipelineId}`], refetchInterval: 3000 });
|
|
const pipeline = details?.pipeline;
|
|
const phases = pipeline?.phases;
|
|
const staging = details?.staging || [];
|
|
const phaseOrder = ["design", "codegen", "validation", "staging", "evolution"];
|
|
|
|
const currentPhase = phaseOrder.find(p => phases?.[p]?.status === "running") || pipeline?.currentPhase;
|
|
const isFinished = pipeline?.status === "completed" || pipeline?.status === "failed";
|
|
const hasPending = staging.some((s: any) => s.status === "pending");
|
|
const elapsedTime = pipeline?.budget?.usedTimeMs ? Math.round(pipeline.budget.usedTimeMs / 1000) : 0;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="w-full rounded-lg border bg-white p-3 flex items-center gap-2" data-testid={`inline-pipeline-${pipelineId}`}>
|
|
<Loader2 className="w-4 h-4 text-purple-500 animate-spin" />
|
|
<span className="text-sm text-gray-500">Pipeline #{pipelineId}...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !pipeline) {
|
|
return (
|
|
<div className="w-full rounded-lg border border-red-200 bg-red-50 p-3 flex items-center gap-2" data-testid={`inline-pipeline-${pipelineId}`}>
|
|
<XCircle className="w-4 h-4 text-red-400" />
|
|
<span className="text-sm text-red-600">Pipeline #{pipelineId} — erro ao carregar</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full rounded-lg border bg-white overflow-hidden" data-testid={`inline-pipeline-${pipelineId}`}>
|
|
<div className="px-4 py-3">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="text-sm font-semibold text-gray-800">#{pipelineId}</span>
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
|
pipeline?.status === "completed" ? "bg-green-100 text-green-700" :
|
|
pipeline?.status === "failed" ? "bg-red-100 text-red-700" :
|
|
pipeline?.status === "staging_review" ? "bg-yellow-100 text-yellow-700" :
|
|
"bg-blue-100 text-blue-700"
|
|
}`}>
|
|
{pipeline?.status === "running" ? "Executando" :
|
|
pipeline?.status === "completed" ? "Concluído" :
|
|
pipeline?.status === "failed" ? "Falhou" :
|
|
pipeline?.status === "staging_review" ? "Revisão" :
|
|
pipeline?.status}
|
|
</span>
|
|
{!isFinished && currentPhase && (
|
|
<span className="text-xs text-gray-400 flex items-center gap-1">
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
{PHASE_LABELS[currentPhase]?.label || currentPhase}
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-gray-400 ml-auto">{elapsedTime}s</span>
|
|
<button onClick={() => setExpanded(!expanded)} className="p-1 hover:bg-gray-100 rounded" data-testid={`btn-expand-pipeline-${pipelineId}`}>
|
|
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500 line-clamp-1 mb-2">{pipeline?.prompt?.replace(/^\[MODO (?:PLANEJAMENTO|EXECUÇÃO)\].*?Solicitação:\s*/s, "")}</p>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{phaseOrder.map((phase) => {
|
|
const data = phases?.[phase] || { status: "pending" };
|
|
const isActive = data.status === "running";
|
|
const isComplete = data.status === "completed";
|
|
const isFailed = data.status === "failed";
|
|
return (
|
|
<div key={phase} className="flex-1 flex flex-col items-center gap-1">
|
|
<div className={`w-full h-1.5 rounded-full ${
|
|
isActive ? "bg-blue-400 animate-pulse" :
|
|
isComplete ? "bg-green-400" :
|
|
isFailed ? "bg-red-400" :
|
|
"bg-gray-200"
|
|
}`} />
|
|
<span className={`text-[9px] leading-none ${
|
|
isActive ? "text-blue-600 font-medium" :
|
|
isComplete ? "text-green-600" :
|
|
isFailed ? "text-red-500" :
|
|
"text-gray-300"
|
|
}`}>{PHASE_LABELS[phase]?.label?.split(" ")[0]}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{hasPending && (
|
|
<div className="px-4 py-2 bg-yellow-50 border-t flex items-center justify-between">
|
|
<span className="text-xs text-yellow-700">{staging.filter((s: any) => s.status === "pending").length} arquivo(s) para revisar</span>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs border-yellow-400 text-yellow-700 hover:bg-yellow-100" onClick={() => { if (onExpand) onExpand(); }} data-testid={`btn-review-pipeline-${pipelineId}`}>
|
|
Revisar
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{expanded && (
|
|
<div className="border-t px-4 py-3 bg-gray-50 space-y-2">
|
|
{phaseOrder.map((phase) => {
|
|
const data = phases?.[phase];
|
|
if (!data || data.status === "pending") return null;
|
|
return (
|
|
<div key={phase} className="text-xs">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`font-medium ${data.status === "completed" ? "text-green-700" : data.status === "failed" ? "text-red-600" : "text-blue-700"}`}>
|
|
{PHASE_LABELS[phase]?.label}
|
|
</span>
|
|
{data.duration && <span className="text-gray-400">{Math.round(data.duration / 1000)}s</span>}
|
|
</div>
|
|
{data.thought && <p className="text-gray-500 line-clamp-2 ml-2">{data.thought}</p>}
|
|
{data.error && <p className="text-red-500 ml-2">{data.error}</p>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PipelinePreviewPanel() {
|
|
const [previewUrl, setPreviewUrl] = useState("/");
|
|
const [previewDevice, setPreviewDevice] = useState<"desktop" | "tablet" | "mobile">("desktop");
|
|
return (
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardHeader className="py-3 border-b border-zinc-800">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm text-zinc-300 flex items-center gap-2"><Monitor className="w-4 h-4" /> Preview ao Vivo do Sistema</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center bg-zinc-800 rounded-lg p-1 gap-1">
|
|
<Button variant={previewDevice === "desktop" ? "secondary" : "ghost"} size="sm" className="h-7 w-7 p-0" onClick={() => setPreviewDevice("desktop")} data-testid="btn-pp-desktop"><Monitor className="w-3 h-3" /></Button>
|
|
<Button variant={previewDevice === "tablet" ? "secondary" : "ghost"} size="sm" className="h-7 w-7 p-0" onClick={() => setPreviewDevice("tablet")} data-testid="btn-pp-tablet"><Tablet className="w-3 h-3" /></Button>
|
|
<Button variant={previewDevice === "mobile" ? "secondary" : "ghost"} size="sm" className="h-7 w-7 p-0" onClick={() => setPreviewDevice("mobile")} data-testid="btn-pp-mobile"><Smartphone className="w-3 h-3" /></Button>
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { const iframe = document.getElementById("pipeline-preview-iframe") as HTMLIFrameElement; if (iframe) iframe.src = iframe.src; }} data-testid="btn-pp-refresh"><RefreshCw className="w-3 h-3" /></Button>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => window.open(previewUrl, "_blank")} data-testid="btn-pp-external"><ExternalLink className="w-3 h-3" /></Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-3 space-y-3">
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-zinc-800 rounded-lg">
|
|
<Globe className="w-4 h-4 text-zinc-500" />
|
|
<Input value={previewUrl} onChange={(e: any) => setPreviewUrl(e.target.value)} onKeyDown={(e: any) => { if (e.key === "Enter") { const iframe = document.getElementById("pipeline-preview-iframe") as HTMLIFrameElement; if (iframe) iframe.src = previewUrl; } }} className="border-0 bg-transparent p-0 h-auto text-sm text-zinc-300 focus-visible:ring-0 font-mono" placeholder="/" data-testid="input-pp-url" />
|
|
</div>
|
|
<div className={`bg-zinc-950 rounded-lg overflow-hidden border border-zinc-700 mx-auto transition-all ${previewDevice === "desktop" ? "w-full h-[500px]" : previewDevice === "tablet" ? "max-w-[768px] h-[500px]" : "max-w-[375px] h-[667px]"}`}>
|
|
<iframe id="pipeline-preview-iframe" src={previewUrl} className="w-full h-full bg-white" title="Pipeline Preview" data-testid="iframe-pipeline-preview" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function InlineDiffViewer({ original, modified }: { original: string; modified: string }) {
|
|
const origLines = original.split("\n");
|
|
const modLines = modified.split("\n");
|
|
const maxLines = Math.max(origLines.length, modLines.length, 200);
|
|
const displayLines = Math.min(maxLines, 200);
|
|
|
|
const diffLines: { type: "same" | "add" | "remove" | "modify"; lineNum: number; origLine?: string; modLine?: string }[] = [];
|
|
|
|
for (let i = 0; i < displayLines; i++) {
|
|
const orig = origLines[i];
|
|
const mod = modLines[i];
|
|
if (orig === undefined && mod !== undefined) {
|
|
diffLines.push({ type: "add", lineNum: i + 1, modLine: mod });
|
|
} else if (orig !== undefined && mod === undefined) {
|
|
diffLines.push({ type: "remove", lineNum: i + 1, origLine: orig });
|
|
} else if (orig === mod) {
|
|
diffLines.push({ type: "same", lineNum: i + 1, origLine: orig, modLine: mod });
|
|
} else {
|
|
diffLines.push({ type: "modify", lineNum: i + 1, origLine: orig, modLine: mod });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ScrollArea className="h-72">
|
|
<div className="grid grid-cols-2 text-[11px] font-mono" data-testid="inline-diff-viewer">
|
|
<div className="border-r border-zinc-800">
|
|
<div className="px-3 py-1 bg-red-950/30 text-red-400 text-[10px] border-b border-zinc-800 sticky top-0 z-10">Original</div>
|
|
{diffLines.map((line, idx) => (
|
|
<div
|
|
key={`orig-${idx}`}
|
|
className={`px-3 py-0.5 flex ${
|
|
line.type === "remove" ? "bg-red-950/40 text-red-300" :
|
|
line.type === "modify" ? "bg-yellow-950/30 text-yellow-200" :
|
|
line.type === "add" ? "bg-zinc-900/50 text-zinc-600" :
|
|
"text-zinc-400"
|
|
}`}
|
|
>
|
|
<span className="text-zinc-600 w-8 shrink-0 text-right pr-2 select-none">{line.lineNum}</span>
|
|
<span className="whitespace-pre-wrap break-all">{line.type === "add" ? "" : (line.origLine || "")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div>
|
|
<div className="px-3 py-1 bg-green-950/30 text-green-400 text-[10px] border-b border-zinc-800 sticky top-0 z-10">Modificado</div>
|
|
{diffLines.map((line, idx) => (
|
|
<div
|
|
key={`mod-${idx}`}
|
|
className={`px-3 py-0.5 flex ${
|
|
line.type === "add" ? "bg-green-950/40 text-green-300" :
|
|
line.type === "modify" ? "bg-green-950/30 text-green-200" :
|
|
line.type === "remove" ? "bg-zinc-900/50 text-zinc-600" :
|
|
"text-zinc-400"
|
|
}`}
|
|
>
|
|
<span className="text-zinc-600 w-8 shrink-0 text-right pr-2 select-none">{line.lineNum}</span>
|
|
<span className="whitespace-pre-wrap break-all">{line.type === "remove" ? "" : (line.modLine || "")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{maxLines > 200 && <div className="p-2 text-center text-xs text-zinc-500">... mostrando primeiras 200 linhas de {maxLines}</div>}
|
|
</ScrollArea>
|
|
);
|
|
}
|
|
|
|
function PipelineReviewPanelV2({ pipelineId, changes, onAction }: { pipelineId: number; changes: any[]; onAction: () => void }) {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
|
const [previewUrl, setPreviewUrl] = useState("/");
|
|
const [showPreview, setShowPreview] = useState(true);
|
|
const allPending = changes.filter(c => c.status === "pending");
|
|
const pendingMap = new Map<string, any>();
|
|
for (const c of allPending) { pendingMap.set(c.filePath, c); }
|
|
const pendingChanges = Array.from(pendingMap.values());
|
|
useEffect(() => { setSelectedFiles(new Set(pendingChanges.map(c => c.filePath))); }, [changes.length]);
|
|
const toggleFile = (filePath: string) => { setSelectedFiles(prev => { const next = new Set(prev); if (next.has(filePath)) next.delete(filePath); else next.add(filePath); return next; }); };
|
|
const approveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const files = Array.from(selectedFiles);
|
|
const body: any = {};
|
|
if (files.length < pendingChanges.length) body.selectedFiles = files;
|
|
const res = await apiRequest("POST", `/api/xos/pipeline/${pipelineId}/approve`, body);
|
|
return res.json();
|
|
},
|
|
onSuccess: (data: any) => {
|
|
const applied = data.applied?.length || 0;
|
|
const errs = data.errors?.length || 0;
|
|
toast({ title: `${applied} arquivo(s) aplicado(s)${errs > 0 ? `, ${errs} erro(s)` : ""}`, variant: errs > 0 ? "destructive" : "default" });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/pipeline"] });
|
|
queryClient.invalidateQueries({ queryKey: [`/api/xos/pipeline/${pipelineId}`] });
|
|
onAction();
|
|
const iframe = document.getElementById("review-preview-iframe") as HTMLIFrameElement;
|
|
if (iframe) setTimeout(() => { iframe.src = iframe.src; }, 1500);
|
|
},
|
|
onError: (err: any) => { toast({ title: "Erro ao aprovar", description: err.message, variant: "destructive" }); },
|
|
});
|
|
const rejectMutation = useMutation({
|
|
mutationFn: () => apiRequest("POST", `/api/xos/pipeline/${pipelineId}/reject`),
|
|
onSuccess: () => { toast({ title: "Alterações rejeitadas" }); queryClient.invalidateQueries({ queryKey: ["/api/xos/pipeline"] }); queryClient.invalidateQueries({ queryKey: [`/api/xos/pipeline/${pipelineId}`] }); onAction(); },
|
|
onError: (err: any) => { toast({ title: "Erro ao rejeitar", description: err.message, variant: "destructive" }); },
|
|
});
|
|
const detectRoute = (filePath: string) => {
|
|
const pageMatch = filePath.match(/pages\/(\w+)\.tsx/);
|
|
if (pageMatch) {
|
|
const name = pageMatch[1];
|
|
const routeMap: Record<string, string> = { BiWorkspace: "/insights", Cockpit: "/", Agent: "/agent", Fisco: "/fisco", SOE: "/soe", Financeiro: "/financeiro", People: "/people", XosPipeline: "/xos/pipeline", DevCenter: "/dev-center", Admin: "/admin", Chat: "/chat", Tickets: "/tickets", CommCenter: "/comm", ApiHub: "/api-hub", AppCenter: "/apps", MetabaseProxyPage: "/insights", ProxyPage: "/insights", CommercialEnv: "/commercial" };
|
|
return routeMap[name] || "/";
|
|
}
|
|
if (filePath.includes("server/routes")) return "/";
|
|
if (filePath.includes("modules/")) return "/apps";
|
|
return null;
|
|
};
|
|
useEffect(() => { if (pendingChanges.length > 0) { const firstRoute = pendingChanges.map(c => detectRoute(c.filePath)).find(r => r !== null); if (firstRoute && previewUrl === "/") setPreviewUrl(firstRoute); } }, [pendingChanges.length]);
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-yellow-300 flex items-center gap-2"><Eye className="w-4 h-4" /> Revisão de Código — {pendingChanges.length} arquivo(s) pendente(s)</h3>
|
|
<Button variant="outline" size="sm" className="h-7 border-zinc-700 text-zinc-400" onClick={() => setShowPreview(!showPreview)} data-testid="btn-toggle-preview-v2"><PanelLeft className="w-3 h-3 mr-1" /> {showPreview ? "Esconder" : "Mostrar"} Preview</Button>
|
|
</div>
|
|
<div className={`flex gap-4 ${showPreview ? "" : "flex-col"}`}>
|
|
<div className={`space-y-3 ${showPreview ? "w-1/2" : "w-full"}`}>
|
|
{pendingChanges.map((change: any) => {
|
|
const isSelected = selectedFiles.has(change.filePath);
|
|
const isExpanded = expandedFile === change.filePath;
|
|
const route = detectRoute(change.filePath);
|
|
return (
|
|
<div key={change.id} className={`border rounded-lg overflow-hidden transition-all ${isSelected ? "border-yellow-500/50" : "border-zinc-700/50 opacity-60"}`}>
|
|
<div className="bg-zinc-800 px-3 py-2 flex items-center gap-2">
|
|
<button onClick={() => toggleFile(change.filePath)} className="shrink-0" data-testid={`toggle-review-v2-${change.id}`}>{isSelected ? <CheckSquare className="w-4 h-4 text-yellow-400" /> : <Square className="w-4 h-4 text-zinc-600" />}</button>
|
|
<FileCode className="w-4 h-4 text-zinc-400 shrink-0" />
|
|
<span className="text-xs font-mono text-zinc-300 truncate flex-1">{change.filePath}</span>
|
|
<Badge variant="outline" className="text-[10px] shrink-0">{change.action || (change.originalContent ? "modify" : "create")}</Badge>
|
|
{route && showPreview && <Button variant="ghost" size="sm" className="h-6 px-2 text-[10px] text-blue-400" onClick={() => setPreviewUrl(route)} data-testid={`nav-preview-v2-${change.id}`}><Monitor className="w-3 h-3 mr-1" /> Preview</Button>}
|
|
<button onClick={() => setExpandedFile(isExpanded ? null : change.filePath)} className="shrink-0" data-testid={`expand-review-v2-${change.id}`}><ChevronRight className={`w-4 h-4 text-zinc-500 transition-transform ${isExpanded ? "rotate-90" : ""}`} /></button>
|
|
</div>
|
|
{isExpanded && (
|
|
<div className="bg-zinc-950">
|
|
{change.originalContent ? (
|
|
<InlineDiffViewer original={change.originalContent} modified={change.content || ""} />
|
|
) : (
|
|
<ScrollArea className="h-60">
|
|
<pre className="p-3 text-xs text-zinc-300 font-mono whitespace-pre-wrap">{change.content?.slice(0, 5000) || ""}{(change.content?.length || 0) > 5000 && "\n\n... (truncado)"}</pre>
|
|
</ScrollArea>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="flex gap-3 pt-2">
|
|
<Button onClick={() => approveMutation.mutate()} disabled={approveMutation.isPending || selectedFiles.size === 0} className="bg-green-600 hover:bg-green-700 flex-1" data-testid="btn-approve-review-v2">
|
|
{approveMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <ThumbsUp className="w-4 h-4 mr-2" />}
|
|
Aprovar {selectedFiles.size < pendingChanges.length ? `${selectedFiles.size} Selecionado(s)` : `Todos (${pendingChanges.length})`}
|
|
</Button>
|
|
<Button onClick={() => rejectMutation.mutate()} disabled={rejectMutation.isPending} variant="destructive" data-testid="btn-reject-review-v2">
|
|
{rejectMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <ThumbsDown className="w-4 h-4 mr-2" />}Rejeitar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{showPreview && (
|
|
<div className="w-1/2">
|
|
<Card className="bg-zinc-900 border-zinc-800 h-full">
|
|
<CardHeader className="py-2 border-b border-zinc-800">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-xs text-zinc-400 flex items-center gap-2"><Monitor className="w-3 h-3" /> Preview — <span className="font-mono text-blue-400">{previewUrl}</span></CardTitle>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => { const iframe = document.getElementById("review-preview-iframe") as HTMLIFrameElement; if (iframe) iframe.src = iframe.src; }} data-testid="btn-review-pp-refresh"><RefreshCw className="w-3 h-3" /></Button>
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => window.open(previewUrl, "_blank")} data-testid="btn-review-pp-external"><ExternalLink className="w-3 h-3" /></Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-2"><div className="bg-zinc-950 rounded-lg overflow-hidden border border-zinc-700 h-[400px]"><iframe id="review-preview-iframe" src={previewUrl} className="w-full h-full bg-white" title="Review Preview" data-testid="iframe-review-preview-v2" /></div></CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PipelineDetail({ pipelineId }: { pipelineId: number }) {
|
|
const [streamData, setStreamData] = useState<any>(null);
|
|
const [detailTab, setDetailTab] = useState("timeline");
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const { data: details, refetch } = useQuery<any>({ queryKey: [`/api/xos/pipeline/${pipelineId}`], refetchInterval: 5000 });
|
|
useEffect(() => {
|
|
const es = new EventSource(`/api/xos/pipeline/${pipelineId}/stream`);
|
|
es.addEventListener("status", (e) => { try { setStreamData(JSON.parse(e.data)); } catch {} });
|
|
es.addEventListener("phase_started", () => refetch());
|
|
es.addEventListener("phase_completed", () => refetch());
|
|
es.addEventListener("staging_ready", () => refetch());
|
|
es.addEventListener("completed", () => refetch());
|
|
es.addEventListener("failed", () => refetch());
|
|
es.addEventListener("rolled_back", () => refetch());
|
|
return () => es.close();
|
|
}, [pipelineId, refetch]);
|
|
const rollbackMutation = useMutation({
|
|
mutationFn: () => apiRequest("POST", `/api/xos/pipeline/${pipelineId}/rollback`),
|
|
onSuccess: async (res) => { const data = await res.json(); toast({ title: `Rollback: ${data.restored?.length || 0} arquivo(s) restaurado(s)` }); queryClient.invalidateQueries({ queryKey: ["/api/xos/pipeline"] }); refetch(); },
|
|
onError: (err: any) => { toast({ title: "Erro no rollback", description: err.message, variant: "destructive" }); },
|
|
});
|
|
const pipeline = details?.pipeline;
|
|
const phases = streamData?.phases || pipeline?.phases;
|
|
const statusInfo = STATUS_BADGES[pipeline?.status || "queued"];
|
|
const hasAppliedChanges = details?.staging?.some((s: any) => s.status === "applied");
|
|
const pendingStagingCount = details?.staging?.filter((s: any) => s.status === "pending").length || 0;
|
|
const hasPendingChanges = pendingStagingCount > 0;
|
|
useEffect(() => { if (hasPendingChanges && detailTab === "timeline") setDetailTab("review"); if (!hasPendingChanges && detailTab === "review") setDetailTab("timeline"); }, [hasPendingChanges]);
|
|
return (
|
|
<div className="space-y-4" data-testid={`pipeline-detail-${pipelineId}`}>
|
|
{pipeline && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
|
Pipeline #{pipeline.id}
|
|
{pipeline.correlationId && <span className="text-[10px] text-zinc-600 font-mono flex items-center gap-1"><Fingerprint className="w-3 h-3" /> {pipeline.correlationId.slice(0, 8)}</span>}
|
|
</h3>
|
|
<p className="text-sm text-zinc-400 mt-1">{pipeline.prompt}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{hasAppliedChanges && (
|
|
<Button variant="outline" size="sm" onClick={() => rollbackMutation.mutate()} disabled={rollbackMutation.isPending} className="border-red-500/50 text-red-400 hover:bg-red-500/10" data-testid="btn-rollback">
|
|
{rollbackMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <Undo2 className="w-3 h-3 mr-1" />}Rollback
|
|
</Button>
|
|
)}
|
|
<Badge className={statusInfo?.color || "bg-gray-600"}>{statusInfo?.label || pipeline.status}</Badge>
|
|
</div>
|
|
</div>
|
|
<PipelineTimeline phases={phases} />
|
|
<BudgetMeter budget={streamData?.budget || pipeline?.budget} />
|
|
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
|
<TabsList className="bg-zinc-900 border border-zinc-800">
|
|
<TabsTrigger value="timeline" className="data-[state=active]:bg-zinc-700" data-testid="subtab-timeline"><Clock className="w-3 h-3 mr-1" /> Timeline</TabsTrigger>
|
|
{hasPendingChanges && <TabsTrigger value="review" className="data-[state=active]:bg-yellow-600" data-testid="subtab-review"><Eye className="w-3 h-3 mr-1" /> Revisar & Aprovar<Badge className="bg-yellow-500 text-[9px] ml-1 h-4 px-1">{pendingStagingCount}</Badge></TabsTrigger>}
|
|
<TabsTrigger value="preview" className="data-[state=active]:bg-zinc-700" data-testid="subtab-preview"><Monitor className="w-3 h-3 mr-1" /> Preview</TabsTrigger>
|
|
<TabsTrigger value="runbook" className="data-[state=active]:bg-zinc-700" data-testid="subtab-runbook"><BookOpen className="w-3 h-3 mr-1" /> Runbook</TabsTrigger>
|
|
<TabsTrigger value="artifacts" className="data-[state=active]:bg-zinc-700" data-testid="subtab-artifacts"><FileCode className="w-3 h-3 mr-1" /> Artefatos</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="timeline" className="mt-3 space-y-4">
|
|
{hasPendingChanges && (
|
|
<Card className="bg-yellow-500/10 border-yellow-500/30 cursor-pointer hover:bg-yellow-500/15 transition-colors" onClick={() => setDetailTab("review")} data-testid="card-goto-review">
|
|
<CardContent className="py-4 flex items-center gap-3">
|
|
<Eye className="w-5 h-5 text-yellow-400" />
|
|
<div className="flex-1"><p className="text-sm font-medium text-yellow-300">{pendingStagingCount} arquivo(s) aguardando sua aprovação</p><p className="text-xs text-zinc-400">Clique para revisar o código e ver o preview ao vivo</p></div>
|
|
<ArrowRight className="w-4 h-4 text-yellow-400" />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{pipeline.error && <Card className="bg-red-500/5 border-red-500/30"><CardContent className="py-3 flex items-start gap-2"><AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" /><p className="text-sm text-red-400">{pipeline.error}</p></CardContent></Card>}
|
|
{details?.logs?.length > 0 && (
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardHeader className="pb-2"><CardTitle className="text-sm text-zinc-400">Log dos Agentes</CardTitle></CardHeader>
|
|
<CardContent><ScrollArea className="h-48"><div className="space-y-1">{details.logs.map((log: any, idx: number) => (<div key={idx} className="flex items-start gap-2 text-xs"><Badge variant="outline" className="text-[10px] shrink-0">{log.agentName}</Badge><span className="text-zinc-400">{log.action}:</span><span className="text-zinc-300">{log.thought}</span></div>))}</div></ScrollArea></CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
{hasPendingChanges && <TabsContent value="review" className="mt-3"><PipelineReviewPanelV2 pipelineId={pipelineId} changes={details?.staging || []} onAction={() => { refetch(); queryClient.invalidateQueries({ queryKey: ["/api/xos/pipeline"] }); }} /></TabsContent>}
|
|
<TabsContent value="preview" className="mt-3"><PipelinePreviewPanel /></TabsContent>
|
|
<TabsContent value="runbook" className="mt-3"><RunbookView pipelineId={pipelineId} /></TabsContent>
|
|
<TabsContent value="artifacts" className="mt-3">
|
|
{details?.artifacts?.length > 0 ? (
|
|
<Card className="bg-zinc-900 border-zinc-800"><CardContent className="pt-4"><div className="space-y-1">{details.artifacts.map((a: any) => (<div key={a.id} className="flex items-center gap-2 text-xs p-2 bg-zinc-800/50 rounded"><FileCode className="w-3 h-3 text-zinc-500" /><span className="text-zinc-300 font-mono">{a.name}</span><Badge variant="outline" className="text-[10px]">{a.type}</Badge><span className="text-zinc-500 ml-auto">{a.createdBy}</span></div>))}</div></CardContent></Card>
|
|
) : <p className="text-sm text-zinc-500 text-center py-4">Nenhum artefato gerado ainda</p>}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PolicyTestsPanel() {
|
|
const [results, setResults] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const { toast } = useToast();
|
|
const runTests = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/governance/policy-tests");
|
|
const data = await res.json();
|
|
setResults(data);
|
|
toast({ title: data.failed === 0 ? "Todos os testes passaram!" : `${data.failed} teste(s) falharam`, variant: data.failed === 0 ? "default" : "destructive" });
|
|
} catch (err: any) { toast({ title: "Erro ao executar testes", variant: "destructive" }); }
|
|
setLoading(false);
|
|
};
|
|
return (
|
|
<Card className="bg-zinc-900 border-zinc-800" data-testid="policy-tests-panel">
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-sm text-zinc-400 flex items-center gap-2"><ShieldCheck className="w-4 h-4" /> Policy Tests</CardTitle>
|
|
<Button variant="outline" size="sm" onClick={runTests} disabled={loading} data-testid="btn-run-policy-tests"><Play className="w-3 h-3 mr-1" /> Executar Testes</Button>
|
|
</CardHeader>
|
|
{results && (
|
|
<CardContent>
|
|
<div className="flex items-center gap-4 mb-3 text-sm">
|
|
<Badge className="bg-green-600">{results.passed} passaram</Badge>
|
|
{results.failed > 0 && <Badge className="bg-red-600">{results.failed} falharam</Badge>}
|
|
<span className="text-xs text-zinc-500">Total: {results.total}</span>
|
|
</div>
|
|
<ScrollArea className="h-48"><div className="space-y-1">{results.results?.map((r: any, idx: number) => (<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${r.passed ? "bg-green-500/5" : "bg-red-500/10"}`}>{r.passed ? <CheckCircle className="w-3 h-3 text-green-400 mt-0.5 shrink-0" /> : <XCircle className="w-3 h-3 text-red-400 mt-0.5 shrink-0" />}<div><span className="text-zinc-300">{r.description}</span>{!r.passed && <p className="text-red-400 mt-0.5">Esperado: {r.expected ? "permitido" : "bloqueado"}, Recebido: {r.actual ? "permitido" : "bloqueado"}</p>}</div></div>))}</div></ScrollArea>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function AgentTerminal() {
|
|
const { data } = useQuery<any>({
|
|
queryKey: ["/api/xos/pipeline/agent-terminal"],
|
|
refetchInterval: 3000,
|
|
});
|
|
const logs = data?.logs || [];
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (terminalRef.current) {
|
|
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
|
}
|
|
}, [logs]);
|
|
|
|
return (
|
|
<Card className="mt-3">
|
|
<CardHeader className="py-2 px-4 bg-gray-900 rounded-t-lg">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex gap-1.5">
|
|
<div className="w-3 h-3 rounded-full bg-red-500" />
|
|
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
|
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
</div>
|
|
<span className="text-xs text-green-400 font-mono ml-2">Terminal do Agente</span>
|
|
<Badge className="bg-gray-700 text-gray-300 text-[10px] ml-auto">{logs.length} comandos</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<div
|
|
ref={terminalRef}
|
|
className="bg-gray-950 text-green-400 font-mono text-xs p-4 rounded-b-lg max-h-[300px] overflow-y-auto"
|
|
data-testid="agent-terminal-output"
|
|
>
|
|
{logs.length === 0 ? (
|
|
<div className="text-gray-600 flex items-center gap-2">
|
|
<span className="animate-pulse">_</span> Aguardando comandos do agente...
|
|
</div>
|
|
) : (
|
|
logs.map((log: any, idx: number) => (
|
|
<div key={idx} className="mb-3 border-b border-gray-800 pb-2 last:border-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-gray-500">{new Date(log.timestamp).toLocaleTimeString("pt-BR")}</span>
|
|
<span className={log.success ? "text-green-500" : "text-red-500"}>
|
|
{log.success ? "✓" : "✗"}
|
|
</span>
|
|
</div>
|
|
<div className="text-blue-400 mb-1">$ {log.command}</div>
|
|
{log.output && (
|
|
<pre className="text-gray-400 whitespace-pre-wrap text-[11px] max-h-[100px] overflow-y-auto">{log.output.slice(0, 2000)}</pre>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function DevCenter() {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [testRepoUrl, setTestRepoUrl] = useState("https://github.com/n8n-io/n8n");
|
|
const [testFilePath, setTestFilePath] = useState("package.json");
|
|
const [analysisResult, setAnalysisResult] = useState<any>(null);
|
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
|
|
const [autoCommit, setAutoCommit] = useState(false);
|
|
const [targetBranch, setTargetBranch] = useState("main");
|
|
const [attachedImages, setAttachedImages] = useState<{ name: string; base64: string; preview: string }[]>([]);
|
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [agentMode, setAgentMode] = useState<"plan" | "act">("act");
|
|
const [planContext, setPlanContext] = useState<string | null>(null);
|
|
const [showTerminal, setShowTerminal] = useState(false);
|
|
|
|
const [selectedPipeline, setSelectedPipeline] = useState<number | null>(null);
|
|
const [pipelineAttachedFiles, setPipelineAttachedFiles] = useState<{ name: string; content: string; type: string }[]>([]);
|
|
const [pipelinePlanObjective, setPipelinePlanObjective] = useState("");
|
|
const [pipelinePlanRefs, setPipelinePlanRefs] = useState("");
|
|
const [showPipelinePlan, setShowPipelinePlan] = useState(false);
|
|
const pipelineFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const { data: pipelinesData, refetch: refetchPipelines } = useQuery<any>({
|
|
queryKey: ["/api/xos/pipeline"],
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
const createPipelineMutation = useMutation({
|
|
mutationFn: async (promptText: string) => {
|
|
const res = await apiRequest("POST", "/api/xos/pipeline", { prompt: promptText });
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({ title: "Pipeline iniciado!" });
|
|
setSelectedPipeline(data.pipeline.id);
|
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/pipeline"] });
|
|
},
|
|
onError: (err: any) => {
|
|
toast({ title: "Erro ao criar pipeline", description: err.message, variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const pipelines = pipelinesData?.pipelines || [];
|
|
const runningPipelineCount = pipelines.filter((p: any) => p.status === "running").length;
|
|
const stagingPipelineCount = pipelines.filter((p: any) => p.status === "staging_review" || p.hasPendingChanges).length;
|
|
|
|
const loadedPipelineIdsRef = useRef<Set<number>>(new Set());
|
|
useEffect(() => {
|
|
if (pipelines.length === 0) return;
|
|
const activePipelines = pipelines.filter((p: any) =>
|
|
(p.status === "running" || p.status === "staging_review" || p.hasPendingChanges) &&
|
|
!loadedPipelineIdsRef.current.has(p.id)
|
|
);
|
|
if (activePipelines.length > 0) {
|
|
activePipelines.forEach((p: any) => loadedPipelineIdsRef.current.add(p.id));
|
|
const pipelineMsgs: ChatMessage[] = activePipelines.map((p: any) => ({
|
|
id: `pipeline-restore-${p.id}`,
|
|
role: "assistant" as const,
|
|
content: `Pipeline #${p.id}`,
|
|
timestamp: new Date(p.createdAt || Date.now()),
|
|
type: "pipeline" as const,
|
|
pipelineId: p.id,
|
|
}));
|
|
setChatMessages(prev => {
|
|
const existingPipelineIds = prev.filter(m => m.type === "pipeline").map(m => m.pipelineId);
|
|
const existingSet = new Set(existingPipelineIds);
|
|
const newMsgs = pipelineMsgs.filter(m => !existingSet.has(m.pipelineId));
|
|
return newMsgs.length > 0 ? [...prev, ...newMsgs] : prev;
|
|
});
|
|
}
|
|
}, [pipelines]);
|
|
|
|
const handlePipelineFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files) return;
|
|
const newFiles = await Promise.all(
|
|
Array.from(files).map(async (f) => {
|
|
const content = await f.text();
|
|
return { name: f.name, content: content.slice(0, 50000), type: f.type };
|
|
})
|
|
);
|
|
setPipelineAttachedFiles(prev => [...prev, ...newFiles]);
|
|
if (pipelineFileInputRef.current) pipelineFileInputRef.current.value = "";
|
|
};
|
|
|
|
|
|
interface ChatMessage {
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
timestamp: Date;
|
|
type?: "text" | "spec" | "code" | "error" | "success" | "pipeline" | "review";
|
|
data?: any;
|
|
images?: { name: string; base64: string; preview: string }[];
|
|
pipelineId?: number;
|
|
}
|
|
|
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
|
|
{
|
|
id: "welcome",
|
|
role: "assistant",
|
|
content: "Descreva o que deseja criar. Os agentes vão projetar, codificar e validar automaticamente.",
|
|
timestamp: new Date(),
|
|
type: "text"
|
|
}
|
|
]);
|
|
const [chatInput, setChatInput] = useState("");
|
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [chatMessages]);
|
|
|
|
const { data: repoInfo, isLoading: loadingInfo, error: infoError } = useQuery({
|
|
queryKey: ["/api/github/info"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/github/info", { credentials: "include" });
|
|
if (!res.ok) throw new Error("Falha ao conectar");
|
|
return res.json();
|
|
},
|
|
retry: false
|
|
});
|
|
|
|
const { data: branches, isLoading: loadingBranches } = useQuery({
|
|
queryKey: ["/api/github/branches"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/github/branches", { credentials: "include" });
|
|
if (!res.ok) return { branches: [] };
|
|
return res.json();
|
|
},
|
|
enabled: !!repoInfo?.success,
|
|
retry: false
|
|
});
|
|
|
|
const analyzeMutation = useMutation({
|
|
mutationFn: async (repoUrl: string) => {
|
|
const res = await apiRequest("POST", "/api/github/analyze", { repoUrl });
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
setAnalysisResult(data);
|
|
toast({
|
|
title: "Análise concluída",
|
|
description: `Repositório ${data.data?.repository || "analisado"} com sucesso`,
|
|
});
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erro na análise",
|
|
description: error.message,
|
|
variant: "destructive"
|
|
});
|
|
}
|
|
});
|
|
|
|
const readFileMutation = useMutation({
|
|
mutationFn: async ({ repoUrl, filePath }: { repoUrl: string; filePath: string }) => {
|
|
const res = await apiRequest("POST", "/api/github/read-external", { repoUrl, filePath });
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
setFileContent(data.content || null);
|
|
toast({
|
|
title: data.success ? "Arquivo lido" : "Erro",
|
|
description: data.result,
|
|
variant: data.success ? "default" : "destructive"
|
|
});
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erro ao ler arquivo",
|
|
description: error.message,
|
|
variant: "destructive"
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
const processImageFile = (file: File): Promise<{ name: string; base64: string; preview: string }> => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!file.type.startsWith("image/")) {
|
|
reject(new Error("Arquivo não é uma imagem"));
|
|
return;
|
|
}
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
reject(new Error("Imagem muito grande. Máximo 10MB."));
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const base64 = reader.result as string;
|
|
resolve({ name: file.name, base64, preview: base64 });
|
|
};
|
|
reader.onerror = () => reject(new Error("Erro ao ler imagem"));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files) return;
|
|
try {
|
|
const newImages = await Promise.all(
|
|
Array.from(files).map(f => processImageFile(f))
|
|
);
|
|
setAttachedImages(prev => [...prev, ...newImages]);
|
|
} catch (err: any) {
|
|
toast({ title: "Erro", description: err.message, variant: "destructive" });
|
|
}
|
|
if (imageInputRef.current) imageInputRef.current.value = "";
|
|
};
|
|
|
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
const imageItems = Array.from(items).filter(item => item.type.startsWith("image/"));
|
|
if (imageItems.length === 0) return;
|
|
e.preventDefault();
|
|
try {
|
|
const newImages = await Promise.all(
|
|
imageItems.map(item => {
|
|
const file = item.getAsFile();
|
|
if (!file) throw new Error("Erro ao processar imagem colada");
|
|
return processImageFile(file);
|
|
})
|
|
);
|
|
setAttachedImages(prev => [...prev, ...newImages]);
|
|
toast({ title: "Imagem colada", description: `${newImages.length} imagem(ns) anexada(s)` });
|
|
} catch (err: any) {
|
|
toast({ title: "Erro", description: err.message, variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const removeImage = (index: number) => {
|
|
setAttachedImages(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSendChat = () => {
|
|
if (!chatInput.trim() && attachedImages.length === 0) return;
|
|
|
|
let fullPrompt = chatInput;
|
|
if (pipelinePlanObjective) fullPrompt = `[Objetivo: ${pipelinePlanObjective}]\n\n${fullPrompt}`;
|
|
if (pipelinePlanRefs) fullPrompt = `${fullPrompt}\n\n--- REFERÊNCIAS ---\n${pipelinePlanRefs}`;
|
|
if (pipelineAttachedFiles.length > 0) fullPrompt = `${fullPrompt}\n\n--- ARQUIVOS ANEXADOS ---\n${pipelineAttachedFiles.map(f => `[${f.name}]\n${f.content.slice(0, 10000)}`).join("\n\n")}`;
|
|
if (attachedImages.length > 0) fullPrompt = `${fullPrompt}\n\n[Imagens anexadas: ${attachedImages.map(img => img.name).join(", ")}]`;
|
|
|
|
const msgContent = attachedImages.length > 0
|
|
? `${attachedImages.map(img => `[img ${img.name}]`).join(" ")}\n\n${chatInput}`.trim()
|
|
: chatInput;
|
|
const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: "user", content: msgContent, timestamp: new Date(), type: "text", images: attachedImages.length > 0 ? [...attachedImages] : undefined };
|
|
setChatMessages(prev => [...prev, userMsg]);
|
|
|
|
const modeLabel = agentMode === "plan" ? "Analisando projeto e gerando plano..." : "Iniciando pipeline autônomo com 6 agentes...";
|
|
const thinkingMsg: ChatMessage = { id: `thinking-${Date.now()}`, role: "assistant", content: modeLabel, timestamp: new Date(), type: "text" };
|
|
setChatMessages(prev => [...prev, thinkingMsg]);
|
|
|
|
setChatInput("");
|
|
setAttachedImages([]);
|
|
setPipelineAttachedFiles([]);
|
|
setPipelinePlanObjective("");
|
|
setPipelinePlanRefs("");
|
|
setShowPipelinePlan(false);
|
|
|
|
fetch("/api/xos/pipeline", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
prompt: fullPrompt,
|
|
mode: agentMode,
|
|
planContext: agentMode === "act" ? planContext : undefined,
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.pipeline) {
|
|
setSelectedPipeline(data.pipeline.id);
|
|
const pipelineMsg: ChatMessage = { id: `pipeline-${data.pipeline.id}`, role: "assistant", content: `Pipeline #${data.pipeline.id} iniciado`, timestamp: new Date(), type: "pipeline", pipelineId: data.pipeline.id };
|
|
setChatMessages(prev => [...prev.filter(m => !m.id.startsWith("thinking")), pipelineMsg]);
|
|
} else {
|
|
const errorMsg: ChatMessage = { id: `error-${Date.now()}`, role: "assistant", content: `Erro: ${data.error || "Falha ao criar pipeline"}`, timestamp: new Date(), type: "error" };
|
|
setChatMessages(prev => [...prev.filter(m => !m.id.startsWith("thinking")), errorMsg]);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
const errorMsg: ChatMessage = { id: `error-${Date.now()}`, role: "assistant", content: `Erro: ${err.message}`, timestamp: new Date(), type: "error" };
|
|
setChatMessages(prev => [...prev.filter(m => !m.id.startsWith("thinking")), errorMsg]);
|
|
});
|
|
};
|
|
|
|
const [commitMessage, setCommitMessage] = useState("");
|
|
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
|
const commitMutation = useMutation({
|
|
mutationFn: async (message: string) => {
|
|
const res = await apiRequest("POST", "/api/github/commit", {
|
|
message,
|
|
files: [],
|
|
branch: targetBranch
|
|
});
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({
|
|
title: data.success ? "Commit realizado!" : "Erro",
|
|
description: data.success ? "Alterações salvas no GitHub" : data.error,
|
|
variant: data.success ? "default" : "destructive"
|
|
});
|
|
setShowCommitDialog(false);
|
|
setCommitMessage("");
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Erro no commit",
|
|
description: error.message,
|
|
variant: "destructive"
|
|
});
|
|
}
|
|
});
|
|
|
|
const isConnected = repoInfo?.success === true;
|
|
|
|
return (
|
|
<BrowserFrame>
|
|
<TooltipProvider>
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 rounded-xl bg-gradient-to-br from-purple-600 to-indigo-700 text-white">
|
|
<Code2 className="w-8 h-8" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Centro de Desenvolvimento</h1>
|
|
<p className="text-muted-foreground">
|
|
Crie, teste e publique funcionalidades com agentes de IA autônomos
|
|
</p>
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<div className="flex items-center bg-muted rounded-lg p-1 gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
disabled={!isConnected}
|
|
data-testid="button-history"
|
|
>
|
|
<History className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Histórico de commits</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
disabled={!isConnected}
|
|
data-testid="button-sync"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Sincronizar</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
className="h-8 px-3 bg-blue-500 hover:bg-blue-600"
|
|
disabled={!isConnected || commitMutation.isPending}
|
|
onClick={() => setShowCommitDialog(true)}
|
|
data-testid="button-commit"
|
|
>
|
|
{commitMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Upload className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Fazer commit</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{loadingInfo ? (
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
|
<Loader2 className="w-3 h-3 animate-spin" /> Verificando...
|
|
</Badge>
|
|
) : isConnected ? (
|
|
<Badge className="bg-green-500 flex items-center gap-1" data-testid="status-connected">
|
|
<CheckCircle className="w-3 h-3" /> Conectado
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="destructive" className="flex items-center gap-1" data-testid="status-disconnected">
|
|
<XCircle className="w-3 h-3" /> Não configurado
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="develop" className="space-y-4">
|
|
<TabsList>
|
|
<TabsTrigger value="develop" data-testid="tab-develop" className="relative">
|
|
<Sparkles className="w-4 h-4 mr-2" /> Desenvolver
|
|
{runningPipelineCount > 0 && <span className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />}
|
|
{stagingPipelineCount > 0 && <span className="absolute -top-1 right-1 w-2 h-2 bg-yellow-500 rounded-full" />}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="status" data-testid="tab-status">
|
|
<Settings className="w-4 h-4 mr-2" /> Status
|
|
</TabsTrigger>
|
|
<TabsTrigger value="analyze" data-testid="tab-analyze">
|
|
<FolderSearch className="w-4 h-4 mr-2" /> Analisar Repos
|
|
</TabsTrigger>
|
|
<TabsTrigger value="tools" data-testid="tab-tools">
|
|
<Bot className="w-4 h-4 mr-2" /> Ferramentas
|
|
</TabsTrigger>
|
|
<TabsTrigger value="system" data-testid="tab-system">
|
|
<LayoutGrid className="w-4 h-4 mr-2" /> Sistema
|
|
</TabsTrigger>
|
|
<TabsTrigger value="preview" data-testid="tab-preview">
|
|
<Monitor className="w-4 h-4 mr-2" /> Preview & Aprovar
|
|
</TabsTrigger>
|
|
<TabsTrigger value="history" data-testid="tab-history">
|
|
<History className="w-4 h-4 mr-2" /> Histórico
|
|
</TabsTrigger>
|
|
<TabsTrigger value="design" data-testid="tab-design">
|
|
<Layers className="w-4 h-4 mr-2" /> Design
|
|
</TabsTrigger>
|
|
<TabsTrigger value="assemble" data-testid="tab-assemble">
|
|
<Cpu className="w-4 h-4 mr-2" /> Montar
|
|
</TabsTrigger>
|
|
<TabsTrigger value="deploy" data-testid="tab-deploy">
|
|
<Rocket className="w-4 h-4 mr-2" /> Deploy
|
|
</TabsTrigger>
|
|
<TabsTrigger value="galeria" data-testid="tab-galeria">
|
|
<Brain className="w-4 h-4 mr-2" /> Galeria
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="develop" className="space-y-0">
|
|
<Card className="flex flex-col h-[calc(100vh-220px)] min-h-[500px]">
|
|
<CardHeader className="border-b py-3 px-4">
|
|
<div className="flex items-center gap-2">
|
|
<Bot className="w-5 h-5 text-purple-600" />
|
|
<CardTitle className="text-sm font-semibold">Dev Agent</CardTitle>
|
|
{createPipelineMutation.isPending && (
|
|
<span className="text-xs text-amber-500 flex items-center gap-1 ml-2">
|
|
<Loader2 className="w-3 h-3 animate-spin" /> criando...
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<ScrollArea className="flex-1 p-4">
|
|
<div className="space-y-4">
|
|
{chatMessages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className={`flex gap-3 ${msg.role === "user" ? "flex-row-reverse" : ""}`}
|
|
data-testid={`chat-message-${msg.id}`}
|
|
>
|
|
{msg.type === "pipeline" && msg.pipelineId ? (
|
|
<InlinePipelineTracker pipelineId={msg.pipelineId} />
|
|
) : (
|
|
<>
|
|
<Avatar className={`h-8 w-8 ${msg.role === "user" ? "bg-blue-500" : "bg-purple-600"}`}>
|
|
<AvatarFallback className="text-white text-xs">
|
|
{msg.role === "user" ? "EU" : "IA"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div
|
|
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
|
msg.role === "user"
|
|
? "bg-blue-500 text-white rounded-tr-sm"
|
|
: msg.type === "error"
|
|
? "bg-red-50 border border-red-200 text-red-800 rounded-tl-sm"
|
|
: msg.type === "success"
|
|
? "bg-green-50 border border-green-200 text-green-800 rounded-tl-sm"
|
|
: msg.type === "spec"
|
|
? "bg-purple-50 border border-purple-200 rounded-tl-sm"
|
|
: msg.type === "code"
|
|
? "bg-gray-900 text-gray-100 font-mono text-sm rounded-tl-sm"
|
|
: "bg-gray-100 rounded-tl-sm"
|
|
}`}
|
|
>
|
|
{msg.images && msg.images.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{msg.images.map((img, idx) => (
|
|
<img
|
|
key={idx}
|
|
src={img.preview}
|
|
alt={img.name}
|
|
className="max-w-[200px] max-h-[150px] rounded-lg border border-white/20 cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => window.open(img.preview, "_blank")}
|
|
data-testid={`chat-image-${msg.id}-${idx}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="whitespace-pre-wrap text-sm">{msg.content}</p>
|
|
<p className={`text-xs mt-1 ${msg.role === "user" ? "text-blue-100" : "text-gray-400"}`}>
|
|
{msg.timestamp.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div ref={chatEndRef} />
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="border-t p-3 bg-gray-50/80 rounded-b-lg space-y-2">
|
|
{(attachedImages.length > 0 || pipelineAttachedFiles.length > 0) && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{attachedImages.map((img, idx) => (
|
|
<div key={`img-${idx}`} className="relative group">
|
|
<img src={img.preview} alt={img.name} className="w-12 h-12 object-cover rounded border" data-testid={`attached-image-${idx}`} />
|
|
<button onClick={() => removeImage(idx)} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-3.5 h-3.5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" data-testid={`remove-image-${idx}`}><X className="w-2.5 h-2.5" /></button>
|
|
</div>
|
|
))}
|
|
{pipelineAttachedFiles.map((f, idx) => (
|
|
<div key={`file-${idx}`} className="flex items-center gap-1 px-2 py-1 bg-white rounded border text-[10px] text-gray-600">
|
|
<FileText className="w-3 h-3 text-blue-500" />
|
|
<span className="truncate max-w-[80px]">{f.name}</span>
|
|
<button onClick={() => setPipelineAttachedFiles(prev => prev.filter((_, i) => i !== idx))} className="text-red-400 hover:text-red-600"><X className="w-2.5 h-2.5" /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 items-end">
|
|
<input type="file" ref={imageInputRef} onChange={handleImageSelect} accept="image/*" multiple className="hidden" data-testid="input-image-file" />
|
|
<input type="file" ref={pipelineFileInputRef} onChange={handlePipelineFileSelect} accept=".pdf,.txt,.csv,.docx,.json,.md" multiple className="hidden" data-testid="input-pipeline-file-unified" />
|
|
<div className="flex flex-col gap-1 shrink-0">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => imageInputRef.current?.click()} disabled={createPipelineMutation.isPending} title="Imagem" data-testid="button-attach-image">
|
|
<ImageIcon className="w-3.5 h-3.5 text-gray-400" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => pipelineFileInputRef.current?.click()} disabled={createPipelineMutation.isPending} title="Arquivo" data-testid="button-attach-file-unified">
|
|
<Paperclip className="w-3.5 h-3.5 text-gray-400" />
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
placeholder="Descreva o que deseja criar..."
|
|
value={chatInput}
|
|
onChange={(e) => setChatInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSendChat(); } }}
|
|
onPaste={handlePaste}
|
|
disabled={createPipelineMutation.isPending}
|
|
className="flex-1 min-h-[38px] max-h-[100px] resize-none text-sm"
|
|
rows={1}
|
|
data-testid="input-chat"
|
|
/>
|
|
<Button
|
|
onClick={handleSendChat}
|
|
disabled={(!chatInput.trim() && attachedImages.length === 0) || createPipelineMutation.isPending}
|
|
className={`self-end shrink-0 ${agentMode === "plan" ? "bg-amber-500 hover:bg-amber-600" : "bg-purple-600 hover:bg-purple-700"}`}
|
|
data-testid="button-send-chat"
|
|
>
|
|
{createPipelineMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : agentMode === "plan" ? <BookOpen className="w-4 h-4" /> : <Rocket className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 text-[11px] text-gray-400">
|
|
<div className="flex items-center bg-white rounded border p-0.5 gap-0.5" data-testid="mode-toggle">
|
|
<button onClick={() => setAgentMode("plan")} className={`px-2 py-0.5 rounded text-[11px] font-medium transition-all ${agentMode === "plan" ? "bg-amber-500 text-white" : "text-gray-400 hover:text-gray-600"}`} data-testid="btn-plan-mode">Planejar</button>
|
|
<button onClick={() => setAgentMode("act")} className={`px-2 py-0.5 rounded text-[11px] font-medium transition-all ${agentMode === "act" ? "bg-purple-500 text-white" : "text-gray-400 hover:text-gray-600"}`} data-testid="btn-act-mode">Executar</button>
|
|
</div>
|
|
{planContext && agentMode === "act" && <span className="text-amber-500">plano carregado</span>}
|
|
<button onClick={() => setShowTerminal(!showTerminal)} className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${showTerminal ? "bg-gray-800 text-green-400" : "hover:text-gray-600"}`} data-testid="btn-toggle-terminal">
|
|
<Monitor className="w-3 h-3" /> terminal
|
|
</button>
|
|
<button onClick={() => setShowPipelinePlan(!showPipelinePlan)} className="hover:text-gray-600 transition-colors" data-testid="btn-toggle-plan-unified">
|
|
+ contexto
|
|
</button>
|
|
</div>
|
|
|
|
{showPipelinePlan && (
|
|
<div className="space-y-1.5 p-2 bg-white rounded border">
|
|
<Input value={pipelinePlanObjective} onChange={(e) => setPipelinePlanObjective(e.target.value)} placeholder="Objetivo (opcional)" className="text-xs h-7" data-testid="input-plan-objective-unified" />
|
|
<Textarea value={pipelinePlanRefs} onChange={(e) => setPipelinePlanRefs(e.target.value)} placeholder="Referências, specs..." className="text-xs min-h-[40px] resize-none" data-testid="input-plan-refs-unified" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{showTerminal && <AgentTerminal />}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="status" className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Github className="w-5 h-5" /> Repositório
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Repositório configurado para commits automáticos
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isConnected ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Nome:</span>
|
|
<span className="font-medium">{repoInfo.repository?.fullName}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Branch padrão:</span>
|
|
<Badge variant="outline">{repoInfo.repository?.defaultBranch}</Badge>
|
|
</div>
|
|
<Button variant="outline" className="w-full mt-2" asChild data-testid="link-github-repo">
|
|
<a href={repoInfo.repository?.url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="w-4 h-4 mr-2" /> Abrir no GitHub
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-4">
|
|
<XCircle className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Configure o GITHUB_TOKEN nas variáveis de ambiente para habilitar
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<GitBranch className="w-5 h-5" /> Branches
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Branches disponíveis no repositório
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isConnected && branches?.branches ? (
|
|
<ScrollArea className="h-32">
|
|
<div className="space-y-1">
|
|
{branches.branches.map((branch: string, idx: number) => (
|
|
<div key={branch} className="flex items-center gap-2 text-sm" data-testid={`text-branch-${idx}`}>
|
|
<GitBranch className="w-3 h-3 text-muted-foreground" />
|
|
<span>{branch}</span>
|
|
{branch === repoInfo.repository?.defaultBranch && (
|
|
<Badge variant="secondary" className="text-xs">default</Badge>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{loadingBranches ? "Carregando..." : "Não disponível"}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Configuração</CardTitle>
|
|
<CardDescription>
|
|
Variáveis de ambiente necessárias para a integração
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>GITHUB_TOKEN</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="password"
|
|
value={isConnected ? "••••••••••••••••" : ""}
|
|
disabled
|
|
data-testid="input-token"
|
|
/>
|
|
{isConnected ? (
|
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-red-500" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>GITHUB_OWNER</Label>
|
|
<Input
|
|
value={isConnected ? repoInfo.repository?.fullName?.split("/")[0] || "" : "JonasRodriguesPachceo"}
|
|
disabled
|
|
data-testid="input-owner"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>GITHUB_REPO</Label>
|
|
<Input
|
|
value={isConnected ? repoInfo.repository?.name || "" : "ArcadiaSuite-"}
|
|
disabled
|
|
data-testid="input-repo"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Para configurar, adicione estas variáveis na aba "Secrets" do Replit ou no arquivo .env
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="analyze" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FolderSearch className="w-5 h-5" /> Analisar Repositório Externo
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Analise repositórios open-source (n8n, OpenManus, etc.) para inspiração
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="https://github.com/n8n-io/n8n"
|
|
value={testRepoUrl}
|
|
onChange={(e) => setTestRepoUrl(e.target.value)}
|
|
data-testid="input-repo-url"
|
|
/>
|
|
<Button
|
|
onClick={() => analyzeMutation.mutate(testRepoUrl)}
|
|
disabled={analyzeMutation.isPending}
|
|
data-testid="button-analyze"
|
|
>
|
|
{analyzeMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<FolderSearch className="w-4 h-4" />
|
|
)}
|
|
Analisar
|
|
</Button>
|
|
</div>
|
|
|
|
{analysisResult?.success && (
|
|
<div className="bg-muted rounded-lg p-4" data-testid="result-analysis">
|
|
<h4 className="font-medium mb-2">Resultado da Análise</h4>
|
|
<pre className="text-xs whitespace-pre-wrap overflow-auto max-h-64" data-testid="text-analysis-summary">
|
|
{analysisResult.data?.summary || analysisResult.result}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FileCode className="w-5 h-5" /> Ler Arquivo Externo
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Leia arquivos específicos de repositórios externos
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<Input
|
|
placeholder="https://github.com/owner/repo"
|
|
value={testRepoUrl}
|
|
onChange={(e) => setTestRepoUrl(e.target.value)}
|
|
data-testid="input-file-repo"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="caminho/do/arquivo.ts"
|
|
value={testFilePath}
|
|
onChange={(e) => setTestFilePath(e.target.value)}
|
|
data-testid="input-file-path"
|
|
/>
|
|
<Button
|
|
onClick={() => readFileMutation.mutate({ repoUrl: testRepoUrl, filePath: testFilePath })}
|
|
disabled={readFileMutation.isPending}
|
|
data-testid="button-read-file"
|
|
>
|
|
{readFileMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<FileCode className="w-4 h-4" />
|
|
)}
|
|
Ler
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{fileContent && (
|
|
<ScrollArea className="h-64 bg-muted rounded-lg p-4">
|
|
<pre className="text-xs whitespace-pre-wrap">
|
|
{fileContent}
|
|
</pre>
|
|
</ScrollArea>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tools" className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<GitCommit className="w-5 h-5 text-green-500" /> github_commit
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Faz commit automático de arquivos para o repositório
|
|
</p>
|
|
<div className="text-xs bg-muted rounded p-2">
|
|
<code>{"github_commit(message, files[], branch?)"}</code>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<FolderSearch className="w-5 h-5 text-blue-500" /> analyze_external_repo
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Analisa estrutura de repositórios externos para inspiração
|
|
</p>
|
|
<div className="text-xs bg-muted rounded p-2">
|
|
<code>{"analyze_external_repo(repoUrl, focusPaths?)"}</code>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<FileCode className="w-5 h-5 text-purple-500" /> read_external_file
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Lê conteúdo de arquivos de projetos open-source
|
|
</p>
|
|
<div className="text-xs bg-muted rounded p-2">
|
|
<code>{"read_external_file(repoUrl, filePath)"}</code>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Zap className="w-5 h-5" /> Fluxo de Desenvolvimento Autônomo
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between gap-2 text-sm">
|
|
<div className="flex flex-col items-center gap-1 text-center p-2">
|
|
<div className="p-2 rounded-full bg-blue-100">
|
|
<Bot className="w-4 h-4 text-blue-600" />
|
|
</div>
|
|
<span>Requisição</span>
|
|
</div>
|
|
<span className="text-muted-foreground">→</span>
|
|
<div className="flex flex-col items-center gap-1 text-center p-2">
|
|
<div className="p-2 rounded-full bg-purple-100">
|
|
<FolderSearch className="w-4 h-4 text-purple-600" />
|
|
</div>
|
|
<span>Análise</span>
|
|
</div>
|
|
<span className="text-muted-foreground">→</span>
|
|
<div className="flex flex-col items-center gap-1 text-center p-2">
|
|
<div className="p-2 rounded-full bg-orange-100">
|
|
<FileCode className="w-4 h-4 text-orange-600" />
|
|
</div>
|
|
<span>Leitura</span>
|
|
</div>
|
|
<span className="text-muted-foreground">→</span>
|
|
<div className="flex flex-col items-center gap-1 text-center p-2">
|
|
<div className="p-2 rounded-full bg-green-100">
|
|
<GitCommit className="w-4 h-4 text-green-600" />
|
|
</div>
|
|
<span>Commit</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="system" className="space-y-0">
|
|
<SystemOverviewTab />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="preview" className="space-y-4">
|
|
<StagingPreviewTab />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="history" className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="border-b bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-t-lg py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-white/20 rounded-full">
|
|
<History className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-white">Histórico de Desenvolvimento</CardTitle>
|
|
<CardDescription className="text-white/70">
|
|
Todas as solicitações e tarefas executadas pelos agentes autônomos
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
<DevHistory embedded onContinueTask={(taskTitle: string) => {
|
|
setChatInput(`Continuar tarefa: ${taskTitle}`);
|
|
const tabEl = document.querySelector('[data-testid="tab-develop"]') as HTMLElement;
|
|
if (tabEl) tabEl.click();
|
|
}} />
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* ================================================================
|
|
PHASE 6: FÁBRICA DE AGENTES
|
|
================================================================ */}
|
|
<AgentFactoryTabs />
|
|
|
|
</Tabs>
|
|
|
|
{showCommitDialog && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<GitCommit className="w-5 h-5" /> Fazer Commit
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Descreva as alterações para salvar no GitHub
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Textarea
|
|
placeholder="Ex: Adiciona módulo de valuation SOTP..."
|
|
value={commitMessage}
|
|
onChange={(e) => setCommitMessage(e.target.value)}
|
|
className="min-h-24"
|
|
data-testid="textarea-commit-message"
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowCommitDialog(false)}
|
|
data-testid="button-cancel-commit"
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
onClick={() => commitMutation.mutate(commitMessage)}
|
|
disabled={!commitMessage.trim() || commitMutation.isPending}
|
|
data-testid="button-confirm-commit"
|
|
>
|
|
{commitMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
)}
|
|
Commit
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TooltipProvider>
|
|
</BrowserFrame>
|
|
);
|
|
}
|