import { useComposerRuntime } from "@assistant-ui/react"; import { IconPlus, IconUpload, IconBulb, IconClock, IconBolt, IconTool, IconPlugConnected, IconPhotoPlus, IconLoader2, IconArrowLeft, IconX, IconHelpCircle, IconTerminal2, } 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 { Switch } from "../ui/switch.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"; export interface ComposerTerminalModeControl { enabled: boolean; onChange: (enabled: boolean) => void; onNewTerminal?: () => void; } interface ComposerPlusMenuProps { onSelectMode?: (mode: ComposerMode) => void; onAttachmentError?: (message: string) => void; /** * Show the "Create Extension" entry. Extensions are optional and hidden * unless the host explicitly enables their agent tool surface. */ extensionTools?: boolean; /** * "full" (default): full + menu with Upload File, Create Skill, Schedule Task, * Automation, and MCP Server. Extension is included only when * `extensionTools` is true. "upload-only": clicking + opens the file picker * directly — no popover, no other modes. Use for prompt popovers where the * only thing to attach is a file. "terminal": one new-terminal action plus * the Terminal mode switch. */ mode?: "full" | "upload-only" | "terminal"; terminalModeControl?: ComposerTerminalModeControl; } 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; export function isExtensionComposerMenuEnabled( extensionTools?: boolean, ): boolean { return extensionTools === true; } 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, generatedImageLabel: 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) : generatedImageLabel; } catch { return generatedImageLabel; } } 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 MenuItemHelp({ label, description, }: { label: string; description?: string; }) { const normalizedDescription = description?.trim(); if (!normalizedDescription) return null; return ( event.stopPropagation()} className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > {normalizedDescription} ); } function UploadOnlyAttachButton({ onAttachmentError, }: Pick) { const composerRuntime = useComposerRuntime(); const t = useComposerRuntimeAdapters().translate!; 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, t("agentChat.composer.uploadFailed", { defaultValue: "Could not upload the selected file.", }), ), ); } }; return ( <> { void handleFilesSelected(event.target.files); event.target.value = ""; }} /> {t("agentChat.composer.upload", { defaultValue: "Upload" })} ); } export function ComposerPlusMenu({ onSelectMode, onAttachmentError, extensionTools = false, mode = "full", terminalModeControl, }: ComposerPlusMenuProps) { if (mode === "upload-only") { return ; } if (mode === "terminal" && terminalModeControl) { return ( ); } return ( ); } function ComposerPlusMenuTerminal({ terminalModeControl, }: Pick) { const [open, setOpen] = useState(false); if (!terminalModeControl) return null; const handlePrimaryAction = () => { if (!terminalModeControl.enabled) { terminalModeControl.onChange(true); setOpen(false); return; } terminalModeControl.onNewTerminal?.(); setOpen(false); }; return ( <> event.preventDefault()} >
{"Terminal mode" /* i18n-ignore -- portable toolkit label */}
); } function ComposerPlusMenuFull({ onSelectMode, onAttachmentError, extensionTools, }: Pick< ComposerPlusMenuProps, "onSelectMode" | "onAttachmentError" | "extensionTools" >) { 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; // Composer connections belong to the person asking for them. Organization // sharing remains an explicit choice for owners and admins in the dialog. const defaultMcpScope: "user" = "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, t("agentChat.composer.uploadFailed", { defaultValue: "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 || t("agentChat.composer.skill.uploadFailedStatus", { status: res.status, defaultValue: `Upload failed (${res.status})`, }), ); } setSkillUploadStatus({ kind: "ok", message: t("agentChat.composer.skill.added", { name: skillUploadFileName || `${slug}/SKILL.md`, defaultValue: `Skill "${skillUploadFileName || `${slug}/SKILL.md`}" added`, }), }); window.setTimeout(() => setOpen(false), 1200); } catch (err: any) { setSkillUploadStatus({ kind: "err", message: err?.message || t("agentChat.composer.skill.saveFailed", { defaultValue: "Failed to save skill file", }), }); } finally { setSkillUploadBusy(false); } }; const menuItems: { icon: React.ReactNode; label: string; desc: string; action: () => void; hoverAction?: () => void; isSkill?: boolean; }[] = [ { icon: , label: t("agentChat.composer.menu.uploadFile", { defaultValue: "Upload File", }), desc: t("agentChat.composer.menu.uploadFileDescription", { defaultValue: "Images, PDFs, text/code, JSON, CSV", }), action: () => { setOpen(false); setTimeout(() => fileUploadRef.current?.click(), 0); }, }, { icon: , label: t("agentChat.composer.menu.generateImage", { defaultValue: "Generate Image", }), desc: t("agentChat.composer.menu.generateImageDescription", { defaultValue: "Open the Assets image picker", }), action: () => { setOpen(false); setAssetsPickerOpen(true); }, }, { icon: , label: t("agentChat.composer.menu.scheduleTask", { defaultValue: "Schedule Task", }), desc: t("agentChat.composer.menu.scheduleTaskDescription", { defaultValue: "Run something on a schedule", }), action: () => { onSelectMode?.("job"); setOpen(false); }, }, { icon: , label: t("agentChat.composer.menu.createAutomation", { defaultValue: "Create Automation", }), desc: t("agentChat.composer.menu.createAutomationDescription", { defaultValue: "Set up a when-X-do-Y rule", }), action: () => { onSelectMode?.("automation"); setOpen(false); }, }, ...(isExtensionComposerMenuEnabled(extensionTools) ? [ { icon: , label: t("agentChat.composer.menu.createExtension", { defaultValue: "Create Extension", }), desc: t("agentChat.composer.menu.createExtensionDescription", { defaultValue: "Build a mini app extension", }), action: () => { onSelectMode?.("extension"); setOpen(false); }, }, ] : []), ...(showMcpIntegrations ? [ { icon: , label: t("agentChat.composer.menu.integrations", { defaultValue: "Integrations", }), desc: t("agentChat.composer.menu.integrationsDescription", { defaultValue: "Connect tools and services to the agent", }), action: () => { setOpen(false); setMcpDialogOpen(true); }, }, ] : []), { icon: , label: t("agentChat.composer.menu.createSkill", { defaultValue: "Create Skill", }), desc: t("agentChat.composer.menu.createSkillDescription", { defaultValue: "Teach the agent a new ability", }), action: openSkillFlyout, hoverAction: openSkillFlyout, isSkill: true, }, ]; 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 = ""; }} /> {t("agentChat.composer.add", { defaultValue: "Add..." })} e.preventDefault()} > {view === "menu" && (
{menuItems.map((item) => { const isSkill = item.isSkill === true; 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 && ( )} {isSkill && skillFlyoutOpen && (
openSkillFlyout()} onMouseLeave={scheduleSkillFlyoutClose} className={cn( "absolute top-0 z-20 w-[240px] rounded-lg border border-input bg-muted py-1 text-foreground shadow-md", skillFlyoutSide === "right" ? "left-full ml-1" : "right-full mr-1", )} >
)}
); })}
)} {view === "skill-upload" && (

{t("agentChat.composer.skill.review", { name: skillUploadFileName || t("agentChat.composer.skill.selectedFile", { defaultValue: "the selected file", }), defaultValue: `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" />

{t("agentChat.composer.skill.savedAt", { defaultValue: "Saved at", })}{" "} skills/{slugifyName(skillUploadSlug || "uploaded-skill")} /SKILL.md