Skip to content

Commit 99ed5fb

Browse files
mzxchandraWaleed Latifwaleedlatif1
authored
feat(project-files): let Chat read and write Project files (#9034)
* feat(files): restore Project file browser and Mothership integration * fix(files): resolve CI audits and test types after restacking on staging - Keep prepareProjectsForAccountDeletion returning the Project IDs to delete. The object return added an unused storageCleanupEventIds field; the durable cleanup outbox already processes those events, so restore the staging contract and its stronger integration assertions. - Exempt lib/mothership/generated from the relative-import convention. contracts:sync copies those wire contracts verbatim and byte-checks them, so their sibling imports must stay relative; drop their baseline rows. - Re-record the files page module-count baseline for the Project browser. - Type the test fixtures that regressed, including a queue-recovery fixture that persisted the invalid completion status 'success'. * fix(files): gate Project sandbox callbacks like workspace callbacks Staging now holds resource-delegated Chat principals to content admission, so a Project callback read with no gate was refused and the sandbox got a 503. Run Project callbacks under the same workbench session gate as workspace callbacks (the gate's workspace is optional, since Projects have none) and share the withheld-result handling. The Project CLI read suite now runs reads under the tool executor's registry gate, as Chat does. * fix(projects): keep Project chat tabs behind the rollout flag The chat resources API stored a Project-owned tab without checking the projects flag. Refuse new Project tabs while Projects are off; removing or reordering stored tabs stays possible so they can be cleaned up. Also drop the projects flag check in list_user_projects that could never run (listProjects already refuses while the flag is off) and the content delivery barrel export left unused by the shared sandbox delivery helper. * refactor(files): drop the Project files UI from the Chat transport PR The Project files UI will be built for the project-first redesign, so this PR keeps only the backend and Chat transport behind the projects flag. Restore the workspace Files page, file viewer, Chat resource panel, add-resource and mention entries, and their client query hooks and owner adapters to staging. The previous tip is tagged project-files-ui-seed for that UI work. Kept: the shared ChatContext wire type (Project contexts and file owners reach the Chat server) and its exhaustive kind registry entry. Also drop the UI-only browser constants and response types nothing uses now, restore the files page module-count baseline, and type the use-chat fixtures that returned with the staging test. * fix(files): redact Project file text like workspace file text Project grep and text reads admitted every Project artifact as opaque bytes, so any file carrying a secret was refused to Chat instead of having the secret masked, unlike the same file in a workspace. Share one text extraction core between workspace text reads and private file observation: parse, redact before a line window can cut a secret, then slice. Project text reads now go through readProjectFileText, which admits the source and every rendered input as complete text and returns text only. The complete artifact read stays private to that use case, so bytes never leave under a complete label; readProjectFileArtifact keeps admitting opaque bytes, and visual reads of a secret still refuse. * fix(sim-cli): keep unpublished groups in the Mothership command inventory #9057 hid the projects group from --help and `sim cli` search until launch, and the inventory printer dropped every hidden command, so the Mothership worker lost the Project file commands it routes --project to. List a hidden top-level group that is only unpublished, marked `unpublished`, so the worker can route to it once Sim enables Projects for a turn; other hidden commands stay out, and --help is unchanged. * fix(files): address Cubic and Greptile review findings Workspace regressions while Projects are off: - Sandbox file copies between workspaces keep the workspace route when the lease has not negotiated the file-owner protocol; only pairs that name a Project owner need it. - Chat upload contexts keep their uploads/ path in the workspace owner adapter. Project transport: - Project CLI requests refuse mutations during benchmark reference generation, as workspace requests do. - Project grep defaults an omitted scope to files. - Project files with unrecorded or empty exact provenance are readable when the caller holds a registry; a missing registry is still refused. Mothership inventory: - Unpublished Project commands carry response shapes from the launch-ready Project OpenAPI definitions. - An operation whose delegated services exclude Chat no longer mints a Chat principal, so the refused-route list now matches runtime for Project file version history. * fix(files): address the second review round - Project `files read` returns binary metadata for undecodable files: the text route checked `workspaceFileTextFormat` against null, but it returns undefined. - Workspace file resources keep `workspaceId` beside their new workspace owner, so current browsers can open and close those tabs in organization chats; the resource key is unchanged. - The Project tab flag gate is covered against the database instead of through mocked collaborators. * fix(files): choose a generated Project document's read by its rendered type A generated PDF is stored under its source type (text/x-python-pdf), so the Project text route treated `files read report.pdf` as text instead of the default visual representation. Share the workspace reader's type selection as observedMediaType: a generated document is read as the type its name promises, otherwise as its effective stored type. * fix(mothership): close review gaps in Project file transport - Dispatch CLI services before file-owner adapters, so a service that carries a file owner gets the services validation instead of a bridge error. The unreachable Project-side service check is removed. - Check caller-chosen names on Project file renames and folder create and update with the same secret-provenance gate as create and upload, including workbench history for sandbox callbacks. - Move first-message file panel resolution into resolveFileAttachment and replace the mock-only post test with a database-backed check of the saved tab. * fix(mothership): match chat tabs within the chat's workspace - The chat resource store now keys tabs with the chat's workspace as the fallback scope, so an unscoped tab and the same tab addressed by that workspace merge instead of duplicating. Upsert, remove and reorder all use the same identity. - Project file renames and folder writes recheck cancellation after the name check and never forward a cancelled write. - Leave the .claude and .cursor import rules to staging, whose source conventions check now exempts generated contract copies itself. * fix(mothership): bound Project name checks and keep equal owners - Parse Project file renames and folder writes with their route contracts under the inline body limit before the name check, so an oversized body is refused instead of buffered. Only those routes copy the request. - Treat an equal file owner as unchanged when merging chat tabs, so a repeated upsert stays a no-op. * fix(project-files): harden owner hints, grep provenance, and tab identity - Restore info/warn levels for stale or forbidden tab attachments and skip an unresolvable target instead of throwing - Make Project owner hints best-effort so a failed lookup never fails the turn; document that a worker rollback needs projects off first - Cache the worker file-owner protocol probe per base URL (lru-cache, 30s, success only) - Answer an invalid copy body on an unnegotiated lease from the workspace route (v2 400) instead of a 403 - One tab-key function on server and client, keyed with the chat workspace on both sides - Project grep withholds a file without provenance instead of failing the search or latching the registry, and stops listing once the file budget is full - Bound copy effects: one copied file opens a tab, bulk and folder copies refresh the destination owner; org copies carry the destination workspace - Bound list_user_projects capability fan-out - Refuse unextractable or oversized Project text reads before download, with the text-extraction size message - Share the create-file input mapper, the resource authoring version, and isProjectsEnabled * fix(project-files): refuse secret-bearing raw Project downloads without a workbench tracker * test(project-files): pin refusals and cover live owner-hint, tab-key and log-level paths * refactor(project-files): move owner routing and admission behind the file-owner adapter - copilot-request carries one opaque resource grant per request; the Project file and file-copy grants are minted next to their adapters, so the shared route layer no longer imports Project or copy policy - One projectFileDelegationScope for the verifier, the Copilot adapter and the request grant; admitsCopilotResourceDelegation shared by the adapter and the grant - Private v2 transports derive route patterns from their contracts (v2RoutePattern / isV2ContractRoute / tryMatchV2Route), including the name-write lookup, so a renamed route can no longer skip interception silently - Project upload and write transports share their route prologue, cancellation check and failure envelope - One forwarded-header strip list and one callback context for sandbox callbacks; SandboxResourceScope is exported instead of re-derived - isOwnerScopedFile replaces adapter field comparisons; the adapter field is renamed invocationScope so it no longer reads like Principal.resourceScope - authoringInvocation picks a turn's chat-or-workspace invocation in one place * refactor(mothership): share chat tab keys, CLI session setup, and file read helpers - chatResourceKeyFor binds the tab key to one chat's workspace for the store, reorder, stream handler and use-chat; resource removal looks keys up in a Set - isFileResourceType names the file/filefolder check; shared owner refinement in the resource contracts - prepareCliSession gives every CLI invocation its endpoint, workbench session, file provenance and effect sink - Text-extraction refusals, the default-visual media check and MIME normalization are shared rather than copied - Grep page cap and listing note are shared; a corpus states its own scope refusal instead of the engine naming Projects - resource-effects builds an owned file path once and shares the owner refresh * perf(project-files): skip the folder tree for grep reads and check hint gates before the flag - Project grep reads ask the artifact read not to load the owner's whole folder tree per file; grep never reads file.folderPath - File-owner hints run the route, mode and admission checks before awaiting the projects flag - Project text reads reuse the workspace text-extraction refusals; Project grep uses the shared page cap and states its own scope refusal * test(mothership): drive stale-tab and owner-hint failures through the real boundary * test(project-files): pin upload cancellation during provenance and new-chat tab order * fix(project-files): recheck cancellation before completing a workbench upload Provenance resolution can outlast the invocation; completion now rechecks both signals before it registers the file, as the create path does. * fix(mothership): persist new-chat tabs in attachment order Attachments resolve concurrently, so tabs were persisted in completion order. Each authorized tab now keeps its attachment index. * fix(mothership): store the recovery authoring marker only while Projects are on Earlier builds parse recovery config strictly and skip recovery on an unknown key. Only Project authoring reads the marker, so flag-off turns no longer write it and stay recoverable across a rollback. * fix(mothership): tag an unlabeled file selection as a bare mention * test(project-files): give new suites their own reports and drop an unreachable adapter case * refactor(files): return readSourceBuffer's TSDoc to its function --------- Co-authored-by: Waleed Latif <waleed@sim.ai> Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent 84f41ea commit 99ed5fb

144 files changed

Lines changed: 9895 additions & 1121 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/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
@@ -30,6 +30,7 @@ import {
3030
createInternalServerErrorResponse,
3131
createUnauthorizedResponse,
3232
} from '@/lib/mothership/request/http'
33+
import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation'
3334
import { captureServerEvent } from '@/lib/posthog/server'
3435

3536
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/api/v2/files/[fileId]/metadata/route.test.ts‎

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,18 @@ import {
55
v2RateLimiterModuleMock,
66
v2RouteMocks,
77
} from '@sim/testing'
8+
import {
9+
readWorkspaceFileMetadataMock,
10+
readWorkspaceFileMetadataMockFns,
11+
} from '@sim/testing/mocks/read-workspace-file-metadata.mock'
812
import { usersQueriesMock, usersQueriesMockFns } from '@sim/testing/mocks/users-queries.mock'
913
import { NextRequest } from 'next/server'
1014
import { beforeEach, describe, expect, it, vi } from 'vitest'
1115

12-
const mocks = vi.hoisted(() => ({
13-
readMetadata: vi.fn(),
14-
}))
15-
16-
vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({
17-
readWorkspaceFileMetadataWithVersion: {
18-
operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' },
19-
execute: mocks.readMetadata,
20-
},
21-
}))
16+
vi.mock(
17+
'@/lib/workspace-files/application/read-workspace-file-metadata',
18+
() => readWorkspaceFileMetadataMock
19+
)
2220

2321
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
2422
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
@@ -30,6 +28,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
3028
import { GET } from '@/app/api/v2/files/[fileId]/metadata/route'
3129

3230
const { mockGetUserEmailsByIds } = usersQueriesMockFns
31+
const readMetadata = readWorkspaceFileMetadataMockFns.mockReadWorkspaceFileMetadataWithVersion
3332

3433
const WORKSPACE_ID = 'workspace-1'
3534
const FILE_ID = 'wf_1'
@@ -89,15 +88,15 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
8988
v2RouteMocks.authenticate.mockResolvedValue(auth)
9089
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
9190
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
92-
mocks.readMetadata.mockResolvedValue({
91+
readMetadata.mockResolvedValue({
9392
file: { ...buildRecord(), currentVersion: 3 },
9493
share: SHARE,
9594
})
9695
mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']]))
9796
})
9897

9998
it('conceals cross-workspace authorization as not found', async () => {
100-
mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError())
99+
readMetadata.mockRejectedValue(new NoWorkspaceAccessError())
101100

102101
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
103102

@@ -106,35 +105,35 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
106105
})
107106

108107
it('leaves an archived file unreachable when scope is omitted', async () => {
109-
mocks.readMetadata.mockImplementation(archivedFileUseCase)
108+
readMetadata.mockImplementation(archivedFileUseCase)
110109

111110
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
112111

113112
expect(response.status).toBe(404)
114113
expect((await response.json()).error.code).toBe('NOT_FOUND')
115-
expect(mocks.readMetadata).toHaveBeenCalledWith(
114+
expect(readMetadata).toHaveBeenCalledWith(
116115
expect.objectContaining({
117116
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false },
118117
})
119118
)
120119
})
121120

122121
it('leaves an archived file unreachable under an explicit scope=active', async () => {
123-
mocks.readMetadata.mockImplementation(archivedFileUseCase)
122+
readMetadata.mockImplementation(archivedFileUseCase)
124123

125124
const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=active`)
126125

127126
expect(response.status).toBe(404)
128127
expect((await response.json()).error.code).toBe('NOT_FOUND')
129-
expect(mocks.readMetadata).toHaveBeenCalledWith(
128+
expect(readMetadata).toHaveBeenCalledWith(
130129
expect.objectContaining({
131130
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false },
132131
})
133132
)
134133
})
135134

136135
it('returns archived metadata when scope=archived opts into the archived set', async () => {
137-
mocks.readMetadata.mockImplementation(archivedFileUseCase)
136+
readMetadata.mockImplementation(archivedFileUseCase)
138137

139138
const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=archived`)
140139

@@ -157,7 +156,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
157156
revision: expect.any(String),
158157
},
159158
})
160-
expect(mocks.readMetadata).toHaveBeenCalledWith(
159+
expect(readMetadata).toHaveBeenCalledWith(
161160
expect.objectContaining({
162161
principal: auth.principal,
163162
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: true },

‎apps/sim/app/api/v2/projects/[projectId]/files/route.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import {
1111
v2OrchestrationErrorPolicy,
1212
v2RateLimits,
1313
} from '@/lib/api/server/routes'
14+
import { toCreateProjectFileInput } from '@/lib/projects/files/api'
1415
import { toV2ProjectFile } from '@/lib/projects/files/api/presenters'
1516
import { createProjectFile, listProjectFiles } from '@/lib/projects/files/application'
1617
import { projectFileOperations } from '@/lib/projects/files/application/operations'
17-
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file'
1818
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
1919
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
2020

@@ -68,15 +68,7 @@ export const POST = defineV2JsonRoute({
6868
rateLimit: v2RateLimits.publicApi,
6969
errorPolicy: v2OrchestrationErrorPolicy,
7070
parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES },
71-
mapInput: ({ params, body }) => ({
72-
projectId: params.projectId,
73-
name: body.name,
74-
contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)),
75-
content: body.content,
76-
encoding: body.encoding,
77-
folderPath: body.folderPath ?? '/',
78-
exactName: true,
79-
}),
71+
mapInput: ({ params, body }) => toCreateProjectFileInput(params.projectId, body),
8072
useCase: createProjectFile,
8173
present: ({ file }) => ({ data: toV2ProjectFile(file) }),
8274
})

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
108108
label: 'File folder',
109109
renderIcon: ({ className }) => <FolderIcon className={className} />,
110110
},
111+
project: {
112+
label: 'Project',
113+
renderIcon: ({ className }) => <FolderIcon className={className} />,
114+
},
111115
workspace: {
112116
label: 'Workspace',
113117
renderIcon: ({ className }) => <Workspaces className={className} />,

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,34 @@ describe('organization resource stream', () => {
467467
})
468468
})
469469

470+
describe('owner-addressed effects on a legacy tab', () => {
471+
const legacyTab: MothershipResource = { type: 'file', id: 'f', title: 'notes' }
472+
const owner = { entityType: 'workspace' as const, entityId: 'ws' }
473+
474+
function applyOwnerEffect(op: 'upsert' | 'remove') {
475+
const deps = makeStreamLoopDeps({ workspaceId: 'ws', resourcesRef: ref([legacyTab]) })
476+
handleResourceEvent(
477+
{ deps } as StreamLoopContext,
478+
{
479+
...removeEvent('file', 'f'),
480+
payload: { op, effectId: `effect:${op}`, resource: { type: 'file', id: 'f', owner } },
481+
} as ResourceEvent
482+
)
483+
const updater = (deps.setResources as ReturnType<typeof vi.fn>).mock.calls[0][0] as (
484+
current: MothershipResource[]
485+
) => MothershipResource[]
486+
return updater([legacyTab])
487+
}
488+
489+
it('closes the tab when the removal names its workspace owner', () => {
490+
expect(applyOwnerEffect('remove')).toEqual([])
491+
})
492+
493+
it('updates the tab in place instead of opening a duplicate', () => {
494+
expect(applyOwnerEffect('upsert')).toHaveLength(1)
495+
})
496+
})
497+
470498
it('seeds only fresh matching search effects and never seeds replayed or foreign evidence', () => {
471499
const deps = makeStreamLoopDeps({
472500
workspaceId: undefined,

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

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { FilePreviewSession } from '@/lib/mothership/request/session'
88
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
99
import { searchResourceMatchesOwner } from '@/lib/mothership/resources/search'
1010
import {
11-
getChatResourceKey,
11+
chatResourceKeyFor,
1212
getChatResourceSelectionId,
1313
mergeChatResource,
1414
} from '@/lib/mothership/resources/types'
@@ -102,6 +102,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
102102
} = ctx.deps
103103
const onResourceEvent = onResourceEventRef.current
104104
const payload = parsed.payload
105+
const keyOf = chatResourceKeyFor(chatWorkspaceId)
105106
if (payload.op === 'upsert' && payload.readOnly) return
106107
if (payload.resource.type === 'settings') {
107108
const settings = payload.resource
@@ -135,9 +136,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
135136
search,
136137
}
137138
if (payload.op === 'remove') {
138-
setResources((current) =>
139-
current.filter((item) => getChatResourceKey(item) !== getChatResourceKey(resource))
140-
)
139+
setResources((current) => current.filter((item) => keyOf(item) !== keyOf(resource)))
141140
return
142141
}
143142
const scopeKey =
@@ -181,9 +180,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
181180
if (ctx.deps.citedSourcesEnabled) return
182181
if (payload.effectId)
183182
setResources((current) => {
184-
const found = current.find(
185-
(item) => getChatResourceKey(item) === getChatResourceKey(resource)
186-
)
183+
const found = current.find((item) => keyOf(item) === keyOf(resource))
187184
return found
188185
? current.map((item) => (item === found ? mergeChatResource(item, resource) : item))
189186
: [...current, resource]
@@ -257,7 +254,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
257254
const resourceType = resource.type
258255
if (payload.effectId) {
259256
applyResourceEffect(ctx, (current) =>
260-
current.filter((item) => getChatResourceKey(item) !== getChatResourceKey(resource))
257+
current.filter((item) => keyOf(item) !== keyOf(resource))
261258
)
262259
ctx.deps.setActiveResourceId((current) =>
263260
current === getChatResourceSelectionId(resource) ? null : current
@@ -308,13 +305,11 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
308305
!shouldAutoActivatePreviewSession(previewForResource)))
309306
const wasAdded =
310307
shouldSuppressFileResourceActivation || payload.effectId
311-
? !resourcesRef.current.some((r) => getChatResourceKey(r) === getChatResourceKey(resource))
308+
? !resourcesRef.current.some((r) => keyOf(r) === keyOf(resource))
312309
: addResource(resourceUpdate)
313310
if (payload.effectId) {
314311
applyResourceEffect(ctx, (current) => {
315-
const previous = current.find(
316-
(item) => getChatResourceKey(item) === getChatResourceKey(resource)
317-
)
312+
const previous = current.find((item) => keyOf(item) === keyOf(resource))
318313
const next = {
319314
...previous,
320315
...resource,
@@ -326,9 +321,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
326321
})
327322
} else if (shouldSuppressFileResourceActivation && wasAdded) {
328323
setResources((current) =>
329-
current.some((r) => getChatResourceKey(r) === getChatResourceKey(resource))
330-
? current
331-
: [...current, resource]
324+
current.some((r) => keyOf(r) === keyOf(resource)) ? current : [...current, resource]
332325
)
333326
}
334327
if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) {
@@ -341,14 +334,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
341334
// report "added", while only the first updater actually inserted — the
342335
// updater is idempotent, so it simply runs every time.
343336
setResources((current) =>
344-
current.some(
345-
(r) => getChatResourceKey(r) === getChatResourceKey(resource) && r.viewId !== pinnedViewId
346-
)
347-
? current.map((r) =>
348-
getChatResourceKey(r) === getChatResourceKey(resource)
349-
? { ...r, viewId: pinnedViewId }
350-
: r
351-
)
337+
current.some((r) => keyOf(r) === keyOf(resource) && r.viewId !== pinnedViewId)
338+
? current.map((r) => (keyOf(r) === keyOf(resource) ? { ...r, viewId: pinnedViewId } : r))
352339
: current
353340
)
354341
// Consumed by the embedded table once its views list carries the view —

0 commit comments

Comments
 (0)