Skip to content

Commit d66d69d

Browse files
committed
feat(files): restore Project file browser and Mothership integration
1 parent 02cd2f6 commit d66d69d

232 files changed

Lines changed: 14709 additions & 2032 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/_shell/paste-admission-guard.tsx‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
44
import { useToast } from '@sim/emcn'
55
import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste'
66
import { readSelectionContextFromClipboard } from '@/lib/mothership/chat/selection-clipboard'
7+
import { FileOperationOwner } from '@/lib/mothership/generated/file-owner'
78

89
const EDITABLE_TARGET_SELECTOR =
910
'input:not([type="file"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="hidden"]), textarea, [contenteditable]:not([contenteditable="false"]), .monaco-editor, .xterm'
@@ -23,6 +24,27 @@ function clipboardHasImageFile(data: DataTransfer | null): boolean {
2324
)
2425
}
2526

27+
function selectionOwnerHints(element: Element | null): FileOperationOwner[] {
28+
const encoded = element?.getAttribute('data-paste-selection-owners')
29+
if (encoded !== null && encoded !== undefined) {
30+
try {
31+
const parsed: unknown = JSON.parse(encoded)
32+
if (!Array.isArray(parsed)) return []
33+
const owners: FileOperationOwner[] = []
34+
for (const candidate of parsed) {
35+
const owner = FileOperationOwner.safeParse(candidate)
36+
if (!owner.success) return []
37+
owners.push(owner.data)
38+
}
39+
return owners
40+
} catch {
41+
return []
42+
}
43+
}
44+
const workspaceId = element?.getAttribute('data-paste-selection-context')
45+
return workspaceId ? [{ entityType: 'workspace', entityId: workspaceId }] : []
46+
}
47+
2648
/**
2749
* Last-resort admission for every editable workspace surface. Specialized editors publish their
2850
* downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a
@@ -42,13 +64,15 @@ export function PasteAdmissionGuard() {
4264
return
4365
}
4466

45-
const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]')
46-
const destinationWorkspaceId = acceptsSelectionContext?.getAttribute(
47-
'data-paste-selection-context'
67+
const acceptsSelectionContext = event.target.closest(
68+
'[data-paste-selection-owners], [data-paste-selection-context]'
4869
)
4970
if (
50-
destinationWorkspaceId &&
51-
readSelectionContextFromClipboard(event.clipboardData, destinationWorkspaceId)
71+
acceptsSelectionContext &&
72+
readSelectionContextFromClipboard(
73+
event.clipboardData,
74+
selectionOwnerHints(acceptsSelectionContext)
75+
)
5276
) {
5377
return
5478
}

‎apps/sim/app/api/copilot/chat/queries.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
createInternalServerErrorResponse,
2121
createUnauthorizedResponse,
2222
} from '@/lib/mothership/request/http'
23+
import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation'
2324
import {
2425
assertActiveWorkspaceAccess,
2526
isWorkspaceAccessDeniedError,
@@ -49,7 +50,11 @@ function transformChat(chat: {
4950
effort: chat.effort ?? null,
5051
...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}),
5152
...('resources' in chat
52-
? { resources: Array.isArray(chat.resources) ? chat.resources : [] }
53+
? {
54+
resources: Array.isArray(chat.resources)
55+
? chat.resources.map(presentChatResourceForBrowser)
56+
: [],
57+
}
5358
: {}),
5459
createdAt: chat.createdAt,
5560
updatedAt: chat.updatedAt,

‎apps/sim/app/api/copilot/chat/resources/route.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import {
55
} from '@/lib/api/contracts/copilot'
66
import {
77
defineInternalJsonRoute,
8-
internalJsonPresenters,
98
internalOrchestrationErrorPolicy,
109
internalRateLimits,
1110
internalSessionAuth,
1211
} from '@/lib/api/server/routes'
1312
import { changeChatResources } from '@/lib/mothership/chat/application/change-resources'
13+
import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation'
14+
import type { MothershipResource } from '@/lib/mothership/resources/types'
1415

1516
const policy = {
1617
auth: internalSessionAuth,
@@ -20,7 +21,10 @@ const policy = {
2021
}),
2122
errorPolicy: internalOrchestrationErrorPolicy,
2223
useCase: changeChatResources,
23-
present: internalJsonPresenters.withSuccess,
24+
present: ({ resources }: { resources: MothershipResource[] }) => ({
25+
success: true as const,
26+
resources: resources.map(presentChatResourceForBrowser),
27+
}),
2428
}
2529

2630
export const POST = defineInternalJsonRoute({
@@ -53,7 +57,14 @@ export const DELETE = defineInternalJsonRoute({
5357
chatId: body.chatId,
5458
change: {
5559
kind: 'remove' as const,
56-
resources: [{ type: body.resourceType, id: body.resourceId, workspaceId: body.workspaceId }],
60+
resources: [
61+
{
62+
type: body.resourceType,
63+
id: body.resourceId,
64+
workspaceId: body.workspaceId,
65+
owner: body.owner,
66+
},
67+
],
5768
},
5869
}),
5970
})

‎apps/sim/app/api/mothership/chats/[chatId]/route.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
createInternalServerErrorResponse,
3030
createUnauthorizedResponse,
3131
} from '@/lib/mothership/request/http'
32+
import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation'
3233
import { captureServerEvent } from '@/lib/posthog/server'
3334

3435
const logger = createLogger('MothershipChatAPI')
@@ -107,7 +108,9 @@ export const GET = withRouteHandler(
107108
mode: chat.mode,
108109
messages: effectiveMessages,
109110
activeStreamId: liveStreamId,
110-
resources: Array.isArray(chat.resources) ? chat.resources : [],
111+
resources: Array.isArray(chat.resources)
112+
? chat.resources.map(presentChatResourceForBrowser)
113+
: [],
111114
effort: chat.effort,
112115
olderCursor: chat.olderCursor,
113116
windowStartCursor: chat.windowStartCursor,

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => ({
5151
vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
5252
vi.mock('@/hooks/queries/workspace', () => ({
5353
useWorkspacesQuery: () => ({ data: mocks.workspaces }),
54+
useOrderedWorkspacesQuery: () => ({ data: mocks.workspaces }),
5455
}))
5556
vi.mock('@/hooks/queries/skills', () => ({
5657
useSkills: () => ({ data: [] }),
@@ -240,6 +241,7 @@ async function render(
240241
changelog: false,
241242
'mothership-model-selector': mocks.advanced,
242243
'mothership-plan-mode': mocks.plan,
244+
projects: false,
243245
}}
244246
>
245247
<Harness />
@@ -319,6 +321,7 @@ it.each([
319321
changelog: false,
320322
'mothership-model-selector': false,
321323
'mothership-plan-mode': planEnabled,
324+
projects: false,
322325
}}
323326
>
324327
<Harness />
@@ -415,6 +418,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
415418
changelog: false,
416419
'mothership-model-selector': mocks.advanced,
417420
'mothership-plan-mode': mocks.plan,
421+
projects: false,
418422
}}
419423
>
420424
<Harness />

‎apps/sim/app/o/[organizationId]/home/organization-home.test.tsx‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @vitest-environment jsdom */
22

33
import { act, type ComponentProps, type ReactNode } from 'react'
4+
import { toast } from '@sim/emcn'
45
import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
56
import {
67
createMockDeploymentShape,
@@ -9,7 +10,6 @@ import {
910
} from '@sim/testing/mocks/deployment-shape.mock'
1011
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
1112
import { kbConnectorsQueriesMock } from '@sim/testing/mocks/kb-connectors-queries.mock'
12-
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
1313
import {
1414
organizationProviderMock,
1515
organizationProviderMockFns,
@@ -24,6 +24,7 @@ import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/st
2424

2525
const mocks = vi.hoisted(() => ({
2626
plan: false,
27+
projects: true,
2728
resourcePanel: vi.fn(),
2829
chat: vi.fn(),
2930
composer: vi.fn(),
@@ -40,11 +41,15 @@ const mocks = vi.hoisted(() => ({
4041
activeResource: null as string | null,
4142
}))
4243
vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () => ({
43-
useFeatureFlag: (name: string) => (name === 'mothership-plan-mode' ? mocks.plan : false),
44+
useFeatureFlag: (name: string) =>
45+
name === 'mothership-plan-mode' ? mocks.plan : name === 'projects' ? mocks.projects : false,
4446
}))
4547
vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
4648
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
47-
vi.mock('next/navigation', () => nextNavigationMock)
49+
vi.mock(
50+
'next/navigation',
51+
async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
52+
)
4853
vi.mock('@tanstack/react-query', () => reactQueryMock)
4954
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({
5055
useResourcePanelController: () => ({
@@ -103,6 +108,7 @@ let root: Root
103108
let container: HTMLDivElement
104109
beforeEach(() => {
105110
useMothershipDraftsStore.setState({ drafts: {} })
111+
mocks.projects = true
106112
mocks.plan = false
107113
mocks.activeResource = null
108114
mockSession.mockReturnValue({ data: { user: { id: 'reader' } } })
@@ -224,3 +230,28 @@ it('isolates saved drafts by user, organization, and conversation', async () =>
224230
await act(async () => renderHome(<OrganizationHome chatId='chat-a' requestMode='assistant' />))
225231
expect(composerProps().value).toBe('')
226232
})
233+
234+
it.each([false])('does not look up saved Project references with projects=%s', async (projects) => {
235+
mocks.projects = projects
236+
vi.spyOn(toast, 'error').mockReturnValue('lookup-error')
237+
const requests: unknown[] = []
238+
const resources: unknown[] = []
239+
vi.stubGlobal('fetch', async (input: unknown) => {
240+
requests.push(input)
241+
throw new Error('Project lookup is disabled')
242+
})
243+
mocks.addResource.mockImplementation((resource: unknown) => resources.push(resource))
244+
await act(async () => renderHome(<OrganizationHome chatId='chat-a' />))
245+
const selectResource = mocks.renderer.mock.lastCall?.[0].onWorkspaceResourceSelect
246+
if (!selectResource) throw new Error('Chat resource selection is unavailable')
247+
await act(async () =>
248+
selectResource({
249+
type: 'file',
250+
id: 'file-a',
251+
title: 'Saved Project file',
252+
owner: { entityType: 'project', entityId: 'project-a' },
253+
})
254+
)
255+
expect(requests).toEqual([])
256+
expect(resources).toEqual([])
257+
})

‎apps/sim/app/o/[organizationId]/home/organization-home.tsx‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Link from 'next/link'
77
import { useQueryStates } from 'nuqs'
88
import { requestJson } from '@/lib/api/client/request'
99
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
10+
import { getProjectContract } from '@/lib/api/contracts/projects'
1011
import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
1112
import { useSession } from '@/lib/auth/client'
1213
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
@@ -47,7 +48,8 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-
4748
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
4849
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
4950
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
50-
import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files'
51+
import { getProjectFileQueryOptions } from '@/hooks/queries/project-files'
52+
import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/utils/workspace-file-query'
5153
import { useHydrated } from '@/hooks/use-hydrated'
5254
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
5355
import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store'
@@ -92,6 +94,8 @@ function OrganizationHomeContent({
9294
const rememberMode = useOrganizationChatModeStore((state) => state.setMode)
9395
const [selectedMode, setSelectedMode] = useState<ChatRequestMode | null>(null)
9496
const planEnabled = useFeatureFlag('mothership-plan-mode')
97+
const projectsEnabled = useFeatureFlag('projects')
98+
9599
const requestMode =
96100
selectedMode ??
97101
(urlSearchLevel && searchAccess.memberScoped && !chatId ? 'assistant' : undefined) ??
@@ -168,11 +172,30 @@ function OrganizationHomeContent({
168172
])
169173
const selectResource = useCallback(
170174
async (ref: WorkspaceResourceRef) => {
171-
if (!ref.workspaceId) {
172-
toast.error('This resource has no workspace address.')
173-
return
174-
}
175175
try {
176+
if (ref.type === 'file' && ref.owner?.entityType === 'project') {
177+
if (!projectsEnabled) return
178+
if (!ref.id) throw new Error('Missing Project file identity')
179+
await requestJson(getProjectContract, {
180+
params: { id: ref.owner.entityId },
181+
query: { organizationId: organization.id },
182+
})
183+
const result = await queryClient.fetchQuery({
184+
...getProjectFileQueryOptions(ref.owner.entityId, ref.id),
185+
staleTime: 0,
186+
})
187+
addResource({
188+
type: 'file',
189+
id: result.file.id,
190+
title: result.file.name,
191+
owner: result.file.owner,
192+
})
193+
return
194+
}
195+
if (!ref.workspaceId) {
196+
toast.error('This resource has no workspace address.')
197+
return
198+
}
176199
const host = await requestJson(getWorkspaceHostContextContract, {
177200
params: { id: ref.workspaceId },
178201
})
@@ -194,7 +217,7 @@ function OrganizationHomeContent({
194217
toast.error(`Couldn't open "${ref.title}". Check your access and try again.`)
195218
}
196219
},
197-
[queryClient, addResource, organization.id]
220+
[queryClient, addResource, organization.id, projectsEnabled]
198221
)
199222
const files = useFileAttachments({
200223
userId: session?.user?.id,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { notFound, redirect } from 'next/navigation'
2+
import { internalSessionAuth } from '@/lib/api/server/routes'
3+
import { getSession } from '@/lib/auth'
4+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
5+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
6+
import { getProject } from '@/lib/projects/application/use-cases'
7+
import { getProjectFileMetadata } from '@/lib/projects/files/application'
8+
9+
interface ProjectFilePageProps {
10+
params: Promise<{ projectId: string; fileId: string }>
11+
}
12+
13+
/** Resolves a durable Project link into an environment the current viewer can access. */
14+
export default async function ProjectFilePage({ params }: ProjectFilePageProps) {
15+
if (!(await isFeatureEnabled('projects'))) notFound()
16+
const { projectId, fileId } = await params
17+
if (!(await getSession())?.user) {
18+
const destination = `/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileId)}`
19+
redirect(`/login?callbackUrl=${encodeURIComponent(destination)}`)
20+
}
21+
const principal = await internalSessionAuth.authenticate()
22+
let workspaceId: string | undefined
23+
try {
24+
const [context] = await Promise.all([
25+
getProject.execute({ principal, input: { projectId } }),
26+
getProjectFileMetadata.execute({ principal, input: { projectId, fileId } }),
27+
])
28+
workspaceId = context.project.environments[0]?.id
29+
} catch (error) {
30+
const classified = asOrchestrationError(error)
31+
if (classified?.code === 'not_found' || classified?.code === 'forbidden') notFound()
32+
throw error
33+
}
34+
if (!workspaceId) notFound()
35+
redirect(
36+
`/workspace/${encodeURIComponent(workspaceId)}/files/${encodeURIComponent(fileId)}?owner=project&projectId=${encodeURIComponent(projectId)}`
37+
)
38+
}

0 commit comments

Comments
 (0)