mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(chat): drag workflows and folders from sidebar into chat input (#4028)
* feat(chat): drag workflows and folders from sidebar into chat input * fix(chat): fix effectAllowed, stale atInsertPosRef, and drag-enter overlay for resource drags * feat(chat): add task dragging and visible drag ghost for sidebar items * feat(sidebar): add drag ghost with icons and task icon to context chips * refactor(types): narrow ChatMessageContext.kind to ChatContextKind union and add workflowBorderColor utility * feat(user-input): support Tab to select resource in mention dropdown * fix(user-input): narrow ChatContext discriminated union before accessing workflowId * fix(colors): overload workflowBorderColor to accept string | undefined * fix(colors): simplify workflowBorderColor to single string | undefined signature * fix(chat): remove resource panel tab when context mention is deleted from input * fix(chat): use resource ID for context removal identity check * fix(chat): add folder/task cases to resource resolver, task key to existingResourceKeys, and use workflowBorderColor in drag ghost * revert(chat): remove folder/task from resolveResourceFromContext — no panel UI for these types * fix(chat): add chatId to stored context types and workflow.color to drag callback deps * fix(chat): guard chatId before adding task key to existingResourceKeys
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
xAIIcon,
|
||||
} from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
|
||||
interface FeaturesPreviewProps {
|
||||
activeTab: number
|
||||
@@ -383,7 +384,7 @@ function MiniCardIcon({ variant, color }: { variant: CardVariant; color?: string
|
||||
className='h-[7px] w-[7px] flex-shrink-0 rounded-[1.5px] border'
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: `${c}60`,
|
||||
borderColor: workflowBorderColor(c),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
@@ -470,7 +471,7 @@ function WorkflowCardBody({ color }: { color: string }) {
|
||||
className='absolute top-2.5 left-[40px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
@@ -481,7 +482,7 @@ function WorkflowCardBody({ color }: { color: string }) {
|
||||
className='absolute top-[36px] left-[68px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
@@ -896,7 +897,7 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
|
||||
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Download } from 'lucide-react'
|
||||
import { ArrowUpDown, Badge, Library, ListFilter, Search } from '@/components/emcn'
|
||||
import type { BadgeProps } from '@/components/emcn/components/badge/badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
|
||||
interface LogRow {
|
||||
id: string
|
||||
@@ -283,7 +284,7 @@ export function LandingPreviewLogs() {
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: log.workflowColor,
|
||||
borderColor: `${log.workflowColor}60`,
|
||||
borderColor: workflowBorderColor(log.workflowColor),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Table,
|
||||
} from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
export type SidebarView =
|
||||
@@ -211,7 +212,7 @@ export function LandingPreviewSidebar({
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
borderColor: workflowBorderColor(workflow.color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Blimp, Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
interface ContextMentionIconProps {
|
||||
context: ChatMessageContext
|
||||
/** Only used when context.kind is 'workflow' or 'current_workflow'; ignored otherwise. */
|
||||
workflowColor?: string | null
|
||||
/** Applied to every icon element. Include sizing and positional classes (e.g. h-[12px] w-[12px]). */
|
||||
className: string
|
||||
}
|
||||
|
||||
/** Renders the icon for a context mention chip. Returns null when no icon applies. */
|
||||
export function ContextMentionIcon({ context, workflowColor, className }: ContextMentionIconProps) {
|
||||
switch (context.kind) {
|
||||
case 'workflow':
|
||||
case 'current_workflow':
|
||||
return workflowColor ? (
|
||||
<span
|
||||
className={cn('rounded-[3px] border-[2px]', className)}
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: workflowBorderColor(workflowColor),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
case 'knowledge':
|
||||
return <Database className={className} />
|
||||
case 'table':
|
||||
return <TableIcon className={className} />
|
||||
case 'file': {
|
||||
const FileDocIcon = getDocumentIcon('', context.label)
|
||||
return <FileDocIcon className={className} />
|
||||
}
|
||||
case 'folder':
|
||||
return <FolderIcon className={className} />
|
||||
case 'past_chat':
|
||||
return <Blimp className={className} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ChatMessageAttachments } from './chat-message-attachments'
|
||||
export { ContextMentionIcon } from './context-mention-icon'
|
||||
export {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
|
||||
@@ -37,6 +37,7 @@ interface MothershipChatProps {
|
||||
userId?: string
|
||||
chatId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
onContextRemove?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
layout?: 'mothership-view' | 'copilot-view'
|
||||
@@ -83,6 +84,7 @@ export function MothershipChat({
|
||||
userId,
|
||||
chatId,
|
||||
onContextAdd,
|
||||
onContextRemove,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
layout = 'mothership-view',
|
||||
@@ -207,6 +209,7 @@ export function MothershipChat({
|
||||
isInitialView={false}
|
||||
userId={userId}
|
||||
onContextAdd={onContextAdd}
|
||||
onContextRemove={onContextRemove}
|
||||
editValue={editValue}
|
||||
onEditValueConsumed={onEditValueConsumed}
|
||||
onEnterWhileEmpty={handleEnterWhileEmpty}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
|
||||
import { useTablesList } from '@/hooks/queries/tables'
|
||||
import { useTasks } from '@/hooks/queries/tasks'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
||||
|
||||
@@ -53,6 +54,7 @@ export function useAvailableResources(
|
||||
const { data: files = [] } = useWorkspaceFiles(workspaceId)
|
||||
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
|
||||
const { data: folders = [] } = useFolders(workspaceId)
|
||||
const { data: tasks = [] } = useTasks(workspaceId)
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
@@ -97,8 +99,16 @@ export function useAvailableResources(
|
||||
isOpen: existingKeys.has(`knowledgebase:${kb.id}`),
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'task' as const,
|
||||
items: tasks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
isOpen: existingKeys.has(`task:${t.id}`),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[workflows, folders, tables, files, knowledgeBases, existingKeys]
|
||||
[workflows, folders, tables, files, knowledgeBases, tasks, existingKeys]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getFileExtension,
|
||||
getMimeTypeFromExtension,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import {
|
||||
FileViewer,
|
||||
type PreviewMode,
|
||||
@@ -514,7 +515,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
|
||||
className='h-[12px] w-[12px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: w.color,
|
||||
borderColor: `${w.color}60`,
|
||||
borderColor: workflowBorderColor(w.color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type ElementType, type ReactNode, useMemo } from 'react'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Blimp,
|
||||
Database,
|
||||
File as FileIcon,
|
||||
Folder as FolderIcon,
|
||||
@@ -13,12 +14,14 @@ import {
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
import { tableKeys } from '@/hooks/queries/tables'
|
||||
import { taskKeys } from '@/hooks/queries/tasks'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
@@ -48,7 +51,7 @@ function WorkflowTabSquare({ workflowId, className }: { workflowId: string; clas
|
||||
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
@@ -63,7 +66,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
@@ -151,6 +154,15 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
),
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
|
||||
},
|
||||
task: {
|
||||
type: 'task',
|
||||
label: 'Tasks',
|
||||
icon: Blimp,
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<Blimp className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Blimp} />,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
|
||||
@@ -185,6 +197,9 @@ const RESOURCE_INVALIDATORS: Record<
|
||||
folder: (qc) => {
|
||||
qc.invalidateQueries({ queryKey: folderKeys.lists() })
|
||||
},
|
||||
task: (qc, wId) => {
|
||||
qc.invalidateQueries({ queryKey: taskKeys.list(wId) })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +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 { 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'
|
||||
@@ -164,7 +165,7 @@ export function ResourceTabs({
|
||||
const resource = resources[idx]
|
||||
if (resource) {
|
||||
e.dataTransfer.setData(
|
||||
'application/x-sim-resource',
|
||||
SIM_RESOURCE_DRAG_TYPE,
|
||||
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export function mapResourceToContext(resource: MothershipResource): ChatContext
|
||||
return { kind: 'file', fileId: resource.id, label: resource.title }
|
||||
case 'folder':
|
||||
return { kind: 'folder', folderId: resource.id, label: resource.title }
|
||||
case 'task':
|
||||
return { kind: 'past_chat', chatId: resource.id, label: resource.title }
|
||||
default:
|
||||
return { kind: 'docs', label: resource.title }
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const PlusMenuDropdown = React.memo(
|
||||
e.preventDefault()
|
||||
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
|
||||
firstItem?.focus()
|
||||
} else if (e.key === 'Enter') {
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const first = filteredItemsRef.current?.[0]
|
||||
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
|
||||
@@ -99,6 +99,12 @@ export const PlusMenuDropdown = React.memo(
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
const focused = document.activeElement as HTMLElement | null
|
||||
if (focused?.getAttribute('role') === 'menuitem') {
|
||||
e.preventDefault()
|
||||
focused.click()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
|
||||
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
|
||||
import type {
|
||||
PlusMenuHandle,
|
||||
@@ -108,6 +108,7 @@ interface UserInputProps {
|
||||
isInitialView?: boolean
|
||||
userId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
onContextRemove?: (context: ChatContext) => void
|
||||
onEnterWhileEmpty?: () => boolean
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ export function UserInput({
|
||||
isInitialView = true,
|
||||
userId,
|
||||
onContextAdd,
|
||||
onContextRemove,
|
||||
onEnterWhileEmpty,
|
||||
}: UserInputProps) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
@@ -170,6 +172,37 @@ export function UserInput({
|
||||
[addContext, onContextAdd]
|
||||
)
|
||||
|
||||
const onContextRemoveRef = useRef(onContextRemove)
|
||||
onContextRemoveRef.current = onContextRemove
|
||||
|
||||
const prevSelectedContextsRef = useRef<ChatContext[]>([])
|
||||
useEffect(() => {
|
||||
const prev = prevSelectedContextsRef.current
|
||||
const curr = contextManagement.selectedContexts
|
||||
const contextId = (ctx: ChatContext): string => {
|
||||
switch (ctx.kind) {
|
||||
case 'workflow':
|
||||
case 'current_workflow':
|
||||
return `${ctx.kind}:${ctx.workflowId}`
|
||||
case 'knowledge':
|
||||
return `knowledge:${ctx.knowledgeId ?? ''}`
|
||||
case 'table':
|
||||
return `table:${ctx.tableId}`
|
||||
case 'file':
|
||||
return `file:${ctx.fileId}`
|
||||
case 'folder':
|
||||
return `folder:${ctx.folderId}`
|
||||
case 'past_chat':
|
||||
return `past_chat:${ctx.chatId}`
|
||||
default:
|
||||
return `${ctx.kind}:${ctx.label}`
|
||||
}
|
||||
}
|
||||
const removed = prev.filter((p) => !curr.some((c) => contextId(c) === contextId(p)))
|
||||
if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx))
|
||||
prevSelectedContextsRef.current = curr
|
||||
}, [contextManagement.selectedContexts])
|
||||
|
||||
const existingResourceKeys = useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const ctx of contextManagement.selectedContexts) {
|
||||
@@ -178,6 +211,7 @@ export function UserInput({
|
||||
if (ctx.kind === 'table' && ctx.tableId) keys.add(`table:${ctx.tableId}`)
|
||||
if (ctx.kind === 'file' && ctx.fileId) keys.add(`file:${ctx.fileId}`)
|
||||
if (ctx.kind === 'folder' && ctx.folderId) keys.add(`folder:${ctx.folderId}`)
|
||||
if (ctx.kind === 'past_chat' && ctx.chatId) keys.add(`task:${ctx.chatId}`)
|
||||
}
|
||||
return keys
|
||||
}, [contextManagement.selectedContexts])
|
||||
@@ -247,15 +281,17 @@ export function UserInput({
|
||||
if (textarea) {
|
||||
const currentValue = valueRef.current
|
||||
const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
|
||||
atInsertPosRef.current = null
|
||||
|
||||
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
|
||||
const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
|
||||
const before = currentValue.slice(0, insertAt)
|
||||
const after = currentValue.slice(insertAt)
|
||||
const newValue = `${before}${insertText}${after}`
|
||||
const newPos = before.length + insertText.length
|
||||
pendingCursorRef.current = newPos
|
||||
setValue(`${before}${insertText}${after}`)
|
||||
// Eagerly sync refs so successive drop-handler iterations see the updated position
|
||||
valueRef.current = newValue
|
||||
atInsertPosRef.current = newPos
|
||||
setValue(newValue)
|
||||
}
|
||||
|
||||
const context = mapResourceToContext(resource)
|
||||
@@ -281,7 +317,10 @@ export function UserInput({
|
||||
}, [])
|
||||
|
||||
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('application/x-sim-resource')) {
|
||||
if (
|
||||
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
|
||||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
@@ -292,13 +331,30 @@ export function UserInput({
|
||||
|
||||
const handleContainerDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const resourceJson = e.dataTransfer.getData('application/x-sim-resource')
|
||||
const resourcesJson = e.dataTransfer.getData(SIM_RESOURCES_DRAG_TYPE)
|
||||
if (resourcesJson) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const resources = JSON.parse(resourcesJson) as MothershipResource[]
|
||||
for (const resource of resources) {
|
||||
handleResourceSelect(resource)
|
||||
}
|
||||
// Reset after batch so the next non-drop insert uses the cursor position
|
||||
atInsertPosRef.current = null
|
||||
} catch {
|
||||
// Invalid JSON — ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
const resourceJson = e.dataTransfer.getData(SIM_RESOURCE_DRAG_TYPE)
|
||||
if (resourceJson) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const resource = JSON.parse(resourceJson) as MothershipResource
|
||||
handleResourceSelect(resource)
|
||||
atInsertPosRef.current = null
|
||||
} catch {
|
||||
// Invalid JSON — ignore
|
||||
}
|
||||
@@ -310,11 +366,17 @@ export function UserInput({
|
||||
)
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
filesRef.current.handleDragEnter(e)
|
||||
const isResourceDrag =
|
||||
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
|
||||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
|
||||
if (!isResourceDrag) filesRef.current.handleDragEnter(e)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
filesRef.current.handleDragLeave(e)
|
||||
const isResourceDrag =
|
||||
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
|
||||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
|
||||
if (!isResourceDrag) filesRef.current.handleDragLeave(e)
|
||||
}, [])
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -643,42 +705,17 @@ export function UserInput({
|
||||
: range.token
|
||||
const matchingCtx = contexts.find((c) => c.label === mentionLabel)
|
||||
|
||||
let mentionIconNode: React.ReactNode = null
|
||||
if (matchingCtx) {
|
||||
const iconClasses = 'absolute inset-0 m-auto h-[12px] w-[12px] text-[var(--text-icon)]'
|
||||
switch (matchingCtx.kind) {
|
||||
case 'workflow':
|
||||
case 'current_workflow': {
|
||||
const wfId = (matchingCtx as { workflowId: string }).workflowId
|
||||
const wfColor = workflowsById[wfId]?.color ?? '#888'
|
||||
mentionIconNode = (
|
||||
<div
|
||||
className='absolute inset-0 m-auto h-[12px] w-[12px] rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: wfColor,
|
||||
borderColor: `${wfColor}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'knowledge':
|
||||
mentionIconNode = <Database className={iconClasses} />
|
||||
break
|
||||
case 'table':
|
||||
mentionIconNode = <TableIcon className={iconClasses} />
|
||||
break
|
||||
case 'file': {
|
||||
const FileDocIcon = getDocumentIcon('', mentionLabel)
|
||||
mentionIconNode = <FileDocIcon className={iconClasses} />
|
||||
break
|
||||
}
|
||||
case 'folder':
|
||||
mentionIconNode = <FolderIcon className={iconClasses} />
|
||||
break
|
||||
}
|
||||
}
|
||||
const wfId =
|
||||
matchingCtx?.kind === 'workflow' || matchingCtx?.kind === 'current_workflow'
|
||||
? matchingCtx.workflowId
|
||||
: undefined
|
||||
const mentionIconNode = matchingCtx ? (
|
||||
<ContextMentionIcon
|
||||
context={matchingCtx}
|
||||
workflowColor={wfId ? (workflowsById[wfId]?.color ?? null) : null}
|
||||
className='absolute inset-0 m-auto h-[12px] w-[12px] text-[var(--text-icon)]'
|
||||
/>
|
||||
) : null
|
||||
|
||||
elements.push(
|
||||
<span
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
|
||||
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
|
||||
@@ -53,42 +52,13 @@ function MentionHighlight({ context }: { context: ChatMessageContext }) {
|
||||
return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null
|
||||
}, [workflowList, context.kind, context.workflowId])
|
||||
|
||||
let icon: React.ReactNode = null
|
||||
const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
|
||||
|
||||
switch (context.kind) {
|
||||
case 'workflow':
|
||||
case 'current_workflow':
|
||||
icon = workflowColor ? (
|
||||
<span
|
||||
className='inline-block h-[12px] w-[12px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: `${workflowColor}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
break
|
||||
case 'knowledge':
|
||||
icon = <Database className={iconClasses} />
|
||||
break
|
||||
case 'table':
|
||||
icon = <TableIcon className={iconClasses} />
|
||||
break
|
||||
case 'file': {
|
||||
const FileDocIcon = getDocumentIcon('', context.label)
|
||||
icon = <FileDocIcon className={iconClasses} />
|
||||
break
|
||||
}
|
||||
case 'folder':
|
||||
icon = <FolderIcon className={iconClasses} />
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px]'>
|
||||
{icon && <span className='relative top-0.5 flex-shrink-0'>{icon}</span>}
|
||||
<ContextMentionIcon
|
||||
context={context}
|
||||
workflowColor={workflowColor}
|
||||
className='relative top-0.5 h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
|
||||
/>
|
||||
{context.label}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
|
||||
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
import type { FileAttachmentForApi, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
|
||||
@@ -261,51 +261,42 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return () => window.removeEventListener('mothership-send-message', handler)
|
||||
}, [sendMessage])
|
||||
|
||||
const handleContextAdd = useCallback(
|
||||
(context: ChatContext) => {
|
||||
let resourceType: MothershipResourceType | null = null
|
||||
let resourceId: string | null = null
|
||||
const resourceTitle: string = context.label
|
||||
|
||||
const resolveResourceFromContext = useCallback(
|
||||
(context: ChatContext): { type: MothershipResourceType; id: string } | null => {
|
||||
switch (context.kind) {
|
||||
case 'workflow':
|
||||
case 'current_workflow':
|
||||
resourceType = 'workflow'
|
||||
resourceId = context.workflowId
|
||||
break
|
||||
return context.workflowId ? { type: 'workflow', id: context.workflowId } : null
|
||||
case 'knowledge':
|
||||
if (context.knowledgeId) {
|
||||
resourceType = 'knowledgebase'
|
||||
resourceId = context.knowledgeId
|
||||
}
|
||||
break
|
||||
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
|
||||
case 'table':
|
||||
if (context.tableId) {
|
||||
resourceType = 'table'
|
||||
resourceId = context.tableId
|
||||
}
|
||||
break
|
||||
return context.tableId ? { type: 'table', id: context.tableId } : null
|
||||
case 'file':
|
||||
if (context.fileId) {
|
||||
resourceType = 'file'
|
||||
resourceId = context.fileId
|
||||
}
|
||||
break
|
||||
return context.fileId ? { type: 'file', id: context.fileId } : null
|
||||
default:
|
||||
break
|
||||
return null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (resourceType && resourceId) {
|
||||
const resource: MothershipResource = {
|
||||
type: resourceType,
|
||||
id: resourceId,
|
||||
title: resourceTitle,
|
||||
}
|
||||
addResource(resource)
|
||||
const handleContextAdd = useCallback(
|
||||
(context: ChatContext) => {
|
||||
const resolved = resolveResourceFromContext(context)
|
||||
if (resolved) {
|
||||
addResource({ ...resolved, title: context.label })
|
||||
handleResourceEvent()
|
||||
}
|
||||
},
|
||||
[addResource, handleResourceEvent]
|
||||
[resolveResourceFromContext, addResource, handleResourceEvent]
|
||||
)
|
||||
|
||||
const handleContextRemove = useCallback(
|
||||
(context: ChatContext) => {
|
||||
const resolved = resolveResourceFromContext(context)
|
||||
if (resolved) removeResource(resolved.type, resolved.id)
|
||||
},
|
||||
[resolveResourceFromContext, removeResource]
|
||||
)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
@@ -345,6 +336,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
onStopGeneration={handleStopGeneration}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
onContextRemove={handleContextRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,6 +367,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
userId={session?.user?.id}
|
||||
chatId={resolvedChatId}
|
||||
onContextAdd={handleContextAdd}
|
||||
onContextRemove={handleContextRemove}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
animateInput={isInputEntering}
|
||||
|
||||
@@ -6,6 +6,9 @@ export type {
|
||||
MothershipResourceType,
|
||||
} from '@/lib/copilot/resource-types'
|
||||
|
||||
/** Union of all valid context kind strings, derived from {@link ChatContext}. */
|
||||
export type ChatContextKind = ChatContext['kind']
|
||||
|
||||
export interface FileAttachmentForApi {
|
||||
id: string
|
||||
key: string
|
||||
@@ -260,13 +263,14 @@ export interface ChatMessageAttachment {
|
||||
}
|
||||
|
||||
export interface ChatMessageContext {
|
||||
kind: string
|
||||
kind: ChatContextKind
|
||||
label: string
|
||||
workflowId?: string
|
||||
knowledgeId?: string
|
||||
tableId?: string
|
||||
fileId?: string
|
||||
folderId?: string
|
||||
chatId?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
@@ -93,7 +94,7 @@ function WorkflowsListInner({
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: `${workflowColor}60`,
|
||||
borderColor: workflowBorderColor(workflowColor),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
FileCards,
|
||||
@@ -431,7 +432,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: c ? `${c}60` : undefined,
|
||||
borderColor: c ? workflowBorderColor(c) : undefined,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge, buttonVariants } from '@/components/emcn'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
@@ -90,7 +91,7 @@ const LogRow = memo(
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: `${workflowColor}60`,
|
||||
borderColor: workflowBorderColor(workflowColor),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasActiveFilters } from '@/lib/logs/filters'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
@@ -124,7 +125,7 @@ function getColorIcon(
|
||||
width: 10,
|
||||
height: 10,
|
||||
...(withRing && {
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box' as const,
|
||||
}),
|
||||
}}
|
||||
@@ -604,7 +605,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-xs border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: selectedWorkflow.color,
|
||||
borderColor: `${selectedWorkflow.color}60`,
|
||||
borderColor: workflowBorderColor(selectedWorkflow.color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
@@ -735,7 +736,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-xs border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: selectedWorkflow.color,
|
||||
borderColor: `${selectedWorkflow.color}60`,
|
||||
borderColor: workflowBorderColor(selectedWorkflow.color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type TriggerData,
|
||||
type WorkflowData,
|
||||
} from '@/lib/logs/search-suggestions'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import type {
|
||||
FilterTag,
|
||||
HeaderAction,
|
||||
@@ -157,7 +158,7 @@ function getColorIcon(
|
||||
width: 10,
|
||||
height: 10,
|
||||
...(withRing && {
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box' as const,
|
||||
}),
|
||||
}}
|
||||
@@ -742,7 +743,7 @@ export default function Logs() {
|
||||
className='h-[10px] w-[10px] rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: `${workflowColor}60`,
|
||||
borderColor: workflowBorderColor(workflowColor),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
@@ -1441,7 +1442,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-xs border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: selectedWorkflow.color,
|
||||
borderColor: `${selectedWorkflow.color}60`,
|
||||
borderColor: workflowBorderColor(selectedWorkflow.color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Combobox, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { formatDate } from '@/lib/core/utils/formatting'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { DeletedItemSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/deleted-item-skeleton'
|
||||
@@ -97,7 +98,7 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
|
||||
className='h-[14px] w-[14px] shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Pencil, SquareArrowUpRight } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
@@ -131,7 +132,7 @@ function WorkflowColorSwatch({ color }: { color: string }) {
|
||||
className='h-[16px] w-[16px] flex-shrink-0 rounded-sm border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { memo } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { Blimp } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import type { CommandItemProps } from '../utils'
|
||||
import { COMMAND_ITEM_CLASSNAME } from '../utils'
|
||||
|
||||
@@ -64,7 +65,7 @@ export const MemoizedWorkflowItem = memo(
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-sm border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
borderColor: workflowBorderColor(color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { generateId } from '@/lib/core/utils/uuid'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -18,6 +19,10 @@ import {
|
||||
useSidebarDragContext,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import {
|
||||
buildDragResources,
|
||||
createSidebarDragGhost,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import {
|
||||
useCanDelete,
|
||||
useDeleteFolder,
|
||||
@@ -136,6 +141,7 @@ export function FolderItem({
|
||||
})
|
||||
|
||||
const isEditingRef = useRef(false)
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const handleCreateWorkflowInFolder = useCallback(() => {
|
||||
const name = generateCreativeWorkflowName()
|
||||
@@ -196,10 +202,24 @@ export function FolderItem({
|
||||
}
|
||||
|
||||
e.dataTransfer.setData('sidebar-selection', JSON.stringify(selection))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.effectAllowed = 'copyMove'
|
||||
|
||||
const resources = buildDragResources(selection, workspaceId)
|
||||
if (resources.length > 0) {
|
||||
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(resources))
|
||||
}
|
||||
|
||||
const total = selection.folderIds.length + selection.workflowIds.length
|
||||
const ghostLabel = total > 1 ? `${folder.name} +${total - 1} more` : folder.name
|
||||
const icon = total === 1 ? { kind: 'folder' as const } : undefined
|
||||
const ghost = createSidebarDragGhost(ghostLabel, icon)
|
||||
void ghost.offsetHeight
|
||||
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
|
||||
dragGhostRef.current = ghost
|
||||
|
||||
onDragStartProp?.()
|
||||
},
|
||||
[folder.id, onDragStartProp]
|
||||
[folder.id, folder.name, workspaceId, onDragStartProp]
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -212,6 +232,10 @@ export function FolderItem({
|
||||
})
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragGhostRef.current) {
|
||||
dragGhostRef.current.remove()
|
||||
dragGhostRef.current = null
|
||||
}
|
||||
handleDragEndBase()
|
||||
onDragEndProp?.()
|
||||
}, [handleDragEndBase, onDragEndProp])
|
||||
|
||||
@@ -5,6 +5,8 @@ import clsx from 'clsx'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
@@ -16,6 +18,10 @@ import {
|
||||
useItemRename,
|
||||
useSidebarDragContext,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import {
|
||||
buildDragResources,
|
||||
createSidebarDragGhost,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import {
|
||||
useCanDelete,
|
||||
useDeleteSelection,
|
||||
@@ -198,6 +204,7 @@ export function WorkflowItem({
|
||||
}, [isActiveWorkflow, isWorkflowLocked])
|
||||
|
||||
const isEditingRef = useRef(false)
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -337,10 +344,25 @@ export function WorkflowItem({
|
||||
}
|
||||
|
||||
e.dataTransfer.setData('sidebar-selection', JSON.stringify(selection))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.effectAllowed = 'copyMove'
|
||||
|
||||
const resources = buildDragResources(selection, workspaceId)
|
||||
if (resources.length > 0) {
|
||||
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(resources))
|
||||
}
|
||||
|
||||
const total = selection.workflowIds.length + selection.folderIds.length
|
||||
const ghostLabel = total > 1 ? `${workflow.name} +${total - 1} more` : workflow.name
|
||||
const icon = total === 1 ? { kind: 'workflow' as const, color: workflow.color } : undefined
|
||||
const ghost = createSidebarDragGhost(ghostLabel, icon)
|
||||
// Force reflow so the browser can capture the rendered element
|
||||
void ghost.offsetHeight
|
||||
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
|
||||
dragGhostRef.current = ghost
|
||||
|
||||
onDragStartProp?.()
|
||||
},
|
||||
[workflow.id, onDragStartProp]
|
||||
[workflow.id, workflow.name, workflow.color, workspaceId, onDragStartProp]
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -353,6 +375,10 @@ export function WorkflowItem({
|
||||
})
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragGhostRef.current) {
|
||||
dragGhostRef.current.remove()
|
||||
dragGhostRef.current = null
|
||||
}
|
||||
handleDragEndBase()
|
||||
onDragEndProp?.()
|
||||
}, [handleDragEndBase, onDragEndProp])
|
||||
@@ -414,7 +440,7 @@ export function WorkflowItem({
|
||||
className='h-[16px] w-[16px] flex-shrink-0 rounded-sm border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
borderColor: workflowBorderColor(workflow.color),
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Wordmark,
|
||||
} from '@/components/emcn/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isMacPlatform } from '@/lib/core/utils/platform'
|
||||
import { buildFolderTree } from '@/lib/folders/tree'
|
||||
@@ -72,7 +73,10 @@ import {
|
||||
useWorkflowOperations,
|
||||
useWorkspaceManagement,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { groupWorkflowsByFolder } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import {
|
||||
createSidebarDragGhost,
|
||||
groupWorkflowsByFolder,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import {
|
||||
useDuplicateWorkspace,
|
||||
useExportWorkspace,
|
||||
@@ -159,6 +163,30 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
onMorePointerDown: () => void
|
||||
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, taskId: string) => void
|
||||
}) {
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = 'copyMove'
|
||||
e.dataTransfer.setData(
|
||||
SIM_RESOURCES_DRAG_TYPE,
|
||||
JSON.stringify([{ type: 'task', id: task.id, title: task.name }])
|
||||
)
|
||||
const ghost = createSidebarDragGhost(task.name, { kind: 'task' })
|
||||
void ghost.offsetHeight
|
||||
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
|
||||
dragGhostRef.current = ghost
|
||||
},
|
||||
[task.id, task.name]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragGhostRef.current) {
|
||||
dragGhostRef.current.remove()
|
||||
dragGhostRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SidebarTooltip label={task.name} enabled={showCollapsedTooltips}>
|
||||
<Link
|
||||
@@ -182,6 +210,9 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
}
|
||||
}}
|
||||
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
|
||||
draggable={task.id !== 'new'}
|
||||
onDragStart={task.id !== 'new' ? handleDragStart : undefined}
|
||||
onDragEnd={task.id !== 'new' ? handleDragEnd : undefined}
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>{task.name}</div>
|
||||
|
||||
@@ -1,5 +1,96 @@
|
||||
import type { MothershipResource } from '@/lib/copilot/resource-types'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
/**
|
||||
* Builds a `MothershipResource` array from a sidebar drag selection so it can
|
||||
* be set as `application/x-sim-resources` drag data and dropped into the chat.
|
||||
*/
|
||||
export function buildDragResources(
|
||||
selection: { workflowIds: string[]; folderIds: string[] },
|
||||
workspaceId: string
|
||||
): MothershipResource[] {
|
||||
const allWorkflows = getWorkflows(workspaceId)
|
||||
const workflowMap = Object.fromEntries(allWorkflows.map((w) => [w.id, w]))
|
||||
const folderMap = getFolderMap(workspaceId)
|
||||
return [
|
||||
...selection.workflowIds.map((id) => ({
|
||||
type: 'workflow' as const,
|
||||
id,
|
||||
title: workflowMap[id]?.name ?? id,
|
||||
})),
|
||||
...selection.folderIds.map((id) => ({
|
||||
type: 'folder' as const,
|
||||
id,
|
||||
title: folderMap[id]?.name ?? id,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export type SidebarDragGhostIcon =
|
||||
| { kind: 'workflow'; color: string }
|
||||
| { kind: 'folder' }
|
||||
| { kind: 'task' }
|
||||
|
||||
const FOLDER_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`
|
||||
|
||||
const BLIMP_SVG = `<svg width="14" height="14" viewBox="1.25 1.25 18 18" fill="currentColor" stroke="currentColor" stroke-width="0.75" stroke-linejoin="round" aria-hidden="true"><path transform="translate(20.5, 0) scale(-1, 1)" d="M18.24 9.18C18.16 8.94 18 8.74 17.83 8.56L17.83 8.56C17.67 8.4 17.49 8.25 17.3 8.11V5.48C17.3 5.32 17.24 5.17 17.14 5.06C17.06 4.95 16.93 4.89 16.79 4.89H15.93C15.61 4.89 15.32 5.11 15.19 5.44L14.68 6.77C14.05 6.51 13.23 6.22 12.15 6C11.04 5.77 9.66 5.61 7.9 5.61C5.97 5.61 4.56 6.13 3.61 6.89C3.14 7.28 2.78 7.72 2.54 8.19C2.29 8.66 2.18 9.15 2.18 9.63C2.18 10.1 2.29 10.59 2.52 11.06C2.87 11.76 3.48 12.41 4.34 12.89C4.91 13.2 5.61 13.44 6.43 13.56L6.8 14.78C6.94 15.27 7.33 15.59 7.78 15.59H10.56C11.06 15.59 11.48 15.18 11.58 14.61L11.81 13.29C12.31 13.2 12.75 13.09 13.14 12.99C13.74 12.82 14.24 12.64 14.67 12.48L15.19 13.82C15.32 14.16 15.61 14.38 15.93 14.38H16.79C16.93 14.38 17.06 14.31 17.14 14.2C17.24 14.1 17.29 13.95 17.3 13.79V11.15C17.33 11.12 17.37 11.09 17.42 11.07L17.4 11.07L17.42 11.07C17.65 10.89 17.87 10.69 18.04 10.46C18.12 10.35 18.19 10.22 18.24 10.08C18.29 9.94 18.32 9.79 18.32 9.63C18.32 9.47 18.29 9.32 18.24 9.18Z"/></svg>`
|
||||
|
||||
/**
|
||||
* Creates a lightweight drag ghost pill showing an icon and label for the item(s) being dragged.
|
||||
* Append to `document.body`, pass to `e.dataTransfer.setDragImage`, then remove on dragend.
|
||||
*/
|
||||
export function createSidebarDragGhost(label: string, icon?: SidebarDragGhostIcon): HTMLElement {
|
||||
const ghost = document.createElement('div')
|
||||
ghost.style.cssText = `
|
||||
position: fixed;
|
||||
top: -500px;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--surface-active);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text-body);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
z-index: 9999;
|
||||
`
|
||||
|
||||
if (icon) {
|
||||
if (icon.kind === 'workflow') {
|
||||
const square = document.createElement('div')
|
||||
square.style.cssText = `
|
||||
width: 14px; height: 14px; flex-shrink: 0;
|
||||
border-radius: 3px; border: 2px solid ${workflowBorderColor(icon.color)};
|
||||
background: ${icon.color}; background-clip: padding-box;
|
||||
`
|
||||
ghost.appendChild(square)
|
||||
} else {
|
||||
const iconWrapper = document.createElement('div')
|
||||
iconWrapper.style.cssText =
|
||||
'display: flex; align-items: center; flex-shrink: 0; color: var(--text-icon);'
|
||||
iconWrapper.innerHTML = icon.kind === 'folder' ? FOLDER_SVG : BLIMP_SVG
|
||||
ghost.appendChild(iconWrapper)
|
||||
}
|
||||
}
|
||||
|
||||
const text = document.createElement('span')
|
||||
text.style.cssText = 'max-width: 200px; overflow: hidden; text-overflow: ellipsis;'
|
||||
text.textContent = label
|
||||
ghost.appendChild(text)
|
||||
|
||||
document.body.appendChild(ghost)
|
||||
return ghost
|
||||
}
|
||||
|
||||
export function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
|
||||
a: T,
|
||||
b: T
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
export interface TaskMetadata {
|
||||
id: string
|
||||
@@ -42,13 +42,14 @@ export interface TaskStoredFileAttachment {
|
||||
}
|
||||
|
||||
export interface TaskStoredMessageContext {
|
||||
kind: string
|
||||
kind: ChatContextKind
|
||||
label: string
|
||||
workflowId?: string
|
||||
knowledgeId?: string
|
||||
tableId?: string
|
||||
fileId?: string
|
||||
folderId?: string
|
||||
chatId?: string
|
||||
}
|
||||
|
||||
export interface TaskStoredMessage {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type MothershipResourceType =
|
||||
| 'workflow'
|
||||
| 'knowledgebase'
|
||||
| 'folder'
|
||||
| 'task'
|
||||
| 'generic'
|
||||
|
||||
export interface MothershipResource {
|
||||
@@ -19,3 +20,9 @@ export const VFS_DIR_TO_RESOURCE: Record<string, MothershipResourceType> = {
|
||||
knowledgebases: 'knowledgebase',
|
||||
folders: 'folder',
|
||||
} as const
|
||||
|
||||
/** MIME type for a single dragged resource (used by resource-tabs internal reordering). */
|
||||
export const SIM_RESOURCE_DRAG_TYPE = 'application/x-sim-resource' as const
|
||||
|
||||
/** MIME type for an array of dragged resources (used by sidebar drag-to-chat). */
|
||||
export const SIM_RESOURCES_DRAG_TYPE = 'application/x-sim-resources' as const
|
||||
|
||||
@@ -77,6 +77,14 @@ function withAlpha(hexColor: string, alpha: number): string {
|
||||
return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hex color with 60/ff (~38%) alpha — used for workflow color border accents.
|
||||
* Returns `undefined` when `color` is undefined so callers can pass it directly to `borderColor`.
|
||||
*/
|
||||
export function workflowBorderColor(color: string | undefined): string | undefined {
|
||||
return color ? `${color}60` : undefined
|
||||
}
|
||||
|
||||
function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string {
|
||||
const rotation = (rotationSeed * 25) % 360
|
||||
return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})`
|
||||
|
||||
Reference in New Issue
Block a user