import { Button } from "@agent-native/toolkit/ui/button"; import { IconPlus, IconUpload, IconArrowLeft, IconPencil, IconBulb, IconBolt, IconTrash, IconEye, IconCode, IconClock, IconHierarchy2, IconPlugConnected, } from "@tabler/icons-react"; import React, { useState, useRef, useCallback, useEffect, useMemo, } from "react"; import { CLAUDE_SONNET_MODEL_ID, CLAUDE_SONNET_MODEL_LABEL, } from "../../agent/model-config.js"; import { serializeFrontmatter } from "../../resources/metadata.js"; import { sendToAgentChat } from "../agent-chat.js"; import { agentNativePath } from "../api-path.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip.js"; import { PromptComposer } from "../composer/index.js"; import { useT } from "../i18n.js"; import { useOrg } from "../org/hooks.js"; import { useUploadResource } from "../uploads/use-upload-resource.js"; import { cn } from "../utils.js"; import { BuiltinCapabilityDetail } from "./BuiltinCapabilityDetail.js"; import { isMcpIntegrationCatalogAvailable, type DefaultMcpIntegration, } from "./mcp-integration-catalog.js"; import { McpIntegrationDialog } from "./McpIntegrationDialog.js"; import { McpServerDetail } from "./McpServerDetail.js"; import { ResourceEditor } from "./ResourceEditor.js"; import { ResourceTree } from "./ResourceTree.js"; import { parseMcpBuiltinVirtualId, useBuiltinCapabilities, } from "./use-builtin-capabilities.js"; import { useMcpServers, useCreateMcpServer, useDeleteMcpServer, parseMcpVirtualId, type McpServerScope, } from "./use-mcp-servers.js"; import { useResourceTree, useResource, useCreateResource, useUpdateResource, useDeleteResource, withMcpServersFolder, withAgentScratchFolder, type ResourceScope, type ResourceMeta, type Resource, type TreeNode, } from "./use-resources.js"; const LOCAL_WORKSPACE_RESOURCE_METADATA_SOURCE = "local-workspace-resource"; export function normalizeResourceFileName(name: string): string { const trimmed = name.trim(); if (!trimmed || trimmed.endsWith("/")) return ""; const finalSegment = trimmed.split("/").pop() ?? ""; return /\.[^/]+$/.test(finalSegment) ? trimmed : `${trimmed}.md`; } const EMPTY_RESOURCE_ACTION_LABELS: Record = { files: "Add file", instructions: "Add instructions", agents: "Add agent", memory: "Add memory", skills: "Add skill", learnings: "Add learning", "remote-agents": "Add remote agent", }; const EMPTY_RESOURCE_SEEDS: Partial< Record > = { instructions: { path: "AGENTS.md", content: "# Agent Instructions\n\n", mimeType: "text/markdown", }, memory: { path: "memory/MEMORY.md", content: "# Memory\n\n", mimeType: "text/markdown", }, learnings: { path: "LEARNINGS.md", content: "# Learnings\n\n", mimeType: "text/markdown", }, "remote-agents": { path: "remote-agents/new-agent.json", content: '{\n "id": "new-agent",\n "name": "New agent",\n "description": "",\n "url": "",\n "color": "#6B7280"\n}\n', mimeType: "application/json", }, }; export type ResourceView = | "files" | "instructions" | "agents" | "memory" | "skills" | "learnings" | "remote-agents"; export type ResourceTreeVariant = "tree" | "collection"; const SPECIAL_RESOURCE_ROOTS = new Set([ "agents", "agent-scratch", "jobs", "memory", "remote-agents", "skills", ]); const SPECIAL_RESOURCE_FILES = new Set(["agents.md", "learnings.md"]); function normalizedResourcePath(path: string): string { return path.replace(/^\/+/, "").toLowerCase(); } function resourceMatchesView(node: TreeNode, view: ResourceView): boolean { const path = normalizedResourcePath(node.path); switch (view) { case "files": return ( !SPECIAL_RESOURCE_ROOTS.has(path.split("/", 1)[0]) && !SPECIAL_RESOURCE_FILES.has(path.split("/").pop() ?? "") ); case "instructions": return path.split("/").pop() === "agents.md"; case "agents": return node.kind === "agent"; case "memory": return path === "memory" || path.startsWith("memory/"); case "skills": return node.kind === "skill"; case "learnings": return path.split("/").pop() === "learnings.md"; case "remote-agents": return node.kind === "remote-agent"; } } export function filterResourceTree( tree: TreeNode[], view: ResourceView | undefined, ): TreeNode[] { if (!view) return tree; return tree.flatMap((node) => { if (node.type === "folder") { if ( view === "files" && (SPECIAL_RESOURCE_ROOTS.has( normalizedResourcePath(node.path).split("/", 1)[0], ) || SPECIAL_RESOURCE_FILES.has( normalizedResourcePath(node.path).split("/").pop() ?? "", )) ) { return []; } const children = filterResourceTree(node.children ?? [], view); return children.length > 0 ? [{ ...node, children }] : []; } return resourceMatchesView(node, view) ? [node] : []; }); } // ─── Create Menu (unified + button) ──────────────────────────────────────── type CreateMenuView = | "menu" | "file" | "skill" | "skill-upload" | "job" | "agent-mode" | "agent-prompt" | "agent-form"; const AGENT_MODEL_OPTIONS = [ { value: "inherit", label: "Default model" }, { value: "claude-fable-5", label: "Claude Fable 5" }, { value: "claude-opus-4-8", label: "Claude Opus 4.8" }, { value: CLAUDE_SONNET_MODEL_ID, label: CLAUDE_SONNET_MODEL_LABEL }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ] as const; function slugifyName(value: string): string { return ( value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || "agent" ); } function isLocalWorkspaceResource(resource: Resource | null | undefined) { if (!resource?.metadata) return false; try { const metadata = JSON.parse(resource.metadata) as { source?: unknown }; return metadata.source === LOCAL_WORKSPACE_RESOURCE_METADATA_SOURCE; } catch { return false; } } function buildAgentResourceContent({ name, description, model, tools, body, }: { name: string; description: string; model: string; tools: string; body: string; }): string { const fields = [ { key: "name", value: name }, { key: "description", value: description }, { key: "model", value: model }, { key: "tools", value: tools }, { key: "delegate-default", value: "false" }, ]; return serializeFrontmatter(fields) + body.trim() + "\n"; } function CreateMenu({ scope, resourceFilter, onCreateFile, onCreateResource, onCreateMcpServer, canCreateOrgMcp, hasOrg, onCreated, showToast, mcpIntegrations, triggerVariant = "icon", triggerLabel, initialView = "menu", }: { scope: ResourceScope; resourceFilter?: ResourceView; onCreateFile: (name: string) => void; onCreateResource: ( path: string, content: string, mimeType?: string, opts?: { onSuccess?: (resource: ResourceMeta) => void; onError?: (err: unknown) => void; }, ) => void; onCreateMcpServer: (args: { scope: McpServerScope; name: string; url: string; headers?: Record; description?: string; }) => Promise; canCreateOrgMcp: boolean; hasOrg: boolean; onCreated?: () => void; mcpIntegrations?: DefaultMcpIntegration[]; showToast?: ( kind: "ok" | "err", message: string, opts?: { resourceId?: string; durationMs?: number }, ) => void; triggerVariant?: "icon" | "outline"; triggerLabel?: string; initialView?: CreateMenuView; }) { const t = useT(); const [open, setOpen] = useState(false); const [mcpDialogOpen, setMcpDialogOpen] = useState(false); const [view, setView] = useState("menu"); const showMcpIntegrations = useMemo( () => mcpIntegrations ? mcpIntegrations.length > 0 : isMcpIntegrationCatalogAvailable(), [mcpIntegrations], ); const [value, setValue] = useState(""); const [agentName, setAgentName] = useState(""); const [agentDescription, setAgentDescription] = useState(""); const [agentModel, setAgentModel] = useState("inherit"); const [agentInstructions, setAgentInstructions] = useState( `# Role\n\nDefine how this agent should work.\n\n## Focus\n\n- What kinds of tasks it should handle\n- What tone or approach it should use\n- Important constraints or preferences\n`, ); const defaultMcpScope: McpServerScope = scope === "shared" && canCreateOrgMcp ? "org" : "user"; const inputRef = useRef(null); const skillFileInputRef = useRef(null); const skillHoverTimerRef = useRef(null); const [skillFlyoutOpen, setSkillFlyoutOpen] = useState(false); const [skillFlyoutSide, setSkillFlyoutSide] = useState<"right" | "left">( "right", ); const skillFlyoutCloseTimerRef = useRef(null); const openSkillFlyout = (rowEl?: HTMLElement | null) => { if (skillFlyoutCloseTimerRef.current) { window.clearTimeout(skillFlyoutCloseTimerRef.current); skillFlyoutCloseTimerRef.current = null; } if ( rowEl && typeof window !== "undefined" && typeof rowEl.getBoundingClientRect === "function" ) { const rect = rowEl.getBoundingClientRect(); const FLYOUT_WIDTH = 248; setSkillFlyoutSide( window.innerWidth - rect.right < FLYOUT_WIDTH ? "left" : "right", ); } setSkillFlyoutOpen(true); }; const scheduleSkillFlyoutClose = () => { if (skillFlyoutCloseTimerRef.current) window.clearTimeout(skillFlyoutCloseTimerRef.current); skillFlyoutCloseTimerRef.current = window.setTimeout(() => { setSkillFlyoutOpen(false); }, 160); }; const [skillUploadSlug, setSkillUploadSlug] = useState(""); const [skillUploadContent, setSkillUploadContent] = useState(""); const [skillUploadFileName, setSkillUploadFileName] = useState(""); useEffect(() => { if (open) { setView(initialView); setValue(""); setAgentName(""); setAgentDescription(""); setAgentModel("inherit"); setAgentInstructions( `# Role\n\nDefine how this agent should work.\n\n## Focus\n\n- What kinds of tasks it should handle\n- What tone or approach it should use\n- Important constraints or preferences\n`, ); setSkillUploadSlug(""); setSkillUploadContent(""); setSkillUploadFileName(""); setSkillFlyoutOpen(false); } }, [initialView, open]); useEffect(() => { if (view !== "menu" && view !== "agent-form") { setValue(""); const t = setTimeout(() => inputRef.current?.focus(), 50); return () => clearTimeout(t); } }, [view]); const submitFile = () => { const normalizedName = normalizeResourceFileName(value); if (normalizedName) { onCreateFile(normalizedName); setOpen(false); } }; const submitSkill = (text: string = value) => { const trimmed = text.trim(); if (!trimmed) return; sendToAgentChat({ message: `Create a skill: ${trimmed}`, newTab: true, context: `The user wants to create an agent skill. Their description: "${trimmed}" Follow the create-skill pattern to build this. Before writing: 1. **Determine the skill name** — derive a hyphen-case name from the description (e.g. "code review" → "code-review") 2. **Determine the skill type** — Pattern (architectural rule), Workflow (step-by-step), or Generator (scaffolding) 3. **Write the skill** as a ${scope} resource at path "skills//SKILL.md" using the \`resources\` tool with \`action: "write"\` The skill file MUST have YAML frontmatter with name and description (under 40 words), then markdown with: - Clear rule/purpose statement - Why this skill exists - How to follow it (with code examples where helpful) - Common violations to avoid - Related skills Template for a Pattern skill: \`\`\`markdown --- name: description: >- --- # ## Rule ## Why ## How ## Don't \`\`\` Template for a Workflow skill: \`\`\`markdown --- name: description: >- --- # ## Prerequisites ## Steps ## Verification \`\`\` After creating, update the shared AGENTS.md resource to reference the new skill in its skills table. Keep the skill concise (under 500 lines) and actionable.`, submit: true, }); setOpen(false); onCreated?.(); }; const handleUploadSkillFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; const text = await file.text(); const baseName = file.name.replace(/\.[^./]+$/, ""); const slug = slugifyName( baseName.toLowerCase() === "skill" ? "uploaded-skill" : baseName, ); setSkillUploadSlug(slug); setSkillUploadContent(text); setSkillUploadFileName(file.name); setView("skill-upload"); }; const saveUploadedSkill = () => { const slug = slugifyName(skillUploadSlug || "uploaded-skill"); const path = `skills/${slug}/SKILL.md`; const fileLabel = skillUploadFileName || `${slug}/SKILL.md`; onCreateResource(path, skillUploadContent, "text/markdown", { onSuccess: (resource) => { showToast?.("ok", `Skill "${fileLabel}" added`, { resourceId: resource.id, }); }, onError: (err) => { const msg = err instanceof Error && err.message ? err.message : "Failed to save skill file"; showToast?.("err", msg); }, }); setOpen(false); onCreated?.(); }; const submitJob = (text: string = value) => { const trimmed = text.trim(); if (!trimmed) return; sendToAgentChat({ message: `Create a recurring job: ${trimmed}`, newTab: true, context: `The user wants to create a recurring job. Their description: "${trimmed}" Use the manage-jobs tool with action "create" to create this. You need to: 1. Derive a hyphen-case name from the description 2. Convert the schedule to a cron expression (e.g., "every weekday at 9am" → "0 9 * * 1-5") 3. Write clear, self-contained instructions for what the agent should do each time the job runs 4. Create it in ${scope} scope The job will run automatically on the schedule. Make the instructions specific — include which actions to call and what to do with results.`, submit: true, }); setOpen(false); }; const submitAgentPrompt = (text: string = value) => { const trimmed = text.trim(); if (!trimmed) return; sendToAgentChat({ message: `Create a custom agent: ${trimmed}`, newTab: true, context: `The user wants a reusable custom sub-agent profile for the workspace. Their description: "${trimmed}" Create it as a ${scope} resource under "agents/.md" using the \`resources\` tool with \`action: "write"\`. Requirements: 1. Derive a hyphen-case file name from the intent 2. Use YAML frontmatter with: - name - description - model (use "inherit" unless the request clearly needs a different model) - tools (set to "inherit") - delegate-default (set to false) 3. Put the main operating instructions in the markdown body 4. Keep it concise and directive, similar to a Claude Code-style custom agent Template: \`\`\`markdown --- name: Design description: >- Helps with product and interface design decisions. model: inherit tools: inherit delegate-default: false --- # Role You are a focused design agent. ## Responsibilities - ... ## Approach - ... \`\`\` The result should be a reusable agent profile, not a one-off task response.`, submit: true, }); setOpen(false); onCreated?.(); }; const submitAgentManual = () => { const trimmedName = agentName.trim(); const trimmedDescription = agentDescription.trim(); const trimmedInstructions = agentInstructions.trim(); if (!trimmedName || !trimmedDescription || !trimmedInstructions) return; const slug = slugifyName(trimmedName); onCreateResource( `agents/${slug}.md`, buildAgentResourceContent({ name: trimmedName, description: trimmedDescription, model: agentModel, tools: "inherit", body: trimmedInstructions, }), "text/markdown", ); setOpen(false); onCreated?.(); }; const menuItems: { icon: React.ReactNode; label: string; desc: string; action: () => void; hoverAction?: () => void; }[] = [ { icon: , label: "Create File", desc: t("agentResources.createFile.menuDescription"), action: () => setView("file"), }, { icon: , label: "Create Skill", desc: "Teach the agent a new ability", action: () => openSkillFlyout(), hoverAction: openSkillFlyout, }, { icon: , label: "Schedule Task", desc: "Run something on a schedule", action: () => setView("job"), }, { icon: , label: "Create Custom Agent", desc: "Add a reusable sub-agent profile", action: () => setView("agent-mode"), }, { icon: , label: "Create Automation", desc: "Set up a when-X-do-Y rule", action: () => { setOpen(false); window.dispatchEvent( new CustomEvent("agent-panel:set-mode", { detail: { mode: "chat" }, }), ); sendToAgentChat({ message: "Help me create a new automation. Ask me what I want to automate.", context: `The user wants to create a new automation. Scope: personal. Use manage-automations with action=define to create it. Ask clarifying questions if needed about what event to trigger on, conditions, and what actions to take.`, submit: true, newTab: true, }); onCreated?.(); }, }, ...(showMcpIntegrations ? [ { icon: , label: t("mcpIntegrations.menuLabel"), desc: t("mcpIntegrations.menuDescription"), action: () => { setOpen(false); setMcpDialogOpen(true); }, }, ] : []), ]; const visibleMenuItems = menuItems.filter((item) => { if (!resourceFilter || resourceFilter === "files") return true; if (resourceFilter === "agents") return item.label === "Create Custom Agent"; if (resourceFilter === "skills") return item.label === "Create Skill"; return false; }); return ( <> { handleUploadSkillFiles(e.target.files); e.target.value = ""; }} /> {triggerVariant === "outline" ? ( ) : ( Create new... )} {view === "menu" && (
{visibleMenuItems.map((item) => { const isSkill = item.label === "Create Skill"; return (
{ if (isSkill) { openSkillFlyout(e.currentTarget); return; } if (!item.hoverAction) return; if (skillHoverTimerRef.current) window.clearTimeout(skillHoverTimerRef.current); skillHoverTimerRef.current = window.setTimeout(() => { item.hoverAction?.(); }, 180); }} onMouseLeave={() => { if (isSkill) { scheduleSkillFlyoutClose(); return; } if (skillHoverTimerRef.current) { window.clearTimeout(skillHoverTimerRef.current); skillHoverTimerRef.current = null; } }} > {isSkill && skillFlyoutOpen && (
openSkillFlyout()} onMouseLeave={scheduleSkillFlyoutClose} className={cn( "absolute top-0 z-20 w-[240px] rounded-lg border border-border bg-popover py-1 shadow-md", skillFlyoutSide === "right" ? "left-full ml-1" : "right-full mr-1", )} >
)}
); })}
)} {view === "file" && (
} value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitFile(); if (e.key === "Escape") { e.stopPropagation(); setView("menu"); } }} className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-[13px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent" placeholder={t("agentResources.createFile.namePlaceholder")} />
)} {view === "skill" && (

Describe what kind of skill you want and the agent will create it.

submitSkill(text)} />
)} {view === "skill-upload" && (

Review the content from{" "} {skillUploadFileName || "the selected file"} {" "} before saving.

setSkillUploadSlug(e.target.value)} className="mb-2 w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent" placeholder="my-skill" />

Will be saved at{" "} skills/{slugifyName(skillUploadSlug || "uploaded-skill")} /SKILL.md