diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.test.tsx new file mode 100644 index 00000000000..4e8384d804e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MentionRowContent } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content' + +let container: HTMLDivElement +let root: Root + +/** Stands in for a family renderer that pins trailing content with `ml-auto`, as the log row does. */ +function LogLikeRow() { + return ( + <> + Daily digest + + 2m ago + + + ) +} + +function renderRow(node: React.ReactNode) { + act(() => { + root.render() + }) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('MentionRowContent', () => { + it('leaves a location-less row unwrapped so `ml-auto` still reaches the row edge', () => { + renderRow( + + + + ) + + const trailing = container.querySelector('[data-testid="trailing"]') + expect(trailing).not.toBeNull() + expect(trailing?.parentElement?.tagName).toBe('BUTTON') + }) + + it('wraps and caps the name only when a location follows it', () => { + renderRow( + + Enterprise + + ) + + const name = container.querySelector('button > span') + expect(name?.className).toContain('max-w-[65%]') + expect(name?.className).toContain('flex-shrink-0') + expect(container.textContent).toContain('Files') + expect(container.textContent).toContain('Growth') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.tsx new file mode 100644 index 00000000000..ef8c1a1ff35 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { ReactNode } from 'react' +import { FolderPathLabel } from '@/components/ui' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { FolderMentionLocation } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' + +export interface MentionRowContentProps { + /** The resource family's own row rendering, a fragment of the row's flex children. */ + children: ReactNode + /** Present only for folder rows, which need a location to disambiguate same-named siblings. */ + location?: FolderMentionLocation +} + +/** + * Body of one flat mention row. + * + * Rows without a location render their family output as direct children of the row + * button, unwrapped. That is load-bearing rather than incidental: renderers such as + * the log row pin trailing content with `ml-auto`, which only reaches the row's right + * edge while the button is its flex parent. Wrapping every row would silently pull + * those timestamps back beside the name. + */ +export function MentionRowContent({ children, location }: MentionRowContentProps) { + if (!location) return <>{children} + + return ( + <> + {/* Capped rather than shrinkable so a long name cannot squeeze out the segment + that tells two same-named folders apart. */} + + {children} + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index a16be8f1c04..bd6fa5f247b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -15,7 +15,9 @@ import { } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' +import { MentionRowContent } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content' import { + buildFolderMentionLocationMap, resourceMentionMatches, withDesktopTabMentions, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' @@ -119,6 +121,11 @@ export const PlusMenuDropdown = React.memo( return attachable.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)) }, [availableResources, browserTabs, isMention, terminalTabs]) + const folderMentionLocations = useMemo( + () => buildFolderMentionLocationMap(visibleResources), + [visibleResources] + ) + const treeSections = useResourceTreeSections({ groups: visibleResources, structureFolders, @@ -298,7 +305,9 @@ export const PlusMenuDropdown = React.memo( // Plus-click shows short fixed labels (Workflows, Tables, …) — let it size // to its content via the emcn DropdownMenuContent default max-w. // Mention mode renders resource names directly, so widen for breathing room. - isMention && 'max-w-[min(300px,calc(100vw-32px))]' + // Wide enough that a folder row fits its name and its right-aligned + // location column without either collapsing to a stub. + isMention && 'w-[min(380px,calc(100vw-32px))] max-w-[calc(100vw-32px)]' )} onCloseAutoFocus={handleCloseAutoFocus} onOpenAutoFocus={handleOpenAutoFocus} @@ -334,6 +343,7 @@ export const PlusMenuDropdown = React.memo( filteredItems.map(({ type, item }, index) => { const config = getResourceConfig(type) const isActive = index === activeIndex + const location = folderMentionLocations.get(`${type}:${item.id}`) return ( ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts index bed14025882..bf23bdf83be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts @@ -4,6 +4,7 @@ import { TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' import { + buildFolderMentionLocationMap, resourceMentionMatches, withDesktopTabMentions, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' @@ -20,6 +21,88 @@ const groups = [ }, ] +describe('buildFolderMentionLocationMap', () => { + it('distinguishes same-named top-level workflow and file folders by family', () => { + const locations = buildFolderMentionLocationMap([ + { + type: 'folder', + items: [{ id: 'enterprise', name: 'Enterprise', parentId: null }], + }, + { + type: 'filefolder', + items: [{ id: 'enterprise', name: 'Enterprise', parentId: null }], + }, + ]) + + expect(locations.get('folder:enterprise')).toEqual({ + familyType: 'workflow', + parentNames: [], + }) + expect(locations.get('filefolder:enterprise')).toEqual({ + familyType: 'file', + parentNames: [], + }) + }) + + it('returns root-first parents without repeating the current folder name', () => { + const locations = buildFolderMentionLocationMap([ + { + type: 'folder', + items: [ + { id: 'engineering', name: 'Engineering', parentId: null }, + { id: 'accounts', name: 'Accounts', parentId: 'engineering' }, + { id: 'enterprise', name: 'Enterprise', parentId: 'accounts' }, + ], + }, + ]) + + expect(locations.get('folder:enterprise')).toEqual({ + familyType: 'workflow', + parentNames: ['Engineering', 'Accounts'], + }) + }) + + it('falls back to the family when a parent is missing', () => { + const locations = buildFolderMentionLocationMap([ + { + type: 'filefolder', + items: [{ id: 'enterprise', name: 'Enterprise', parentId: 'missing' }], + }, + ]) + + expect(locations.get('filefolder:enterprise')).toEqual({ + familyType: 'file', + parentNames: [], + }) + }) + + it('terminates cyclic ancestry without repeating the current folder', () => { + const locations = buildFolderMentionLocationMap([ + { + type: 'folder', + items: [ + { id: 'enterprise', name: 'Enterprise', parentId: 'accounts' }, + { id: 'accounts', name: 'Accounts', parentId: 'enterprise' }, + ], + }, + ]) + + expect(locations.get('folder:enterprise')).toEqual({ + familyType: 'workflow', + parentNames: ['Accounts'], + }) + }) + + it('does not add locations for non-folder resources', () => { + const locations = buildFolderMentionLocationMap([ + { type: 'workflow', items: [{ id: 'workflow-1', name: 'Enterprise' }] }, + { type: 'file', items: [{ id: 'file-1', name: 'Enterprise' }] }, + ]) + + expect(locations.size).toBe(0) + }) +}) + describe('withDesktopTabMentions', () => { it('keeps Browser and Terminal as flat resource mentions with no live tabs', () => { const result = withDesktopTabMentions(groups, [], []) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts index bbbe84ad29e..a8faa8be477 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts @@ -4,6 +4,7 @@ import { BROWSER_SESSION_RESOURCE_ID, TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' +import { folderAncestorChain } from '@/lib/folders/tree' import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' import { browserTabTitle } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' @@ -15,6 +16,57 @@ export interface ResourceMentionGroup { export type ResourceMentionLevel = 'resource' | 'tab' +export interface FolderMentionLocation { + familyType: 'workflow' | 'file' + parentNames: string[] +} + +interface FolderMentionNode { + id: string + name: string + parentId: string | null +} + +function folderFamilyType( + type: MothershipResourceType +): FolderMentionLocation['familyType'] | null { + if (type === 'folder') return 'workflow' + if (type === 'filefolder') return 'file' + return null +} + +/** Builds display-only locations for the folder rows in the flat resource picker. */ +export function buildFolderMentionLocationMap( + groups: readonly ResourceMentionGroup[] +): Map { + const locations = new Map() + + for (const group of groups) { + const familyType = folderFamilyType(group.type) + if (!familyType) continue + + const nodes = new Map( + group.items.map((item) => [ + item.id, + { + id: item.id, + name: item.name, + parentId: typeof item.parentId === 'string' ? item.parentId : null, + }, + ]) + ) + + for (const node of nodes.values()) { + const parentNames = folderAncestorChain(node.parentId, (id) => nodes.get(id)) + .filter((parent) => parent.id !== node.id) + .map((parent) => parent.name) + locations.set(`${group.type}:${node.id}`, { familyType, parentNames }) + } + } + + return locations +} + /** A family query such as "browser" keeps that resource's live tabs visible. */ export function resourceMentionMatches(item: AvailableItem, query: string): boolean { const normalized = query.toLowerCase().trim() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index c67b2089917..02c3a3accdc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -4,6 +4,7 @@ import type { ComponentType } from 'react' import { memo } from 'react' import { File, Workflow } from '@sim/emcn/icons' import { Command } from 'cmdk' +import { FolderPathLabel } from '@/components/ui' import { HEX_COLOR_REGEX } from '@/lib/branding' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' @@ -23,27 +24,6 @@ function ItemMeta({ meta }: ItemMetaProps) { ) } -interface ItemFolderPathProps { - folderPath: string[] -} - -/** Trailing folder-path receipt whose head segments yield space to the leaf. */ -function ItemFolderPath({ folderPath }: ItemFolderPathProps) { - return ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - - ) -} - /** Structural equality for the optional folder-path prop in memo comparators. */ function sameFolderPath(prev?: string[], next?: string[]): boolean { return ( @@ -167,7 +147,7 @@ export const MemoizedWorkflowItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - + ) : null} ) @@ -204,7 +184,7 @@ export const MemoizedFileItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - + ) : null} ) @@ -349,7 +329,7 @@ export const MemoizedIconItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - + ) : null} ) diff --git a/apps/sim/components/ui/folder-path-label.test.ts b/apps/sim/components/ui/folder-path-label.test.ts new file mode 100644 index 00000000000..e479e87a6df --- /dev/null +++ b/apps/sim/components/ui/folder-path-label.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { collapseFolderPath } from '@/components/ui/folder-path-label' + +describe('collapseFolderPath', () => { + it('leaves a shallow chain untouched', () => { + expect(collapseFolderPath([])).toEqual([]) + expect(collapseFolderPath(['Growth'])).toEqual(['Growth']) + expect(collapseFolderPath(['Growth', 'Campaigns', 'Q3'])).toEqual(['Growth', 'Campaigns', 'Q3']) + }) + + it('drops whole ancestors rather than clipping one mid-word', () => { + expect(collapseFolderPath(['Growth', 'Campaigns', 'Paid', 'Q3'])).toEqual(['Growth', '…', 'Q3']) + }) + + it('keeps the root and the leaf however deep the chain runs', () => { + const deep = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] + expect(collapseFolderPath(deep)).toEqual(['A', '…', 'G']) + }) + + it('does not mutate the input', () => { + const segments = ['A', 'B', 'C', 'D'] + collapseFolderPath(segments) + expect(segments).toEqual(['A', 'B', 'C', 'D']) + }) +}) diff --git a/apps/sim/components/ui/folder-path-label.tsx b/apps/sim/components/ui/folder-path-label.tsx new file mode 100644 index 00000000000..3bf9bca267d --- /dev/null +++ b/apps/sim/components/ui/folder-path-label.tsx @@ -0,0 +1,64 @@ +import { cn } from '@sim/emcn' + +/** + * Ancestors kept before the path collapses. Three is the widest chain that still + * reads at the ~30% of a menu row this label is allowed to occupy. + */ +const MAX_VISIBLE_SEGMENTS = 3 +const ELLIPSIS = '…' + +/** + * Collapses a root-first folder chain so an over-long path drops whole ancestors + * instead of clipping one mid-word: `Growth / … / Q3` rather than `Growth / Mark…`. + * + * The root orients and the leaf disambiguates, so those are the two that survive; + * everything between them is what the reader can least act on. + */ +export function collapseFolderPath(segments: readonly string[]): string[] { + if (segments.length <= MAX_VISIBLE_SEGMENTS) return [...segments] + return [segments[0], ELLIPSIS, segments[segments.length - 1]] +} + +export interface FolderPathLabelProps { + /** Root-first ancestor names of the row's resource. */ + segments: readonly string[] + /** + * Pinned lead-in that never clips — the resource family (`Files`, `Workflows`) + * when the label doubles as the row's disambiguator. + */ + prefix?: string + className?: string +} + +/** + * Right-aligned location receipt for a menu row. Head segments yield their space + * first so the leaf — the segment that tells two same-named rows apart — is the + * last thing to clip. + */ +export function FolderPathLabel({ segments, prefix, className }: FolderPathLabelProps) { + const visible = collapseFolderPath(segments) + const leaf = visible.at(-1) + const head = visible.slice(0, -1) + const hasLeadIn = Boolean(prefix) || head.length > 0 + + if (!hasLeadIn && !leaf) return null + + return ( + + {prefix && {prefix}} + {head.length > 0 && ( + + {prefix ? ` / ${head.join(' / ')}` : head.join(' / ')} + + )} + {leaf && ( + <> + {hasLeadIn && / } + {leaf} + + )} + + ) +} diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index 234f6f50a60..fc7e4cb55b1 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -1,4 +1,9 @@ export { Button, buttonVariants } from './button' +export { + collapseFolderPath, + FolderPathLabel, + type FolderPathLabelProps, +} from './folder-path-label' export { GeneratedPasswordInput } from './generated-password-input' export { Progress } from './progress' export {