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,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. */}
<span className='flex max-w-[65%] flex-shrink-0 items-center gap-2 [&>span]:min-w-0 [&>span]:truncate'>
{children}
</span>
<FolderPathLabel
prefix={getResourceConfig(location.familyType).label}
segments={location.parentNames}
/>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 (
<button
key={`${type}:${item.id}`}
Expand All @@ -350,7 +360,9 @@ export const PlusMenuDropdown = React.memo(
isActive && 'bg-[var(--surface-hover)]'
)}
>
{config.renderDropdownItem({ item })}
<MentionRowContent location={location}>
{config.renderDropdownItem({ item })}
</MentionRowContent>
</button>
)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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, [], [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, FolderMentionLocation> {
const locations = new Map<string, FolderMentionLocation>()

for (const group of groups) {
const familyType = folderFamilyType(group.type)
if (!familyType) continue

const nodes = new Map<string, FolderMentionNode>(
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)
}

/** Structural equality for the optional folder-path prop in memo comparators. */
function sameFolderPath(prev?: string[], next?: string[]): boolean {
return (
Expand Down Expand Up @@ -167,7 +147,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 @@ -204,7 +184,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 @@ -349,7 +329,7 @@ export const MemoizedIconItem = memo(
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
<FolderPathLabel segments={folderPath} />
) : null}
</Command.Item>
)
Expand Down
Loading
Loading