diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx index ddc208f17ef..0c8a75dd428 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx @@ -1,10 +1,8 @@ 'use client' import { createContext, type ReactNode, useContext, useMemo } from 'react' -import type { - MothershipResource, - MothershipResourceType, -} from '@/app/workspace/[workspaceId]/home/types' +import type { ChatResourceAddress } from '@/lib/mothership/resources/types' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' /** * Resource-management operations for the Mothership resource panel. Provided @@ -18,11 +16,7 @@ interface MothershipResourcesContextValue { /** Adds a resource to the panel and activates it. */ addResource: (resource: MothershipResource) => void /** Removes a resource from the panel. */ - removeResource: ( - resourceType: MothershipResourceType, - resourceId: string, - workspaceId?: string - ) => void + removeResource: (resource: ChatResourceAddress) => void /** Replaces the resource list with a new ordering. */ reorderResources: (resources: MothershipResource[]) => void /** Collapses the resource panel. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index 3eafad11021..b2223026cdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts @@ -1,5 +1,5 @@ export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown' -export { ResourceActions, ResourceContent } from './resource-content' +export { EmbeddedProjectFile, ResourceActions, ResourceContent } from './resource-content' export type { ResourceTypeConfig } from './resource-registry' export { getResourceConfig, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts index 9dd2b9e9da3..48a7e6131ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts @@ -1 +1 @@ -export { ResourceActions, ResourceContent } from './resource-content' +export { EmbeddedProjectFile, ResourceActions, ResourceContent } from './resource-content' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index ee06410f14c..f6bb90f4623 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -35,6 +35,7 @@ import type { FilePreviewSession } from '@/lib/mothership/request/session' import { stopRunToolForExecution } from '@/lib/mothership/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import { type FileDownloadSource, triggerFileDownload } from '@/lib/uploads/client/download' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file' import { FileViewer, @@ -67,6 +68,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useFolders } from '@/hooks/queries/folders' import { useLogByExecutionId, useLogDetail } from '@/hooks/queries/logs' +import { useProjectFile } from '@/hooks/queries/projects' import { exportTable } from '@/hooks/queries/tables' import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' @@ -74,7 +76,10 @@ import { mapWorkflow } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflowTest } from '@/hooks/queries/workflow-tests' import { useWorkflows, WORKFLOW_STATE_STALE_TIME } from '@/hooks/queries/workflows' import { useAddressedWorkspaceFileRecord, useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { createWorkspaceFileContentSource } from '@/hooks/use-file-content-source' +import { + createProjectFileContentSource, + createWorkspaceFileContentSource, +} from '@/hooks/use-file-content-source' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useExecutionStore } from '@/stores/execution/store' import { useTableViewPinStore } from '@/stores/table/view-pin/store' @@ -98,6 +103,16 @@ const LOADING_SKELETON = ( ) +const FILE_NOT_FOUND = ( +
+ +
+

File not found

+

This file may have been deleted or moved

+
+
+) + /** * Opens an internal app link the way the host expects: a new browser tab on the * web, and the current view in the desktop app, whose shell would otherwise turn @@ -945,19 +960,7 @@ function EmbeddedFile({ if (!file && (listLoading || detail.isFetching)) return LOADING_SKELETON - if (!file) { - return ( -
- -
-

File not found

-

- This file may have been deleted or moved -

-
-
- ) - } + if (!file) return FILE_NOT_FOUND return (
@@ -983,6 +986,44 @@ function EmbeddedFile({ ) } +interface EmbeddedProjectFileProps { + projectId: string + fileId: string +} + +/** + * Read-only view of a Project-owned file. Bytes come through the Project file routes, so the viewer + * never addresses a workspace with a Project id; editing stays on the Project surface. + */ +export function EmbeddedProjectFile({ projectId, fileId }: EmbeddedProjectFileProps) { + const { data: projectFile, isLoading } = useProjectFile(projectId, fileId) + const contentSource = useMemo( + () => createProjectFileContentSource(projectId, fileId), + [projectId, fileId] + ) + const file = useMemo( + () => projectFile && { ...projectFile, workspaceId: projectId }, + [projectFile, projectId] + ) + + if (isLoading) return LOADING_SKELETON + if (!file) return FILE_NOT_FOUND + + return ( +
+ +
+ ) +} + interface EmbeddedFolderProps { workspaceId: string folderId: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index e2cb65ed72d..f892938a589 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -460,7 +460,7 @@ export function ResourceTabs({ ) continue } - onRemoveResource(r.type, r.id, r.workspaceId) + onRemoveResource(r) if (r.type === 'browser') { sendBrowserPanelAction('close-tab', { tabId: r.id }, desktopScopeId) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index be794b83269..e36605cac7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -5,7 +5,10 @@ import { cn } from '@sim/emcn' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import type { MothershipTableViewContext } from '@/lib/api/contracts/mothership-resources' import type { FilePreviewSession } from '@/lib/mothership/request/session' -import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' +import { + getChatResourceSelectionId, + getChatResourceWorkspaceId, +} from '@/lib/mothership/resources/types' import type { FileDownloadSource } from '@/lib/uploads/client/download' import { getFileExtension } from '@/lib/uploads/utils/file' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' @@ -34,7 +37,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useUserPermissions } from '@/hooks/use-user-permissions' -import { ResourceActions, ResourceContent, ResourceTabs } from './components' +import { EmbeddedProjectFile, ResourceActions, ResourceContent, ResourceTabs } from './components' /** * Panels that are kept mounted across resource switches rather than rebuilt. @@ -130,7 +133,7 @@ export const MothershipView = memo( ref ) { const active = resources.find((r) => getChatResourceSelectionId(r) === activeResourceId) ?? null - const activeWorkspaceId = active?.workspaceId ?? workspaceId + const activeWorkspaceId = active ? getChatResourceWorkspaceId(active, workspaceId) : workspaceId const inheritedPermissions = useUserPermissionsContext() const permissions = useWorkspacePermissionsQuery(organizationId ? activeWorkspaceId : undefined) const scopedPermissions = useUserPermissions( @@ -206,6 +209,7 @@ export const MothershipView = memo( const isActiveFilePreviewable = canEdit && active?.type === 'file' && + Boolean(activeWorkspaceId) && // Dashboards store extensionless names, so the record's type decides once it loads. (activeFile ? isPreviewable(activeFile) @@ -316,7 +320,9 @@ export const MothershipView = memo( previewSession={previewForActive} isAgentResponding={isAgentResponding} previewContextKey={chatId} - onNotFound={(resourceId) => removeResource('log', resourceId, active.workspaceId)} + onNotFound={(resourceId) => + removeResource({ type: 'log', id: resourceId, workspaceId: active.workspaceId }) + } /> )} {!active && ( @@ -345,6 +351,10 @@ function ScopedResourceContent({ }) { if (props.resource.type === 'search') return + if (props.resource.type === 'file' && props.resource.owner?.entityType === 'project') + return ( + + ) if (!workspaceId) { if (props.resource.type === 'generic') return if (props.resource.type === 'browser') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 9cbebd085ea..7ecc6146c1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -351,7 +351,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { return otherResolved?.type === resolved.type && otherResolved.id === resolved.id }) if (stillReferenced) return - removeResource(resolved.type, resolved.id) + removeResource(resolved) } function openWorkspaceResource(resource: MothershipResource) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts index 2b2742fd60d..c2cf8544492 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts @@ -24,6 +24,6 @@ export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEven (item) => getChatResourceKey(item) === getChatResourceKey(streamedSearch) ) if (streamedSearch && !wasVisible) { - ctx.deps.removeResource('search', streamedSearch.id, streamedSearch.workspaceId) + ctx.deps.removeResource(streamedSearch) } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index e0f457806eb..a603f404210 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -85,7 +85,11 @@ describe('handleResourceEvent removal', () => { handleResourceEvent(ctx, removeEvent('workflow', 'wf-1')) - expect(deps.removeResource).toHaveBeenCalledWith('workflow', 'wf-1') + expect(deps.removeResource).toHaveBeenCalledWith({ + type: 'workflow', + id: 'wf-1', + title: 'wf-1', + }) expect(mocks.removeWorkflowFromActiveCache).toHaveBeenCalledWith( deps.queryClient, 'ws-1', diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 1930055f347..83b5590b7c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -29,6 +29,7 @@ import { mothershipChatKeys, refreshMothershipChatDetail, } from '@/hooks/queries/mothership-chats' +import { invalidateProjectFileQueries } from '@/hooks/queries/projects' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { useTableViewPinStore } from '@/stores/table/view-pin/store' @@ -189,6 +190,9 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven onResourceEvent?.(getChatResourceSelectionId(resource)) return } + const owner = 'owner' in payload.resource ? payload.resource.owner : undefined + if (owner?.entityType === 'project' && payload.resource.type === 'file' && payload.resource.id) + invalidateProjectFileQueries(queryClient, owner.entityId, payload.resource.id) const workspaceId = payload.resource.workspaceId ?? chatWorkspaceId if (!workspaceId || (chatWorkspaceId && workspaceId !== chatWorkspaceId)) return // Browser and terminal tabs are projected from the desktop app's live @@ -203,8 +207,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven if (payload.op === 'remove' && payload.effectId) { // The server applied this removal before publishing it, so a client add // still queued for the resource must not restore it, live or on recovery. - const { type, id } = payload.resource - ctx.deps.discardPendingResourceUpsert({ type, id, workspaceId: payload.resource.workspaceId }) + const { type, id, workspaceId: resourceWorkspaceId, owner } = payload.resource + ctx.deps.discardPendingResourceUpsert({ type, id, workspaceId: resourceWorkspaceId, owner }) } if (payload.replay || ctx.deps.options.deferFlushes) { refreshRecoveredChatResources(ctx) @@ -259,8 +263,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven ctx.deps.setActiveResourceId((current) => current === getChatResourceSelectionId(resource) ? null : current ) - } else if (resource.workspaceId) removeResource(resourceType, resource.id, resource.workspaceId) - else removeResource(resourceType, resource.id) + } else removeResource(resource) if (resourceType === 'workflow') { removeWorkflowFromActiveCache(queryClient, workspaceId, resource.id) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 270fa35fa2e..7dd84f357f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -5,7 +5,10 @@ import type { RevealedSimKeysByMessage } from '@/lib/mothership/chat/sim-key-red import { captureRevealedSimKeys } from '@/lib/mothership/chat/sim-key-redaction' import type { SyntheticFilePreviewPayload } from '@/lib/mothership/request/session' import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' -import type { MothershipResourceUpdate } from '@/lib/mothership/resources/types' +import type { + ChatResourceAddress, + MothershipResourceUpdate, +} from '@/lib/mothership/resources/types' import { createTurnModel, type TurnModel, @@ -20,7 +23,6 @@ import type { ChatMessage, ContentBlock, MothershipResource, - MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' import { type MothershipChatHistory, @@ -116,15 +118,9 @@ export interface StreamLoopDeps { setActiveResourceId: Dispatch> addResource: (resource: MothershipResourceUpdate) => boolean - removeResource: ( - resourceType: MothershipResourceType, - resourceId: string, - workspaceId?: string - ) => void + removeResource: (resource: ChatResourceAddress) => void /** Drops a queued client write that would re-add a resource the server already removed. */ - discardPendingResourceUpsert: ( - resource: Pick - ) => void + discardPendingResourceUpsert: (resource: ChatResourceAddress) => void startClientWorkflowTool: (id: string, name: string, args: Record) => void startClientLocalFilesystemTool: (id: string, name: string, args: Record) => void startClientComputerTool: (id: string, args: Record, ts?: string) => void diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx index c8c733515d7..d1b78592733 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx @@ -95,7 +95,10 @@ import { type MothershipStreamV1EventEnvelope, } from '@/lib/mothership/generated/mothership-stream-v1' import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' -import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' +import { + getChatResourceSelectionId, + type MothershipResource, +} from '@/lib/mothership/resources/types' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' import { ModelSelector } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector' @@ -938,6 +941,84 @@ describe('useChat remount send recovery', () => { }) }) + describe('closing a file tab', () => { + const projectFile: MothershipResource = { + type: 'file', + id: 'shared-file', + title: 'brief.md', + owner: { entityType: 'project', entityId: 'project-1' }, + } + const workspaceFile: MothershipResource = { + type: 'file', + id: 'shared-file', + title: 'notes.md', + workspaceId: 'ws-1', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + } + + /** Opens a chat holding `resources` and records each resource DELETE body. */ + function renderChatRecordingRemovals(resources: MothershipResource[], selected?: string) { + const history: MothershipChatHistory = { + id: 'chat-file-tabs', + title: 'Files', + messages: [], + activeStreamId: null, + resources, + } + const removals: unknown[] = [] + mockRequestJson.mockImplementation(async (contract: AnyApiRouteContract, input) => { + if (contract.path !== '/api/mothership/chat/resources') return { chats: [] } + removals.push((input as { body: unknown }).body) + return { resources: [] } + }) + const { getResult } = renderUseChatInChat(history.id, history, undefined, selected) + return { history, removals, getResult } + } + + it('closes a Project file tab by its owner and leaves a workspace file with the same id', async () => { + const { history, removals, getResult } = renderChatRecordingRemovals( + [projectFile, workspaceFile], + getChatResourceSelectionId(projectFile) + ) + + await act(async () => { + getResult().removeResource(projectFile) + await sleep(0) + }) + + expect(removals).toEqual([ + { + chatId: history.id, + resourceType: 'file', + resourceId: 'shared-file', + owner: { entityType: 'project', entityId: 'project-1' }, + }, + ]) + expect(getResult().resources).toEqual([workspaceFile]) + expect(getResult().activeResourceId).not.toBe(getChatResourceSelectionId(projectFile)) + }) + + it('closes a workspace file tab named only by type and id, deleting it as stored', async () => { + const { history, removals, getResult } = renderChatRecordingRemovals([workspaceFile]) + + await act(async () => { + getResult().removeResource({ type: 'file', id: 'shared-file' }) + await sleep(0) + }) + + expect(removals).toEqual([ + { + chatId: history.id, + resourceType: 'file', + resourceId: 'shared-file', + workspaceId: 'ws-1', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + }, + ]) + expect(getResult().resources).toEqual([]) + }) + }) + it('restores an explicitly selected Search tab while reconnecting an active turn', async () => { const shape = resolveDeploymentShape() seedDeploymentShape({ ...shape, features: shape.features }) 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 c8879bc4ca0..e8c28ad699d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -70,6 +70,7 @@ import type { FilePreviewSession } from '@/lib/mothership/request/session/file-p import { canDisplayResource } from '@/lib/mothership/resources/availability' import { ResourcePersistenceQueue } from '@/lib/mothership/resources/client-persistence-queue' import { + type ChatResourceAddress, chatResourceKeyFor, getChatResourceSelectionId, isAddressableResource, @@ -162,7 +163,6 @@ import type { ContentBlock, FileAttachmentForApi, MothershipResource, - MothershipResourceType, QueuedMessage, SendPayload, ToolCallInfo, @@ -319,11 +319,7 @@ export interface UseChatReturn { setActiveResourceId: (id: string | null) => void addResource: (resource: MothershipResourceUpdate) => boolean setTableViewContext: (tableId: string, context: MothershipTableViewContext) => void - removeResource: ( - resourceType: MothershipResourceType, - resourceId: string, - workspaceId?: string - ) => void + removeResource: (resource: ChatResourceAddress) => void reorderResources: (resources: MothershipResource[]) => void messageQueue: QueuedMessage[] removeFromQueue: (id: string) => void @@ -818,6 +814,8 @@ export function useChat( * make the tabs disappear for the desktop app too. */ const undisplayableResourcesRef = useRef([]) + const workspaceIdRef = useRef(workspaceId) + workspaceIdRef.current = workspaceId const resourcePersistenceQueueRef = useRef(null) const pendingResourceReordersRef = useRef | null>(null) const pendingResourceReorders = (pendingResourceReordersRef.current ??= new Map()) @@ -861,6 +859,7 @@ export function useChat( onError: (error) => { logger.warn('Failed to persist resource; will retry on next hydration', error) }, + chatWorkspaceId: () => workspaceIdRef.current, }) } const resourcePersistenceQueue = resourcePersistenceQueueRef.current @@ -1475,29 +1474,23 @@ export function useChat( ) const removeResource = useCallback( - (resourceType: MothershipResourceType, resourceId: string, resourceWorkspaceId?: string) => { - const matches = (resource: MothershipResource) => - resource.type === resourceType && - resource.id === resourceId && - resource.workspaceId === resourceWorkspaceId - if (resourceType === 'table') tableViewContextsRef.current.views.delete(resourceId) + (address: ChatResourceAddress) => { + const keyOf = chatResourceKeyFor(workspaceId) + const removedKey = keyOf(address) + const matches = (resource: MothershipResource) => keyOf(resource) === removedKey + const existing = resourcesRef.current.find(matches) + // The open tab's own address, so a caller that only knows `type` and `id` still removes, + // un-queues, and deletes the tab exactly as it was stored. + const removed: ChatResourceAddress = existing ?? address + const { type, id, workspaceId: resourceWorkspaceId, owner } = removed + if (type === 'table') tableViewContextsRef.current.views.delete(id) setResources((prev) => prev.filter((r) => !matches(r))) - setActiveResourceId((prev) => - prev === - getChatResourceSelectionId({ - type: resourceType, - id: resourceId, - workspaceId: resourceWorkspaceId, - title: '', - }) - ? null - : prev - ) + const removedSelectionId = getChatResourceSelectionId(removed) + setActiveResourceId((prev) => (prev === removedSelectionId ? null : prev)) // Ephemeral panels were never persisted; nothing to delete server-side. - if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return + if (isEphemeralResource(removed)) return - const existing = resourcesRef.current.find(matches) const persistChatId = chatIdRef.current ?? selectedChatIdRef.current const persistenceScopeId = persistChatId ?? pendingChatKeyRef.current if (persistChatId) { @@ -1513,11 +1506,9 @@ export function useChat( wasPending, wasPersisted, } = resourcePersistenceQueue.remove( - resourceType, - resourceId, + removed, persistenceScopeId, - Boolean(existing && persistChatId), - resourceWorkspaceId + Boolean(existing && persistChatId) ) if (wasPending && !inFlightAdd && !wasPersisted) return @@ -1526,20 +1517,21 @@ export function useChat( const result = await requestJson(removeMothershipChatResourceContract, { body: { chatId: persistChatId, - resourceType, - resourceId, + resourceType: type, + resourceId: id, workspaceId: resourceWorkspaceId, + owner, }, }) await refreshResourceHistory(persistChatId) return result }) }, - [queryClient, refreshResourceHistory, resourcePersistenceQueue] + [queryClient, refreshResourceHistory, resourcePersistenceQueue, workspaceId] ) const discardPendingResourceUpsert = useCallback( - ({ type, id, workspaceId }: Pick) => { + (address: ChatResourceAddress) => { // An add queued before the chat id resolved stays in the provisional // bucket until a flush adopts it, so both scopes may hold it. const scopes = new Set([ @@ -1547,7 +1539,7 @@ export function useChat( pendingChatKeyRef.current, ]) for (const scopeId of scopes) { - if (scopeId) resourcePersistenceQueue.discardUpsert(type, id, scopeId, workspaceId) + if (scopeId) resourcePersistenceQueue.discardUpsert(address, scopeId) } }, [resourcePersistenceQueue] @@ -1600,7 +1592,7 @@ export function useChat( error.status === 404 && (chatIdRef.current ?? selectedChatIdRef.current) === chatId ) - removeResource('workflow', resource.id, resource.workspaceId) + removeResource(resource) } } }) @@ -2133,7 +2125,7 @@ export function useChat( getChatResourceSelectionId(resource) !== selectedResourceIdRef.current && !resourcesRef.current.some((visible) => keyOf(visible) === keyOf(resource)) ) { - removeResource('search', resource.id, resource.workspaceId) + removeResource(resource) } } updatedResources = resourcePersistenceQueue.applyPendingUpdates( @@ -2171,8 +2163,7 @@ export function useChat( (r) => r.id !== 'streaming-file' && !serverKeys.has(keyOf(r)) && - (isEphemeralResource(r) || - resourcePersistenceQueue.hasPendingUpsert(chatHistory.id, r.type, r.id, r.workspaceId)) + (isEphemeralResource(r) || resourcePersistenceQueue.hasPendingUpsert(chatHistory.id, r)) ) // Server order is authoritative for persisted resources, but local-only // items (pending-persist adds and synthetic ephemeral panels) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts index 9a739dbda50..5f8ef729cc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources.ts @@ -1,5 +1,9 @@ import { useEffect, useMemo, useRef } from 'react' -import type { MothershipResource, MothershipResourceType } from '@/lib/mothership/resources/types' +import type { + ChatResourceAddress, + MothershipResource, + MothershipResourceType, +} from '@/lib/mothership/resources/types' import { terminalResourceId } from '@/lib/terminal/resource-id' import type { ResourceEventHandler } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import type { NativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/resource-view-policy' @@ -42,7 +46,7 @@ export interface DesktopTabResourceOptions { selectedResourceId: string | null /** Adds a tab without activating it; activation goes through {@link onResourceEvent}. */ addResource: (resource: MothershipResource) => void - removeResource: (resourceType: MothershipResourceType, resourceId: string) => void + removeResource: (resource: ChatResourceAddress) => void /** Explicit user selection, which claims the strip's selection for the user. */ selectResource: (resourceId: string) => void /** Agent activity on a tab, subject to the panel's user-ownership policy. */ @@ -167,7 +171,7 @@ export function useDesktopTabResources({ for (const tabId of known) { if (liveTabIds.has(tabId)) continue known.delete(tabId) - if (resourceTabIds.has(tabId)) removeResource(type, tabId) + if (resourceTabIds.has(tabId)) removeResource({ type, id: tabId }) } }, [addResource, hasSession, removeResource, resources, scopeId, tabs, type]) diff --git a/apps/sim/hooks/queries/projects.ts b/apps/sim/hooks/queries/projects.ts index 0a40e4fe65b..b3c2c32a374 100644 --- a/apps/sim/hooks/queries/projects.ts +++ b/apps/sim/hooks/queries/projects.ts @@ -1,14 +1,19 @@ -import { useInfiniteQuery } from '@tanstack/react-query' +import { type QueryClient, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' +import { getProjectFileContract } from '@/lib/api/contracts/project-files' import { listProjectsContract } from '@/lib/api/contracts/projects' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' const PROJECT_LIST_STALE_TIME = 30_000 +const PROJECT_FILE_STALE_TIME = 30_000 const projectKeys = { all: ['projects'] as const, lists: () => [...projectKeys.all, 'list'] as const, list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const, + files: () => [...projectKeys.all, 'file'] as const, + file: (projectId: string, fileId: string) => [...projectKeys.files(), projectId, fileId] as const, } /** Lists the caller's Projects; never requests while the `projects` rollout flag is off. */ @@ -33,3 +38,35 @@ export function useProjects(organizationId: string) { enabled: projectsEnabled && Boolean(organizationId), }) } + +/** One Project file's record; never requests while the `projects` rollout flag is off. */ +export function useProjectFile(projectId: string, fileId: string) { + const projectsEnabled = useFeatureFlag('projects') + return useQuery({ + queryKey: projectKeys.file(projectId, fileId), + queryFn: async ({ signal }) => { + const { file } = await requestJson(getProjectFileContract, { + params: { id: projectId, fileId }, + signal, + }) + return file + }, + staleTime: PROJECT_FILE_STALE_TIME, + enabled: projectsEnabled && Boolean(projectId) && Boolean(fileId), + }) +} + +/** + * Refetches an open Project file view after the agent changes the file: its record, and the bytes + * the file viewer caches under the Project id it uses in place of a workspace id. + */ +export function invalidateProjectFileQueries( + queryClient: QueryClient, + projectId: string, + fileId: string +): void { + void queryClient.invalidateQueries({ queryKey: projectKeys.file(projectId, fileId) }) + void queryClient.invalidateQueries({ + queryKey: workspaceFilesKeys.contentFile(projectId, fileId), + }) +} diff --git a/apps/sim/hooks/use-file-content-source.tsx b/apps/sim/hooks/use-file-content-source.tsx index f1586b8611b..bcedf42bfd6 100644 --- a/apps/sim/hooks/use-file-content-source.tsx +++ b/apps/sim/hooks/use-file-content-source.tsx @@ -1,6 +1,11 @@ 'use client' import { createContext, useContext } from 'react' +import { contractUrl } from '@/lib/api/client/request' +import { + readProjectFileArtifactContract, + readProjectFileContentContract, +} from '@/lib/api/contracts/project-files' import { type EmbeddedFileRef, resolveEmbeddedFileRef, @@ -142,6 +147,32 @@ export function createPublicFileContentSource( ) } +/** + * In-app source for one Project file, which has no workspace serve URL: bytes come from the Project + * file routes, and embedded images resolve only within the same Project. + */ +export function createProjectFileContentSource( + projectId: string, + fileId: string +): FileContentSource { + const params = { id: projectId, fileId } + return inlineImageSource( + (_key, opts) => + opts?.raw + ? contractUrl(readProjectFileContentContract, { params }) + : contractUrl(readProjectFileArtifactContract, { + params, + query: { + preview: opts?.preview ? '1' : undefined, + v: opts?.version == null ? undefined : String(opts.version), + t: opts?.version == null && opts?.bust ? String(Date.now()) : undefined, + }, + }), + `/api/projects/${encodeURIComponent(projectId)}/files/inline`, + { entityType: 'project', entityId: projectId } + ) +} + /** * Context default for components rendered outside a {@link FileContentSourceProvider}: serve URLs for * direct bytes, embeds passed through unchanged. The file viewer always provides a workspace- or diff --git a/apps/sim/lib/mothership/chat/resource-attachments.ts b/apps/sim/lib/mothership/chat/resource-attachments.ts index fa11f12ce4d..885d7cbe0ac 100644 --- a/apps/sim/lib/mothership/chat/resource-attachments.ts +++ b/apps/sim/lib/mothership/chat/resource-attachments.ts @@ -43,11 +43,7 @@ const GENERIC_RESOURCE_TITLE: Record = { * their page title and URL remain request context only. */ function isPersistableAttachment(resource: ResourceAttachment): boolean { - return !isEphemeralResource({ - type: resource.type, - id: resource.id, - title: resource.title ?? '', - }) + return !isEphemeralResource({ type: resource.type, id: resource.id }) } /** diff --git a/apps/sim/lib/mothership/resources/client-persistence-queue.test.ts b/apps/sim/lib/mothership/resources/client-persistence-queue.test.ts index e9a194dce1d..9a7f5205dd3 100644 --- a/apps/sim/lib/mothership/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/mothership/resources/client-persistence-queue.test.ts @@ -27,12 +27,12 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') await Promise.resolve() expect(queue.getPendingResourceKeys('chat-1').size).toBe(0) - expect(queue.hasPendingUpsert('chat-1', 'table', 'table-1')).toBe(true) - expect(queue.hasPendingUpsert('chat-2', 'table', 'table-1')).toBe(false) + expect(queue.hasPendingUpsert('chat-1', TABLE_RESOURCE)).toBe(true) + expect(queue.hasPendingUpsert('chat-2', TABLE_RESOURCE)).toBe(false) const settled = queue.flush('chat-1') write.resolve({ success: true }) await settled - expect(queue.hasPendingUpsert('chat-1', 'table', 'table-1')).toBe(false) + expect(queue.hasPendingUpsert('chat-1', TABLE_RESOURCE)).toBe(false) }) it('drains a newer update after the write for the same resource settles', async () => { @@ -66,14 +66,10 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue({ ...TABLE_RESOURCE, clearViewId: true }, 'chat-1', 'chat-1', saved) expect(queue.applyPendingUpdates('chat-1', [saved])).toEqual([TABLE_RESOURCE]) expect(queue.applyPendingUpdates('chat-2', [saved])).toEqual([saved]) - queue - .remove('table', TABLE_RESOURCE.id, 'chat-1') - .scheduleDelete('chat-1', () => removal.promise) + queue.remove(TABLE_RESOURCE, 'chat-1').scheduleDelete('chat-1', () => removal.promise) expect(queue.applyPendingUpdates('chat-1', [saved])).toEqual([]) write.resolve({ success: true }) - await vi.waitFor(() => - expect(queue.hasPendingUpsert('chat-1', 'table', TABLE_RESOURCE.id)).toBe(false) - ) + await vi.waitFor(() => expect(queue.hasPendingUpsert('chat-1', TABLE_RESOURCE)).toBe(false)) expect(queue.applyPendingUpdates('chat-1', [saved])).toEqual([]) removal.resolve({ success: true }) await queue.flush('chat-1') @@ -110,7 +106,7 @@ describe('ResourcePersistenceQueue', () => { const queue = new ResourcePersistenceQueue({ persist, onError }) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1') - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const removal = queue.remove(TABLE_RESOURCE, 'chat-1') removal.scheduleDelete('chat-1', remove) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1', 'chat-1') await Promise.resolve() @@ -138,7 +134,7 @@ describe('ResourcePersistenceQueue', () => { failed.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const removal = queue.remove(TABLE_RESOURCE, 'chat-1') expect(removal.wasPending).toBe(true) expect(removal.wasPersisted).toBe(true) const remove = vi.fn().mockResolvedValue({ success: true }) @@ -157,7 +153,7 @@ describe('ResourcePersistenceQueue', () => { failed.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const removal = queue.remove(TABLE_RESOURCE, 'chat-1') expect(removal.wasPending).toBe(true) expect(removal.wasPersisted).toBe(false) }) @@ -172,7 +168,7 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1', TABLE_RESOURCE) await Promise.allSettled(Array.from(queue.inFlight.values())) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const removal = queue.remove(TABLE_RESOURCE, 'chat-1') removal.scheduleDelete('chat-1', remove) await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) @@ -194,7 +190,7 @@ describe('ResourcePersistenceQueue', () => { .mockResolvedValueOnce({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const removal = queue.remove(TABLE_RESOURCE, 'chat-1') removal.scheduleDelete('chat-1', remove) first.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) @@ -261,7 +257,7 @@ describe('ResourcePersistenceQueue', () => { // The server still holds a resource the client has dropped, so an order // built from client state would not match and must wait for the delete. - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const removal = queue.remove(TABLE_RESOURCE, 'chat-1') removal.scheduleDelete('chat-1', remove) await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) @@ -327,14 +323,14 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1', TABLE_RESOURCE) await Promise.allSettled(queue.getInFlightWrites('chat-1')) - const firstRemoval = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const firstRemoval = queue.remove(TABLE_RESOURCE, 'chat-1') firstRemoval.scheduleDelete('chat-1', remove) await Promise.allSettled(queue.getInFlightWrites('chat-1')) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1', 'chat-1') await Promise.allSettled(queue.getInFlightWrites('chat-1')) - const secondRemoval = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + const secondRemoval = queue.remove(TABLE_RESOURCE, 'chat-1') expect(secondRemoval.wasPending).toBe(true) expect(secondRemoval.wasPersisted).toBe(true) }) @@ -347,7 +343,7 @@ it('persists same-named cross-workspace resources independently and deletes only const second = { ...TABLE_RESOURCE, workspaceId: 'b' } queue.enqueue(first, undefined, 'pending') queue.enqueue(second, undefined, 'pending') - queue.remove(second.type, second.id, 'pending', false, 'b') + queue.remove(second, 'pending') await queue.flush('chat', 'pending') expect(persist.mock.calls).toEqual([['chat', first]]) }) diff --git a/apps/sim/lib/mothership/resources/client-persistence-queue.ts b/apps/sim/lib/mothership/resources/client-persistence-queue.ts index 43d0340a485..c7af0bdd2b6 100644 --- a/apps/sim/lib/mothership/resources/client-persistence-queue.ts +++ b/apps/sim/lib/mothership/resources/client-persistence-queue.ts @@ -1,4 +1,8 @@ -import type { MothershipResource, MothershipResourceUpdate } from '@/lib/mothership/resources/types' +import type { + ChatResourceAddress, + MothershipResource, + MothershipResourceUpdate, +} from '@/lib/mothership/resources/types' import { getChatResourceKey, mergeChatResource, @@ -8,6 +12,8 @@ import { interface ResourcePersistenceQueueOptions { persist: (chatId: string, update: MothershipResourceUpdate) => Promise onError: (error: unknown) => void + /** The chat's workspace, so an unscoped write and one addressed by that workspace share a key. */ + chatWorkspaceId?: () => string | undefined } const QUEUE_KEY_SEPARATOR = '\u0000' @@ -37,10 +43,12 @@ export class ResourcePersistenceQueue { private readonly writeTokens = new Map() private readonly persist: ResourcePersistenceQueueOptions['persist'] private readonly onError: ResourcePersistenceQueueOptions['onError'] + private readonly chatWorkspaceId: ResourcePersistenceQueueOptions['chatWorkspaceId'] - constructor({ persist, onError }: ResourcePersistenceQueueOptions) { + constructor({ persist, onError, chatWorkspaceId }: ResourcePersistenceQueueOptions) { this.persist = persist this.onError = onError + this.chatWorkspaceId = chatWorkspaceId } /** @@ -56,7 +64,7 @@ export class ResourcePersistenceQueue { scopeId: string, base?: MothershipResource ): void { - const key = this.getKey(scopeId, update.type, update.id, update.workspaceId) + const key = this.getKey(scopeId, update) const trackedLocally = this.desiredUpdates.has(key) || this.pendingKeys.has(key) || this.inFlight.has(key) if (base && !trackedLocally) this.persistedKeys.add(key) @@ -87,13 +95,11 @@ export class ResourcePersistenceQueue { } remove( - type: MothershipResource['type'], - id: string, + resource: ChatResourceAddress, scopeId: string, - assumePersisted = false, - workspaceId?: string + assumePersisted = false ): RemovedResourcePersistence { - const key = this.getKey(scopeId, type, id, workspaceId) + const key = this.getKey(scopeId, resource) const trackedLocally = this.desiredUpdates.has(key) || this.pendingKeys.has(key) || this.inFlight.has(key) if (assumePersisted && !trackedLocally) this.persistedKeys.add(key) @@ -127,13 +133,8 @@ export class ResourcePersistenceQueue { * server history resurrects it. A removal the user queued still runs, and a * write already in flight is left to its own bookkeeping. */ - discardUpsert( - type: MothershipResource['type'], - id: string, - scopeId: string, - workspaceId?: string - ): void { - const key = this.getKey(scopeId, type, id, workspaceId) + discardUpsert(resource: ChatResourceAddress, scopeId: string): void { + const key = this.getKey(scopeId, resource) if (this.pendingRemovals.has(key) || this.inFlight.has(key)) return this.desiredUpdates.delete(key) this.failedKeys.delete(key) @@ -158,13 +159,8 @@ export class ResourcePersistenceQueue { } /** Keeps an optimistic add visible while its write is pending or in flight. */ - hasPendingUpsert( - scopeId: string, - type: MothershipResource['type'], - id: string, - workspaceId?: string - ): boolean { - return this.desiredUpdates.has(this.getKey(scopeId, type, id, workspaceId)) + hasPendingUpsert(scopeId: string, resource: ChatResourceAddress): boolean { + return this.desiredUpdates.has(this.getKey(scopeId, resource)) } /** Projects writes still in flight over server history, including queued removals. */ @@ -177,7 +173,7 @@ export class ResourcePersistenceQueue { ) const projected: MothershipResource[] = [] for (const resource of resources) { - const key = this.getKey(scopeId, resource.type, resource.id, resource.workspaceId) + const key = this.getKey(scopeId, resource) if (this.pendingRemovals.has(key)) continue const update = remaining.get(key) projected.push(update ? mergeChatResource(resource, update) : resource) @@ -332,12 +328,7 @@ export class ResourcePersistenceQueue { return `${scopeId}${QUEUE_KEY_SEPARATOR}` } - private getKey( - scopeId: string, - type: MothershipResource['type'], - id: string, - workspaceId?: string - ): string { - return `${this.getScopePrefix(scopeId)}${getChatResourceKey({ type, id, workspaceId })}` + private getKey(scopeId: string, resource: ChatResourceAddress): string { + return `${this.getScopePrefix(scopeId)}${getChatResourceKey(resource, this.chatWorkspaceId?.())}` } } diff --git a/apps/sim/lib/mothership/resources/types.ts b/apps/sim/lib/mothership/resources/types.ts index 05f5efb5ae4..b5fa4c2b291 100644 --- a/apps/sim/lib/mothership/resources/types.ts +++ b/apps/sim/lib/mothership/resources/types.ts @@ -115,13 +115,16 @@ export function normalizeChatResource( } } +/** The fields that identify a chat tab; everything else on a resource is display metadata. */ +export type ChatResourceAddress = Pick + /** * Tab identity, shared by the server store and the browser. Scope is part of identity for aliases * such as integration names and folder paths. The chat's workspace is the fallback scope, so an * unscoped tab and the same tab addressed by that workspace are one tab on both sides. */ export function getChatResourceKey( - resource: Pick, + resource: ChatResourceAddress, chatWorkspaceId?: string ): string { if (resource.owner && resource.owner.entityType !== 'workspace') @@ -140,12 +143,12 @@ export function getChatResourceKey( /** {@link getChatResourceKey} bound to one chat's workspace, for comparing that chat's tabs. */ export function chatResourceKeyFor( chatWorkspaceId?: string -): (resource: Pick) => string { +): (resource: ChatResourceAddress) => string { return (resource) => getChatResourceKey(resource, chatWorkspaceId) } /** UUID resources keep their existing deep links; workspace aliases need their owner in the URL. */ -export function getChatResourceSelectionId(resource: MothershipResource): string { +export function getChatResourceSelectionId(resource: ChatResourceAddress): string { if (resource.owner && resource.owner.entityType !== 'workspace') return getChatResourceKey(resource) const canonicalId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( @@ -218,7 +221,7 @@ export function isDesktopOnlyResource(resource: MothershipResource): boolean { return RESOURCE_POLICY[resource.type]?.desktopOnly === true } -export function isEphemeralResource(resource: MothershipResource): boolean { +export function isEphemeralResource(resource: Pick): boolean { // The in-flight file preview is a placeholder that becomes a real file once // the write lands, so persisting it would restore a tab for a file that was // never created.