feat(openclaw): Fase 4 Sprint 2 - Frontend Widgets

- useAgentEmergence.ts: hook para Socket.IO + estado de sugestões
- SkillSuggestion.tsx: modal com preview de skill (padrão, nome, descrição, automação)
- OpenClawWidget.tsx: widget flutuante no canto inferior direito
- App.tsx: integração do OpenClawWidget no layout global

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Arcádia Dev 2026-03-26 10:42:28 -03:00
parent dcaa94a422
commit 52311666aa
4 changed files with 377 additions and 0 deletions

View File

@ -9,6 +9,7 @@ import { SoeMotorProvider } from "@/contexts/SoeMotorContext";
import { ProtectedRoute } from "@/lib/protected-route";
import { CommandPalette } from "@/components/CommandPalette";
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
import NotFound from "@/pages/not-found";
import AuthPage from "@/pages/auth-page";
@ -164,6 +165,7 @@ function App() {
<KnowledgeCollectorInit />
<Toaster />
<CommandPalette />
<OpenClawWidget />
<Router />
</TooltipProvider>
</SoeMotorProvider>

View File

@ -0,0 +1,131 @@
/**
* OpenClawWidget.tsx
*
* Widget flutuante do OpenClaw aparece quando sugestões pendentes.
* Posicionado no canto inferior direito, fora das rotas.
* Abre o modal SkillSuggestion para confirmação.
*
* Fase 4: OpenClaw Sprint 2
*/
import { useState } from "react";
import { Sparkles, X, ChevronUp, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAgentEmergence } from "@/hooks/useAgentEmergence";
import { SkillSuggestion } from "./SkillSuggestion";
import { useAuth } from "@/hooks/use-auth";
export function OpenClawWidget() {
const { user } = useAuth();
const {
suggestions,
activeSuggestion,
pendingCount,
confirmSkill,
rejectSkill,
openSuggestion,
closeSuggestion,
} = useAgentEmergence();
const [collapsed, setCollapsed] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Não renderiza se não há usuário, não há sugestões, ou foi dispensado
if (!user || pendingCount === 0 || dismissed) return null;
return (
<>
{/* Widget flutuante */}
<div
className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2"
role="complementary"
aria-label="OpenClaw - Sugestões de Skills"
>
{/* Painel expandido */}
{!collapsed && (
<div className="w-72 rounded-xl border bg-background shadow-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 border-b bg-violet-50 dark:bg-violet-900/20">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
<span className="text-sm font-semibold text-violet-700 dark:text-violet-300">
OpenClaw
</span>
<Badge className="bg-violet-600 text-white text-xs px-1.5 py-0">
{pendingCount}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setCollapsed(true)}
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
aria-label="Minimizar"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setDismissed(true)}
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
aria-label="Fechar"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="p-3 space-y-2 max-h-64 overflow-y-auto">
<p className="text-xs text-muted-foreground">
Detectei padrões de uso que podem virar skills automáticas:
</p>
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => openSuggestion(s)}
className="w-full text-left rounded-lg border p-2.5 hover:bg-muted/50 transition-colors space-y-1"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-tight line-clamp-1">
{s.suggested_skill_name}
</span>
<Badge variant="outline" className="text-xs shrink-0">
{Math.round(s.confidence * 100)}%
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{s.suggested_description}
</p>
</button>
))}
</div>
</div>
)}
{/* Botão minimizado */}
{collapsed && (
<button
onClick={() => setCollapsed(false)}
className="flex items-center gap-2 rounded-full bg-violet-600 hover:bg-violet-700 text-white px-4 py-2.5 shadow-lg transition-colors"
aria-label="Expandir sugestões de skills"
>
<Sparkles className="h-4 w-4" />
<span className="text-sm font-medium">Skills sugeridas</span>
<Badge className="bg-white text-violet-700 text-xs px-1.5 py-0 ml-0.5">
{pendingCount}
</Badge>
<ChevronUp className="h-3.5 w-3.5 ml-0.5" />
</button>
)}
</div>
{/* Modal de detalhes */}
{activeSuggestion && (
<SkillSuggestion
suggestion={activeSuggestion}
onConfirm={confirmSkill}
onReject={rejectSkill}
onClose={closeSuggestion}
/>
)}
</>
);
}

View File

@ -0,0 +1,101 @@
/**
* SkillSuggestion.tsx
*
* Modal com preview da skill sugerida pelo OpenClaw.
* Apresenta padrão detectado, nome, descrição e preview de automação.
* Usuário pode confirmar ou rejeitar.
*
* Fase 4: OpenClaw Sprint 2
*/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Sparkles, X, Check, Zap, BarChart2 } from "lucide-react";
import type { SkillSuggestionData } from "@/hooks/useAgentEmergence";
interface SkillSuggestionProps {
suggestion: SkillSuggestionData;
onConfirm: (id: string) => void;
onReject: (id: string) => void;
onClose: () => void;
}
export function SkillSuggestion({ suggestion, onConfirm, onReject, onClose }: SkillSuggestionProps) {
const confidencePct = Math.round(suggestion.confidence * 100);
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-violet-100 dark:bg-violet-900/30">
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
</div>
<DialogTitle className="text-base">Nova skill detectada</DialogTitle>
</div>
</DialogHeader>
{/* Padrão detectado */}
<div className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Padrão detectado</p>
<p className="font-medium">{suggestion.pattern.description || suggestion.pattern.action_type}</p>
<div className="flex items-center gap-3 text-muted-foreground text-xs mt-1">
<span className="flex items-center gap-1">
<BarChart2 className="h-3 w-3" />
{suggestion.pattern.frequency}x detectado
</span>
<span className="flex items-center gap-1">
<Zap className="h-3 w-3" />
Confiança: {confidencePct}%
</span>
</div>
</div>
{/* Skill sugerida */}
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Nome da skill</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs">
{suggestion.suggested_skill_name}
</Badge>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Descrição</p>
<p className="text-sm">{suggestion.suggested_description}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">O que será automatizado</p>
<div className="rounded-md border bg-muted/20 p-2.5 text-sm text-muted-foreground">
{suggestion.estimated_automation}
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
size="sm"
onClick={() => onReject(suggestion.id)}
className="gap-1.5"
>
<X className="h-3.5 w-3.5" />
Rejeitar
</Button>
<Button
size="sm"
onClick={() => onConfirm(suggestion.id)}
className="gap-1.5 bg-violet-600 hover:bg-violet-700"
>
<Check className="h-3.5 w-3.5" />
Criar skill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,143 @@
/**
* useAgentEmergence.ts
*
* Hook para gerenciar sugestões de skills do OpenClaw.
* Escuta eventos Socket.IO e coordena confirmações/rejeições.
*
* Fase 4: OpenClaw Sprint 2
*/
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "./use-auth";
export interface SkillSuggestionData {
id: string;
pattern: {
id: string;
action_type: string;
frequency: number;
confidence: number;
description: string;
};
suggested_skill_name: string;
suggested_description: string;
estimated_automation: string;
confidence: number;
created_at: string;
status: "pending" | "accepted" | "rejected";
}
interface UseAgentEmergenceReturn {
suggestions: SkillSuggestionData[];
activeSuggestion: SkillSuggestionData | null;
isOpen: boolean;
pendingCount: number;
confirmSkill: (suggestionId: string) => Promise<void>;
rejectSkill: (suggestionId: string) => Promise<void>;
openSuggestion: (suggestion: SkillSuggestionData) => void;
closeSuggestion: () => void;
dismissWidget: () => void;
}
export function useAgentEmergence(): UseAgentEmergenceReturn {
const { user } = useAuth();
const [suggestions, setSuggestions] = useState<SkillSuggestionData[]>([]);
const [activeSuggestion, setActiveSuggestion] = useState<SkillSuggestionData | null>(null);
const [isOpen, setIsOpen] = useState(false);
// Carrega sugestões pendentes do servidor
const fetchPendingSuggestions = useCallback(async () => {
if (!user) return;
try {
const res = await fetch("/api/openclaw/suggestions");
if (res.ok) {
const data = await res.json();
setSuggestions(data.suggestions || []);
}
} catch {
// silencioso — não bloqueia o usuário
}
}, [user]);
// Escuta eventos Socket.IO via window (padrão da suite)
useEffect(() => {
if (!user) return;
fetchPendingSuggestions();
const handleOpenClawEvent = (event: CustomEvent) => {
const suggestion: SkillSuggestionData = event.detail;
setSuggestions((prev) => {
const exists = prev.find((s) => s.id === suggestion.id);
if (exists) return prev;
return [suggestion, ...prev];
});
};
window.addEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
return () => {
window.removeEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
};
}, [user, fetchPendingSuggestions]);
const confirmSkill = useCallback(async (suggestionId: string) => {
try {
const res = await fetch("/api/openclaw/confirm-skill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestion_id: suggestionId, action: "confirm" }),
});
if (res.ok) {
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
setActiveSuggestion(null);
setIsOpen(false);
}
} catch {
// erro tratado na UI via toast externo
}
}, []);
const rejectSkill = useCallback(async (suggestionId: string) => {
try {
const res = await fetch("/api/openclaw/confirm-skill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestion_id: suggestionId, action: "reject" }),
});
if (res.ok) {
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
setActiveSuggestion(null);
setIsOpen(false);
}
} catch {
// silencioso
}
}, []);
const openSuggestion = useCallback((suggestion: SkillSuggestionData) => {
setActiveSuggestion(suggestion);
setIsOpen(true);
}, []);
const closeSuggestion = useCallback(() => {
setActiveSuggestion(null);
setIsOpen(false);
}, []);
const dismissWidget = useCallback(() => {
setIsOpen(false);
setActiveSuggestion(null);
}, []);
return {
suggestions,
activeSuggestion,
isOpen,
pendingCount: suggestions.length,
confirmSkill,
rejectSkill,
openSuggestion,
closeSuggestion,
dismissWidget,
};
}