fix(resource): Hide resources that have been deleted (#3528)

* Hide resources that have been deleted

* Handle table, workflow not found

* Add animation to prevent flash when previous resource was deleted

* Fix animation playing on every switch

* Run workflows client side in mothership to transmit logs

* Fix race condition for animation

* Use shared workflow tool util file

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-11 18:51:06 -07:00
committed by GitHub
parent 68909e71d0
commit 1266a66838
3 changed files with 200 additions and 13 deletions

View File

@@ -167,12 +167,14 @@ export function Home({ chatId }: HomeProps = {}) {
sendMessage,
stopGeneration,
resources,
isResourceCleanupSettled,
activeResourceId,
setActiveResourceId,
} = useChat(workspaceId, chatId)
const [isResourceCollapsed, setIsResourceCollapsed] = useState(false)
const [showExpandButton, setShowExpandButton] = useState(false)
const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false)
useEffect(() => {
if (!isResourceCollapsed) {
@@ -186,16 +188,24 @@ export function Home({ chatId }: HomeProps = {}) {
const collapseResource = useCallback(() => setIsResourceCollapsed(true), [])
const expandResource = useCallback(() => setIsResourceCollapsed(false), [])
const prevResourceCountRef = useRef(resources.length)
const animateResourcePanel =
prevResourceCountRef.current === 0 && resources.length > 0 && isSending
const visibleResources = isResourceCleanupSettled ? resources : []
const prevResourceCountRef = useRef(visibleResources.length)
const shouldEnterResourcePanel =
isSending && prevResourceCountRef.current === 0 && visibleResources.length > 0
useEffect(() => {
if (animateResourcePanel) {
if (shouldEnterResourcePanel) {
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
if (!isCollapsed) toggleCollapsed()
setIsResourceAnimatingIn(true)
}
prevResourceCountRef.current = resources.length
})
prevResourceCountRef.current = visibleResources.length
}, [shouldEnterResourcePanel, visibleResources.length])
useEffect(() => {
if (!isResourceAnimatingIn) return
const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400)
return () => clearTimeout(timer)
}, [isResourceAnimatingIn])
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[]) => {
@@ -340,19 +350,19 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
</div>
{resources.length > 0 && (
{visibleResources.length > 0 && (
<MothershipView
workspaceId={workspaceId}
resources={resources}
resources={visibleResources}
activeResourceId={activeResourceId}
onSelectResource={setActiveResourceId}
onCollapse={collapseResource}
isCollapsed={isResourceCollapsed}
className={animateResourcePanel ? 'animate-slide-in-right' : undefined}
className={isResourceAnimatingIn ? 'animate-slide-in-right' : undefined}
/>
)}
{resources.length > 0 && showExpandButton && (
{visibleResources.length > 0 && showExpandButton && (
<div className='absolute top-[8.5px] right-[16px]'>
<button
type='button'

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
@@ -6,7 +6,7 @@ import { executeRunToolOnClient } from '@/lib/copilot/client-sse/run-tool-execut
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { tableKeys, useTablesList } from '@/hooks/queries/tables'
import {
type TaskChatHistory,
type TaskStoredContentBlock,
@@ -16,7 +16,8 @@ import {
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflows, workflowKeys } from '@/hooks/queries/workflows'
import { useWorkspaceFiles, workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { FileAttachmentForApi } from '../components/user-input/user-input'
import type {
@@ -48,6 +49,7 @@ export interface UseChatReturn {
sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[]) => Promise<void>
stopGeneration: () => Promise<void>
resources: MothershipResource[]
isResourceCleanupSettled: boolean
activeResourceId: string | null
setActiveResourceId: (id: string | null) => void
}
@@ -57,6 +59,57 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
error: 'error',
} as const
function areResourcesEqual(left: MothershipResource[], right: MothershipResource[]): boolean {
if (left.length !== right.length) return false
return left.every(
(resource, index) =>
resource.id === right[index]?.id &&
resource.type === right[index]?.type &&
resource.title === right[index]?.title
)
}
function sanitizeResources(
resources: MothershipResource[],
existingFileIds: Set<string>,
existingTableIds: Set<string>,
existingWorkflowIds: Set<string>,
pendingFileIds: Set<string>,
pendingTableIds: Set<string>,
pendingWorkflowIds: Set<string>,
shouldFilterMissingFiles: boolean,
shouldFilterMissingTables: boolean,
shouldFilterMissingWorkflows: boolean
): MothershipResource[] {
return resources.filter((resource) => {
if (resource.type === 'file') {
if (pendingFileIds.has(resource.id)) {
return true
}
if (shouldFilterMissingFiles && !existingFileIds.has(resource.id)) {
return false
}
}
if (resource.type === 'table') {
if (pendingTableIds.has(resource.id)) {
return true
}
if (shouldFilterMissingTables && !existingTableIds.has(resource.id)) {
return false
}
}
if (resource.type === 'workflow') {
if (pendingWorkflowIds.has(resource.id)) {
return true
}
if (shouldFilterMissingWorkflows && !existingWorkflowIds.has(resource.id)) {
return false
}
}
return true
})
}
function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
const mapped: ContentBlock = {
type: block.type as ContentBlockType,
@@ -161,12 +214,55 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
const streamGenRef = useRef(0)
const streamingContentRef = useRef('')
const pendingFileResourceIdsRef = useRef<Set<string>>(new Set())
const pendingTableResourceIdsRef = useRef<Set<string>>(new Set())
const pendingWorkflowResourceIdsRef = useRef<Set<string>>(new Set())
const isHomePage = pathname.endsWith('/home')
const { data: chatHistory } = useChatHistory(initialChatId)
const {
data: workspaceFiles = [],
isLoading: isWorkspaceFilesLoading,
isError: isWorkspaceFilesError,
} = useWorkspaceFiles(workspaceId)
const {
data: workspaceTables = [],
isLoading: isWorkspaceTablesLoading,
isError: isWorkspaceTablesError,
} = useTablesList(workspaceId)
const {
data: workflows = [],
isLoading: isWorkflowsLoading,
isError: isWorkflowsError,
} = useWorkflows(workspaceId, { syncRegistry: false })
const existingWorkspaceFileIds = useMemo(
() => new Set(workspaceFiles.map((file) => file.id)),
[workspaceFiles]
)
const existingWorkspaceTableIds = useMemo(
() => new Set(workspaceTables.map((table) => table.id)),
[workspaceTables]
)
const existingWorkflowIds = useMemo(
() => new Set(workflows.map((workflow) => workflow.id)),
[workflows]
)
const isResourceCleanupSettled = useMemo(
() => !isWorkspaceFilesLoading && !isWorkspaceTablesLoading && !isWorkflowsLoading,
[isWorkspaceFilesLoading, isWorkspaceTablesLoading, isWorkflowsLoading]
)
const addResource = useCallback((resource: MothershipResource) => {
if (resource.type === 'file') {
pendingFileResourceIdsRef.current.add(resource.id)
} else if (resource.type === 'table') {
pendingTableResourceIdsRef.current.add(resource.id)
} else if (resource.type === 'workflow') {
pendingWorkflowResourceIdsRef.current.add(resource.id)
}
setResources((prev) => {
const existing = prev.find((r) => r.type === resource.type && r.id === resource.id)
if (existing) {
@@ -182,6 +278,30 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setActiveResourceId(resource.id)
}, [])
useEffect(() => {
for (const id of pendingFileResourceIdsRef.current) {
if (existingWorkspaceFileIds.has(id)) {
pendingFileResourceIdsRef.current.delete(id)
}
}
}, [existingWorkspaceFileIds])
useEffect(() => {
for (const id of pendingTableResourceIdsRef.current) {
if (existingWorkspaceTableIds.has(id)) {
pendingTableResourceIdsRef.current.delete(id)
}
}
}, [existingWorkspaceTableIds])
useEffect(() => {
for (const id of pendingWorkflowResourceIdsRef.current) {
if (existingWorkflowIds.has(id)) {
pendingWorkflowResourceIdsRef.current.delete(id)
}
}
}, [existingWorkflowIds])
useEffect(() => {
if (sendingRef.current) {
chatIdRef.current = initialChatId
@@ -194,6 +314,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setIsSending(false)
setResources([])
setActiveResourceId(null)
pendingFileResourceIdsRef.current.clear()
pendingTableResourceIdsRef.current.clear()
pendingWorkflowResourceIdsRef.current.clear()
}, [initialChatId])
useEffect(() => {
@@ -209,6 +332,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setIsSending(false)
setResources([])
setActiveResourceId(null)
pendingFileResourceIdsRef.current.clear()
pendingTableResourceIdsRef.current.clear()
pendingWorkflowResourceIdsRef.current.clear()
}, [isHomePage])
useEffect(() => {
@@ -245,6 +371,51 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
}
}, [chatHistory, workspaceId])
useEffect(() => {
setResources((prev) => {
const shouldFilterMissingFiles = !isWorkspaceFilesLoading && !isWorkspaceFilesError
const shouldFilterMissingTables = !isWorkspaceTablesLoading && !isWorkspaceTablesError
const shouldFilterMissingWorkflows = !isWorkflowsLoading && !isWorkflowsError
const next = sanitizeResources(
prev,
existingWorkspaceFileIds,
existingWorkspaceTableIds,
existingWorkflowIds,
pendingFileResourceIdsRef.current,
pendingTableResourceIdsRef.current,
pendingWorkflowResourceIdsRef.current,
shouldFilterMissingFiles,
shouldFilterMissingTables,
shouldFilterMissingWorkflows
)
return areResourcesEqual(prev, next) ? prev : next
})
}, [
resources,
existingWorkspaceFileIds,
existingWorkspaceTableIds,
existingWorkflowIds,
isWorkspaceFilesError,
isWorkspaceFilesLoading,
isWorkspaceTablesError,
isWorkspaceTablesLoading,
isWorkflowsError,
isWorkflowsLoading,
])
useEffect(() => {
if (resources.length === 0) {
if (activeResourceId !== null) {
setActiveResourceId(null)
}
return
}
if (!activeResourceId || !resources.some((resource) => resource.id === activeResourceId)) {
setActiveResourceId(resources[resources.length - 1].id)
}
}, [activeResourceId, resources])
const processSSEStream = useCallback(
async (reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => {
const decoder = new TextDecoder()
@@ -425,6 +596,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resource = extractTableResource(parsed, storedArgs, lastTableId)
if (resource) {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
}
@@ -444,6 +616,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
if (resource) {
if (resource.type === 'table') {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
} else if (resource.type === 'file') {
@@ -459,6 +632,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resource = extractFunctionExecuteResource(parsed, storedArgs)
if (resource?.type === 'table') {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
}
@@ -466,6 +640,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resource = extractWorkflowResource(parsed, lastWorkflowId)
if (resource) {
lastWorkflowId = resource.id
queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
const registry = useWorkflowRegistry.getState()
if (!registry.workflows[resource.id]) {
useWorkflowRegistry.setState((state) => ({
@@ -761,6 +936,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
sendMessage,
stopGeneration,
resources,
isResourceCleanupSettled,
activeResourceId,
setActiveResourceId,
}

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import {
asRecord,
getEventData,