Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<Probe />))
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()
})
})
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 @@ -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'
Expand Down Expand Up @@ -62,6 +63,11 @@ export function ChatResourcePanel({
handleResourceResizeKeyDown,
handleResourceResizeFocus,
handleResourceInteraction,
requestResourceTransition,
reportResourceDirty,
showDiscardConfirmation,
dismissDiscardConfirmation,
confirmDiscard,
} = panel
const summarize = useCallback(
(message: string, filters: WorkspaceSearchFilters) => {
Expand All @@ -71,47 +77,58 @@ export function ChatResourcePanel({
[onSummarize, chat.sendMessage]
)
return (
<ChatPanelLayout
collapsed={isResourceCollapsed}
label='resource view'
activityCount={resourceActivityIds.size}
onToggle={isResourceCollapsed ? expandResource : collapseResource}
onResize={handleResourceResizePointerDown}
onResizeKeyDown={handleResourceResizeKeyDown}
onResizeFocus={handleResourceResizeFocus}
panel={
<MothershipResourcesProvider
selectResource={selectResourceFromUser}
addResource={addResourceFromUser}
removeResource={removeResource}
reorderResources={reorderResources}
collapseResource={collapseResource}
>
<Suspense fallback={null}>
<MothershipView
ref={mothershipRef}
workspaceId={workspaceId}
organizationId={organizationId}
allowBuildControls={allowBuildControls}
chatId={resolvedChatId}
desktopScopeId={desktopScopeId}
resources={resources}
onTableViewContextChange={setTableViewContext}
activeResourceId={activeResourceId}
activityResourceIds={resourceActivityIds}
isCollapsed={isResourceCollapsed}
previewSession={previewSession}
isAgentResponding={isSending}
genericResourceData={genericResourceData ?? undefined}
onSummarize={summarize}
onUserInteraction={handleResourceInteraction}
className={skipResourceTransition ? 'transition-none!' : undefined}
/>
</Suspense>
</MothershipResourcesProvider>
}
>
{children}
</ChatPanelLayout>
<>
<ChatPanelLayout
collapsed={isResourceCollapsed}
label='resource view'
activityCount={resourceActivityIds.size}
onToggle={isResourceCollapsed ? expandResource : collapseResource}
onResize={handleResourceResizePointerDown}
onResizeKeyDown={handleResourceResizeKeyDown}
onResizeFocus={handleResourceResizeFocus}
panel={
<MothershipResourcesProvider
selectResource={selectResourceFromUser}
addResource={addResourceFromUser}
removeResource={removeResource}
reorderResources={reorderResources}
collapseResource={collapseResource}
requestResourceTransition={requestResourceTransition}
reportResourceDirty={reportResourceDirty}
>
<Suspense fallback={null}>
<MothershipView
ref={mothershipRef}
workspaceId={workspaceId}
organizationId={organizationId}
allowBuildControls={allowBuildControls}
chatId={resolvedChatId}
desktopScopeId={desktopScopeId}
resources={resources}
onTableViewContextChange={setTableViewContext}
activeResourceId={activeResourceId}
activityResourceIds={resourceActivityIds}
isCollapsed={isResourceCollapsed}
previewSession={previewSession}
isAgentResponding={isSending}
genericResourceData={genericResourceData ?? undefined}
onSummarize={summarize}
onUserInteraction={handleResourceInteraction}
className={skipResourceTransition ? 'transition-none!' : undefined}
/>
</Suspense>
</MothershipResourcesProvider>
}
>
{children}
</ChatPanelLayout>
<UnsavedChangesModal
open={showDiscardConfirmation}
onOpenChange={(open) => {
if (!open) dismissDiscardConfirmation()
}}
onDiscard={confirmDiscard}
/>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MothershipResourcesContextValue | null>(null)
Expand All @@ -46,11 +50,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 @@ -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'
Expand Down Expand Up @@ -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])

/**
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -313,6 +347,9 @@ export function useAvailableResources(
dashboardData,
knowledgeBases,
tasks,
skills,
customTools,
mcpServers,
logs,
excludeTypes,
dashboardsEnabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import type { MothershipResourceType } from '@/lib/mothership/resources/types'
import {
type AvailableResources,
useAvailableResources,
Expand All @@ -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])
Expand Down
Loading
Loading