Skip to content

Commit 962786f

Browse files
authored
fix(chat): open and close Project file tabs by their owner (#9160)
* fix(chat): open and close Project file tabs by their owner Closing a Project-owned file tab dropped its owner, so the removal request never matched the server's owner-keyed tab and a history refresh restored it. Tab removal, the persistence queue, and the selection reset now take one ChatResourceAddress and match it through getChatResourceKey; the DELETE body carries the owner. Workspace tabs key exactly as before. A Project file tab borrowed the chat's workspace and showed 'File not found' or 'no workspace address'. It now renders a read-only FileViewer fed by the Project file routes, with no workspace-only tab actions. * fix(chat): remove tabs by their stored address and refresh open Project files Tab removal matches through the chat-scoped key that adding uses, then removes, un-queues, and deletes the stored tab, so a caller that names a workspace tab by type and id alone deletes it with its workspace. The persistence queue keys with the same chat-workspace fallback. A Project file upsert in the stream now refetches the open Project file view's record and bytes. Also drops a stale title argument to isEphemeralResource.
1 parent 99b6de7 commit 962786f

20 files changed

Lines changed: 325 additions & 147 deletions

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-resources-context/mothership-resources-context.tsx‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
'use client'
22

33
import { createContext, type ReactNode, useContext, useMemo } from 'react'
4-
import type {
5-
MothershipResource,
6-
MothershipResourceType,
7-
} from '@/app/workspace/[workspaceId]/home/types'
4+
import type { ChatResourceAddress } from '@/lib/mothership/resources/types'
5+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
86

97
/**
108
* Resource-management operations for the Mothership resource panel. Provided
@@ -18,11 +16,7 @@ interface MothershipResourcesContextValue {
1816
/** Adds a resource to the panel and activates it. */
1917
addResource: (resource: MothershipResource) => void
2018
/** Removes a resource from the panel. */
21-
removeResource: (
22-
resourceType: MothershipResourceType,
23-
resourceId: string,
24-
workspaceId?: string
25-
) => void
19+
removeResource: (resource: ChatResourceAddress) => void
2620
/** Replaces the resource list with a new ordering. */
2721
reorderResources: (resources: MothershipResource[]) => void
2822
/** Collapses the resource panel. */

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown'
2-
export { ResourceActions, ResourceContent } from './resource-content'
2+
export { EmbeddedProjectFile, ResourceActions, ResourceContent } from './resource-content'
33
export type { ResourceTypeConfig } from './resource-registry'
44
export {
55
getResourceConfig,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { ResourceActions, ResourceContent } from './resource-content'
1+
export { EmbeddedProjectFile, ResourceActions, ResourceContent } from './resource-content'

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx‎

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type { FilePreviewSession } from '@/lib/mothership/request/session'
3535
import { stopRunToolForExecution } from '@/lib/mothership/tools/client/run-tool-execution'
3636
import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils'
3737
import { type FileDownloadSource, triggerFileDownload } from '@/lib/uploads/client/download'
38+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
3839
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file'
3940
import {
4041
FileViewer,
@@ -67,14 +68,18 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
6768
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
6869
import { useFolders } from '@/hooks/queries/folders'
6970
import { useLogByExecutionId, useLogDetail } from '@/hooks/queries/logs'
71+
import { useProjectFile } from '@/hooks/queries/projects'
7072
import { exportTable } from '@/hooks/queries/tables'
7173
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
7274
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
7375
import { mapWorkflow } from '@/hooks/queries/utils/workflow-list-query'
7476
import { useWorkflowTest } from '@/hooks/queries/workflow-tests'
7577
import { useWorkflows, WORKFLOW_STATE_STALE_TIME } from '@/hooks/queries/workflows'
7678
import { useAddressedWorkspaceFileRecord, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
77-
import { createWorkspaceFileContentSource } from '@/hooks/use-file-content-source'
79+
import {
80+
createProjectFileContentSource,
81+
createWorkspaceFileContentSource,
82+
} from '@/hooks/use-file-content-source'
7883
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
7984
import { useExecutionStore } from '@/stores/execution/store'
8085
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
@@ -98,6 +103,16 @@ const LOADING_SKELETON = (
98103
</div>
99104
)
100105

106+
const FILE_NOT_FOUND = (
107+
<div className='flex h-full flex-col items-center justify-center gap-3'>
108+
<FileX className='size-[32px] text-[var(--text-icon)]' />
109+
<div className='flex flex-col items-center gap-1'>
110+
<h2 className='text-[20px] text-[var(--text-primary)]'>File not found</h2>
111+
<p className='text-[var(--text-body)] text-small'>This file may have been deleted or moved</p>
112+
</div>
113+
</div>
114+
)
115+
101116
/**
102117
* Opens an internal app link the way the host expects: a new browser tab on the
103118
* web, and the current view in the desktop app, whose shell would otherwise turn
@@ -945,19 +960,7 @@ function EmbeddedFile({
945960

946961
if (!file && (listLoading || detail.isFetching)) return LOADING_SKELETON
947962

948-
if (!file) {
949-
return (
950-
<div className='flex h-full flex-col items-center justify-center gap-3'>
951-
<FileX className='size-[32px] text-[var(--text-icon)]' />
952-
<div className='flex flex-col items-center gap-1'>
953-
<h2 className='text-[20px] text-[var(--text-primary)]'>File not found</h2>
954-
<p className='text-[var(--text-body)] text-small'>
955-
This file may have been deleted or moved
956-
</p>
957-
</div>
958-
</div>
959-
)
960-
}
963+
if (!file) return FILE_NOT_FOUND
961964

962965
return (
963966
<div className='flex h-full flex-col overflow-hidden'>
@@ -983,6 +986,44 @@ function EmbeddedFile({
983986
)
984987
}
985988

989+
interface EmbeddedProjectFileProps {
990+
projectId: string
991+
fileId: string
992+
}
993+
994+
/**
995+
* Read-only view of a Project-owned file. Bytes come through the Project file routes, so the viewer
996+
* never addresses a workspace with a Project id; editing stays on the Project surface.
997+
*/
998+
export function EmbeddedProjectFile({ projectId, fileId }: EmbeddedProjectFileProps) {
999+
const { data: projectFile, isLoading } = useProjectFile(projectId, fileId)
1000+
const contentSource = useMemo(
1001+
() => createProjectFileContentSource(projectId, fileId),
1002+
[projectId, fileId]
1003+
)
1004+
const file = useMemo<WorkspaceFileRecord | undefined>(
1005+
() => projectFile && { ...projectFile, workspaceId: projectId },
1006+
[projectFile, projectId]
1007+
)
1008+
1009+
if (isLoading) return LOADING_SKELETON
1010+
if (!file) return FILE_NOT_FOUND
1011+
1012+
return (
1013+
<div className='flex h-full flex-col overflow-hidden'>
1014+
<FileViewer
1015+
key={file.id}
1016+
file={file}
1017+
workspaceId={projectId}
1018+
contentSource={contentSource}
1019+
canEdit={false}
1020+
readOnly
1021+
enableFind
1022+
/>
1023+
</div>
1024+
)
1025+
}
1026+
9861027
interface EmbeddedFolderProps {
9871028
workspaceId: string
9881029
folderId: string

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export function ResourceTabs({
460460
)
461461
continue
462462
}
463-
onRemoveResource(r.type, r.id, r.workspaceId)
463+
onRemoveResource(r)
464464
if (r.type === 'browser') {
465465
sendBrowserPanelAction('close-tab', { tabId: r.id }, desktopScopeId)
466466
}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { cn } from '@sim/emcn'
55
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
66
import type { MothershipTableViewContext } from '@/lib/api/contracts/mothership-resources'
77
import type { FilePreviewSession } from '@/lib/mothership/request/session'
8-
import { getChatResourceSelectionId } from '@/lib/mothership/resources/types'
8+
import {
9+
getChatResourceSelectionId,
10+
getChatResourceWorkspaceId,
11+
} from '@/lib/mothership/resources/types'
912
import type { FileDownloadSource } from '@/lib/uploads/client/download'
1013
import { getFileExtension } from '@/lib/uploads/utils/file'
1114
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
@@ -34,7 +37,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
3437
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
3538
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
3639
import { useUserPermissions } from '@/hooks/use-user-permissions'
37-
import { ResourceActions, ResourceContent, ResourceTabs } from './components'
40+
import { EmbeddedProjectFile, ResourceActions, ResourceContent, ResourceTabs } from './components'
3841

3942
/**
4043
* Panels that are kept mounted across resource switches rather than rebuilt.
@@ -130,7 +133,7 @@ export const MothershipView = memo(
130133
ref
131134
) {
132135
const active = resources.find((r) => getChatResourceSelectionId(r) === activeResourceId) ?? null
133-
const activeWorkspaceId = active?.workspaceId ?? workspaceId
136+
const activeWorkspaceId = active ? getChatResourceWorkspaceId(active, workspaceId) : workspaceId
134137
const inheritedPermissions = useUserPermissionsContext()
135138
const permissions = useWorkspacePermissionsQuery(organizationId ? activeWorkspaceId : undefined)
136139
const scopedPermissions = useUserPermissions(
@@ -206,6 +209,7 @@ export const MothershipView = memo(
206209
const isActiveFilePreviewable =
207210
canEdit &&
208211
active?.type === 'file' &&
212+
Boolean(activeWorkspaceId) &&
209213
// Dashboards store extensionless names, so the record's type decides once it loads.
210214
(activeFile
211215
? isPreviewable(activeFile)
@@ -316,7 +320,9 @@ export const MothershipView = memo(
316320
previewSession={previewForActive}
317321
isAgentResponding={isAgentResponding}
318322
previewContextKey={chatId}
319-
onNotFound={(resourceId) => removeResource('log', resourceId, active.workspaceId)}
323+
onNotFound={(resourceId) =>
324+
removeResource({ type: 'log', id: resourceId, workspaceId: active.workspaceId })
325+
}
320326
/>
321327
)}
322328
{!active && (
@@ -345,6 +351,10 @@ function ScopedResourceContent({
345351
}) {
346352
if (props.resource.type === 'search')
347353
return <SearchResourceContent resource={props.resource} onSummarize={onSummarize} />
354+
if (props.resource.type === 'file' && props.resource.owner?.entityType === 'project')
355+
return (
356+
<EmbeddedProjectFile projectId={props.resource.owner.entityId} fileId={props.resource.id} />
357+
)
348358
if (!workspaceId) {
349359
if (props.resource.type === 'generic') return <GenericResourceContent />
350360
if (props.resource.type === 'browser')

‎apps/sim/app/workspace/[workspaceId]/home/home.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) {
351351
return otherResolved?.type === resolved.type && otherResolved.id === resolved.id
352352
})
353353
if (stillReferenced) return
354-
removeResource(resolved.type, resolved.id)
354+
removeResource(resolved)
355355
}
356356

357357
function openWorkspaceResource(resource: MothershipResource) {

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEven
2424
(item) => getChatResourceKey(item) === getChatResourceKey(streamedSearch)
2525
)
2626
if (streamedSearch && !wasVisible) {
27-
ctx.deps.removeResource('search', streamedSearch.id, streamedSearch.workspaceId)
27+
ctx.deps.removeResource(streamedSearch)
2828
}
2929
}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ describe('handleResourceEvent removal', () => {
8585

8686
handleResourceEvent(ctx, removeEvent('workflow', 'wf-1'))
8787

88-
expect(deps.removeResource).toHaveBeenCalledWith('workflow', 'wf-1')
88+
expect(deps.removeResource).toHaveBeenCalledWith({
89+
type: 'workflow',
90+
id: 'wf-1',
91+
title: 'wf-1',
92+
})
8993
expect(mocks.removeWorkflowFromActiveCache).toHaveBeenCalledWith(
9094
deps.queryClient,
9195
'ws-1',

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
mothershipChatKeys,
3030
refreshMothershipChatDetail,
3131
} from '@/hooks/queries/mothership-chats'
32+
import { invalidateProjectFileQueries } from '@/hooks/queries/projects'
3233
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
3334
import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache'
3435
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
@@ -189,6 +190,9 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
189190
onResourceEvent?.(getChatResourceSelectionId(resource))
190191
return
191192
}
193+
const owner = 'owner' in payload.resource ? payload.resource.owner : undefined
194+
if (owner?.entityType === 'project' && payload.resource.type === 'file' && payload.resource.id)
195+
invalidateProjectFileQueries(queryClient, owner.entityId, payload.resource.id)
192196
const workspaceId = payload.resource.workspaceId ?? chatWorkspaceId
193197
if (!workspaceId || (chatWorkspaceId && workspaceId !== chatWorkspaceId)) return
194198
// Browser and terminal tabs are projected from the desktop app's live
@@ -203,8 +207,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
203207
if (payload.op === 'remove' && payload.effectId) {
204208
// The server applied this removal before publishing it, so a client add
205209
// still queued for the resource must not restore it, live or on recovery.
206-
const { type, id } = payload.resource
207-
ctx.deps.discardPendingResourceUpsert({ type, id, workspaceId: payload.resource.workspaceId })
210+
const { type, id, workspaceId: resourceWorkspaceId, owner } = payload.resource
211+
ctx.deps.discardPendingResourceUpsert({ type, id, workspaceId: resourceWorkspaceId, owner })
208212
}
209213
if (payload.replay || ctx.deps.options.deferFlushes) {
210214
refreshRecoveredChatResources(ctx)
@@ -259,8 +263,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
259263
ctx.deps.setActiveResourceId((current) =>
260264
current === getChatResourceSelectionId(resource) ? null : current
261265
)
262-
} else if (resource.workspaceId) removeResource(resourceType, resource.id, resource.workspaceId)
263-
else removeResource(resourceType, resource.id)
266+
} else removeResource(resource)
264267
if (resourceType === 'workflow') {
265268
removeWorkflowFromActiveCache(queryClient, workspaceId, resource.id)
266269
}

0 commit comments

Comments
 (0)