diff --git a/bun.lockb b/bun.lockb
index 7d464b6..6380595 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 2bd89c9..1104cf5 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,8 @@
},
"dependencies": {
"@cloudflare/vite-plugin": "^1.25.5",
+ "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.2.2",
"@lovable.dev/cloud-auth-js": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.12",
@@ -47,6 +49,7 @@
"@tanstack/react-router": "^1.168.0",
"@tanstack/react-start": "^1.167.14",
"@tanstack/router-plugin": "^1.167.10",
+ "@uiw/react-codemirror": "^4.25.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -60,8 +63,10 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.72.1",
"react-imask": "^7.6.1",
+ "react-markdown": "^10.1.0",
"react-resizable-panels": "^4.6.5",
"recharts": "^2.15.4",
+ "remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
diff --git a/src/components/mika/skills/AgentProvisioningState.tsx b/src/components/mika/skills/AgentProvisioningState.tsx
new file mode 100644
index 0000000..062094b
--- /dev/null
+++ b/src/components/mika/skills/AgentProvisioningState.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { Hourglass } from "lucide-react";
+
+export function AgentProvisioningState() {
+ return (
+
+
+
+
+
Aguardando seu agente ficar pronto
+
+ Estamos provisionando sua instância do Mika. Você poderá criar skills assim
+ que o provisionamento terminar — geralmente em até 10 minutos.
+
+
+ );
+}
diff --git a/src/components/mika/skills/EmptySkillsState.tsx b/src/components/mika/skills/EmptySkillsState.tsx
new file mode 100644
index 0000000..2ea0259
--- /dev/null
+++ b/src/components/mika/skills/EmptySkillsState.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { Sparkles } from "lucide-react";
+import { Link } from "@tanstack/react-router";
+import { Button } from "@/components/ui/button";
+
+export function EmptySkillsState({ disabled }: { disabled?: boolean }) {
+ return (
+
+
+
+
+
Você ainda não tem skills personalizadas
+
+ Crie sua primeira skill em 2 minutos e ensine seu agente Mika a fazer
+ exatamente o que você precisa.
+
+
+ Criar primeira skill
+
+
+ );
+}
diff --git a/src/components/mika/skills/NoSubscriptionState.tsx b/src/components/mika/skills/NoSubscriptionState.tsx
new file mode 100644
index 0000000..fb50df6
--- /dev/null
+++ b/src/components/mika/skills/NoSubscriptionState.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Sparkles } from "lucide-react";
+import { Link } from "@tanstack/react-router";
+import { Button } from "@/components/ui/button";
+
+export function NoSubscriptionState() {
+ return (
+
+
+
+
+
+ Você precisa de uma assinatura ativa para criar skills
+
+
+ Escolha um plano para liberar o Skill Studio e começar a personalizar
+ seu agente Mika com automações próprias.
+
+
+
+ Ver planos
+
+
+
+ );
+}
diff --git a/src/components/mika/skills/SkillCard.tsx b/src/components/mika/skills/SkillCard.tsx
new file mode 100644
index 0000000..09de28e
--- /dev/null
+++ b/src/components/mika/skills/SkillCard.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { useState } from "react";
+import { Link, useNavigate } from "@tanstack/react-router";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { MoreVertical, Edit, Play, Power, Copy, Archive, RotateCcw, Trash2 } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { ptBR } from "date-fns/locale";
+import { toast } from "sonner";
+import { supabase } from "@/integrations/supabase/client";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { SkillStatusBadge } from "./SkillStatusBadge";
+import type { Skill } from "@/hooks/use-skills";
+import { SkillTestPanel } from "./SkillTestPanel";
+
+export function SkillCard({ skill }: { skill: Skill }) {
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const [confirmArchive, setConfirmArchive] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [testOpen, setTestOpen] = useState(false);
+
+ const isArchived = skill.status === "archived";
+
+ const updateStatus = useMutation({
+ mutationFn: async (newStatus: string) => {
+ const { error } = await supabase
+ .from("skills")
+ .update({ status: newStatus, updated_at: new Date().toISOString() })
+ .eq("id", skill.id);
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["skills"] });
+ qc.invalidateQueries({ queryKey: ["user-limits"] });
+ },
+ });
+
+ const handleToggleActive = () => {
+ const next = skill.status === "active" ? "disabled" : "active";
+ updateStatus.mutate(next, {
+ onSuccess: () => toast.success(next === "active" ? "Skill ativada" : "Skill desativada"),
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao atualizar"),
+ });
+ };
+
+ const handleArchive = () => {
+ updateStatus.mutate("archived", {
+ onSuccess: () => {
+ toast.success("Skill arquivada");
+ setConfirmArchive(false);
+ },
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao arquivar"),
+ });
+ };
+
+ const handleRestore = () => {
+ updateStatus.mutate("draft", {
+ onSuccess: () => toast.success("Skill restaurada como rascunho"),
+ onError: (e: unknown) => {
+ if ((e as { code?: string })?.code === "23505") {
+ toast.error("Já existe outra skill ativa com esse nome. Renomeie antes de restaurar.");
+ } else {
+ toast.error(e instanceof Error ? e.message : "Erro ao restaurar");
+ }
+ },
+ });
+ };
+
+ const handleDelete = useMutation({
+ mutationFn: async () => {
+ const { error } = await supabase.from("skills").delete().eq("id", skill.id);
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ toast.success("Skill deletada");
+ qc.invalidateQueries({ queryKey: ["skills"] });
+ qc.invalidateQueries({ queryKey: ["user-limits"] });
+ setConfirmDelete(false);
+ },
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"),
+ });
+
+ return (
+ <>
+
+
+
+
navigate({ to: "/painel/skills/$id", params: { id: skill.id } })}
+ className="text-left font-semibold text-base hover:text-primary transition-colors truncate w-full"
+ >
+ {skill.name}
+
+
+ {skill.description}
+
+
+
+
+
+
+
+
+
+ {isArchived ? (
+ <>
+
+ Restaurar
+
+
+ setConfirmDelete(true)}
+ className="cursor-pointer text-destructive focus:text-destructive"
+ >
+ Deletar
+
+ >
+ ) : (
+ <>
+
+
+ Editar
+
+
+ setTestOpen(true)}
+ disabled={!skill.current_version_id}
+ className="cursor-pointer"
+ >
+ Testar
+
+
+
+ {skill.status === "active" ? "Desativar" : "Ativar"}
+
+
+ Duplicar
+
+
+ setConfirmArchive(true)}
+ className="cursor-pointer text-destructive focus:text-destructive"
+ >
+ Arquivar
+
+ >
+ )}
+
+
+
+
+
+
+
+ {formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
+
+
+
+
+
+
+
+ Arquivar esta skill?
+
+ A skill {skill.name} ficará invisível para o agente. Você
+ poderá restaurá-la depois sem perder o histórico.
+
+
+
+ Cancelar
+ Arquivar
+
+
+
+
+
+
+
+ Deletar definitivamente?
+
+ Esta ação remove {skill.name} e todo o histórico de versões.
+ Não é possível desfazer.
+
+
+
+ Cancelar
+ handleDelete.mutate()}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Deletar
+
+
+
+
+
+ {testOpen && skill.current_version_id && (
+
+ )}
+ >
+ );
+}
diff --git a/src/components/mika/skills/SkillMarkdownEditor.tsx b/src/components/mika/skills/SkillMarkdownEditor.tsx
new file mode 100644
index 0000000..082b1bc
--- /dev/null
+++ b/src/components/mika/skills/SkillMarkdownEditor.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import CodeMirror from "@uiw/react-codemirror";
+import { markdown as markdownLang } from "@codemirror/lang-markdown";
+import { oneDark } from "@codemirror/theme-one-dark";
+import { EditorView } from "@codemirror/view";
+
+interface Props {
+ value: string;
+ onChange: (val: string) => void;
+ readOnly?: boolean;
+}
+
+// Light theme — matches Mika design system
+const mikaLight = EditorView.theme({
+ "&": { backgroundColor: "var(--color-card)", color: "var(--color-foreground)" },
+ ".cm-content": { caretColor: "var(--color-primary)" },
+ ".cm-activeLine": { backgroundColor: "var(--color-muted)" },
+ ".cm-selectionBackground, ::selection": { backgroundColor: "oklch(0.70 0.19 47 / 0.25) !important" },
+ ".cm-gutters": { backgroundColor: "var(--color-card)", borderRight: "1px solid var(--color-border)" },
+});
+
+export default function SkillMarkdownEditor({ value, onChange, readOnly = false }: Props) {
+ const [isDark, setIsDark] = useState(false);
+
+ useEffect(() => {
+ const html = document.documentElement;
+ const check = () => setIsDark(html.classList.contains("dark"));
+ check();
+ const obs = new MutationObserver(check);
+ obs.observe(html, { attributes: true, attributeFilter: ["class"] });
+ return () => obs.disconnect();
+ }, []);
+
+ const extensions = useMemo(
+ () => [markdownLang(), EditorView.lineWrapping],
+ [],
+ );
+
+ const handleChange = useCallback(
+ (val: string) => onChange(val),
+ [onChange],
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/mika/skills/SkillStatusBadge.tsx b/src/components/mika/skills/SkillStatusBadge.tsx
new file mode 100644
index 0000000..f83cfb9
--- /dev/null
+++ b/src/components/mika/skills/SkillStatusBadge.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+const STATUS_LABELS: Record = {
+ draft: "Rascunho",
+ testing: "Em teste",
+ active: "Ativa",
+ disabled: "Desativada",
+ archived: "Arquivada",
+};
+
+const STATUS_CLASSES: Record = {
+ draft: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
+ testing: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30",
+ active: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
+ disabled: "bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30",
+ archived: "bg-slate-500/10 text-slate-500/70 border-slate-500/20",
+};
+
+export function SkillStatusBadge({ status, className }: { status: string; className?: string }) {
+ return (
+
+ {STATUS_LABELS[status] ?? status}
+
+ );
+}
diff --git a/src/components/mika/skills/SkillTestPanel.tsx b/src/components/mika/skills/SkillTestPanel.tsx
new file mode 100644
index 0000000..7ed2060
--- /dev/null
+++ b/src/components/mika/skills/SkillTestPanel.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Play, X } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { ptBR } from "date-fns/locale";
+import { supabase } from "@/integrations/supabase/client";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ skillName: string;
+ skillVersionId: string;
+ triggerKeywords?: string;
+ // se não houver skill_version_id ainda (preview), passa o markdown direto e usa stateless mode
+ stateless?: { markdown_content: string };
+}
+
+interface TestResult {
+ status: "success" | "error";
+ test_output?: string;
+ duration_ms: number;
+ error_message?: string;
+}
+
+export function SkillTestPanel({
+ open,
+ onOpenChange,
+ skillName,
+ skillVersionId,
+ triggerKeywords,
+ stateless,
+}: Props) {
+ const [input, setInput] = useState("");
+ const [result, setResult] = useState(null);
+ const inputRef = useRef(null);
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (open) setTimeout(() => inputRef.current?.focus(), 50);
+ }, [open]);
+
+ const placeholder = triggerKeywords
+ ? `Ex: "${triggerKeywords.split(",")[0]?.trim() || "..."}"`
+ : "Digite um exemplo de input que você daria ao Mika para acionar esta skill";
+
+ const history = useQuery({
+ queryKey: ["skill-test-runs", skillVersionId],
+ enabled: open && !stateless,
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("skill_test_runs")
+ .select("*")
+ .eq("skill_version_id", skillVersionId)
+ .order("created_at", { ascending: false })
+ .limit(5);
+ if (error) throw error;
+ return data ?? [];
+ },
+ });
+
+ const runTest = useMutation({
+ mutationFn: async (): Promise => {
+ if (stateless) {
+ // Modo preview sem persistência: chama AI direto via edge function
+ const start = Date.now();
+ const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
+ body: { skill_version_id: skillVersionId, test_input: input },
+ });
+ if (error) {
+ return {
+ status: "error",
+ duration_ms: Date.now() - start,
+ error_message: error.message,
+ };
+ }
+ return data as TestResult;
+ }
+ const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
+ body: { skill_version_id: skillVersionId, test_input: input },
+ });
+ if (error) throw new Error(error.message);
+ return data as TestResult;
+ },
+ onSuccess: (r) => {
+ setResult(r);
+ qc.invalidateQueries({ queryKey: ["skill-test-runs", skillVersionId] });
+ },
+ onError: (e: unknown) => {
+ setResult({
+ status: "error",
+ duration_ms: 0,
+ error_message: e instanceof Error ? e.message : "Erro desconhecido",
+ });
+ },
+ });
+
+ return (
+
+
+
+
+
+ Testar skill: {skillName}
+
+
+
+
+
+
+ Digite um exemplo de input
+
+
+
+
runTest.mutate()}
+ disabled={!input.trim() || runTest.isPending}
+ className="w-full bg-primary hover:bg-primary-dark text-primary-foreground"
+ >
+ {runTest.isPending ? (
+ <>
+
+ Mika está pensando...
+ >
+ ) : (
+ <>
+
+ Executar teste
+ >
+ )}
+
+
+ {result && (
+
+
+
+ {result.status === "success" ? "Resultado do teste" : "Erro no teste"}
+
+ {result.status === "success" && (
+
+ Dry-run
+
+ )}
+ setResult(null)} className="ml-auto text-muted-foreground hover:text-foreground">
+
+
+
+
+ {result.status === "success" ? result.test_output : result.error_message}
+
+ {result.duration_ms > 0 && (
+
+ Executado em {(result.duration_ms / 1000).toFixed(1)}s
+
+ )}
+
+ )}
+
+ {!stateless && history.data && history.data.length > 0 && (
+
+
+ Histórico ({history.data.length})
+
+
+ {history.data.map((run) => (
+
+
+
+ {run.status === "success" ? "Sucesso" : "Erro"}
+
+
+ {formatDistanceToNow(new Date(run.created_at), { addSuffix: true, locale: ptBR })}
+
+
+
{run.test_input}
+
+ ))}
+
+
+ )}
+
+
+ O teste em modo simulação não executa ferramentas reais. Em breve você poderá
+ testar a skill diretamente no seu agente.
+
+
+
+
+ );
+}
diff --git a/src/hooks/use-agent-instance.ts b/src/hooks/use-agent-instance.ts
new file mode 100644
index 0000000..0ba6fcd
--- /dev/null
+++ b/src/hooks/use-agent-instance.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+
+export interface AgentInstance {
+ id: string;
+ user_id: string;
+ status: string;
+ telegram_bot_username: string | null;
+ created_at: string;
+}
+
+export function useAgentInstance() {
+ const { user } = useAuth();
+ return useQuery({
+ queryKey: ["agent-instance", user?.id],
+ enabled: !!user,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("agent_instances")
+ .select("id, user_id, status, telegram_bot_username, created_at")
+ .eq("user_id", user!.id)
+ .maybeSingle();
+ if (error) throw error;
+ return data;
+ },
+ });
+}
diff --git a/src/hooks/use-skills.ts b/src/hooks/use-skills.ts
new file mode 100644
index 0000000..09b23e9
--- /dev/null
+++ b/src/hooks/use-skills.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import type { Database } from "@/integrations/supabase/types";
+
+export type Skill = Database["public"]["Tables"]["skills"]["Row"];
+export type SkillStatus = "draft" | "testing" | "active" | "disabled" | "archived";
+
+export function useSkills(includeArchived = false) {
+ const { user } = useAuth();
+ return useQuery({
+ queryKey: ["skills", user?.id, includeArchived],
+ enabled: !!user,
+ queryFn: async (): Promise => {
+ let q = supabase
+ .from("skills")
+ .select("*")
+ .eq("user_id", user!.id)
+ .order("updated_at", { ascending: false });
+ if (!includeArchived) {
+ q = q.neq("status", "archived");
+ }
+ const { data, error } = await q;
+ if (error) throw error;
+ return (data ?? []) as Skill[];
+ },
+ });
+}
+
+export function useSkill(skillId: string | undefined) {
+ return useQuery({
+ queryKey: ["skill", skillId],
+ enabled: !!skillId,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("skills")
+ .select("*")
+ .eq("id", skillId!)
+ .maybeSingle();
+ if (error) throw error;
+ return data as Skill | null;
+ },
+ });
+}
diff --git a/src/hooks/use-user-skill-limits.ts b/src/hooks/use-user-skill-limits.ts
new file mode 100644
index 0000000..6278277
--- /dev/null
+++ b/src/hooks/use-user-skill-limits.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+
+export interface UserSkillLimits {
+ user_id: string;
+ plan_slug: string | null;
+ max_skills: number | null;
+ current_skills_count: number;
+}
+
+export function useUserSkillLimits() {
+ const { user } = useAuth();
+ return useQuery({
+ queryKey: ["user-limits", user?.id],
+ enabled: !!user,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("user_skill_limits")
+ .select("*")
+ .eq("user_id", user!.id)
+ .maybeSingle();
+ if (error) throw error;
+ return data as UserSkillLimits | null;
+ },
+ });
+}
diff --git a/src/lib/skill-schema.ts b/src/lib/skill-schema.ts
new file mode 100644
index 0000000..2d8d2f9
--- /dev/null
+++ b/src/lib/skill-schema.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+export const skillFormSchema = z.object({
+ name: z
+ .string()
+ .trim()
+ .min(2, "Mínimo 2 caracteres")
+ .max(60, "Máximo 60 caracteres"),
+ description: z
+ .string()
+ .trim()
+ .min(10, "Descreva em pelo menos 10 caracteres")
+ .max(200, "Máximo 200 caracteres"),
+ trigger_keywords: z
+ .string()
+ .trim()
+ .min(2, "Adicione ao menos 1 palavra-chave")
+ .max(200, "Máximo 200 caracteres"),
+ expected_inputs: z.string().trim().max(500).nullable().optional(),
+ steps: z
+ .string()
+ .trim()
+ .min(50, "Descreva o passo a passo (mín. 50 caracteres)")
+ .max(3000, "Máximo 3000 caracteres"),
+ required_tools: z
+ .array(z.string())
+ .min(1, "Selecione pelo menos uma ferramenta"),
+ success_criteria: z
+ .string()
+ .trim()
+ .min(5, "Defina o critério de sucesso")
+ .max(300, "Máximo 300 caracteres"),
+ example_use_case: z.string().trim().max(500).nullable().optional(),
+});
+
+export type SkillFormValues = z.infer;
+
+export const AVAILABLE_TOOLS = [
+ "Gmail",
+ "Google Calendar",
+ "Google Drive",
+ "Notion",
+ "Cal.com",
+ "Microsoft Outlook",
+ "Todoist",
+ "Web Search",
+] as const;
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index d965976..025f321 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -16,9 +16,15 @@ import { Route as PainelRouteImport } from './routes/painel'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PainelIndexRouteImport } from './routes/painel.index'
+import { Route as PainelSkillsRouteImport } from './routes/painel.skills'
import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento'
import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes'
+import { Route as PainelAgenteRouteImport } from './routes/painel.agente'
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
+import { Route as PainelSkillsIndexRouteImport } from './routes/painel.skills.index'
+import { Route as PainelSkillsPreviewRouteImport } from './routes/painel.skills.preview'
+import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova'
+import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
const SignupRoute = SignupRouteImport.update({
id: '/signup',
@@ -55,6 +61,11 @@ const PainelIndexRoute = PainelIndexRouteImport.update({
path: '/',
getParentRoute: () => PainelRoute,
} as any)
+const PainelSkillsRoute = PainelSkillsRouteImport.update({
+ id: '/skills',
+ path: '/skills',
+ getParentRoute: () => PainelRoute,
+} as any)
const PainelFaturamentoRoute = PainelFaturamentoRouteImport.update({
id: '/faturamento',
path: '/faturamento',
@@ -65,11 +76,36 @@ const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({
path: '/configuracoes',
getParentRoute: () => PainelRoute,
} as any)
+const PainelAgenteRoute = PainelAgenteRouteImport.update({
+ id: '/agente',
+ path: '/agente',
+ getParentRoute: () => PainelRoute,
+} as any)
const CheckoutSucessoRoute = CheckoutSucessoRouteImport.update({
id: '/checkout/sucesso',
path: '/checkout/sucesso',
getParentRoute: () => rootRouteImport,
} as any)
+const PainelSkillsIndexRoute = PainelSkillsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => PainelSkillsRoute,
+} as any)
+const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({
+ id: '/preview',
+ path: '/preview',
+ getParentRoute: () => PainelSkillsRoute,
+} as any)
+const PainelSkillsNovaRoute = PainelSkillsNovaRouteImport.update({
+ id: '/nova',
+ path: '/nova',
+ getParentRoute: () => PainelSkillsRoute,
+} as any)
+const PainelSkillsIdRoute = PainelSkillsIdRouteImport.update({
+ id: '/$id',
+ path: '/$id',
+ getParentRoute: () => PainelSkillsRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -79,9 +115,15 @@ export interface FileRoutesByFullPath {
'/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute
'/checkout/sucesso': typeof CheckoutSucessoRoute
+ '/painel/agente': typeof PainelAgenteRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
+ '/painel/skills': typeof PainelSkillsRouteWithChildren
'/painel/': typeof PainelIndexRoute
+ '/painel/skills/$id': typeof PainelSkillsIdRoute
+ '/painel/skills/nova': typeof PainelSkillsNovaRoute
+ '/painel/skills/preview': typeof PainelSkillsPreviewRoute
+ '/painel/skills/': typeof PainelSkillsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -90,9 +132,14 @@ export interface FileRoutesByTo {
'/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute
'/checkout/sucesso': typeof CheckoutSucessoRoute
+ '/painel/agente': typeof PainelAgenteRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel': typeof PainelIndexRoute
+ '/painel/skills/$id': typeof PainelSkillsIdRoute
+ '/painel/skills/nova': typeof PainelSkillsNovaRoute
+ '/painel/skills/preview': typeof PainelSkillsPreviewRoute
+ '/painel/skills': typeof PainelSkillsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -103,9 +150,15 @@ export interface FileRoutesById {
'/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute
'/checkout/sucesso': typeof CheckoutSucessoRoute
+ '/painel/agente': typeof PainelAgenteRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
+ '/painel/skills': typeof PainelSkillsRouteWithChildren
'/painel/': typeof PainelIndexRoute
+ '/painel/skills/$id': typeof PainelSkillsIdRoute
+ '/painel/skills/nova': typeof PainelSkillsNovaRoute
+ '/painel/skills/preview': typeof PainelSkillsPreviewRoute
+ '/painel/skills/': typeof PainelSkillsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -117,9 +170,15 @@ export interface FileRouteTypes {
| '/redefinir-senha'
| '/signup'
| '/checkout/sucesso'
+ | '/painel/agente'
| '/painel/configuracoes'
| '/painel/faturamento'
+ | '/painel/skills'
| '/painel/'
+ | '/painel/skills/$id'
+ | '/painel/skills/nova'
+ | '/painel/skills/preview'
+ | '/painel/skills/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -128,9 +187,14 @@ export interface FileRouteTypes {
| '/redefinir-senha'
| '/signup'
| '/checkout/sucesso'
+ | '/painel/agente'
| '/painel/configuracoes'
| '/painel/faturamento'
| '/painel'
+ | '/painel/skills/$id'
+ | '/painel/skills/nova'
+ | '/painel/skills/preview'
+ | '/painel/skills'
id:
| '__root__'
| '/'
@@ -140,9 +204,15 @@ export interface FileRouteTypes {
| '/redefinir-senha'
| '/signup'
| '/checkout/sucesso'
+ | '/painel/agente'
| '/painel/configuracoes'
| '/painel/faturamento'
+ | '/painel/skills'
| '/painel/'
+ | '/painel/skills/$id'
+ | '/painel/skills/nova'
+ | '/painel/skills/preview'
+ | '/painel/skills/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -206,6 +276,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelIndexRouteImport
parentRoute: typeof PainelRoute
}
+ '/painel/skills': {
+ id: '/painel/skills'
+ path: '/skills'
+ fullPath: '/painel/skills'
+ preLoaderRoute: typeof PainelSkillsRouteImport
+ parentRoute: typeof PainelRoute
+ }
'/painel/faturamento': {
id: '/painel/faturamento'
path: '/faturamento'
@@ -220,6 +297,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelConfiguracoesRouteImport
parentRoute: typeof PainelRoute
}
+ '/painel/agente': {
+ id: '/painel/agente'
+ path: '/agente'
+ fullPath: '/painel/agente'
+ preLoaderRoute: typeof PainelAgenteRouteImport
+ parentRoute: typeof PainelRoute
+ }
'/checkout/sucesso': {
id: '/checkout/sucesso'
path: '/checkout/sucesso'
@@ -227,18 +311,68 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CheckoutSucessoRouteImport
parentRoute: typeof rootRouteImport
}
+ '/painel/skills/': {
+ id: '/painel/skills/'
+ path: '/'
+ fullPath: '/painel/skills/'
+ preLoaderRoute: typeof PainelSkillsIndexRouteImport
+ parentRoute: typeof PainelSkillsRoute
+ }
+ '/painel/skills/preview': {
+ id: '/painel/skills/preview'
+ path: '/preview'
+ fullPath: '/painel/skills/preview'
+ preLoaderRoute: typeof PainelSkillsPreviewRouteImport
+ parentRoute: typeof PainelSkillsRoute
+ }
+ '/painel/skills/nova': {
+ id: '/painel/skills/nova'
+ path: '/nova'
+ fullPath: '/painel/skills/nova'
+ preLoaderRoute: typeof PainelSkillsNovaRouteImport
+ parentRoute: typeof PainelSkillsRoute
+ }
+ '/painel/skills/$id': {
+ id: '/painel/skills/$id'
+ path: '/$id'
+ fullPath: '/painel/skills/$id'
+ preLoaderRoute: typeof PainelSkillsIdRouteImport
+ parentRoute: typeof PainelSkillsRoute
+ }
}
}
+interface PainelSkillsRouteChildren {
+ PainelSkillsIdRoute: typeof PainelSkillsIdRoute
+ PainelSkillsNovaRoute: typeof PainelSkillsNovaRoute
+ PainelSkillsPreviewRoute: typeof PainelSkillsPreviewRoute
+ PainelSkillsIndexRoute: typeof PainelSkillsIndexRoute
+}
+
+const PainelSkillsRouteChildren: PainelSkillsRouteChildren = {
+ PainelSkillsIdRoute: PainelSkillsIdRoute,
+ PainelSkillsNovaRoute: PainelSkillsNovaRoute,
+ PainelSkillsPreviewRoute: PainelSkillsPreviewRoute,
+ PainelSkillsIndexRoute: PainelSkillsIndexRoute,
+}
+
+const PainelSkillsRouteWithChildren = PainelSkillsRoute._addFileChildren(
+ PainelSkillsRouteChildren,
+)
+
interface PainelRouteChildren {
+ PainelAgenteRoute: typeof PainelAgenteRoute
PainelConfiguracoesRoute: typeof PainelConfiguracoesRoute
PainelFaturamentoRoute: typeof PainelFaturamentoRoute
+ PainelSkillsRoute: typeof PainelSkillsRouteWithChildren
PainelIndexRoute: typeof PainelIndexRoute
}
const PainelRouteChildren: PainelRouteChildren = {
+ PainelAgenteRoute: PainelAgenteRoute,
PainelConfiguracoesRoute: PainelConfiguracoesRoute,
PainelFaturamentoRoute: PainelFaturamentoRoute,
+ PainelSkillsRoute: PainelSkillsRouteWithChildren,
PainelIndexRoute: PainelIndexRoute,
}
diff --git a/src/routes/painel.agente.tsx b/src/routes/painel.agente.tsx
new file mode 100644
index 0000000..b71d7ab
--- /dev/null
+++ b/src/routes/painel.agente.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { Bot, Cpu, MessageSquare, Sparkles, BarChart3, Loader2 } from "lucide-react";
+import { useProfile } from "@/hooks/use-profile";
+import { useAgentInstance } from "@/hooks/use-agent-instance";
+import { useUserSkillLimits } from "@/hooks/use-user-skill-limits";
+import { useQuery } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { formatDistanceToNow } from "date-fns";
+import { ptBR } from "date-fns/locale";
+import { cn } from "@/lib/utils";
+
+export const Route = createFileRoute("/painel/agente")({
+ component: AgentePage,
+});
+
+const STATUS_MAP: Record = {
+ provisioning: { label: "Provisionando", color: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30", pulse: true },
+ active: { label: "Online", color: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30" },
+ suspended: { label: "Suspenso", color: "bg-destructive/15 text-destructive border-destructive/30" },
+ error: { label: "Erro", color: "bg-destructive/15 text-destructive border-destructive/30" },
+};
+
+function AgentePage() {
+ const { data: profile } = useProfile();
+ const agent = useAgentInstance();
+ const limits = useUserSkillLimits();
+ const { user } = useAuth();
+
+ const lastTestRun = useQuery({
+ queryKey: ["last-test-run", user?.id],
+ enabled: !!user,
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("skill_test_runs")
+ .select("created_at")
+ .eq("user_id", user!.id)
+ .order("created_at", { ascending: false })
+ .limit(1)
+ .maybeSingle();
+ if (error) throw error;
+ return data?.created_at ?? null;
+ },
+ });
+
+ const loading = agent.isLoading;
+
+ if (loading) {
+ return (
+
+
+
+
+ );
+ }
+
+ const firstName = (profile?.full_name || "").split(" ")[0] || "Você";
+ const agentName = `Mika de ${firstName}`;
+ const status = agent.data?.status ?? "provisioning";
+ const statusInfo = STATUS_MAP[status] ?? STATUS_MAP.provisioning;
+
+ return (
+
+
+
+
+ {/* Card 1: Seu agente — full width */}
+
+
+
+
+
+
+
+
{agentName}
+
+ {statusInfo.label}
+
+
+
+ {status === "provisioning"
+ ? "Estamos preparando sua instância. Geralmente leva até 10 minutos."
+ : status === "active"
+ ? "Seu agente está online e pronto para receber skills."
+ : "Houve um problema com sua instância. Entre em contato com o suporte."}
+
+
+
+
+
+ {/* Card 2: Modelo de IA */}
+
+
+
+
Modelo de IA
+
+
Opencode Zen
+
+ Padrão do plano
+
+
+
+ {/* Card 3: Canais conectados */}
+
+
+
+
Canais conectados
+
+
+
+ Telegram
+
+
+
+
+ Conectar
+
+
+ Disponível em breve
+
+
+
+
+ {/* Card 4: Estatísticas */}
+
+
+
+
Estatísticas
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function StatItem({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
diff --git a/src/routes/painel.skills.$id.tsx b/src/routes/painel.skills.$id.tsx
new file mode 100644
index 0000000..1dfa854
--- /dev/null
+++ b/src/routes/painel.skills.$id.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
+import { useState, useCallback, lazy, Suspense } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, Check, Loader2, Play, Rocket, Save, MoreVertical, Copy, Archive, Trash2 } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { ptBR } from "date-fns/locale";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { toast } from "sonner";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import { useSkill } from "@/hooks/use-skills";
+import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge";
+import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor"));
+
+export const Route = createFileRoute("/painel/skills/$id")({
+ component: SkillDetailPage,
+});
+
+interface SkillVersion {
+ id: string;
+ version_number: number;
+ markdown_content: string;
+ form_inputs: Record;
+ is_live: boolean;
+ created_at: string;
+}
+
+function SkillDetailPage() {
+ const { id } = Route.useParams();
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const skill = useSkill(id);
+
+ const versions = useQuery({
+ queryKey: ["skill-versions", id],
+ enabled: !!id,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("skill_versions")
+ .select("id, version_number, markdown_content, form_inputs, is_live, created_at")
+ .eq("skill_id", id)
+ .order("version_number", { ascending: false })
+ .limit(12);
+ if (error) throw error;
+ return (data ?? []) as SkillVersion[];
+ },
+ });
+
+ const [selectedVersionId, setSelectedVersionId] = useState(null);
+ const [markdown, setMarkdown] = useState("");
+ const [editing, setEditing] = useState(false);
+ const [testOpen, setTestOpen] = useState(false);
+ const [confirmArchive, setConfirmArchive] = useState(false);
+
+ // Sync markdown when versions load or selection changes
+ const currentVersion = versions.data?.find((v) =>
+ selectedVersionId ? v.id === selectedVersionId : v.is_live,
+ ) ?? versions.data?.[0];
+
+ if (currentVersion && markdown === "" && !editing) {
+ // initial load
+ setTimeout(() => setMarkdown(currentVersion.markdown_content), 0);
+ }
+
+ const selectVersion = useCallback(
+ (v: SkillVersion) => {
+ setSelectedVersionId(v.id);
+ setMarkdown(v.markdown_content);
+ setEditing(false);
+ },
+ [],
+ );
+
+ // Save new version
+ const saveVersion = useMutation({
+ mutationFn: async () => {
+ if (!user || !versions.data) throw new Error("Dados indisponíveis");
+ const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0);
+ const { data, error } = await supabase
+ .from("skill_versions")
+ .insert([{
+ skill_id: id,
+ version_number: maxVer + 1,
+ markdown_content: markdown,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ form_inputs: (currentVersion?.form_inputs ?? {}) as any,
+ is_live: false,
+ created_by: user.id,
+ }])
+ .select("id, version_number")
+ .single();
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (data) => {
+ toast.success(`Versão ${data.version_number} salva`);
+ setSelectedVersionId(data.id);
+ setEditing(false);
+ qc.invalidateQueries({ queryKey: ["skill-versions", id] });
+ },
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao salvar"),
+ });
+
+ // Publish version
+ const publishVersion = useMutation({
+ mutationFn: async (versionId: string) => {
+ const { data, error } = await supabase.functions.invoke("publish-skill-version", {
+ body: { skill_version_id: versionId },
+ });
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (data) => {
+ if (data.no_op) {
+ toast.info("Esta versão já está publicada");
+ } else {
+ toast.success(`Versão ${data.version_number} publicada!`);
+ }
+ qc.invalidateQueries({ queryKey: ["skill-versions", id] });
+ qc.invalidateQueries({ queryKey: ["skill", id] });
+ qc.invalidateQueries({ queryKey: ["skills"] });
+ },
+ onError: (e: unknown) => {
+ const msg = e instanceof Error ? e.message : "Erro";
+ if (msg.includes("409")) {
+ toast.error("Conflito de concorrência. Recarregue e tente novamente.");
+ } else {
+ toast.error(msg);
+ }
+ },
+ });
+
+ // Archive
+ const archiveSkill = useMutation({
+ mutationFn: async () => {
+ const { error } = await supabase
+ .from("skills")
+ .update({ status: "archived", updated_at: new Date().toISOString() })
+ .eq("id", id);
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ toast.success("Skill arquivada");
+ qc.invalidateQueries({ queryKey: ["skills"] });
+ qc.invalidateQueries({ queryKey: ["user-limits"] });
+ navigate({ to: "/painel/skills" });
+ },
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"),
+ });
+
+ const loading = skill.isLoading || versions.isLoading;
+
+ if (loading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!skill.data) {
+ return (
+
+
Skill não encontrada.
+
+ Voltar
+
+
+ );
+ }
+
+ const isCurrentLive = currentVersion?.is_live === true;
+ const hasChanged = editing && markdown !== currentVersion?.markdown_content;
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Main content */}
+
+ {/* Editor / Preview (3 cols) */}
+
+ {/* Desktop split */}
+
+
+ }>
+ { setMarkdown(v); if (!editing) setEditing(true); }}
+ readOnly={!editing}
+ />
+
+
+
+ {markdown}
+
+
+ {/* Mobile tabs */}
+
+
+
+ Editor
+ Preview
+
+
+
+ }>
+ { setMarkdown(v); if (!editing) setEditing(true); }}
+ readOnly={!editing}
+ />
+
+
+
+
+
+ {markdown}
+
+
+
+
+
+
+ {/* Timeline sidebar (1 col) */}
+
+
+
Versões
+ {versions.data?.map((v) => (
+
selectVersion(v)}
+ className={`w-full text-left rounded-lg p-3 transition-colors text-sm ${
+ currentVersion?.id === v.id
+ ? "bg-primary/10 border border-primary/30"
+ : "hover:bg-muted border border-transparent"
+ }`}
+ >
+
+ v{v.version_number}
+ {v.is_live && (
+
+ Live
+
+ )}
+
+
+ {formatDistanceToNow(new Date(v.created_at), { addSuffix: true, locale: ptBR })}
+
+ {!v.is_live && currentVersion?.id !== v.id && (
+ {
+ e.stopPropagation();
+ publishVersion.mutate(v.id);
+ }}
+ >
+ Restaurar
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Test panel */}
+ {testOpen && currentVersion && (
+
+ )}
+
+ {/* Archive dialog */}
+
+
+
+ Arquivar esta skill?
+
+ A skill ficará invisível para o agente. Você poderá restaurá-la depois.
+
+
+
+ Cancelar
+ archiveSkill.mutate()}>Arquivar
+
+
+
+
+ );
+}
diff --git a/src/routes/painel.skills.index.tsx b/src/routes/painel.skills.index.tsx
new file mode 100644
index 0000000..5c5f32b
--- /dev/null
+++ b/src/routes/painel.skills.index.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { useState } from "react";
+import { Plus, Sparkles } from "lucide-react";
+import { useSkills } from "@/hooks/use-skills";
+import { useUserSkillLimits } from "@/hooks/use-user-skill-limits";
+import { useAgentInstance } from "@/hooks/use-agent-instance";
+import { NoSubscriptionState } from "@/components/mika/skills/NoSubscriptionState";
+import { AgentProvisioningState } from "@/components/mika/skills/AgentProvisioningState";
+import { EmptySkillsState } from "@/components/mika/skills/EmptySkillsState";
+import { SkillCard } from "@/components/mika/skills/SkillCard";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export const Route = createFileRoute("/painel/skills/")({
+ component: SkillsPage,
+});
+
+function SkillsPage() {
+ const [showArchived, setShowArchived] = useState(false);
+ const limits = useUserSkillLimits();
+ const agent = useAgentInstance();
+ const skills = useSkills(showArchived);
+
+ const loading = limits.isLoading || agent.isLoading || skills.isLoading;
+ const noSub = !limits.isLoading && (limits.data?.max_skills == null);
+ const agentNotReady =
+ !agent.isLoading &&
+ (!agent.data || agent.data.status === "provisioning");
+ const atLimit =
+ limits.data != null &&
+ limits.data.max_skills != null &&
+ limits.data.current_skills_count >= limits.data.max_skills;
+
+ if (loading) {
+ return (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {noSub ? (
+
+ ) : agentNotReady ? (
+
+ ) : skills.data && skills.data.length === 0 ? (
+
+ ) : (
+
+ {skills.data?.map((skill) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/routes/painel.skills.nova.tsx b/src/routes/painel.skills.nova.tsx
new file mode 100644
index 0000000..6eabb60
--- /dev/null
+++ b/src/routes/painel.skills.nova.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useState } from "react";
+import { useForm, Controller } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ArrowLeft, Check, CircleDashed, Loader2, Sparkles, X } from "lucide-react";
+import { Link } from "@tanstack/react-router";
+import { toast } from "sonner";
+import { supabase } from "@/integrations/supabase/client";
+import { skillFormSchema, AVAILABLE_TOOLS, type SkillFormValues } from "@/lib/skill-schema";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+export const Route = createFileRoute("/painel/skills/nova")({
+ component: NovaSkillPage,
+});
+
+const FIELDS_META: { key: keyof SkillFormValues; label: string; required: boolean }[] = [
+ { key: "name", label: "Nome", required: true },
+ { key: "description", label: "Descrição", required: true },
+ { key: "trigger_keywords", label: "Gatilhos", required: true },
+ { key: "expected_inputs", label: "Inputs esperados", required: false },
+ { key: "steps", label: "Passo a passo", required: true },
+ { key: "required_tools", label: "Ferramentas", required: true },
+ { key: "success_criteria", label: "Critério de sucesso", required: true },
+ { key: "example_use_case", label: "Exemplo de uso", required: false },
+];
+
+function NovaSkillPage() {
+ const navigate = useNavigate();
+ const [generating, setGenerating] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(skillFormSchema),
+ defaultValues: {
+ name: "",
+ description: "",
+ trigger_keywords: "",
+ expected_inputs: "",
+ steps: "",
+ required_tools: [],
+ success_criteria: "",
+ example_use_case: "",
+ },
+ mode: "onChange",
+ });
+
+ const values = form.watch();
+ const { isValid } = form.formState;
+
+ const fieldFilled = (key: keyof SkillFormValues): boolean => {
+ const v = values[key];
+ if (key === "required_tools") return Array.isArray(v) && v.length > 0;
+ return typeof v === "string" && v.trim().length > 0;
+ };
+
+ const handleGenerate = async () => {
+ const valid = await form.trigger();
+ if (!valid) return;
+ setGenerating(true);
+ try {
+ const formValues = form.getValues();
+ const formInputs = {
+ name: formValues.name,
+ description: formValues.description,
+ trigger_keywords: formValues.trigger_keywords,
+ expected_inputs: formValues.expected_inputs || null,
+ steps: formValues.steps,
+ required_tools: formValues.required_tools,
+ success_criteria: formValues.success_criteria,
+ example_use_case: formValues.example_use_case || null,
+ };
+
+ const { data, error } = await supabase.functions.invoke("generate-skill-markdown", {
+ body: { form_inputs: formInputs },
+ });
+
+ if (error) {
+ const msg = error.message || "Falha ao gerar skill";
+ if (msg.includes("429") || msg.includes("Muitas")) {
+ toast.error("Muitas requisições. Aguarde 1 minuto e tente novamente.");
+ } else {
+ toast.error(msg);
+ }
+ return;
+ }
+
+ // Navigate to preview with state
+ navigate({
+ to: "/painel/skills/preview",
+ search: {},
+ state: {
+ markdown_content: data.markdown_content,
+ form_inputs: formInputs,
+ } as Record,
+ });
+ } catch (e) {
+ toast.error("Erro inesperado ao gerar skill. Tente novamente.");
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {/* Form column */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+ {AVAILABLE_TOOLS.map((tool) => {
+ const selected = field.value?.includes(tool);
+ return (
+ {
+ const next = selected
+ ? field.value.filter((t) => t !== tool)
+ : [...(field.value ?? []), tool];
+ field.onChange(next);
+ }}
+ >
+ {tool}
+
+ );
+ })}
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* Sticky checklist column */}
+
+
+
+
Checklist
+
+ {FIELDS_META.map((f) => {
+ const done = fieldFilled(f.key);
+ return (
+
+ {done ? (
+
+ ) : (
+
+ )}
+
+ {f.label}
+ {!f.required && " (opcional)"}
+
+
+ );
+ })}
+
+
+
+
+ {generating ? (
+ <>
+
+ Gerando sua skill...
+ >
+ ) : (
+ <>
+
+ Gerar com IA
+ >
+ )}
+
+
+
+
+
+ );
+}
+
+function FormField({
+ label,
+ helperText,
+ error,
+ children,
+}: {
+ label: string;
+ helperText?: string;
+ error?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{label}
+ {children}
+ {error ? (
+
{error}
+ ) : helperText ? (
+
{helperText}
+ ) : null}
+
+ );
+}
diff --git a/src/routes/painel.skills.preview.tsx b/src/routes/painel.skills.preview.tsx
new file mode 100644
index 0000000..38b4920
--- /dev/null
+++ b/src/routes/painel.skills.preview.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import { createFileRoute, useNavigate, Link, useRouter } from "@tanstack/react-router";
+import { useState, lazy, Suspense, useCallback, useMemo } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, Eye, FileText, Loader2, Play, Save } from "lucide-react";
+import { toast } from "sonner";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import { useAgentInstance } from "@/hooks/use-agent-instance";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
+
+// Lazy-load CodeMirror to reduce initial bundle
+const CodeMirrorEditor = lazy(() => import("@/components/mika/skills/SkillMarkdownEditor"));
+
+export const Route = createFileRoute("/painel/skills/preview")({
+ component: SkillPreviewPage,
+});
+
+function SkillPreviewPage() {
+ const router = useRouter();
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const agent = useAgentInstance();
+ const qc = useQueryClient();
+
+ // State passed from /painel/skills/nova
+ const routerState = (router.state.location.state ?? {}) as {
+ markdown_content?: string;
+ form_inputs?: Record;
+ };
+
+ const [markdown, setMarkdown] = useState(routerState.markdown_content ?? "");
+ const formInputs = useMemo(() => routerState.form_inputs ?? {}, [routerState.form_inputs]);
+ const [testOpen, setTestOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [publishing, setPublishing] = useState(false);
+
+ const agentId = agent.data?.id;
+
+ const hasContent = markdown.trim().length > 0;
+
+ // Helper: parse Supabase error codes
+ const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => {
+ if (error.code === "P0001") {
+ toast.error("Você precisa de uma assinatura ativa para criar skills.", {
+ action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
+ });
+ return true;
+ }
+ if (error.code === "P0002") {
+ toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.");
+ return true;
+ }
+ if (error.code === "23505") {
+ toast.error("Você já tem uma skill com esse nome. Escolha outro.");
+ return true;
+ }
+ return false;
+ }, [navigate]);
+
+ const createSkill = useCallback(async (publish: boolean) => {
+ if (!user || !agentId) return;
+ if (markdown.length > 50000) {
+ toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
+ return;
+ }
+
+ const setter = publish ? setPublishing : setSaving;
+ setter(true);
+
+ try {
+ const fi = formInputs as Record;
+ // 1. Create skill
+ const { data: skill, error: skillErr } = await supabase
+ .from("skills")
+ .insert({
+ user_id: user.id,
+ agent_instance_id: agentId,
+ name: (fi.name as string) || "Skill sem nome",
+ description: (fi.description as string) || "",
+ trigger_keywords: (fi.trigger_keywords as string) || "",
+ status: "draft",
+ })
+ .select("id")
+ .single();
+
+ if (skillErr) {
+ if (!handleSupabaseError(skillErr as { code?: string })) {
+ toast.error(skillErr.message || "Erro ao criar skill");
+ }
+ return;
+ }
+
+ // 2. Create version
+ const { data: ver, error: verErr } = await supabase
+ .from("skill_versions")
+ .insert([{
+ skill_id: skill.id,
+ version_number: 1,
+ markdown_content: markdown,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ form_inputs: formInputs as any,
+ is_live: false,
+ created_by: user.id,
+ }])
+ .select("id")
+ .single();
+
+ if (verErr) {
+ toast.error(verErr.message || "Erro ao salvar versão");
+ return;
+ }
+
+ // 3. Optionally publish
+ if (publish) {
+ const { data: pubData, error: pubErr } = await supabase.functions.invoke(
+ "publish-skill-version",
+ { body: { skill_version_id: ver.id } },
+ );
+ if (pubErr) {
+ toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
+ } else {
+ toast.success("Skill publicada com sucesso!");
+ }
+ } else {
+ toast.success("Rascunho salvo!");
+ }
+
+ qc.invalidateQueries({ queryKey: ["skills"] });
+ qc.invalidateQueries({ queryKey: ["user-limits"] });
+ navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
+ } catch {
+ toast.error("Erro inesperado");
+ } finally {
+ setter(false);
+ }
+ }, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
+
+ if (!routerState.markdown_content) {
+ return (
+
+
Nenhum conteúdo para pré-visualizar.
+
+ Voltar ao formulário
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
Pré-visualização
+
+
+
setTestOpen(true)} disabled={!hasContent}>
+ Testar antes
+
+
createSkill(false)} disabled={saving || publishing || !hasContent}>
+ {saving ? : }
+ Salvar rascunho
+
+
createSkill(true)}
+ disabled={saving || publishing || !hasContent}
+ className="bg-primary hover:bg-primary-dark text-primary-foreground"
+ >
+ {publishing ? : }
+ Publicar agora
+
+
+
+
+ {/* Desktop: split | Mobile: tabs */}
+
+
+
+
+
+
+ Editor
+
+
+ Preview
+
+
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+ {testOpen && (
+
).name || "Skill"}
+ skillVersionId="preview"
+ triggerKeywords={(formInputs as Record).trigger_keywords}
+ stateless={{ markdown_content: markdown }}
+ />
+ )}
+
+ );
+}
+
+function MarkdownPreview({ content }: { content: string }) {
+ return (
+
+ {content}
+
+ );
+}
+
+function EditorSkeleton() {
+ return (
+
+
+
+ );
+}
diff --git a/src/routes/painel.skills.tsx b/src/routes/painel.skills.tsx
new file mode 100644
index 0000000..28d1533
--- /dev/null
+++ b/src/routes/painel.skills.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { createFileRoute } from "@tanstack/react-router";
+import { Outlet } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/painel/skills")({
+ component: SkillsLayout,
+});
+
+function SkillsLayout() {
+ return ;
+}
diff --git a/src/routes/painel.tsx b/src/routes/painel.tsx
index f595c50..3dcbac1 100644
--- a/src/routes/painel.tsx
+++ b/src/routes/painel.tsx
@@ -58,7 +58,7 @@ export const Route = createFileRoute("/painel")({
});
interface NavItem {
- to: "/painel" | "/painel/faturamento" | "/painel/configuracoes";
+ to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/faturamento" | "/painel/configuracoes";
label: string;
icon: React.ComponentType<{ className?: string }>;
disabled?: boolean;
@@ -73,8 +73,8 @@ interface DisabledNavItem {
const NAV: (NavItem | DisabledNavItem)[] = [
{ to: "/painel", label: "Dashboard", icon: Home },
- { to: null, label: "Meu Agente", icon: Bot, disabled: true },
- { to: null, label: "Skills", icon: Sparkles, disabled: true },
+ { to: "/painel/agente", label: "Meu Agente", icon: Bot },
+ { to: "/painel/skills", label: "Skills", icon: Sparkles },
{ to: null, label: "Integrações", icon: Plug, disabled: true },
{ to: "/painel/faturamento", label: "Faturamento", icon: CreditCard },
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings },