Skip to content
5 changes: 3 additions & 2 deletions apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
}

const retentionThreshold = new Date(Date.now() - JOB_RETENTION_HOURS * 60 * 60 * 1000)
const retentionNow = Date.now()
const retentionThreshold = new Date(retentionNow - JOB_RETENTION_HOURS * 60 * 60 * 1000)
const irrecoverableCarrierRetentionThreshold = new Date(
Date.now() - SCHEDULE_CARRIER_IRRECOVERABLE_RETENTION_HOURS * 60 * 60 * 1000
retentionNow - SCHEDULE_CARRIER_IRRECOVERABLE_RETENTION_HOURS * 60 * 60 * 1000
)
let asyncJobsDeleted = 0

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @vitest-environment jsdom
*/

import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

const { mockPush } = vi.hoisted(() => ({ mockPush: vi.fn() }))

vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))

import { useUnsavedChangesGuard } from '@/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard'

function mountDisabledDirtyGuard(): () => void {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const root: Root = createRoot(document.createElement('div'))

function Probe() {
useUnsavedChangesGuard({
isDirty: true,
backHref: '/workspace/ws-1/skills',
enabled: false,
})
return null
}

act(() => root.render(<Probe />))
return () => act(() => root.unmount())
}

describe('useUnsavedChangesGuard', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('installs no nested navigation guard when its embedded host owns transitions', () => {
const pushState = vi.spyOn(window.history, 'pushState')
const unmount = mountDisabledDirtyGuard()

const beforeUnload = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(beforeUnload)

expect(pushState).not.toHaveBeenCalled()
expect(beforeUnload.defaultPrevented).toBe(false)

unmount()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -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<HTMLAnchorElement>) => {
if (isDirty && !isReleased) {
if (enabled && isDirty && !isReleased) {
event.preventDefault()
setShowUnsavedAlert(true)
}
},
[isDirty, isReleased]
[enabled, isDirty, isReleased]
)

const confirmDiscard = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ interface MothershipResourcesContextValue {
reorderResources: (resources: MothershipResource[]) => void
/** Collapses the resource panel. */
collapseResource: () => void
/** Defers a user transition when the active embedded editor has a dirty draft. */
requestResourceTransition: (transition: () => void) => void
/** Reports dirty state for an embedded editor mounted in the active tab. */
reportResourceDirty: (resourceId: string, dirty: boolean) => void
}

const MothershipResourcesContext = createContext<MothershipResourcesContextValue | null>(null)
Expand All @@ -42,11 +46,29 @@ export function MothershipResourcesProvider({
removeResource,
reorderResources,
collapseResource,
requestResourceTransition,
reportResourceDirty,
children,
}: MothershipResourcesProviderProps) {
const value = useMemo<MothershipResourcesContextValue>(
() => ({ 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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { listIntegrationsByPopularity } from '@/blocks/integration-matcher'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
import { useMcpServers } from '@/hooks/queries/mcp'
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
Expand Down Expand Up @@ -180,6 +183,18 @@ export function useAvailableResources(
LOG_DROPDOWN_FILTERS,
{ enabled }
)
const skillsEnabled = enabled && !excludeTypes?.includes('skill')
const customToolsEnabled = enabled && !excludeTypes?.includes('custom_tool')
const mcpServersEnabled = enabled && !excludeTypes?.includes('mcp_server')
const { data: skills, isPending: skillsPending } = useSkills(workspaceId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the picker remains mounted across a workspace switch, these hooks serve the previous workspace's placeholder rows while isHydrating stays false. Track isPlaceholderData for all three queries or suppress placeholder rows before allowing selection, otherwise the panel can attach a resource from the prior workspace.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx, line 189:

<comment>When the picker remains mounted across a workspace switch, these hooks serve the previous workspace's placeholder rows while `isHydrating` stays false. Track `isPlaceholderData` for all three queries or suppress placeholder rows before allowing selection, otherwise the panel can attach a resource from the prior workspace.</comment>

<file context>
@@ -180,6 +183,18 @@ export function useAvailableResources(
+  const skillsEnabled = enabled && !excludeTypes?.includes('skill')
+  const customToolsEnabled = enabled && !excludeTypes?.includes('custom_tool')
+  const mcpServersEnabled = enabled && !excludeTypes?.includes('mcp_server')
+  const { data: skills, isPending: skillsPending } = useSkills(workspaceId, {
+    enabled: skillsEnabled,
+  })
</file context>

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])

/**
Expand All @@ -201,7 +216,10 @@ export function useAvailableResources(
foldersPending ||
fileFoldersPending ||
tasksPending ||
logsPending)
logsPending ||
(skillsEnabled && skillsPending) ||
(customToolsEnabled && customToolsPending) ||
(mcpServersEnabled && mcpServersPending))

const groups = useMemo(() => {
if (!enabled) return NO_RESOURCE_GROUPS
Expand Down Expand Up @@ -266,6 +284,21 @@ export function useAvailableResources(
type: 'task' as const,
items: (tasks ?? []).map((t) => ({ id: t.id, name: t.name })),
},
{
type: 'skill' as const,
items: (skills ?? []).map((skill) => ({ id: skill.id, name: skill.name })),
},
{
type: 'custom_tool' as const,
items: (customTools ?? []).map((tool) => ({ id: tool.id, name: tool.title })),
},
{
type: 'mcp_server' as const,
items: (mcpServers ?? []).map((server) => ({
id: server.id,
name: server.name || 'Unnamed server',
})),
},
/**
* The chip's `name` keeps the absolute timestamp because it is persisted
* with the chat, where "2m ago" would age into a lie; the row renders the
Expand Down Expand Up @@ -325,6 +358,9 @@ export function useAvailableResources(
files,
knowledgeBases,
tasks,
skills,
customTools,
mcpServers,
logs,
excludeTypes,
])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: () => false }))
vi.mock('@/lib/terminal/transport', () => ({ isTerminalAvailable: () => false }))
vi.mock('@/blocks/integration-matcher', () => ({ listIntegrationsByPopularity: () => [] }))
vi.mock('@/hooks/queries/custom-tools', () => ({
useCustomTools: () => ({
data: [{ id: 'tool-1', title: 'Lookup order' }],
isPending: false,
}),
}))
vi.mock('@/hooks/queries/folders', () => ({
useFolders: () => ({ data: [], isPending: false }),
}))
vi.mock('@/hooks/queries/kb/knowledge', () => ({
useKnowledgeBasesQuery: () => ({ data: [], isPending: false }),
}))
vi.mock('@/hooks/queries/logs', () => ({
useLogsList: () => ({ data: { pages: [] }, isPending: false }),
}))
vi.mock('@/hooks/queries/mcp', () => ({
useMcpServers: () => ({
data: [{ id: 'server-1', name: 'DeepWiki' }],
isPending: false,
}),
}))
vi.mock('@/hooks/queries/mothership-chats', () => ({
useMothershipChats: () => ({ data: [], isPending: false }),
}))
vi.mock('@/hooks/queries/skills', () => ({
useSkills: () => ({
data: [{ id: 'skill-1', name: 'Research' }],
isPending: false,
}),
}))
vi.mock('@/hooks/queries/tables', () => ({
useTablesList: () => ({ data: [], isPending: false }),
}))
vi.mock('@/hooks/queries/workflows', () => ({
useWorkflows: () => ({ data: [], isPending: false }),
}))
vi.mock('@/hooks/queries/workspace-file-folders', () => ({
useWorkspaceFileFolders: () => ({ data: [], isPending: false }),
}))
vi.mock('@/hooks/queries/workspace-files', () => ({
useWorkspaceFiles: () => ({ data: [], isPending: false }),
}))

import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown'

describe('useAvailableResources panel resource groups', () => {
it('offers Skills, Custom Tools, and MCP servers to the panel picker', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
let latest: ReturnType<typeof useAvailableResources> | undefined

function Probe() {
latest = useAvailableResources('workspace-1', { enabled: true })
return null
}

act(() => root.render(<Probe />))

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()
})
})
Loading
Loading