diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.test.tsx new file mode 100644 index 00000000000..b7bd827d50b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.test.tsx @@ -0,0 +1,44 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' +import { createRoot, type Root } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('next/navigation', () => nextNavigationMock) + +import { useUnsavedChangesGuard } from '@/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard' + +function mountDisabledDirtyGuard(): () => void { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + + function Probe() { + useUnsavedChangesGuard({ + isDirty: true, + backHref: '/workspace/ws-1/skills', + enabled: false, + }) + return null + } + + act(() => root.render()) + return () => act(() => root.unmount()) +} + +describe('useUnsavedChangesGuard', () => { + it('installs no nested navigation guard when its embedded host owns transitions', () => { + const pushState = vi.spyOn(window.history, 'pushState') + const unmount = mountDisabledDirtyGuard() + + const beforeUnload = new Event('beforeunload', { cancelable: true }) + window.dispatchEvent(beforeUnload) + + expect(pushState).not.toHaveBeenCalled() + expect(beforeUnload.defaultPrevented).toBe(false) + + unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts index 1dd0bb241bc..aef5c42554c 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts @@ -8,6 +8,8 @@ interface UseUnsavedChangesGuardParams { isDirty: boolean /** Where a confirmed discard navigates to. */ backHref: string + /** Embedded surfaces disable this guard and delegate to their host. */ + enabled?: boolean } /** @@ -23,13 +25,18 @@ interface UseUnsavedChangesGuardParams { * still mounted), never in cleanup, so an intentional discard/navigation away is * not reversed. */ -export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) { +export function useUnsavedChangesGuard({ + isDirty, + backHref, + enabled = true, +}: UseUnsavedChangesGuardParams) { const router = useRouter() const [showUnsavedAlert, setShowUnsavedAlert] = useState(false) const [isReleased, setIsReleased] = useState(false) const hasSentinelRef = useRef(false) useEffect(() => { + if (!enabled) return // The caller is navigating away — popping the seeded entry would cancel it. But // Back during that window consumes the entry with no listener left to re-push // it, so track that: a later rearm() must seed a fresh one rather than trust a @@ -71,16 +78,16 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('popstate', handlePopState) } - }, [isDirty, isReleased]) + }, [enabled, isDirty, isReleased]) const handleBackClick = useCallback( (event: MouseEvent) => { - if (isDirty && !isReleased) { + if (enabled && isDirty && !isReleased) { event.preventDefault() setShowUnsavedAlert(true) } }, - [isDirty, isReleased] + [enabled, isDirty, isReleased] ) const confirmDiscard = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx index 1b67be0a675..03d86015387 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx @@ -2,6 +2,7 @@ import { lazy, type ReactNode, Suspense, useCallback } from 'react' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { ChatPanelLayout } from '@/app/workspace/[workspaceId]/home/components/chat-panel-layout' import { MothershipResourcesProvider } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources' @@ -62,6 +63,11 @@ export function ChatResourcePanel({ handleResourceResizeKeyDown, handleResourceResizeFocus, handleResourceInteraction, + requestResourceTransition, + reportResourceDirty, + showDiscardConfirmation, + dismissDiscardConfirmation, + confirmDiscard, } = panel const summarize = useCallback( (message: string, filters: WorkspaceSearchFilters) => { @@ -71,47 +77,58 @@ export function ChatResourcePanel({ [onSummarize, chat.sendMessage] ) return ( - - - - - - } - > - {children} - + <> + + + + + + } + > + {children} + + { + if (!open) dismissDiscardConfirmation() + }} + onDiscard={confirmDiscard} + /> + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx index ddc208f17ef..a85ca6445df 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx @@ -27,6 +27,10 @@ interface MothershipResourcesContextValue { reorderResources: (resources: MothershipResource[]) => void /** Collapses the resource panel. */ collapseResource: () => void + /** Defers a user transition when the active embedded editor has a dirty draft. */ + requestResourceTransition: (transition: () => void) => void + /** Reports dirty state for an embedded editor mounted in the active tab. */ + reportResourceDirty: (resourceId: string, dirty: boolean) => void } const MothershipResourcesContext = createContext(null) @@ -46,11 +50,29 @@ export function MothershipResourcesProvider({ removeResource, reorderResources, collapseResource, + requestResourceTransition, + reportResourceDirty, children, }: MothershipResourcesProviderProps) { const value = useMemo( - () => ({ selectResource, addResource, removeResource, reorderResources, collapseResource }), - [selectResource, addResource, removeResource, reorderResources, collapseResource] + () => ({ + selectResource, + addResource, + removeResource, + reorderResources, + collapseResource, + requestResourceTransition, + reportResourceDirty, + }), + [ + selectResource, + addResource, + removeResource, + reorderResources, + collapseResource, + requestResourceTransition, + reportResourceDirty, + ] ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts index 70bf34e89cc..ea0cf18a4dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts @@ -11,11 +11,14 @@ import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/ import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { listIntegrationsByPopularity } from '@/blocks/integration-matcher' +import { useCustomTools } from '@/hooks/queries/custom-tools' import { useWorkspaceDashboard } from '@/hooks/queries/dashboards' import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useLogsList } from '@/hooks/queries/logs' +import { useMcpServers } from '@/hooks/queries/mcp' import { useMothershipChats } from '@/hooks/queries/mothership-chats' +import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' @@ -155,6 +158,19 @@ export function useAvailableResources( LOG_DROPDOWN_FILTERS, { enabled: enabled && Boolean(workspaceId) } ) + const skillsEnabled = enabled && Boolean(workspaceId) && !excludeTypes?.includes('skill') + const customToolsEnabled = + enabled && Boolean(workspaceId) && !excludeTypes?.includes('custom_tool') + const mcpServersEnabled = enabled && Boolean(workspaceId) && !excludeTypes?.includes('mcp_server') + const { data: skills, isPending: skillsPending } = useSkills(workspaceId, { + enabled: skillsEnabled, + }) + const { data: customTools, isPending: customToolsPending } = useCustomTools(workspaceId, { + enabled: customToolsEnabled, + }) + const { data: mcpServers, isPending: mcpServersPending } = useMcpServers(workspaceId, { + enabled: mcpServersEnabled, + }) const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData]) /** @@ -179,7 +195,10 @@ export function useAvailableResources( (!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) || fileFoldersPending || tasksPending || - logsPending) + logsPending || + (skillsEnabled && skillsPending) || + (customToolsEnabled && customToolsPending) || + (mcpServersEnabled && mcpServersPending)) const groups = useMemo(() => { if (!enabled) return NO_RESOURCE_GROUPS @@ -251,6 +270,21 @@ export function useAvailableResources( type: 'task' as const, items: (tasks ?? []).map((t) => ({ id: t.id, name: t.name })), }, + { + type: 'skill' as const, + items: (skills ?? []).map((skill) => ({ id: skill.id, name: skill.name })), + }, + { + type: 'custom_tool' as const, + items: (customTools ?? []).map((tool) => ({ id: tool.id, name: tool.title })), + }, + { + type: 'mcp_server' as const, + items: (mcpServers ?? []).map((server) => ({ + id: server.id, + name: server.name || 'Unnamed server', + })), + }, /** * The chip's `name` keeps the absolute timestamp because it is persisted * with the chat, where "2m ago" would age into a lie; the row renders the @@ -313,6 +347,9 @@ export function useAvailableResources( dashboardData, knowledgeBases, tasks, + skills, + customTools, + mcpServers, logs, excludeTypes, dashboardsEnabled, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx index 1dbf07425e5..6322d8a8b77 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import type { MothershipResourceType } from '@/lib/mothership/resources/types' import { type AvailableResources, useAvailableResources, @@ -8,11 +9,16 @@ import { export function OrganizationResourceInventory({ workspaceId, onChange, + excludeTypes, }: { workspaceId: string + excludeTypes?: readonly MothershipResourceType[] onChange: (workspaceId: string, inventory: AvailableResources) => void }) { - const inventory = useAvailableResources(workspaceId, { includeFolderMentions: true }) + const inventory = useAvailableResources(workspaceId, { + includeFolderMentions: true, + excludeTypes, + }) useEffect(() => { onChange(workspaceId, inventory) }, [workspaceId, inventory, onChange]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/panel-resource-groups.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/panel-resource-groups.test.tsx new file mode 100644 index 00000000000..964d34381e7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/panel-resource-groups.test.tsx @@ -0,0 +1,95 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: () => false })) +vi.mock('@/lib/terminal/transport', () => ({ isTerminalAvailable: () => false })) +vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () => ({ + useFeatureFlag: () => false, +})) +vi.mock('@/hooks/queries/dashboards', () => ({ + useWorkspaceDashboard: () => ({ data: undefined, isPending: false }), +})) +vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock) +vi.mock('@/hooks/queries/custom-tools', () => ({ + useCustomTools: () => ({ + data: [{ id: 'tool-1', title: 'Lookup order' }], + isPending: false, + }), +})) +vi.mock('@/hooks/queries/folders', () => ({ + useFolders: () => ({ data: [], isPending: false }), +})) +vi.mock('@/hooks/queries/kb/knowledge', () => ({ + useKnowledgeBasesQuery: () => ({ data: [], isPending: false }), +})) +vi.mock('@/hooks/queries/logs', () => ({ + useLogsList: () => ({ data: { pages: [] }, isPending: false }), +})) +vi.mock('@/hooks/queries/mcp', () => ({ + useMcpServers: () => ({ + data: [{ id: 'server-1', name: 'DeepWiki' }], + isPending: false, + }), +})) +vi.mock('@/hooks/queries/mothership-chats', () => ({ + useMothershipChats: () => ({ data: [], isPending: false }), +})) +vi.mock('@/hooks/queries/skills', () => ({ + useSkills: () => ({ + data: [{ id: 'skill-1', name: 'Research' }], + isPending: false, + }), +})) +vi.mock('@/hooks/queries/tables', () => ({ + useTablesList: () => ({ data: [], isPending: false }), +})) +vi.mock('@/hooks/queries/workflows', () => ({ + useWorkflows: () => ({ data: [], isPending: false }), +})) +vi.mock('@/hooks/queries/workspace-file-folders', () => ({ + useWorkspaceFileFolders: () => ({ data: [], isPending: false }), +})) +vi.mock('@/hooks/queries/workspace-files', () => ({ + useWorkspaceFiles: () => ({ data: [], isPending: false }), +})) + +import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' + +describe('useAvailableResources panel resource groups', () => { + it('offers Skills, Custom Tools, and MCP servers to the panel picker', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + let latest: ReturnType | undefined + + function Probe() { + latest = useAvailableResources('workspace-1', { enabled: true }) + return null + } + + act(() => root.render()) + + expect(latest?.groups.find(({ type }) => type === 'skill')).toEqual({ + type: 'skill', + items: [{ id: 'skill-1', name: 'Research' }], + }) + expect(latest?.groups.find(({ type }) => type === 'custom_tool')).toEqual({ + type: 'custom_tool', + items: [{ id: 'tool-1', name: 'Lookup order' }], + }) + expect(latest?.groups.find(({ type }) => type === 'mcp_server')).toEqual({ + type: 'mcp_server', + items: [{ id: 'server-1', name: 'DeepWiki' }], + }) + expect(latest?.isHydrating).toBe(false) + + act(() => root.unmount()) + container.remove() + }) +}) 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 0685009d447..e672bf6afa2 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 @@ -1,6 +1,16 @@ 'use client' -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + lazy, + memo, + type ReactNode, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { Button, OverflowText, @@ -11,6 +21,7 @@ import { toast, } from '@sim/emcn' import { + ArrowLeft, Download, FileX, Folder as FolderIcon, @@ -24,12 +35,15 @@ import { createLogger } from '@sim/logger' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { DashboardResource } from '@/components/dashboards/dashboard-resource' +import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { isApiClientError } from '@/lib/api/client/errors' import type { MothershipTableViewContext } from '@/lib/api/contracts/mothership-resources' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' import { prefersInPlaceNavigation } from '@/lib/desktop' import type { FilePreviewSession } from '@/lib/mothership/request/session' +import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' import { stopRunToolForExecution } from '@/lib/mothership/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import { type FileDownloadSource, triggerFileDownload } from '@/lib/uploads/client/download' @@ -39,6 +53,7 @@ import { type PreviewMode, resolveFileCategory, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' import type { BrowserPanelOverlayController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' @@ -57,9 +72,15 @@ import { useUserPermissionsContext, useWorkspacePermissionsContext, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' +import { MCP } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SkillDetail } from '@/app/workspace/[workspaceId]/skills/[skillId]/skill-detail' import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { useCustomTools } from '@/hooks/queries/custom-tools' import { useFolders } from '@/hooks/queries/folders' import { useLogByExecutionId, useLogDetail } from '@/hooks/queries/logs' import { exportTable } from '@/hooks/queries/tables' @@ -193,6 +214,12 @@ export const ResourceContent = memo(function ResourceContent({ visible = true, onBrowserOverlayControllerChange, }: ResourceContentProps) { + const { reportResourceDirty } = useMothershipResources() + const resourceSelectionId = getChatResourceSelectionId(resource) + const handleDirtyChange = useCallback( + (dirty: boolean) => reportResourceDirty(resourceSelectionId, dirty), + [reportResourceDirty, resourceSelectionId] + ) const observedTableViewRef = useRef<{ tableId: string; viewId?: string } | null>(null) useEffect(() => { @@ -350,6 +377,41 @@ export const ResourceContent = memo(function ResourceContent({ /> ) + case 'skill': + return ( + onNotFound?.(resource.id)} + /> + ) + + case 'custom_tool': + return ( + onNotFound?.(resource.id)} + /> + ) + + case 'mcp_server': + return ( + + onNotFound?.(resource.id)} + onDirtyChange={handleDirtyChange} + /> + + ) + case 'generic': return ( @@ -417,6 +479,27 @@ export function ResourceActions({ executionId={resource.executionId} /> ) + case 'skill': + return ( + + ) + case 'custom_tool': + return ( + + ) + case 'mcp_server': + return ( + + ) case 'folder': case 'generic': case 'browser': @@ -427,6 +510,85 @@ export function ResourceActions({ } } +function EmbeddedSettingsShell({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +interface EmbeddedCustomToolProps { + workspaceId: string + toolId: string + onDirtyChange: (dirty: boolean) => void + onClose: () => void +} + +function EmbeddedCustomTool({ + workspaceId, + toolId, + onDirtyChange, + onClose, +}: EmbeddedCustomToolProps) { + const workspacePermissions = useUserPermissionsContext() + const { requestResourceTransition } = useMothershipResources() + const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions) + const { data: tools = [], isPending, isPlaceholderData, error } = useCustomTools(workspaceId) + const tool = tools.find((candidate) => candidate.id === toolId) + + if (isPending || isPlaceholderData || workspacePermissions.isLoading) return LOADING_SKELETON + + return ( + + {tool ? ( + requestResourceTransition(onClose)} + onDeleted={onClose} + /> + ) : ( + + + {error ? 'Failed to load this Custom Tool.' : 'This Custom Tool may have been deleted.'} + + + )} + + ) +} + +function EmbeddedResourceEditorAction({ href, label }: { href: string; label: string }) { + const openInternalLink = useOpenInternalLink() + const { requestResourceTransition } = useMothershipResources() + const handleOpen = () => { + if (prefersInPlaceNavigation()) { + requestResourceTransition(() => openInternalLink(href)) + return + } + openInternalLink(href) + } + return ( + + + + + + + +

{label}

+
+
+ ) +} + interface EmbeddedWorkflowActionsProps { workspaceId: string workflowId: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts index 6962cdab5f8..1f10d80febf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts @@ -4,9 +4,12 @@ import { dashboardKeys } from '@/hooks/queries/dashboards' import { deploymentKeys, invalidateDeploymentQueries } from '@/hooks/queries/deployments' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { skillsKeys } from '@/hooks/queries/skills' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' +import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' @@ -68,6 +71,13 @@ const RESOURCE_INVALIDATORS: Record< * invalidate when one is added. */ integration: () => {}, + skill: (qc, wId) => invalidate(qc, skillsKeys.list(wId)), + custom_tool: (qc, wId) => invalidate(qc, customToolsKeys.list(wId)), + mcp_server: (qc, wId, id) => { + invalidate(qc, mcpKeys.serversList(wId)) + invalidate(qc, id ? mcpKeys.serverToolsList(wId, id) : mcpKeys.serverToolsWorkspace(wId)) + invalidate(qc, mcpKeys.storedToolsList(wId)) + }, /** * The browser panel hosts the desktop app's natively embedded browser view * (in-memory page state, no server-backed query), so there is nothing to diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.test.tsx new file mode 100644 index 00000000000..fcb9b4100b0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.test.tsx @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it, vi } from 'vitest' +import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { skillsKeys } from '@/hooks/queries/skills' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' +import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' + +describe('panel resource invalidation', () => { + it('refreshes the Skill and Custom Tool lists', () => { + const queryClient = new QueryClient() + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + + invalidateResourceQueries(queryClient, 'workspace-1', 'skill', 'skill-1') + invalidateResourceQueries(queryClient, 'workspace-1', 'custom_tool', 'tool-1') + + expect(invalidate).toHaveBeenCalledWith({ queryKey: skillsKeys.list('workspace-1') }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: customToolsKeys.list('workspace-1') }) + }) + + it('refreshes the MCP server, its child tools, and stored workflow references', () => { + const queryClient = new QueryClient() + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + + invalidateResourceQueries(queryClient, 'workspace-1', 'mcp_server', 'server-1') + + expect(invalidate).toHaveBeenCalledWith({ queryKey: mcpKeys.serversList('workspace-1') }) + expect(invalidate).toHaveBeenCalledWith({ + queryKey: mcpKeys.serverToolsList('workspace-1', 'server-1'), + }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: mcpKeys.storedToolsList('workspace-1') }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1a9de189f0d..8337bd6a0a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -15,7 +15,9 @@ import { Task, TerminalWindow, Workflow, + Wrench, } from '@sim/emcn/icons' +import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { terminalIdFromResourceId } from '@/lib/terminal/resource-id' import { BrowserTabIcon } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon' @@ -257,6 +259,33 @@ export const RESOURCE_REGISTRY: Record , }, + skill: { + type: 'skill', + label: 'Skills', + icon: AgentSkillsIcon, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, + custom_tool: { + type: 'custom_tool', + label: 'Custom Tools', + icon: Wrench, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, + mcp_server: { + type: 'mcp_server', + label: 'MCP Servers', + icon: McpIcon, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, browser: { type: 'browser', label: 'Browser', @@ -298,6 +327,9 @@ export const MENTION_PREVIEW_DEFAULT_LIMIT = 5 export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ 'integration', 'task', + 'skill', + 'custom_tool', + 'mcp_server', 'dashboard', 'table', 'file', 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 99cbbc3bf53..7c9d57c30c9 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 @@ -49,8 +49,11 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { customToolsQueryOptions, useCustomTools } from '@/hooks/queries/custom-tools' import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { mcpServersQueryOptions, useMcpServers } from '@/hooks/queries/mcp' +import { getSkillsQueryOptions, useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflows } from '@/hooks/queries/workflows' @@ -193,6 +196,33 @@ function useResourceNameLookup( enabled: enabled && Boolean(workspaceId), }) + const editableOwners = (type: MothershipResourceType) => [ + ...new Set( + resources.flatMap((resource) => + resource.type === type && resource.workspaceId && resource.workspaceId !== workspaceId + ? [resource.workspaceId] + : [] + ) + ), + ] + const ownedSkills = useQueries({ + queries: editableOwners('skill').map(getSkillsQueryOptions), + }) + const ownedCustomTools = useQueries({ + queries: editableOwners('custom_tool').map(customToolsQueryOptions), + }) + const ownedMcpServers = useQueries({ + queries: editableOwners('mcp_server').map(mcpServersQueryOptions), + }) + const openTypes = new Set(resources.map((resource) => resource.type)) + const { data: skills } = useSkills(workspaceId ?? '', { enabled: openTypes.has('skill') }) + const { data: customTools } = useCustomTools(workspaceId ?? '', { + enabled: openTypes.has('custom_tool'), + }) + const { data: mcpServers } = useMcpServers(workspaceId ?? '', { + enabled: openTypes.has('mcp_server'), + }) + return useMemo(() => { if (!enabled) return NO_RESOURCE_NAMES const map = new Map() @@ -206,8 +236,39 @@ function useResourceNameLookup( for (const file of files ?? []) map.set(`file:${file.id}`, file.name) for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) for (const folder of folders ?? []) map.set(`folder:${folder.id}`, folder.name) + for (const result of ownedSkills) { + for (const skill of result.data ?? []) map.set(`skill:${skill.id}`, skill.name) + } + for (const result of ownedCustomTools) { + for (const tool of result.data ?? []) map.set(`custom_tool:${tool.id}`, tool.title) + } + for (const result of ownedMcpServers) { + for (const server of result.data ?? []) { + map.set(`mcp_server:${server.id}`, server.name || 'Unnamed server') + } + } + for (const skill of skills ?? []) map.set(`skill:${skill.id}`, skill.name) + for (const tool of customTools ?? []) map.set(`custom_tool:${tool.id}`, tool.title) + for (const server of mcpServers ?? []) { + map.set(`mcp_server:${server.id}`, server.name || 'Unnamed server') + } return map - }, [enabled, workflows, tables, files, knowledgeBases, folders, ownedWorkflows, owners]) + }, [ + enabled, + workflows, + tables, + files, + knowledgeBases, + folders, + ownedWorkflows, + owners, + skills, + customTools, + mcpServers, + ownedSkills, + ownedCustomTools, + ownedMcpServers, + ]) } interface ResourceTabsProps { @@ -257,6 +318,7 @@ export function ResourceTabs({ addResource: onAddResource, removeResource: onRemoveResource, reorderResources: onReorderResources, + requestResourceTransition, } = useMothershipResources() const { confirmTerminalClose, confirmationDialog } = useTerminalCloseConfirmation(desktopScopeId) @@ -337,34 +399,36 @@ export function ResourceTabs({ const handleAdd = useCallback( (resource: MothershipResource) => { - // A browser tab or terminal is a live page or shell the desktop app - // creates; it joins the strip through the tab list rather than as a - // resource of its own. - if (resource.type === 'browser') { - void openBrowserTab(desktopScopeId) - .then((state) => { - if (!state?.activeTabId) return - requestBrowserOmniboxFocus(state.activeTabId, state.scopeId) - selectResource(state.activeTabId) - }) - .catch(() => toast.error('Could not open a new browser tab. Please try again.')) - return - } - if (resource.type === 'terminal') { - void openTerminal(undefined, desktopScopeId) - .then((state) => { - if (!state.activeTerminalId) return - selectResource(terminalResourceId(state.activeTerminalId)) - requestTerminalFocus(state.activeTerminalId) - }) - .catch(() => toast.error('Could not open a new terminal. Please try again.')) - return - } - // The chat owner handles optimistic state and its single ordered persistence queue. - onAddResource(resource) + requestResourceTransition(() => { + // A browser tab or terminal is a live page or shell the desktop app + // creates; it joins the strip through the tab list rather than as a + // resource of its own. + if (resource.type === 'browser') { + void openBrowserTab(desktopScopeId) + .then((state) => { + if (!state?.activeTabId) return + requestBrowserOmniboxFocus(state.activeTabId, state.scopeId) + selectResource(state.activeTabId) + }) + .catch(() => toast.error('Could not open a new browser tab. Please try again.')) + return + } + if (resource.type === 'terminal') { + void openTerminal(undefined, desktopScopeId) + .then((state) => { + if (!state.activeTerminalId) return + selectResource(terminalResourceId(state.activeTerminalId)) + requestTerminalFocus(state.activeTerminalId) + }) + .catch(() => toast.error('Could not open a new terminal. Please try again.')) + return + } + // The chat owner handles optimistic state and its single ordered persistence queue. + onAddResource(resource) + }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [desktopScopeId, onAddResource, selectResource] + [desktopScopeId, onAddResource, selectResource, requestResourceTransition] ) const handleSelect = useCallback( @@ -373,56 +437,60 @@ export function ResourceTabs({ const resource = resources[idx] if (!resource) return - // Shift+click: contiguous range from anchor - if (e?.shiftKey) { - // Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar) - const anchorId = anchorIdRef.current ?? activeId - const anchorIdx = anchorId - ? resources.findIndex((r) => getChatResourceSelectionId(r) === anchorId) - : -1 - if (anchorIdx !== -1) { - const start = Math.min(anchorIdx, idx) - const end = Math.max(anchorIdx, idx) - const next = new Set() - for (let i = start; i <= end; i++) next.add(getChatResourceSelectionId(resources[i])) - setSelectedIds(next) - selectResource(getChatResourceSelectionId(resource)) - return + const select = () => { + // Shift+click: contiguous range from anchor + if (e?.shiftKey) { + // Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar) + const anchorId = anchorIdRef.current ?? activeId + const anchorIdx = anchorId + ? resources.findIndex((r) => getChatResourceSelectionId(r) === anchorId) + : -1 + if (anchorIdx !== -1) { + const start = Math.min(anchorIdx, idx) + const end = Math.max(anchorIdx, idx) + const next = new Set() + for (let i = start; i <= end; i++) next.add(getChatResourceSelectionId(resources[i])) + setSelectedIds(next) + selectResource(getChatResourceSelectionId(resource)) + return + } } - } - // Cmd/Ctrl+click: toggle individual tab in/out of selection - if (e?.metaKey || e?.ctrlKey) { - const wasSelected = selectedIds.has(getChatResourceSelectionId(resource)) - if (wasSelected) { - const next = new Set(selectedIds) - next.delete(getChatResourceSelectionId(resource)) - setSelectedIds(next) - // Only switch active if we just deselected the currently-active tab - if (activeId === getChatResourceSelectionId(resource)) { - const fallback = - findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null) - if (fallback) selectResource(fallback) + // Cmd/Ctrl+click: toggle individual tab in/out of selection + if (e?.metaKey || e?.ctrlKey) { + const wasSelected = selectedIds.has(getChatResourceSelectionId(resource)) + if (wasSelected) { + const next = new Set(selectedIds) + next.delete(getChatResourceSelectionId(resource)) + setSelectedIds(next) + // Only switch active if we just deselected the currently-active tab + if (activeId === getChatResourceSelectionId(resource)) { + const fallback = + findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null) + if (fallback) selectResource(fallback) + } + } else { + setSelectedIds((prev) => new Set(prev).add(getChatResourceSelectionId(resource))) + selectResource(getChatResourceSelectionId(resource)) } - } else { - setSelectedIds((prev) => new Set(prev).add(getChatResourceSelectionId(resource))) - selectResource(getChatResourceSelectionId(resource)) + if (!anchorIdRef.current) anchorIdRef.current = getChatResourceSelectionId(resource) + return } - if (!anchorIdRef.current) anchorIdRef.current = getChatResourceSelectionId(resource) - return - } - // Plain click: single-select - anchorIdRef.current = getChatResourceSelectionId(resource) - setSelectedIds(new Set([getChatResourceSelectionId(resource)])) - selectResource(getChatResourceSelectionId(resource)) - // A pointer pick of a shell also hands it the keyboard; arrow-key - // navigation along the strip keeps its own focus. - if (resource.type === 'terminal' && source !== 'keyboard') { - requestTerminalFocus(terminalIdFromResourceId(resource.id)) + // Plain click: single-select + anchorIdRef.current = getChatResourceSelectionId(resource) + setSelectedIds(new Set([getChatResourceSelectionId(resource)])) + selectResource(getChatResourceSelectionId(resource)) + // A pointer pick of a shell also hands it the keyboard; arrow-key + // navigation along the strip keeps its own focus. + if (resource.type === 'terminal' && source !== 'keyboard') { + requestTerminalFocus(terminalIdFromResourceId(resource.id)) + } } + if (id === activeId) select() + else requestResourceTransition(select) }, - [resources, selectResource, selectedIds, activeId] + [resources, selectResource, selectedIds, activeId, requestResourceTransition] ) const handleClose = useCallback( @@ -438,44 +506,49 @@ export function ResourceTabs({ .filter((target) => target.type === 'terminal') .map((target) => terminalIdFromResourceId(target.id)) if (!(await confirmTerminalClose(terminalIds))) return - // Closing the shown tab moves to its neighbour, right then left, so the - // strip does not fall back to its last tab and jump. For a desktop tab - // this is also the neighbour the desktop app itself picks. - if (!isMulti && activeId === getChatResourceSelectionId(resource)) { - const sameKind = new Set( - resources.filter((r) => r.type === resource.type).map(getChatResourceSelectionId) - ) - const nextId = - findNearestId(resources, index, sameKind) ?? findNearestId(resources, index, null) - if (nextId) selectResource(nextId) - } - // A browser tab's page is closed natively and its resource dropped at - // once; the tab list then confirms the removal. A shell's close answers - // with the tab list, so its resource follows that list instead — a - // close the desktop app refuses must not leave a running shell with no - // tab. - for (const r of targets) { - if (r.type === 'terminal') { - void closeTerminal(terminalIdFromResourceId(r.id), desktopScopeId).catch(() => - toast.error('Could not close that terminal. Please try again.') + const close = () => { + // Closing the shown tab moves to its neighbour, right then left, so the + // strip does not fall back to its last tab and jump. For a desktop tab + // this is also the neighbour the desktop app itself picks. + if (!isMulti && activeId === getChatResourceSelectionId(resource)) { + const sameKind = new Set( + resources.filter((r) => r.type === resource.type).map(getChatResourceSelectionId) ) - continue + const nextId = + findNearestId(resources, index, sameKind) ?? findNearestId(resources, index, null) + if (nextId) selectResource(nextId) + } + // A browser tab's page is closed natively and its resource dropped at + // once; the tab list then confirms the removal. A shell's close answers + // with the tab list, so its resource follows that list instead — a + // close the desktop app refuses must not leave a running shell with no + // tab. + for (const r of targets) { + if (r.type === 'terminal') { + void closeTerminal(terminalIdFromResourceId(r.id), desktopScopeId).catch(() => + toast.error('Could not close that terminal. Please try again.') + ) + continue + } + onRemoveResource(r.type, r.id, r.workspaceId) + if (r.type === 'browser') { + sendBrowserPanelAction('close-tab', { tabId: r.id }, desktopScopeId) + } } - onRemoveResource(r.type, r.id, r.workspaceId) - if (r.type === 'browser') { - sendBrowserPanelAction('close-tab', { tabId: r.id }, desktopScopeId) + // Clear stale selection and anchor for all removed targets + const removedIds = new Set(targets.map(getChatResourceSelectionId)) + setSelectedIds((prev) => { + const next = new Set(prev) + for (const removedId of removedIds) next.delete(removedId) + return next + }) + if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) { + anchorIdRef.current = null } } - // Clear stale selection and anchor for all removed targets - const removedIds = new Set(targets.map(getChatResourceSelectionId)) - setSelectedIds((prev) => { - const next = new Set(prev) - for (const removedId of removedIds) next.delete(removedId) - return next - }) - if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) { - anchorIdRef.current = null - } + if (targets.some((target) => getChatResourceSelectionId(target) === activeId)) { + requestResourceTransition(close) + } else close() }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -487,6 +560,7 @@ export function ResourceTabs({ selectResource, selectedIds, confirmTerminalClose, + requestResourceTransition, ] ) 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 75ded534ef2..b15e0757af4 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 @@ -318,7 +318,9 @@ export const MothershipView = memo( isAgentResponding={isAgentResponding} genericResourceData={active.type === 'generic' ? genericResourceData : undefined} previewContextKey={chatId} - onNotFound={(resourceId) => removeResource('log', resourceId, active.workspaceId)} + onNotFound={(resourceId) => + removeResource(active.type, resourceId, active.workspaceId) + } /> )} {!active && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 4bb8d632e97..536e03f2d3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -107,7 +107,8 @@ export const SPEECH_RECOGNITION_LANG = 'en-US' * Maps a {@link MothershipResource} (resource-picker domain) to a * {@link ChatContext} (chat-input domain). Keyed by `MothershipResourceType` * so adding a new resource type fails compilation here until a conversion is - * supplied — preventing silent drift between the two taxonomies. + * supplied. Panel-only resources explicitly return `null`, so they cannot + * become artificial prompt attachments without weakening exhaustive coverage. */ // A browser resource is one live page and a terminal resource one live shell, // so each id is a precise pointer the agent can act on directly. @@ -142,6 +143,9 @@ const RESOURCE_TO_CONTEXT: Record< log: (r) => ({ kind: 'logs', executionId: r.executionId ?? r.id, label: r.title }), integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }), generic: (r) => ({ kind: 'docs', label: r.title }), + skill: () => null, + custom_tool: () => null, + mcp_server: () => null, } export function mapResourceToContext(resource: MothershipResource): ChatContext | null { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 3759f6e3d81..e67880b297a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -77,6 +77,11 @@ function candidateKey({ type, item }: MentionCandidate): string { * (`ADD_RESOURCE_EXCLUDED_TYPES` in `resource-tabs`). */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) +const PANEL_ONLY_RESOURCE_TYPES: readonly MothershipResourceType[] = [ + 'skill', + 'custom_tool', + 'mcp_server', +] as const /** * Families an organization chat's workspace submenus leave out: the mention-only @@ -85,6 +90,7 @@ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integratio */ const WORKSPACE_SUBMENU_EXCLUDED_TYPES: readonly MothershipResourceType[] = [ ...MENTION_ONLY_RESOURCE_TYPES, + ...PANEL_ONLY_RESOURCE_TYPES, 'browser', 'terminal', ] @@ -154,6 +160,7 @@ export const PlusMenuDropdown = React.memo( const workspaceInventory = useAvailableResources(organizationId ? '' : workspaceId, { enabled: inventoryEnabled, includeFolderMentions: true, + excludeTypes: PANEL_ONLY_RESOURCE_TYPES, }) const { data: allWorkspaces, isPending: workspacesPending } = useOrderedWorkspacesQuery( Boolean(organizationId) && inventoryEnabled @@ -201,7 +208,10 @@ export const PlusMenuDropdown = React.memo( const visibleResources = useMemo(() => { const resources = withTerminalTabMentions( withBrowserTabMentions( - withFolderMentions(availableResources, structureFolders), + withFolderMentions( + availableResources.filter(({ type }) => !PANEL_ONLY_RESOURCE_TYPES.includes(type)), + structureFolders + ), browserTabs ), terminalTabs, @@ -409,6 +419,7 @@ export const PlusMenuDropdown = React.memo( inventoryEnabled && workspaces.map((workspace) => ( { + it.each(['skill', 'custom_tool', 'mcp_server'] as const)( + 'does not insert panel-only %s resources into the prompt', + (type) => { + const onContextAdd = vi.fn() + const { result, unmount } = renderPromptEditor({ + workspaceId: 'ws-1', + initialValue: 'Keep this', + onContextAdd, + }) + + act(() => { + result().insertResource({ type, id: 'resource-1', title: 'Panel resource' }) + }) + + expect(result().value).toBe('Keep this') + expect(result().contexts).toEqual([]) + expect(onContextAdd).not.toHaveBeenCalled() + unmount() + } + ) + it('leaves a cross-workspace selection to the ordinary plain-text paste path', () => { const context = { kind: 'file_selection', diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index c7b50e9ec2e..e62b45e44db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -314,6 +314,10 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { return context.fileId ? { type: 'file', id: context.fileId } : null case 'file_selection': return context.fileId ? { type: 'file', id: context.fileId } : null + case 'skill': + return context.skillId ? { type: 'skill', id: context.skillId } : null + case 'mcp': + return context.serverId ? { type: 'mcp_server', id: context.serverId } : null case 'dashboard': return { type: 'dashboard', id: context.dashboardId } default: diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 362a342ba20..f3aff3cd1c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -70,7 +70,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven (!settings.workspaceId || (chatWorkspaceId && settings.workspaceId !== chatWorkspaceId)) ) return - refreshSettings(queryClient, settings) + refreshSettings(queryClient, settings, ctx.deps.chatIdRef.current ?? undefined) if (settings.scope !== 'account' || settings.id === 'profile') ctx.deps.refreshRoute?.() return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/refresh-settings.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/refresh-settings.ts index 5a830ee9fd4..0a06fe0cdf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/refresh-settings.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/refresh-settings.ts @@ -5,7 +5,11 @@ import { refreshGeneralSettings } from '@/hooks/queries/general-settings' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' /** Read canonical settings again; replay must never restore a historical setting value. */ -export function refreshSettings(queryClient: QueryClient, settings: SettingsRefresh): void { +export function refreshSettings( + queryClient: QueryClient, + settings: SettingsRefresh, + chatId?: string +): void { if (settings.scope === 'account' && settings.id === 'preferences') { void refreshGeneralSettings(queryClient) return @@ -14,6 +18,13 @@ export function refreshSettings(queryClient: QueryClient, settings: SettingsRefr void queryClient.invalidateQueries({ queryKey: userProfileKeys.all }) return } + if ( + chatId && + settings.scope === 'workspace' && + ['skills', 'custom-tools', 'mcp'].includes(settings.id) + ) { + void queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) }) + } // Access, billing and integration policies affect resources beyond the Settings screen. // Keep live chat history stable while revalidating the rest of the current viewer's cache. void queryClient.invalidateQueries({ 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 333ebad5e6a..8bd8cf3d1e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1349,6 +1349,9 @@ export function useChat( return false } + const visibleResourceId = activeResourceIdRef.current + if (visibleResourceId) setActiveResourceId((current) => current ?? visibleResourceId) + setResources((prev) => { const current = prev.find((r) => getChatResourceKey(r) === getChatResourceKey(resource)) if (!current) return [...prev, resource] @@ -1367,7 +1370,7 @@ export function useChat( resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, persistenceScopeId, existing) return existing === undefined }, - [queryClient, resourcePersistenceQueue] + [queryClient, resourcePersistenceQueue, setActiveResourceId] ) const removeResource = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts index 45d678d0ff5..a3c2f91ffdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts @@ -22,6 +22,7 @@ import { type useChat, } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import { useMothershipResize } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-resize' +import { useResourceTransitionGuard } from '@/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard' import { resolveResourceEventPresentation, resolveResourceSelectionUpdate, @@ -34,6 +35,7 @@ import { /** URL selection and event attention are installed before the chat starts streaming. */ export function useResourcePanelController() { + const transitionGuard = useResourceTransitionGuard() /** * URL is the single source of truth for the selected resource. `Home` renders * client-side, so nuqs reads `?resource=` from the URL on mount — the same @@ -107,23 +109,29 @@ export function useResourcePanelController() { setResourceActivityIds((current) => new Set(current).add(resourceId)) return } - setResourceActivityIds((current) => { - if (!current.has(resourceId)) return current - const next = new Set(current) - next.delete(resourceId) - return next - }) - if (presentation.activateResource && options?.tableViewId) { - /** A live view request replaces the host URL's previous table selection. */ - void setTableParams({ view: options.tableViewId, sort: null, dir: null }) - } - if (presentation.activateResource && activeResourceId !== resourceId) { - activeResourceParamRef.current = resourceId - setActiveResourceUrl(resourceId) + const focusResource = () => { + setResourceActivityIds((current) => { + if (!current.has(resourceId)) return current + const next = new Set(current) + next.delete(resourceId) + return next + }) + if (presentation.activateResource && options?.tableViewId) { + /** A live view request replaces the host URL's previous table selection. */ + void setTableParams({ view: options.tableViewId, sort: null, dir: null }) + } + if (presentation.activateResource && activeResourceId !== resourceId) { + activeResourceParamRef.current = resourceId + setActiveResourceUrl(resourceId) + } } + transitionGuard.routeAutomaticResourceFocus(resourceId, focusResource, () => { + setResourceActivityIds((current) => new Set(current).add(resourceId)) + }) } return { + ...transitionGuard, activeResourceParam, activeResourceParamRef, activeResourceState, @@ -178,6 +186,9 @@ export function useChatResourcePanel( resourceSelectionOwnedByUserRef, effectiveActiveResourceIdRef, onResourceEvent: handleResourceEvent, + requestResourceTransition, + rebaseHistorySentinel, + reset: resetResourceTransitionGuard, } = controller const { mothershipRef, handleResizePointerDown, handleResizeKeyDown, handleResizeFocus } = useMothershipResize(desktopScopeId, { userId, collapsed: isResourceCollapsed }) @@ -185,10 +196,12 @@ export function useChatResourcePanel( const resourceAttentionChatIdRef = useRef(resolvedChatId) const collapseResource = useCallback(() => { - resourceCollapseOwnedByUserRef.current = true - resourceSelectionOwnedByUserRef.current = true - setResourceCollapsed(true) - }, [setResourceCollapsed]) + requestResourceTransition(() => { + resourceCollapseOwnedByUserRef.current = true + resourceSelectionOwnedByUserRef.current = true + setResourceCollapsed(true) + }) + }, [requestResourceTransition, setResourceCollapsed]) const clearResourceActivity = useCallback((resourceId: string) => { setResourceActivityIds((current) => { @@ -209,14 +222,18 @@ export function useChatResourcePanel( const selectResourceFromUser = useCallback( (resourceId: string) => { - resourceSelectionOwnedByUserRef.current = true - clearResourceActivity(resourceId) - if (effectiveActiveResourceIdRef.current === resourceId) return - effectiveActiveResourceIdRef.current = resourceId - activeResourceParamRef.current = resourceId - setActiveResourceId(resourceId) + const select = () => { + resourceSelectionOwnedByUserRef.current = true + clearResourceActivity(resourceId) + if (effectiveActiveResourceIdRef.current === resourceId) return + effectiveActiveResourceIdRef.current = resourceId + activeResourceParamRef.current = resourceId + setActiveResourceId(resourceId) + } + if (effectiveActiveResourceIdRef.current === resourceId) select() + else requestResourceTransition(select) }, - [setActiveResourceId, clearResourceActivity] + [setActiveResourceId, clearResourceActivity, requestResourceTransition] ) const desktopTabResourceOptions = { @@ -232,13 +249,15 @@ export function useChatResourcePanel( const addResourceFromUser = useCallback( (resource: MothershipResource) => { - resourceCollapseOwnedByUserRef.current = false - resourceSelectionOwnedByUserRef.current = true - addResource(resource) - selectResourceFromUser(getChatResourceSelectionId(resource)) - setResourceCollapsed(false) + requestResourceTransition(() => { + resourceCollapseOwnedByUserRef.current = false + resourceSelectionOwnedByUserRef.current = true + addResource(resource) + selectResourceFromUser(getChatResourceSelectionId(resource)) + setResourceCollapsed(false) + }) }, - [addResource, selectResourceFromUser, setResourceCollapsed] + [addResource, selectResourceFromUser, setResourceCollapsed, requestResourceTransition] ) const handleResourceResizePointerDown = useCallback( @@ -269,6 +288,7 @@ export function useChatResourcePanel( useEffect(() => { const previousChatId = resourceAttentionChatIdRef.current resourceAttentionChatIdRef.current = resolvedChatId + if (resolvedChatId && !previousChatId) rebaseHistorySentinel() if (!resolvedChatId) { setResourceCollapsed(true) } @@ -276,8 +296,9 @@ export function useChatResourcePanel( resourceCollapseOwnedByUserRef.current = false resourceSelectionOwnedByUserRef.current = false setResourceActivityIds(new Set()) + resetResourceTransitionGuard() } - }, [resolvedChatId, setResourceCollapsed]) + }, [resolvedChatId, setResourceCollapsed, rebaseHistorySentinel, resetResourceTransitionGuard]) useEffect(() => { if ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.test.tsx new file mode 100644 index 00000000000..86ad1af12da --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.test.tsx @@ -0,0 +1,194 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { requestMothershipNavigation } from '@/lib/mothership/events' +import { useResourceTransitionGuard } from '@/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard' + +function renderGuard() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + let latest: ReturnType + + function Probe() { + latest = useResourceTransitionGuard() + return null + } + + act(() => root.render()) + return { result: () => latest, unmount: () => act(() => root.unmount()) } +} + +describe('useResourceTransitionGuard', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/workspace/ws-1/chat/chat-1') + vi.spyOn(window.history, 'back').mockImplementation(() => {}) + }) + + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('keeps a dirty draft when cancelled and performs the complete deferred action on discard', () => { + const selectAnotherTab = vi.fn() + const closeSelectedTabs = vi.fn() + const guard = renderGuard() + + act(() => guard.result().reportResourceDirty('skill-1', true)) + act(() => guard.result().requestResourceTransition(selectAnotherTab)) + + expect(selectAnotherTab).not.toHaveBeenCalled() + expect(guard.result().showDiscardConfirmation).toBe(true) + + act(() => guard.result().dismissDiscardConfirmation()) + + expect(selectAnotherTab).not.toHaveBeenCalled() + expect(guard.result().showDiscardConfirmation).toBe(false) + + act(() => guard.result().requestResourceTransition(closeSelectedTabs)) + act(() => guard.result().confirmDiscard()) + + expect(closeSelectedTabs).not.toHaveBeenCalled() + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(closeSelectedTabs).toHaveBeenCalledOnce() + expect(guard.result().showDiscardConfirmation).toBe(false) + + act(() => guard.result().requestResourceTransition(selectAnotherTab)) + expect(selectAnotherTab).toHaveBeenCalledOnce() + guard.unmount() + }) + + it('marks agent activity without focusing over a dirty editor or opening a modal', () => { + const focus = vi.fn() + const markAttention = vi.fn() + const guard = renderGuard() + + act(() => guard.result().reportResourceDirty('skill-1', true)) + act(() => guard.result().routeAutomaticResourceFocus('mcp-1', focus, markAttention)) + + expect(focus).not.toHaveBeenCalled() + expect(markAttention).toHaveBeenCalledOnce() + expect(guard.result().showDiscardConfirmation).toBe(false) + act(() => guard.result().reportResourceDirty('skill-1', false)) + guard.unmount() + }) + + it('guards browser unload and replays app-link navigation only after discard', () => { + const guard = renderGuard() + const link = document.createElement('a') + link.href = '/workspace/ws-1/chat/chat-2' + link.textContent = 'Another chat' + const navigate = vi.fn((event: MouseEvent) => event.preventDefault()) + link.addEventListener('click', navigate) + document.body.appendChild(link) + + act(() => guard.result().reportResourceDirty('custom-tool-1', true)) + + const beforeUnload = new Event('beforeunload', { cancelable: true }) + window.dispatchEvent(beforeUnload) + expect(beforeUnload.defaultPrevented).toBe(true) + + act(() => link.click()) + expect(navigate).not.toHaveBeenCalled() + expect(guard.result().showDiscardConfirmation).toBe(true) + + act(() => guard.result().dismissDiscardConfirmation()) + expect(navigate).not.toHaveBeenCalled() + + act(() => link.click()) + act(() => guard.result().confirmDiscard()) + expect(navigate).not.toHaveBeenCalled() + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(navigate).toHaveBeenCalledOnce() + expect(guard.result().showDiscardConfirmation).toBe(false) + + const cleanBeforeUnload = new Event('beforeunload', { cancelable: true }) + window.dispatchEvent(cleanBeforeUnload) + expect(cleanBeforeUnload.defaultPrevented).toBe(false) + guard.unmount() + }) + + it('defers browser Back until the dirty draft is discarded', () => { + const guard = renderGuard() + + act(() => guard.result().reportResourceDirty('mcp-1', true)) + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(guard.result().showDiscardConfirmation).toBe(true) + + act(() => guard.result().dismissDiscardConfirmation()) + expect(window.history.back).not.toHaveBeenCalled() + + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + act(() => guard.result().confirmDiscard()) + expect(window.history.back).toHaveBeenCalledOnce() + + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(window.history.back).toHaveBeenCalledTimes(2) + expect(guard.result().showDiscardConfirmation).toBe(false) + guard.unmount() + }) + + it('does not pop history after first-message routing replaces the sentinel', () => { + const guard = renderGuard() + + act(() => guard.result().reportResourceDirty('custom-tool-1', true)) + window.history.replaceState({ chat: 'chat-2' }, '', '/workspace/ws-1/chat/chat-2') + act(() => guard.result().reportResourceDirty('custom-tool-1', false)) + + expect(window.history.back).not.toHaveBeenCalled() + expect(window.location.pathname).toBe('/workspace/ws-1/chat/chat-2') + guard.unmount() + }) + + it('re-owns first-message history so Back cancellation stays put and discard leaves once', () => { + const guard = renderGuard() + + act(() => guard.result().reportResourceDirty('skill-1', true)) + window.history.replaceState(null, '', '/workspace/ws-1/chat/chat-2') + act(() => guard.result().rebaseHistorySentinel()) + + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(guard.result().showDiscardConfirmation).toBe(true) + act(() => guard.result().dismissDiscardConfirmation()) + expect(window.location.pathname).toBe('/workspace/ws-1/chat/chat-2') + + vi.mocked(window.history.back) + .mockImplementationOnce(() => { + window.history.replaceState({}, '', '/workspace/ws-1/chat/chat-2') + window.dispatchEvent(new PopStateEvent('popstate')) + }) + .mockImplementationOnce(() => { + window.history.replaceState({}, '', '/workspace/ws-1/home') + window.dispatchEvent(new PopStateEvent('popstate')) + }) + + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + act(() => guard.result().confirmDiscard()) + + expect(window.location.pathname).toBe('/workspace/ws-1/home') + expect(guard.result().showDiscardConfirmation).toBe(false) + guard.unmount() + }) + + it('defers programmatic navigation through the shared request entrypoint', () => { + const routerPush = vi.fn() + const guard = renderGuard() + + act(() => guard.result().reportResourceDirty('skill-1', true)) + act(() => requestMothershipNavigation(routerPush)) + + expect(routerPush).not.toHaveBeenCalled() + expect(guard.result().showDiscardConfirmation).toBe(true) + + act(() => guard.result().confirmDiscard()) + expect(routerPush).not.toHaveBeenCalled() + + act(() => window.dispatchEvent(new PopStateEvent('popstate'))) + expect(routerPush).toHaveBeenCalledOnce() + guard.unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.ts new file mode 100644 index 00000000000..78734055097 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.ts @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { generateId } from '@sim/utils/id' +import { + MOTHERSHIP_NAVIGATION_REQUEST_EVENT, + type MothershipNavigationRequestDetail, +} from '@/lib/mothership/events' + +interface ResourceTransitionGuard { + showDiscardConfirmation: boolean + reportResourceDirty: (resourceId: string, dirty: boolean) => void + requestResourceTransition: (transition: () => void) => void + routeAutomaticResourceFocus: ( + nextResourceId: string, + focus: () => void, + markAttention: () => void + ) => void + dismissDiscardConfirmation: () => void + confirmDiscard: () => void + rebaseHistorySentinel: () => void + reset: () => void +} + +const RESOURCE_HISTORY_SENTINEL_KEY = '__simResourceDraftSentinel' + +interface HistorySentinel { + token: string + url: string +} + +/** + * Owns the one dirty draft that can be mounted in Sim Chat's resource panel. + * User transitions wait for confirmation, while agent-driven focus is routed + * to the tab's attention state without interrupting the editor. + */ +export function useResourceTransitionGuard(): ResourceTransitionGuard { + const dirtyResourceIdRef = useRef(null) + const pendingTransitionRef = useRef<(() => void) | null>(null) + const historySentinelRef = useRef(null) + const [showDiscardConfirmation, setShowDiscardConfirmation] = useState(false) + + const seedHistorySentinel = useCallback(() => { + if (historySentinelRef.current) return + const sentinel = { token: generateId(), url: window.location.href } + const currentState = window.history.state + window.history.pushState( + { + ...(currentState && typeof currentState === 'object' ? currentState : {}), + [RESOURCE_HISTORY_SENTINEL_KEY]: sentinel.token, + }, + '', + sentinel.url + ) + historySentinelRef.current = sentinel + }, []) + + const retireHistorySentinel = useCallback((afterRetirement?: () => void) => { + const sentinel = historySentinelRef.current + historySentinelRef.current = null + const currentState = window.history.state + const ownsCurrentEntry = + sentinel !== null && + window.location.href === sentinel.url && + currentState !== null && + typeof currentState === 'object' && + currentState[RESOURCE_HISTORY_SENTINEL_KEY] === sentinel.token + + if (!ownsCurrentEntry) { + afterRetirement?.() + return + } + if (afterRetirement) { + window.addEventListener('popstate', afterRetirement, { once: true }) + } + window.history.back() + }, []) + + const rebaseHistorySentinel = useCallback(() => { + if (!dirtyResourceIdRef.current) return + const sentinel = historySentinelRef.current + const currentState = window.history.state + const ownsCurrentEntry = + sentinel !== null && + window.location.href === sentinel.url && + currentState !== null && + typeof currentState === 'object' && + currentState[RESOURCE_HISTORY_SENTINEL_KEY] === sentinel.token + if (ownsCurrentEntry) return + + historySentinelRef.current = null + seedHistorySentinel() + }, [seedHistorySentinel]) + + const reportResourceDirty = useCallback( + (resourceId: string, dirty: boolean) => { + if (dirty) { + dirtyResourceIdRef.current = resourceId + seedHistorySentinel() + return + } + if (dirtyResourceIdRef.current !== resourceId) return + dirtyResourceIdRef.current = null + pendingTransitionRef.current = null + setShowDiscardConfirmation(false) + retireHistorySentinel() + }, + [retireHistorySentinel, seedHistorySentinel] + ) + + const requestResourceTransition = useCallback((transition: () => void) => { + if (!dirtyResourceIdRef.current) { + transition() + return + } + pendingTransitionRef.current = transition + setShowDiscardConfirmation(true) + }, []) + + const routeAutomaticResourceFocus = useCallback( + (nextResourceId: string, focus: () => void, markAttention: () => void) => { + // Resource upserts run before their focus event. Adding an item can move + // the derived fallback ID to that new last tab before this callback runs, + // even though the dirty editor is still what the user sees. The guard is + // the authoritative owner of that mounted dirty editor, so protect it + // directly instead of trusting an active ID that may already have moved. + if (dirtyResourceIdRef.current && dirtyResourceIdRef.current !== nextResourceId) { + markAttention() + return + } + focus() + }, + [] + ) + + const dismissDiscardConfirmation = useCallback(() => { + pendingTransitionRef.current = null + setShowDiscardConfirmation(false) + }, []) + + const confirmDiscard = useCallback(() => { + const transition = pendingTransitionRef.current + pendingTransitionRef.current = null + dirtyResourceIdRef.current = null + setShowDiscardConfirmation(false) + retireHistorySentinel(transition ?? undefined) + }, [retireHistorySentinel]) + + const reset = useCallback(() => { + dirtyResourceIdRef.current = null + pendingTransitionRef.current = null + setShowDiscardConfirmation(false) + retireHistorySentinel() + }, [retireHistorySentinel]) + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!dirtyResourceIdRef.current) return + event.preventDefault() + } + + const handleNavigationRequest = (event: Event) => { + const detail = (event as CustomEvent).detail + if (typeof detail?.navigate !== 'function') return + event.preventDefault() + requestResourceTransition(detail.navigate) + } + + const handlePopState = () => { + if (!dirtyResourceIdRef.current) return + historySentinelRef.current = null + seedHistorySentinel() + requestResourceTransition(() => window.history.back()) + } + + /** + * Next.js handles links before a history listener can block them. Capture + * same-window app links first, then replay the original click after the + * user confirms so the link keeps its own routing and selection behavior. + */ + const handleDocumentClick = (event: MouseEvent) => { + if ( + !dirtyResourceIdRef.current || + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + !(event.target instanceof Element) + ) { + return + } + + const anchor = event.target.closest('a[href]') + if ( + !anchor || + anchor.hasAttribute('download') || + (anchor.target && anchor.target !== '_self') + ) { + return + } + + const destination = new URL(anchor.href, window.location.href) + const current = new URL(window.location.href) + if ( + destination.origin !== current.origin || + (destination.pathname === current.pathname && destination.search === current.search) + ) { + return + } + + event.preventDefault() + event.stopPropagation() + requestResourceTransition(() => anchor.click()) + } + + window.addEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('popstate', handlePopState) + window.addEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest) + document.addEventListener('click', handleDocumentClick, true) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('popstate', handlePopState) + window.removeEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest) + document.removeEventListener('click', handleDocumentClick, true) + } + }, [requestResourceTransition, seedHistorySentinel]) + + return { + showDiscardConfirmation, + reportResourceDirty, + requestResourceTransition, + routeAutomaticResourceFocus, + dismissDiscardConfirmation, + confirmDiscard, + rebaseHistorySentinel, + reset, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx index 809feeeb408..9c45c20f977 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -38,7 +38,13 @@ interface CustomToolDetailProps { tool: CustomToolDefinition | null /** Viewers without edit rights get the same page with every control inert. */ readOnly?: boolean + /** Embedded editors defer navigation through the resource panel's guard. */ + embedded?: boolean + /** Reports draft state to the resource-panel transition guard. */ + onDirtyChange?: (dirty: boolean) => void onBack: () => void + /** Lets an embedded detail close immediately after its resource was deleted. */ + onDeleted?: () => void /** Lands the caller on the tool it just created, matching the skill create flow. */ onCreated?: (toolId: string) => void } @@ -53,7 +59,10 @@ export function CustomToolDetail({ workspaceId, tool, readOnly = false, + embedded = false, + onDirtyChange, onBack, + onDeleted, onCreated, }: CustomToolDetailProps) { const isEditing = !!tool @@ -75,10 +84,46 @@ export function CustomToolDetail({ const [jsonSchema, setJsonSchema] = useState(seededSchema) const [functionCode, setFunctionCode] = useState(seededCode) + const [previousToolSource, setPreviousToolSource] = useState<{ + id: string + schema: string + code: string + } | null>(() => + tool + ? { id: tool.id, schema: JSON.stringify(tool.schema, null, 2), code: tool.code ?? '' } + : null + ) const [schemaError, setSchemaError] = useState(null) const [codeError, setCodeError] = useState(null) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + if (tool) { + const nextSource = { + id: tool.id, + schema: JSON.stringify(tool.schema, null, 2), + code: tool.code ?? '', + } + const switchedTool = previousToolSource?.id !== tool.id + const sourceChanged = + previousToolSource !== null && + (previousToolSource.id !== nextSource.id || + previousToolSource.schema !== nextSource.schema || + previousToolSource.code !== nextSource.code) + + if (switchedTool || (sourceChanged && !updateTool.isPending)) { + const hadLocalDraft = jsonSchema !== seededSchema || functionCode !== seededCode + setPreviousToolSource(nextSource) + setSeededSchema(nextSource.schema) + setSeededCode(nextSource.code) + if (switchedTool || !hadLocalDraft) { + setJsonSchema(nextSource.schema) + setFunctionCode(nextSource.code) + setSchemaError(null) + setCodeError(null) + } + } + } + const schemaParameters = useMemo(() => extractSchemaParameters(jsonSchema), [jsonSchema]) /** @@ -117,7 +162,15 @@ export function CustomToolDetail({ ? jsonSchema !== seededSchema || functionCode !== seededCode : jsonSchema.trim().length > 0 || functionCode.trim().length > 0 - const guard = useSettingsUnsavedGuard({ isDirty: dirty }) + const guard = useSettingsUnsavedGuard({ isDirty: dirty, enabled: !embedded }) + + useEffect(() => { + onDirtyChange?.(dirty) + }, [dirty, onDirtyChange]) + + useEffect(() => { + return () => onDirtyChange?.(false) + }, [onDirtyChange]) const saving = createTool.isPending || updateTool.isPending const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema]) @@ -186,7 +239,8 @@ export function CustomToolDetail({ setShowDeleteConfirm(false) try { await deleteTool.mutateAsync({ workspaceId, toolId: tool.id }) - onBack() + if (onDeleted) onDeleted() + else onBack() } catch (error) { logger.error('Failed to delete custom tool', error) toast.error("Couldn't delete tool", { @@ -299,11 +353,13 @@ export function CustomToolDetail({ }} /> - + {!embedded && ( + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.test.tsx new file mode 100644 index 00000000000..236fe4db7cb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.test.tsx @@ -0,0 +1,82 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { clearTestResult } = vi.hoisted(() => ({ clearTestResult: vi.fn() })) + +vi.mock('@/hooks/queries/mcp', () => ({ + useMcpServerTest: () => ({ + testResult: null, + isTestingConnection: false, + testConnection: vi.fn(), + clearTestResult, + }), +})) + +import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal' + +describe('McpServerFormModal dirty state', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + clearTestResult.mockReset() + }) + + it('reports edits only while the embedded edit form is open', () => { + const onDirtyChange = vi.fn() + const renderModal = (open: boolean) => { + act(() => { + root.render( + + ) + }) + } + + renderModal(true) + expect(onDirtyChange).toHaveBeenLastCalledWith(false) + + const nameInput = document.querySelector( + 'input[placeholder="e.g., My MCP Server"]' + ) + if (!nameInput) throw new Error('MCP server name input was not rendered') + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(nameInput, 'Renamed server') + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(onDirtyChange).toHaveBeenLastCalledWith(true) + + renderModal(false) + expect(onDirtyChange).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index d1d3ceb4b4c..2326597ad83 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -57,6 +57,8 @@ export interface McpServerFormConfig { export interface McpServerFormModalProps { open: boolean onOpenChange: (open: boolean) => void + /** Reports unsaved edits when a host surface owns navigation protection. */ + onDirtyChange?: (dirty: boolean) => void mode: 'add' | 'edit' initialData?: McpServerFormData onSubmit: (config: McpServerFormConfig) => Promise @@ -308,6 +310,7 @@ function updateHeadersArray( export function McpServerFormModal({ open, onOpenChange, + onDirtyChange, mode, initialData, onSubmit, @@ -452,6 +455,14 @@ export function McpServerFormModal({ } const hasChanges = computeHasChanges() + useEffect(() => { + onDirtyChange?.(open && hasChanges) + }, [hasChanges, onDirtyChange, open]) + + useEffect(() => { + return () => onDirtyChange?.(false) + }, [onDirtyChange]) + const parseJsonConfig = ( json: string ): { name: string; url: string; headers: Record } | null => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 488a0ef206c..cd89c4d2642 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -181,15 +181,30 @@ function buildEditInitialData(server: McpServer) { } } -export function MCP() { +interface MCPProps { + /** Explicit values embed one server detail without changing settings search params. */ + workspaceId?: string + serverId?: string + onBack?: () => void + /** Reports edits in the server form to an embedding navigation guard. */ + onDirtyChange?: (dirty: boolean) => void +} + +export function MCP({ + workspaceId: explicitWorkspaceId, + serverId, + onBack, + onDirtyChange, +}: MCPProps = {}) { const params = useParams() - const workspaceId = params.workspaceId as string + const workspaceId = explicitWorkspaceId ?? (params.workspaceId as string) const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('mcp', workspacePermissions) const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, { ...mcpServerIdParam.parser, ...mcpServerIdUrlKeys, }) + const activeServerId = serverId ?? selectedServerId const [searchTerm, setSearchTerm] = useSettingsSearch() const [showAddModal, setShowAddModal] = useState(false) const [editingServerId, setEditingServerId] = useState(null) @@ -201,11 +216,13 @@ export function MCP() { const { data: servers = [], isLoading: serversLoading, + isPending: serversPending, + isPlaceholderData: serversPlaceholder, error: serversError, } = useMcpServers(workspaceId) const { data: mcpToolsData = [], toolsStateByServer } = useMcpToolsQuery(workspaceId) const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId, { - enabled: selectedServerId !== null, + enabled: activeServerId !== null, }) const forceRefreshToolsMutation = useForceRefreshMcpTools() const forceRefreshTools = forceRefreshToolsMutation.mutate @@ -230,7 +247,7 @@ export function MCP() { const showDeleteDialog = serverToDeleteId !== null - const initialServerIdRef = useRef(selectedServerId) + const initialServerIdRef = useRef(activeServerId) const didDeepLinkRefreshRef = useRef(false) useEffect(() => { if (didDeepLinkRefreshRef.current) return @@ -256,7 +273,7 @@ export function MCP() { await deleteServerMutation.mutateAsync({ workspaceId, serverId }) // Deleting from the detail view leaves a dead id in the URL — drop it so Back // doesn't land on a server that no longer exists. - if (selectedServerId === serverId) handleBackToList() + if (activeServerId === serverId) handleBackToList() logger.info(`Removed MCP server: ${serverId}`) } catch (error) { logger.error('Failed to remove MCP server:', error) @@ -294,7 +311,8 @@ export function MCP() { /** Closing replaces the URL — Back should leave the section, not reopen the detail view. */ const handleBackToList = () => { - setSelectedServerId(null, { history: 'replace' }) + if (onBack) onBack() + else setSelectedServerId(null, { history: 'replace' }) setExpandedTools(new Set()) } @@ -355,10 +373,10 @@ export function MCP() { const editInitialData = editingServer ? buildEditInitialData(editingServer) : undefined const selectedServer = (() => { - if (!selectedServerId) return null - const server = servers.find((s) => s.id === selectedServerId) as McpServer | undefined + if (!activeServerId) return null + const server = servers.find((s) => s.id === activeServerId) as McpServer | undefined if (!server) return null - const serverTools = (toolsByServer[selectedServerId] || []) as McpTool[] + const serverTools = (toolsByServer[activeServerId] || []) as McpTool[] return { server, tools: serverTools } })() @@ -434,6 +452,30 @@ export function MCP() { /> ) : null + if (serverId && (serversPending || serversPlaceholder)) { + return ( + + Loading... + + ) + } + + if (serverId && serversError && !selectedServer) { + return ( + + + {getErrorMessage(serversError, 'Failed to load this MCP server')} + + + ) + } + if (selectedServer) { const { server, tools } = selectedServer const transportLabel = formatTransportLabel(server.transport || 'http') @@ -646,13 +688,14 @@ export function MCP() { onOpenChange={(open) => { if (!open) setEditingServerId(null) }} + onDirtyChange={onDirtyChange} mode='edit' initialData={editInitialData} onSubmit={async (config) => { - const currentServer = servers.find((s) => s.id === selectedServerId) + const currentServer = servers.find((s) => s.id === activeServerId) await updateServerMutation.mutateAsync({ workspaceId, - serverId: selectedServerId!, + serverId: activeServerId!, updates: { ...config, enabled: currentServer?.enabled ?? true, @@ -671,6 +714,19 @@ export function MCP() { ) } + if (serverId) { + return ( + + + This MCP server may have been deleted or disconnected. + + + ) + } + return ( <> void + onDeleted?: () => void } /** @@ -42,7 +47,13 @@ interface SkillDetailProps { * Description / Content sections, and the Skill Editors roster. Non-editors * and built-in template skills render read-only. */ -export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { +export function SkillDetail({ + workspaceId, + skillId, + embedded = false, + onDirtyChange, + onDeleted, +}: SkillDetailProps) { const router = useRouter() const skillsHref = `/workspace/${workspaceId}/skills` @@ -71,7 +82,12 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const [errors, setErrors] = useState({}) const [shareOpen, setShareOpen] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const [prevSkillId, setPrevSkillId] = useState(null) + const [previousSkillSource, setPreviousSkillSource] = useState<{ + id: string + name: string + description: string + content: string + } | null>(null) /** Applies a full skill shape to all three drafts and remounts the Content editor. */ const seedDrafts = (source: { name: string; description: string; content: string }) => { @@ -82,21 +98,57 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { setContentSeed((seed) => seed + 1) } - // Seed drafts when the skill first resolves (or the route id changes); a - // background refetch of the same skill must not clobber an in-progress edit. - if (skill && skill.id !== prevSkillId) { - setPrevSkillId(skill.id) - seedDrafts(skill) + // A clean editor follows server-side changes (including Mothership edits), + // while a background refetch must not clobber an in-progress local draft. + if (skill) { + const nextSource = { + id: skill.id, + name: skill.name, + description: skill.description, + content: skill.content, + } + const switchedSkill = previousSkillSource?.id !== skill.id + const sourceChanged = + previousSkillSource !== null && + (previousSkillSource.id !== nextSource.id || + previousSkillSource.name !== nextSource.name || + previousSkillSource.description !== nextSource.description || + previousSkillSource.content !== nextSource.content) + + if (switchedSkill || (sourceChanged && !updateSkill.isPending)) { + const shouldReseed = + switchedSkill || + previousSkillSource === null || + (nameDraft === previousSkillSource.name && + descriptionDraft === previousSkillSource.description && + contentDraft === previousSkillSource.content) + setPreviousSkillSource(nextSource) + if (shouldReseed) seedDrafts(skill) + } } + const dirtyBaseline = previousSkillSource?.id === skill?.id ? previousSkillSource : skill const isDirty = !!skill && !isBuiltin && - (nameDraft !== skill.name || - descriptionDraft !== skill.description || - contentDraft !== skill.content) + !!dirtyBaseline && + (nameDraft !== dirtyBaseline.name || + descriptionDraft !== dirtyBaseline.description || + contentDraft !== dirtyBaseline.content) + + const guard = useUnsavedChangesGuard({ + isDirty, + backHref: skillsHref, + enabled: !embedded, + }) + + useEffect(() => { + onDirtyChange?.(isDirty) + }, [isDirty, onDirtyChange]) - const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref }) + useEffect(() => { + return () => onDirtyChange?.(false) + }, [onDirtyChange]) const handleSave = async () => { if (!skill || !canEdit || !isDirty || updateSkill.isPending) return @@ -144,7 +196,8 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { guard.release() try { await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id }) - router.replace(skillsHref) + if (embedded) onDeleted?.() + else router.replace(skillsHref) } catch (error) { guard.rearm() toast.error("Couldn't delete skill", { @@ -166,7 +219,9 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { return true } - const back = ( + const back = embedded ? ( +
+ ) : ( Skills @@ -282,11 +337,13 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { hideRole /> - + {!embedded && ( + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 85a47af1cf1..c30a3d0117a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -44,7 +44,7 @@ import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transpo import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { getFolderPathNames } from '@/lib/folders/tree' -import { sendMothershipMessage } from '@/lib/mothership/events' +import { requestMothershipNavigation, sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' @@ -177,6 +177,13 @@ function SearchModalContent({ const posthogRef = useRef(posthog) posthogRef.current = posthog + const navigate = useCallback((href: string, afterNavigate?: () => void) => { + requestMothershipNavigation(() => { + routerRef.current.push(href) + afterNavigate?.() + }) + }, []) + const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data) /** @@ -397,7 +404,7 @@ function SearchModalContent({ exactQueries: ['chats'], icon: Home, context: 'global', - run: () => routerRef.current.push(`/workspace/${workspaceId}/home`), + run: () => navigate(`/workspace/${workspaceId}/home`), }) } if (canEdit && onCreateWorkflow) { @@ -678,6 +685,7 @@ function SearchModalContent({ onCreateFolder, onImportWorkflow, invokeCommand, + navigate, navigateToSettings, ]) @@ -808,9 +816,10 @@ function SearchModalContent({ const handleWorkflowSelect = useCallback( (workflow: WorkflowItem) => { if (!workflow.isCurrent && workflow.href) { - routerRef.current.push(workflow.href) - window.dispatchEvent( - new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } }) + navigate(workflow.href, () => + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } }) + ) ) } captureEvent(posthogRef.current, 'search_result_selected', { @@ -820,13 +829,13 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleWorkspaceSelect = useCallback( (workspace: WorkspaceItem) => { if (!workspace.isCurrent && workspace.href) { - routerRef.current.push(workspace.href) + navigate(workspace.href) } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'workspace', @@ -835,12 +844,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleChatSelect = useCallback( (chat: TaskItem) => { - routerRef.current.push(chat.href) + navigate(chat.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'task', query_length: searchRef.current.length, @@ -848,12 +857,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleTableSelect = useCallback( (item: TaskItem) => { - routerRef.current.push(item.href) + navigate(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'table', query_length: searchRef.current.length, @@ -861,12 +870,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleFileSelect = useCallback( (item: FileItem) => { - routerRef.current.push(item.href) + navigate(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'file', query_length: searchRef.current.length, @@ -874,12 +883,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleKbSelect = useCallback( (item: TaskItem) => { - routerRef.current.push(item.href) + navigate(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'knowledge_base', query_length: searchRef.current.length, @@ -887,7 +896,7 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handlePageSelect = useCallback( @@ -898,7 +907,7 @@ function SearchModalContent({ if (page.href.startsWith('http')) { window.open(page.href, '_blank', 'noopener,noreferrer') } else { - routerRef.current.push(page.href) + navigate(page.href) } } captureEvent(posthogRef.current, 'search_result_selected', { @@ -908,12 +917,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleLogSelect = useCallback( (item: LogItem) => { - routerRef.current.push(item.href) + navigate(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'log', query_length: searchRef.current.length, @@ -921,12 +930,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleConnectedAccountSelect = useCallback( (item: IntegrationSearchItem) => { - routerRef.current.push(item.href) + navigate(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'connected_account', query_length: searchRef.current.length, @@ -934,12 +943,12 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleIntegrationSelect = useCallback( (item: IntegrationSearchItem) => { - routerRef.current.push(item.href) + navigate(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'integration', query_length: searchRef.current.length, @@ -947,7 +956,7 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleActionSelect = useCallback( @@ -971,28 +980,36 @@ function SearchModalContent({ const homeHref = `/workspace/${workspaceId}/home` const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) + const finish = () => { + onOpenChangeRef.current(false) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: 'action', + action_id: 'new-chat-from-query', + query_length: query.length, + workspace_id: workspaceId, + }) + } + if (!sentToMountedHome) { /* One-shot auto-send handoff: Home's mount consumer sends it on arrival, so both routes deliver the raw query identically. use-chat's queued send dispatch now survives the mount-settling effect cycle that used to silently abort programmatic sends (the old reason this was a prefill). */ - if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { - logger.warn('Failed to persist command palette query for a new chat', { - workspaceId, - }) - return - } - routerRef.current.push(homeHref) + requestMothershipNavigation(() => { + if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { + logger.warn('Failed to persist command palette query for a new chat', { + workspaceId, + }) + return + } + routerRef.current.push(homeHref) + finish() + }) + return } - onOpenChangeRef.current(false) - captureEvent(posthogRef.current, 'search_result_selected', { - result_type: 'action', - action_id: 'new-chat-from-query', - query_length: query.length, - workspace_id: workspaceId, - }) + finish() }, [workspaceId]) /** Enter in ask mode: hand the query to Sim, or just open a new chat when empty. */ @@ -1001,7 +1018,7 @@ function SearchModalContent({ handleNewChatFromQuery() return } - routerRef.current.push(`/workspace/${workspaceId}/home`) + navigate(`/workspace/${workspaceId}/home`) onOpenChangeRef.current(false) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'action', @@ -1009,7 +1026,7 @@ function SearchModalContent({ query_length: 0, workspace_id: workspaceId, }) - }, [workspaceId, handleNewChatFromQuery]) + }, [workspaceId, handleNewChatFromQuery, navigate]) const handleOverlayClick = useCallback(() => { onOpenChangeRef.current(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 683439f271b..318bc201899 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -49,6 +49,7 @@ import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { requestMothershipNavigation } from '@/lib/mothership/events' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { captureEvent } from '@/lib/posthog/client' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' @@ -833,7 +834,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) label: 'Organization', icon: Building, href: organizationHref, - onNavigate: () => router.push(organizationHref), + onNavigate: () => requestMothershipNavigation(() => router.push(organizationHref)), }) } @@ -888,31 +889,39 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) (id) => currentPath === `/workspace/${workspaceId}/chat/${id}` ) - const onDeleteSuccess = () => { - useFolderStore.getState().clearChatSelection() - if (isViewingDeletedChat) { - router.push(`/workspace/${workspaceId}/home`) + const deleteChats = () => { + const onDeleteSuccess = () => { + useFolderStore.getState().clearChatSelection() + if (isViewingDeletedChat) { + router.push(`/workspace/${workspaceId}/home`) + } } - } - if (chatIdsToDelete.length === 1) { - deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess: onDeleteSuccess }) - } else { - deleteChatsMutation.mutate(chatIdsToDelete, { - onSuccess: onDeleteSuccess, - onError: (error) => { - if ( - error instanceof MothershipChatDeleteError && - error.deletedChatIds.some( - (id) => window.location.pathname === `/workspace/${workspaceId}/chat/${id}` - ) - ) { - router.push(`/workspace/${workspaceId}/home`) - } - }, - }) + if (chatIdsToDelete.length === 1) { + deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess: onDeleteSuccess }) + } else { + deleteChatsMutation.mutate(chatIdsToDelete, { + onSuccess: onDeleteSuccess, + onError: (error) => { + if ( + error instanceof MothershipChatDeleteError && + error.deletedChatIds.some( + (id) => window.location.pathname === `/workspace/${workspaceId}/chat/${id}` + ) + ) { + router.push(`/workspace/${workspaceId}/home`) + } + }, + }) + } } setIsChatDeleteModalOpen(false) + if (!isViewingDeletedChat) { + deleteChats() + return + } + + requestMothershipNavigation(deleteChats) } const [visibleChatCount, setVisibleChatCount] = useState(5) @@ -1248,7 +1257,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) try { const pathWorkspaceId = resolveWorkspaceIdFromPath() if (pathWorkspaceId) { - router.push(`/workspace/${pathWorkspaceId}/logs`) + requestMothershipNavigation(() => router.push(`/workspace/${pathWorkspaceId}/logs`)) logger.info('Navigated to logs', { workspaceId: pathWorkspaceId }) } else { logger.warn('No workspace ID found, cannot navigate to logs') diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx b/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx new file mode 100644 index 00000000000..3766720bb30 --- /dev/null +++ b/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx @@ -0,0 +1,44 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +function renderDisabledGuard() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + let latest: ReturnType + + function Probe() { + latest = useSettingsUnsavedGuard({ isDirty: true, enabled: false }) + return null + } + + act(() => root.render()) + return { result: () => latest, unmount: () => act(() => root.unmount()) } +} + +describe('useSettingsUnsavedGuard', () => { + beforeEach(() => { + useSettingsDirtyStore.getState().reset() + }) + + it('leaves global settings navigation clean when an embedded editor owns guarding', () => { + const leave = vi.fn() + const guard = renderDisabledGuard() + + expect(useSettingsDirtyStore.getState().isDirty).toBe(false) + + act(() => guard.result().guardBack(leave)) + + expect(leave).toHaveBeenCalledOnce() + expect(guard.result().showUnsavedModal).toBe(false) + + guard.unmount() + expect(useSettingsDirtyStore.getState().isDirty).toBe(false) + }) +}) diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.ts b/apps/sim/components/settings/use-settings-unsaved-guard.ts index a9f272a652d..d53c91ce7bd 100644 --- a/apps/sim/components/settings/use-settings-unsaved-guard.ts +++ b/apps/sim/components/settings/use-settings-unsaved-guard.ts @@ -3,6 +3,8 @@ import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' interface UseSettingsUnsavedGuardParams { isDirty: boolean + /** Embedded editors use their host's guard instead of global settings navigation. */ + enabled?: boolean navigationBlocked?: boolean } @@ -18,19 +20,25 @@ interface SettingsUnsavedGuard { */ export function useSettingsUnsavedGuard({ isDirty, + enabled = true, navigationBlocked = false, }: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard { const setDirty = useSettingsDirtyStore((state) => state.setDirty) const setNavigationBlocked = useSettingsDirtyStore((state) => state.setNavigationBlocked) const reset = useSettingsDirtyStore((state) => state.reset) - const isDirtyRef = useRef(isDirty) - const navigationBlockedRef = useRef(navigationBlocked) + const isDirtyRef = useRef(enabled && isDirty) + const navigationBlockedRef = useRef(enabled && navigationBlocked) const pendingLeaveRef = useRef<(() => void) | null>(null) const [showUnsavedModal, setShowUnsavedModal] = useState(false) useEffect(() => { - isDirtyRef.current = isDirty - navigationBlockedRef.current = navigationBlocked + isDirtyRef.current = enabled && isDirty + navigationBlockedRef.current = enabled && navigationBlocked + if (!enabled) { + pendingLeaveRef.current = null + setShowUnsavedModal(false) + return + } setDirty(isDirty) setNavigationBlocked(navigationBlocked) if (navigationBlocked) { @@ -42,11 +50,12 @@ export function useSettingsUnsavedGuard({ pendingLeaveRef.current = null setShowUnsavedModal(false) } - }, [isDirty, navigationBlocked, setDirty, setNavigationBlocked]) + }, [enabled, isDirty, navigationBlocked, setDirty, setNavigationBlocked]) useEffect(() => { + if (!enabled) return return () => reset() - }, [reset]) + }, [enabled, reset]) const guardBack = useCallback((onLeave: () => void) => { if (navigationBlockedRef.current || useSettingsDirtyStore.getState().navigationBlocked) { diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index 066c7136fb6..6b038ceb743 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -183,10 +183,10 @@ export function customToolsQueryOptions(workspaceId: string) { /** * Hook to fetch custom tools */ -export function useCustomTools(workspaceId: string) { +export function useCustomTools(workspaceId: string, options?: { enabled?: boolean }) { return useQuery({ ...customToolsQueryOptions(workspaceId), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 9fd4e8ac1c9..7b1408d753c 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -96,10 +96,10 @@ export interface McpServerInput { authType?: McpAuthType } -export function useMcpServers(workspaceId: string) { +export function useMcpServers(workspaceId: string, options?: { enabled?: boolean }) { return useQuery({ ...mcpServersQueryOptions(workspaceId), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), }) } diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index 35e13c4ed78..2d48164786d 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -60,8 +60,11 @@ export function getSkillsQueryOptions(workspaceId: string) { }) } -export function useSkills(workspaceId: string) { - return useQuery(getSkillsQueryOptions(workspaceId)) +export function useSkills(workspaceId: string, options?: { enabled?: boolean }) { + return useQuery({ + ...getSkillsQueryOptions(workspaceId), + enabled: !!workspaceId && (options?.enabled ?? true), + }) } interface CreateSkillParams { diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts index 534309ab677..a98bafa8df5 100644 --- a/apps/sim/hooks/use-settings-navigation.ts +++ b/apps/sim/hooks/use-settings-navigation.ts @@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' +import { requestMothershipNavigation } from '@/lib/mothership/events' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { popSettingsReturnUrl, rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -79,13 +80,15 @@ export function useSettingsNavigation(): UseSettingsNavigationReturn { const navigateToSettings = useCallback( (options?: SettingsNavigationOptions) => { - const currentPath = window.location.pathname - if (currentPath.startsWith(settingsPrefix)) { - router.replace(getSettingsHref(options), { scroll: false }) - } else { - rememberSettingsReturnUrl(getSettingsHref(options)) - router.push(getSettingsHref(options)) - } + requestMothershipNavigation(() => { + const currentPath = window.location.pathname + if (currentPath.startsWith(settingsPrefix)) { + router.replace(getSettingsHref(options), { scroll: false }) + } else { + rememberSettingsReturnUrl(getSettingsHref(options)) + router.push(getSettingsHref(options)) + } + }) }, [router, settingsPrefix, getSettingsHref] ) diff --git a/apps/sim/lib/mothership/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts index 6e28a4e67ae..d51a8c4e75d 100644 --- a/apps/sim/lib/mothership/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -990,6 +990,35 @@ describe('handleUnifiedChatPost', () => { expect(persistChatResources).not.toHaveBeenCalled() }) + it('accepts and persists panel-only resource attachments without adding artificial context', async () => { + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/mothership/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Keep these tabs open', + workspaceId: 'ws-1', + createNewChat: true, + resourceAttachments: [ + { type: 'skill', id: 'skill-1', title: 'Writing' }, + { type: 'custom_tool', id: 'tool-1', title: 'Formatter' }, + { type: 'mcp_server', id: 'mcp-1', title: 'GitHub' }, + ], + }), + }) + ) + + expect(response.status).toBe(200) + expect(persistChatResources).toHaveBeenCalledWith('chat-1', [ + { type: 'skill', id: 'skill-1', title: 'Writing' }, + { type: 'custom_tool', id: 'tool-1', title: 'Formatter' }, + { type: 'mcp_server', id: 'mcp-1', title: 'GitHub' }, + ]) + expect(resolveActiveResourceContext).not.toHaveBeenCalled() + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ contexts: [] }) + ) + }) + it('accepts and forwards more than eight open terminal hints', async () => { const terminals = Array.from({ length: 12 }, (_, index) => ({ id: String(index + 1), diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index e980d9be025..6b2927eb227 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -135,6 +135,9 @@ const GENERIC_RESOURCE_TITLE: Record['t filefolder: 'File Folder', task: 'Task', log: 'Log', + skill: 'Skill', + custom_tool: 'Custom Tool', + mcp_server: 'MCP Server', generic: 'Resource', browser: 'Browser', terminal: 'Terminal', @@ -603,6 +606,23 @@ async function resolveAgentContexts(params: { resource.workspaceId ) : { workspaceId: workspaceId! } + if ( + resource.type === 'skill' || + resource.type === 'custom_tool' || + resource.type === 'mcp_server' + ) { + if (persistResources) + authorizedResources.push( + mothershipResourceSchema.parse({ + ...resource, + title: resource.title ?? GENERIC_RESOURCE_TITLE[resource.type], + ...(organizationId + ? { workspaceId: target.workspaceId } + : { workspaceId: undefined, workspaceName: undefined }), + }) + ) + return null + } const ctx = await withWorkspaceInvocationScope( { workspaceId: target.workspaceId, organizationId }, () => diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 304545c6b94..a3c4f3022f8 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -19,6 +19,24 @@ function dispatchClaimable(name: string, detail: T): boolean { return !window.dispatchEvent(new CustomEvent(name, { detail, cancelable: true })) } +/** + * Lets programmatic workspace navigation pass through a mounted Sim Chat's + * resource-draft guard. When no chat is mounted, the navigation runs directly. + */ +export const MOTHERSHIP_NAVIGATION_REQUEST_EVENT = 'mothership-navigation-request' + +export interface MothershipNavigationRequestDetail { + navigate: () => void +} + +export function requestMothershipNavigation(navigate: () => void): void { + const consumed = dispatchClaimable( + MOTHERSHIP_NAVIGATION_REQUEST_EVENT, + { navigate } + ) + if (!consumed) navigate() +} + /** * Custom-event name used to send a user message to the Mothership chat. * The mothership host components (workspace home, workflow panel) listen diff --git a/apps/sim/lib/mothership/request/tools/resources.ts b/apps/sim/lib/mothership/request/tools/resources.ts index c74853cfda4..75581bf68e6 100644 --- a/apps/sim/lib/mothership/request/tools/resources.ts +++ b/apps/sim/lib/mothership/request/tools/resources.ts @@ -5,6 +5,7 @@ import { MothershipStreamV1EventType, MothershipStreamV1ResourceOp, } from '@/lib/mothership/generated/mothership-stream-v1' +import { ResourceType } from '@/lib/mothership/generated/resources' import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/mothership/request/otel' @@ -19,9 +20,19 @@ import { } from '@/lib/mothership/resources/persistence' import { searchResultFromToolResult } from '@/lib/mothership/resources/search-tool-result' import { changeStoredChatResources } from '@/lib/mothership/resources/store' +import type { + MothershipResourceType, + MothershipResourceUpdate, +} from '@/lib/mothership/resources/types' const logger = createLogger('CopilotResourceEffects') +const PANEL_SETTINGS_SECTIONS: Partial> = { + skill: 'skills', + custom_tool: 'custom-tools', + mcp_server: 'mcp', +} + /** * Persist and emit resource events after a successful tool execution. * @@ -41,6 +52,23 @@ export async function handleResourceSideEffects( ): Promise { // Only organization chats address a workspace; a workspace chat's resources leave it implicit. const workspaceId = owner?.organizationId ? owner.workspaceId : undefined + const refreshPanelSettings = async (resource: MothershipResourceUpdate) => { + const section = PANEL_SETTINGS_SECTIONS[resource.type] + const resourceWorkspaceId = resource.workspaceId ?? owner?.workspaceId + if (!section || !resourceWorkspaceId) return + await onEvent?.({ + type: MothershipStreamV1EventType.resource, + payload: { + op: 'refresh', + resource: { + type: 'settings', + scope: 'workspace', + workspaceId: resourceWorkspaceId, + id: section, + }, + }, + }) + } // Cheap early exit so we don't emit a span for tools that can never // produce resources (most of them). The span only shows up for tools // that might actually do resource work. @@ -75,27 +103,30 @@ export async function handleResourceSideEffects( if (deleted.length > 0) { isDeleteOp = true removedCount = deleted.length - // Detached from the span lifecycle — the span ends before the - // DB call completes. That is intentional; we want the span to - // reflect the synchronous decision + event emission, not the - // best-effort persistence. - removeChatResources(chatId, deleted).catch((err) => { + // Panel refreshes re-read stored chat resources, so they must follow the write. + const removal = removeChatResources(chatId, deleted).catch((err) => { logger.warn('Failed to remove chat resources after deletion', { chatId, error: toError(err).message, }) }) + if (deleted.some((resource) => PANEL_SETTINGS_SECTIONS[resource.type])) await removal for (let index = 0; index < deleted.length; index += 1) { if (isAborted()) break const resource = deleted[index] const projected = projectedDeleted[index] + const nativeResourceType = ResourceType.safeParse(resource.type) + if (!nativeResourceType.success) { + await refreshPanelSettings(resource) + continue + } await onEvent?.({ type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.remove, resource: { - type: resource.type, + type: nativeResourceType.data, id: resource.id, ...(resource.workspaceId ? { workspaceId: resource.workspaceId } : {}), title: projected?.title ?? '', @@ -141,12 +172,13 @@ export async function handleResourceSideEffects( const upserts = resources.filter( (resource) => !('clearViewId' in resource && resource.clearViewId === true) ) - persistChatResources(chatId, upserts).catch((err) => { + const persistence = persistChatResources(chatId, upserts).catch((err) => { logger.warn('Failed to persist chat resources', { chatId, error: toError(err).message, }) }) + if (upserts.some((resource) => PANEL_SETTINGS_SECTIONS[resource.type])) await persistence for (const resource of resources) { if (isAborted()) break @@ -175,11 +207,16 @@ export async function handleResourceSideEffects( }) continue } + const nativeResourceType = ResourceType.safeParse(resource.type) + if (!nativeResourceType.success) { + await refreshPanelSettings(resource) + continue + } await onEvent?.({ type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.upsert, - resource, + resource: { ...resource, type: nativeResourceType.data }, ...(toolName === 'search_workspace' && resource.type === 'search' ? { searchResult: searchResultFromToolResult(projectedResult.output, actorUserId), diff --git a/apps/sim/lib/mothership/resources/extraction.test.ts b/apps/sim/lib/mothership/resources/extraction.test.ts index f3edfd58ade..45d3f08548c 100644 --- a/apps/sim/lib/mothership/resources/extraction.test.ts +++ b/apps/sim/lib/mothership/resources/extraction.test.ts @@ -190,6 +190,32 @@ describe('extractDeletedResourcesFromToolResult', () => { ) ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) }) + + it.each([ + [ + 'manage_skill', + { operation: 'delete', skillId: 'skill-1' }, + { success: true, operation: 'delete', skillId: 'skill-1' }, + [{ type: 'skill', id: 'skill-1', title: 'Skill' }], + ], + [ + 'manage_custom_tool', + { operation: 'delete', toolIds: ['tool-1', 'tool-2'] }, + { success: true, operation: 'delete', deleted: ['tool-1', 'tool-2'] }, + [ + { type: 'custom_tool', id: 'tool-1', title: 'Custom Tool' }, + { type: 'custom_tool', id: 'tool-2', title: 'Custom Tool' }, + ], + ], + [ + 'manage_mcp_connection', + { operation: 'delete', serverId: 'mcp-1' }, + { success: true, operation: 'delete', serverId: 'mcp-1' }, + [{ type: 'mcp_server', id: 'mcp-1', title: 'MCP Server' }], + ], + ])('extracts deleted panel resources from %s', (toolName, params, output, expected) => { + expect(extractDeletedResourcesFromToolResult(toolName, params, output)).toEqual(expected) + }) }) describe('extractResourcesFromToolResult for table_views', () => { diff --git a/apps/sim/lib/mothership/resources/extraction.ts b/apps/sim/lib/mothership/resources/extraction.ts index 3579ab39f30..a58274d8fb1 100644 --- a/apps/sim/lib/mothership/resources/extraction.ts +++ b/apps/sim/lib/mothership/resources/extraction.ts @@ -10,7 +10,10 @@ import { GenerateImage, GenerateVideo, Knowledge, + ManageCustomTool, ManageKnowledgeBase, + ManageMcpConnection, + ManageSkill, PrepareFileEdit, Rm, RunFunction, @@ -258,6 +261,9 @@ const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { [PrepareFileEdit.id]: 'file', [UserTable.id]: 'table', [ManageKnowledgeBase.id]: 'knowledgebase', + [ManageSkill.id]: 'skill', + [ManageCustomTool.id]: 'custom_tool', + [ManageMcpConnection.id]: 'mcp_server', // rm spans categories, so unlike every other entry its resource type comes // from each outcome's kind rather than from this map. The entry exists so // hasDeleteCapability(rm) holds; the rm case below ignores this value. @@ -362,6 +368,26 @@ export function extractDeletedResourcesFromToolResult( return [] } + case ManageSkill.id: { + if (operation !== 'delete') return [] + const skillId = (result.skillId as string) ?? (params?.skillId as string) + return skillId ? [{ type: resourceType, id: skillId, title: 'Skill' }] : [] + } + + case ManageCustomTool.id: { + if (operation !== 'delete') return [] + const deleted = Array.isArray(result.deleted) + ? result.deleted.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + return deleted.map((id) => ({ type: resourceType, id, title: 'Custom Tool' })) + } + + case ManageMcpConnection.id: { + if (operation !== 'delete') return [] + const serverId = (result.serverId as string) ?? (params?.serverId as string) + return serverId ? [{ type: resourceType, id: serverId, title: 'MCP Server' }] : [] + } + default: return [] } diff --git a/apps/sim/lib/mothership/resources/types.ts b/apps/sim/lib/mothership/resources/types.ts index 6e4990a6d31..06f2120ba8f 100644 --- a/apps/sim/lib/mothership/resources/types.ts +++ b/apps/sim/lib/mothership/resources/types.ts @@ -13,6 +13,9 @@ export const MothershipResourceType = { task: 'task', log: 'log', integration: 'integration', + skill: 'skill', + custom_tool: 'custom_tool', + mcp_server: 'mcp_server', generic: 'generic', browser: 'browser', terminal: 'terminal', @@ -119,6 +122,9 @@ const RESOURCE_POLICY: Record = { task: { persisted: true }, log: { persisted: true }, integration: { persisted: true }, + skill: { persisted: true }, + custom_tool: { persisted: true }, + mcp_server: { persisted: true }, // A synthetic panel with no addressable entity behind it to reopen. generic: { persisted: false }, // One tab per live desktop page or shell, keyed by the native id. The @@ -240,6 +246,9 @@ export const GENERIC_RESOURCE_TITLES = new Set([ 'Knowledge Base', 'Folder', 'Log', + 'Skill', + 'Custom Tool', + 'MCP Server', ]) /** diff --git a/apps/sim/lib/mothership/tools/client/resource-display.ts b/apps/sim/lib/mothership/tools/client/resource-display.ts index 4b04e09bd17..56c9c6fabad 100644 --- a/apps/sim/lib/mothership/tools/client/resource-display.ts +++ b/apps/sim/lib/mothership/tools/client/resource-display.ts @@ -1,6 +1,7 @@ import { isRecordLike } from '@sim/utils/object' import type { QueryKey } from '@tanstack/react-query' import type { ResourceAddress } from '@/lib/mothership/generated/resources' +import type { MothershipResourceType } from '@/lib/mothership/resources/types' import { blockDisplayName, cliFirstPositional, @@ -23,7 +24,7 @@ import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' export interface ToolResourceContext { workspaceId?: string - resources?: readonly ResourceAddress[] + resources?: readonly (Omit & { type: MothershipResourceType })[] } type NamedResource = ResourceAddress['type'] | 'skill' | 'customtool' | 'mcpserver' | 'workspace' diff --git a/packages/testing/src/mocks/integration-matcher.mock.ts b/packages/testing/src/mocks/integration-matcher.mock.ts index 6c54be43429..882537c1cf2 100644 --- a/packages/testing/src/mocks/integration-matcher.mock.ts +++ b/packages/testing/src/mocks/integration-matcher.mock.ts @@ -13,5 +13,6 @@ import { vi } from 'vitest' */ export const integrationMatcherMock = { getIntegrationMatcher: vi.fn(() => ({ regex: null, byName: new Map() })), + listIntegrationsByPopularity: vi.fn(() => []), mentionifyIntegrations: vi.fn((text: string) => text), }