diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index d20098720e7..137e41de6d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -8,6 +8,7 @@ import { Task, TerminalWindow, Workflow, + Workspaces, } from '@sim/emcn/icons' import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' @@ -54,6 +55,10 @@ function renderIntegrationTile({ context, className }: RenderIconArgs): ReactNod * without an icon. */ export const CHAT_CONTEXT_KIND_REGISTRY: Record = { + workspace: { + label: 'Workspace', + renderIcon: ({ className }) => , + }, browser_tab: { label: 'Browser tab', renderIcon: ({ className }) => , diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index ec9af2c3669..231a56a85a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -204,6 +204,9 @@ export function useResourceTreeSections({ } interface ResourceMenuSectionsProps { + flat?: boolean + /** Organization chat combines two selectable resource kinds under Files. */ + combineFileFolders?: boolean /** Foldered families, from {@link useResourceTreeSections}. */ sections: ResourceTreeSection[] /** Every available family. Foldered ones are taken from `sections` instead. */ @@ -225,16 +228,21 @@ interface ResourceMenuSectionsProps { * canonical order. */ export function ResourceMenuSections({ + flat = false, + combineFileFolders = false, sections, groups, onSelect, subContentClassName, }: ResourceMenuSectionsProps) { const sectionByType = new Map(sections.map((section) => [section.type, section])) + const fileFolderItems = groups.find((group) => group.type === 'filefolder')?.items ?? [] const entries = groups - .filter(({ type, items }) => - FOLDERED_RESOURCE_TYPES.has(type) ? sectionByType.has(type) : items.length > 0 - ) + .filter(({ type, items }) => { + if (combineFileFolders && type === 'filefolder') return false + if (combineFileFolders && type === 'file') return items.length + fileFolderItems.length > 0 + return !flat && FOLDERED_RESOURCE_TYPES.has(type) ? sectionByType.has(type) : items.length > 0 + }) .sort(byResourceMenuOrder) return ( @@ -243,6 +251,13 @@ export function ResourceMenuSections({ const config = getResourceConfig(type) const Icon = config.icon const section = sectionByType.get(type) + const rows = + combineFileFolders && type === 'file' + ? [ + ...fileFolderItems.map((item) => ({ type: 'filefolder' as const, item })), + ...items.map((item) => ({ type: 'file' as const, item })), + ] + : items.map((item) => ({ type, item })) // The Browser and Terminal launchers are flat rows that open a new page // or shell. Live pages and shells offered as context are an ordinary @@ -275,12 +290,12 @@ export function ResourceMenuSections({ onSelect={onSelect} /> ) : ( - items.map((item) => ( + rows.map(({ type: itemType, item }) => ( onSelect(resourceFromItem(type, item))} + key={`${itemType}:${item.workspaceId ?? ''}:${item.id}`} + onClick={() => onSelect(resourceFromItem(itemType, item))} > - {config.renderDropdownItem({ item })} + {getResourceConfig(itemType).renderDropdownItem({ item })} {typeof item.workspaceName === 'string' && ( {item.workspaceName} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 2cc097a1be2..c4e922a817c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -23,6 +23,7 @@ const CHIP_LINK_SCHEME = 'sim' * no single portable id (an array / two ids / none) and degrade to plain text. */ const PORTABLE_KIND_TO_ID_FIELD = { + workspace: 'workspaceId', table: 'tableId', file: 'fileId', folder: 'folderId', @@ -235,6 +236,8 @@ export function parseChipLinks(text: string): ParsedChipLink[] { */ export function chipLinkToContext(link: ParsedChipLink): ChatContext { switch (link.kind) { + case 'workspace': + return { kind: 'workspace', workspaceId: link.id, label: link.label } case 'table': return { kind: 'table', tableId: link.id, label: link.label } case 'file': 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 0d59291a4c7..057d6caed5f 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 @@ -5,17 +5,22 @@ import { cn, DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemLabel, DropdownMenuLabel, DropdownMenuSearchInput, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, dropdownMenuRowClass, } from '@sim/emcn' +import { Workspaces } from '@sim/emcn/icons' import { ResourceMenuSections, resourceFromItem, useAvailableResources, useResourceTreeSections, - WorkspaceResourceSubmenu, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import type { AvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' import { @@ -30,6 +35,7 @@ import { import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' import { buildMentionPreview, + type ResourceMentionCandidate, resourceMentionMatches, withBrowserTabMentions, withFolderMentions, @@ -64,17 +70,6 @@ const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available- */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) -/** - * Families an organization chat's workspace submenus leave out: the mention-only - * ones, plus Browser and Terminal, which belong to this desktop rather than to a - * workspace and so sit once after the workspaces. - */ -const WORKSPACE_SUBMENU_EXCLUDED_TYPES: readonly MothershipResourceType[] = [ - ...MENTION_ONLY_RESOURCE_TYPES, - 'browser', - 'terminal', -] - function isNativeResourceGroup({ type }: { type: MothershipResourceType }): boolean { return type === 'browser' || type === 'terminal' } @@ -82,6 +77,10 @@ function isNativeResourceGroup({ type }: { type: MothershipResourceType }): bool const EMPTY_BROWSER_TABS = [] as const const EMPTY_TERMINAL_TABS = [] as const +type PickerCandidate = + | ResourceMentionCandidate + | { type: 'workspace'; item: { id: string; name: string } } + interface PlusMenuDropdownProps { workspaceId: string organizationId?: string @@ -94,6 +93,7 @@ interface PlusMenuDropdownProps { */ warm?: boolean onResourceSelect: (resource: MothershipResource) => void + onWorkspaceSelect: (workspace: { id: string; name: string }) => void onClose: () => void textareaRef: React.RefObject pendingCursorRef: React.MutableRefObject @@ -108,6 +108,7 @@ export const PlusMenuDropdown = React.memo( organizationId, warm, onResourceSelect, + onWorkspaceSelect, onClose, textareaRef, pendingCursorRef, @@ -197,31 +198,44 @@ export const PlusMenuDropdown = React.memo( terminalTabs, ]) - /** - * 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 - * ids are only unique within a workspace. - */ + /** Organization chat uses flat cross-workspace rows; workspace chat keeps its tree. */ const treeSections = useResourceTreeSections({ groups: workspaceInventory.groups, structureFolders: workspaceInventory.structureFolders, selectFolders: true, }) - const filteredItems = useMemo(() => { + const filteredItems = useMemo(() => { const rawQuery = isMention ? (mentionQuery ?? '') : search const q = rawQuery.toLowerCase().trim() if (!isMention && !q) return null + const workspaceMatches = organizationId + ? workspaces + .filter( + (workspace) => + !q || workspace.name.toLowerCase().includes(q) || 'workspaces'.includes(q) + ) + .map((workspace) => ({ + type: 'workspace' as const, + item: { id: workspace.id, name: workspace.name }, + })) + : [] if (isMention && !q) { - return buildMentionPreview( - visibleResources, - (type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT - ) + return [ + ...workspaceMatches.slice(0, MENTION_PREVIEW_DEFAULT_LIMIT), + ...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 })) - ) - }, [isMention, mentionQuery, search, visibleResources]) + return [ + ...workspaceMatches, + ...visibleResources.flatMap(({ type, items }) => + items.filter((item) => resourceMentionMatches(item, q)).map((item) => ({ type, item })) + ), + ] + }, [isMention, mentionQuery, search, visibleResources, organizationId, workspaces]) const filteredItemsRef = useRef(filteredItems) filteredItemsRef.current = filteredItems @@ -238,15 +252,29 @@ export const PlusMenuDropdown = React.memo( if (isMention) setActiveIndex(0) }, [isMention, mentionQuery]) - const handleSelect = (resource: MothershipResource) => { - onResourceSelect(resource) + const closeAfterSelect = () => { setOpen(false) setSearch('') setActiveIndex(0) } - const handleSelectRef = useRef(handleSelect) - handleSelectRef.current = handleSelect + const handleSelect = (resource: MothershipResource) => { + onResourceSelect(resource) + closeAfterSelect() + } + + const handleWorkspaceSelect = (workspace: { id: string; name: string }) => { + onWorkspaceSelect(workspace) + closeAfterSelect() + } + + const handleCandidateSelect = (candidate: PickerCandidate) => { + if (candidate.type === 'workspace') handleWorkspaceSelect(candidate.item) + else handleSelect(resourceFromItem(candidate.type, candidate.item)) + } + + const handleSelectRef = useRef(handleCandidateSelect) + handleSelectRef.current = handleCandidateSelect React.useImperativeHandle( ref, @@ -267,7 +295,7 @@ export const PlusMenuDropdown = React.memo( const items = filteredItemsRef.current const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined if (!target) return isHydratingRef.current ? 'hydrating' : 'empty' - handleSelectRef.current(resourceFromItem(target.type, target.item)) + handleSelectRef.current(target) return 'selected' }, }), @@ -306,7 +334,7 @@ export const PlusMenuDropdown = React.memo( } else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) { e.preventDefault() const target = filteredItems[activeIndex] ?? filteredItems[0] - if (target) handleSelect(resourceFromItem(target.type, target.item)) + if (target) handleCandidateSelect(target) } } @@ -405,21 +433,36 @@ export const PlusMenuDropdown = React.memo( {/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */}