Skip to content

Commit e218963

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(chat): disambiguate folder mentions
1 parent 0e84e92 commit e218963

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
1717
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
1818
import {
19+
buildFolderMentionLocationMap,
1920
resourceMentionMatches,
2021
withDesktopTabMentions,
2122
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
@@ -41,6 +42,35 @@ const NON_ATTACHABLE_RESOURCE_TYPES = new Set<MothershipResourceType>(['browser'
4142
const EMPTY_BROWSER_TABS = [] as const
4243
const EMPTY_TERMINAL_TABS = [] as const
4344

45+
interface FolderMentionPathProps {
46+
segments: readonly string[]
47+
}
48+
49+
/** Right-aligned folder location whose middle ancestors yield space first. */
50+
function FolderMentionPath({ segments }: FolderMentionPathProps) {
51+
const family = segments[0]
52+
const parentNames = segments.slice(1)
53+
const nearestParent = parentNames.at(-1)
54+
const middleParents = parentNames.slice(0, -1)
55+
56+
return (
57+
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
58+
<span className='flex-shrink-0'>{family}</span>
59+
{middleParents.length > 0 && (
60+
<span className='min-w-0 truncate whitespace-pre [flex-shrink:9999]'>
61+
{` / ${middleParents.join(' / ')}`}
62+
</span>
63+
)}
64+
{nearestParent && (
65+
<>
66+
<span className='flex-shrink-0 whitespace-pre'> / </span>
67+
<span className='min-w-0 truncate'>{nearestParent}</span>
68+
</>
69+
)}
70+
</span>
71+
)
72+
}
73+
4474
interface PlusMenuDropdownProps {
4575
workspaceId: string
4676
/**
@@ -119,6 +149,11 @@ export const PlusMenuDropdown = React.memo(
119149
return attachable.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type))
120150
}, [availableResources, browserTabs, isMention, terminalTabs])
121151

152+
const folderMentionLocations = useMemo(
153+
() => buildFolderMentionLocationMap(visibleResources),
154+
[visibleResources]
155+
)
156+
122157
const treeSections = useResourceTreeSections({
123158
groups: visibleResources,
124159
structureFolders,
@@ -334,6 +369,10 @@ export const PlusMenuDropdown = React.memo(
334369
filteredItems.map(({ type, item }, index) => {
335370
const config = getResourceConfig(type)
336371
const isActive = index === activeIndex
372+
const location = folderMentionLocations.get(`${type}:${item.id}`)
373+
const locationPath = location
374+
? [getResourceConfig(location.familyType).label, ...location.parentNames]
375+
: null
337376
return (
338377
<button
339378
key={`${type}:${item.id}`}
@@ -351,6 +390,7 @@ export const PlusMenuDropdown = React.memo(
351390
)}
352391
>
353392
{config.renderDropdownItem({ item })}
393+
{locationPath && <FolderMentionPath segments={locationPath} />}
354394
</button>
355395
)
356396
})

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
TERMINAL_SESSION_RESOURCE_ID,
55
} from '@/lib/copilot/resources/types'
66
import {
7+
buildFolderMentionLocationMap,
78
resourceMentionMatches,
89
withDesktopTabMentions,
910
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
@@ -20,6 +21,88 @@ const groups = [
2021
},
2122
]
2223

24+
describe('buildFolderMentionLocationMap', () => {
25+
it('distinguishes same-named top-level workflow and file folders by family', () => {
26+
const locations = buildFolderMentionLocationMap([
27+
{
28+
type: 'folder',
29+
items: [{ id: 'enterprise', name: 'Enterprise', parentId: null }],
30+
},
31+
{
32+
type: 'filefolder',
33+
items: [{ id: 'enterprise', name: 'Enterprise', parentId: null }],
34+
},
35+
])
36+
37+
expect(locations.get('folder:enterprise')).toEqual({
38+
familyType: 'workflow',
39+
parentNames: [],
40+
})
41+
expect(locations.get('filefolder:enterprise')).toEqual({
42+
familyType: 'file',
43+
parentNames: [],
44+
})
45+
})
46+
47+
it('returns root-first parents without repeating the current folder name', () => {
48+
const locations = buildFolderMentionLocationMap([
49+
{
50+
type: 'folder',
51+
items: [
52+
{ id: 'engineering', name: 'Engineering', parentId: null },
53+
{ id: 'accounts', name: 'Accounts', parentId: 'engineering' },
54+
{ id: 'enterprise', name: 'Enterprise', parentId: 'accounts' },
55+
],
56+
},
57+
])
58+
59+
expect(locations.get('folder:enterprise')).toEqual({
60+
familyType: 'workflow',
61+
parentNames: ['Engineering', 'Accounts'],
62+
})
63+
})
64+
65+
it('falls back to the family when a parent is missing', () => {
66+
const locations = buildFolderMentionLocationMap([
67+
{
68+
type: 'filefolder',
69+
items: [{ id: 'enterprise', name: 'Enterprise', parentId: 'missing' }],
70+
},
71+
])
72+
73+
expect(locations.get('filefolder:enterprise')).toEqual({
74+
familyType: 'file',
75+
parentNames: [],
76+
})
77+
})
78+
79+
it('terminates cyclic ancestry without repeating the current folder', () => {
80+
const locations = buildFolderMentionLocationMap([
81+
{
82+
type: 'folder',
83+
items: [
84+
{ id: 'enterprise', name: 'Enterprise', parentId: 'accounts' },
85+
{ id: 'accounts', name: 'Accounts', parentId: 'enterprise' },
86+
],
87+
},
88+
])
89+
90+
expect(locations.get('folder:enterprise')).toEqual({
91+
familyType: 'workflow',
92+
parentNames: ['Accounts'],
93+
})
94+
})
95+
96+
it('does not add locations for non-folder resources', () => {
97+
const locations = buildFolderMentionLocationMap([
98+
{ type: 'workflow', items: [{ id: 'workflow-1', name: 'Enterprise' }] },
99+
{ type: 'file', items: [{ id: 'file-1', name: 'Enterprise' }] },
100+
])
101+
102+
expect(locations.size).toBe(0)
103+
})
104+
})
105+
23106
describe('withDesktopTabMentions', () => {
24107
it('keeps Browser and Terminal as flat resource mentions with no live tabs', () => {
25108
const result = withDesktopTabMentions(groups, [], [])

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
BROWSER_SESSION_RESOURCE_ID,
55
TERMINAL_SESSION_RESOURCE_ID,
66
} from '@/lib/copilot/resources/types'
7+
import { folderAncestorChain } from '@/lib/folders/tree'
78
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
89
import { browserTabTitle } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label'
910
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
@@ -15,6 +16,57 @@ export interface ResourceMentionGroup {
1516

1617
export type ResourceMentionLevel = 'resource' | 'tab'
1718

19+
export interface FolderMentionLocation {
20+
familyType: 'workflow' | 'file'
21+
parentNames: string[]
22+
}
23+
24+
interface FolderMentionNode {
25+
id: string
26+
name: string
27+
parentId: string | null
28+
}
29+
30+
function folderFamilyType(
31+
type: MothershipResourceType
32+
): FolderMentionLocation['familyType'] | null {
33+
if (type === 'folder') return 'workflow'
34+
if (type === 'filefolder') return 'file'
35+
return null
36+
}
37+
38+
/** Builds display-only locations for the folder rows in the flat resource picker. */
39+
export function buildFolderMentionLocationMap(
40+
groups: readonly ResourceMentionGroup[]
41+
): Map<string, FolderMentionLocation> {
42+
const locations = new Map<string, FolderMentionLocation>()
43+
44+
for (const group of groups) {
45+
const familyType = folderFamilyType(group.type)
46+
if (!familyType) continue
47+
48+
const nodes = new Map<string, FolderMentionNode>(
49+
group.items.map((item) => [
50+
item.id,
51+
{
52+
id: item.id,
53+
name: item.name,
54+
parentId: typeof item.parentId === 'string' ? item.parentId : null,
55+
},
56+
])
57+
)
58+
59+
for (const node of nodes.values()) {
60+
const parentNames = folderAncestorChain(node.parentId, (id) => nodes.get(id))
61+
.filter((parent) => parent.id !== node.id)
62+
.map((parent) => parent.name)
63+
locations.set(`${group.type}:${node.id}`, { familyType, parentNames })
64+
}
65+
}
66+
67+
return locations
68+
}
69+
1870
/** A family query such as "browser" keeps that resource's live tabs visible. */
1971
export function resourceMentionMatches(item: AvailableItem, query: string): boolean {
2072
const normalized = query.toLowerCase().trim()

0 commit comments

Comments
 (0)