From 194a8dcc1b1f9cba0af5578fe87f79dbec6acf8e Mon Sep 17 00:00:00 2001 From: Jonas Pacheco Date: Wed, 25 Mar 2026 09:38:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(skills):=20rota=20/skills=20+=20autocomple?= =?UTF-8?q?te=20de=20refer=C3=AAncias=20/=20no=20Monaco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx: adiciona ProtectedRoute path="/skills" - Skills.tsx: completion provider para referências no body editor - dispara ao digitar "/" em qualquer ponto do markdown - sugere todos os prefixos: /skill/, /skill:system/, /var/, /data/, /kg/, /file/, /tool/, /agent/ - ao digitar /skill/ lista skills existentes do tenant com slug/desc - usa ref para evitar closure stale com skillsData - cleanup do provider no unmount Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.tsx | 1 + client/src/pages/Skills.tsx | 90 ++++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 21ce75d..da45d01 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -147,6 +147,7 @@ function Router() { + diff --git a/client/src/pages/Skills.tsx b/client/src/pages/Skills.tsx index c187d17..83a8424 100644 --- a/client/src/pages/Skills.tsx +++ b/client/src/pages/Skills.tsx @@ -1,5 +1,5 @@ import { BrowserFrame } from "@/components/Browser/BrowserFrame"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -133,6 +133,10 @@ export default function Skills() { const [historyOpen, setHistoryOpen] = useState(false); const [historySkill, setHistorySkill] = useState(null); + // Ref para skills — usado no completion provider sem closure stale + const skillsRef = useRef([]); + const completionDisposable = useRef<{ dispose(): void } | null>(null); + // ── Queries ──────────────────────────────────────────────────────────────── const { data: skillsData, isLoading } = useQuery<{ skills: Skill[] }>({ @@ -263,6 +267,87 @@ export default function Skills() { const skills = skillsData?.skills ?? []; + // Mantém ref atualizado para o completion provider + useEffect(() => { skillsRef.current = skills; }, [skills]); + + // Cleanup do completion provider ao desmontar + useEffect(() => () => { completionDisposable.current?.dispose(); }, []); + + // Handler do Monaco Body — registra completion provider de referências / + function handleBodyEditorMount(editor: unknown, monaco: any) { + completionDisposable.current?.dispose(); + + const PREFIXES = [ + { label: "/skill/", detail: "Skill do tenant atual" }, + { label: "/skill:system/", detail: "Skill global do sistema" }, + { label: "/skill:company/", detail: "Skill da empresa" }, + { label: "/var/", detail: "Variável de contexto ou parâmetro de entrada" }, + { label: "/data/", detail: "Dado dinâmico do banco" }, + { label: "/kg/", detail: "Nó do Knowledge Graph (Neo4j)" }, + { label: "/file/", detail: "Arquivo no storage" }, + { label: "/tool/", detail: "Tool disponível no Manus" }, + { label: "/agent/", detail: "Agente especialista" }, + ]; + + completionDisposable.current = monaco.languages.registerCompletionItemProvider("markdown", { + triggerCharacters: ["/"], + provideCompletionItems(model: any, position: any) { + const line = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const match = line.match(/\/([\w:\/\-.]*)$/); + if (!match) return { suggestions: [] }; + + const typed = match[1]; + const startCol = position.column - typed.length - 1; + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: startCol, + endColumn: position.column, + }; + + const CIK = monaco.languages.CompletionItemKind; + + // Sugestões de prefixos + const prefixSuggestions = PREFIXES.map(p => ({ + label: p.label, + kind: CIK.Reference, + detail: p.detail, + insertText: p.label, + sortText: "0_" + p.label, + range, + })); + + // Sugestões de skills existentes quando digita /skill/... + const skillSuggestions: any[] = []; + if (typed.startsWith("skill/") || typed.startsWith("skill:")) { + const afterSkill = typed.replace(/^skill(:[^/]+)?\//, ""); + skillsRef.current.forEach(s => { + const ref = `/skill/${s.namespace}/${s.slug}`; + if (!afterSkill || s.slug.includes(afterSkill) || s.name.toLowerCase().includes(afterSkill)) { + skillSuggestions.push({ + label: ref, + kind: CIK.Variable, + detail: s.description ?? s.name, + documentation: `namespace: ${s.namespace} | status: ${s.status}`, + insertText: ref, + sortText: "1_" + ref, + range, + }); + } + }); + } + + return { suggestions: [...prefixSuggestions, ...skillSuggestions] }; + }, + }); + } + // ── Render ───────────────────────────────────────────────────────────────── return ( @@ -529,6 +614,7 @@ export default function Skills() { defaultLanguage="markdown" value={form.body} onChange={v => setForm(f => ({ ...f, body: v ?? "" }))} + onMount={handleBodyEditorMount} theme="vs-dark" options={{ fontSize: 13, @@ -537,6 +623,8 @@ export default function Skills() { lineNumbers: "on", scrollBeyondLastLine: false, padding: { top: 8 }, + quickSuggestions: { other: true, comments: true, strings: true }, + suggestOnTriggerCharacters: true, }} />