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:
Waleed
2026-04-07 20:06:21 -07:00
committed by GitHub
parent 98be968b54
commit 6c3caf61e1
31 changed files with 435 additions and 148 deletions

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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
}
}

View File

@@ -1,4 +1,5 @@
export { ChatMessageAttachments } from './chat-message-attachments'
export { ContextMentionIcon } from './context-mention-icon'
export {
assistantMessageHasRenderableContent,
MessageContent,

View File

@@ -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}

View File

@@ -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]
)
}

View File

@@ -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',
}}
/>

View File

@@ -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) })
},
}
/**

View File

@@ -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 })
)
}

View File

@@ -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 }
}

View File

@@ -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()
}
}
}, [])

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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',
}}
/>

View File

@@ -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])

View File

@@ -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',
}}
/>

View File

@@ -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>

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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})`