Skip to content
Merged
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
Expand Up @@ -41,7 +41,7 @@ import type {
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { listIntegrations } from '@/blocks/integration-matcher'
import { listIntegrationsByPopularity } from '@/blocks/integration-matcher'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
Expand Down Expand Up @@ -255,7 +255,7 @@ export function useAvailableResources(
},
{
type: 'integration' as const,
items: listIntegrations().map((integration) => ({
items: listIntegrationsByPopularity().map((integration) => ({
id: integration.blockType,
name: integration.name,
iconComponent: integration.icon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function BrowserDownloads({ scopeId, open, requestOpen, onClose }: Browse
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' sideOffset={5} className='w-[320px] p-1'>
<DropdownMenuContent align='end' sideOffset={5} className='w-[320px]'>
<DropdownMenuLabel>Downloads</DropdownMenuLabel>
{downloads.map((download) => {
const completed = download.state === 'completed'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
byResourceMenuOrder,
getResourceConfig,
invalidateResourceQueries,
MENTION_PREVIEW_DEFAULT_LIMIT,
RESOURCE_MENU_ORDER,
RESOURCE_REGISTRY,
} from './resource-registry'
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ export interface ResourceTypeConfig {
renderTabIcon: (resource: MothershipResource, className: string) => ReactNode
renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode
/**
* How many of this family's candidates an unfiltered `@` list shows.
* Uncapped by default; set it for a family whose near-identical rows would
* otherwise bury every other family.
* How many of this family's candidates an unfiltered `@` list shows, overriding
* {@link MENTION_PREVIEW_DEFAULT_LIMIT}. Raise it only for a family whose rows a
* user browses; the unfiltered list is a preview, not a browser, and typing a
* query lifts the cap entirely — see `buildMentionPreview`.
*/
mentionPreviewLimit?: number
}
Expand Down Expand Up @@ -218,7 +219,6 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
<Library className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <LogDropdownItem {...props} />,
mentionPreviewLimit: 5,
},
integration: {
type: 'integration',
Expand Down Expand Up @@ -249,6 +249,13 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
},
} as const

/**
* Rows per family in the unfiltered `@` preview, unless the family overrides it
* with {@link ResourceTypeConfig.mentionPreviewLimit}. Enough to show what a family
* holds without any one of them crowding out the rest.
*/
export const MENTION_PREVIEW_DEFAULT_LIMIT = 5

/**
* Top-down order for every menu that lists resource families, mirroring the
* workspace sidebar so a user reads the same sequence in both places. The two
Expand Down Expand Up @@ -324,7 +331,7 @@ const RESOURCE_INVALIDATORS: Record<
},
/**
* Integrations are sourced from the static integration catalog
* (`listIntegrations()`), not a server-backed query, so there is nothing to
* (`listIntegrationsByPopularity()`), not a server-backed query, so there is nothing to
* invalidate when one is added.
*/
integration: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSearchInput,
DropdownMenuTrigger,
dropdownMenuRowClass,
} from '@sim/emcn'
import {
ResourceMenuSections,
resourceFromItem,
useAvailableResources,
useResourceTreeSections,
} 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 {
getResourceConfig,
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 {
buildMentionPreview,
resourceMentionMatches,
withDesktopTabMentions,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
Expand All @@ -27,6 +33,14 @@ import type {
import { useBrowserSessionStore } from '@/stores/browser-session/store'
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'

/**
* The `@` list is shorter than the emcn menu default (420px, sized for right-click
* action menus). This one floats directly over the chat input, so a menu tall enough
* to swallow the conversation behind it reads as a takeover rather than an
* autocomplete. ~10 rows is enough to show several families at once.
*/
const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available-height,280px))]'

/**
* Resource types that are only offered via `@`-mention autocomplete and hidden
* from the `+` browse menu. Integrations are searchable inline (e.g. typing
Expand Down Expand Up @@ -130,11 +144,10 @@ export const PlusMenuDropdown = React.memo(
const q = rawQuery.toLowerCase().trim()
if (!isMention && !q) return null
if (isMention && !q) {
return visibleResources.flatMap(({ type, items }) => {
const limit = getResourceConfig(type).mentionPreviewLimit
const previewed = limit === undefined ? items : items.slice(0, limit)
return previewed.map((item) => ({ type, item }))
})
return buildMentionPreview(
visibleResources,
(type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT
)
}
return visibleResources.flatMap(({ type, items }) =>
items.filter((item) => resourceMentionMatches(item, q)).map((item) => ({ type, item }))
Expand Down Expand Up @@ -298,7 +311,7 @@ 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))]'
isMention && `max-w-[min(300px,calc(100vw-32px))] ${MENTION_MAX_HEIGHT_CLASS}`
)}
onCloseAutoFocus={handleCloseAutoFocus}
onOpenAutoFocus={handleOpenAutoFocus}
Expand Down Expand Up @@ -334,28 +347,36 @@ export const PlusMenuDropdown = React.memo(
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
const isActive = index === activeIndex
/* 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
therefore every keyboard path — indexing exactly what it did. */
const startsSection = index === 0 || filteredItems[index - 1]?.type !== type
return (
<button
key={`${type}:${item.id}`}
type='button'
role='menuitem'
data-filtered-idx={index}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect(resourceFromItem(type, item))
}}
className={cn(
'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)]',
/* `activeIndex` is the cursor, not a selection — hover surface. */
isActive && 'bg-[var(--surface-hover)]'
)}
>
{config.renderDropdownItem({ item })}
</button>
<React.Fragment key={`${type}:${item.id}`}>
{startsSection && <DropdownMenuLabel>{config.label}</DropdownMenuLabel>}
<button
type='button'
role='menuitem'
data-filtered-idx={index}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect(resourceFromItem(type, item))
}}
className={cn(
dropdownMenuRowClass,
'w-full text-left',
/* `activeIndex` is the cursor, not a selection — hover surface. */
isActive && 'bg-[var(--surface-hover)]'
)}
>
{config.renderDropdownItem({ item })}
</button>
</React.Fragment>
)
})
) : (
<div className='px-2 py-1.5 text-center text-[var(--text-tertiary)] text-caption'>
<div className='flex h-[28px] items-center justify-center px-2 text-[var(--text-muted)] text-caption'>
No results
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
BROWSER_SESSION_RESOURCE_ID,
TERMINAL_SESSION_RESOURCE_ID,
} from '@/lib/copilot/resources/types'
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
import {
buildMentionPreview,
resourceMentionMatches,
withDesktopTabMentions,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
Expand Down Expand Up @@ -106,3 +108,53 @@ describe('withDesktopTabMentions', () => {
expect(resourceMentionMatches(tab, 'terminal')).toBe(false)
})
})

describe('buildMentionPreview', () => {
const item = (id: string): AvailableItem => ({ id, name: id })
const many = (n: number) => Array.from({ length: n }, (_, i) => item(`i${i}`))

it('caps each family so a large one cannot bury the families after it', () => {
const preview = buildMentionPreview(
[
{ type: 'integration', items: many(300) },
{ type: 'workflow', items: [item('thermal-field')] },
],
() => 5
)

expect(preview.filter((c) => c.type === 'integration')).toHaveLength(5)
expect(preview.map((c) => c.item.id)).toContain('thermal-field')
})

it('lets a family raise its own cap', () => {
const preview = buildMentionPreview(
[
{ type: 'integration', items: many(10) },
{ type: 'workflow', items: many(10) },
],
(type) => (type === 'workflow' ? 2 : 5)
)

expect(preview.filter((c) => c.type === 'integration')).toHaveLength(5)
expect(preview.filter((c) => c.type === 'workflow')).toHaveLength(2)
})

it('keeps families in the order they were given, so headings stay contiguous', () => {
const preview = buildMentionPreview(
[
{ type: 'integration', items: many(3) },
{ type: 'workflow', items: many(3) },
],
() => 5
)

const boundaries = preview.filter((c, i) => i > 0 && preview[i - 1].type !== c.type)
expect(boundaries).toHaveLength(1)
expect(preview.at(-1)?.type).toBe('workflow')
})

it('keeps a family shorter than the cap intact', () => {
const preview = buildMentionPreview([{ type: 'workflow', items: many(2) }], () => 5)
expect(preview).toHaveLength(2)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,30 @@ export function withDesktopTabMentions(
return group
})
}

/** One row of the `@` list: an item plus the family it came from. */
export interface ResourceMentionCandidate {
type: MothershipResourceType
item: AvailableItem
}

/**
* The rows an `@` list shows for an EMPTY query — a preview of what is mentionable,
* capped per family so no one family can bury the rest.
*
* `integration` carries 300+ near-identical rows and sorts FIRST, so while the cap
* defaulted to "uncapped" the preview was its entire catalog and no other family was
* reachable without scrolling past all of it. Capping is therefore the default and a
* family opts out by raising its own limit, not by omitting one.
*
* Only the empty-query preview is capped; {@link resourceMentionMatches} searches
* every family in full once the user types.
*/
export function buildMentionPreview(
groups: readonly ResourceMentionGroup[],
limitFor: (type: MothershipResourceType) => number
): ResourceMentionCandidate[] {
return groups.flatMap(({ type, items }) =>
items.slice(0, limitFor(type)).map((item) => ({ type, item }))
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use client'

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn'
import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
dropdownMenuRowClass,
} from '@sim/emcn'
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
import type { McpServer } from '@/hooks/queries/mcp'
import type { SkillDefinition } from '@/hooks/queries/skills'
Expand Down Expand Up @@ -210,7 +216,8 @@ export const SkillsMenuDropdown = React.memo(
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(target)}
className={cn(
'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)]',
dropdownMenuRowClass,
'w-full text-left',
/* `activeIndex` is the cursor, not a selection — hover surface. */
isActive && 'bg-[var(--surface-hover)]'
)}
Expand All @@ -221,7 +228,7 @@ export const SkillsMenuDropdown = React.memo(
)
})
) : (
<div className='px-2 py-1.5 text-center text-[var(--text-tertiary)] text-caption'>
<div className='flex h-[28px] items-center justify-center px-2 text-[var(--text-muted)] text-caption'>
No skills or MCP servers
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='min-w-[320px] max-w-[420px] gap-0 p-1'>
<DropdownMenuContent align='end' className='min-w-[320px] max-w-[420px]'>
{imports.map((row) => {
const stage = getImportStage(row)
const isReadyExport = row.jobType === 'export' && row.phase === 'ready' && row.hasResult
Expand Down
Loading
Loading