SurfSense/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx

347 lines
10 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import {
ChevronLeft,
ChevronRight,
LayoutGrid,
Settings2,
TriangleAlert,
Unplug,
Upload,
Wrench,
} from "lucide-react";
import Image from "next/image";
import { type ReactNode, useCallback, useRef, useState } from "react";
import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
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 { Spinner } from "@/components/ui/spinner";
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, with live indexing health (`useConnectorRows`). */
connectorRows: ConnectorRow[];
/** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
onSelectConnector: (row: ConnectorRow) => void;
/** Navigate to the full connectors catalog. */
onBrowseConnectors: () => void;
regularToolGroups: ToolGroupView[];
connectorToolGroups: ToolGroupView[];
otherToolGroup?: ToolGroupView;
disabledToolsSet: Set<string>;
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 };
2026-07-24 14:30:46 -07:00
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<Screen[]>([{ 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 (
<div key={name} className={ROW}>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{getToolDisplayName(name)}</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => onToggleTool(name)}
className="shrink-0"
/>
</div>
);
};
const renderBody = () => {
if (current.kind === "root") {
return (
<>
<button
type="button"
className={ROW}
onClick={() => {
onUploadFiles();
close();
}}
>
<Upload className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Upload Files</span>
</button>
2026-07-24 14:30:46 -07:00
<button type="button" className={ROW} onClick={() => push({ kind: "connectors" })}>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">MCP Connectors</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<button type="button" className={ROW} onClick={() => push({ kind: "tools" })}>
<Settings2 className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage Tools</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
</>
);
}
if (current.kind === "connectors") {
return (
<>
{connectorRows.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
No connectors yet.
</p>
) : (
connectorRows.map((row) => (
<button
type="button"
key={row.type}
className={ROW}
onClick={() => {
onSelectConnector(row);
close();
}}
>
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
<span className="min-w-0 flex-1 truncate text-left">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="size-4 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">{row.accountCount}</span>
) : null}
</button>
))
)}
<Separator className="my-1" />
<button
type="button"
className={ROW}
onClick={() => {
onBrowseConnectors();
close();
}}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage connectors</span>
</button>
</>
);
}
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 (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
);
}
return (
<>
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{group.label}
</div>
{group.tools.map((t) => renderToolRow(t.name))}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
Connector Actions
</div>
{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 (
<div key={group.label} className={ROW}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3"
onClick={() => push({ kind: "toolGroup", label: group.label })}
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-left font-medium">
{group.label}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<Switch
checked={!allDisabled}
onCheckedChange={() => onToggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((t) => renderToolRow(t.name))}
</div>
)}
</>
);
};
return (
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="flex flex-row items-center gap-1 px-2 pb-2 pt-1">
{stack.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
onClick={pop}
aria-label="Back"
>
<ChevronLeft className="size-5" />
</Button>
) : (
<span className="size-9 shrink-0" aria-hidden />
)}
<DrawerTitle className="flex-1 text-center text-base font-semibold">{title}</DrawerTitle>
<span className="size-9 shrink-0" aria-hidden />
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
<div
key={current.kind === "toolGroup" ? `toolGroup:${current.label}` : current.kind}
className={cn(
"animate-in fade-in-0 duration-200",
dirRef.current === "forward" ? "slide-in-from-right-4" : "slide-in-from-left-4"
)}
>
{renderBody()}
</div>
</div>
</DrawerContent>
</Drawer>
);
}