Skip to content

Commit 266bc21

Browse files
TheodoreSpeaksTheodore Li
andauthored
feat(ui): allow multiselect in resource tabs (#4094)
* feat(ui): allow multiselect in resource tabs * Fix bugs with deselection * Try catch resource tab deletion independently * Fix chat switch selection * Default to null active id --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent 6099683 commit 266bc21

File tree

2 files changed

+194
-23
lines changed

2 files changed

+194
-23
lines changed

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export interface AddResourceDropdownProps {
3636
existingKeys: Set<string>
3737
onAdd: (resource: MothershipResource) => void
3838
onSwitch?: (resourceId: string) => void
39+
/** Resource types to hide from the dropdown (e.g. `['folder', 'task']`). */
40+
excludeTypes?: readonly MothershipResourceType[]
3941
}
4042

4143
export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown }
@@ -47,7 +49,8 @@ interface AvailableItemsByType {
4749

4850
export function useAvailableResources(
4951
workspaceId: string,
50-
existingKeys: Set<string>
52+
existingKeys: Set<string>,
53+
excludeTypes?: readonly MothershipResourceType[]
5154
): AvailableItemsByType[] {
5255
const { data: workflows = [] } = useWorkflows(workspaceId)
5356
const { data: tables = [] } = useTablesList(workspaceId)
@@ -56,8 +59,9 @@ export function useAvailableResources(
5659
const { data: folders = [] } = useFolders(workspaceId)
5760
const { data: tasks = [] } = useTasks(workspaceId)
5861

59-
return useMemo(
60-
() => [
62+
return useMemo(() => {
63+
const excluded = new Set<MothershipResourceType>(excludeTypes ?? [])
64+
const groups: AvailableItemsByType[] = [
6165
{
6266
type: 'workflow' as const,
6367
items: workflows.map((w) => ({
@@ -107,21 +111,22 @@ export function useAvailableResources(
107111
isOpen: existingKeys.has(`task:${t.id}`),
108112
})),
109113
},
110-
],
111-
[workflows, folders, tables, files, knowledgeBases, tasks, existingKeys]
112-
)
114+
]
115+
return groups.filter((g) => !excluded.has(g.type))
116+
}, [workflows, folders, tables, files, knowledgeBases, tasks, existingKeys, excludeTypes])
113117
}
114118

115119
export function AddResourceDropdown({
116120
workspaceId,
117121
existingKeys,
118122
onAdd,
119123
onSwitch,
124+
excludeTypes,
120125
}: AddResourceDropdownProps) {
121126
const [open, setOpen] = useState(false)
122127
const [search, setSearch] = useState('')
123128
const [activeIndex, setActiveIndex] = useState(0)
124-
const available = useAvailableResources(workspaceId, existingKeys)
129+
const available = useAvailableResources(workspaceId, existingKeys, excludeTypes)
125130

126131
const handleOpenChange = useCallback((next: boolean) => {
127132
setOpen(next)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx

Lines changed: 182 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { Button, Tooltip } from '@/components/emcn'
1111
import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons'
1212
import { isEphemeralResource } from '@/lib/copilot/resource-extraction'
13-
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
13+
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
1414
import { cn } from '@/lib/core/utils/cn'
1515
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
1616
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
@@ -38,6 +38,62 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
3838
const EDGE_ZONE = 40
3939
const SCROLL_SPEED = 8
4040

41+
const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const
42+
43+
/**
44+
* Returns the id of the nearest resource to `idx` that is in `filter`
45+
* (or any resource if `filter` is null). Returns undefined if nothing qualifies.
46+
*/
47+
function findNearestId(
48+
resources: MothershipResource[],
49+
idx: number,
50+
filter: Set<string> | null
51+
): string | undefined {
52+
for (let offset = 1; offset < resources.length; offset++) {
53+
for (const candidate of [idx + offset, idx - offset]) {
54+
const r = resources[candidate]
55+
if (r && (!filter || filter.has(r.id))) return r.id
56+
}
57+
}
58+
return undefined
59+
}
60+
61+
/**
62+
* Builds an offscreen drag image showing all selected tabs side-by-side, so the
63+
* cursor visibly carries every tab in the multi-selection. The element is
64+
* appended to the document and removed on the next tick after the browser has
65+
* snapshotted it.
66+
*/
67+
function buildMultiDragImage(
68+
scrollNode: HTMLElement | null,
69+
selected: MothershipResource[]
70+
): HTMLElement | null {
71+
if (!scrollNode || selected.length === 0) return null
72+
const container = document.createElement('div')
73+
container.style.position = 'fixed'
74+
container.style.top = '-10000px'
75+
container.style.left = '-10000px'
76+
container.style.display = 'flex'
77+
container.style.alignItems = 'center'
78+
container.style.gap = '6px'
79+
container.style.padding = '4px'
80+
container.style.pointerEvents = 'none'
81+
let appendedAny = false
82+
for (const r of selected) {
83+
const original = scrollNode.querySelector<HTMLElement>(
84+
`[data-resource-tab-id="${CSS.escape(r.id)}"]`
85+
)
86+
if (!original) continue
87+
const clone = original.cloneNode(true) as HTMLElement
88+
clone.style.opacity = '0.95'
89+
container.appendChild(clone)
90+
appendedAny = true
91+
}
92+
if (!appendedAny) return null
93+
document.body.appendChild(container)
94+
return container
95+
}
96+
4197
const PREVIEW_MODE_ICONS = {
4298
editor: Columns3,
4399
split: Eye,
@@ -125,8 +181,19 @@ export function ResourceTabs({
125181
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null)
126182
const [draggedIdx, setDraggedIdx] = useState<number | null>(null)
127183
const [dropGapIdx, setDropGapIdx] = useState<number | null>(null)
184+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
128185
const dragStartIdx = useRef<number | null>(null)
129186
const autoScrollRaf = useRef<number | null>(null)
187+
const anchorIdRef = useRef<string | null>(null)
188+
const prevChatIdRef = useRef(chatId)
189+
190+
// Reset selection when switching chats — component instance persists across
191+
// chat switches so stale IDs would otherwise carry over.
192+
if (prevChatIdRef.current !== chatId) {
193+
prevChatIdRef.current = chatId
194+
setSelectedIds(new Set())
195+
anchorIdRef.current = null
196+
}
130197

131198
const existingKeys = useMemo(
132199
() => new Set(resources.map((r) => `${r.type}:${r.id}`)),
@@ -143,34 +210,129 @@ export function ResourceTabs({
143210
[chatId, onAddResource]
144211
)
145212

213+
const handleTabClick = useCallback(
214+
(e: React.MouseEvent, idx: number) => {
215+
const resource = resources[idx]
216+
if (!resource) return
217+
218+
// Shift+click: contiguous range from anchor
219+
if (e.shiftKey) {
220+
// Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar)
221+
const anchorId = anchorIdRef.current ?? activeId
222+
const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1
223+
if (anchorIdx !== -1) {
224+
const start = Math.min(anchorIdx, idx)
225+
const end = Math.max(anchorIdx, idx)
226+
const next = new Set<string>()
227+
for (let i = start; i <= end; i++) next.add(resources[i].id)
228+
setSelectedIds(next)
229+
onSelect(resource.id)
230+
return
231+
}
232+
}
233+
234+
// Cmd/Ctrl+click: toggle individual tab in/out of selection
235+
if (e.metaKey || e.ctrlKey) {
236+
const wasSelected = selectedIds.has(resource.id)
237+
if (wasSelected) {
238+
const next = new Set(selectedIds)
239+
next.delete(resource.id)
240+
setSelectedIds(next)
241+
// Only switch active if we just deselected the currently-active tab
242+
if (activeId === resource.id) {
243+
const fallback =
244+
findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null)
245+
if (fallback) onSelect(fallback)
246+
}
247+
} else {
248+
setSelectedIds((prev) => new Set(prev).add(resource.id))
249+
onSelect(resource.id)
250+
}
251+
if (!anchorIdRef.current) anchorIdRef.current = resource.id
252+
return
253+
}
254+
255+
// Plain click: single-select
256+
anchorIdRef.current = resource.id
257+
setSelectedIds(new Set([resource.id]))
258+
onSelect(resource.id)
259+
},
260+
[resources, onSelect, selectedIds, activeId]
261+
)
262+
146263
const handleRemove = useCallback(
147264
(e: React.MouseEvent, resource: MothershipResource) => {
148265
e.stopPropagation()
149266
if (!chatId) return
150-
if (!isEphemeralResource(resource)) {
151-
removeResource.mutate({ chatId, resourceType: resource.type, resourceId: resource.id })
267+
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
268+
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
269+
// Update parent state immediately for all targets
270+
for (const r of targets) {
271+
onRemoveResource(r.type, r.id)
272+
}
273+
// Clear stale selection and anchor for all removed targets
274+
const removedIds = new Set(targets.map((r) => r.id))
275+
setSelectedIds((prev) => {
276+
const next = new Set(prev)
277+
for (const id of removedIds) next.delete(id)
278+
return next
279+
})
280+
if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) {
281+
anchorIdRef.current = null
282+
}
283+
// Serialize mutations so each onMutate sees the cache updated by the prior
284+
// one. Continue on individual failures so remaining removals still fire.
285+
const persistable = targets.filter((r) => !isEphemeralResource(r))
286+
if (persistable.length > 0) {
287+
void (async () => {
288+
for (const r of persistable) {
289+
try {
290+
await removeResource.mutateAsync({
291+
chatId,
292+
resourceType: r.type,
293+
resourceId: r.id,
294+
})
295+
} catch {
296+
// Individual failure — the mutation's onError already rolled back
297+
// this resource in cache. Remaining removals continue.
298+
}
299+
}
300+
})()
152301
}
153-
onRemoveResource(resource.type, resource.id)
154302
},
155303
// eslint-disable-next-line react-hooks/exhaustive-deps
156-
[chatId, onRemoveResource]
304+
[chatId, onRemoveResource, resources, selectedIds]
157305
)
158306

159307
const handleDragStart = useCallback(
160308
(e: React.DragEvent, idx: number) => {
309+
const resource = resources[idx]
310+
if (!resource) return
311+
const selected = resources.filter((r) => selectedIds.has(r.id))
312+
const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id)
313+
if (isMultiDrag) {
314+
e.dataTransfer.effectAllowed = 'copy'
315+
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected))
316+
const dragImage = buildMultiDragImage(scrollNodeRef.current, selected)
317+
if (dragImage) {
318+
e.dataTransfer.setDragImage(dragImage, 16, 16)
319+
setTimeout(() => dragImage.remove(), 0)
320+
}
321+
// Skip dragStartIdx so internal reorder is disabled for multi-select drags
322+
dragStartIdx.current = null
323+
setDraggedIdx(null)
324+
return
325+
}
161326
dragStartIdx.current = idx
162327
setDraggedIdx(idx)
163328
e.dataTransfer.effectAllowed = 'copyMove'
164329
e.dataTransfer.setData('text/plain', String(idx))
165-
const resource = resources[idx]
166-
if (resource) {
167-
e.dataTransfer.setData(
168-
SIM_RESOURCE_DRAG_TYPE,
169-
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
170-
)
171-
}
330+
e.dataTransfer.setData(
331+
SIM_RESOURCE_DRAG_TYPE,
332+
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
333+
)
172334
},
173-
[resources]
335+
[resources, selectedIds]
174336
)
175337

176338
const stopAutoScroll = useCallback(() => {
@@ -308,6 +470,7 @@ export function ResourceTabs({
308470
const isActive = activeId === resource.id
309471
const isHovered = hoveredTabId === resource.id
310472
const isDragging = draggedIdx === idx
473+
const isSelected = selectedIds.has(resource.id) && selectedIds.size > 1
311474
const showGapBefore =
312475
dropGapIdx === idx &&
313476
draggedIdx !== null &&
@@ -329,22 +492,24 @@ export function ResourceTabs({
329492
<Button
330493
variant='subtle'
331494
draggable
495+
data-resource-tab-id={resource.id}
332496
onDragStart={(e) => handleDragStart(e, idx)}
333497
onDragOver={(e) => handleDragOver(e, idx)}
334498
onDragLeave={handleDragLeave}
335499
onDragEnd={handleDragEnd}
336500
onMouseDown={(e) => {
337-
if (e.button === 1 && chatId) {
501+
if (e.button === 1) {
338502
e.preventDefault()
339-
handleRemove(e, resource)
503+
if (chatId) handleRemove(e, resource)
340504
}
341505
}}
342-
onClick={() => onSelect(resource.id)}
506+
onClick={(e) => handleTabClick(e, idx)}
343507
onMouseEnter={() => setHoveredTabId(resource.id)}
344508
onMouseLeave={() => setHoveredTabId(null)}
345509
className={cn(
346510
'group relative shrink-0 bg-transparent px-2 py-1 pr-[22px] text-caption transition-opacity duration-150',
347511
isActive && 'bg-[var(--surface-4)]',
512+
isSelected && !isActive && 'bg-[var(--surface-3)]',
348513
isDragging && 'opacity-30'
349514
)}
350515
>
@@ -394,6 +559,7 @@ export function ResourceTabs({
394559
existingKeys={existingKeys}
395560
onAdd={handleAdd}
396561
onSwitch={onSelect}
562+
excludeTypes={ADD_RESOURCE_EXCLUDED_TYPES}
397563
/>
398564
)}
399565
</div>

0 commit comments

Comments
 (0)