Skip to content

Commit 99a22d8

Browse files
committed
refactor(chat): one folder-path label for the mention menu and cmd-K
The mention menu's folder location and the cmd-K row's folder receipt were two copies of the same flex-shrink layout. Both now render `FolderPathLabel`, which collapses ancestors past the third into a single `…` segment so a deep path drops whole folders instead of clipping one mid-word. Mention rows also cap the name at 65% and fix the popover at 380px, so the location column keeps a stable right edge and can never be squeezed out by a long resource name.
1 parent c829f81 commit 99a22d8

5 files changed

Lines changed: 122 additions & 60 deletions

File tree

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

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DropdownMenuSearchInput,
99
DropdownMenuTrigger,
1010
} from '@sim/emcn'
11+
import { FolderPathLabel } from '@/components/ui'
1112
import {
1213
ResourceMenuSections,
1314
useAvailableResources,
@@ -42,35 +43,6 @@ const NON_ATTACHABLE_RESOURCE_TYPES = new Set<MothershipResourceType>(['browser'
4243
const EMPTY_BROWSER_TABS = [] as const
4344
const EMPTY_TERMINAL_TABS = [] as const
4445

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-
7446
interface PlusMenuDropdownProps {
7547
workspaceId: string
7648
/**
@@ -333,7 +305,9 @@ export const PlusMenuDropdown = React.memo(
333305
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
334306
// to its content via the emcn DropdownMenuContent default max-w.
335307
// Mention mode renders resource names directly, so widen for breathing room.
336-
isMention && 'max-w-[min(300px,calc(100vw-32px))]'
308+
// Wide enough that a folder row fits its name and its right-aligned
309+
// location column without either collapsing to a stub.
310+
isMention && 'w-[min(380px,calc(100vw-32px))] max-w-[calc(100vw-32px)]'
337311
)}
338312
onCloseAutoFocus={handleCloseAutoFocus}
339313
onOpenAutoFocus={handleOpenAutoFocus}
@@ -370,9 +344,6 @@ export const PlusMenuDropdown = React.memo(
370344
const config = getResourceConfig(type)
371345
const isActive = index === activeIndex
372346
const location = folderMentionLocations.get(`${type}:${item.id}`)
373-
const locationPath = location
374-
? [getResourceConfig(location.familyType).label, ...location.parentNames]
375-
: null
376347
return (
377348
<button
378349
key={`${type}:${item.id}`}
@@ -384,13 +355,27 @@ export const PlusMenuDropdown = React.memo(
384355
handleSelect({ type, id: item.id, title: item.name })
385356
}}
386357
className={cn(
387-
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
358+
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
388359
/* `activeIndex` is the cursor, not a selection — hover surface. */
389360
isActive && 'bg-[var(--surface-hover)]'
390361
)}
391362
>
392-
{config.renderDropdownItem({ item })}
393-
{locationPath && <FolderMentionPath segments={locationPath} />}
363+
{/* Capped, not shrinkable: a long name must never squeeze out the
364+
location that tells two same-named folders apart. */}
365+
<span
366+
className={cn(
367+
'flex min-w-0 items-center gap-2 [&>span]:min-w-0 [&>span]:truncate',
368+
location && 'max-w-[65%] flex-shrink-0'
369+
)}
370+
>
371+
{config.renderDropdownItem({ item })}
372+
</span>
373+
{location && (
374+
<FolderPathLabel
375+
prefix={getResourceConfig(location.familyType).label}
376+
segments={location.parentNames}
377+
/>
378+
)}
394379
</button>
395380
)
396381
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ComponentType } from 'react'
44
import { memo } from 'react'
55
import { File, Workflow } from '@sim/emcn/icons'
66
import { Command } from 'cmdk'
7+
import { FolderPathLabel } from '@/components/ui'
78
import { HEX_COLOR_REGEX } from '@/lib/branding'
89
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
910
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
@@ -23,27 +24,6 @@ function ItemMeta({ meta }: ItemMetaProps) {
2324
)
2425
}
2526

26-
interface ItemFolderPathProps {
27-
folderPath: string[]
28-
}
29-
30-
/** Trailing folder-path receipt whose head segments yield space to the leaf. */
31-
function ItemFolderPath({ folderPath }: ItemFolderPathProps) {
32-
return (
33-
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
34-
{folderPath.length > 1 && (
35-
<>
36-
<span className='min-w-0 truncate [flex-shrink:9999]'>
37-
{folderPath.slice(0, -1).join(' / ')}
38-
</span>
39-
<span className='flex-shrink-0 whitespace-pre'> / </span>
40-
</>
41-
)}
42-
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
43-
</span>
44-
)
45-
}
46-
4727
/** Structural equality for the optional folder-path prop in memo comparators. */
4828
function sameFolderPath(prev?: string[], next?: string[]): boolean {
4929
return (
@@ -167,7 +147,7 @@ export const MemoizedWorkflowItem = memo(
167147
{meta ? (
168148
<ItemMeta meta={meta} />
169149
) : folderPath && folderPath.length > 0 ? (
170-
<ItemFolderPath folderPath={folderPath} />
150+
<FolderPathLabel segments={folderPath} />
171151
) : null}
172152
</Command.Item>
173153
)
@@ -204,7 +184,7 @@ export const MemoizedFileItem = memo(
204184
{meta ? (
205185
<ItemMeta meta={meta} />
206186
) : folderPath && folderPath.length > 0 ? (
207-
<ItemFolderPath folderPath={folderPath} />
187+
<FolderPathLabel segments={folderPath} />
208188
) : null}
209189
</Command.Item>
210190
)
@@ -349,7 +329,7 @@ export const MemoizedIconItem = memo(
349329
{meta ? (
350330
<ItemMeta meta={meta} />
351331
) : folderPath && folderPath.length > 0 ? (
352-
<ItemFolderPath folderPath={folderPath} />
332+
<FolderPathLabel segments={folderPath} />
353333
) : null}
354334
</Command.Item>
355335
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { collapseFolderPath } from '@/components/ui/folder-path-label'
6+
7+
describe('collapseFolderPath', () => {
8+
it('leaves a shallow chain untouched', () => {
9+
expect(collapseFolderPath([])).toEqual([])
10+
expect(collapseFolderPath(['Growth'])).toEqual(['Growth'])
11+
expect(collapseFolderPath(['Growth', 'Campaigns', 'Q3'])).toEqual(['Growth', 'Campaigns', 'Q3'])
12+
})
13+
14+
it('drops whole ancestors rather than clipping one mid-word', () => {
15+
expect(collapseFolderPath(['Growth', 'Campaigns', 'Paid', 'Q3'])).toEqual(['Growth', '…', 'Q3'])
16+
})
17+
18+
it('keeps the root and the leaf however deep the chain runs', () => {
19+
const deep = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
20+
expect(collapseFolderPath(deep)).toEqual(['A', '…', 'G'])
21+
})
22+
23+
it('does not mutate the input', () => {
24+
const segments = ['A', 'B', 'C', 'D']
25+
collapseFolderPath(segments)
26+
expect(segments).toEqual(['A', 'B', 'C', 'D'])
27+
})
28+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { cn } from '@sim/emcn'
2+
3+
/**
4+
* Ancestors kept before the path collapses. Three is the widest chain that still
5+
* reads at the ~30% of a menu row this label is allowed to occupy.
6+
*/
7+
const MAX_VISIBLE_SEGMENTS = 3
8+
const ELLIPSIS = '…'
9+
10+
/**
11+
* Collapses a root-first folder chain so an over-long path drops whole ancestors
12+
* instead of clipping one mid-word: `Growth / … / Q3` rather than `Growth / Mark…`.
13+
*
14+
* The root orients and the leaf disambiguates, so those are the two that survive;
15+
* everything between them is what the reader can least act on.
16+
*/
17+
export function collapseFolderPath(segments: readonly string[]): string[] {
18+
if (segments.length <= MAX_VISIBLE_SEGMENTS) return [...segments]
19+
return [segments[0], ELLIPSIS, segments[segments.length - 1]]
20+
}
21+
22+
export interface FolderPathLabelProps {
23+
/** Root-first ancestor names of the row's resource. */
24+
segments: readonly string[]
25+
/**
26+
* Pinned lead-in that never clips — the resource family (`Files`, `Workflows`)
27+
* when the label doubles as the row's disambiguator.
28+
*/
29+
prefix?: string
30+
className?: string
31+
}
32+
33+
/**
34+
* Right-aligned location receipt for a menu row. Head segments yield their space
35+
* first so the leaf — the segment that tells two same-named rows apart — is the
36+
* last thing to clip.
37+
*/
38+
export function FolderPathLabel({ segments, prefix, className }: FolderPathLabelProps) {
39+
const visible = collapseFolderPath(segments)
40+
const leaf = visible.at(-1)
41+
const head = visible.slice(0, -1)
42+
const hasLeadIn = Boolean(prefix) || head.length > 0
43+
44+
if (!hasLeadIn && !leaf) return null
45+
46+
return (
47+
<span
48+
className={cn('ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small', className)}
49+
>
50+
{prefix && <span className='flex-shrink-0'>{prefix}</span>}
51+
{head.length > 0 && (
52+
<span className='min-w-0 truncate whitespace-pre [flex-shrink:9999]'>
53+
{prefix ? ` / ${head.join(' / ')}` : head.join(' / ')}
54+
</span>
55+
)}
56+
{leaf && (
57+
<>
58+
{hasLeadIn && <span className='flex-shrink-0 whitespace-pre'> / </span>}
59+
<span className='min-w-0 truncate'>{leaf}</span>
60+
</>
61+
)}
62+
</span>
63+
)
64+
}

apps/sim/components/ui/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
export { Button, buttonVariants } from './button'
2+
export {
3+
collapseFolderPath,
4+
FolderPathLabel,
5+
type FolderPathLabelProps,
6+
} from './folder-path-label'
27
export { GeneratedPasswordInput } from './generated-password-input'
38
export { Progress } from './progress'
49
export {

0 commit comments

Comments
 (0)