feat(home): add folders to resource menu (#4000)

* feat(home): add folders to resource menu

* fix(home): add folder to API validation and dedup logic

* fix(home): add folder context processing and generic title dedup

* fix(home): add folder icon to mention chip overlay

* fix(home): add folder to AgentContextType and context persistence

* fix(home): add workspace scoping to folder resolver, fix folderId type and dedup

* user message
This commit is contained in:
Waleed
2026-04-06 19:25:00 -07:00
committed by GitHub
parent 5eb494de0c
commit 606477e4e1
19 changed files with 213 additions and 13 deletions

View File

@@ -15,13 +15,19 @@ import type { ChatResource, ResourceType } from '@/lib/copilot/resources'
const logger = createLogger('CopilotChatResourcesAPI')
const VALID_RESOURCE_TYPES = new Set<ResourceType>(['table', 'file', 'workflow', 'knowledgebase'])
const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base'])
const VALID_RESOURCE_TYPES = new Set<ResourceType>([
'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(),
})

View File

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

View File

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

View File

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

View File

@@ -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 <EmbeddedFolder key={resource.id} workspaceId={workspaceId} folderId={resource.id} />
case 'generic':
return (
<GenericResourceContent key={resource.id} data={genericResourceData ?? { entries: [] }} />
@@ -172,6 +182,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
return (
<EmbeddedKnowledgeBaseActions workspaceId={workspaceId} knowledgeBaseId={resource.id} />
)
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 (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<FolderIcon className='h-[32px] w-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>Folder not found</h2>
<p className='text-[var(--text-body)] text-small'>
This folder may have been deleted or moved
</p>
</div>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-y-auto p-6'>
<h2 className='mb-4 font-medium text-[16px] text-[var(--text-primary)]'>{folder.name}</h2>
{folderWorkflows.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>No workflows in this folder</p>
) : (
<div className='flex flex-col gap-1'>
{folderWorkflows.map((w) => (
<button
key={w.id}
type='button'
onClick={() => window.open(`/workspace/${workspaceId}/w/${w.id}`, '_blank')}
className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]'
>
<div
className='h-[12px] w-[12px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: w.color,
borderColor: `${w.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate text-[13px] text-[var(--text-primary)]'>{w.name}</span>
</button>
))}
</div>
)}
</div>
)
}
function extractFileContent(raw: string): string {
const marker = '"content":'
const idx = raw.indexOf(marker)

View File

@@ -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<MothershipResourceType, ResourceTypeConfi
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
},
folder: {
type: 'folder',
label: 'Folders',
icon: FolderIcon,
renderTabIcon: (_resource, className) => (
<FolderIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
},
} 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() })
},
}
/**

View File

@@ -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<string, string> {
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<string, string>()
@@ -64,8 +66,9 @@ function useResourceNameLookup(workspaceId: string): Map<string, string> {
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 {

View File

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

View File

@@ -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 = <FileDocIcon className={iconClasses} />
break
}
case 'folder':
mentionIconNode = <FolderIcon className={iconClasses} />
break
}
}

View File

@@ -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 = <FileDocIcon className={iconClasses} />
break
}
case 'folder':
icon = <FolderIcon className={iconClasses} />
break
}
return (

View File

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

View File

@@ -266,6 +266,7 @@ export interface ChatMessageContext {
knowledgeId?: string
tableId?: string
fileId?: string
folderId?: string
}
export interface ChatMessage {

View File

@@ -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<SVGSVGElement>) {
return (
<svg
width='15'
height='13'
viewBox='0 0 15 13'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M4.32234e-07 5.83339V3.79628C4.32234e-07 3.19982 -0.000206684 2.71995 0.0338546 2.33339C0.0685083 1.94027 0.141749 1.59614 0.317058 1.28196C0.542977 0.877129 0.87707 0.543036 1.2819 0.317117C1.59608 0.141808 1.94021 0.0685674 2.33333 0.0339137C2.71989 -0.000147559 3.19976 5.9557e-05 3.79622 5.9557e-05C4.53268 5.9264e-05 5.03054 -0.0078558 5.47526 0.158914C6.46893 0.531571 6.86678 1.44909 7.19141 2.09837L7.47591 2.66673H10.3333C11.025 2.66673 11.5814 2.66637 12.0267 2.71165C12.4803 2.75779 12.874 2.85548 13.222 3.08795C13.495 3.27035 13.7297 3.50508 13.9121 3.77805C14.1446 4.12607 14.2423 4.51976 14.2884 4.97337C14.3337 5.41867 14.3333 5.97505 14.3333 6.66673C14.3333 7.82671 14.3338 8.73433 14.2604 9.45579C14.1862 10.1855 14.0323 10.7801 13.6875 11.2963C13.4078 11.7148 13.0481 12.0746 12.6296 12.3542C12.1134 12.6991 11.5188 12.8529 10.7891 12.9271C10.0676 13.0005 9.15998 13.0001 8 13.0001H7.16667C5.6096 13.0001 4.39144 13.0013 3.44271 12.8738C2.47955 12.7443 1.71959 12.4736 1.12305 11.877C0.526507 11.2805 0.255796 10.5205 0.126303 9.55735C-0.00122168 8.60861 4.32234e-07 7.39046 4.32234e-07 5.83339ZM1 5.83339C1 7.41888 1.00132 8.55789 1.11784 9.42454C1.23243 10.2767 1.45034 10.7902 1.83008 11.17C2.20982 11.5497 2.72339 11.7676 3.57552 11.8822C4.44217 11.9987 5.58118 12.0001 7.16667 12.0001H8C9.18079 12.0001 10.029 11.9994 10.6882 11.9324C11.3387 11.8661 11.7498 11.7396 12.0742 11.5228C12.3836 11.3161 12.6494 11.0503 12.8561 10.7409C13.0729 10.4165 13.1994 10.0054 13.2656 9.35488C13.3327 8.69577 13.3333 7.84752 13.3333 6.66673C13.3333 5.9541 13.3326 5.45727 13.2936 5.07428C13.2555 4.69972 13.1852 4.48976 13.0807 4.33339C12.9713 4.16961 12.8305 4.02877 12.6667 3.91933C12.5103 3.81488 12.3003 3.74454 11.9258 3.70644C11.5428 3.66748 11.046 3.66673 10.3333 3.66673H5.16667C4.89052 3.66673 4.66667 3.44287 4.66667 3.16673C4.66667 2.89058 4.89052 2.66673 5.16667 2.66673H6.35742L6.29688 2.54563C5.92188 1.79565 5.68045 1.30454 5.1237 1.09576C4.88932 1.00791 4.6112 1.00006 3.79622 1.00006C3.18196 1.00006 2.75368 1.00072 2.42122 1.03001C2.09531 1.05874 1.90901 1.11196 1.76888 1.19016C1.52605 1.3257 1.32564 1.52611 1.1901 1.76894C1.1119 1.90907 1.05868 2.09537 1.02995 2.42128C1.00066 2.75373 1 3.18202 1 3.79628V5.83339Z'
fill='currentColor'
stroke='currentColor'
strokeWidth='0.3'
/>
</svg>
)
}

View File

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

View File

@@ -48,6 +48,7 @@ export interface TaskStoredMessageContext {
knowledgeId?: string
tableId?: string
fileId?: string
folderId?: string
}
export interface TaskStoredMessage {

View File

@@ -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<AgentContext | null> {
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
}
}

View File

@@ -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<string, MothershipResourceType> = {
files: 'file',
workflows: 'workflow',
knowledgebases: 'knowledgebase',
folders: 'folder',
} as const

View File

@@ -41,7 +41,7 @@ export async function persistChatResources(
const existing = Array.isArray(chat.resources) ? (chat.resources as MothershipResource[]) : []
const map = new Map<string, MothershipResource>()
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)

View File

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