diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 69d7bb204dc..8d528150218 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -15,13 +15,19 @@ import type { ChatResource, ResourceType } from '@/lib/copilot/resources' 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 VALID_RESOURCE_TYPES = new Set([ + 'table', + 'file', + 'workflow', + 'knowledgebase', + 'folder', +]) +const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder']) const AddResourceSchema = z.object({ chatId: z.string(), resource: z.object({ - type: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + type: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder']), id: z.string(), title: z.string(), }), @@ -29,7 +35,7 @@ const AddResourceSchema = z.object({ const RemoveResourceSchema = z.object({ chatId: z.string(), - resourceType: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + resourceType: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder']), resourceId: z.string(), }) @@ -37,7 +43,7 @@ const ReorderResourcesSchema = z.object({ chatId: z.string(), resources: z.array( z.object({ - type: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + type: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder']), id: z.string(), title: z.string(), }) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index bc9f736f52b..21f83737066 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -88,6 +88,7 @@ const ChatMessageSchema = z.object({ 'docs', 'table', 'file', + 'folder', ]), label: z.string(), chatId: z.string().optional(), @@ -99,6 +100,7 @@ const ChatMessageSchema = z.object({ executionId: z.string().optional(), tableId: z.string().optional(), fileId: z.string().optional(), + folderId: z.string().optional(), }) ) .optional(), diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 9f567244fb3..09dea73a050 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -36,7 +36,7 @@ const FileAttachmentSchema = z.object({ }) const ResourceAttachmentSchema = z.object({ - type: z.enum(['workflow', 'table', 'file', 'knowledgebase']), + type: z.enum(['workflow', 'table', 'file', 'knowledgebase', 'folder']), id: z.string().min(1), title: z.string().optional(), active: z.boolean().optional(), @@ -66,6 +66,7 @@ const MothershipMessageSchema = z.object({ 'docs', 'table', 'file', + 'folder', ]), label: z.string(), chatId: z.string().optional(), @@ -77,6 +78,7 @@ const MothershipMessageSchema = z.object({ executionId: z.string().optional(), tableId: z.string().optional(), fileId: z.string().optional(), + folderId: z.string().optional(), }) ) .optional(), @@ -224,6 +226,7 @@ export async function POST(req: NextRequest) { ...(c.knowledgeId && { knowledgeId: c.knowledgeId }), ...(c.tableId && { tableId: c.tableId }), ...(c.fileId && { fileId: c.fileId }), + ...(c.folderId && { folderId: c.folderId }), })), }), } 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 index 821d6c47242..d1a0320e21f 100644 --- 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 @@ -24,6 +24,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' @@ -51,6 +52,7 @@ export function useAvailableResources( const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) + const { data: folders = [] } = useFolders(workspaceId) return useMemo( () => [ @@ -63,6 +65,14 @@ export function useAvailableResources( isOpen: existingKeys.has(`workflow:${w.id}`), })), }, + { + type: 'folder' as const, + items: folders.map((f) => ({ + id: f.id, + name: f.name, + isOpen: existingKeys.has(`folder:${f.id}`), + })), + }, { type: 'table' as const, items: tables.map((t) => ({ @@ -88,7 +98,7 @@ export function useAvailableResources( })), }, ], - [workflows, tables, files, knowledgeBases, existingKeys] + [workflows, folders, tables, files, knowledgeBases, existingKeys] ) } 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 3ec33a67366..e9fb56844ed 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 @@ -5,7 +5,13 @@ import { createLogger } from '@sim/logger' import { Square } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' -import { Download, FileX, SquareArrowUpRight, WorkflowX } from '@/components/emcn/icons' +import { + Download, + FileX, + Folder as FolderIcon, + SquareArrowUpRight, + WorkflowX, +} from '@/components/emcn/icons' import { cancelRunToolExecution, markRunToolManuallyStopped, @@ -37,6 +43,7 @@ import { import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { useFolders } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -147,6 +154,9 @@ export const ResourceContent = memo(function ResourceContent({ /> ) + case 'folder': + return + case 'generic': return ( @@ -172,6 +182,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) return ( ) + case 'folder': case 'generic': return null default: @@ -450,6 +461,72 @@ function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent }: Em ) } +interface EmbeddedFolderProps { + workspaceId: string + folderId: string +} + +function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { + const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) + const { data: workflowList = [] } = useWorkflows(workspaceId) + + const folder = useMemo( + () => (folderList ?? []).find((f) => f.id === folderId), + [folderList, folderId] + ) + + const folderWorkflows = useMemo( + () => workflowList.filter((w) => w.folderId === folderId), + [workflowList, folderId] + ) + + if (isFoldersPending) return LOADING_SKELETON + + if (!folder) { + return ( +
+ +
+

Folder not found

+

+ This folder may have been deleted or moved +

+
+
+ ) + } + + return ( +
+

{folder.name}

+ {folderWorkflows.length === 0 ? ( +

No workflows in this folder

+ ) : ( +
+ {folderWorkflows.map((w) => ( + + ))} +
+ )} +
+ ) +} + function extractFileContent(raw: string): string { const marker = '"content":' const idx = raw.indexOf(marker) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 5c8bd184cf5..e10c31b9a61 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -6,6 +6,7 @@ import { useParams } from 'next/navigation' import { Database, File as FileIcon, + Folder as FolderIcon, Table as TableIcon, TerminalWindow, } from '@/components/emcn/icons' @@ -18,6 +19,7 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { tableKeys } from '@/hooks/queries/tables' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { useWorkflows } from '@/hooks/queries/workflows' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' @@ -140,6 +142,15 @@ export const RESOURCE_REGISTRY: Record , }, + folder: { + type: 'folder', + label: 'Folders', + icon: FolderIcon, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, } as const export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY) @@ -171,6 +182,9 @@ const RESOURCE_INVALIDATORS: Record< qc.invalidateQueries({ queryKey: knowledgeKeys.detail(id) }) qc.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(id) }) }, + folder: (qc) => { + qc.invalidateQueries({ queryKey: folderKeys.lists() }) + }, } /** 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 c630d50b5e9..a3ef9628d03 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 @@ -23,6 +23,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' import { @@ -57,6 +58,7 @@ function useResourceNameLookup(workspaceId: string): Map { const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) + const { data: folders = [] } = useFolders(workspaceId) return useMemo(() => { const map = new Map() @@ -64,8 +66,9 @@ function useResourceNameLookup(workspaceId: string): Map { 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) + for (const folder of folders) map.set(`folder:${folder.id}`, folder.name) return map - }, [workflows, tables, files, knowledgeBases]) + }, [workflows, tables, files, knowledgeBases, folders]) } interface ResourceTabsProps { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 93c3d79fa39..d619aed9102 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -87,6 +87,8 @@ export function mapResourceToContext(resource: MothershipResource): ChatContext return { kind: 'table', tableId: resource.id, label: resource.title } case 'file': return { kind: 'file', fileId: resource.id, label: resource.title } + case 'folder': + return { kind: 'folder', folderId: resource.id, label: resource.title } default: return { kind: 'docs', label: resource.title } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index c44471f019a..2e3097c843e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -3,7 +3,7 @@ import type React from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'next/navigation' -import { Database, Table as TableIcon } from '@/components/emcn/icons' +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 { cn } from '@/lib/core/utils/cn' @@ -175,6 +175,7 @@ export function UserInput({ if (ctx.kind === 'knowledge' && ctx.knowledgeId) keys.add(`knowledgebase:${ctx.knowledgeId}`) 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}`) } return keys }, [contextManagement.selectedContexts]) @@ -663,6 +664,9 @@ export function UserInput({ mentionIconNode = break } + case 'folder': + mentionIconNode = + break } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index ed6da498d47..7e72bdf54f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' -import { Database, Table as TableIcon } from '@/components/emcn/icons' +import { Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { useWorkflows } from '@/hooks/queries/workflows' @@ -81,6 +81,9 @@ function MentionHighlight({ context }: { context: ChatMessageContext }) { icon = break } + case 'folder': + icon = + break } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 3003704c65a..ac5bf1ab167 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -294,6 +294,7 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage { ...(c.knowledgeId && { knowledgeId: c.knowledgeId }), ...(c.tableId && { tableId: c.tableId }), ...(c.fileId && { fileId: c.fileId }), + ...(c.folderId && { folderId: c.folderId }), })) } @@ -1953,6 +1954,7 @@ export function useChat( ...('knowledgeId' in c && c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), ...('tableId' in c && c.tableId ? { tableId: c.tableId } : {}), ...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}), + ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), })) setMessages((prev) => [ diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 1395c99db6d..d4f812cc25a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -266,6 +266,7 @@ export interface ChatMessageContext { knowledgeId?: string tableId?: string fileId?: string + folderId?: string } export interface ChatMessage { diff --git a/apps/sim/components/emcn/icons/folder.tsx b/apps/sim/components/emcn/icons/folder.tsx new file mode 100644 index 00000000000..cbeef8272d3 --- /dev/null +++ b/apps/sim/components/emcn/icons/folder.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * Folder icon component + * @param props - SVG properties including className, fill, etc. + */ +export function Folder(props: SVGProps) { + return ( + + ) +} diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index 7df4c5fba0e..272e2cb897d 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -30,6 +30,7 @@ export { Eye } from './eye' export { File } from './file' export { FileX } from './file-x' export { Fingerprint } from './fingerprint' +export { Folder } from './folder' export { FolderCode } from './folder-code' export { FolderPlus } from './folder-plus' export { Hammer } from './hammer' diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index ea2a6cef4e3..2c38f339558 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -48,6 +48,7 @@ export interface TaskStoredMessageContext { knowledgeId?: string tableId?: string fileId?: string + folderId?: string } export interface TaskStoredMessage { diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 5378b678f5c..9673a124aeb 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -33,6 +33,7 @@ export type AgentContextType = | 'templates' | 'workflow_block' | 'docs' + | 'folder' | 'active_resource' export interface AgentContext { @@ -178,6 +179,11 @@ export async function processContextsServer( if (!result) return null return { type: 'file', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content } } + if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) { + const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId) + if (!result) return null + return { type: 'folder', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content } + } if (ctx.kind === 'docs') { try { const { searchDocumentationServerTool } = await import( @@ -776,6 +782,9 @@ export async function resolveActiveResourceContext( case 'file': { return await resolveFileResource(resourceId, workspaceId) } + case 'folder': { + return await resolveFolderResource(resourceId, workspaceId) + } default: return null } @@ -812,3 +821,31 @@ async function resolveFileResource( }), } } + +async function resolveFolderResource( + folderId: string, + workspaceId: string +): Promise { + try { + const { workflowFolder, workflow } = await import('@sim/db/schema') + const [folder] = await db + .select({ id: workflowFolder.id, name: workflowFolder.name }) + .from(workflowFolder) + .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .limit(1) + if (!folder) return null + + const workflows = await db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) + + const workflowList = workflows.map((w) => `- ${w.name} (id: ${w.id})`).join('\n') + const content = `Folder: ${folder.name} (id: ${folder.id})\nWorkflows:\n${workflowList || '(empty)'}` + + return { type: 'active_resource', tag: '@active_resource', content } + } catch (error) { + logger.error('Failed to resolve folder resource', { folderId, error }) + return null + } +} diff --git a/apps/sim/lib/copilot/resource-types.ts b/apps/sim/lib/copilot/resource-types.ts index a3b3f3f4bfb..c0e83fe8a46 100644 --- a/apps/sim/lib/copilot/resource-types.ts +++ b/apps/sim/lib/copilot/resource-types.ts @@ -1,4 +1,10 @@ -export type MothershipResourceType = 'table' | 'file' | 'workflow' | 'knowledgebase' | 'generic' +export type MothershipResourceType = + | 'table' + | 'file' + | 'workflow' + | 'knowledgebase' + | 'folder' + | 'generic' export interface MothershipResource { type: MothershipResourceType @@ -11,4 +17,5 @@ export const VFS_DIR_TO_RESOURCE: Record = { files: 'file', workflows: 'workflow', knowledgebases: 'knowledgebase', + folders: 'folder', } as const diff --git a/apps/sim/lib/copilot/resources.ts b/apps/sim/lib/copilot/resources.ts index 5efec3dd50b..4a674c83119 100644 --- a/apps/sim/lib/copilot/resources.ts +++ b/apps/sim/lib/copilot/resources.ts @@ -41,7 +41,7 @@ export async function persistChatResources( const existing = Array.isArray(chat.resources) ? (chat.resources as MothershipResource[]) : [] const map = new Map() - const GENERIC = new Set(['Table', 'File', 'Workflow', 'Knowledge Base']) + const GENERIC = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder']) for (const r of existing) { map.set(`${r.type}:${r.id}`, r) diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 5d0bf4eca21..f60a3f6a513 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -29,6 +29,7 @@ export type ChatContext = | { kind: 'knowledge'; knowledgeId?: string; label: string } | { kind: 'table'; tableId: string; label: string } | { kind: 'file'; fileId: string; label: string } + | { kind: 'folder'; folderId: string; label: string } | { kind: 'templates'; templateId?: string; label: string } | { kind: 'docs'; label: string } | { kind: 'slash_command'; command: string; label: string }