Skip to content

Commit 57f92b7

Browse files
authored
fix(copilot): resolve folder mentions across resource families (#7615)
* fix(copilot): resolve folder mentions across resource families * fix(copilot): preserve organization folder delegation restrictions
1 parent 69e58ad commit 57f92b7

20 files changed

Lines changed: 804 additions & 123 deletions

File tree

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

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,8 @@ interface AvailableItemsByType {
7575
}
7676

7777
/**
78-
* Folder hierarchies that exist purely to structure the browse menus. Unlike
79-
* workflow (`folder`) and workspace-file (`filefolder`) folders these are not
80-
* attachable resources, so they stay out of `groups` — which also feeds the
81-
* flat search results, where a non-attachable row would be a dead end.
78+
* Table and knowledge-base folder hierarchies. Chat also offers these as folder
79+
* mentions, while the resource tab picker uses them only for navigation.
8280
*/
8381
interface StructureFolders {
8482
table: AvailableItem[]
@@ -97,6 +95,8 @@ interface AvailableResources {
9795
}
9896

9997
interface UseAvailableResourcesOptions {
98+
/** Chat can attach every folder family, so these lists also gate mention hydration. */
99+
includeFolderMentions?: boolean
100100
/**
101101
* Skips the underlying list queries and the group construction they feed
102102
* while `false`, returning a stable empty result. Menus pass their own open
@@ -171,16 +171,17 @@ export function useAvailableResources(
171171
{ enabled }
172172
)
173173
const { data: folders, isPending: foldersPending } = useFolders(workspaceId, { enabled })
174-
// Folder lists exist only to shape their family's submenu, so they skip the
175-
// fetch entirely when that family is excluded.
176-
const { data: tableFolders } = useFolders(workspaceId, {
174+
const { data: tableFolders, isPending: tableFoldersPending } = useFolders(workspaceId, {
177175
enabled: enabled && !excludeTypes?.includes('table'),
178176
resourceType: 'table',
179177
})
180-
const { data: knowledgeBaseFolders } = useFolders(workspaceId, {
181-
enabled: enabled && !excludeTypes?.includes('knowledgebase'),
182-
resourceType: 'knowledge_base',
183-
})
178+
const { data: knowledgeBaseFolders, isPending: knowledgeBaseFoldersPending } = useFolders(
179+
workspaceId,
180+
{
181+
enabled: enabled && !excludeTypes?.includes('knowledgebase'),
182+
resourceType: 'knowledge_base',
183+
}
184+
)
184185
const { data: fileFolders, isPending: fileFoldersPending } = useWorkspaceFileFolders(
185186
workspaceId,
186187
'active',
@@ -199,10 +200,8 @@ export function useAvailableResources(
199200
* settles to "not hydrating" — an errored query must not block the caller
200201
* forever.
201202
*
202-
* Only the lists feeding `groups` count. The table and knowledge-base folder
203-
* lists shape submenus but never add candidates, so gating on them would
204-
* swallow an `@`-mention Enter behind two round-trips that cannot change the
205-
* answer.
203+
* Chat includes table and knowledge-base folders as candidates. Its Enter
204+
* handling must wait for those lists too, or an unresolved mention can submit.
206205
*/
207206
const isHydrating =
208207
enabled &&
@@ -211,6 +210,9 @@ export function useAvailableResources(
211210
filesPending ||
212211
knowledgeBasesPending ||
213212
foldersPending ||
213+
(options?.includeFolderMentions &&
214+
((!excludeTypes?.includes('table') && tableFoldersPending) ||
215+
(!excludeTypes?.includes('knowledgebase') && knowledgeBaseFoldersPending))) ||
214216
fileFoldersPending ||
215217
tasksPending ||
216218
logsPending)
@@ -371,9 +373,7 @@ interface ResourceFolderTreeItemsProps {
371373
/** Resource type of the leaf items. */
372374
type: MothershipResourceType
373375
/**
374-
* Set when the folder is itself an attachable resource (workspace files): the
375-
* folder is then offered as the first entry of its own submenu. Omitted for
376-
* folders that only provide structure (workflows, tables, knowledge bases).
376+
* Offers the folder itself as the first entry of its submenu when selectable.
377377
*/
378378
folderType?: MothershipResourceType
379379
onSelect: (resource: MothershipResource) => void
@@ -429,10 +429,6 @@ export function ResourceFolderTreeItems({
429429
interface FolderedSectionSpec {
430430
/** Leaf resource type — also supplies the submenu's label and icon. */
431431
type: MothershipResourceType
432-
/**
433-
* Where this family's folders come from: another entry in `groups` when the
434-
* folders are attachable resources, or `structureFolders` when they are not.
435-
*/
436432
folders:
437433
| { kind: 'group'; type: MothershipResourceType }
438434
| { kind: 'structure'; key: keyof StructureFolders }
@@ -483,22 +479,25 @@ export interface ResourceTreeSection {
483479
export function useResourceTreeSections({
484480
groups,
485481
structureFolders,
486-
}: Pick<AvailableResources, 'groups' | 'structureFolders'>): ResourceTreeSection[] {
482+
selectFolders = false,
483+
}: Pick<AvailableResources, 'groups' | 'structureFolders'> & {
484+
selectFolders?: boolean
485+
}): ResourceTreeSection[] {
487486
return useMemo(() => {
488487
const itemsOf = (type: MothershipResourceType) =>
489488
groups.find((group) => group.type === type)?.items ?? []
490489
return FOLDERED_SECTION_SPECS.map((spec) => ({
491490
type: spec.type,
492-
folderType: spec.folderType,
491+
folderType: spec.folderType ?? (selectFolders ? 'folder' : undefined),
493492
nodes: buildResourceFolderTree(
494493
itemsOf(spec.type),
495494
spec.folders.kind === 'group'
496495
? itemsOf(spec.folders.type)
497496
: structureFolders[spec.folders.key],
498-
{ orderBySortOrder: spec.orderBySortOrder, pruneEmpty: !spec.folderType }
497+
{ orderBySortOrder: spec.orderBySortOrder, pruneEmpty: !selectFolders && !spec.folderType }
499498
),
500499
})).filter((section) => section.nodes.length > 0)
501-
}, [groups, structureFolders])
500+
}, [groups, structureFolders, selectFolders])
502501
}
503502

504503
interface ResourceMenuSectionsProps {

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,18 @@ const fixtures = vi.hoisted(() => ({
1010
browserAvailable: vi.fn(() => true),
1111
terminalAvailable: vi.fn(() => true),
1212
resources: { data: [{ id: 'resource-1', name: 'Example' }], isPending: false },
13-
folders: { data: [], isPending: false },
13+
folders: {
14+
data: [] as { id: string; name: string; parentId: string | null }[],
15+
isPending: false,
16+
},
17+
tableFolders: {
18+
data: [] as { id: string; name: string; parentId: string | null }[],
19+
isPending: false,
20+
},
21+
knowledgeFolders: {
22+
data: [] as { id: string; name: string; parentId: string | null }[],
23+
isPending: false,
24+
},
1425
tabs: [],
1526
logs: {
1627
data: {
@@ -32,7 +43,14 @@ vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => fix
3243
vi.mock('@/hooks/queries/kb/knowledge', () => ({
3344
useKnowledgeBasesQuery: () => fixtures.resources,
3445
}))
35-
vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => fixtures.folders }))
46+
vi.mock('@/hooks/queries/folders', () => ({
47+
useFolders: (_workspaceId: string, options?: { resourceType?: string }) =>
48+
options?.resourceType === 'table'
49+
? fixtures.tableFolders
50+
: options?.resourceType === 'knowledge_base'
51+
? fixtures.knowledgeFolders
52+
: fixtures.folders,
53+
}))
3654
vi.mock('@/hooks/queries/workspace-file-folders', () => ({
3755
useWorkspaceFileFolders: () => fixtures.folders,
3856
}))
@@ -72,14 +90,15 @@ const PREFERENCES: DesktopPreferences = {
7290
terminalEnabled: true,
7391
}
7492

75-
function openMenu(mention = false) {
93+
function openMenu(mention = false, mentionQuery?: string) {
7694
const ref = createRef<PlusMenuHandle>()
7795
const onResourceSelect = vi.fn()
7896
act(() =>
7997
root.render(
8098
<PlusMenuDropdown
8199
ref={ref}
82100
workspaceId='workspace-1'
101+
mentionQuery={mentionQuery}
83102
onResourceSelect={onResourceSelect}
84103
onClose={vi.fn()}
85104
textareaRef={createRef<HTMLTextAreaElement>()}
@@ -120,6 +139,11 @@ describe('PlusMenuDropdown desktop resources', () => {
120139
}
121140
)
122141
vi.clearAllMocks()
142+
fixtures.resources.data = [{ id: 'resource-1', name: 'Example' }]
143+
for (const folders of [fixtures.folders, fixtures.tableFolders, fixtures.knowledgeFolders]) {
144+
folders.data = []
145+
folders.isPending = false
146+
}
123147
fixtures.browserAvailable.mockReturnValue(true)
124148
fixtures.terminalAvailable.mockReturnValue(true)
125149
setDesktopPreferencesSnapshot(PREFERENCES)
@@ -231,4 +255,42 @@ describe('PlusMenuDropdown desktop resources', () => {
231255
expect(names).not.toContain('Browser')
232256
expect(names).not.toContain('Terminal')
233257
})
258+
259+
it.each(['tableFolders', 'knowledgeFolders'] as const)(
260+
'selects an empty %s folder by @ mention and preserves its ID',
261+
(family) => {
262+
fixtures[family].data = [{ id: 'folder-1', name: 'Planning', parentId: null }]
263+
const { ref, onResourceSelect } = openMenu(true, 'Planning')
264+
act(() => {
265+
expect(ref.current?.selectActive()).toBe('selected')
266+
})
267+
expect(mapResourceToContext(onResourceSelect.mock.calls[0][0])).toEqual({
268+
kind: 'folder',
269+
folderId: 'folder-1',
270+
label: 'Planning',
271+
})
272+
}
273+
)
274+
275+
it.each(['tableFolders', 'knowledgeFolders'] as const)(
276+
'waits for %s hydration before submitting an unresolved mention',
277+
(family) => {
278+
fixtures[family].isPending = true
279+
const { ref, onResourceSelect } = openMenu(true, 'Planning')
280+
expect(ref.current?.selectActive()).toBe('hydrating')
281+
expect(onResourceSelect).not.toHaveBeenCalled()
282+
}
283+
)
284+
285+
it('keeps empty table and knowledge folder families in the attachment browse menu', () => {
286+
fixtures.resources.data = []
287+
fixtures.tableFolders.data = [{ id: 'table-folder', name: 'Table Planning', parentId: null }]
288+
fixtures.knowledgeFolders.data = [
289+
{ id: 'kb-folder', name: 'Knowledge Planning', parentId: null },
290+
]
291+
openMenu()
292+
expect(menuItems().map((item) => item.textContent)).toEqual(
293+
expect.arrayContaining(['Tables', 'Knowledge Bases'])
294+
)
295+
})
234296
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
buildMentionPreview,
2626
resourceMentionMatches,
2727
withDesktopTabMentions,
28+
withFolderMentions,
2829
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
2930
import type {
3031
MothershipResource,
@@ -103,6 +104,7 @@ export const PlusMenuDropdown = React.memo(
103104
isHydrating,
104105
} = useAvailableResources(workspaceId, {
105106
enabled: open || !!warm,
107+
includeFolderMentions: true,
106108
})
107109

108110
const doOpen = useCallback(
@@ -121,15 +123,17 @@ export const PlusMenuDropdown = React.memo(
121123
}, [])
122124

123125
const visibleResources = useMemo(() => {
126+
const resources = withFolderMentions(availableResources, structureFolders)
124127
if (isMention) {
125-
return withDesktopTabMentions(availableResources, browserTabs, terminalTabs)
128+
return withDesktopTabMentions(resources, browserTabs, terminalTabs)
126129
}
127-
return availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type))
128-
}, [availableResources, browserTabs, isMention, terminalTabs])
130+
return resources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type))
131+
}, [availableResources, structureFolders, browserTabs, isMention, terminalTabs])
129132

130133
const treeSections = useResourceTreeSections({
131-
groups: visibleResources,
134+
groups: availableResources,
132135
structureFolders,
136+
selectFolders: true,
133137
})
134138

135139
const filteredItems = useMemo(() => {

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
buildMentionPreview,
1010
resourceMentionMatches,
1111
withDesktopTabMentions,
12+
withFolderMentions,
1213
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
1314

1415
const groups = [
@@ -172,3 +173,31 @@ describe('byResourceMenuOrder', () => {
172173
expect(ordered.map((group) => group.type)).toEqual(['task', 'workflow', 'log', 'browser'])
173174
})
174175
})
176+
177+
describe('folder mentions', () => {
178+
it('includes nested and empty table and knowledge folders without changing the browse groups', () => {
179+
const source = [
180+
{ type: 'folder' as const, items: [{ id: 'workflow-folder', name: 'Planning' }] },
181+
]
182+
const result = withFolderMentions(source, {
183+
table: [{ id: 'table-folder', name: 'Planning', parentId: 'parent' }],
184+
knowledgebase: [{ id: 'kb-folder', name: 'Planning', parentId: null }],
185+
})
186+
expect(source[0].items).toHaveLength(1)
187+
expect(result[0].items.map((item) => item.id)).toEqual([
188+
'workflow-folder',
189+
'table-folder',
190+
'kb-folder',
191+
])
192+
expect(
193+
result[0].items
194+
.filter((item) => resourceMentionMatches(item, 'table folders'))
195+
.map((item) => item.id)
196+
).toEqual(['table-folder'])
197+
expect(
198+
result[0].items
199+
.filter((item) => resourceMentionMatches(item, 'knowledge base folders'))
200+
.map((item) => item.id)
201+
).toEqual(['kb-folder'])
202+
})
203+
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@ export interface ResourceMentionGroup {
1313
items: AvailableItem[]
1414
}
1515

16+
/** Adds table and knowledge-base folders as stable folder-ID chat mentions. */
17+
export function withFolderMentions(
18+
groups: readonly ResourceMentionGroup[],
19+
folders: { table: AvailableItem[]; knowledgebase: AvailableItem[] }
20+
): ResourceMentionGroup[] {
21+
return groups.map((group) =>
22+
group.type === 'folder'
23+
? {
24+
...group,
25+
items: [
26+
...group.items,
27+
...folders.table.map((item) => ({ ...item, mentionFamily: 'Table folders' })),
28+
...folders.knowledgebase.map((item) => ({
29+
...item,
30+
mentionFamily: 'Knowledge base folders',
31+
})),
32+
],
33+
}
34+
: group
35+
)
36+
}
37+
1638
export type ResourceMentionLevel = 'resource' | 'tab'
1739

1840
/** A family query such as "browser" keeps that resource's live tabs visible. */

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,54 @@ describe('usePromptEditor context insertion', () => {
430430
unmount()
431431
})
432432
})
433+
434+
describe('folder resource mention identity', () => {
435+
it('retains distinct folder IDs and labels when inserting a batch', () => {
436+
const { result, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
437+
try {
438+
act(() =>
439+
result().insertResources([
440+
{ type: 'folder', id: 'workflow-folder', title: 'Planning' },
441+
{ type: 'folder', id: 'table-folder', title: 'Planning' },
442+
{ type: 'folder', id: 'knowledge-folder', title: 'Planning' },
443+
{ type: 'filefolder', id: 'file-folder', title: 'Planning' },
444+
{ type: 'folder', id: 'table-folder', title: 'Planning' },
445+
])
446+
)
447+
expect(result().contexts).toEqual([
448+
{ kind: 'folder', folderId: 'workflow-folder', label: 'Planning' },
449+
{ kind: 'folder', folderId: 'table-folder', label: 'Planning (2)' },
450+
{ kind: 'folder', folderId: 'knowledge-folder', label: 'Planning (3)' },
451+
{ kind: 'filefolder', fileFolderId: 'file-folder', label: 'Planning (4)' },
452+
])
453+
expect(result().value).toBe(
454+
'@Planning @Planning (2) @Planning (3) @Planning (4) @Planning (2) '
455+
)
456+
} finally {
457+
unmount()
458+
}
459+
})
460+
461+
it('keeps same-named folders as separate chips and reuses the label for a repeated ID', () => {
462+
const { result, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
463+
try {
464+
act(() =>
465+
result().insertResource({ type: 'folder', id: 'workflow-folder', title: 'Planning' })
466+
)
467+
act(() => result().insertResource({ type: 'folder', id: 'table-folder', title: 'Planning' }))
468+
act(() =>
469+
result().insertResource({ type: 'folder', id: 'knowledge-folder', title: 'Planning' })
470+
)
471+
act(() => result().insertResource({ type: 'folder', id: 'table-folder', title: 'Planning' }))
472+
expect(result().contexts).toEqual([
473+
{ kind: 'folder', folderId: 'workflow-folder', label: 'Planning' },
474+
{ kind: 'folder', folderId: 'table-folder', label: 'Planning (2)' },
475+
{ kind: 'folder', folderId: 'knowledge-folder', label: 'Planning (3)' },
476+
])
477+
expect(result().value).toContain('@Planning (2)')
478+
expect(result().value).toContain('@Planning (3)')
479+
} finally {
480+
unmount()
481+
}
482+
})
483+
})

0 commit comments

Comments
 (0)