From c6c6310b14291702ce715483ecff8806925da1ba Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 16:33:13 -0700 Subject: [PATCH 1/8] Make resources persist to backend --- .../app/api/copilot/chat/resources/route.ts | 131 +++++++ apps/sim/app/api/copilot/chat/route.ts | 2 + .../mothership-view/components/index.ts | 6 +- .../components/resource-content/index.ts | 6 +- .../resource-content/resource-content.tsx | 21 +- .../components/resource-registry.tsx | 310 +++++++++++++++++ .../resource-tabs/resource-tabs.tsx | 269 ++++++++++++--- .../mothership-view/mothership-view.tsx | 42 +-- .../app/workspace/[workspaceId]/home/home.tsx | 47 ++- .../[workspaceId]/home/hooks/use-chat.ts | 319 +++--------------- .../app/workspace/[workspaceId]/home/types.ts | 2 + .../app/workspace/[workspaceId]/home/utils.ts | 233 ------------- apps/sim/components/emcn/icons/index.ts | 1 + .../emcn/icons/square-arrow-up-right.tsx | 26 ++ apps/sim/hooks/queries/tasks.ts | 93 +++++ .../sse/handlers/tool-execution.ts | 28 ++ apps/sim/lib/copilot/orchestrator/types.ts | 3 + apps/sim/lib/copilot/resources.ts | 189 +++++++++++ packages/db/schema.ts | 1 + 19 files changed, 1111 insertions(+), 618 deletions(-) create mode 100644 apps/sim/app/api/copilot/chat/resources/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry.tsx create mode 100644 apps/sim/components/emcn/icons/square-arrow-up-right.tsx create mode 100644 apps/sim/lib/copilot/resources.ts 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..ca22d7429b --- /dev/null +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -0,0 +1,131 @@ +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(), +}) + +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 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/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index 1aee2f9aa2..c46d7d66b0 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,2 @@ -export { - EmbeddedKnowledgeBaseActions, - EmbeddedWorkflowActions, - ResourceContent, -} from './resource-content' +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..4cca8bdc09 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry.tsx @@ -0,0 +1,310 @@ +'use client' + +import { type ElementType, type ReactNode, Suspense, lazy } from 'react' +import { Square } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useMemo } from 'react' +import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' +import { BookOpen, Database, File as FileIcon, SquareArrowUpRight, Table as TableIcon } from '@/components/emcn/icons' +import { WorkflowIcon } from '@/components/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' +import { + markRunToolManuallyStopped, + reportManualRunToolStop, +} from '@/lib/copilot/client-sse/run-tool-execution' +import { + FileViewer, + type PreviewMode, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' +import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' +import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +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 { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useExecutionStore } from '@/stores/execution/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const LazyWorkflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow')) + +const LOADING_SKELETON = ( +
+ + + +
+) + +interface ContentProps { + workspaceId: string + resource: MothershipResource + previewMode?: PreviewMode +} + +interface ActionsProps { + workspaceId: string + resource: MothershipResource +} + +interface DropdownItemRenderProps { + item: { id: string; name: string; [key: string]: unknown } +} + +export interface ResourceTypeConfig { + type: MothershipResourceType + label: string + icon: ElementType + getTabIcon: (resource: MothershipResource) => ElementType + renderContent: (props: ContentProps) => ReactNode + renderActions?: (props: ActionsProps) => ReactNode + renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode +} + +function WorkflowContent({ workspaceId, resource }: ContentProps) { + return ( + + + + ) +} + +function WorkflowActions({ workspaceId, resource }: ActionsProps) { + const router = useRouter() + const { navigateToSettings } = useSettingsNavigation() + const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() + const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) + const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() + const isExecuting = useExecutionStore( + (state) => state.workflowExecutions.get(resource.id)?.isExecuting ?? false + ) + const { usageExceeded } = useUsageLimits() + + useEffect(() => { + setActiveWorkflow(resource.id) + }, [setActiveWorkflow, resource.id]) + + const isRunButtonDisabled = + !isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading + + const handleRun = useCallback(async () => { + setActiveWorkflow(resource.id) + + if (isExecuting) { + markRunToolManuallyStopped(resource.id) + await handleCancelExecution() + await reportManualRunToolStop(resource.id) + return + } + + if (usageExceeded) { + navigateToSettings({ section: 'subscription' }) + return + } + + await handleRunWorkflow() + }, [ + handleCancelExecution, + handleRunWorkflow, + isExecuting, + navigateToSettings, + setActiveWorkflow, + usageExceeded, + resource.id, + ]) + + const handleOpenWorkflow = useCallback(() => { + router.push(`/workspace/${workspaceId}/w/${resource.id}`) + }, [router, workspaceId, resource.id]) + + return ( + <> + + + + + +

Open Workflow

+
+
+ + + + + +

{isExecuting ? 'Stop' : 'Run'}

+
+
+ + ) +} + +function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { + const color = (item.color as string) ?? '#888' + return ( + <> +
+ {item.name} + + ) +} + +function TableContent({ workspaceId, resource }: ContentProps) { + return +} + +function DefaultDropdownItem({ item }: DropdownItemRenderProps) { + return {item.name} +} + +function FileContent({ workspaceId, resource, previewMode }: ContentProps) { + return ( + + ) +} + +function EmbeddedFile({ workspaceId, fileId, previewMode }: { workspaceId: string; fileId: string; previewMode?: PreviewMode }) { + const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId) + const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId]) + + if (isLoading) return LOADING_SKELETON + + if (!file) { + return ( +
+ File not found +
+ ) + } + + return ( +
+ +
+ ) +} + +function FileDropdownItem({ item }: DropdownItemRenderProps) { + const DocIcon = getDocumentIcon('', item.name) + return ( + <> + + {item.name} + + ) +} + +function KnowledgeBaseContent({ workspaceId, resource }: ContentProps) { + return ( + + ) +} + +function KnowledgeBaseActions({ workspaceId, resource }: ActionsProps) { + const router = useRouter() + + const handleOpen = useCallback(() => { + router.push(`/workspace/${workspaceId}/knowledge/${resource.id}`) + }, [router, workspaceId, resource.id]) + + return ( + + + + + +

Open Knowledge Base

+
+
+ ) +} + +export const RESOURCE_REGISTRY: Record = { + workflow: { + type: 'workflow', + label: 'Workflows', + icon: WorkflowIcon, + getTabIcon: () => WorkflowIcon, + renderContent: (props) => , + renderActions: (props) => , + renderDropdownItem: (props) => , + }, + table: { + type: 'table', + label: 'Tables', + icon: TableIcon, + getTabIcon: () => TableIcon, + renderContent: (props) => , + renderDropdownItem: (props) => , + }, + file: { + type: 'file', + label: 'Files', + icon: FileIcon, + getTabIcon: (resource) => getDocumentIcon('', resource.title), + renderContent: (props) => , + renderDropdownItem: (props) => , + }, + knowledgebase: { + type: 'knowledgebase', + label: 'Knowledge Bases', + icon: Database, + getTabIcon: () => Database, + renderContent: (props) => , + renderActions: (props) => , + renderDropdownItem: (props) => , + }, +} as const + +export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY) + +export function getResourceConfig(type: MothershipResourceType): ResourceTypeConfig { + return RESOURCE_REGISTRY[type] +} 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 88934b6bc0..c24682255b 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,22 +1,37 @@ 'use client' import { - type ElementType, type ReactNode, type RefCallback, type SVGProps, useCallback, + useMemo, + useState, } 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 { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Tooltip, +} from '@/components/emcn' +import { PanelLeft, Plus } 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 } 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, RESOURCE_TYPES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' 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' @@ -48,36 +63,27 @@ function PreviewModeIcon({ mode, ...props }: { mode: PreviewMode } & SVGProps void + onAddResource: (resource: MothershipResource) => void + onRemoveResource: (resourceType: MothershipResourceType, resourceId: string) => 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, onCollapse, previewMode, onCyclePreviewMode, @@ -95,6 +101,37 @@ export function ResourceTabs({ return () => node.removeEventListener('wheel', handler) }, []) + const addResource = useAddChatResource(chatId) + const removeResource = useRemoveChatResource(chatId) + + const [hoveredTabId, setHoveredTabId] = useState(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] + ) + return (
@@ -117,8 +154,10 @@ export function ResourceTabs({ className='mx-[2px] flex min-w-0 items-center gap-[6px] overflow-x-auto px-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' > {resources.map((resource) => { - const Icon = getResourceIcon(resource) + const config = getResourceConfig(resource.type) + const Icon = config.getTabIcon(resource) const isActive = activeId === resource.id + const isHovered = hoveredTabId === resource.id return ( @@ -126,13 +165,29 @@ export function ResourceTabs({ @@ -141,30 +196,152 @@ export function ResourceTabs({ ) })} -
-
- {actions} - {previewMode && onCyclePreviewMode && ( - - - - - -

Preview mode

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

Preview mode

+
+
+ )} +
+ )} ) } + +interface AddResourceDropdownProps { + workspaceId: string + existingKeys: Set + onAdd: (resource: MothershipResource) => void +} + +const EMPTY_SUBMENU = ( + + None available + +) + +type AvailableItem = { id: string; name: string; [key: string]: unknown } + +interface AvailableItemsByType { + type: MothershipResourceType + items: AvailableItem[] +} + +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 + .filter((w) => !existingKeys.has(`workflow:${w.id}`)) + .map((w) => ({ id: w.id, name: w.name, color: w.color })), + }, + { + type: 'table' as const, + items: tables + .filter((t) => !existingKeys.has(`table:${t.id}`)) + .map((t) => ({ id: t.id, name: t.name })), + }, + { + type: 'file' as const, + items: files + .filter((f) => !existingKeys.has(`file:${f.id}`)) + .map((f) => ({ id: f.id, name: f.name })), + }, + { + type: 'knowledgebase' as const, + items: (knowledgeBases ?? []) + .filter((kb) => !existingKeys.has(`knowledgebase:${kb.id}`)) + .map((kb) => ({ id: kb.id, name: kb.name })), + }, + ], [workflows, tables, files, knowledgeBases, existingKeys]) +} + +function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddResourceDropdownProps) { + const [open, setOpen] = useState(false) + const available = useAvailableResources(workspaceId, existingKeys) + + const select = useCallback( + (resource: MothershipResource) => { + onAdd(resource) + setOpen(false) + }, + [onAdd] + ) + + return ( + + + + + + + + +

Add resource

+
+
+ + {available.map(({ type, items }) => { + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + + {config.label} + + + {items.length > 0 + ? items.map((item) => ( + select({ type, id: item.id, title: item.name })} + > + {config.renderDropdownItem({ item })} + + )) + : EMPTY_SUBMENU} + + + ) + })} + +
+ ) +} 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..ea75eb71dc 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,25 @@ 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 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, onCollapse, isCollapsed, className, @@ -58,13 +57,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 yet

+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index cae75df63f..fa5cc063c5 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' }, @@ -166,33 +164,25 @@ export function Home({ chatId }: HomeProps = {}) { sendMessage, stopGeneration, resources, - isResourceCleanupSettled, activeResourceId, setActiveResourceId, + addResource, + removeResource, } = useChat(workspaceId, chatId) - const [isResourceCollapsed, setIsResourceCollapsed] = useState(false) - const [showExpandButton, setShowExpandButton] = useState(false) + const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false) - useEffect(() => { - if (!isResourceCollapsed) { - setShowExpandButton(false) - return - } - const timer = setTimeout(() => setShowExpandButton(true), RESOURCE_PANEL_EXPAND_DELAY) - return () => clearTimeout(timer) - }, [isResourceCollapsed]) - const collapseResource = useCallback(() => setIsResourceCollapsed(true), []) const expandResource = useCallback(() => setIsResourceCollapsed(false), []) - const visibleResources = isResourceCleanupSettled ? resources : [] + const visibleResources = resources const prevResourceCountRef = useRef(visibleResources.length) const shouldEnterResourcePanel = isSending && prevResourceCountRef.current === 0 && visibleResources.length > 0 useEffect(() => { if (shouldEnterResourcePanel) { + setIsResourceCollapsed(false) setIsResourceAnimatingIn(true) } prevResourceCountRef.current = visibleResources.length @@ -347,19 +337,20 @@ export function Home({ chatId }: HomeProps = {}) {
- {visibleResources.length > 0 && ( - - )} - - {visibleResources.length > 0 && showExpandButton && ( + + + {isResourceCollapsed && (
- - -

{resource.title}

-
- +
+ {showGapBefore && ( +
+ )} + + + + + +

{resource.title}

+
+
+ {showGapAfter && ( +
+ )} +
) })} - {chatId && ( - - )}
+ {chatId && ( + + )} {(actions || (previewMode && onCyclePreviewMode)) && (
{actions} @@ -262,7 +417,7 @@ function useAvailableResources(workspaceId: string, existingKeys: Set): type: 'workflow' as const, items: workflows .filter((w) => !existingKeys.has(`workflow:${w.id}`)) - .map((w) => ({ id: w.id, name: w.name, color: w.color })), + .map((w) => ({ id: w.id, name: w.name, color: w.color, folderId: w.folderId })), }, { type: 'table' as const, @@ -285,20 +440,187 @@ function useAvailableResources(workspaceId: string, existingKeys: Set): ], [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 getFolderTree = useFolderStore((state) => state.getFolderTree) + const folderTree = useMemo(() => getFolderTree(workspaceId), [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 })} + + ))} + + ) +} + function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: 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) => { onAdd(resource) setOpen(false) + setSearch('') }, [onAdd] ) + 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 ( - + @@ -315,19 +637,62 @@ function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddResourceDr

Add resource

- - {available.map(({ type, items }) => { - const config = getResourceConfig(type) - const Icon = config.icon - return ( - - - - {config.label} - - - {items.length > 0 - ? items.map((item) => ( + +
+ + 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 })} + > + {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 })} + /> + ) : items.length > 0 ? ( + items.map((item) => ( select({ type, id: item.id, title: item.name })} @@ -335,11 +700,14 @@ function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddResourceDr {config.renderDropdownItem({ item })} )) - : EMPTY_SUBMENU} - - - ) - })} + ) : ( + EMPTY_SUBMENU + )} +
+
+ ) + }) + )}
) 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 05bf60e6c3..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 @@ -27,6 +27,7 @@ interface MothershipViewProps { onSelectResource: (id: string) => void onAddResource: (resource: MothershipResource) => void onRemoveResource: (resourceType: MothershipResourceType, resourceId: string) => void + onReorderResources: (resources: MothershipResource[]) => void onCollapse: () => void isCollapsed: boolean className?: string @@ -40,6 +41,7 @@ export function MothershipView({ onSelectResource, onAddResource, onRemoveResource, + onReorderResources, onCollapse, isCollapsed, className, @@ -74,6 +76,7 @@ export function MothershipView({ onSelect={onSelectResource} onAddResource={onAddResource} onRemoveResource={onRemoveResource} + onReorderResources={onReorderResources} onCollapse={onCollapse} actions={active ? : null} previewMode={isActivePreviewable ? previewMode : undefined} @@ -88,7 +91,7 @@ export function MothershipView({ /> ) : (
-

No resources open

+

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 fa5cc063c5..349ba34752 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -168,6 +168,7 @@ export function Home({ chatId }: HomeProps = {}) { setActiveResourceId, addResource, removeResource, + reorderResources, } = useChat(workspaceId, chatId) const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) @@ -345,6 +346,7 @@ export function Home({ chatId }: HomeProps = {}) { onSelectResource={setActiveResourceId} onAddResource={addResource} onRemoveResource={removeResource} + onReorderResources={reorderResources} onCollapse={collapseResource} isCollapsed={isResourceCollapsed} className={isResourceAnimatingIn ? 'animate-slide-in-right' : undefined} 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 58b1e7b237..934cc8db29 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -54,6 +54,7 @@ export interface UseChatReturn { setActiveResourceId: (id: string | null) => void addResource: (resource: MothershipResource) => void removeResource: (resourceType: MothershipResourceType, resourceId: string) => void + reorderResources: (resources: MothershipResource[]) => void } const STATE_TO_STATUS: Record = { @@ -222,6 +223,10 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet [] ) + const reorderResources = useCallback((newOrder: MothershipResource[]) => { + setResources(newOrder) + }, []) + useEffect(() => { if (sendingRef.current) { chatIdRef.current = initialChatId @@ -842,5 +847,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet setActiveResourceId, addResource, removeResource, + reorderResources, } } diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index a03c94dbbf..a37748f161 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -256,6 +256,48 @@ export function useAddChatResource(chatId?: string) { }) } +async function reorderChatResources(params: { + chatId: string + resources: MothershipResource[] +}): Promise<{ resources: MothershipResource[] }> { + const response = await fetch('/api/copilot/chat/resources', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId: params.chatId, resources: params.resources }), + }) + if (!response.ok) throw new Error('Failed to reorder resources') + return response.json() +} + +export function useReorderChatResources(chatId?: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: reorderChatResources, + onMutate: async ({ resources }) => { + if (!chatId) return + await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) }) + const previous = queryClient.getQueryData(taskKeys.detail(chatId)) + if (previous) { + queryClient.setQueryData(taskKeys.detail(chatId), { + ...previous, + resources, + }) + } + return { previous } + }, + onError: (_err, _variables, context) => { + if (context?.previous && chatId) { + queryClient.setQueryData(taskKeys.detail(chatId), context.previous) + } + }, + onSettled: () => { + if (chatId) { + queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatId) }) + } + }, + }) +} + async function removeChatResource(params: { chatId: string resourceType: string From a18b01a600c813a0eac1f93d1191aaa660d14f8e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 18:14:39 -0700 Subject: [PATCH 4/8] Fix expanding panel logic --- .../components/resource-registry.tsx | 44 ++++++++++++++++ .../app/workspace/[workspaceId]/home/home.tsx | 52 ++++++++++++------- .../[workspaceId]/home/hooks/use-chat.ts | 41 ++++++--------- .../app/workspace/[workspaceId]/home/utils.ts | 10 ---- apps/sim/lib/copilot/resources.ts | 3 +- 5 files changed, 95 insertions(+), 55 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/utils.ts 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 index ff0b012b2c..7d506e6627 100644 --- 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 @@ -1,6 +1,7 @@ 'use client' import { type ElementType, type ReactNode, Suspense, lazy } from 'react' +import type { QueryClient } from '@tanstack/react-query' import { Square } from 'lucide-react' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' @@ -9,6 +10,10 @@ import { BookOpen, Database, File as FileIcon, SquareArrowUpRight, Table as Tabl 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 { markRunToolManuallyStopped, reportManualRunToolStop, @@ -328,3 +333,42 @@ 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/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 349ba34752..af5210791f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -158,6 +158,25 @@ export function Home({ chatId }: HomeProps = {}) { const { isLoading: isLoadingHistory } = useChatHistory(chatId) + 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, @@ -169,25 +188,9 @@ export function Home({ chatId }: HomeProps = {}) { addResource, removeResource, reorderResources, - } = useChat(workspaceId, chatId) - - const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) - const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false) - - const collapseResource = useCallback(() => setIsResourceCollapsed(true), []) - const expandResource = useCallback(() => setIsResourceCollapsed(false), []) + } = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent }) const visibleResources = resources - const prevResourceCountRef = useRef(visibleResources.length) - const shouldEnterResourcePanel = - isSending && prevResourceCountRef.current === 0 && visibleResources.length > 0 - useEffect(() => { - if (shouldEnterResourcePanel) { - setIsResourceCollapsed(false) - setIsResourceAnimatingIn(true) - } - prevResourceCountRef.current = visibleResources.length - }, [shouldEnterResourcePanel, visibleResources.length]) useEffect(() => { if (!isResourceAnimatingIn) return @@ -195,6 +198,19 @@ export function Home({ chatId }: HomeProps = {}) { return () => clearTimeout(timer) }, [isResourceAnimatingIn]) + useEffect(() => { + if (resources.length > 0 && isResourceCollapsedRef.current) { + setSkipResourceTransition(true) + setIsResourceCollapsed(false) + } + }, [resources]) + + useEffect(() => { + if (!skipResourceTransition) return + const id = requestAnimationFrame(() => setSkipResourceTransition(false)) + return () => cancelAnimationFrame(id) + }, [skipResourceTransition]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[]) => { const trimmed = text.trim() @@ -349,7 +365,7 @@ export function Home({ chatId }: HomeProps = {}) { onReorderResources={reorderResources} onCollapse={collapseResource} isCollapsed={isResourceCollapsed} - className={isResourceAnimatingIn ? 'animate-slide-in-right' : undefined} + className={isResourceAnimatingIn ? 'animate-slide-in-right' : skipResourceTransition ? '!transition-none' : undefined} /> {isResourceCollapsed && ( 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 934cc8db29..4315780a92 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -10,8 +10,6 @@ import { import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants' import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' -import { tableKeys } from '@/hooks/queries/tables' import { type TaskChatHistory, type TaskStoredContentBlock, @@ -22,13 +20,12 @@ import { useChatHistory, } from '@/hooks/queries/tasks' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { workflowKeys } from '@/hooks/queries/workflows' -import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' import { useFolderStore } from '@/stores/folders/store' import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { invalidateResourceQueries } from '../components/mothership-view/components/resource-registry' import type { FileAttachmentForApi } from '../components/user-input/user-input' import type { ChatMessage, @@ -41,7 +38,6 @@ import type { SSEPayloadData, ToolCallStatus, } from '../types' -import { RESOURCE_TOOL_NAMES } from '../utils' export interface UseChatReturn { messages: ChatMessage[] @@ -184,7 +180,15 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } -export function useChat(workspaceId: string, initialChatId?: string): UseChatReturn { +export interface UseChatOptions { + onResourceEvent?: () => void +} + +export function useChat( + workspaceId: string, + initialChatId?: string, + options?: UseChatOptions +): UseChatReturn { const pathname = usePathname() const queryClient = useQueryClient() const [messages, setMessages] = useState([]) @@ -192,13 +196,15 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const [error, setError] = useState(null) const [resources, setResources] = useState([]) const [activeResourceId, setActiveResourceId] = useState(null) + const onResourceEventRef = useRef(options?.onResourceEvent) + onResourceEventRef.current = options?.onResourceEvent + const abortControllerRef = useRef(null) const chatIdRef = useRef(initialChatId) const appliedChatIdRef = useRef(undefined) const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null) const streamIdRef = useRef(undefined) const sendingRef = useRef(false) - const toolArgsMapRef = useRef>>(new Map()) const streamGenRef = useRef(0) const streamingContentRef = useRef('') const streamingBlocksRef = useRef([]) @@ -298,7 +304,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet streamingContentRef.current = '' streamingBlocksRef.current = [] - toolArgsMapRef.current.clear() const ensureTextBlock = (): ContentBlock => { const last = blocks[blocks.length - 1] @@ -392,13 +397,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const isPartial = data?.partial === true if (!id) break - if (RESOURCE_TOOL_NAMES.has(name)) { - const args = data?.arguments ?? data?.input - if (args) { - toolArgsMapRef.current.set(id, args) - } - } - if (name.endsWith('_respond')) break const ui = parsed.ui || data?.ui if (ui?.hidden) break @@ -474,23 +472,14 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet flush() } - const toolName = parsed.toolName || getPayloadData(parsed)?.name - if (toolName && parsed.success && RESOURCE_TOOL_NAMES.has(toolName)) { - if (toolName === 'user_table' || toolName === 'workspace_file' || toolName === 'function_execute' || toolName === 'read') { - queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(workspaceId) }) - queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) }) - } else if (toolName === 'create_workflow' || toolName === 'edit_workflow') { - queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) }) - } else if (toolName === 'knowledge_base' || toolName === 'knowledge') { - queryClient.invalidateQueries({ queryKey: knowledgeKeys.list(workspaceId) }) - } - } break } case 'resource_added': { const resource = parsed.resource if (resource?.type && resource?.id) { addResource(resource) + invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) + onResourceEventRef.current?.() if (resource.type === 'workflow') { if (ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)) { useWorkflowRegistry.getState().setActiveWorkflow(resource.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/utils.ts deleted file mode 100644 index 88f9359654..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const RESOURCE_TOOL_NAMES = new Set([ - 'user_table', - 'workspace_file', - 'create_workflow', - 'edit_workflow', - 'function_execute', - 'read', - 'knowledge_base', - 'knowledge', -]) diff --git a/apps/sim/lib/copilot/resources.ts b/apps/sim/lib/copilot/resources.ts index 3b71ca0638..2e1afcb67e 100644 --- a/apps/sim/lib/copilot/resources.ts +++ b/apps/sim/lib/copilot/resources.ts @@ -64,7 +64,8 @@ export function extractResourcesFromToolResult( { type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }, ] } - const tableId = (data.tableId as string) ?? params?.tableId + const args = asRecord(params?.args) + const tableId = (data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string) if (tableId) { return [ { type: 'table', id: tableId as string, title: (data.tableName as string) || 'Table' }, From a0e7a982cca204ebcd80df2591607318eddb0dfa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 18:48:43 -0700 Subject: [PATCH 5/8] Reduce duplication, reading resource also opens up resource panel --- .../components/resource-registry.tsx | 223 +----------------- .../resource-tabs/resource-tabs.tsx | 3 +- .../[workspaceId]/home/hooks/use-chat.ts | 57 +++++ .../app/workspace/[workspaceId]/home/types.ts | 12 +- .../sse/handlers/tool-execution.ts | 14 +- .../orchestrator/tool-executor/vfs-tools.ts | 45 +++- apps/sim/lib/copilot/orchestrator/types.ts | 3 + apps/sim/lib/copilot/resource-types.ts | 14 ++ apps/sim/lib/copilot/resources.ts | 9 +- 9 files changed, 135 insertions(+), 245 deletions(-) create mode 100644 apps/sim/lib/copilot/resource-types.ts 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 index 7d506e6627..d55746c321 100644 --- 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 @@ -1,12 +1,8 @@ 'use client' -import { type ElementType, type ReactNode, Suspense, lazy } from 'react' +import { type ElementType, type ReactNode } from 'react' import type { QueryClient } from '@tanstack/react-query' -import { Square } from 'lucide-react' -import { useRouter } from 'next/navigation' -import { useCallback, useEffect, useMemo } from 'react' -import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' -import { BookOpen, Database, File as FileIcon, SquareArrowUpRight, Table as TableIcon } from '@/components/emcn/icons' +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' @@ -14,49 +10,12 @@ 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 { - markRunToolManuallyStopped, - reportManualRunToolStop, -} from '@/lib/copilot/client-sse/run-tool-execution' -import { - FileViewer, - type PreviewMode, -} from '@/app/workspace/[workspaceId]/files/components/file-viewer' import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' -import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -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 { useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useExecutionStore } from '@/stores/execution/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -const LazyWorkflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow')) - -const LOADING_SKELETON = ( -
- - - -
-) - -interface ContentProps { - workspaceId: string - resource: MothershipResource - previewMode?: PreviewMode -} - -interface ActionsProps { - workspaceId: string - resource: MothershipResource -} - interface DropdownItemRenderProps { item: { id: string; name: string; [key: string]: unknown } } @@ -66,8 +25,6 @@ export interface ResourceTypeConfig { label: string icon: ElementType renderTabIcon: (resource: MothershipResource, className: string) => ReactNode - renderContent: (props: ContentProps) => ReactNode - renderActions?: (props: ActionsProps) => ReactNode renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode } @@ -85,103 +42,6 @@ function WorkflowTabSquare({ workflowId, className }: { workflowId: string; clas ) } -function WorkflowContent({ workspaceId, resource }: ContentProps) { - return ( - - - - ) -} - -function WorkflowActions({ workspaceId, resource }: ActionsProps) { - const router = useRouter() - const { navigateToSettings } = useSettingsNavigation() - const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() - const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) - const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() - const isExecuting = useExecutionStore( - (state) => state.workflowExecutions.get(resource.id)?.isExecuting ?? false - ) - const { usageExceeded } = useUsageLimits() - - useEffect(() => { - setActiveWorkflow(resource.id) - }, [setActiveWorkflow, resource.id]) - - const isRunButtonDisabled = - !isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading - - const handleRun = useCallback(async () => { - setActiveWorkflow(resource.id) - - if (isExecuting) { - markRunToolManuallyStopped(resource.id) - await handleCancelExecution() - await reportManualRunToolStop(resource.id) - return - } - - if (usageExceeded) { - navigateToSettings({ section: 'subscription' }) - return - } - - await handleRunWorkflow() - }, [ - handleCancelExecution, - handleRunWorkflow, - isExecuting, - navigateToSettings, - setActiveWorkflow, - usageExceeded, - resource.id, - ]) - - const handleOpenWorkflow = useCallback(() => { - router.push(`/workspace/${workspaceId}/w/${resource.id}`) - }, [router, workspaceId, resource.id]) - - return ( - <> - - - - - -

Open Workflow

-
-
- - - - - -

{isExecuting ? 'Stop' : 'Run'}

-
-
- - ) -} - function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { const color = (item.color as string) ?? '#888' return ( @@ -199,46 +59,10 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { ) } -function TableContent({ workspaceId, resource }: ContentProps) { - return
-} - function DefaultDropdownItem({ item }: DropdownItemRenderProps) { return {item.name} } -function FileContent({ workspaceId, resource, previewMode }: ContentProps) { - return ( - - ) -} - -function EmbeddedFile({ workspaceId, fileId, previewMode }: { workspaceId: string; fileId: string; previewMode?: PreviewMode }) { - const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId) - const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId]) - - if (isLoading) return LOADING_SKELETON - - if (!file) { - return ( -
- File not found -
- ) - } - - return ( -
- -
- ) -} - function FileDropdownItem({ item }: DropdownItemRenderProps) { const DocIcon = getDocumentIcon('', item.name) return ( @@ -249,43 +73,6 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) { ) } -function KnowledgeBaseContent({ workspaceId, resource }: ContentProps) { - return ( - - ) -} - -function KnowledgeBaseActions({ workspaceId, resource }: ActionsProps) { - const router = useRouter() - - const handleOpen = useCallback(() => { - router.push(`/workspace/${workspaceId}/knowledge/${resource.id}`) - }, [router, workspaceId, resource.id]) - - return ( - - - - - -

Open Knowledge Base

-
-
- ) -} - export const RESOURCE_REGISTRY: Record = { workflow: { type: 'workflow', @@ -294,8 +81,6 @@ export const RESOURCE_REGISTRY: Record ( ), - renderContent: (props) => , - renderActions: (props) => , renderDropdownItem: (props) => , }, table: { @@ -303,7 +88,6 @@ export const RESOURCE_REGISTRY: Record , - renderContent: (props) => , renderDropdownItem: (props) => , }, file: { @@ -314,7 +98,6 @@ export const RESOURCE_REGISTRY: Record }, - renderContent: (props) => , renderDropdownItem: (props) => , }, knowledgebase: { @@ -322,8 +105,6 @@ export const RESOURCE_REGISTRY: Record , - renderContent: (props) => , - renderActions: (props) => , renderDropdownItem: (props) => , }, } as const 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 ed7b037133..cf1ed09819 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 @@ -523,8 +523,9 @@ function WorkflowSubmenuContent({ onSelect: (item: AvailableItem) => void }) { useFolders(workspaceId) + const folders = useFolderStore((state) => state.folders) const getFolderTree = useFolderStore((state) => state.getFolderTree) - const folderTree = useMemo(() => getFolderTree(workspaceId), [getFolderTree, workspaceId]) + const folderTree = useMemo(() => getFolderTree(workspaceId), [folders, getFolderTree, workspaceId]) const [expanded, setExpanded] = useState>(new Set()) const toggleFolder = useCallback((id: string) => { 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 4315780a92..465888073a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -25,6 +25,7 @@ import { useExecutionStore } from '@/stores/execution/store' import { useFolderStore } from '@/stores/folders/store' import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' import { invalidateResourceQueries } from '../components/mothership-view/components/resource-registry' import type { FileAttachmentForApi } from '../components/user-input/user-input' import type { @@ -180,6 +181,37 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } +function extractResourceFromReadResult( + path: string | undefined, + output: unknown +): MothershipResource | null { + if (!path) return null + + const segments = path.split('/') + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType || !segments[1]) return null + + const obj = + output && typeof output === 'object' ? (output as Record) : undefined + if (!obj) return null + + let id = obj.id as string | undefined + let name = obj.name as string | undefined + + if (!id && typeof obj.content === 'string') { + try { + const parsed = JSON.parse(obj.content) + id = parsed?.id as string | undefined + name = parsed?.name as string | undefined + } catch { + // content is not JSON + } + } + + if (!id) return null + return { type: resourceType, id, title: name || segments[1] } +} + export interface UseChatOptions { onResourceEvent?: () => void } @@ -297,6 +329,7 @@ export function useChat( let buffer = '' const blocks: ContentBlock[] = [] const toolMap = new Map() + const toolArgsMap = new Map>() const clientExecutionStarted = new Set() let activeSubagent: string | undefined let runningText = '' @@ -415,6 +448,12 @@ export function useChat( calledBy: activeSubagent, }, }) + if (name === 'read') { + const args = (data?.arguments ?? data?.input) as + | Record + | undefined + if (args) toolArgsMap.set(id, args) + } } else { const idx = toolMap.get(id)! const tc = blocks[idx].toolCall @@ -470,6 +509,24 @@ export function useChat( error: (parsed.error ?? getPayloadData(parsed)?.error) as string | undefined, } flush() + + if (tc.name === 'read' && tc.status === 'success') { + const readArgs = toolArgsMap.get(id) + const resource = extractResourceFromReadResult( + readArgs?.path as string | undefined, + tc.result.output + ) + if (resource) { + addResource(resource) + invalidateResourceQueries( + queryClient, + workspaceId, + resource.type, + resource.id + ) + onResourceEventRef.current?.() + } + } } break diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 79e734a7b0..dfa482894c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -1,3 +1,7 @@ +import type { MothershipResourceType } from '@/lib/copilot/resource-types' + +export type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types' + /** * SSE event types emitted by the Go orchestrator backend. * @@ -238,11 +242,3 @@ export interface SSEPayload { subagent?: string resource?: { type: MothershipResourceType; id: string; title: string } } - -export type MothershipResourceType = 'table' | 'file' | 'workflow' | 'knowledgebase' - -export interface MothershipResource { - type: MothershipResourceType - id: string - title: string -} diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts index 43fe4e995f..41ae7a3565 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts @@ -514,12 +514,14 @@ export async function executeToolAndReport( } await options?.onEvent?.(resultEvent) - if (result.success && isResourceToolName(toolCall.name) && execContext.chatId) { - const resources = extractResourcesFromToolResult( - toolCall.name, - toolCall.params, - result.output - ) + if (result.success && execContext.chatId) { + const resources = + result.resources && result.resources.length > 0 + ? result.resources + : isResourceToolName(toolCall.name) + ? extractResourcesFromToolResult(toolCall.name, toolCall.params, result.output) + : [] + if (resources.length > 0) { persistChatResources(execContext.chatId, resources).catch((err) => { logger.warn('Failed to persist chat resources', { diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts index 8a97ed97c2..30464ce6fa 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts @@ -1,9 +1,38 @@ import { createLogger } from '@sim/logger' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { MothershipResource } from '@/lib/copilot/resource-types' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' +import type { WorkspaceVFS } from '@/lib/copilot/vfs' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' const logger = createLogger('VfsTools') +/** + * Resolves a VFS resource path to its resource descriptor by reading the + * sibling meta.json (already in memory) for the resource ID and name. + */ +function resolveVfsResource( + vfs: WorkspaceVFS, + path: string +): MothershipResource | null { + const segments = path.split('/') + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType || !segments[1]) return null + + const metaPath = `${segments[0]}/${segments[1]}/meta.json` + const meta = vfs.read(metaPath) + if (!meta) return null + + try { + const parsed = JSON.parse(meta.content) + const id = parsed?.id as string | undefined + if (!id) return null + return { type: resourceType, id, title: (parsed.name as string) || segments[1] } + } catch { + return null + } +} + export async function executeVfsGrep( params: Record, context: ExecutionContext @@ -103,7 +132,13 @@ export async function executeVfsRead( path, totalLines: fileContent.totalLines, }) - return { success: true, output: fileContent } + // Appends metadata of resource to tool response + const resource = resolveVfsResource(vfs, path) + return { + success: true, + output: fileContent, + ...(resource && { resources: [resource] }), + } } const suggestions = vfs.suggestSimilar(path) @@ -115,7 +150,13 @@ export async function executeVfsRead( return { success: false, error: `File not found: ${path}.${hint}` } } logger.debug('vfs_read result', { path, totalLines: result.totalLines }) - return { success: true, output: result } + // Appends metadata of resource to tool response + const resource = resolveVfsResource(vfs, path) + return { + success: true, + output: result, + ...(resource && { resources: [resource] }), + } } catch (err) { logger.error('vfs_read failed', { path, diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts index 7eada7abcc..82ced642aa 100644 --- a/apps/sim/lib/copilot/orchestrator/types.ts +++ b/apps/sim/lib/copilot/orchestrator/types.ts @@ -1,3 +1,5 @@ +import type { MothershipResource } from '@/lib/copilot/resource-types' + export type SSEEventType = | 'chat_id' | 'title_updated' @@ -67,6 +69,7 @@ export interface ToolCallResult { success: boolean output?: T error?: string + resources?: MothershipResource[] } export type ContentBlockType = 'text' | 'thinking' | 'tool_call' | 'subagent_text' | 'subagent' diff --git a/apps/sim/lib/copilot/resource-types.ts b/apps/sim/lib/copilot/resource-types.ts new file mode 100644 index 0000000000..6dd11ad9b5 --- /dev/null +++ b/apps/sim/lib/copilot/resource-types.ts @@ -0,0 +1,14 @@ +export type MothershipResourceType = 'table' | 'file' | 'workflow' | 'knowledgebase' + +export interface MothershipResource { + type: MothershipResourceType + id: string + title: string +} + +export const VFS_DIR_TO_RESOURCE: Record = { + tables: 'table', + files: 'file', + workflows: 'workflow', + knowledgebases: 'knowledgebase', +} as const diff --git a/apps/sim/lib/copilot/resources.ts b/apps/sim/lib/copilot/resources.ts index 2e1afcb67e..2eb6927dc9 100644 --- a/apps/sim/lib/copilot/resources.ts +++ b/apps/sim/lib/copilot/resources.ts @@ -5,13 +5,8 @@ import { eq, sql } from 'drizzle-orm' const logger = createLogger('CopilotResources') -export type ResourceType = 'table' | 'file' | 'workflow' | 'knowledgebase' - -export interface ChatResource { - type: ResourceType - id: string - title: string -} +export type { MothershipResourceType as ResourceType } from '@/lib/copilot/resource-types' +export type { MothershipResource as ChatResource } from '@/lib/copilot/resource-types' const RESOURCE_TOOL_NAMES = new Set([ 'user_table', From 9ff87c103ccb66f1d0279ac4801987c9db8ca174 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 18:57:06 -0700 Subject: [PATCH 6/8] Move resource dropdown to own file --- .../add-resource-dropdown.tsx | 362 ++++++++++++++++++ .../components/add-resource-dropdown/index.ts | 3 + .../mothership-view/components/index.ts | 3 + .../resource-tabs/resource-tabs.tsx | 347 +---------------- 4 files changed, 371 insertions(+), 344 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts 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..5b7ddf948e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -0,0 +1,362 @@ +'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 +} + +export type AvailableItem = { id: string; name: string; [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 + .filter((w) => !existingKeys.has(`workflow:${w.id}`)) + .map((w) => ({ id: w.id, name: w.name, color: w.color, folderId: w.folderId })), + }, + { + type: 'table' as const, + items: tables + .filter((t) => !existingKeys.has(`table:${t.id}`)) + .map((t) => ({ id: t.id, name: t.name })), + }, + { + type: 'file' as const, + items: files + .filter((f) => !existingKeys.has(`file:${f.id}`)) + .map((f) => ({ id: f.id, name: f.name })), + }, + { + type: 'knowledgebase' as const, + items: (knowledgeBases ?? []) + .filter((kb) => !existingKeys.has(`knowledgebase:${kb.id}`)) + .map((kb) => ({ id: kb.id, name: kb.name })), + }, + ], [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 }: 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) => { + onAdd(resource) + setOpen(false) + setSearch('') + }, + [onAdd] + ) + + 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 })} + > + {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 })} + /> + ) : items.length > 0 ? ( + items.map((item) => ( + select({ type, id: item.id, title: item.name })} + > + {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 c46d7d66b0..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,2 +1,5 @@ +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-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index cf1ed09819..45568d2d57 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 @@ -11,33 +11,18 @@ import { } from 'react' import { Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, Tooltip, } from '@/components/emcn' -import { PanelLeft, Plus, Search } from '@/components/emcn/icons' -import { ChevronRight, Folder } from 'lucide-react' +import { PanelLeft } from '@/components/emcn/icons' 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 { useAddChatResource, useRemoveChatResource, useReorderChatResources } from '@/hooks/queries/tasks' -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 { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { getResourceConfig, RESOURCE_TYPES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +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' @@ -387,329 +372,3 @@ export function ResourceTabs({ ) } -interface AddResourceDropdownProps { - workspaceId: string - existingKeys: Set - onAdd: (resource: MothershipResource) => void -} - -const EMPTY_SUBMENU = ( - - None available - -) - -type AvailableItem = { id: string; name: string; [key: string]: unknown } - -interface AvailableItemsByType { - type: MothershipResourceType - items: AvailableItem[] -} - -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 - .filter((w) => !existingKeys.has(`workflow:${w.id}`)) - .map((w) => ({ id: w.id, name: w.name, color: w.color, folderId: w.folderId })), - }, - { - type: 'table' as const, - items: tables - .filter((t) => !existingKeys.has(`table:${t.id}`)) - .map((t) => ({ id: t.id, name: t.name })), - }, - { - type: 'file' as const, - items: files - .filter((f) => !existingKeys.has(`file:${f.id}`)) - .map((f) => ({ id: f.id, name: f.name })), - }, - { - type: 'knowledgebase' as const, - items: (knowledgeBases ?? []) - .filter((kb) => !existingKeys.has(`knowledgebase:${kb.id}`)) - .map((kb) => ({ id: kb.id, name: kb.name })), - }, - ], [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 })} - - ))} - - ) -} - -function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: 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) => { - onAdd(resource) - setOpen(false) - setSearch('') - }, - [onAdd] - ) - - 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 })} - > - {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 })} - /> - ) : items.length > 0 ? ( - items.map((item) => ( - select({ type, id: item.id, title: item.name })} - > - {config.renderDropdownItem({ item })} - - )) - ) : ( - EMPTY_SUBMENU - )} - - - ) - }) - )} -
-
- ) -} From 419e1a1acf09e24c513679c0b4380ae617ee2243 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 19:19:12 -0700 Subject: [PATCH 7/8] Handle renamed resources --- .../resource-tabs/resource-tabs.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) 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 9e2c68c6e4..bf5d13d7bb 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 @@ -12,11 +12,13 @@ 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 { 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, @@ -57,6 +59,26 @@ 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 @@ -86,6 +108,7 @@ export function ResourceTabs({ onCyclePreviewMode, actions, }: ResourceTabsProps) { + const nameLookup = useResourceNameLookup(workspaceId) const scrollNodeRef = useRef(null) useEffect(() => { @@ -274,6 +297,7 @@ export function ResourceTabs({ > {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 @@ -310,7 +334,7 @@ export function ResourceTabs({ )} > {config.renderTabIcon(resource, 'mr-[6px] h-[14px] w-[14px]')} - {resource.title} + {displayName} {(isHovered || isActive) && chatId && ( handleRemove(e, resource)} onKeyDown={(e) => { if (e.key === 'Enter') handleRemove(e as unknown as React.MouseEvent, resource) }} className='absolute right-[4px] top-1/2 flex -translate-y-1/2 items-center justify-center rounded-[4px] p-[1px] hover:bg-[var(--surface-5)]' - aria-label={`Close ${resource.title}`} + aria-label={`Close ${displayName}`} > @@ -328,7 +352,7 @@ export function ResourceTabs({ -

{resource.title}

+

{displayName}

{showGapAfter && ( From 847c899d3f116b1cf0ec5e9eb28f408184978429 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 19:31:15 -0700 Subject: [PATCH 8/8] Clicking already open tab should just switch to tab --- .../add-resource-dropdown.tsx | 33 ++++++++++--------- .../resource-tabs/resource-tabs.tsx | 1 + 2 files changed, 18 insertions(+), 16 deletions(-) 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 5b7ddf948e..d3466bd552 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 @@ -38,9 +38,10 @@ export interface AddResourceDropdownProps { workspaceId: string existingKeys: Set onAdd: (resource: MothershipResource) => void + onSwitch?: (resourceId: string) => void } -export type AvailableItem = { id: string; name: string; [key: string]: unknown } +export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown } interface AvailableItemsByType { type: MothershipResourceType @@ -63,26 +64,22 @@ export function useAvailableResources(workspaceId: string, existingKeys: Set !existingKeys.has(`workflow:${w.id}`)) - .map((w) => ({ id: w.id, name: w.name, color: w.color, folderId: w.folderId })), + .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 - .filter((t) => !existingKeys.has(`table:${t.id}`)) - .map((t) => ({ id: t.id, name: t.name })), + .map((t) => ({ id: t.id, name: t.name, isOpen: existingKeys.has(`table:${t.id}`) })), }, { type: 'file' as const, items: files - .filter((f) => !existingKeys.has(`file:${f.id}`)) - .map((f) => ({ id: f.id, name: f.name })), + .map((f) => ({ id: f.id, name: f.name, isOpen: existingKeys.has(`file:${f.id}`) })), }, { type: 'knowledgebase' as const, items: (knowledgeBases ?? []) - .filter((kb) => !existingKeys.has(`knowledgebase:${kb.id}`)) - .map((kb) => ({ id: kb.id, name: kb.name })), + .map((kb) => ({ id: kb.id, name: kb.name, isOpen: existingKeys.has(`knowledgebase:${kb.id}`) })), }, ], [workflows, tables, files, knowledgeBases, existingKeys]) } @@ -234,7 +231,7 @@ function WorkflowSubmenuContent({ ) } -export function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddResourceDropdownProps) { +export function AddResourceDropdown({ workspaceId, existingKeys, onAdd, onSwitch }: AddResourceDropdownProps) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const available = useAvailableResources(workspaceId, existingKeys) @@ -248,12 +245,16 @@ export function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddRes }, []) const select = useCallback( - (resource: MothershipResource) => { - onAdd(resource) + (resource: MothershipResource, isOpen?: boolean) => { + if (isOpen && onSwitch) { + onSwitch(resource.id) + } else { + onAdd(resource) + } setOpen(false) setSearch('') }, - [onAdd] + [onAdd, onSwitch] ) const query = search.trim().toLowerCase() @@ -306,7 +307,7 @@ export function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddRes return ( select({ type, id: item.id, title: item.name })} + onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)} > {config.renderDropdownItem({ item })} @@ -337,13 +338,13 @@ export function AddResourceDropdown({ workspaceId, existingKeys, onAdd }: AddRes workspaceId={workspaceId} items={items} config={config} - onSelect={(item) => select({ type, id: item.id, title: item.name })} + onSelect={(item) => select({ type, id: item.id, title: item.name }, item.isOpen)} /> ) : items.length > 0 ? ( items.map((item) => ( select({ type, id: item.id, title: item.name })} + onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)} > {config.renderDropdownItem({ item })} 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 bf5d13d7bb..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 @@ -367,6 +367,7 @@ export function ResourceTabs({ workspaceId={workspaceId} existingKeys={existingKeys} onAdd={handleAdd} + onSwitch={onSelect} /> )} {(actions || (previewMode && onCyclePreviewMode)) && (