diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts new file mode 100644 index 0000000000..b9443c2411 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -0,0 +1,187 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import type { ChatResource, ResourceType } from '@/lib/copilot/resources' +import { + authenticateCopilotRequestSessionOnly, + createBadRequestResponse, + createInternalServerErrorResponse, + createNotFoundResponse, + createUnauthorizedResponse, +} from '@/lib/copilot/request-helpers' + +const logger = createLogger('CopilotChatResourcesAPI') + +const VALID_RESOURCE_TYPES = new Set(['table', 'file', 'workflow', 'knowledgebase']) +const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base']) + +const AddResourceSchema = z.object({ + chatId: z.string(), + resource: z.object({ + type: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + id: z.string(), + title: z.string(), + }), +}) + +const RemoveResourceSchema = z.object({ + chatId: z.string(), + resourceType: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + resourceId: z.string(), +}) + +const ReorderResourcesSchema = z.object({ + chatId: z.string(), + resources: z.array( + z.object({ + type: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + id: z.string(), + title: z.string(), + }) + ), +}) + +export async function POST(req: NextRequest) { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await req.json() + const { chatId, resource } = AddResourceSchema.parse(body) + + if (!VALID_RESOURCE_TYPES.has(resource.type)) { + return createBadRequestResponse(`Invalid resource type: ${resource.type}`) + } + + const [chat] = await db + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .limit(1) + + if (!chat) { + return createNotFoundResponse('Chat not found or unauthorized') + } + + const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + const key = `${resource.type}:${resource.id}` + const prev = existing.find((r) => `${r.type}:${r.id}` === key) + + let merged: ChatResource[] + if (prev) { + if (GENERIC_TITLES.has(prev.title) && !GENERIC_TITLES.has(resource.title)) { + merged = existing.map((r) => (`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r)) + } else { + merged = existing + } + } else { + merged = [...existing, resource] + } + + await db + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() }) + .where(eq(copilotChats.id, chatId)) + + logger.info('Added resource to chat', { chatId, resource }) + + return NextResponse.json({ success: true, resources: merged }) + } catch (error) { + if (error instanceof z.ZodError) { + return createBadRequestResponse(error.errors.map((e) => e.message).join(', ')) + } + logger.error('Error adding chat resource:', error) + return createInternalServerErrorResponse('Failed to add resource') + } +} + +export async function PATCH(req: NextRequest) { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await req.json() + const { chatId, resources: newOrder } = ReorderResourcesSchema.parse(body) + + const [chat] = await db + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .limit(1) + + if (!chat) { + return createNotFoundResponse('Chat not found or unauthorized') + } + + const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`)) + const newKeys = new Set(newOrder.map((r) => `${r.type}:${r.id}`)) + + if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) { + return createBadRequestResponse('Reordered resources must match existing resources') + } + + await db + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() }) + .where(eq(copilotChats.id, chatId)) + + logger.info('Reordered resources for chat', { chatId, count: newOrder.length }) + + return NextResponse.json({ success: true, resources: newOrder }) + } catch (error) { + if (error instanceof z.ZodError) { + return createBadRequestResponse(error.errors.map((e) => e.message).join(', ')) + } + logger.error('Error reordering chat resources:', error) + return createInternalServerErrorResponse('Failed to reorder resources') + } +} + +export async function DELETE(req: NextRequest) { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await req.json() + const { chatId, resourceType, resourceId } = RemoveResourceSchema.parse(body) + + const [chat] = await db + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .limit(1) + + if (!chat) { + return createNotFoundResponse('Chat not found or unauthorized') + } + + const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + const key = `${resourceType}:${resourceId}` + const merged = existing.filter((r) => `${r.type}:${r.id}` !== key) + + await db + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() }) + .where(eq(copilotChats.id, chatId)) + + logger.info('Removed resource from chat', { chatId, resourceType, resourceId }) + + return NextResponse.json({ success: true, resources: merged }) + } catch (error) { + if (error instanceof z.ZodError) { + return createBadRequestResponse(error.errors.map((e) => e.message).join(', ')) + } + logger.error('Error removing chat resource:', error) + return createInternalServerErrorResponse('Failed to remove resource') + } +} diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index ddf89ad1b2..8da3452329 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -444,6 +444,7 @@ export async function GET(req: NextRequest) { planArtifact: copilotChats.planArtifact, config: copilotChats.config, conversationId: copilotChats.conversationId, + resources: copilotChats.resources, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, }) @@ -464,6 +465,7 @@ export async function GET(req: NextRequest) { planArtifact: chat.planArtifact || null, config: chat.config || null, conversationId: chat.conversationId || null, + resources: Array.isArray(chat.resources) ? chat.resources : [], createdAt: chat.createdAt, updatedAt: chat.updatedAt, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx new file mode 100644 index 0000000000..d3466bd552 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -0,0 +1,363 @@ +'use client' + +import { + type RefCallback, + useCallback, + useMemo, + useState, +} from 'react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Tooltip, +} from '@/components/emcn' +import { Plus, Search } from '@/components/emcn/icons' +import { ChevronRight, Folder } from 'lucide-react' +import { cn } from '@/lib/core/utils/cn' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useFolders } from '@/hooks/queries/folders' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useFolderStore } from '@/stores/folders/store' +import type { FolderTreeNode } from '@/stores/folders/types' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' + +export interface AddResourceDropdownProps { + workspaceId: string + existingKeys: Set + onAdd: (resource: MothershipResource) => void + onSwitch?: (resourceId: string) => void +} + +export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown } + +interface AvailableItemsByType { + type: MothershipResourceType + items: AvailableItem[] +} + +const EMPTY_SUBMENU = ( + + None available + +) + +export function useAvailableResources(workspaceId: string, existingKeys: Set): AvailableItemsByType[] { + const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: tables = [] } = useTablesList(workspaceId) + const { data: files = [] } = useWorkspaceFiles(workspaceId) + const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) + + return useMemo(() => [ + { + type: 'workflow' as const, + items: workflows + .map((w) => ({ id: w.id, name: w.name, color: w.color, folderId: w.folderId, isOpen: existingKeys.has(`workflow:${w.id}`) })), + }, + { + type: 'table' as const, + items: tables + .map((t) => ({ id: t.id, name: t.name, isOpen: existingKeys.has(`table:${t.id}`) })), + }, + { + type: 'file' as const, + items: files + .map((f) => ({ id: f.id, name: f.name, isOpen: existingKeys.has(`file:${f.id}`) })), + }, + { + type: 'knowledgebase' as const, + items: (knowledgeBases ?? []) + .map((kb) => ({ id: kb.id, name: kb.name, isOpen: existingKeys.has(`knowledgebase:${kb.id}`) })), + }, + ], [workflows, tables, files, knowledgeBases, existingKeys]) +} + +function CollapsibleFolder({ + folder, + workflows, + expanded, + onToggle, + onSelect, + config, + level, +}: { + folder: FolderTreeNode + workflows: AvailableItem[] + expanded: Set + onToggle: (id: string) => void + onSelect: (item: AvailableItem) => void + config: ReturnType + level: number +}) { + const folderWorkflows = workflows.filter( + (w) => (w.folderId as string | null) === folder.id + ) + const isExpanded = expanded.has(folder.id) + const indent = level * 12 + + return ( + <> +
{ e.preventDefault(); onToggle(folder.id) }} + onKeyDown={(e) => { if (e.key === 'Enter') onToggle(folder.id) }} + className='flex cursor-pointer items-center gap-[6px] rounded-sm px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]' + style={{ paddingLeft: `${8 + indent}px` }} + > + + + {folder.name} +
+ {isExpanded && ( + <> + {folder.children.map((child) => ( + + ))} + {folderWorkflows.map((item) => ( + onSelect(item)} + style={{ paddingLeft: `${8 + (level + 1) * 12}px` }} + > + {config.renderDropdownItem({ item })} + + ))} + + )} + + ) +} + +function WorkflowSubmenuContent({ + workspaceId, + items, + config, + onSelect, +}: { + workspaceId: string + items: AvailableItem[] + config: ReturnType + onSelect: (item: AvailableItem) => void +}) { + useFolders(workspaceId) + const folders = useFolderStore((state) => state.folders) + const getFolderTree = useFolderStore((state) => state.getFolderTree) + const folderTree = useMemo(() => getFolderTree(workspaceId), [folders, getFolderTree, workspaceId]) + const [expanded, setExpanded] = useState>(new Set()) + + const toggleFolder = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const workflowsByFolder = useMemo(() => { + const grouped: Record = {} + for (const item of items) { + const fId = (item.folderId as string | null) ?? 'root' + if (!grouped[fId]) grouped[fId] = [] + grouped[fId].push(item) + } + return grouped + }, [items]) + + const rootWorkflows = workflowsByFolder.root ?? [] + + const folderTreeHasItems = useCallback( + (folder: FolderTreeNode): boolean => { + if (workflowsByFolder[folder.id]?.length) return true + return folder.children.some(folderTreeHasItems) + }, + [workflowsByFolder] + ) + + const visibleFolders = useMemo( + () => folderTree.filter(folderTreeHasItems), + [folderTree, folderTreeHasItems] + ) + + if (items.length === 0) return EMPTY_SUBMENU + + return ( + <> + {visibleFolders.map((folder) => ( + + ))} + {rootWorkflows.map((item) => ( + onSelect(item)}> + {config.renderDropdownItem({ item })} + + ))} + + ) +} + +export function AddResourceDropdown({ workspaceId, existingKeys, onAdd, onSwitch }: AddResourceDropdownProps) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const available = useAvailableResources(workspaceId, existingKeys) + const inputRef = useCallback>((node) => { + if (node) setTimeout(() => node.focus(), 0) + }, []) + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next) + if (!next) setSearch('') + }, []) + + const select = useCallback( + (resource: MothershipResource, isOpen?: boolean) => { + if (isOpen && onSwitch) { + onSwitch(resource.id) + } else { + onAdd(resource) + } + setOpen(false) + setSearch('') + }, + [onAdd, onSwitch] + ) + + const query = search.trim().toLowerCase() + + const filtered = useMemo(() => { + if (!query) return null + return available.flatMap(({ type, items }) => + items + .filter((item) => item.name.toLowerCase().includes(query)) + .map((item) => ({ type, item })) + ) + }, [available, query]) + + return ( + + + + + + + + +

Add resource

+
+
+ +
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + placeholder='Search resources…' + className='h-[20px] w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]' + /> +
+ + {filtered ? ( + filtered.length > 0 ? ( +
+ {filtered.map(({ type, item }) => { + const config = getResourceConfig(type) + return ( + select({ type, id: item.id, title: item.name }, item.isOpen)} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + })} +
+ ) : ( +
+ No results +
+ ) + ) : ( + available.map(({ type, items }) => { + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + + {config.label} + + + {type === 'workflow' ? ( + select({ type, id: item.id, title: item.name }, item.isOpen)} + /> + ) : items.length > 0 ? ( + items.map((item) => ( + select({ type, id: item.id, title: item.name }, item.isOpen)} + > + {config.renderDropdownItem({ item })} + + )) + ) : ( + EMPTY_SUBMENU + )} + + + ) + }) + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts new file mode 100644 index 0000000000..b10aa4dde4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -0,0 +1,3 @@ +export { AddResourceDropdown } from './add-resource-dropdown' +export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown' +export { useAvailableResources } from './add-resource-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index 1aee2f9aa2..acc628b795 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts @@ -1,6 +1,5 @@ -export { - EmbeddedKnowledgeBaseActions, - EmbeddedWorkflowActions, - ResourceContent, -} from './resource-content' +export { AddResourceDropdown } from './add-resource-dropdown' +export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown' +export { useAvailableResources } from './add-resource-dropdown' +export { ResourceActions, ResourceContent } from './resource-content' export { ResourceTabs } from './resource-tabs' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts index 29d9d26d38..9dd2b9e9da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts @@ -1,5 +1 @@ -export { - EmbeddedKnowledgeBaseActions, - EmbeddedWorkflowActions, - ResourceContent, -} from './resource-content' +export { ResourceActions, ResourceContent } from './resource-content' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 881cf23bef..42091ecf92 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -4,8 +4,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react' import { Square } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' -import { BookOpen } from '@/components/emcn/icons' -import { WorkflowIcon } from '@/components/icons' +import { BookOpen, SquareArrowUpRight } from '@/components/emcn/icons' import { markRunToolManuallyStopped, reportManualRunToolStop, @@ -83,6 +82,22 @@ export function ResourceContent({ workspaceId, resource, previewMode }: Resource } } +interface ResourceActionsProps { + workspaceId: string + resource: MothershipResource +} + +export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) { + switch (resource.type) { + case 'workflow': + return + case 'knowledgebase': + return + default: + return null + } +} + interface EmbeddedWorkflowActionsProps { workspaceId: string workflowId: string @@ -146,7 +161,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]' aria-label='Open workflow' > - + diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry.tsx new file mode 100644 index 0000000000..d55746c321 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry.tsx @@ -0,0 +1,155 @@ +'use client' + +import { type ElementType, type ReactNode } from 'react' +import type { QueryClient } from '@tanstack/react-query' +import { Database, File as FileIcon, Table as TableIcon } from '@/components/emcn/icons' +import { WorkflowIcon } from '@/components/icons' +import { cn } from '@/lib/core/utils/cn' +import { getDocumentIcon } from '@/components/icons/document-icons' +import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { tableKeys } from '@/hooks/queries/tables' +import { workflowKeys } from '@/hooks/queries/workflows' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface DropdownItemRenderProps { + item: { id: string; name: string; [key: string]: unknown } +} + +export interface ResourceTypeConfig { + type: MothershipResourceType + label: string + icon: ElementType + renderTabIcon: (resource: MothershipResource, className: string) => ReactNode + renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode +} + +function WorkflowTabSquare({ workflowId, className }: { workflowId: string; className?: string }) { + const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888') + return ( +
+ ) +} + +function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { + const color = (item.color as string) ?? '#888' + return ( + <> +
+ {item.name} + + ) +} + +function DefaultDropdownItem({ item }: DropdownItemRenderProps) { + return {item.name} +} + +function FileDropdownItem({ item }: DropdownItemRenderProps) { + const DocIcon = getDocumentIcon('', item.name) + return ( + <> + + {item.name} + + ) +} + +export const RESOURCE_REGISTRY: Record = { + workflow: { + type: 'workflow', + label: 'Workflows', + icon: WorkflowIcon, + renderTabIcon: (resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, + table: { + type: 'table', + label: 'Tables', + icon: TableIcon, + renderTabIcon: (_resource, className) => , + renderDropdownItem: (props) => , + }, + file: { + type: 'file', + label: 'Files', + icon: FileIcon, + renderTabIcon: (resource, className) => { + const DocIcon = getDocumentIcon('', resource.title) + return + }, + renderDropdownItem: (props) => , + }, + knowledgebase: { + type: 'knowledgebase', + label: 'Knowledge Bases', + icon: Database, + renderTabIcon: (_resource, className) => , + renderDropdownItem: (props) => , + }, +} as const + +export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY) + +export function getResourceConfig(type: MothershipResourceType): ResourceTypeConfig { + return RESOURCE_REGISTRY[type] +} + +// --------------------------------------------------------------------------- +// Resource query invalidation +// --------------------------------------------------------------------------- + +const RESOURCE_INVALIDATORS: Record< + MothershipResourceType, + (qc: QueryClient, workspaceId: string, resourceId: string) => void +> = { + table: (qc, wId, id) => { + qc.invalidateQueries({ queryKey: tableKeys.list(wId) }) + qc.invalidateQueries({ queryKey: tableKeys.detail(id) }) + }, + file: (qc, wId, id) => { + qc.invalidateQueries({ queryKey: workspaceFilesKeys.list(wId) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.content(wId, id) }) + }, + workflow: (qc, wId) => { + qc.invalidateQueries({ queryKey: workflowKeys.list(wId) }) + }, + knowledgebase: (qc, wId, id) => { + qc.invalidateQueries({ queryKey: knowledgeKeys.list(wId) }) + qc.invalidateQueries({ queryKey: knowledgeKeys.detail(id) }) + }, +} + +/** + * Invalidate list and detail queries for a specific resource. + * Called when a `resource_added` event arrives so the embedded view refreshes + * and the add-resource dropdown stays up to date. + */ +export function invalidateResourceQueries( + queryClient: QueryClient, + workspaceId: string, + resourceType: MothershipResourceType, + resourceId: string +): void { + RESOURCE_INVALIDATORS[resourceType](queryClient, workspaceId, resourceId) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 21986da681..1f3e2f5975 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -1,16 +1,31 @@ -'use client' - -import { type ElementType, type ReactNode, type SVGProps, useEffect, useRef } from 'react' -import { Button, Tooltip } from '@/components/emcn' -import { BookOpen, PanelLeft, Table as TableIcon } from '@/components/emcn/icons' -import { WorkflowIcon } from '@/components/icons' -import { getDocumentIcon } from '@/components/icons/document-icons' +import { + type ReactNode, + type RefCallback, + type SVGProps, + useCallback, + useMemo, + useEffect, + useRef, + useState, +} from 'react' +import { + Button, + Tooltip, +} from '@/components/emcn' +import { PanelLeft } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useTablesList } from '@/hooks/queries/tables' +import { useAddChatResource, useRemoveChatResource, useReorderChatResources } from '@/hooks/queries/tasks' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' const LEFT_HALF = 'M10.25 0.75H3.25C1.86929 0.75 0.75 1.86929 0.75 3.25V16.25C0.75 17.6307 1.86929 18.75 3.25 18.75H10.25V0.75Z' @@ -41,42 +56,59 @@ function PreviewModeIcon({ mode, ...props }: { mode: PreviewMode } & SVGProps current name lookup from live query data so resource + * tabs always reflect the latest name even after a rename. + */ +function useResourceNameLookup(workspaceId: string): Map { + const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: tables = [] } = useTablesList(workspaceId) + const { data: files = [] } = useWorkspaceFiles(workspaceId) + const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) + + return useMemo(() => { + const map = new Map() + for (const w of workflows) map.set(`workflow:${w.id}`, w.name) + for (const t of tables) map.set(`table:${t.id}`, t.name) + for (const f of files) map.set(`file:${f.id}`, f.name) + for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) + return map + }, [workflows, tables, files, knowledgeBases]) +} + interface ResourceTabsProps { + workspaceId: string + chatId?: string resources: MothershipResource[] activeId: string | null onSelect: (id: string) => void + onAddResource: (resource: MothershipResource) => void + onRemoveResource: (resourceType: MothershipResourceType, resourceId: string) => void + onReorderResources: (resources: MothershipResource[]) => void onCollapse: () => void previewMode?: PreviewMode onCyclePreviewMode?: () => void actions?: ReactNode } -const RESOURCE_ICONS: Record, ElementType> = { - table: TableIcon, - workflow: WorkflowIcon, - knowledgebase: BookOpen, -} - -function getResourceIcon(resource: MothershipResource): ElementType { - if (resource.type === 'file') { - return getDocumentIcon('', resource.title) - } - return RESOURCE_ICONS[resource.type] -} - -/** - * Horizontal tab bar for switching between mothership resources. - * Renders each resource as a subtle Button matching ResourceHeader actions. - */ export function ResourceTabs({ + workspaceId, + chatId, resources, activeId, onSelect, + onAddResource, + onRemoveResource, + onReorderResources, onCollapse, previewMode, onCyclePreviewMode, actions, }: ResourceTabsProps) { + const nameLookup = useResourceNameLookup(workspaceId) const scrollNodeRef = useRef(null) useEffect(() => { @@ -92,6 +124,132 @@ export function ResourceTabs({ return () => node.removeEventListener('wheel', handler) }, []) + const addResource = useAddChatResource(chatId) + const removeResource = useRemoveChatResource(chatId) + const reorderResources = useReorderChatResources(chatId) + + const [hoveredTabId, setHoveredTabId] = useState(null) + const [draggedIdx, setDraggedIdx] = useState(null) + const [dropGapIdx, setDropGapIdx] = useState(null) + const dragStartIdx = useRef(null) + const autoScrollRaf = useRef(null) + + const existingKeys = useMemo( + () => new Set(resources.map((r) => `${r.type}:${r.id}`)), + [resources] + ) + + const handleAdd = useCallback( + (resource: MothershipResource) => { + if (!chatId) return + addResource.mutate({ chatId, resource }) + onAddResource(resource) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [chatId, onAddResource] + ) + + const handleRemove = useCallback( + (e: React.MouseEvent, resource: MothershipResource) => { + e.stopPropagation() + if (!chatId) return + removeResource.mutate({ chatId, resourceType: resource.type, resourceId: resource.id }) + onRemoveResource(resource.type, resource.id) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [chatId, onRemoveResource] + ) + + const handleDragStart = useCallback((e: React.DragEvent, idx: number) => { + dragStartIdx.current = idx + setDraggedIdx(idx) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', String(idx)) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = e.currentTarget.getBoundingClientRect() + const midpoint = rect.left + rect.width / 2 + const gap = e.clientX < midpoint ? idx : idx + 1 + setDropGapIdx(gap) + + const container = scrollNodeRef.current + if (!container) return + const cRect = container.getBoundingClientRect() + const x = e.clientX + if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current) + if (x < cRect.left + EDGE_ZONE) { + const tick = () => { + container.scrollLeft -= SCROLL_SPEED + autoScrollRaf.current = requestAnimationFrame(tick) + } + autoScrollRaf.current = requestAnimationFrame(tick) + } else if (x > cRect.right - EDGE_ZONE) { + const tick = () => { + container.scrollLeft += SCROLL_SPEED + autoScrollRaf.current = requestAnimationFrame(tick) + } + autoScrollRaf.current = requestAnimationFrame(tick) + } else { + autoScrollRaf.current = null + } + }, []) + + const stopAutoScroll = useCallback(() => { + if (autoScrollRaf.current) { + cancelAnimationFrame(autoScrollRaf.current) + autoScrollRaf.current = null + } + }, []) + + const handleDragLeave = useCallback(() => { + setDropGapIdx(null) + stopAutoScroll() + }, [stopAutoScroll]) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + stopAutoScroll() + const fromIdx = dragStartIdx.current + const gapIdx = dropGapIdx + if (fromIdx === null || gapIdx === null) { + setDraggedIdx(null) + setDropGapIdx(null) + dragStartIdx.current = null + return + } + const insertAt = gapIdx > fromIdx ? gapIdx - 1 : gapIdx + if (insertAt === fromIdx) { + setDraggedIdx(null) + setDropGapIdx(null) + dragStartIdx.current = null + return + } + const reordered = [...resources] + const [moved] = reordered.splice(fromIdx, 1) + reordered.splice(insertAt, 0, moved) + onReorderResources(reordered) + if (chatId) { + reorderResources.mutate({ chatId, resources: reordered }) + } + setDraggedIdx(null) + setDropGapIdx(null) + dragStartIdx.current = null + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [chatId, resources, onReorderResources, dropGapIdx, stopAutoScroll] + ) + + const handleDragEnd = useCallback(() => { + stopAutoScroll() + setDraggedIdx(null) + setDropGapIdx(null) + dragStartIdx.current = null + }, [stopAutoScroll]) + return (
@@ -112,56 +270,132 @@ export function ResourceTabs({
{ + e.preventDefault() + const container = scrollNodeRef.current + if (!container) return + const cRect = container.getBoundingClientRect() + const x = e.clientX + if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current) + if (x < cRect.left + EDGE_ZONE) { + const tick = () => { + container.scrollLeft -= SCROLL_SPEED + autoScrollRaf.current = requestAnimationFrame(tick) + } + autoScrollRaf.current = requestAnimationFrame(tick) + } else if (x > cRect.right - EDGE_ZONE) { + const tick = () => { + container.scrollLeft += SCROLL_SPEED + autoScrollRaf.current = requestAnimationFrame(tick) + } + autoScrollRaf.current = requestAnimationFrame(tick) + } else { + stopAutoScroll() + } + }} + onDrop={handleDrop} > - {resources.map((resource) => { - const Icon = getResourceIcon(resource) + {resources.map((resource, idx) => { + const config = getResourceConfig(resource.type) + const displayName = nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title const isActive = activeId === resource.id + const isHovered = hoveredTabId === resource.id + const isDragging = draggedIdx === idx + const showGapBefore = dropGapIdx === idx && draggedIdx !== null && draggedIdx !== idx && draggedIdx !== idx - 1 + const showGapAfter = idx === resources.length - 1 && dropGapIdx === resources.length && draggedIdx !== null && draggedIdx !== idx return ( - +
+ {showGapBefore && ( +
+ )} + + + + + +

{displayName}

+
+
+ {showGapAfter && ( +
+ )} +
+ ) + })} +
+ {chatId && ( + + )} + {(actions || (previewMode && onCyclePreviewMode)) && ( +
+ {actions} + {previewMode && onCyclePreviewMode && ( + -

{resource.title}

+

Preview mode

- ) - })} -
-
- {actions} - {previewMode && onCyclePreviewMode && ( - - - - - -

Preview mode

-
-
- )} -
+ )} +
+ )}
) } + diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index 4af038c9b3..7cd5b71326 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -4,13 +4,11 @@ import { useCallback, useEffect, useState } from 'react' import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' -import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' -import { - EmbeddedKnowledgeBaseActions, - EmbeddedWorkflowActions, - ResourceContent, - ResourceTabs, -} from './components' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' +import { ResourceActions, ResourceContent, ResourceTabs } from './components' const PREVIEWABLE_EXTENSIONS = new Set(['md', 'html', 'htm', 'csv']) const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm']) @@ -23,24 +21,27 @@ const PREVIEW_CYCLE: Record = { interface MothershipViewProps { workspaceId: string + chatId?: string resources: MothershipResource[] activeResourceId: string | null onSelectResource: (id: string) => void + onAddResource: (resource: MothershipResource) => void + onRemoveResource: (resourceType: MothershipResourceType, resourceId: string) => void + onReorderResources: (resources: MothershipResource[]) => void onCollapse: () => void isCollapsed: boolean className?: string } -/** - * Split-pane view that renders embedded resources (tables, files, workflows, knowledge bases) - * alongside the chat conversation. Composes ResourceTabs for navigation - * and ResourceContent for rendering the active resource. - */ export function MothershipView({ workspaceId, + chatId, resources, activeResourceId, onSelectResource, + onAddResource, + onRemoveResource, + onReorderResources, onCollapse, isCollapsed, className, @@ -58,13 +59,6 @@ export function MothershipView({ const isActivePreviewable = active?.type === 'file' && PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) - const headerActions = - active?.type === 'workflow' ? ( - - ) : active?.type === 'knowledgebase' ? ( - - ) : null - return (
: null} previewMode={isActivePreviewable ? previewMode : undefined} onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} />
- {active && ( + {active ? ( + ) : ( +
+

No resources open

+

+ Click the + button above to add a resource to this task +

+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 6f674b28ba..935ba35ab9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -21,8 +21,6 @@ import { useAutoScroll, useChat } from './hooks' const logger = createLogger('Home') -const RESOURCE_PANEL_EXPAND_DELAY = 175 - const THINKING_BLOCKS = [ { color: '#2ABBF8', delay: '0s' }, { color: '#00F701', delay: '0.2s' }, @@ -163,6 +161,25 @@ export function Home({ chatId }: HomeProps = {}) { const { isLoading: isLoadingHistory } = useChatHistory(chatId) const { mutate: markRead } = useMarkTaskRead(workspaceId) + const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) + const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false) + const [skipResourceTransition, setSkipResourceTransition] = useState(false) + const isResourceCollapsedRef = useRef(isResourceCollapsed) + isResourceCollapsedRef.current = isResourceCollapsed + + const collapseResource = useCallback(() => setIsResourceCollapsed(true), []) + const expandResource = useCallback(() => { + setIsResourceCollapsed(false) + setIsResourceAnimatingIn(true) + }, []) + + const handleResourceEvent = useCallback(() => { + if (isResourceCollapsedRef.current) { + setIsResourceCollapsed(false) + setIsResourceAnimatingIn(true) + } + }, []) + const { messages, isSending, @@ -170,10 +187,12 @@ export function Home({ chatId }: HomeProps = {}) { stopGeneration, resolvedChatId, resources, - isResourceCleanupSettled, activeResourceId, setActiveResourceId, - } = useChat(workspaceId, chatId) + addResource, + removeResource, + reorderResources, + } = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent }) useEffect(() => { wasSendingRef.current = false @@ -187,38 +206,26 @@ export function Home({ chatId }: HomeProps = {}) { wasSendingRef.current = isSending }, [isSending, resolvedChatId, markRead]) - const [isResourceCollapsed, setIsResourceCollapsed] = useState(false) - const [showExpandButton, setShowExpandButton] = useState(false) - const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false) + const visibleResources = resources useEffect(() => { - if (!isResourceCollapsed) { - setShowExpandButton(false) - return - } - const timer = setTimeout(() => setShowExpandButton(true), RESOURCE_PANEL_EXPAND_DELAY) + if (!isResourceAnimatingIn) return + const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400) return () => clearTimeout(timer) - }, [isResourceCollapsed]) - - const collapseResource = useCallback(() => setIsResourceCollapsed(true), []) - const expandResource = useCallback(() => setIsResourceCollapsed(false), []) + }, [isResourceAnimatingIn]) - const visibleResources = isResourceCleanupSettled ? resources : [] - const prevResourceCountRef = useRef(visibleResources.length) - const shouldEnterResourcePanel = - isSending && prevResourceCountRef.current === 0 && visibleResources.length > 0 useEffect(() => { - if (shouldEnterResourcePanel) { - setIsResourceAnimatingIn(true) + if (resources.length > 0 && isResourceCollapsedRef.current) { + setSkipResourceTransition(true) + setIsResourceCollapsed(false) } - prevResourceCountRef.current = visibleResources.length - }, [shouldEnterResourcePanel, visibleResources.length]) + }, [resources]) useEffect(() => { - if (!isResourceAnimatingIn) return - const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400) - return () => clearTimeout(timer) - }, [isResourceAnimatingIn]) + if (!skipResourceTransition) return + const id = requestAnimationFrame(() => setSkipResourceTransition(false)) + return () => cancelAnimationFrame(id) + }, [skipResourceTransition]) const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[]) => { @@ -363,19 +370,21 @@ export function Home({ chatId }: HomeProps = {}) {
- {visibleResources.length > 0 && ( - - )} - - {visibleResources.length > 0 && showExpandButton && ( + + + {isResourceCollapsed && (