diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 3578a3f958f..c920c86826d 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -504,9 +504,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - const retentionThreshold = new Date(Date.now() - JOB_RETENTION_HOURS * 60 * 60 * 1000) + const retentionNow = Date.now() + const retentionThreshold = new Date(retentionNow - JOB_RETENTION_HOURS * 60 * 60 * 1000) const irrecoverableCarrierRetentionThreshold = new Date( - Date.now() - SCHEDULE_CARRIER_IRRECOVERABLE_RETENTION_HOURS * 60 * 60 * 1000 + retentionNow - SCHEDULE_CARRIER_IRRECOVERABLE_RETENTION_HOURS * 60 * 60 * 1000 ) let asyncJobsDeleted = 0 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..2fde953b2f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.test.tsx @@ -0,0 +1,51 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockPush } = vi.hoisted(() => ({ mockPush: vi.fn() })) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +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', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + 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/mothership-resources-context/mothership-resources-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx index 1c3c5d90858..38fcdffd838 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 @@ -23,6 +23,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) @@ -42,11 +46,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/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index cf418d5818c..fe4c1933afc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -42,10 +42,13 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' import { listIntegrationsByPopularity } from '@/blocks/integration-matcher' +import { useCustomTools } from '@/hooks/queries/custom-tools' 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' @@ -180,6 +183,18 @@ export function useAvailableResources( LOG_DROPDOWN_FILTERS, { enabled } ) + const skillsEnabled = enabled && !excludeTypes?.includes('skill') + const customToolsEnabled = enabled && !excludeTypes?.includes('custom_tool') + const mcpServersEnabled = enabled && !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]) /** @@ -201,7 +216,10 @@ export function useAvailableResources( foldersPending || fileFoldersPending || tasksPending || - logsPending) + logsPending || + (skillsEnabled && skillsPending) || + (customToolsEnabled && customToolsPending) || + (mcpServersEnabled && mcpServersPending)) const groups = useMemo(() => { if (!enabled) return NO_RESOURCE_GROUPS @@ -266,6 +284,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 @@ -325,6 +358,9 @@ export function useAvailableResources( files, knowledgeBases, tasks, + skills, + customTools, + mcpServers, logs, excludeTypes, ]) 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..263d450f853 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/panel-resource-groups.test.tsx @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +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('@/blocks/integration-matcher', () => ({ listIntegrationsByPopularity: () => [] })) +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/add-resource-dropdown' + +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 b1fb3d08812..af91530e7cd 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,8 +1,19 @@ '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, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { + ArrowLeft, Download, FileX, Folder as FolderIcon, @@ -14,6 +25,8 @@ import { } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' +import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { isApiClientError } from '@/lib/api/client/errors' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' @@ -32,6 +45,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' @@ -52,9 +66,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 { useLogDetail } from '@/hooks/queries/logs' import { exportTable } from '@/hooks/queries/tables' @@ -178,6 +198,11 @@ export const ResourceContent = memo(function ResourceContent({ visible = true, onBrowserOverlayControllerChange, }: ResourceContentProps) { + const { reportResourceDirty } = useMothershipResources() + const handleDirtyChange = useCallback( + (dirty: boolean) => reportResourceDirty(resource.id, dirty), + [reportResourceDirty, resource.id] + ) const streamFileName = previewSession?.fileName || 'file.md' const syntheticFile = useMemo(() => { const ext = getFileExtension(streamFileName) @@ -302,6 +327,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 ( @@ -350,6 +410,27 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) return case 'log': return + case 'skill': + return ( + + ) + case 'custom_tool': + return ( + + ) + case 'mcp_server': + return ( + + ) case 'folder': case 'generic': case 'browser': @@ -360,6 +441,90 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) } } +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-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..a7347bb6bdf --- /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/resource-registry' +import { mcpKeys } from '@/hooks/queries/mcp' +import { skillsKeys } from '@/hooks/queries/skills' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-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 a4b32224b73..c9a044b066b 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 @@ -13,8 +13,10 @@ import { Task, TerminalWindow, Workflow, + Wrench, } from '@sim/emcn/icons' import type { QueryClient } from '@tanstack/react-query' +import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import type { MothershipResource, @@ -23,7 +25,10 @@ import type { import { getDisplayStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils' import { BrandIcon, type StyleableIcon } from '@/blocks/brand-icon' import { logKeys } from '@/hooks/queries/logs' +import { mcpKeys } from '@/hooks/queries/mcp' 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' @@ -229,6 +234,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', @@ -267,6 +299,9 @@ export const MENTION_PREVIEW_DEFAULT_LIMIT = 5 export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ 'integration', 'task', + 'skill', + 'custom_tool', + 'mcp_server', 'table', 'file', 'filefolder', @@ -335,6 +370,17 @@ const RESOURCE_INVALIDATORS: Record< * invalidate when one is added. */ integration: () => {}, + skill: (qc, wId) => { + qc.invalidateQueries({ queryKey: skillsKeys.list(wId) }) + }, + custom_tool: (qc, wId) => { + qc.invalidateQueries({ queryKey: customToolsKeys.list(wId) }) + }, + mcp_server: (qc, wId, id) => { + qc.invalidateQueries({ queryKey: mcpKeys.serversList(wId) }) + qc.invalidateQueries({ queryKey: mcpKeys.serverToolsList(wId, id) }) + qc.invalidateQueries({ queryKey: 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-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 490467861e3..c37916dba98 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 @@ -22,7 +22,6 @@ import { import { Columns3, Eye, Pencil } from '@sim/emcn/icons' import { sendBrowserPanelAction } from '@/lib/browser-agent/transport' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' -import { isEphemeralResource } from '@/lib/copilot/resources/types' import { openTerminal } from '@/lib/terminal/transport' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' @@ -37,13 +36,11 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { useCustomTools } from '@/hooks/queries/custom-tools' import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' -import { - useAddChatResource, - useRemoveChatResource, - useReorderChatResources, -} from '@/hooks/queries/mothership-chats' +import { useMcpServers } from '@/hooks/queries/mcp' +import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' @@ -141,36 +138,50 @@ const PREVIEW_MODE_LABELS: Record = { preview: 'Edit Mode', } -/** - * Stable identity for the empty lookup across `enabled` toggles. The tab list - * memo below takes this map as a dependency, so a fresh empty map each time - * `enabled` flips would rebuild every tab for no change in what they say. - */ -const NO_RESOURCE_NAMES = new Map() - /** * Builds a `type:id` -> current name lookup from live query data so resource - * tabs always reflect the latest name even after a rename. Skipped entirely - * when there are no tabs to label — a chat with no open resources must not - * fetch five workspace-wide lists. + * tabs always reflect the latest name even after a rename. Each query is enabled + * only when that resource family has an open tab. */ -function useResourceNameLookup(workspaceId: string, enabled: boolean): Map { - const { data: workflows } = useWorkflows(workspaceId, { enabled }) - const { data: tables } = useTablesList(workspaceId, 'active', { enabled }) - const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled }) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { enabled }) - const { data: folders } = useFolders(workspaceId, { enabled }) +function useResourceNameLookup( + workspaceId: string, + openTypes: ReadonlySet +): Map { + const workflowsEnabled = openTypes.has('workflow') + const tablesEnabled = openTypes.has('table') + const filesEnabled = openTypes.has('file') + const knowledgeBasesEnabled = openTypes.has('knowledgebase') + const foldersEnabled = openTypes.has('folder') + const skillsEnabled = openTypes.has('skill') + const customToolsEnabled = openTypes.has('custom_tool') + const mcpServersEnabled = openTypes.has('mcp_server') + const { data: workflows } = useWorkflows(workspaceId, { enabled: workflowsEnabled }) + const { data: tables } = useTablesList(workspaceId, 'active', { enabled: tablesEnabled }) + const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled: filesEnabled }) + const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { + enabled: knowledgeBasesEnabled, + }) + const { data: folders } = useFolders(workspaceId, { enabled: foldersEnabled }) + const { data: skills } = useSkills(workspaceId, { enabled: skillsEnabled }) + const { data: customTools } = useCustomTools(workspaceId, { enabled: customToolsEnabled }) + const { data: mcpServers } = useMcpServers(workspaceId, { enabled: mcpServersEnabled }) return useMemo(() => { - if (!enabled) return NO_RESOURCE_NAMES const map = new Map() - for (const w of workflows ?? []) map.set(`workflow:${w.id}`, w.name) - for (const t of tables ?? []) map.set(`table:${t.id}`, t.name) - for (const f of files ?? []) map.set(`file:${f.id}`, f.name) - for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) + for (const workflow of workflows ?? []) map.set(`workflow:${workflow.id}`, workflow.name) + for (const table of tables ?? []) map.set(`table:${table.id}`, table.name) + for (const file of files ?? []) map.set(`file:${file.id}`, file.name) + for (const knowledgeBase of knowledgeBases ?? []) { + map.set(`knowledgebase:${knowledgeBase.id}`, knowledgeBase.name) + } for (const folder of folders ?? []) map.set(`folder:${folder.id}`, folder.name) + 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]) + }, [workflows, tables, files, knowledgeBases, folders, skills, customTools, mcpServers]) } interface ResourceTabsProps { @@ -210,18 +221,16 @@ export function ResourceTabs({ onAddResourceClose, }: ResourceTabsProps) { const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split'] - const nameLookup = useResourceNameLookup(workspaceId, resources.length > 0) + const openTypes = useMemo(() => new Set(resources.map((resource) => resource.type)), [resources]) + const nameLookup = useResourceNameLookup(workspaceId, openTypes) const { selectResource, addResource: onAddResource, removeResource: onRemoveResource, reorderResources: onReorderResources, + requestResourceTransition, } = useMothershipResources() - const addResource = useAddChatResource(chatId) - const removeResource = useRemoveChatResource(chatId) - const reorderResources = useReorderChatResources(chatId) - const [selectedIds, setSelectedIds] = useState>(new Set()) const anchorIdRef = useRef(null) const prevChatIdRef = useRef(chatId) @@ -266,24 +275,18 @@ export function ResourceTabs({ const handleAdd = useCallback( (resource: MothershipResource) => { - // Opening a resource before the first message is sent is allowed: there - // is simply no chat to attach it to yet. `onAddResource` queues it and - // persists once the chat exists, so only the server call is conditional. - // Synthetic result/preview panels are in-memory only either way. - if (chatId && !isEphemeralResource(resource)) { - addResource.mutate({ chatId, resource }) - } - onAddResource(resource) + requestResourceTransition(() => onAddResource(resource)) }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [chatId, onAddResource] + [onAddResource, requestResourceTransition] ) const handleOpenExisting = useCallback( (resource: MothershipResource) => { - openExistingResourceTab(resource, desktopScopeId, selectResource) + const open = () => openExistingResourceTab(resource, desktopScopeId, selectResource) + if (resource.id === activeId) open() + else requestResourceTransition(open) }, - [desktopScopeId, selectResource] + [activeId, desktopScopeId, requestResourceTransition, selectResource] ) const handleSelect = useCallback( @@ -302,8 +305,12 @@ export function ResourceTabs({ const end = Math.max(anchorIdx, idx) const next = new Set() for (let i = start; i <= end; i++) next.add(resources[i].id) - setSelectedIds(next) - selectResource(resource.id) + const select = () => { + setSelectedIds(next) + selectResource(resource.id) + } + if (resource.id === activeId) select() + else requestResourceTransition(select) return } } @@ -314,27 +321,39 @@ export function ResourceTabs({ if (wasSelected) { const next = new Set(selectedIds) next.delete(resource.id) - setSelectedIds(next) - // Only switch active if we just deselected the currently-active tab - if (activeId === resource.id) { - const fallback = - findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null) + const fallback = + activeId === resource.id + ? (findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null)) + : undefined + const deselect = () => { + setSelectedIds(next) if (fallback) selectResource(fallback) + if (!anchorIdRef.current) anchorIdRef.current = resource.id } + if (fallback && fallback !== activeId) requestResourceTransition(deselect) + else deselect() } else { - setSelectedIds((prev) => new Set(prev).add(resource.id)) - selectResource(resource.id) + const select = () => { + setSelectedIds((prev) => new Set(prev).add(resource.id)) + selectResource(resource.id) + if (!anchorIdRef.current) anchorIdRef.current = resource.id + } + if (resource.id === activeId) select() + else requestResourceTransition(select) } - if (!anchorIdRef.current) anchorIdRef.current = resource.id return } // Plain click: single-select - anchorIdRef.current = resource.id - setSelectedIds(new Set([resource.id])) - selectResource(resource.id) + const select = () => { + anchorIdRef.current = resource.id + setSelectedIds(new Set([resource.id])) + selectResource(resource.id) + } + if (resource.id === activeId) select() + else requestResourceTransition(select) }, - [resources, selectResource, selectedIds, activeId] + [resources, selectResource, selectedIds, activeId, requestResourceTransition] ) const handleClose = useCallback( @@ -343,32 +362,26 @@ export function ResourceTabs({ if (!resource) return const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1 const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource] - // Update parent state immediately for all targets - for (const r of targets) { - onRemoveResource(r.type, r.id) - } - // Clear stale selection and anchor for all removed targets - const removedIds = new Set(targets.map((r) => r.id)) - 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 - } - // Mirrors `handleAdd`: a resource opened while composing the first prompt - // has to be closable before there is a chat to attach it to. Only the - // server call is conditional — the local removal above also drops the - // queued write, so nothing resurrects it once the chat exists. - if (!chatId) return - for (const r of targets) { - if (isEphemeralResource(r)) continue - removeResource.mutate({ chatId, resourceType: r.type, resourceId: r.id }) + const close = () => { + // Update parent state immediately for all targets + for (const r of targets) { + onRemoveResource(r.type, r.id) + } + // Clear stale selection and anchor for all removed targets + const removedIds = new Set(targets.map((r) => r.id)) + 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) => target.id === activeId)) requestResourceTransition(close) + else close() }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [chatId, onRemoveResource, resources, selectedIds] + [activeId, onRemoveResource, requestResourceTransition, resources, selectedIds] ) const handleTabDragStart = useCallback( @@ -415,15 +428,8 @@ export function ResourceTabs({ const [moved] = reordered.splice(fromIndex, 1) reordered.splice(targetIndex, 0, moved) onReorderResources(reordered) - if (chatId) { - const persistable = reordered.filter((r) => !isEphemeralResource(r)) - if (persistable.length > 0) { - reorderResources.mutate({ chatId, resources: persistable }) - } - } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [chatId, resources, onReorderResources] + [resources, onReorderResources] ) const previewToggle = 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 3147d9ff89e..62dad6e48b6 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 @@ -244,7 +244,7 @@ export const MothershipView = memo( isAgentResponding={isAgentResponding} genericResourceData={active.type === 'generic' ? genericResourceData : undefined} previewContextKey={chatId} - onNotFound={(resourceId) => removeResource('log', resourceId)} + onNotFound={(resourceId) => removeResource(active.type, resourceId)} /> )} {!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 6d1658abfc3..33744caa565 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,14 +107,15 @@ 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. */ // Browser/terminal resources may name either the singleton panel or one live // inner tab. The singleton ids ask the agent to inspect the whole resource; // every other id is a precise live-tab pointer. const RESOURCE_TO_CONTEXT: Record< MothershipResourceType, - (resource: MothershipResource) => ChatContext + (resource: MothershipResource) => ChatContext | null > = { browser: (r) => ({ kind: 'browser_tab', tabId: r.id, label: r.title }), terminal: (r) => ({ kind: 'terminal_tab', terminalId: r.id, label: r.title }), @@ -133,8 +134,11 @@ 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 { +export function mapResourceToContext(resource: MothershipResource): ChatContext | null { return RESOURCE_TO_CONTEXT[resource.type](resource) } 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 da971050c25..cfdb333d43e 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 @@ -53,6 +53,11 @@ const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available- */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) const NON_ATTACHABLE_RESOURCE_TYPES = new Set(['browser']) +const PANEL_ONLY_RESOURCE_TYPES: readonly MothershipResourceType[] = [ + 'skill', + 'custom_tool', + 'mcp_server', +] as const const EMPTY_BROWSER_TABS = [] as const const EMPTY_TERMINAL_TABS = [] as const @@ -104,6 +109,7 @@ export const PlusMenuDropdown = React.memo( isHydrating, } = useAvailableResources(workspaceId, { enabled: open || !!warm, + excludeTypes: PANEL_ONLY_RESOURCE_TYPES, }) const doOpen = useCallback( @@ -121,9 +127,9 @@ export const PlusMenuDropdown = React.memo( setOpen(false) }, []) - // The `+` browse menu hides non-attachable and mention-only resource types. - // `@` mode exposes the full catalog and adds each live Browser/Terminal tab - // after its always-present whole-resource row. + // The hook has already excluded panel-only resources. The `+` browse menu + // also hides non-attachable and mention-only types; `@` mode adds each live + // Browser/Terminal tab after its always-present whole-resource row. const visibleResources = useMemo(() => { if (isMention) { return withDesktopTabMentions(availableResources, browserTabs, terminalTabs) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 9391d492434..23196c9c11d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -242,6 +242,27 @@ describe('usePromptEditor context insertion', () => { vi.restoreAllMocks() }) + 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('appends a real mention token and context, then focuses the editor', () => { const onContextAdd = vi.fn() let focusFrame: FrameRequestCallback | undefined diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 41ddd83e4e8..5761b77436c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -406,6 +406,9 @@ export function usePromptEditor({ const insertResource = useCallback( (resource: MothershipResource) => { + const context = mapResourceToContext(resource) + if (!context) return + const textarea = textareaRef.current if (textarea) { const currentValue = valueRef.current @@ -441,7 +444,6 @@ export function usePromptEditor({ setValueState(newValue) } - const context = mapResourceToContext(resource) addContextNotified(context) }, [textareaRef, addContextNotified] diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 461305cd6ec..19c9d73582a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -34,7 +34,9 @@ import { } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' +import { useResourceTransitionGuard } from '@/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard' import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' @@ -68,6 +70,36 @@ import type { const logger = createLogger('Home') +function resolveResourceFromContext( + context: ChatContext +): { type: MothershipResourceType; id: string } | null { + switch (context.kind) { + case 'workflow': + case 'current_workflow': + return context.workflowId ? { type: 'workflow', id: context.workflowId } : null + case 'knowledge': + return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null + case 'table': + case 'table_selection': + return context.tableId ? { type: 'table', id: context.tableId } : null + case 'file': + 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 + default: + return null + } +} + +function resourceTitleForContext(context: ChatContext): string { + if (context.kind === 'file_selection') return context.fileName + if (context.kind === 'table_selection') return context.tableName + return context.label +} + /** * The resource preview panel pulls in the file-viewer stack (rich-markdown * editor, CSV/PDF viewers). It only renders once a chat has messages, so it is @@ -205,11 +237,22 @@ export function Home({ chatId, userName, userId }: HomeProps) { const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) const [skipResourceTransition, setSkipResourceTransition] = useState(false) const [resourceActivityIds, setResourceActivityIds] = useState>(new Set()) + const { + showDiscardConfirmation, + reportResourceDirty, + requestResourceTransition, + routeAutomaticResourceFocus, + dismissDiscardConfirmation, + confirmDiscard, + rebaseHistorySentinel, + reset: resetResourceTransitionGuard, + } = useResourceTransitionGuard() const isResourceCollapsedRef = useRef(isResourceCollapsed) isResourceCollapsedRef.current = isResourceCollapsed const userOwnsResourceViewRef = useRef(false) const activeResourceParamRef = useRef(activeResourceParam) activeResourceParamRef.current = activeResourceParam + const effectiveActiveResourceIdRef = useRef(activeResourceParam) function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) { // Agent work surfaces the resource and switches to it as it is created or @@ -217,21 +260,31 @@ export function Home({ chatId, userName, userId }: HomeProps) { // existing selection (see shouldActivateResourceEvent). if (isResourceCollapsedRef.current) setIsResourceCollapsed(false) - const activeResourceId = activeResourceParamRef.current - if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) { + const activeResourceId = effectiveActiveResourceIdRef.current + const markAttention = () => { 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 (activeResourceId !== resourceId) { - activeResourceParamRef.current = resourceId - setActiveResourceUrl(resourceId) + if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) { + markAttention() + return } + routeAutomaticResourceFocus( + resourceId, + () => { + setResourceActivityIds((current) => { + if (!current.has(resourceId)) return current + const next = new Set(current) + next.delete(resourceId) + return next + }) + if (activeResourceId !== resourceId) { + effectiveActiveResourceIdRef.current = resourceId + activeResourceParamRef.current = resourceId + setActiveResourceUrl(resourceId) + } + }, + markAttention + ) } const { @@ -277,7 +330,6 @@ export function Home({ chatId, userName, userId }: HomeProps) { ) const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize(desktopScopeId) - const effectiveActiveResourceIdRef = useRef(activeResourceId) effectiveActiveResourceIdRef.current = activeResourceId const resourceAttentionChatIdRef = useRef(resolvedChatId) @@ -303,7 +355,7 @@ export function Home({ chatId, userName, userId }: HomeProps) { setIsResourceCollapsed(false) } - const selectResourceFromUser = useCallback( + const selectResourceImmediately = useCallback( (resourceId: string) => { userOwnsResourceViewRef.current = true clearResourceActivity(resourceId) @@ -315,14 +367,25 @@ export function Home({ chatId, userName, userId }: HomeProps) { [setActiveResourceId, clearResourceActivity] ) - const addResourceFromUser = useCallback( + const addResourceImmediately = useCallback( (resource: MothershipResource) => { userOwnsResourceViewRef.current = true addResource(resource) - selectResourceFromUser(resource.id) + selectResourceImmediately(resource.id) setIsResourceCollapsed(false) }, - [addResource, selectResourceFromUser] + [addResource, selectResourceImmediately] + ) + + const addResourceFromUser = useCallback( + (resource: MothershipResource) => { + if (effectiveActiveResourceIdRef.current === resource.id) { + addResourceImmediately(resource) + return + } + requestResourceTransition(() => addResourceImmediately(resource)) + }, + [addResourceImmediately, requestResourceTransition] ) const handleResourceResizePointerDown = useCallback( @@ -343,6 +406,7 @@ export function Home({ chatId, userName, userId }: HomeProps) { wasSendingRef.current = false if (resolvedChatId) { markRead(resolvedChatId) + if (!previousChatId) rebaseHistorySentinel() } else { clearWidth() setIsResourceCollapsed(true) @@ -350,8 +414,9 @@ export function Home({ chatId, userName, userId }: HomeProps) { if (!resolvedChatId || (previousChatId && previousChatId !== resolvedChatId)) { userOwnsResourceViewRef.current = false setResourceActivityIds(new Set()) + resetResourceTransitionGuard() } - }, [resolvedChatId, markRead, clearWidth]) + }, [resolvedChatId, markRead, clearWidth, rebaseHistorySentinel, resetResourceTransitionGuard]) useEffect(() => { if (wasSendingRef.current && !isSending && resolvedChatId) { @@ -479,39 +544,6 @@ export function Home({ chatId, userName, userId }: HomeProps) { // eslint-disable-next-line react-hooks/exhaustive-deps -- see above }, [chatId, workspaceId, sendMessage]) - function resolveResourceFromContext( - context: ChatContext - ): { type: MothershipResourceType; id: string } | null { - switch (context.kind) { - case 'workflow': - case 'current_workflow': - return context.workflowId ? { type: 'workflow', id: context.workflowId } : null - case 'knowledge': - return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null - case 'table': - return context.tableId ? { type: 'table', id: context.tableId } : null - case 'table_selection': - return context.tableId ? { type: 'table', id: context.tableId } : null - case 'file': - return context.fileId ? { type: 'file', id: context.fileId } : null - case 'file_selection': - return context.fileId ? { type: 'file', id: context.fileId } : null - default: - return null - } - } - - /** - * Tab title for the resource a chip opens. A selection chip's label describes - * the selection (`notes.md:12-40`, `Sales (3 rows)`) but the tab shows the - * whole file/table, so title it from the resource name the context carries. - */ - function resourceTitleForContext(context: ChatContext): string { - if (context.kind === 'file_selection') return context.fileName - if (context.kind === 'table_selection') return context.tableName - return context.label - } - function handleContextAdd(context: ChatContext) { const resolved = resolveResourceFromContext(context) if (resolved) { @@ -531,7 +563,12 @@ export function Home({ chatId, userName, userId }: HomeProps) { return otherResolved?.type === resolved.type && otherResolved.id === resolved.id }) if (stillReferenced) return - removeResource(resolved.type, resolved.id) + const remove = () => removeResource(resolved.type, resolved.id) + if (effectiveActiveResourceIdRef.current === resolved.id) { + requestResourceTransition(remove) + } else { + remove() + } } function openWorkspaceResource(resource: MothershipResource) { @@ -687,11 +724,13 @@ export function Home({ chatId, userName, userId }: HomeProps) { )} + { + if (!open) dismissDiscardConfirmation() + }} + onDiscard={confirmDiscard} + /> +
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 1ac5603f7a4..e10cfe37309 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1807,6 +1807,15 @@ export function useChat( return false } + // Adding a resource must not change the visible tab through the incidental + // "last item" fallback before the explicit resource-focus event runs. Pin + // the current fallback first; clean agent events and user actions can then + // deliberately select the new resource, while a dirty editor stays put. + const visibleResourceId = activeResourceIdRef.current + if (visibleResourceId) { + setActiveResourceId((current) => current ?? visibleResourceId) + } + setResources((prev) => { const exists = prev.some((r) => r.type === resource.type && r.id === resource.id) if (exists) return prev 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 794008189a5..8d526d1fa6b 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 55678e16cbc..d3cdc5d6851 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -172,15 +172,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) @@ -192,11 +207,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 @@ -221,7 +238,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 @@ -247,7 +264,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) @@ -285,7 +302,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()) } @@ -346,10 +364,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 } })() @@ -425,6 +443,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') @@ -630,13 +672,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, @@ -655,6 +698,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.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 4024fa40810..912f1150f94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -6,7 +6,9 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { + MOTHERSHIP_NAVIGATION_REQUEST_EVENT, MOTHERSHIP_SEND_MESSAGE_EVENT, + type MothershipNavigationRequestDetail, type MothershipSendMessageDetail, } from '@/lib/mothership/events' import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal' @@ -335,6 +337,44 @@ describe('SearchModal', () => { expect(rows[2]).toContain('Beta') }) + it('defers programmatic chat navigation through the mounted resource guard', async () => { + let pendingNavigation: (() => void) | undefined + const handleNavigationRequest = (event: Event) => { + event.preventDefault() + pendingNavigation = (event as CustomEvent).detail.navigate + } + window.addEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest) + + try { + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('Alpha planning') + act(() => document.querySelector('[cmdk-item]')?.click()) + + expect(mockPush).not.toHaveBeenCalled() + expect(pendingNavigation).toBeTypeOf('function') + + act(() => pendingNavigation?.()) + expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/chat/chat-a') + } finally { + window.removeEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest) + } + }) + it('shows an empty state when search has no results', async () => { await act(async () => { root.render() 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 58a79fad7e6..4488d8e642b 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 { isChatEnabled } from '@/lib/core/config/env-flags' 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' @@ -173,6 +173,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) /** @@ -392,7 +399,7 @@ function SearchModalContent({ exactQueries: ['chats'], icon: Home, context: 'global', - run: () => routerRef.current.push(`/workspace/${workspaceId}/home`), + run: () => navigate(`/workspace/${workspaceId}/home`), }) } if (canEdit && onCreateWorkflow) { @@ -672,6 +679,7 @@ function SearchModalContent({ onCreateFolder, onImportWorkflow, invokeCommand, + navigate, navigateToSettings, ]) @@ -797,9 +805,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', { @@ -809,13 +818,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', @@ -824,12 +833,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, @@ -837,12 +846,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, @@ -850,12 +859,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, @@ -863,12 +872,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, @@ -876,7 +885,7 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handlePageSelect = useCallback( @@ -887,7 +896,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', { @@ -897,12 +906,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, @@ -910,12 +919,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, @@ -923,12 +932,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, @@ -936,7 +945,7 @@ function SearchModalContent({ }) onOpenChangeRef.current(false) }, - [workspaceId] + [navigate, workspaceId] ) const handleActionSelect = useCallback( @@ -960,28 +969,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. */ @@ -990,7 +1007,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', @@ -998,7 +1015,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 4d06bf87c44..edd7a357a50 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -40,6 +40,7 @@ import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' +import { requestMothershipNavigation } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' @@ -904,10 +905,12 @@ export const Sidebar = memo(function Sidebar({ const navigateToPage = useCallback( (path: string) => { - if (!isCollapsedRef.current) { - setSidebarWidth(SIDEBAR_WIDTH.MIN) - } - router.push(path) + requestMothershipNavigation(() => { + if (!isCollapsedRef.current) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + router.push(path) + }) }, [setSidebarWidth, router] ) @@ -921,19 +924,28 @@ export const Sidebar = memo(function Sidebar({ (id) => currentPath === `/workspace/${workspaceId}/chat/${id}` ) - const onDeleteSuccess = () => { - useFolderStore.getState().clearChatSelection() - if (isViewingDeletedChat) { - navigateToPage(`/workspace/${workspaceId}/home`) + const deleteChats = () => { + const onSuccess = () => useFolderStore.getState().clearChatSelection() + if (chatIdsToDelete.length === 1) { + deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess }) + } else { + deleteChatsMutation.mutate(chatIdsToDelete, { onSuccess }) } } - if (chatIdsToDelete.length === 1) { - deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess: onDeleteSuccess }) - } else { - deleteChatsMutation.mutate(chatIdsToDelete, { onSuccess: onDeleteSuccess }) - } setIsChatDeleteModalOpen(false) + if (!isViewingDeletedChat) { + deleteChats() + return + } + + requestMothershipNavigation(() => { + if (!isCollapsedRef.current) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + router.push(`/workspace/${workspaceId}/home`) + deleteChats() + }) } const [visibleChatCount, setVisibleChatCount] = useState(5) 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 0fbf43786d4..3e3d19607f7 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 } interface SettingsUnsavedGuard { @@ -17,25 +19,32 @@ interface SettingsUnsavedGuard { */ export function useSettingsUnsavedGuard({ isDirty, + enabled = true, }: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard { const setDirty = useSettingsDirtyStore((state) => state.setDirty) const reset = useSettingsDirtyStore((state) => state.reset) - const isDirtyRef = useRef(isDirty) + const isDirtyRef = useRef(enabled && isDirty) const pendingLeaveRef = useRef<(() => void) | null>(null) const [showUnsavedModal, setShowUnsavedModal] = useState(false) useEffect(() => { - isDirtyRef.current = isDirty + isDirtyRef.current = enabled && isDirty + if (!enabled) { + pendingLeaveRef.current = null + setShowUnsavedModal(false) + return + } setDirty(isDirty) if (!isDirty) { pendingLeaveRef.current = null setShowUnsavedModal(false) } - }, [isDirty, setDirty]) + }, [enabled, isDirty, setDirty]) useEffect(() => { + if (!enabled) return return () => reset() - }, [reset]) + }, [enabled, reset]) const guardBack = useCallback((onLeave: () => void) => { if (isDirtyRef.current) { diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index dfcc734731c..2f0369368ac 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -167,11 +167,11 @@ async function fetchCustomTools( /** * Hook to fetch custom tools */ -export function useCustomTools(workspaceId: string) { +export function useCustomTools(workspaceId: string, options?: { enabled?: boolean }) { return useQuery({ queryKey: customToolsKeys.list(workspaceId), queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), staleTime: CUSTOM_TOOL_LIST_STALE_TIME, placeholderData: keepPreviousData, }) diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 8cb44396489..ce196760863 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -100,11 +100,11 @@ async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promi } } -export function useMcpServers(workspaceId: string) { +export function useMcpServers(workspaceId: string, options?: { enabled?: boolean }) { return useQuery({ queryKey: mcpKeys.serversList(workspaceId), queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), retry: false, staleTime: MCP_SERVER_LIST_STALE_TIME, placeholderData: keepPreviousData, diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index f5e3c4e41c2..a8b3d84d9c0 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -44,11 +44,11 @@ async function fetchSkills(workspaceId: string, signal?: AbortSignal): Promise({ queryKey: skillsKeys.list(workspaceId), queryFn: ({ signal }) => fetchSkills(workspaceId, signal), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), staleTime: SKILL_LIST_STALE_TIME, placeholderData: keepPreviousData, }) diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts index 4b2d0635510..a91e249a982 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 { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -117,15 +118,17 @@ 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 { - try { - sessionStorage.setItem(SETTINGS_RETURN_URL_KEY, currentPath) - } catch {} - router.push(getSettingsHref(options)) - } + requestMothershipNavigation(() => { + const currentPath = window.location.pathname + if (currentPath.startsWith(settingsPrefix)) { + router.replace(getSettingsHref(options), { scroll: false }) + } else { + try { + sessionStorage.setItem(SETTINGS_RETURN_URL_KEY, currentPath) + } catch {} + router.push(getSettingsHref(options)) + } + }) }, [router, settingsPrefix, getSettingsHref] ) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index f7f71a24733..ff11dc53a59 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -315,6 +315,36 @@ describe('handleUnifiedChatPost', () => { ]) }) + it('accepts and persists panel-only resource attachments without adding artificial context', async () => { + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/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).toHaveBeenCalledTimes(3) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ contexts: [] }), + { selectedModel: '' } + ) + }) + it('forwards the desktop local filesystem capability into payload construction', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 90b4b3b283c..d2957a8d314 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -59,6 +59,7 @@ import { persistChatResources } from '@/lib/copilot/resources/persistence' import { hasAddressableId, isEphemeralResource, + PERSISTED_RESOURCE_TYPES, sanitizeChatResources, } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' @@ -92,22 +93,7 @@ const FileAttachmentSchema = z.object({ }) const ResourceAttachmentSchema = z.object({ - type: z.enum([ - 'workflow', - 'table', - 'file', - 'knowledgebase', - 'folder', - 'filefolder', - 'task', - 'log', - 'generic', - 'browser', - // Filtered out client-side rather than sent, but accepted here so a stray - // terminal attachment degrades to a no-op instead of rejecting the whole - // chat request. - 'terminal', - ]), + type: z.enum([...PERSISTED_RESOURCE_TYPES, 'generic']), id: z.string().min(1), title: z.string().optional(), active: z.boolean().optional(), @@ -134,6 +120,10 @@ const GENERIC_RESOURCE_TITLE: Record['t filefolder: 'File Folder', task: 'Task', log: 'Log', + integration: 'Integration', + skill: 'Skill', + custom_tool: 'Custom Tool', + mcp_server: 'MCP Server', generic: 'Resource', browser: 'Browser', terminal: 'Terminal', diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index c47413711f2..5b94770da4b 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -193,4 +193,30 @@ 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) + }) }) diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 2f47680dfaf..4861c29e726 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -9,7 +9,10 @@ import { GenerateImage, GenerateVideo, Knowledge, + ManageCustomTool, ManageKnowledgeBase, + ManageMcpConnection, + ManageSkill, PrepareFileEdit, Rm, RunFunction, @@ -225,6 +228,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. @@ -329,6 +335,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/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 58d68d6e737..6d5525e5263 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -8,6 +8,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', @@ -78,6 +81,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 }, browser: { persisted: true, desktopOnly: true }, @@ -210,6 +216,10 @@ export const GENERIC_RESOURCE_TITLES = new Set([ 'Knowledge Base', 'Folder', 'Log', + 'Integration', + 'Skill', + 'Custom Tool', + 'MCP Server', ]) export const VFS_DIR_TO_RESOURCE: Record = { diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts index 9b9ebb41da5..3e4851ab974 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -107,7 +107,11 @@ describe('Copilot management application boundaries', () => { context ) - expect(result).toMatchObject({ success: true, output: { toolId: 'tool-1' } }) + expect(result).toMatchObject({ + success: true, + resources: [{ type: 'custom_tool', id: 'tool-1', title: 'lookup_order' }], + output: { toolId: 'tool-1' }, + }) expect(mocks.custom).toHaveBeenCalledWith(context, useCases.saveCustom, { workspaceId: context.workspaceId, title: 'lookup_order', @@ -139,7 +143,11 @@ describe('Copilot management application boundaries', () => { context ) - expect(result).toMatchObject({ success: true, output: { serverId: 'mcp-server-1' } }) + expect(result).toMatchObject({ + success: true, + resources: [{ type: 'mcp_server', id: 'mcp-server-1', title: 'Docs' }], + output: { serverId: 'mcp-server-1' }, + }) expect(mocks.mcp).toHaveBeenCalledWith( context, useCases.registerMcp, @@ -158,7 +166,11 @@ describe('Copilot management application boundaries', () => { { ...context, userPermission: 'read' } ) - expect(result).toMatchObject({ success: true, output: { skillId: 'skill-1' } }) + expect(result).toMatchObject({ + success: true, + resources: [{ type: 'skill', id: 'skill-1', title: 'refund-policy' }], + output: { skillId: 'skill-1' }, + }) expect(mocks.skill).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: context.workspaceId }), useCases.updateSkill, diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index adb9e45f3a0..04b3c60daf6 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -117,6 +117,7 @@ export async function executeManageCustomTool( return { success: true, + resources: [{ type: 'custom_tool', id: created.id, title: created.title }], output: { success: true, operation, @@ -170,6 +171,7 @@ export async function executeManageCustomTool( return { success: true, + resources: [{ type: 'custom_tool', id: tool.id, title: tool.title }], output: { success: true, operation, diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index 066fb6b8539..6e91efddf8b 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -107,6 +107,9 @@ export async function executeManageMcpTool( return { success: true, + resources: [ + { type: 'mcp_server', id: result.server.id, title: result.server.name || config.name }, + ], output: { success: true, operation, @@ -142,6 +145,9 @@ export async function executeManageMcpTool( return { success: true, + resources: [ + { type: 'mcp_server', id: result.server.id, title: result.server.name || 'MCP Server' }, + ], output: { success: true, operation, diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index d17bac0bfd6..4dc795a4ed4 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -90,6 +90,7 @@ export async function executeManageSkill( return { success: true, + resources: [{ type: 'skill', id: skill.id, title: skill.name }], output: { success: true, operation, @@ -133,6 +134,7 @@ export async function executeManageSkill( return { success: true, + resources: [{ type: 'skill', id: skill.id, title: skill.name }], output: { success: true, operation, diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 4ce4d844da8..223493f53ab 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -14,6 +14,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