Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<>
<span className='truncate'>Daily digest</span>
<span data-testid='trailing' className='ml-auto flex-shrink-0'>
2m ago
</span>
</>
)
}

function renderRow(node: React.ReactNode) {
act(() => {
root.render(<button type='button'>{node}</button>)
})
}

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(
<MentionRowContent>
<LogLikeRow />
</MentionRowContent>
)

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(
<MentionRowContent location={{ familyType: 'file', parentNames: ['Growth'] }}>
<span>Enterprise</span>
</MentionRowContent>
)

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')
})
})
Original file line number Diff line number Diff line change
@@ -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 ? (
<OverflowText
label={workspaceName}
className={cn(
'max-w-[35%] shrink-0 text-[var(--text-muted)] text-xs',
!location && 'ml-auto'
)}
/>
) : 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. */}
<span
className={cn(
'flex min-w-0 flex-shrink-0 items-center',
chipContentGap,
workspaceName ? 'max-w-[50%]' : 'max-w-[65%]'
)}
>
{children}
</span>
<span className={cn('ml-auto flex min-w-0 flex-1 items-center justify-end', chipContentGap)}>
<FolderPathLabel
prefix={getResourceConfig(location.familyType).label}
segments={location.parentNames}
className='ml-0 pl-0'
/>
{workspaceLabel}
</span>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -506,25 +513,26 @@ export const PlusMenuDropdown = React.memo(
isActive && 'bg-[var(--surface-hover)]'
)}
>
{candidate.type === 'workspace' ? (
<>
<IdentityTile
initial={getWorkspaceInitial(candidate.item.name)}
logoUrl={candidate.item.logoUrl}
/>
<DropdownMenuItemLabel label={candidate.item.name} />
</>
) : (
getResourceConfig(candidate.type).renderDropdownItem({
item: candidate.item,
})
)}
{typeof workspaceName === 'string' && (
<OverflowText
label={workspaceName}
className='ml-auto max-w-[35%] shrink-0 text-[var(--text-muted)] text-xs'
/>
)}
<MentionRowContent
location={location}
workspaceName={
typeof workspaceName === 'string' ? workspaceName : undefined
}
>
{candidate.type === 'workspace' ? (
<>
<IdentityTile
initial={getWorkspaceInitial(candidate.item.name)}
logoUrl={candidate.item.logoUrl}
/>
<DropdownMenuItemLabel label={candidate.item.name} />
</>
) : (
getResourceConfig(candidate.type).renderDropdownItem({
item: candidate.item,
})
)}
</MentionRowContent>
</button>
</React.Fragment>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, FolderMentionLocation> {
const locations = new Map<string, FolderMentionLocation>()

for (const group of groups) {
if (group.type !== 'folder' && group.type !== 'filefolder') continue

const nodes = new Map<string, FolderMentionNode>()
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,28 +24,6 @@ function ItemMeta({ meta }: ItemMetaProps) {
return <span className='ml-auto shrink-0 pl-2 text-[var(--text-subtle)] text-small'>{meta}</span>
}

interface ItemFolderPathProps {
folderPath: string[]
}

/** Trailing folder-path receipt whose head segments yield space to the leaf. */
function ItemFolderPath({ folderPath }: ItemFolderPathProps) {
return (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<OverflowText
label={folderPath.slice(0, -1).join(' / ')}
className='[flex-shrink:9999]'
/>
<span className='shrink-0 whitespace-pre'> / </span>
</>
)}
<OverflowText label={folderPath[folderPath.length - 1]} />
</span>
)
}

/** Structural equality for the optional folder-path prop in memo comparators. */
function sameFolderPath(prev?: string[], next?: string[]): boolean {
return (
Expand Down Expand Up @@ -171,7 +150,7 @@ export const MemoizedWorkflowItem = memo(
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
<FolderPathLabel segments={folderPath} />
) : null}
</Command.Item>
)
Expand Down Expand Up @@ -208,7 +187,7 @@ export const MemoizedFileItem = memo(
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
<FolderPathLabel segments={folderPath} />
) : null}
</Command.Item>
)
Expand Down Expand Up @@ -330,7 +309,7 @@ export const MemoizedIconItem = memo(
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
<FolderPathLabel segments={folderPath} />
) : null}
</Command.Item>
)
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/components/ui/folder-path-label.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading
Loading