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