diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 1ac5603f7a4..e10cfe37309 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -1807,6 +1807,15 @@ export function useChat(
return false
}
+ // Adding a resource must not change the visible tab through the incidental
+ // "last item" fallback before the explicit resource-focus event runs. Pin
+ // the current fallback first; clean agent events and user actions can then
+ // deliberately select the new resource, while a dirty editor stays put.
+ const visibleResourceId = activeResourceIdRef.current
+ if (visibleResourceId) {
+ setActiveResourceId((current) => current ?? visibleResourceId)
+ }
+
setResources((prev) => {
const exists = prev.some((r) => r.type === resource.type && r.id === resource.id)
if (exists) return prev
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.test.tsx
new file mode 100644
index 00000000000..86ad1af12da
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.test.tsx
@@ -0,0 +1,194 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { requestMothershipNavigation } from '@/lib/mothership/events'
+import { useResourceTransitionGuard } from '@/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard'
+
+function renderGuard() {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const root: Root = createRoot(document.createElement('div'))
+ let latest: ReturnType
+
+ function Probe() {
+ latest = useResourceTransitionGuard()
+ return null
+ }
+
+ act(() => root.render())
+ return { result: () => latest, unmount: () => act(() => root.unmount()) }
+}
+
+describe('useResourceTransitionGuard', () => {
+ beforeEach(() => {
+ window.history.replaceState({}, '', '/workspace/ws-1/chat/chat-1')
+ vi.spyOn(window.history, 'back').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ document.body.replaceChildren()
+ vi.restoreAllMocks()
+ })
+
+ it('keeps a dirty draft when cancelled and performs the complete deferred action on discard', () => {
+ const selectAnotherTab = vi.fn()
+ const closeSelectedTabs = vi.fn()
+ const guard = renderGuard()
+
+ act(() => guard.result().reportResourceDirty('skill-1', true))
+ act(() => guard.result().requestResourceTransition(selectAnotherTab))
+
+ expect(selectAnotherTab).not.toHaveBeenCalled()
+ expect(guard.result().showDiscardConfirmation).toBe(true)
+
+ act(() => guard.result().dismissDiscardConfirmation())
+
+ expect(selectAnotherTab).not.toHaveBeenCalled()
+ expect(guard.result().showDiscardConfirmation).toBe(false)
+
+ act(() => guard.result().requestResourceTransition(closeSelectedTabs))
+ act(() => guard.result().confirmDiscard())
+
+ expect(closeSelectedTabs).not.toHaveBeenCalled()
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ expect(closeSelectedTabs).toHaveBeenCalledOnce()
+ expect(guard.result().showDiscardConfirmation).toBe(false)
+
+ act(() => guard.result().requestResourceTransition(selectAnotherTab))
+ expect(selectAnotherTab).toHaveBeenCalledOnce()
+ guard.unmount()
+ })
+
+ it('marks agent activity without focusing over a dirty editor or opening a modal', () => {
+ const focus = vi.fn()
+ const markAttention = vi.fn()
+ const guard = renderGuard()
+
+ act(() => guard.result().reportResourceDirty('skill-1', true))
+ act(() => guard.result().routeAutomaticResourceFocus('mcp-1', focus, markAttention))
+
+ expect(focus).not.toHaveBeenCalled()
+ expect(markAttention).toHaveBeenCalledOnce()
+ expect(guard.result().showDiscardConfirmation).toBe(false)
+ act(() => guard.result().reportResourceDirty('skill-1', false))
+ guard.unmount()
+ })
+
+ it('guards browser unload and replays app-link navigation only after discard', () => {
+ const guard = renderGuard()
+ const link = document.createElement('a')
+ link.href = '/workspace/ws-1/chat/chat-2'
+ link.textContent = 'Another chat'
+ const navigate = vi.fn((event: MouseEvent) => event.preventDefault())
+ link.addEventListener('click', navigate)
+ document.body.appendChild(link)
+
+ act(() => guard.result().reportResourceDirty('custom-tool-1', true))
+
+ const beforeUnload = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(beforeUnload)
+ expect(beforeUnload.defaultPrevented).toBe(true)
+
+ act(() => link.click())
+ expect(navigate).not.toHaveBeenCalled()
+ expect(guard.result().showDiscardConfirmation).toBe(true)
+
+ act(() => guard.result().dismissDiscardConfirmation())
+ expect(navigate).not.toHaveBeenCalled()
+
+ act(() => link.click())
+ act(() => guard.result().confirmDiscard())
+ expect(navigate).not.toHaveBeenCalled()
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ expect(navigate).toHaveBeenCalledOnce()
+ expect(guard.result().showDiscardConfirmation).toBe(false)
+
+ const cleanBeforeUnload = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(cleanBeforeUnload)
+ expect(cleanBeforeUnload.defaultPrevented).toBe(false)
+ guard.unmount()
+ })
+
+ it('defers browser Back until the dirty draft is discarded', () => {
+ const guard = renderGuard()
+
+ act(() => guard.result().reportResourceDirty('mcp-1', true))
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ expect(guard.result().showDiscardConfirmation).toBe(true)
+
+ act(() => guard.result().dismissDiscardConfirmation())
+ expect(window.history.back).not.toHaveBeenCalled()
+
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ act(() => guard.result().confirmDiscard())
+ expect(window.history.back).toHaveBeenCalledOnce()
+
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ expect(window.history.back).toHaveBeenCalledTimes(2)
+ expect(guard.result().showDiscardConfirmation).toBe(false)
+ guard.unmount()
+ })
+
+ it('does not pop history after first-message routing replaces the sentinel', () => {
+ const guard = renderGuard()
+
+ act(() => guard.result().reportResourceDirty('custom-tool-1', true))
+ window.history.replaceState({ chat: 'chat-2' }, '', '/workspace/ws-1/chat/chat-2')
+ act(() => guard.result().reportResourceDirty('custom-tool-1', false))
+
+ expect(window.history.back).not.toHaveBeenCalled()
+ expect(window.location.pathname).toBe('/workspace/ws-1/chat/chat-2')
+ guard.unmount()
+ })
+
+ it('re-owns first-message history so Back cancellation stays put and discard leaves once', () => {
+ const guard = renderGuard()
+
+ act(() => guard.result().reportResourceDirty('skill-1', true))
+ window.history.replaceState(null, '', '/workspace/ws-1/chat/chat-2')
+ act(() => guard.result().rebaseHistorySentinel())
+
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ expect(guard.result().showDiscardConfirmation).toBe(true)
+ act(() => guard.result().dismissDiscardConfirmation())
+ expect(window.location.pathname).toBe('/workspace/ws-1/chat/chat-2')
+
+ vi.mocked(window.history.back)
+ .mockImplementationOnce(() => {
+ window.history.replaceState({}, '', '/workspace/ws-1/chat/chat-2')
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ })
+ .mockImplementationOnce(() => {
+ window.history.replaceState({}, '', '/workspace/ws-1/home')
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ })
+
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ act(() => guard.result().confirmDiscard())
+
+ expect(window.location.pathname).toBe('/workspace/ws-1/home')
+ expect(guard.result().showDiscardConfirmation).toBe(false)
+ guard.unmount()
+ })
+
+ it('defers programmatic navigation through the shared request entrypoint', () => {
+ const routerPush = vi.fn()
+ const guard = renderGuard()
+
+ act(() => guard.result().reportResourceDirty('skill-1', true))
+ act(() => requestMothershipNavigation(routerPush))
+
+ expect(routerPush).not.toHaveBeenCalled()
+ expect(guard.result().showDiscardConfirmation).toBe(true)
+
+ act(() => guard.result().confirmDiscard())
+ expect(routerPush).not.toHaveBeenCalled()
+
+ act(() => window.dispatchEvent(new PopStateEvent('popstate')))
+ expect(routerPush).toHaveBeenCalledOnce()
+ guard.unmount()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.ts
new file mode 100644
index 00000000000..78734055097
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-transition-guard.ts
@@ -0,0 +1,238 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { generateId } from '@sim/utils/id'
+import {
+ MOTHERSHIP_NAVIGATION_REQUEST_EVENT,
+ type MothershipNavigationRequestDetail,
+} from '@/lib/mothership/events'
+
+interface ResourceTransitionGuard {
+ showDiscardConfirmation: boolean
+ reportResourceDirty: (resourceId: string, dirty: boolean) => void
+ requestResourceTransition: (transition: () => void) => void
+ routeAutomaticResourceFocus: (
+ nextResourceId: string,
+ focus: () => void,
+ markAttention: () => void
+ ) => void
+ dismissDiscardConfirmation: () => void
+ confirmDiscard: () => void
+ rebaseHistorySentinel: () => void
+ reset: () => void
+}
+
+const RESOURCE_HISTORY_SENTINEL_KEY = '__simResourceDraftSentinel'
+
+interface HistorySentinel {
+ token: string
+ url: string
+}
+
+/**
+ * Owns the one dirty draft that can be mounted in Sim Chat's resource panel.
+ * User transitions wait for confirmation, while agent-driven focus is routed
+ * to the tab's attention state without interrupting the editor.
+ */
+export function useResourceTransitionGuard(): ResourceTransitionGuard {
+ const dirtyResourceIdRef = useRef(null)
+ const pendingTransitionRef = useRef<(() => void) | null>(null)
+ const historySentinelRef = useRef(null)
+ const [showDiscardConfirmation, setShowDiscardConfirmation] = useState(false)
+
+ const seedHistorySentinel = useCallback(() => {
+ if (historySentinelRef.current) return
+ const sentinel = { token: generateId(), url: window.location.href }
+ const currentState = window.history.state
+ window.history.pushState(
+ {
+ ...(currentState && typeof currentState === 'object' ? currentState : {}),
+ [RESOURCE_HISTORY_SENTINEL_KEY]: sentinel.token,
+ },
+ '',
+ sentinel.url
+ )
+ historySentinelRef.current = sentinel
+ }, [])
+
+ const retireHistorySentinel = useCallback((afterRetirement?: () => void) => {
+ const sentinel = historySentinelRef.current
+ historySentinelRef.current = null
+ const currentState = window.history.state
+ const ownsCurrentEntry =
+ sentinel !== null &&
+ window.location.href === sentinel.url &&
+ currentState !== null &&
+ typeof currentState === 'object' &&
+ currentState[RESOURCE_HISTORY_SENTINEL_KEY] === sentinel.token
+
+ if (!ownsCurrentEntry) {
+ afterRetirement?.()
+ return
+ }
+ if (afterRetirement) {
+ window.addEventListener('popstate', afterRetirement, { once: true })
+ }
+ window.history.back()
+ }, [])
+
+ const rebaseHistorySentinel = useCallback(() => {
+ if (!dirtyResourceIdRef.current) return
+ const sentinel = historySentinelRef.current
+ const currentState = window.history.state
+ const ownsCurrentEntry =
+ sentinel !== null &&
+ window.location.href === sentinel.url &&
+ currentState !== null &&
+ typeof currentState === 'object' &&
+ currentState[RESOURCE_HISTORY_SENTINEL_KEY] === sentinel.token
+ if (ownsCurrentEntry) return
+
+ historySentinelRef.current = null
+ seedHistorySentinel()
+ }, [seedHistorySentinel])
+
+ const reportResourceDirty = useCallback(
+ (resourceId: string, dirty: boolean) => {
+ if (dirty) {
+ dirtyResourceIdRef.current = resourceId
+ seedHistorySentinel()
+ return
+ }
+ if (dirtyResourceIdRef.current !== resourceId) return
+ dirtyResourceIdRef.current = null
+ pendingTransitionRef.current = null
+ setShowDiscardConfirmation(false)
+ retireHistorySentinel()
+ },
+ [retireHistorySentinel, seedHistorySentinel]
+ )
+
+ const requestResourceTransition = useCallback((transition: () => void) => {
+ if (!dirtyResourceIdRef.current) {
+ transition()
+ return
+ }
+ pendingTransitionRef.current = transition
+ setShowDiscardConfirmation(true)
+ }, [])
+
+ const routeAutomaticResourceFocus = useCallback(
+ (nextResourceId: string, focus: () => void, markAttention: () => void) => {
+ // Resource upserts run before their focus event. Adding an item can move
+ // the derived fallback ID to that new last tab before this callback runs,
+ // even though the dirty editor is still what the user sees. The guard is
+ // the authoritative owner of that mounted dirty editor, so protect it
+ // directly instead of trusting an active ID that may already have moved.
+ if (dirtyResourceIdRef.current && dirtyResourceIdRef.current !== nextResourceId) {
+ markAttention()
+ return
+ }
+ focus()
+ },
+ []
+ )
+
+ const dismissDiscardConfirmation = useCallback(() => {
+ pendingTransitionRef.current = null
+ setShowDiscardConfirmation(false)
+ }, [])
+
+ const confirmDiscard = useCallback(() => {
+ const transition = pendingTransitionRef.current
+ pendingTransitionRef.current = null
+ dirtyResourceIdRef.current = null
+ setShowDiscardConfirmation(false)
+ retireHistorySentinel(transition ?? undefined)
+ }, [retireHistorySentinel])
+
+ const reset = useCallback(() => {
+ dirtyResourceIdRef.current = null
+ pendingTransitionRef.current = null
+ setShowDiscardConfirmation(false)
+ retireHistorySentinel()
+ }, [retireHistorySentinel])
+
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!dirtyResourceIdRef.current) return
+ event.preventDefault()
+ }
+
+ const handleNavigationRequest = (event: Event) => {
+ const detail = (event as CustomEvent).detail
+ if (typeof detail?.navigate !== 'function') return
+ event.preventDefault()
+ requestResourceTransition(detail.navigate)
+ }
+
+ const handlePopState = () => {
+ if (!dirtyResourceIdRef.current) return
+ historySentinelRef.current = null
+ seedHistorySentinel()
+ requestResourceTransition(() => window.history.back())
+ }
+
+ /**
+ * Next.js handles links before a history listener can block them. Capture
+ * same-window app links first, then replay the original click after the
+ * user confirms so the link keeps its own routing and selection behavior.
+ */
+ const handleDocumentClick = (event: MouseEvent) => {
+ if (
+ !dirtyResourceIdRef.current ||
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey ||
+ !(event.target instanceof Element)
+ ) {
+ return
+ }
+
+ const anchor = event.target.closest('a[href]')
+ if (
+ !anchor ||
+ anchor.hasAttribute('download') ||
+ (anchor.target && anchor.target !== '_self')
+ ) {
+ return
+ }
+
+ const destination = new URL(anchor.href, window.location.href)
+ const current = new URL(window.location.href)
+ if (
+ destination.origin !== current.origin ||
+ (destination.pathname === current.pathname && destination.search === current.search)
+ ) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+ requestResourceTransition(() => anchor.click())
+ }
+
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ window.addEventListener('popstate', handlePopState)
+ window.addEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest)
+ document.addEventListener('click', handleDocumentClick, true)
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload)
+ window.removeEventListener('popstate', handlePopState)
+ window.removeEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest)
+ document.removeEventListener('click', handleDocumentClick, true)
+ }
+ }, [requestResourceTransition, seedHistorySentinel])
+
+ return {
+ showDiscardConfirmation,
+ reportResourceDirty,
+ requestResourceTransition,
+ routeAutomaticResourceFocus,
+ dismissDiscardConfirmation,
+ confirmDiscard,
+ rebaseHistorySentinel,
+ reset,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx
index 809feeeb408..9c45c20f977 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { ChipConfirmModal, toast } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
@@ -38,7 +38,13 @@ interface CustomToolDetailProps {
tool: CustomToolDefinition | null
/** Viewers without edit rights get the same page with every control inert. */
readOnly?: boolean
+ /** Embedded editors defer navigation through the resource panel's guard. */
+ embedded?: boolean
+ /** Reports draft state to the resource-panel transition guard. */
+ onDirtyChange?: (dirty: boolean) => void
onBack: () => void
+ /** Lets an embedded detail close immediately after its resource was deleted. */
+ onDeleted?: () => void
/** Lands the caller on the tool it just created, matching the skill create flow. */
onCreated?: (toolId: string) => void
}
@@ -53,7 +59,10 @@ export function CustomToolDetail({
workspaceId,
tool,
readOnly = false,
+ embedded = false,
+ onDirtyChange,
onBack,
+ onDeleted,
onCreated,
}: CustomToolDetailProps) {
const isEditing = !!tool
@@ -75,10 +84,46 @@ export function CustomToolDetail({
const [jsonSchema, setJsonSchema] = useState(seededSchema)
const [functionCode, setFunctionCode] = useState(seededCode)
+ const [previousToolSource, setPreviousToolSource] = useState<{
+ id: string
+ schema: string
+ code: string
+ } | null>(() =>
+ tool
+ ? { id: tool.id, schema: JSON.stringify(tool.schema, null, 2), code: tool.code ?? '' }
+ : null
+ )
const [schemaError, setSchemaError] = useState(null)
const [codeError, setCodeError] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ if (tool) {
+ const nextSource = {
+ id: tool.id,
+ schema: JSON.stringify(tool.schema, null, 2),
+ code: tool.code ?? '',
+ }
+ const switchedTool = previousToolSource?.id !== tool.id
+ const sourceChanged =
+ previousToolSource !== null &&
+ (previousToolSource.id !== nextSource.id ||
+ previousToolSource.schema !== nextSource.schema ||
+ previousToolSource.code !== nextSource.code)
+
+ if (switchedTool || (sourceChanged && !updateTool.isPending)) {
+ const hadLocalDraft = jsonSchema !== seededSchema || functionCode !== seededCode
+ setPreviousToolSource(nextSource)
+ setSeededSchema(nextSource.schema)
+ setSeededCode(nextSource.code)
+ if (switchedTool || !hadLocalDraft) {
+ setJsonSchema(nextSource.schema)
+ setFunctionCode(nextSource.code)
+ setSchemaError(null)
+ setCodeError(null)
+ }
+ }
+ }
+
const schemaParameters = useMemo(() => extractSchemaParameters(jsonSchema), [jsonSchema])
/**
@@ -117,7 +162,15 @@ export function CustomToolDetail({
? jsonSchema !== seededSchema || functionCode !== seededCode
: jsonSchema.trim().length > 0 || functionCode.trim().length > 0
- const guard = useSettingsUnsavedGuard({ isDirty: dirty })
+ const guard = useSettingsUnsavedGuard({ isDirty: dirty, enabled: !embedded })
+
+ useEffect(() => {
+ onDirtyChange?.(dirty)
+ }, [dirty, onDirtyChange])
+
+ useEffect(() => {
+ return () => onDirtyChange?.(false)
+ }, [onDirtyChange])
const saving = createTool.isPending || updateTool.isPending
const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema])
@@ -186,7 +239,8 @@ export function CustomToolDetail({
setShowDeleteConfirm(false)
try {
await deleteTool.mutateAsync({ workspaceId, toolId: tool.id })
- onBack()
+ if (onDeleted) onDeleted()
+ else onBack()
} catch (error) {
logger.error('Failed to delete custom tool', error)
toast.error("Couldn't delete tool", {
@@ -299,11 +353,13 @@ export function CustomToolDetail({
}}
/>
-
+ {!embedded && (
+
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.test.tsx
new file mode 100644
index 00000000000..236fe4db7cb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { clearTestResult } = vi.hoisted(() => ({ clearTestResult: vi.fn() }))
+
+vi.mock('@/hooks/queries/mcp', () => ({
+ useMcpServerTest: () => ({
+ testResult: null,
+ isTestingConnection: false,
+ testConnection: vi.fn(),
+ clearTestResult,
+ }),
+}))
+
+import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
+
+describe('McpServerFormModal dirty state', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ clearTestResult.mockReset()
+ })
+
+ it('reports edits only while the embedded edit form is open', () => {
+ const onDirtyChange = vi.fn()
+ const renderModal = (open: boolean) => {
+ act(() => {
+ root.render(
+
+ )
+ })
+ }
+
+ renderModal(true)
+ expect(onDirtyChange).toHaveBeenLastCalledWith(false)
+
+ const nameInput = document.querySelector(
+ 'input[placeholder="e.g., My MCP Server"]'
+ )
+ if (!nameInput) throw new Error('MCP server name input was not rendered')
+
+ act(() => {
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ valueSetter?.call(nameInput, 'Renamed server')
+ nameInput.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ expect(onDirtyChange).toHaveBeenLastCalledWith(true)
+
+ renderModal(false)
+ expect(onDirtyChange).toHaveBeenLastCalledWith(false)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
index 794008189a5..8d526d1fa6b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
@@ -57,6 +57,8 @@ export interface McpServerFormConfig {
export interface McpServerFormModalProps {
open: boolean
onOpenChange: (open: boolean) => void
+ /** Reports unsaved edits when a host surface owns navigation protection. */
+ onDirtyChange?: (dirty: boolean) => void
mode: 'add' | 'edit'
initialData?: McpServerFormData
onSubmit: (config: McpServerFormConfig) => Promise
@@ -308,6 +310,7 @@ function updateHeadersArray(
export function McpServerFormModal({
open,
onOpenChange,
+ onDirtyChange,
mode,
initialData,
onSubmit,
@@ -452,6 +455,14 @@ export function McpServerFormModal({
}
const hasChanges = computeHasChanges()
+ useEffect(() => {
+ onDirtyChange?.(open && hasChanges)
+ }, [hasChanges, onDirtyChange, open])
+
+ useEffect(() => {
+ return () => onDirtyChange?.(false)
+ }, [onDirtyChange])
+
const parseJsonConfig = (
json: string
): { name: string; url: string; headers: Record } | null => {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index 55678e16cbc..d3cdc5d6851 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -172,15 +172,30 @@ function buildEditInitialData(server: McpServer) {
}
}
-export function MCP() {
+interface MCPProps {
+ /** Explicit values embed one server detail without changing settings search params. */
+ workspaceId?: string
+ serverId?: string
+ onBack?: () => void
+ /** Reports edits in the server form to an embedding navigation guard. */
+ onDirtyChange?: (dirty: boolean) => void
+}
+
+export function MCP({
+ workspaceId: explicitWorkspaceId,
+ serverId,
+ onBack,
+ onDirtyChange,
+}: MCPProps = {}) {
const params = useParams()
- const workspaceId = params.workspaceId as string
+ const workspaceId = explicitWorkspaceId ?? (params.workspaceId as string)
const workspacePermissions = useUserPermissionsContext()
const canEdit = canMutateWorkspaceSettingsSection('mcp', workspacePermissions)
const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, {
...mcpServerIdParam.parser,
...mcpServerIdUrlKeys,
})
+ const activeServerId = serverId ?? selectedServerId
const [searchTerm, setSearchTerm] = useSettingsSearch()
const [showAddModal, setShowAddModal] = useState(false)
const [editingServerId, setEditingServerId] = useState(null)
@@ -192,11 +207,13 @@ export function MCP() {
const {
data: servers = [],
isLoading: serversLoading,
+ isPending: serversPending,
+ isPlaceholderData: serversPlaceholder,
error: serversError,
} = useMcpServers(workspaceId)
const { data: mcpToolsData = [], toolsStateByServer } = useMcpToolsQuery(workspaceId)
const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId, {
- enabled: selectedServerId !== null,
+ enabled: activeServerId !== null,
})
const forceRefreshToolsMutation = useForceRefreshMcpTools()
const forceRefreshTools = forceRefreshToolsMutation.mutate
@@ -221,7 +238,7 @@ export function MCP() {
const showDeleteDialog = serverToDeleteId !== null
- const initialServerIdRef = useRef(selectedServerId)
+ const initialServerIdRef = useRef(activeServerId)
const didDeepLinkRefreshRef = useRef(false)
useEffect(() => {
if (didDeepLinkRefreshRef.current) return
@@ -247,7 +264,7 @@ export function MCP() {
await deleteServerMutation.mutateAsync({ workspaceId, serverId })
// Deleting from the detail view leaves a dead id in the URL — drop it so Back
// doesn't land on a server that no longer exists.
- if (selectedServerId === serverId) handleBackToList()
+ if (activeServerId === serverId) handleBackToList()
logger.info(`Removed MCP server: ${serverId}`)
} catch (error) {
logger.error('Failed to remove MCP server:', error)
@@ -285,7 +302,8 @@ export function MCP() {
/** Closing replaces the URL — Back should leave the section, not reopen the detail view. */
const handleBackToList = () => {
- setSelectedServerId(null, { history: 'replace' })
+ if (onBack) onBack()
+ else setSelectedServerId(null, { history: 'replace' })
setExpandedTools(new Set())
}
@@ -346,10 +364,10 @@ export function MCP() {
const editInitialData = editingServer ? buildEditInitialData(editingServer) : undefined
const selectedServer = (() => {
- if (!selectedServerId) return null
- const server = servers.find((s) => s.id === selectedServerId) as McpServer | undefined
+ if (!activeServerId) return null
+ const server = servers.find((s) => s.id === activeServerId) as McpServer | undefined
if (!server) return null
- const serverTools = (toolsByServer[selectedServerId] || []) as McpTool[]
+ const serverTools = (toolsByServer[activeServerId] || []) as McpTool[]
return { server, tools: serverTools }
})()
@@ -425,6 +443,30 @@ export function MCP() {
/>
) : null
+ if (serverId && (serversPending || serversPlaceholder)) {
+ return (
+
+ Loading...
+
+ )
+ }
+
+ if (serverId && serversError && !selectedServer) {
+ return (
+
+
+ {getErrorMessage(serversError, 'Failed to load this MCP server')}
+
+
+ )
+ }
+
if (selectedServer) {
const { server, tools } = selectedServer
const transportLabel = formatTransportLabel(server.transport || 'http')
@@ -630,13 +672,14 @@ export function MCP() {
onOpenChange={(open) => {
if (!open) setEditingServerId(null)
}}
+ onDirtyChange={onDirtyChange}
mode='edit'
initialData={editInitialData}
onSubmit={async (config) => {
- const currentServer = servers.find((s) => s.id === selectedServerId)
+ const currentServer = servers.find((s) => s.id === activeServerId)
await updateServerMutation.mutateAsync({
workspaceId,
- serverId: selectedServerId!,
+ serverId: activeServerId!,
updates: {
...config,
enabled: currentServer?.enabled ?? true,
@@ -655,6 +698,19 @@ export function MCP() {
)
}
+ if (serverId) {
+ return (
+
+
+ This MCP server may have been deleted or disconnected.
+
+
+ )
+ }
+
return (
<>
void
+ onDeleted?: () => void
}
/**
@@ -42,7 +47,13 @@ interface SkillDetailProps {
* Description / Content sections, and the Skill Editors roster. Non-editors
* and built-in template skills render read-only.
*/
-export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
+export function SkillDetail({
+ workspaceId,
+ skillId,
+ embedded = false,
+ onDirtyChange,
+ onDeleted,
+}: SkillDetailProps) {
const router = useRouter()
const skillsHref = `/workspace/${workspaceId}/skills`
@@ -71,7 +82,12 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
const [errors, setErrors] = useState({})
const [shareOpen, setShareOpen] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
- const [prevSkillId, setPrevSkillId] = useState(null)
+ const [previousSkillSource, setPreviousSkillSource] = useState<{
+ id: string
+ name: string
+ description: string
+ content: string
+ } | null>(null)
/** Applies a full skill shape to all three drafts and remounts the Content editor. */
const seedDrafts = (source: { name: string; description: string; content: string }) => {
@@ -82,21 +98,57 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
setContentSeed((seed) => seed + 1)
}
- // Seed drafts when the skill first resolves (or the route id changes); a
- // background refetch of the same skill must not clobber an in-progress edit.
- if (skill && skill.id !== prevSkillId) {
- setPrevSkillId(skill.id)
- seedDrafts(skill)
+ // A clean editor follows server-side changes (including Mothership edits),
+ // while a background refetch must not clobber an in-progress local draft.
+ if (skill) {
+ const nextSource = {
+ id: skill.id,
+ name: skill.name,
+ description: skill.description,
+ content: skill.content,
+ }
+ const switchedSkill = previousSkillSource?.id !== skill.id
+ const sourceChanged =
+ previousSkillSource !== null &&
+ (previousSkillSource.id !== nextSource.id ||
+ previousSkillSource.name !== nextSource.name ||
+ previousSkillSource.description !== nextSource.description ||
+ previousSkillSource.content !== nextSource.content)
+
+ if (switchedSkill || (sourceChanged && !updateSkill.isPending)) {
+ const shouldReseed =
+ switchedSkill ||
+ previousSkillSource === null ||
+ (nameDraft === previousSkillSource.name &&
+ descriptionDraft === previousSkillSource.description &&
+ contentDraft === previousSkillSource.content)
+ setPreviousSkillSource(nextSource)
+ if (shouldReseed) seedDrafts(skill)
+ }
}
+ const dirtyBaseline = previousSkillSource?.id === skill?.id ? previousSkillSource : skill
const isDirty =
!!skill &&
!isBuiltin &&
- (nameDraft !== skill.name ||
- descriptionDraft !== skill.description ||
- contentDraft !== skill.content)
+ !!dirtyBaseline &&
+ (nameDraft !== dirtyBaseline.name ||
+ descriptionDraft !== dirtyBaseline.description ||
+ contentDraft !== dirtyBaseline.content)
+
+ const guard = useUnsavedChangesGuard({
+ isDirty,
+ backHref: skillsHref,
+ enabled: !embedded,
+ })
+
+ useEffect(() => {
+ onDirtyChange?.(isDirty)
+ }, [isDirty, onDirtyChange])
- const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref })
+ useEffect(() => {
+ return () => onDirtyChange?.(false)
+ }, [onDirtyChange])
const handleSave = async () => {
if (!skill || !canEdit || !isDirty || updateSkill.isPending) return
@@ -144,7 +196,8 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
guard.release()
try {
await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id })
- router.replace(skillsHref)
+ if (embedded) onDeleted?.()
+ else router.replace(skillsHref)
} catch (error) {
guard.rearm()
toast.error("Couldn't delete skill", {
@@ -166,7 +219,9 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
return true
}
- const back = (
+ const back = embedded ? (
+
+ ) : (
Skills
@@ -282,11 +337,13 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
hideRole
/>
-
+ {!embedded && (
+
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
index 4024fa40810..912f1150f94 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
@@ -6,7 +6,9 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import {
+ MOTHERSHIP_NAVIGATION_REQUEST_EVENT,
MOTHERSHIP_SEND_MESSAGE_EVENT,
+ type MothershipNavigationRequestDetail,
type MothershipSendMessageDetail,
} from '@/lib/mothership/events'
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal'
@@ -335,6 +337,44 @@ describe('SearchModal', () => {
expect(rows[2]).toContain('Beta')
})
+ it('defers programmatic chat navigation through the mounted resource guard', async () => {
+ let pendingNavigation: (() => void) | undefined
+ const handleNavigationRequest = (event: Event) => {
+ event.preventDefault()
+ pendingNavigation = (event as CustomEvent).detail.navigate
+ }
+ window.addEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest)
+
+ try {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+
+ await enterSearchQuery('Alpha planning')
+ act(() => document.querySelector('[cmdk-item]')?.click())
+
+ expect(mockPush).not.toHaveBeenCalled()
+ expect(pendingNavigation).toBeTypeOf('function')
+
+ act(() => pendingNavigation?.())
+ expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/chat/chat-a')
+ } finally {
+ window.removeEventListener(MOTHERSHIP_NAVIGATION_REQUEST_EVENT, handleNavigationRequest)
+ }
+ })
+
it('shows an empty state when search has no results', async () => {
await act(async () => {
root.render()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index 58a79fad7e6..4488d8e642b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -44,7 +44,7 @@ import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transpo
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import { getFolderPathNames } from '@/lib/folders/tree'
-import { sendMothershipMessage } from '@/lib/mothership/events'
+import { requestMothershipNavigation, sendMothershipMessage } from '@/lib/mothership/events'
import { captureEvent } from '@/lib/posthog/client'
import { toSearchToken } from '@/lib/search/tokens'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
@@ -173,6 +173,13 @@ function SearchModalContent({
const posthogRef = useRef(posthog)
posthogRef.current = posthog
+ const navigate = useCallback((href: string, afterNavigate?: () => void) => {
+ requestMothershipNavigation(() => {
+ routerRef.current.push(href)
+ afterNavigate?.()
+ })
+ }, [])
+
const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data)
/**
@@ -392,7 +399,7 @@ function SearchModalContent({
exactQueries: ['chats'],
icon: Home,
context: 'global',
- run: () => routerRef.current.push(`/workspace/${workspaceId}/home`),
+ run: () => navigate(`/workspace/${workspaceId}/home`),
})
}
if (canEdit && onCreateWorkflow) {
@@ -672,6 +679,7 @@ function SearchModalContent({
onCreateFolder,
onImportWorkflow,
invokeCommand,
+ navigate,
navigateToSettings,
])
@@ -797,9 +805,10 @@ function SearchModalContent({
const handleWorkflowSelect = useCallback(
(workflow: WorkflowItem) => {
if (!workflow.isCurrent && workflow.href) {
- routerRef.current.push(workflow.href)
- window.dispatchEvent(
- new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
+ navigate(workflow.href, () =>
+ window.dispatchEvent(
+ new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
+ )
)
}
captureEvent(posthogRef.current, 'search_result_selected', {
@@ -809,13 +818,13 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleWorkspaceSelect = useCallback(
(workspace: WorkspaceItem) => {
if (!workspace.isCurrent && workspace.href) {
- routerRef.current.push(workspace.href)
+ navigate(workspace.href)
}
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'workspace',
@@ -824,12 +833,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleChatSelect = useCallback(
(chat: TaskItem) => {
- routerRef.current.push(chat.href)
+ navigate(chat.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'task',
query_length: searchRef.current.length,
@@ -837,12 +846,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleTableSelect = useCallback(
(item: TaskItem) => {
- routerRef.current.push(item.href)
+ navigate(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'table',
query_length: searchRef.current.length,
@@ -850,12 +859,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleFileSelect = useCallback(
(item: FileItem) => {
- routerRef.current.push(item.href)
+ navigate(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'file',
query_length: searchRef.current.length,
@@ -863,12 +872,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleKbSelect = useCallback(
(item: TaskItem) => {
- routerRef.current.push(item.href)
+ navigate(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'knowledge_base',
query_length: searchRef.current.length,
@@ -876,7 +885,7 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handlePageSelect = useCallback(
@@ -887,7 +896,7 @@ function SearchModalContent({
if (page.href.startsWith('http')) {
window.open(page.href, '_blank', 'noopener,noreferrer')
} else {
- routerRef.current.push(page.href)
+ navigate(page.href)
}
}
captureEvent(posthogRef.current, 'search_result_selected', {
@@ -897,12 +906,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleLogSelect = useCallback(
(item: LogItem) => {
- routerRef.current.push(item.href)
+ navigate(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'log',
query_length: searchRef.current.length,
@@ -910,12 +919,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleConnectedAccountSelect = useCallback(
(item: IntegrationSearchItem) => {
- routerRef.current.push(item.href)
+ navigate(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'connected_account',
query_length: searchRef.current.length,
@@ -923,12 +932,12 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleIntegrationSelect = useCallback(
(item: IntegrationSearchItem) => {
- routerRef.current.push(item.href)
+ navigate(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'integration',
query_length: searchRef.current.length,
@@ -936,7 +945,7 @@ function SearchModalContent({
})
onOpenChangeRef.current(false)
},
- [workspaceId]
+ [navigate, workspaceId]
)
const handleActionSelect = useCallback(
@@ -960,28 +969,36 @@ function SearchModalContent({
const homeHref = `/workspace/${workspaceId}/home`
const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query)
+ const finish = () => {
+ onOpenChangeRef.current(false)
+ captureEvent(posthogRef.current, 'search_result_selected', {
+ result_type: 'action',
+ action_id: 'new-chat-from-query',
+ query_length: query.length,
+ workspace_id: workspaceId,
+ })
+ }
+
if (!sentToMountedHome) {
/* One-shot auto-send handoff: Home's mount consumer sends it on arrival,
so both routes deliver the raw query identically. use-chat's queued
send dispatch now survives the mount-settling effect cycle that used
to silently abort programmatic sends (the old reason this was a
prefill). */
- if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) {
- logger.warn('Failed to persist command palette query for a new chat', {
- workspaceId,
- })
- return
- }
- routerRef.current.push(homeHref)
+ requestMothershipNavigation(() => {
+ if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) {
+ logger.warn('Failed to persist command palette query for a new chat', {
+ workspaceId,
+ })
+ return
+ }
+ routerRef.current.push(homeHref)
+ finish()
+ })
+ return
}
- onOpenChangeRef.current(false)
- captureEvent(posthogRef.current, 'search_result_selected', {
- result_type: 'action',
- action_id: 'new-chat-from-query',
- query_length: query.length,
- workspace_id: workspaceId,
- })
+ finish()
}, [workspaceId])
/** Enter in ask mode: hand the query to Sim, or just open a new chat when empty. */
@@ -990,7 +1007,7 @@ function SearchModalContent({
handleNewChatFromQuery()
return
}
- routerRef.current.push(`/workspace/${workspaceId}/home`)
+ navigate(`/workspace/${workspaceId}/home`)
onOpenChangeRef.current(false)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'action',
@@ -998,7 +1015,7 @@ function SearchModalContent({
query_length: 0,
workspace_id: workspaceId,
})
- }, [workspaceId, handleNewChatFromQuery])
+ }, [workspaceId, handleNewChatFromQuery, navigate])
const handleOverlayClick = useCallback(() => {
onOpenChangeRef.current(false)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index 4d06bf87c44..edd7a357a50 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -40,6 +40,7 @@ import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
+import { requestMothershipNavigation } from '@/lib/mothership/events'
import { captureEvent } from '@/lib/posthog/client'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -904,10 +905,12 @@ export const Sidebar = memo(function Sidebar({
const navigateToPage = useCallback(
(path: string) => {
- if (!isCollapsedRef.current) {
- setSidebarWidth(SIDEBAR_WIDTH.MIN)
- }
- router.push(path)
+ requestMothershipNavigation(() => {
+ if (!isCollapsedRef.current) {
+ setSidebarWidth(SIDEBAR_WIDTH.MIN)
+ }
+ router.push(path)
+ })
},
[setSidebarWidth, router]
)
@@ -921,19 +924,28 @@ export const Sidebar = memo(function Sidebar({
(id) => currentPath === `/workspace/${workspaceId}/chat/${id}`
)
- const onDeleteSuccess = () => {
- useFolderStore.getState().clearChatSelection()
- if (isViewingDeletedChat) {
- navigateToPage(`/workspace/${workspaceId}/home`)
+ const deleteChats = () => {
+ const onSuccess = () => useFolderStore.getState().clearChatSelection()
+ if (chatIdsToDelete.length === 1) {
+ deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess })
+ } else {
+ deleteChatsMutation.mutate(chatIdsToDelete, { onSuccess })
}
}
- if (chatIdsToDelete.length === 1) {
- deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess: onDeleteSuccess })
- } else {
- deleteChatsMutation.mutate(chatIdsToDelete, { onSuccess: onDeleteSuccess })
- }
setIsChatDeleteModalOpen(false)
+ if (!isViewingDeletedChat) {
+ deleteChats()
+ return
+ }
+
+ requestMothershipNavigation(() => {
+ if (!isCollapsedRef.current) {
+ setSidebarWidth(SIDEBAR_WIDTH.MIN)
+ }
+ router.push(`/workspace/${workspaceId}/home`)
+ deleteChats()
+ })
}
const [visibleChatCount, setVisibleChatCount] = useState(5)
diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx b/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx
new file mode 100644
index 00000000000..3766720bb30
--- /dev/null
+++ b/apps/sim/components/settings/use-settings-unsaved-guard.test.tsx
@@ -0,0 +1,44 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+function renderDisabledGuard() {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const root: Root = createRoot(document.createElement('div'))
+ let latest: ReturnType
+
+ function Probe() {
+ latest = useSettingsUnsavedGuard({ isDirty: true, enabled: false })
+ return null
+ }
+
+ act(() => root.render())
+ return { result: () => latest, unmount: () => act(() => root.unmount()) }
+}
+
+describe('useSettingsUnsavedGuard', () => {
+ beforeEach(() => {
+ useSettingsDirtyStore.getState().reset()
+ })
+
+ it('leaves global settings navigation clean when an embedded editor owns guarding', () => {
+ const leave = vi.fn()
+ const guard = renderDisabledGuard()
+
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(false)
+
+ act(() => guard.result().guardBack(leave))
+
+ expect(leave).toHaveBeenCalledOnce()
+ expect(guard.result().showUnsavedModal).toBe(false)
+
+ guard.unmount()
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(false)
+ })
+})
diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.ts b/apps/sim/components/settings/use-settings-unsaved-guard.ts
index 0fbf43786d4..3e3d19607f7 100644
--- a/apps/sim/components/settings/use-settings-unsaved-guard.ts
+++ b/apps/sim/components/settings/use-settings-unsaved-guard.ts
@@ -3,6 +3,8 @@ import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
interface UseSettingsUnsavedGuardParams {
isDirty: boolean
+ /** Embedded editors use their host's guard instead of global settings navigation. */
+ enabled?: boolean
}
interface SettingsUnsavedGuard {
@@ -17,25 +19,32 @@ interface SettingsUnsavedGuard {
*/
export function useSettingsUnsavedGuard({
isDirty,
+ enabled = true,
}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
const setDirty = useSettingsDirtyStore((state) => state.setDirty)
const reset = useSettingsDirtyStore((state) => state.reset)
- const isDirtyRef = useRef(isDirty)
+ const isDirtyRef = useRef(enabled && isDirty)
const pendingLeaveRef = useRef<(() => void) | null>(null)
const [showUnsavedModal, setShowUnsavedModal] = useState(false)
useEffect(() => {
- isDirtyRef.current = isDirty
+ isDirtyRef.current = enabled && isDirty
+ if (!enabled) {
+ pendingLeaveRef.current = null
+ setShowUnsavedModal(false)
+ return
+ }
setDirty(isDirty)
if (!isDirty) {
pendingLeaveRef.current = null
setShowUnsavedModal(false)
}
- }, [isDirty, setDirty])
+ }, [enabled, isDirty, setDirty])
useEffect(() => {
+ if (!enabled) return
return () => reset()
- }, [reset])
+ }, [enabled, reset])
const guardBack = useCallback((onLeave: () => void) => {
if (isDirtyRef.current) {
diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts
index dfcc734731c..2f0369368ac 100644
--- a/apps/sim/hooks/queries/custom-tools.ts
+++ b/apps/sim/hooks/queries/custom-tools.ts
@@ -167,11 +167,11 @@ async function fetchCustomTools(
/**
* Hook to fetch custom tools
*/
-export function useCustomTools(workspaceId: string) {
+export function useCustomTools(workspaceId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: customToolsKeys.list(workspaceId),
queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal),
- enabled: !!workspaceId,
+ enabled: !!workspaceId && (options?.enabled ?? true),
staleTime: CUSTOM_TOOL_LIST_STALE_TIME,
placeholderData: keepPreviousData,
})
diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts
index 8cb44396489..ce196760863 100644
--- a/apps/sim/hooks/queries/mcp.ts
+++ b/apps/sim/hooks/queries/mcp.ts
@@ -100,11 +100,11 @@ async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promi
}
}
-export function useMcpServers(workspaceId: string) {
+export function useMcpServers(workspaceId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: mcpKeys.serversList(workspaceId),
queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal),
- enabled: !!workspaceId,
+ enabled: !!workspaceId && (options?.enabled ?? true),
retry: false,
staleTime: MCP_SERVER_LIST_STALE_TIME,
placeholderData: keepPreviousData,
diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts
index f5e3c4e41c2..a8b3d84d9c0 100644
--- a/apps/sim/hooks/queries/skills.ts
+++ b/apps/sim/hooks/queries/skills.ts
@@ -44,11 +44,11 @@ async function fetchSkills(workspaceId: string, signal?: AbortSignal): Promise({
queryKey: skillsKeys.list(workspaceId),
queryFn: ({ signal }) => fetchSkills(workspaceId, signal),
- enabled: !!workspaceId,
+ enabled: !!workspaceId && (options?.enabled ?? true),
staleTime: SKILL_LIST_STALE_TIME,
placeholderData: keepPreviousData,
})
diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts
index 4b2d0635510..a91e249a982 100644
--- a/apps/sim/hooks/use-settings-navigation.ts
+++ b/apps/sim/hooks/use-settings-navigation.ts
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { useSession } from '@/lib/auth/auth-client'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
+import { requestMothershipNavigation } from '@/lib/mothership/events'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
@@ -117,15 +118,17 @@ export function useSettingsNavigation(): UseSettingsNavigationReturn {
const navigateToSettings = useCallback(
(options?: SettingsNavigationOptions) => {
- const currentPath = window.location.pathname
- if (currentPath.startsWith(settingsPrefix)) {
- router.replace(getSettingsHref(options), { scroll: false })
- } else {
- try {
- sessionStorage.setItem(SETTINGS_RETURN_URL_KEY, currentPath)
- } catch {}
- router.push(getSettingsHref(options))
- }
+ requestMothershipNavigation(() => {
+ const currentPath = window.location.pathname
+ if (currentPath.startsWith(settingsPrefix)) {
+ router.replace(getSettingsHref(options), { scroll: false })
+ } else {
+ try {
+ sessionStorage.setItem(SETTINGS_RETURN_URL_KEY, currentPath)
+ } catch {}
+ router.push(getSettingsHref(options))
+ }
+ })
},
[router, settingsPrefix, getSettingsHref]
)
diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts
index f7f71a24733..ff11dc53a59 100644
--- a/apps/sim/lib/copilot/chat/post.test.ts
+++ b/apps/sim/lib/copilot/chat/post.test.ts
@@ -315,6 +315,36 @@ describe('handleUnifiedChatPost', () => {
])
})
+ it('accepts and persists panel-only resource attachments without adding artificial context', async () => {
+ const response = await handleUnifiedChatPost(
+ new NextRequest('http://localhost/api/copilot/chat', {
+ method: 'POST',
+ body: JSON.stringify({
+ message: 'Keep these tabs open',
+ workspaceId: 'ws-1',
+ createNewChat: true,
+ resourceAttachments: [
+ { type: 'skill', id: 'skill-1', title: 'Writing' },
+ { type: 'custom_tool', id: 'tool-1', title: 'Formatter' },
+ { type: 'mcp_server', id: 'mcp-1', title: 'GitHub' },
+ ],
+ }),
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(persistChatResources).toHaveBeenCalledWith('chat-1', [
+ { type: 'skill', id: 'skill-1', title: 'Writing' },
+ { type: 'custom_tool', id: 'tool-1', title: 'Formatter' },
+ { type: 'mcp_server', id: 'mcp-1', title: 'GitHub' },
+ ])
+ expect(resolveActiveResourceContext).toHaveBeenCalledTimes(3)
+ expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
+ expect.objectContaining({ contexts: [] }),
+ { selectedModel: '' }
+ )
+ })
+
it('forwards the desktop local filesystem capability into payload construction', async () => {
const response = await handleUnifiedChatPost(
new NextRequest('http://localhost/api/copilot/chat', {
diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts
index 90b4b3b283c..d2957a8d314 100644
--- a/apps/sim/lib/copilot/chat/post.ts
+++ b/apps/sim/lib/copilot/chat/post.ts
@@ -59,6 +59,7 @@ import { persistChatResources } from '@/lib/copilot/resources/persistence'
import {
hasAddressableId,
isEphemeralResource,
+ PERSISTED_RESOURCE_TYPES,
sanitizeChatResources,
} from '@/lib/copilot/resources/types'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
@@ -92,22 +93,7 @@ const FileAttachmentSchema = z.object({
})
const ResourceAttachmentSchema = z.object({
- type: z.enum([
- 'workflow',
- 'table',
- 'file',
- 'knowledgebase',
- 'folder',
- 'filefolder',
- 'task',
- 'log',
- 'generic',
- 'browser',
- // Filtered out client-side rather than sent, but accepted here so a stray
- // terminal attachment degrades to a no-op instead of rejecting the whole
- // chat request.
- 'terminal',
- ]),
+ type: z.enum([...PERSISTED_RESOURCE_TYPES, 'generic']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
@@ -134,6 +120,10 @@ const GENERIC_RESOURCE_TITLE: Record['t
filefolder: 'File Folder',
task: 'Task',
log: 'Log',
+ integration: 'Integration',
+ skill: 'Skill',
+ custom_tool: 'Custom Tool',
+ mcp_server: 'MCP Server',
generic: 'Resource',
browser: 'Browser',
terminal: 'Terminal',
diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts
index c47413711f2..5b94770da4b 100644
--- a/apps/sim/lib/copilot/resources/extraction.test.ts
+++ b/apps/sim/lib/copilot/resources/extraction.test.ts
@@ -193,4 +193,30 @@ describe('extractDeletedResourcesFromToolResult', () => {
)
).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }])
})
+
+ it.each([
+ [
+ 'manage_skill',
+ { operation: 'delete', skillId: 'skill-1' },
+ { success: true, operation: 'delete', skillId: 'skill-1' },
+ [{ type: 'skill', id: 'skill-1', title: 'Skill' }],
+ ],
+ [
+ 'manage_custom_tool',
+ { operation: 'delete', toolIds: ['tool-1', 'tool-2'] },
+ { success: true, operation: 'delete', deleted: ['tool-1', 'tool-2'] },
+ [
+ { type: 'custom_tool', id: 'tool-1', title: 'Custom Tool' },
+ { type: 'custom_tool', id: 'tool-2', title: 'Custom Tool' },
+ ],
+ ],
+ [
+ 'manage_mcp_connection',
+ { operation: 'delete', serverId: 'mcp-1' },
+ { success: true, operation: 'delete', serverId: 'mcp-1' },
+ [{ type: 'mcp_server', id: 'mcp-1', title: 'MCP Server' }],
+ ],
+ ])('extracts deleted panel resources from %s', (toolName, params, output, expected) => {
+ expect(extractDeletedResourcesFromToolResult(toolName, params, output)).toEqual(expected)
+ })
})
diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts
index 2f47680dfaf..4861c29e726 100644
--- a/apps/sim/lib/copilot/resources/extraction.ts
+++ b/apps/sim/lib/copilot/resources/extraction.ts
@@ -9,7 +9,10 @@ import {
GenerateImage,
GenerateVideo,
Knowledge,
+ ManageCustomTool,
ManageKnowledgeBase,
+ ManageMcpConnection,
+ ManageSkill,
PrepareFileEdit,
Rm,
RunFunction,
@@ -225,6 +228,9 @@ const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = {
[PrepareFileEdit.id]: 'file',
[UserTable.id]: 'table',
[ManageKnowledgeBase.id]: 'knowledgebase',
+ [ManageSkill.id]: 'skill',
+ [ManageCustomTool.id]: 'custom_tool',
+ [ManageMcpConnection.id]: 'mcp_server',
// rm spans categories, so unlike every other entry its resource type comes
// from each outcome's kind rather than from this map. The entry exists so
// hasDeleteCapability(rm) holds; the rm case below ignores this value.
@@ -329,6 +335,26 @@ export function extractDeletedResourcesFromToolResult(
return []
}
+ case ManageSkill.id: {
+ if (operation !== 'delete') return []
+ const skillId = (result.skillId as string) ?? (params?.skillId as string)
+ return skillId ? [{ type: resourceType, id: skillId, title: 'Skill' }] : []
+ }
+
+ case ManageCustomTool.id: {
+ if (operation !== 'delete') return []
+ const deleted = Array.isArray(result.deleted)
+ ? result.deleted.filter((id): id is string => typeof id === 'string' && id.length > 0)
+ : []
+ return deleted.map((id) => ({ type: resourceType, id, title: 'Custom Tool' }))
+ }
+
+ case ManageMcpConnection.id: {
+ if (operation !== 'delete') return []
+ const serverId = (result.serverId as string) ?? (params?.serverId as string)
+ return serverId ? [{ type: resourceType, id: serverId, title: 'MCP Server' }] : []
+ }
+
default:
return []
}
diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts
index 58d68d6e737..6d5525e5263 100644
--- a/apps/sim/lib/copilot/resources/types.ts
+++ b/apps/sim/lib/copilot/resources/types.ts
@@ -8,6 +8,9 @@ export const MothershipResourceType = {
task: 'task',
log: 'log',
integration: 'integration',
+ skill: 'skill',
+ custom_tool: 'custom_tool',
+ mcp_server: 'mcp_server',
generic: 'generic',
browser: 'browser',
terminal: 'terminal',
@@ -78,6 +81,9 @@ const RESOURCE_POLICY: Record = {
task: { persisted: true },
log: { persisted: true },
integration: { persisted: true },
+ skill: { persisted: true },
+ custom_tool: { persisted: true },
+ mcp_server: { persisted: true },
// A synthetic panel with no addressable entity behind it to reopen.
generic: { persisted: false },
browser: { persisted: true, desktopOnly: true },
@@ -210,6 +216,10 @@ export const GENERIC_RESOURCE_TITLES = new Set([
'Knowledge Base',
'Folder',
'Log',
+ 'Integration',
+ 'Skill',
+ 'Custom Tool',
+ 'MCP Server',
])
export const VFS_DIR_TO_RESOURCE: Record = {
diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts
index 9b9ebb41da5..3e4851ab974 100644
--- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts
+++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts
@@ -107,7 +107,11 @@ describe('Copilot management application boundaries', () => {
context
)
- expect(result).toMatchObject({ success: true, output: { toolId: 'tool-1' } })
+ expect(result).toMatchObject({
+ success: true,
+ resources: [{ type: 'custom_tool', id: 'tool-1', title: 'lookup_order' }],
+ output: { toolId: 'tool-1' },
+ })
expect(mocks.custom).toHaveBeenCalledWith(context, useCases.saveCustom, {
workspaceId: context.workspaceId,
title: 'lookup_order',
@@ -139,7 +143,11 @@ describe('Copilot management application boundaries', () => {
context
)
- expect(result).toMatchObject({ success: true, output: { serverId: 'mcp-server-1' } })
+ expect(result).toMatchObject({
+ success: true,
+ resources: [{ type: 'mcp_server', id: 'mcp-server-1', title: 'Docs' }],
+ output: { serverId: 'mcp-server-1' },
+ })
expect(mocks.mcp).toHaveBeenCalledWith(
context,
useCases.registerMcp,
@@ -158,7 +166,11 @@ describe('Copilot management application boundaries', () => {
{ ...context, userPermission: 'read' }
)
- expect(result).toMatchObject({ success: true, output: { skillId: 'skill-1' } })
+ expect(result).toMatchObject({
+ success: true,
+ resources: [{ type: 'skill', id: 'skill-1', title: 'refund-policy' }],
+ output: { skillId: 'skill-1' },
+ })
expect(mocks.skill).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: context.workspaceId }),
useCases.updateSkill,
diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts
index adb9e45f3a0..04b3c60daf6 100644
--- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts
+++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts
@@ -117,6 +117,7 @@ export async function executeManageCustomTool(
return {
success: true,
+ resources: [{ type: 'custom_tool', id: created.id, title: created.title }],
output: {
success: true,
operation,
@@ -170,6 +171,7 @@ export async function executeManageCustomTool(
return {
success: true,
+ resources: [{ type: 'custom_tool', id: tool.id, title: tool.title }],
output: {
success: true,
operation,
diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts
index 066fb6b8539..6e91efddf8b 100644
--- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts
+++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts
@@ -107,6 +107,9 @@ export async function executeManageMcpTool(
return {
success: true,
+ resources: [
+ { type: 'mcp_server', id: result.server.id, title: result.server.name || config.name },
+ ],
output: {
success: true,
operation,
@@ -142,6 +145,9 @@ export async function executeManageMcpTool(
return {
success: true,
+ resources: [
+ { type: 'mcp_server', id: result.server.id, title: result.server.name || 'MCP Server' },
+ ],
output: {
success: true,
operation,
diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts
index d17bac0bfd6..4dc795a4ed4 100644
--- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts
+++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts
@@ -90,6 +90,7 @@ export async function executeManageSkill(
return {
success: true,
+ resources: [{ type: 'skill', id: skill.id, title: skill.name }],
output: {
success: true,
operation,
@@ -133,6 +134,7 @@ export async function executeManageSkill(
return {
success: true,
+ resources: [{ type: 'skill', id: skill.id, title: skill.name }],
output: {
success: true,
operation,
diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts
index 4ce4d844da8..223493f53ab 100644
--- a/apps/sim/lib/mothership/events.ts
+++ b/apps/sim/lib/mothership/events.ts
@@ -14,6 +14,24 @@ function dispatchClaimable(name: string, detail: T): boolean {
return !window.dispatchEvent(new CustomEvent(name, { detail, cancelable: true }))
}
+/**
+ * Lets programmatic workspace navigation pass through a mounted Sim Chat's
+ * resource-draft guard. When no chat is mounted, the navigation runs directly.
+ */
+export const MOTHERSHIP_NAVIGATION_REQUEST_EVENT = 'mothership-navigation-request'
+
+export interface MothershipNavigationRequestDetail {
+ navigate: () => void
+}
+
+export function requestMothershipNavigation(navigate: () => void): void {
+ const consumed = dispatchClaimable(
+ MOTHERSHIP_NAVIGATION_REQUEST_EVENT,
+ { navigate }
+ )
+ if (!consumed) navigate()
+}
+
/**
* Custom-event name used to send a user message to the Mothership chat.
* The mothership host components (workspace home, workflow panel) listen