@@ -10,7 +10,7 @@ import {
1010import { Button , Tooltip } from '@/components/emcn'
1111import { Columns3 , Eye , PanelLeft , Pencil } from '@/components/emcn/icons'
1212import { 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'
1414import { cn } from '@/lib/core/utils/cn'
1515import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
1616import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
@@ -38,6 +38,62 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
3838const EDGE_ZONE = 40
3939const 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+
4197const 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