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..aa1500d4786 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/mention-row-content.tsx @@ -0,0 +1,69 @@ +'use client' + +import type { ReactNode } from 'react' +import { chipContentGap, cn, OverflowText } from '@sim/emcn' +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 + workspaceName?: string +} + +/** + * 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, workspaceName }: MentionRowContentProps) { + const workspaceLabel = workspaceName ? ( + + ) : null + + if (!location) { + return ( + <> + {children} + {workspaceLabel} + + ) + } + + return ( + <> + {/* Capped rather than shrinkable so a long name cannot squeeze out the segment + that tells two same-named folders apart. */} + + {children} + + + + {workspaceLabel} + + + ) +} 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 3759f6e3d81..dadb1654859 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 @@ -10,7 +10,6 @@ import { DropdownMenuSearchInput, DropdownMenuTrigger, dropdownMenuRowClass, - OverflowText, } from '@sim/emcn' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { getWorkspaceInitial } from '@/lib/workspaces/initials' @@ -32,7 +31,9 @@ import { MENTION_PREVIEW_DEFAULT_LIMIT, } 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, buildMentionPreview, type ResourceMentionCandidate, resourceMentionMatches, @@ -218,6 +219,11 @@ export const PlusMenuDropdown = React.memo( terminalTabs, ]) + const folderMentionLocations = useMemo( + () => buildFolderMentionLocationMap(visibleResources), + [visibleResources] + ) + /** * Built from this workspace's own inventory, which has no foldered families in an * organization chat: there each workspace submenu builds its own sections, because @@ -481,6 +487,7 @@ export const PlusMenuDropdown = React.memo( const config = type === 'workspace' ? null : getResourceConfig(type) const workspaceName = 'workspaceName' in item ? item.workspaceName : undefined const isActive = index === activeIndex + const location = folderMentionLocations.get(candidateKey(candidate)) /* Items arrive grouped by family (one group per type, ordered by RESOURCE_MENU_ORDER), so a type change marks a section boundary. Deriving the heading from the flat list keeps `activeIndex` — and @@ -506,25 +513,26 @@ export const PlusMenuDropdown = React.memo( isActive && 'bg-[var(--surface-hover)]' )} > - {candidate.type === 'workspace' ? ( - <> - - - - ) : ( - getResourceConfig(candidate.type).renderDropdownItem({ - item: candidate.item, - }) - )} - {typeof workspaceName === 'string' && ( - - )} + + {candidate.type === 'workspace' ? ( + <> + + + + ) : ( + getResourceConfig(candidate.type).renderDropdownItem({ + item: candidate.item, + }) + )} + ) 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 4a7e45c129f..9e35e9e1cae 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 @@ -1,6 +1,7 @@ import type { BrowserTabState } from '@sim/browser-protocol' import type { TerminalTabState } from '@sim/terminal-protocol' import { browserTabTitle } from '@/lib/browser-agent/tab-label' +import { folderAncestorChain } from '@/lib/folders/tree' import { terminalResourceId } from '@/lib/terminal/resource-id' import { terminalTabTitle } from '@/lib/terminal/tab-label' import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' @@ -33,6 +34,69 @@ export function withFolderMentions( ) } +export interface FolderMentionLocation { + familyType: 'workflow' | 'file' | 'table' | 'knowledgebase' + parentNames: string[] +} + +interface FolderMentionNode { + id: string + name: string + parentId: string | null + familyType: FolderMentionLocation['familyType'] + workspaceId: string +} + +function folderFamilyType( + type: MothershipResourceType, + item: AvailableItem +): FolderMentionLocation['familyType'] | null { + if (type === 'folder') { + if (item.mentionFamily === 'Table folders') return 'table' + if (item.mentionFamily === 'Knowledge base folders') return 'knowledgebase' + 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) { + if (group.type !== 'folder' && group.type !== 'filefolder') continue + + const nodes = new Map() + for (const item of group.items) { + const familyType = folderFamilyType(group.type, item) + if (!familyType) continue + const workspaceId = typeof item.workspaceId === 'string' ? item.workspaceId : '' + nodes.set(`${familyType}:${workspaceId}:${item.id}`, { + id: item.id, + name: item.name, + parentId: typeof item.parentId === 'string' ? item.parentId : null, + familyType, + workspaceId, + }) + } + + for (const node of nodes.values()) { + const { familyType, workspaceId } = node + const parentNames = folderAncestorChain(node.parentId, (id) => + nodes.get(`${familyType}:${workspaceId}:${id}`) + ) + .filter((parent) => parent.id !== node.id) + .map((parent) => parent.name) + locations.set(`${group.type}:${workspaceId}:${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 c0ce35be12d..0f77a5f6495 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 @@ -6,6 +6,7 @@ import { OverflowText } from '@sim/emcn' import { File, Workflow } from '@sim/emcn/icons' import { Command } from 'cmdk' import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { FolderPathLabel } from '@/components/ui' import { getWorkspaceInitial } from '@/lib/workspaces/initials' 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,28 +24,6 @@ function ItemMeta({ meta }: ItemMetaProps) { return {meta} } -interface ItemFolderPathProps { - folderPath: string[] -} - -/** Trailing folder-path receipt whose head segments yield space to the leaf. */ -function ItemFolderPath({ folderPath }: ItemFolderPathProps) { - return ( - - {folderPath.length > 1 && ( - <> - - / - - )} - - - ) -} - /** Structural equality for the optional folder-path prop in memo comparators. */ function sameFolderPath(prev?: string[], next?: string[]): boolean { return ( @@ -171,7 +150,7 @@ export const MemoizedWorkflowItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - + ) : null} ) @@ -208,7 +187,7 @@ export const MemoizedFileItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - + ) : null} ) @@ -330,7 +309,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..445674f44cc --- /dev/null +++ b/apps/sim/components/ui/folder-path-label.tsx @@ -0,0 +1,69 @@ +import { cn, OverflowText } 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]] +} + +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}} + {prefix && head.length > 0 && / } + {head.length > 0 && ( + + {head.join(' / ')} + + )} + {leaf && ( + <> + {hasLeadIn && / } + + + )} + + ) +} diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index f343ed3b309..0610db18634 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -1,4 +1,5 @@ export { ActivityStatus } from '@/components/ui/activity-status' +export { FolderPathLabel } from './folder-path-label' export { GeneratedPasswordInput } from './generated-password-input' export { Progress } from './progress' export {