Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface AddResourceDropdownProps {
existingKeys: Set<string>
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 }
Expand All @@ -47,7 +49,8 @@ interface AvailableItemsByType {

export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>
existingKeys: Set<string>,
excludeTypes?: readonly MothershipResourceType[]
): AvailableItemsByType[] {
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
Expand All @@ -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<MothershipResourceType>(excludeTypes ?? [])
const groups: AvailableItemsByType[] = [
{
type: 'workflow' as const,
items: workflows.map((w) => ({
Expand Down Expand Up @@ -107,21 +111,22 @@ 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({
workspaceId,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -38,6 +38,62 @@ 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

/**
* Returns the id of the nearest resource to `idx` that is in `filter`
* (or any resource if `filter` is null). Returns undefined if nothing qualifies.
*/
function findNearestId(
resources: MothershipResource[],
idx: number,
filter: Set<string> | null
): string | undefined {
for (let offset = 1; offset < resources.length; offset++) {
for (const candidate of [idx + offset, idx - offset]) {
const r = resources[candidate]
if (r && (!filter || filter.has(r.id))) return r.id
}
}
return undefined
}

/**
* 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<HTMLElement>(
`[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,
Expand Down Expand Up @@ -125,8 +181,10 @@ export function ResourceTabs({
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null)
const [draggedIdx, setDraggedIdx] = useState<number | null>(null)
const [dropGapIdx, setDropGapIdx] = useState<number | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
Comment thread
TheodoreSpeaks marked this conversation as resolved.
const dragStartIdx = useRef<number | null>(null)
const autoScrollRaf = useRef<number | null>(null)
const anchorIdRef = useRef<string | null>(null)

const existingKeys = useMemo(
() => new Set(resources.map((r) => `${r.type}:${r.id}`)),
Expand All @@ -143,34 +201,119 @@ export function ResourceTabs({
[chatId, onAddResource]
)

const handleTabClick = useCallback(
(e: React.MouseEvent, idx: number) => {
const resource = resources[idx]
if (!resource) return

// Shift+click: contiguous range from anchor
if (e.shiftKey) {
// Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar)
const anchorId = anchorIdRef.current ?? activeId
const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1
if (anchorIdx !== -1) {
const start = Math.min(anchorIdx, idx)
const end = Math.max(anchorIdx, idx)
const next = new Set<string>()
for (let i = start; i <= end; i++) next.add(resources[i].id)
setSelectedIds(next)
onSelect(resource.id)
return
}
}

// Cmd/Ctrl+click: toggle individual tab in/out of selection
if (e.metaKey || e.ctrlKey) {
const wasSelected = selectedIds.has(resource.id)
if (wasSelected) {
const next = new Set(selectedIds)
next.delete(resource.id)
setSelectedIds(next)
// Only switch active if we just deselected the currently-active tab
if (activeId === resource.id) {
const fallback =
findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null)
if (fallback) onSelect(fallback)
}
} else {
setSelectedIds((prev) => new Set(prev).add(resource.id))
onSelect(resource.id)
}
if (!anchorIdRef.current) anchorIdRef.current = resource.id
return
}

// Plain click: single-select
anchorIdRef.current = resource.id
setSelectedIds(new Set([resource.id]))
onSelect(resource.id)
},
[resources, onSelect, selectedIds, activeId]
)

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]
// Update parent state immediately for all targets
for (const r of targets) {
onRemoveResource(r.type, r.id)
}
if (isMulti) {
setSelectedIds(new Set())
anchorIdRef.current = null
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
// Serialize mutations so each onMutate sees the cache from the prior one;
// calling .mutate() in a loop races the optimistic updates and the observer
// discards all but the last in-flight mutation.
const persistable = targets.filter((r) => !isEphemeralResource(r))
if (persistable.length > 0) {
void (async () => {
for (const r of persistable) {
await removeResource.mutateAsync({
chatId,
resourceType: r.type,
resourceId: r.id,
})
}
})()
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}
onRemoveResource(resource.type, resource.id)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatId, onRemoveResource]
[chatId, onRemoveResource, resources, selectedIds]
Comment thread
TheodoreSpeaks marked this conversation as resolved.
)

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(() => {
Expand Down Expand Up @@ -308,6 +451,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 &&
Expand All @@ -329,22 +473,24 @@ export function ResourceTabs({
<Button
variant='subtle'
draggable
data-resource-tab-id={resource.id}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
onMouseDown={(e) => {
if (e.button === 1 && chatId) {
if (e.button === 1) {
e.preventDefault()
handleRemove(e, resource)
if (chatId) handleRemove(e, resource)
}
}}
onClick={() => onSelect(resource.id)}
onClick={(e) => handleTabClick(e, idx)}
onMouseEnter={() => setHoveredTabId(resource.id)}
onMouseLeave={() => setHoveredTabId(null)}
className={cn(
'group relative shrink-0 bg-transparent px-2 py-1 pr-[22px] text-caption transition-opacity duration-150',
isActive && 'bg-[var(--surface-4)]',
isSelected && !isActive && 'bg-[var(--surface-3)]',
isDragging && 'opacity-30'
)}
>
Expand Down Expand Up @@ -394,6 +540,7 @@ export function ResourceTabs({
existingKeys={existingKeys}
onAdd={handleAdd}
onSwitch={onSelect}
excludeTypes={ADD_RESOURCE_EXCLUDED_TYPES}
/>
)}
</div>
Expand Down
Loading