From 60d58b6386e4305c521fd465e5ff6c9bec582ff3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Apr 2026 09:45:17 -0700 Subject: [PATCH 1/5] feat(ui): allow multiselect in resource tabs --- .../add-resource-dropdown.tsx | 19 +-- .../resource-tabs/resource-tabs.tsx | 117 +++++++++++++++--- 2 files changed, 115 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 7345277cf6..b19a70f725 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -36,6 +36,8 @@ export interface AddResourceDropdownProps { existingKeys: Set onAdd: (resource: MothershipResource) => void onSwitch?: (resourceId: string) => void + /** Resource types to hide from the dropdown (e.g. `['folder', 'task']`). */ + excludeTypes?: readonly MothershipResourceType[] } export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown } @@ -47,7 +49,8 @@ interface AvailableItemsByType { export function useAvailableResources( workspaceId: string, - existingKeys: Set + existingKeys: Set, + excludeTypes?: readonly MothershipResourceType[] ): AvailableItemsByType[] { const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) @@ -56,8 +59,9 @@ export function useAvailableResources( const { data: folders = [] } = useFolders(workspaceId) const { data: tasks = [] } = useTasks(workspaceId) - return useMemo( - () => [ + return useMemo(() => { + const excluded = new Set(excludeTypes ?? []) + const groups: AvailableItemsByType[] = [ { type: 'workflow' as const, items: workflows.map((w) => ({ @@ -107,9 +111,9 @@ export function useAvailableResources( isOpen: existingKeys.has(`task:${t.id}`), })), }, - ], - [workflows, folders, tables, files, knowledgeBases, tasks, existingKeys] - ) + ] + return groups.filter((g) => !excluded.has(g.type)) + }, [workflows, folders, tables, files, knowledgeBases, tasks, existingKeys, excludeTypes]) } export function AddResourceDropdown({ @@ -117,11 +121,12 @@ export function AddResourceDropdown({ existingKeys, onAdd, onSwitch, + excludeTypes, }: AddResourceDropdownProps) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) - const available = useAvailableResources(workspaceId, existingKeys) + const available = useAvailableResources(workspaceId, existingKeys, excludeTypes) const handleOpenChange = useCallback((next: boolean) => { setOpen(next) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 2809070fde..dbb47675dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -10,7 +10,7 @@ import { import { Button, Tooltip } from '@/components/emcn' import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons' import { isEphemeralResource } from '@/lib/copilot/resource-extraction' -import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { cn } from '@/lib/core/utils/cn' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' @@ -38,6 +38,44 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' const EDGE_ZONE = 40 const SCROLL_SPEED = 8 +const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const + +/** + * Builds an offscreen drag image showing all selected tabs side-by-side, so the + * cursor visibly carries every tab in the multi-selection. The element is + * appended to the document and removed on the next tick after the browser has + * snapshotted it. + */ +function buildMultiDragImage( + scrollNode: HTMLElement | null, + selected: MothershipResource[] +): HTMLElement | null { + if (!scrollNode || selected.length === 0) return null + const container = document.createElement('div') + container.style.position = 'fixed' + container.style.top = '-10000px' + container.style.left = '-10000px' + container.style.display = 'flex' + container.style.alignItems = 'center' + container.style.gap = '6px' + container.style.padding = '4px' + container.style.pointerEvents = 'none' + let appendedAny = false + for (const r of selected) { + const original = scrollNode.querySelector( + `[data-resource-tab-id="${CSS.escape(r.id)}"]` + ) + if (!original) continue + const clone = original.cloneNode(true) as HTMLElement + clone.style.opacity = '0.95' + container.appendChild(clone) + appendedAny = true + } + if (!appendedAny) return null + document.body.appendChild(container) + return container +} + const PREVIEW_MODE_ICONS = { editor: Columns3, split: Eye, @@ -125,8 +163,10 @@ export function ResourceTabs({ const [hoveredTabId, setHoveredTabId] = useState(null) const [draggedIdx, setDraggedIdx] = useState(null) const [dropGapIdx, setDropGapIdx] = useState(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) const dragStartIdx = useRef(null) const autoScrollRaf = useRef(null) + const anchorIdRef = useRef(null) const existingKeys = useMemo( () => new Set(resources.map((r) => `${r.type}:${r.id}`)), @@ -143,34 +183,79 @@ export function ResourceTabs({ [chatId, onAddResource] ) + const handleTabClick = useCallback( + (e: React.MouseEvent, idx: number) => { + const resource = resources[idx] + if (!resource) return + if (e.shiftKey && anchorIdRef.current) { + const anchorIdx = resources.findIndex((r) => r.id === anchorIdRef.current) + if (anchorIdx !== -1) { + const start = Math.min(anchorIdx, idx) + const end = Math.max(anchorIdx, idx) + const next = new Set() + for (let i = start; i <= end; i++) next.add(resources[i].id) + setSelectedIds(next) + onSelect(resource.id) + return + } + } + anchorIdRef.current = resource.id + setSelectedIds(new Set([resource.id])) + onSelect(resource.id) + }, + [resources, onSelect] + ) + const handleRemove = useCallback( (e: React.MouseEvent, resource: MothershipResource) => { e.stopPropagation() if (!chatId) return - if (!isEphemeralResource(resource)) { - removeResource.mutate({ chatId, resourceType: resource.type, resourceId: resource.id }) + const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1 + const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource] + for (const r of targets) { + if (!isEphemeralResource(r)) { + removeResource.mutate({ chatId, resourceType: r.type, resourceId: r.id }) + } + onRemoveResource(r.type, r.id) + } + if (isMulti) { + setSelectedIds(new Set()) + anchorIdRef.current = null } - onRemoveResource(resource.type, resource.id) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [chatId, onRemoveResource] + [chatId, onRemoveResource, resources, selectedIds] ) const handleDragStart = useCallback( (e: React.DragEvent, idx: number) => { + const resource = resources[idx] + if (!resource) return + const selected = resources.filter((r) => selectedIds.has(r.id)) + const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id) + if (isMultiDrag) { + e.dataTransfer.effectAllowed = 'copy' + e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected)) + const dragImage = buildMultiDragImage(scrollNodeRef.current, selected) + if (dragImage) { + e.dataTransfer.setDragImage(dragImage, 16, 16) + setTimeout(() => dragImage.remove(), 0) + } + // Skip dragStartIdx so internal reorder is disabled for multi-select drags + dragStartIdx.current = null + setDraggedIdx(null) + return + } dragStartIdx.current = idx setDraggedIdx(idx) e.dataTransfer.effectAllowed = 'copyMove' e.dataTransfer.setData('text/plain', String(idx)) - const resource = resources[idx] - if (resource) { - e.dataTransfer.setData( - SIM_RESOURCE_DRAG_TYPE, - JSON.stringify({ type: resource.type, id: resource.id, title: resource.title }) - ) - } + e.dataTransfer.setData( + SIM_RESOURCE_DRAG_TYPE, + JSON.stringify({ type: resource.type, id: resource.id, title: resource.title }) + ) }, - [resources] + [resources, selectedIds] ) const stopAutoScroll = useCallback(() => { @@ -308,6 +393,7 @@ export function ResourceTabs({ const isActive = activeId === resource.id const isHovered = hoveredTabId === resource.id const isDragging = draggedIdx === idx + const isSelected = selectedIds.has(resource.id) && selectedIds.size > 1 const showGapBefore = dropGapIdx === idx && draggedIdx !== null && @@ -329,6 +415,7 @@ export function ResourceTabs({