From a32c05a67982f966bf1c07db38c6d47248fff497 Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Fri, 17 Apr 2026 18:53:49 +0000
Subject: [PATCH] Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
---
.../mika/skills/AgentProvisioningState.tsx | 18 ++
.../mika/skills/EmptySkillsState.tsx | 28 +++
.../mika/skills/NoSubscriptionState.tsx | 31 +++
src/components/mika/skills/SkillCard.tsx | 230 ++++++++++++++++++
.../mika/skills/SkillStatusBadge.tsx | 35 +++
src/components/mika/skills/SkillTestPanel.tsx | 227 +++++++++++++++++
src/hooks/use-agent-instance.ts | 30 +++
src/hooks/use-skills.ts | 46 ++++
src/hooks/use-user-skill-limits.ts | 29 +++
src/lib/skill-schema.ts | 47 ++++
10 files changed, 721 insertions(+)
create mode 100644 src/components/mika/skills/AgentProvisioningState.tsx
create mode 100644 src/components/mika/skills/EmptySkillsState.tsx
create mode 100644 src/components/mika/skills/NoSubscriptionState.tsx
create mode 100644 src/components/mika/skills/SkillCard.tsx
create mode 100644 src/components/mika/skills/SkillStatusBadge.tsx
create mode 100644 src/components/mika/skills/SkillTestPanel.tsx
create mode 100644 src/hooks/use-agent-instance.ts
create mode 100644 src/hooks/use-skills.ts
create mode 100644 src/hooks/use-user-skill-limits.ts
create mode 100644 src/lib/skill-schema.ts
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.
+
+
+
+ );
+}
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.
+
+
+
+ );
+}
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 (
+ <>
+
+
+
+
+
+ {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/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 (
+
+ );
+}
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;