From 221139ec42bdc3288e04a7578583da195ffb25ab Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:31:16 +0530 Subject: [PATCH] feat(composer-add-menu-drawer): implement mobile drawer for adding connectors and managing tools --- .../assistant-ui/composer-add-menu-drawer.tsx | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx diff --git a/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx new file mode 100644 index 000000000..182639911 --- /dev/null +++ b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { + ChevronLeft, + ChevronRight, + LayoutGrid, + Settings2, + Unplug, + Upload, + Wrench, +} from "lucide-react"; +import Image from "next/image"; +import { type ReactNode, useCallback, useRef, useState } from "react"; +import type { + ConnectorTypeRow, +} from "@/components/assistant-ui/connector-popup/constants/connector-constants"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { + CONNECTOR_TOOL_ICON_PATHS, + getToolDisplayName, + getToolIcon, +} from "@/contracts/enums/toolIcons"; +import { cn } from "@/lib/utils"; + +/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */ +export interface ToolGroupView { + label: string; + tools: { name: string }[]; + connectorIcon?: string; +} + +interface ComposerAddMenuDrawerProps { + /** The `+` button; rendered as the drawer trigger. */ + trigger: ReactNode; + onUploadFiles: () => void; + /** Connected connectors, one row per type (see `groupConnectorsByType`). */ + connectorRows: ConnectorTypeRow[]; + /** Open a connector's manage view (deep-links via importConnectorRequestAtom). */ + onSelectConnector: (row: ConnectorTypeRow) => void; + /** Navigate to the full connectors catalog. */ + onBrowseConnectors: () => void; + regularToolGroups: ToolGroupView[]; + connectorToolGroups: ToolGroupView[]; + otherToolGroup?: ToolGroupView; + disabledToolsSet: Set; + onToggleTool: (name: string) => void; + onToggleToolGroup: (names: string[]) => void; + /** True while the tool list is still loading (shows a skeleton). */ + toolsLoading: boolean; +} + +/** + * Mobile "+" menu. A single vaul drawer that behaves like a flat list at the + * root and drills into submenus in place (each level replaces the previous, + * with a back button) rather than nesting overlays — the touch-friendly + * equivalent of the desktop dropdown submenus. Screen state is a small push/pop + * stack; closing the drawer resets it to the root. + */ +type Screen = + | { kind: "root" } + | { kind: "connectors" } + | { kind: "tools" } + | { kind: "toolGroup"; label: string }; + +const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"; + +export function ComposerAddMenuDrawer({ + trigger, + onUploadFiles, + connectorRows, + onSelectConnector, + onBrowseConnectors, + regularToolGroups, + connectorToolGroups, + otherToolGroup, + disabledToolsSet, + onToggleTool, + onToggleToolGroup, + toolsLoading, +}: ComposerAddMenuDrawerProps) { + const [open, setOpen] = useState(false); + const [stack, setStack] = useState([{ kind: "root" }]); + // Slide direction: forward on push, back on pop — drives the enter animation. + const dirRef = useRef<"forward" | "back">("forward"); + const current = stack[stack.length - 1]; + + const push = useCallback((screen: Screen) => { + dirRef.current = "forward"; + setStack((prev) => [...prev, screen]); + }, []); + const pop = useCallback(() => { + dirRef.current = "back"; + setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)); + }, []); + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next); + // Reset to root when closed so the next open starts fresh. + if (!next) { + dirRef.current = "forward"; + setStack([{ kind: "root" }]); + } + }, []); + + const close = useCallback(() => handleOpenChange(false), [handleOpenChange]); + + const title = + current.kind === "connectors" + ? "MCP Connectors" + : current.kind === "tools" + ? "Manage Tools" + : current.kind === "toolGroup" + ? current.label + : "Add"; + + const renderToolRow = (name: string) => { + const isDisabled = disabledToolsSet.has(name); + const ToolIcon = getToolIcon(name); + return ( +
+ + {getToolDisplayName(name)} + onToggleTool(name)} + className="shrink-0" + /> +
+ ); + }; + + const renderBody = () => { + if (current.kind === "root") { + return ( + <> + + + + + ); + } + + if (current.kind === "connectors") { + return ( + <> + {connectorRows.length === 0 ? ( +

+ No connectors yet. +

+ ) : ( + connectorRows.map((row) => ( + + )) + )} + + + + ); + } + + if (current.kind === "toolGroup") { + const group = connectorToolGroups.find((g) => g.label === current.label); + return <>{group?.tools.map((t) => renderToolRow(t.name))}; + } + + // current.kind === "tools" + if (toolsLoading) { + return ( +
+ + {["t1", "t2", "t3", "t4"].map((k) => ( +
+ + + +
+ ))} +
+ ); + } + + return ( + <> + {regularToolGroups.map((group) => ( +
+
+ {group.label} +
+ {group.tools.map((t) => renderToolRow(t.name))} +
+ ))} + {connectorToolGroups.length > 0 && ( +
+
+ Connector Actions +
+ {connectorToolGroups.map((group) => { + const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""]; + const toolNames = group.tools.map((t) => t.name); + const allDisabled = toolNames.every((n) => disabledToolsSet.has(n)); + return ( +
+ + onToggleToolGroup(toolNames)} + className="shrink-0" + /> +
+ ); + })} +
+ )} + {otherToolGroup && ( +
+
+ {otherToolGroup.label} +
+ {otherToolGroup.tools.map((t) => renderToolRow(t.name))} +
+ )} + + ); + }; + + return ( + + {trigger} + + + + {stack.length > 1 ? ( + + ) : ( + + )} + {title} + + +
+
+ {renderBody()} +
+
+
+
+ ); +}