import { useComposerRuntime } from "@assistant-ui/react"; import { IconPlus, IconUpload, IconBulb, IconClock, IconBolt, IconTool, IconPlugConnected, IconPhotoPlus, IconLoader2, IconArrowLeft, IconX, } from "@tabler/icons-react"; import React, { useState, useRef, useEffect, useMemo, useCallback, } from "react"; import { createPortal } from "react-dom"; import { Popover, PopoverTrigger, PopoverContent } from "../ui/popover.js"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js"; import { cn } from "../utils.js"; import { createAssetPickerHandoffId, isExternalAssetPickerUrl, standaloneAssetPickerUrl, } from "./asset-picker-url.js"; import { useComposerRuntimeAdapters } from "./runtime-adapters.js"; import type { ComposerMode } from "./types.js"; interface ComposerPlusMenuProps { onSelectMode?: (mode: ComposerMode) => void; onAttachmentError?: (message: string) => void; /** * "full" (default): full + menu with Upload File, Create Skill, Schedule Task, * Automation, Extension, MCP Server. "upload-only": clicking + opens the file * picker directly — no popover, no other modes. Use for prompt popovers * (create extension, create deck, create dashboard, etc.) where the only thing * to attach is a file. */ mode?: "full" | "upload-only"; } type View = "menu" | "skill-upload"; const DEFAULT_ASSETS_PICKER_URL = "https://assets.agent-native.com/picker"; const EMBED_PROTOCOL = "agent-native.embed"; const EMBED_VERSION = 1; interface EmbedEnvelope { protocol?: string; version?: number; type?: string; name?: string; payload?: TPayload; } interface AssetPickerPayload { assetId?: unknown; handoffId?: unknown; url?: unknown; previewUrl?: unknown; downloadUrl?: unknown; embedUrl?: unknown; altText?: unknown; title?: unknown; prompt?: unknown; mediaType?: unknown; libraryId?: unknown; } function assetPickerUrl() { const env = (import.meta as ImportMeta & { env?: Record }) .env ?? {}; return env.VITE_AGENT_NATIVE_ASSETS_PICKER_URL || DEFAULT_ASSETS_PICKER_URL; } function withEmbeddedParams(url: string): string { try { const parsed = new URL(url, window.location.href); parsed.searchParams.set("embedded", "1"); parsed.searchParams.set("mediaType", "image"); return parsed.toString(); } catch { const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}embedded=1&mediaType=image`; } } function assetPickerOrigin(url: string): string | null { try { return new URL(url, window.location.href).origin; } catch { return null; } } function embedEnvelope( type: "message" | "ready", options: { name?: string; payload?: unknown } = {}, ): EmbedEnvelope { return { protocol: EMBED_PROTOCOL, version: EMBED_VERSION, type, ...options, }; } function isEmbedEnvelope(value: unknown): value is EmbedEnvelope { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const candidate = value as EmbedEnvelope; return ( candidate.protocol === EMBED_PROTOCOL && candidate.version === EMBED_VERSION && typeof candidate.type === "string" ); } function assetString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function assetImageSource(payload: unknown): string | null { if (!payload || typeof payload !== "object") return null; const asset = payload as AssetPickerPayload; return ( assetString(asset.url) ?? assetString(asset.previewUrl) ?? assetString(asset.downloadUrl) ?? assetString(asset.embedUrl) ); } function assetTitle(payload: unknown, url: string): string { if (payload && typeof payload === "object") { const title = assetString((payload as AssetPickerPayload).title); if (title) return title; const prompt = assetString((payload as AssetPickerPayload).prompt); if (prompt) return prompt.slice(0, 80); } try { const name = new URL(url).pathname.split("/").filter(Boolean).pop(); return name ? decodeURIComponent(name) : "Generated image"; } catch { return "Generated image"; } } function assetContext(payload: unknown, url: string): string { const lines = [`Image URL: ${url}`]; if (payload && typeof payload === "object") { const asset = payload as AssetPickerPayload; const assetId = assetString(asset.assetId); const libraryId = assetString(asset.libraryId); const prompt = assetString(asset.prompt); const altText = assetString(asset.altText); if (assetId) lines.push(`Asset ID: ${assetId}`); if (libraryId) lines.push(`Library ID: ${libraryId}`); if (prompt) lines.push(`Prompt: ${prompt}`); if (altText) lines.push(`Alt text: ${altText}`); } return lines.join("\n"); } function slugifyName(value: string): string { return ( value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || "uploaded-skill" ); } function formatAttachmentError(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } function UploadOnlyAttachButton({ onAttachmentError, }: Pick) { const composerRuntime = useComposerRuntime(); const inputRef = useRef(null); const handleFilesSelected = async (files: FileList | null) => { if (!files || files.length === 0) return; try { await Promise.all( Array.from(files).map((file) => composerRuntime.addAttachment(file)), ); } catch (error) { onAttachmentError?.( formatAttachmentError(error, "Could not upload the selected file."), ); } }; return ( <> { void handleFilesSelected(event.target.files); event.target.value = ""; }} /> Upload ); } export function ComposerPlusMenu({ onSelectMode, onAttachmentError, mode = "full", }: ComposerPlusMenuProps) { if (mode === "upload-only") { return ; } return ( ); } function ComposerPlusMenuFull({ onSelectMode, onAttachmentError, }: Pick) { const adapters = useComposerRuntimeAdapters(); const t = adapters.translate!; const resources = adapters.resources!; const composerRuntime = useComposerRuntime(); const [open, setOpen] = useState(false); const [assetsPickerOpen, setAssetsPickerOpen] = useState(false); const [mcpDialogOpen, setMcpDialogOpen] = useState(false); const [view, setView] = useState("menu"); const showMcpIntegrations = useMemo( () => resources.isMcpIntegrationAvailable!(), [resources], ); const { data: org } = resources.useOrg!(); const canCreateOrgMcp = !org?.orgId || org.role === "owner" || org.role === "admin"; const hasOrg = !!org?.orgId; const defaultMcpScope: "org" | "user" = hasOrg && canCreateOrgMcp ? "org" : "user"; const createMcp = resources.useCreateMcpServer!(); const McpIntegrationDialog = resources.McpIntegrationDialog; const fileUploadRef = useRef(null); const skillFileInputRef = useRef(null); const skillHoverTimerRef = useRef(null); const [skillUploadSlug, setSkillUploadSlug] = useState(""); const [skillUploadContent, setSkillUploadContent] = useState(""); const [skillUploadFileName, setSkillUploadFileName] = useState(""); const [skillUploadStatus, setSkillUploadStatus] = useState<{ kind: "ok" | "err"; message: string; } | null>(null); const [skillUploadBusy, setSkillUploadBusy] = useState(false); 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") { 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); }; useEffect(() => { if (open) { setView("menu"); setSkillUploadSlug(""); setSkillUploadContent(""); setSkillUploadFileName(""); setSkillUploadStatus(null); setSkillUploadBusy(false); setSkillFlyoutOpen(false); } }, [open]); const handleSkillFileSelected = 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); setSkillUploadStatus(null); setView("skill-upload"); }; const handleFilesSelected = async (files: FileList | null) => { if (!files || files.length === 0) return; try { await Promise.all( Array.from(files).map((file) => composerRuntime.addAttachment(file)), ); } catch (error) { onAttachmentError?.( formatAttachmentError(error, "Could not upload the selected file."), ); } }; const submitSkillUpload = async () => { if (skillUploadBusy) return; const slug = slugifyName(skillUploadSlug || "uploaded-skill"); const path = `skills/${slug}/SKILL.md`; setSkillUploadBusy(true); setSkillUploadStatus(null); try { const res = await fetch( adapters.resolvePath!("/_agent-native/resources"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, content: skillUploadContent, mimeType: "text/markdown", shared: false, }), }, ); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(body || `Upload failed (${res.status})`); } setSkillUploadStatus({ kind: "ok", message: `Skill "${skillUploadFileName || `${slug}/SKILL.md`}" added`, }); window.setTimeout(() => setOpen(false), 1200); } catch (err: any) { setSkillUploadStatus({ kind: "err", message: err?.message || "Failed to save skill file", }); } finally { setSkillUploadBusy(false); } }; const menuItems: { icon: React.ReactNode; label: string; desc: string; action: () => void; hoverAction?: () => void; }[] = [ { icon: , label: "Upload File", desc: "Images, PDFs, text/code, JSON, CSV", action: () => { setOpen(false); setTimeout(() => fileUploadRef.current?.click(), 0); }, }, { icon: , label: "Generate Image", desc: "Open the Assets image picker", action: () => { setOpen(false); setAssetsPickerOpen(true); }, }, { icon: , label: "Schedule Task", desc: "Run something on a schedule", action: () => { onSelectMode?.("job"); setOpen(false); }, }, { icon: , label: "Create Automation", desc: "Set up a when-X-do-Y rule", action: () => { onSelectMode?.("automation"); setOpen(false); }, }, { icon: , label: "Create Extension", desc: "Build a mini app extension", action: () => { onSelectMode?.("extension"); setOpen(false); }, }, ...(showMcpIntegrations ? [ { icon: , label: t("mcpIntegrations.menuLabel"), desc: t("mcpIntegrations.menuDescription"), action: () => { setOpen(false); setMcpDialogOpen(true); }, }, ] : []), { icon: , label: "Create Skill", desc: "Teach the agent a new ability", action: openSkillFlyout, hoverAction: openSkillFlyout, }, ]; return ( <> {/* Hidden input to trigger the native file upload with explicit errors. */} { void handleFilesSelected(event.target.files); event.target.value = ""; }} /> { void handleSkillFileSelected(e.target.files); e.target.value = ""; }} /> Add... e.preventDefault()} > {view === "menu" && (
{menuItems.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 === "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" />

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