refactor: replace Popover with DropdownMenu in ComposerAction and clean up unused state management

This commit is contained in:
Anish Sarkar 2026-05-17 00:57:35 +05:30
parent f65bc81509
commit b52e578021
2 changed files with 129 additions and 172 deletions

View file

@ -14,7 +14,6 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Clipboard, Clipboard,
Dot,
Globe, Globe,
Plus, Plus,
Settings2, Settings2,
@ -83,12 +82,14 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { import {
CONNECTOR_ICON_TO_TYPES, CONNECTOR_ICON_TO_TYPES,
@ -776,24 +777,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false); const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 640px)"); const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top");
const toolsRafRef = useRef<number | undefined>(undefined);
const handleToolsScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
if (toolsRafRef.current) return;
toolsRafRef.current = requestAnimationFrame(() => {
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
toolsRafRef.current = undefined;
});
}, []);
useEffect(
() => () => {
if (toolsRafRef.current) cancelAnimationFrame(toolsRafRef.current);
},
[]
);
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom); const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
const setPendingScreenImages = useSetAtom(pendingUserImageDataUrlsAtom); const setPendingScreenImages = useSetAtom(pendingUserImageDataUrlsAtom);
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
@ -943,10 +926,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
Manage Tools Manage Tools
</DrawerTitle> </DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div <div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6"
onScroll={handleToolsScroll}
>
{groupedTools {groupedTools
.filter((g) => !g.connectorIcon) .filter((g) => !g.connectorIcon)
.map((group) => ( .map((group) => (
@ -1043,153 +1023,143 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</Drawer> </Drawer>
</> </>
) : ( ) : (
<Popover open={toolsPopoverOpen} onOpenChange={setToolsPopoverOpen}> <DropdownMenu onOpenChange={(open) => !open && setToolsPopoverOpen(false)}>
<PopoverTrigger asChild> <DropdownMenuTrigger asChild>
<TooltipIconButton <TooltipIconButton
tooltip="Manage tools" tooltip="More actions"
side="bottom" side="bottom"
disableTooltip={toolsPopoverOpen} disableTooltip={toolsPopoverOpen}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-[34px] rounded-full p-1 font-semibold text-xs dark:border-muted-foreground/15 hover:bg-accent hover:text-accent-foreground" className="size-[34px] rounded-full p-1 font-semibold text-xs dark:border-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
aria-label="Manage tools" aria-label="More actions"
data-joyride="connector-icon" data-joyride="connector-icon"
> >
<Settings2 className="size-4" /> <Plus className="size-4" />
</TooltipIconButton> </TooltipIconButton>
</PopoverTrigger> </DropdownMenuTrigger>
<PopoverContent <DropdownMenuContent className="w-48" side="bottom" align="start" sideOffset={8}>
side="bottom" <DropdownMenuItem onSelect={() => openUploadDialog()}>
align="start" <Upload className="h-4 w-4" />
sideOffset={12} Upload Files
className="w-[calc(100vw-2rem)] max-w-48 sm:max-w-56 sm:w-56 p-0 select-none" </DropdownMenuItem>
onOpenAutoFocus={(e) => e.preventDefault()} <DropdownMenuItem onSelect={() => void handleScreenCapture()}>
> <Camera className="h-4 w-4" />
<div className="sr-only">Manage Tools</div> Take a screenshot
<div </DropdownMenuItem>
className="max-h-44 sm:max-h-56 overflow-y-auto overscroll-none py-0.5" <DropdownMenuSub open={toolsPopoverOpen} onOpenChange={setToolsPopoverOpen}>
onScroll={handleToolsScroll} <DropdownMenuSubTrigger>
style={{ <Settings2 className="h-4 w-4" />
maskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`, Manage Tools
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`, </DropdownMenuSubTrigger>
}} <DropdownMenuPortal>
> <DropdownMenuSubContent
{groupedTools alignOffset={-192}
.filter((g) => !g.connectorIcon) collisionPadding={8}
.map((group) => ( className="w-60 h-56 gap-1 overflow-y-auto overscroll-none"
<div key={group.label}> >
<div className="px-2 sm:px-2.5 pt-1.5 pb-0.5 text-[9px] sm:text-[10px] text-muted-foreground/80 font-normal select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-1.5 sm:gap-2 px-2 sm:px-2.5 py-0.5 sm:py-1 hover:bg-accent hover:text-accent-foreground transition-colors">
<ToolIcon className="size-3 sm:size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-[11px] sm:text-xs font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0 scale-50 sm:scale-[0.6]"
/>
</div>
);
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-64 text-xs">
{tool.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
{groupedTools.some((g) => g.connectorIcon) && (
<div>
<div className="px-2 sm:px-2.5 pt-1.5 pb-0.5 text-[9px] sm:text-[10px] text-muted-foreground/80 font-normal select-none">
Connector Actions
</div>
{groupedTools {groupedTools
.filter((g) => g.connectorIcon) .filter((g) => !g.connectorIcon)
.map((group) => { .map((group) => (
const iconKey = group.connectorIcon ?? ""; <div key={group.label}>
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey]; <div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
const toolNames = group.tools.map((t) => t.name); {group.label}
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
const row = (
<div className="flex w-full items-center gap-1.5 sm:gap-2 px-2 sm:px-2.5 py-0.5 sm:py-1 hover:bg-accent hover:text-accent-foreground transition-colors">
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={14}
height={14}
className="size-3 sm:size-3.5 shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-3 sm:size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 text-[11px] sm:text-xs font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0 scale-50 sm:scale-[0.6]"
/>
</div> </div>
); {group.tools.map((tool) => {
return ( const isDisabled = disabledToolsSet.has(tool.name);
<Tooltip key={group.label}> const ToolIcon = getToolIcon(tool.name);
<TooltipTrigger asChild>{row}</TooltipTrigger> return (
<TooltipContent side="right" className="max-w-72 text-xs"> <DropdownMenuItem
{groupDef?.tooltip ?? key={tool.name}
group.tools.flatMap((t, i) => onSelect={(e) => {
i === 0 e.preventDefault();
? [t.description] toggleTool(tool.name);
: [ }}
<Dot className={cn(
key={`dot-${group.label}-${t.description}`} "mb-1 last:mb-0 transition-all",
className="inline h-4 w-4" "hover:bg-accent hover:text-accent-foreground",
/>, !isDisabled && "text-primary"
t.description,
]
)} )}
</TooltipContent> >
</Tooltip> <ToolIcon className="h-4 w-4" />
); <span className="flex-1 min-w-0 truncate">
})} {formatToolName(tool.name)}
</div> </span>
)} <Switch
{!filteredTools?.length && ( checked={!isDisabled}
<div className="px-2 sm:px-2.5 pt-1.5 pb-1"> tabIndex={-1}
<Skeleton className="h-2 w-12 mb-1.5" /> className="pointer-events-none shrink-0 scale-[0.6]"
{["dt1", "dt2", "dt3", "dt4"].map((k) => ( />
<div key={k} className="flex items-center gap-1.5 sm:gap-2 py-0.5 sm:py-1"> </DropdownMenuItem>
<Skeleton className="size-3 sm:size-3.5 rounded shrink-0" /> );
<Skeleton className="h-2.5 sm:h-3 flex-1" /> })}
<Skeleton className="h-3.5 sm:h-4 w-7 sm:w-8 rounded-full shrink-0" /> </div>
))}
{groupedTools.some((g) => g.connectorIcon) && (
<div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
Connector Actions
</div>
{groupedTools
.filter((g) => g.connectorIcon)
.map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
return (
<DropdownMenuItem
key={group.label}
onSelect={(e) => {
e.preventDefault();
toggleToolGroup(toolNames);
}}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
!allDisabled && "text-primary"
)}
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={16}
height={16}
className="h-4 w-4 shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="h-4 w-4" />
)}
<span className="flex-1 min-w-0 truncate">{group.label}</span>
<Switch
checked={!allDisabled}
tabIndex={-1}
className="pointer-events-none shrink-0 scale-[0.6]"
/>
</DropdownMenuItem>
);
})}
</div> </div>
))} )}
<Skeleton className="h-2 w-20 mt-2 mb-1.5" /> {!filteredTools?.length && (
{["dc1", "dc2", "dc3"].map((k) => ( <div className="px-2 pt-1.5 pb-1">
<div key={k} className="flex items-center gap-1.5 sm:gap-2 py-0.5 sm:py-1"> <Skeleton className="h-2 w-12 mb-1.5" />
<Skeleton className="size-3 sm:size-3.5 rounded shrink-0" /> {["dt1", "dt2", "dt3", "dt4"].map((k) => (
<Skeleton className="h-2.5 sm:h-3 flex-1" /> <div key={k} className="flex items-center gap-2 py-1">
<Skeleton className="h-3.5 sm:h-4 w-7 sm:w-8 rounded-full shrink-0" /> <Skeleton className="h-4 w-4 rounded shrink-0" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-4 w-8 rounded-full shrink-0" />
</div>
))}
</div> </div>
))} )}
</div> </DropdownMenuSubContent>
)} </DropdownMenuPortal>
</div> </DropdownMenuSub>
</PopoverContent> </DropdownMenuContent>
</Popover> </DropdownMenu>
)} )}
{hasWebSearchTool && ( {hasWebSearchTool && (
<Button <Button
@ -1243,19 +1213,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isDesktop && (
<TooltipIconButton
tooltip="Capture screen"
type="button"
variant="ghost"
size="icon"
className="size-8 rounded-full"
aria-label="Capture screen"
onClick={() => void handleScreenCapture()}
>
<Camera className="size-4" />
</TooltipIconButton>
)}
<AuiIf condition={({ thread }) => !thread.isRunning}> <AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}> <ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton <TooltipIconButton

View file

@ -71,7 +71,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-neutral-800 text-white font-medium shadow-xl px-3 py-1.5 dark:bg-neutral-800 dark:text-white border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-pretty pointer-events-none select-none", "bg-neutral-800 text-white font-medium shadow-md shadow-neutral-950/40 px-3 py-1.5 dark:bg-neutral-800 dark:text-white border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-pretty pointer-events-none select-none",
className className
)} )}
{...props} {...props}