From d66b103d6d87acc09337a25742b1641cc46b4b40 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 09:32:30 -0700 Subject: [PATCH 1/3] improvement(chat): defer resource-menu list queries until the menu opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `+` resource menu and the `@`-mention menu both hydrate from `useAvailableResources`, which fired nine workspace-wide list queries and rebuilt ten item groups on mount — from two always-mounted call sites, for menus that were closed. Gate it on each menu's own open state. - add an `enabled` option to `useAvailableResources`; pass `open` from `AddResourceDropdown` and `PlusMenuDropdown` - add `options.enabled` to the six query hooks that lacked it, matching the convention already used by `useWorkspaceFiles` / `useKnowledgeBasesQuery` / `useLogsList` - skip the tab-name lookup's five list queries when there are no tabs to label - drop the `= []` destructuring defaults: a literal default allocates a fresh array every render while `data` is undefined, busting the group memo in exactly the disabled state - move the hook call into `PlusMenuDropdown`, which owns the open state, removing the `availableResources` pass-through from `usePromptEditor` - delete the `existingKeys`/`isOpen` plumbing — one consumer never read it; resolve it at the single call site instead --- .../add-resource-dropdown.tsx | 148 ++++++++---------- .../components/add-resource-dropdown/index.ts | 6 - .../mothership-view/components/index.ts | 1 - .../resource-tabs/resource-tabs.tsx | 55 ++++--- .../components/user-input/components/index.ts | 1 - .../components/plus-menu-dropdown/index.ts | 2 +- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 18 ++- .../prompt-editor/prompt-editor.tsx | 2 +- .../prompt-editor/use-prompt-editor.test.tsx | 12 -- .../prompt-editor/use-prompt-editor.ts | 20 +-- apps/sim/hooks/queries/folders.ts | 7 +- apps/sim/hooks/queries/mothership-chats.ts | 3 +- apps/sim/hooks/queries/schedules.ts | 4 +- apps/sim/hooks/queries/tables.ts | 4 +- apps/sim/hooks/queries/workflows.ts | 6 +- .../hooks/queries/workspace-file-folders.ts | 5 +- 16 files changed, 141 insertions(+), 153 deletions(-) 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 7f67d25704f..6f69e082d80 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 @@ -42,17 +42,41 @@ export interface AddResourceDropdownProps { existingKeys: Set onAdd: (resource: MothershipResource) => void onSwitch?: (resourceId: string) => void - /** Resource types to hide from the dropdown (e.g. `['folder', 'task']`). */ + /** + * Resource types to hide from the dropdown. Must be referentially stable + * (a module constant) — it keys the underlying group memo. + */ excludeTypes?: readonly MothershipResourceType[] } -export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown } +export type AvailableItem = { id: string; name: string; [key: string]: unknown } interface AvailableItemsByType { type: MothershipResourceType items: AvailableItem[] } +interface UseAvailableResourcesOptions { + /** + * Skips the underlying list queries and the group construction they feed + * while `false`, returning a stable empty result. Menus pass their own open + * state so a closed one costs nothing; the lists fetch on first open. + * + * Note this only defers the lists nothing else on the surface already needs — + * the mothership tab bar independently resolves tab names from the workflow, + * table, file, knowledge-base, and folder lists, so those stay warm there. + */ + enabled?: boolean + /** + * Resource types to omit from the result. Must be referentially stable + * (a module constant) — it keys the group memo. + */ + excludeTypes?: readonly MothershipResourceType[] +} + +/** Stable identity for the disabled result, so downstream memos never bust. */ +const NO_RESOURCE_GROUPS: AvailableItemsByType[] = [] + const LOG_DROPDOWN_LIMIT = 50 const LOG_DROPDOWN_FILTERS = { @@ -69,76 +93,65 @@ const LOG_DROPDOWN_FILTERS = { export function useAvailableResources( workspaceId: string, - existingKeys: Set, - excludeTypes?: readonly MothershipResourceType[] + options?: UseAvailableResourcesOptions ): AvailableItemsByType[] { - const { data: workflows = [] } = useWorkflows(workspaceId) - const { data: tables = [] } = useTablesList(workspaceId) - const { data: files = [] } = useWorkspaceFiles(workspaceId) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) - const { data: folders = [] } = useFolders(workspaceId) - const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId) - const { data: tasks = [] } = useMothershipChats(workspaceId) - const { data: schedules = [] } = useWorkspaceSchedules(workspaceId) - const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS) + const enabled = options?.enabled ?? true + const excludeTypes = options?.excludeTypes + // Destructured without `= []` defaults on purpose: a literal default allocates a + // fresh array every render while `data` is undefined (exactly the disabled state), + // which would bust the group memo below on every render. Undefined is stable. + const { data: workflows } = useWorkflows(workspaceId, { enabled }) + const { data: tables } = useTablesList(workspaceId, 'active', { enabled }) + const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled }) + const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { enabled }) + const { data: folders } = useFolders(workspaceId, { enabled }) + const { data: fileFolders } = useWorkspaceFileFolders(workspaceId, 'active', { enabled }) + const { data: tasks } = useMothershipChats(workspaceId, { enabled }) + const { data: schedules } = useWorkspaceSchedules(workspaceId, { enabled }) + const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS, { enabled }) const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData]) return useMemo(() => { + if (!enabled) return NO_RESOURCE_GROUPS const excluded = new Set(excludeTypes ?? []) const groups: AvailableItemsByType[] = [ { type: 'workflow' as const, - items: workflows.map((w) => ({ + items: (workflows ?? []).map((w) => ({ id: w.id, name: w.name, folderId: w.folderId ?? null, sortOrder: w.sortOrder, - isOpen: existingKeys.has(`workflow:${w.id}`), })), }, { type: 'folder' as const, - items: folders.map((f) => ({ + items: (folders ?? []).map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null, sortOrder: f.sortOrder, - isOpen: existingKeys.has(`folder:${f.id}`), })), }, { type: 'table' as const, - items: tables.map((t) => ({ - id: t.id, - name: t.name, - isOpen: existingKeys.has(`table:${t.id}`), - })), + items: (tables ?? []).map((t) => ({ id: t.id, name: t.name })), }, { type: 'file' as const, - items: files.map((f) => ({ - id: f.id, - name: f.name, - folderId: f.folderId ?? null, - isOpen: existingKeys.has(`file:${f.id}`), - })), + items: (files ?? []).map((f) => ({ id: f.id, name: f.name, folderId: f.folderId ?? null })), }, { type: 'filefolder' as const, - items: fileFolders.map((f) => ({ + items: (fileFolders ?? []).map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null, - isOpen: existingKeys.has(`filefolder:${f.id}`), })), }, { type: 'knowledgebase' as const, - items: (knowledgeBases ?? []).map((kb) => ({ - id: kb.id, - name: kb.name, - isOpen: existingKeys.has(`knowledgebase:${kb.id}`), - })), + items: (knowledgeBases ?? []).map((kb) => ({ id: kb.id, name: kb.name })), }, { type: 'integration' as const, @@ -147,25 +160,19 @@ export function useAvailableResources( name: integration.name, iconComponent: integration.icon, bgColor: integration.bgColor, - isOpen: existingKeys.has(`integration:${integration.blockType}`), })), }, { type: 'task' as const, - items: tasks.map((t) => ({ - id: t.id, - name: t.name, - isOpen: existingKeys.has(`task:${t.id}`), - })), + items: (tasks ?? []).map((t) => ({ id: t.id, name: t.name })), }, { type: 'scheduledtask' as const, - items: schedules + items: (schedules ?? []) .filter((s) => s.sourceType === 'job') .map((s) => ({ id: s.id, name: s.jobTitle || truncate(s.prompt ?? '', 40) || 'Scheduled Task', - isOpen: existingKeys.has(`scheduledtask:${s.id}`), })), }, { @@ -173,18 +180,13 @@ export function useAvailableResources( items: logs.map((log) => { const workflowName = log.workflow?.name ?? log.workflowId ?? 'Unknown' const time = formatDate(log.createdAt).compact - return { - id: log.id, - name: `${workflowName} · ${time}`, - workflowName, - time, - isOpen: existingKeys.has(`log:${log.id}`), - } + return { id: log.id, name: `${workflowName} · ${time}`, workflowName, time } }), }, ] return groups.filter((g) => !excluded.has(g.type)) }, [ + enabled, workflows, folders, fileFolders, @@ -194,13 +196,12 @@ export function useAvailableResources( tasks, schedules, logs, - existingKeys, excludeTypes, ]) } export type WorkflowTreeNode = - | { kind: 'workflow'; id: string; name: string; isOpen?: boolean } + | { kind: 'workflow'; id: string; name: string } | { kind: 'folder'; id: string; name: string; children: WorkflowTreeNode[] } export function buildWorkflowFolderTree( @@ -222,7 +223,6 @@ export function buildWorkflowFolderTree( kind: 'workflow', id: w.id, name: w.name, - isOpen: w.isOpen, }) const buildLevel = (parentId: string | null): WorkflowTreeNode[] => { @@ -262,7 +262,7 @@ export function buildWorkflowFolderTree( interface WorkflowFolderTreeItemsProps { nodes: WorkflowTreeNode[] - onSelect: (resource: MothershipResource, isOpen?: boolean) => void + onSelect: (resource: MothershipResource) => void } export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeItemsProps) { @@ -272,9 +272,7 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI node.kind === 'workflow' ? ( - onSelect({ type: 'workflow', id: node.id, title: node.name }, node.isOpen) - } + onClick={() => onSelect({ type: 'workflow', id: node.id, title: node.name })} > {getResourceConfig('workflow').renderDropdownItem({ item: { id: node.id, name: node.name }, @@ -297,8 +295,8 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI } export type FileFolderTreeNode = - | { kind: 'file'; id: string; name: string; isOpen?: boolean } - | { kind: 'folder'; id: string; name: string; isOpen?: boolean; children: FileFolderTreeNode[] } + | { kind: 'file'; id: string; name: string } + | { kind: 'folder'; id: string; name: string; children: FileFolderTreeNode[] } export function buildFileFolderTree( fileItems: AvailableItem[], @@ -319,17 +317,15 @@ export function buildFileFolderTree( const childFiles = byFolder.get(parentId) ?? [] const nodes: FileFolderTreeNode[] = [] for (const folder of childFolders) { - const children = buildLevel(folder.id) nodes.push({ kind: 'folder', id: folder.id, name: folder.name, - isOpen: folder.isOpen, - children, + children: buildLevel(folder.id), }) } for (const file of childFiles) { - nodes.push({ kind: 'file', id: file.id, name: file.name, isOpen: file.isOpen }) + nodes.push({ kind: 'file', id: file.id, name: file.name }) } return nodes } @@ -339,7 +335,7 @@ export function buildFileFolderTree( interface FileFolderTreeItemsProps { nodes: FileFolderTreeNode[] - onSelect: (resource: MothershipResource, isOpen?: boolean) => void + onSelect: (resource: MothershipResource) => void } export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) { @@ -349,7 +345,7 @@ export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProp node.kind === 'file' ? ( onSelect({ type: 'file', id: node.id, title: node.name }, node.isOpen)} + onClick={() => onSelect({ type: 'file', id: node.id, title: node.name })} > {getResourceConfig('file').renderDropdownItem({ item: { id: node.id, name: node.name }, @@ -363,9 +359,7 @@ export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProp - onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen) - } + onClick={() => onSelect({ type: 'filefolder', id: node.id, title: node.name })} > {node.name} @@ -391,10 +385,8 @@ export function AddResourceDropdown({ const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) - const available = useAvailableResources(workspaceId, existingKeys, [ - ...(excludeTypes ?? []), - 'integration', - ]) + // Gated on `open` so an idle tab bar never fetches the workspace lists. + const available = useAvailableResources(workspaceId, { enabled: open, excludeTypes }) const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { @@ -403,8 +395,8 @@ export function AddResourceDropdown({ } } - const select = (resource: MothershipResource, isOpen?: boolean) => { - if (isOpen && onSwitch) { + const select = (resource: MothershipResource) => { + if (onSwitch && existingKeys.has(`${resource.type}:${resource.id}`)) { onSwitch(resource.id) } else { onAdd(resource) @@ -446,7 +438,7 @@ export function AddResourceDropdown({ if (filtered.length > 0 && filtered[activeIndex]) { e.preventDefault() const { type, item } = filtered[activeIndex] - select({ type, id: item.id, title: item.name }, item.isOpen) + select({ type, id: item.id, title: item.name }) } } } @@ -494,7 +486,7 @@ export function AddResourceDropdown({ key={`${type}:${item.id}`} className={cn(index === activeIndex && 'bg-[var(--surface-active)]')} onMouseEnter={() => setActiveIndex(index)} - onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)} + onClick={() => select({ type, id: item.id, title: item.name })} > {config.renderDropdownItem({ item })} @@ -553,9 +545,7 @@ export function AddResourceDropdown({ {items.map((item) => ( - select({ type, id: item.id, title: item.name }, item.isOpen) - } + onClick={() => select({ type, id: item.id, title: item.name })} > {config.renderDropdownItem({ item })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index 8a266363937..7d0e88042b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -1,9 +1,3 @@ -export type { - AddResourceDropdownProps, - AvailableItem, - FileFolderTreeNode, - WorkflowTreeNode, -} from './add-resource-dropdown' export { AddResourceDropdown, buildFileFolderTree, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index e2035dbb6ec..47d3ad87474 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts @@ -1,4 +1,3 @@ -export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown' export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown' export { ResourceActions, ResourceContent } from './resource-content' export type { ResourceTypeConfig } from './resource-registry' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index a99416db8de..94881cfa3b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -41,7 +41,18 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' const EDGE_ZONE = 40 const SCROLL_SPEED = 8 -const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const +/** + * Types that cannot be opened as a resource tab. Folders and chats have no tab + * surface; integrations are `@`-mention-only (see `MENTION_ONLY_RESOURCE_TYPES` + * in `plus-menu-dropdown`), so they are never offered here. + * + * Module-scope by contract — `useAvailableResources` keys its group memo on this. + */ +const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = [ + 'folder', + 'task', + 'integration', +] as const /** * Returns the id of the nearest resource to `idx` that is in `filter` @@ -111,26 +122,37 @@ const PREVIEW_MODE_LABELS: Record = { preview: 'Edit Mode', } +/** + * Stable identity for the empty lookup across `enabled` toggles. Unlike + * `NO_RESOURCE_GROUPS`, nothing downstream keys on this identity — tab rows + * receive the derived `displayName` string — so it is cheap insurance rather + * than a guard against busting a downstream memo. + */ +const NO_RESOURCE_NAMES = new Map() + /** * Builds a `type:id` -> current name lookup from live query data so resource - * tabs always reflect the latest name even after a rename. + * tabs always reflect the latest name even after a rename. Skipped entirely + * when there are no tabs to label — a chat with no open resources must not + * fetch five workspace-wide lists. */ -function useResourceNameLookup(workspaceId: string): Map { - const { data: workflows = [] } = useWorkflows(workspaceId) - const { data: tables = [] } = useTablesList(workspaceId) - const { data: files = [] } = useWorkspaceFiles(workspaceId) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) - const { data: folders = [] } = useFolders(workspaceId) +function useResourceNameLookup(workspaceId: string, enabled: boolean): Map { + const { data: workflows } = useWorkflows(workspaceId, { enabled }) + const { data: tables } = useTablesList(workspaceId, 'active', { enabled }) + const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled }) + const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { enabled }) + const { data: folders } = useFolders(workspaceId, { enabled }) return useMemo(() => { + if (!enabled) return NO_RESOURCE_NAMES const map = new Map() - for (const w of workflows) map.set(`workflow:${w.id}`, w.name) - for (const t of tables) map.set(`table:${t.id}`, t.name) - for (const f of files) map.set(`file:${f.id}`, f.name) + for (const w of workflows ?? []) map.set(`workflow:${w.id}`, w.name) + for (const t of tables ?? []) map.set(`table:${t.id}`, t.name) + for (const f of files ?? []) map.set(`file:${f.id}`, f.name) for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) - for (const folder of folders) map.set(`folder:${folder.id}`, folder.name) + for (const folder of folders ?? []) map.set(`folder:${folder.id}`, folder.name) return map - }, [workflows, tables, files, knowledgeBases, folders]) + }, [enabled, workflows, tables, files, knowledgeBases, folders]) } interface ResourceTabItemProps { @@ -256,7 +278,7 @@ export function ResourceTabs({ actions, }: ResourceTabsProps) { const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split'] - const nameLookup = useResourceNameLookup(workspaceId) + const nameLookup = useResourceNameLookup(workspaceId, resources.length > 0) const { selectResource, addResource: onAddResource, @@ -320,10 +342,7 @@ export function ResourceTabs({ anchorIdRef.current = null } - const existingKeys = useMemo( - () => new Set(resources.map((r) => `${r.type}:${r.id}`)), - [resources] - ) + const existingKeys = new Set(resources.map((r) => `${r.type}:${r.id}`)) const handleAdd = useCallback( (resource: MothershipResource) => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index bf00d079201..321b685c908 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -23,7 +23,6 @@ export { } from './constants' export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' -export type { AvailableResourceGroup } from './plus-menu-dropdown' export { PlusMenuDropdown } from './plus-menu-dropdown' export type { PromptEditorInstance, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts index 00d1cf03946..5a9b0e5cfd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts @@ -1 +1 @@ -export { type AvailableResourceGroup, PlusMenuDropdown } from './plus-menu-dropdown' +export { PlusMenuDropdown } from './plus-menu-dropdown' 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 a29c59b28b3..bc240c7c0dc 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 @@ -17,7 +17,7 @@ import { buildFileFolderTree, buildWorkflowFolderTree, FileFolderTreeItems, - type useAvailableResources, + useAvailableResources, WorkflowFolderTreeItems, } 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' @@ -27,17 +27,20 @@ import type { MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -export type AvailableResourceGroup = ReturnType[number] - /** * Resource types that are only offered via `@`-mention autocomplete and hidden * from the `+` browse menu. Integrations are searchable inline (e.g. typing * `@sla` surfaces Slack) but should not clutter the explicit attach menu. + * + * Filtered here rather than via the hook's `excludeTypes` because the exclusion + * is mode-dependent (`isMention`) — one fetch serves both modes. The resource + * tab bar, whose exclusion is static, uses `excludeTypes` instead + * (`ADD_RESOURCE_EXCLUDED_TYPES` in `resource-tabs`). */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) interface PlusMenuDropdownProps { - availableResources: AvailableResourceGroup[] + workspaceId: string onResourceSelect: (resource: MothershipResource) => void onClose: () => void textareaRef: React.RefObject @@ -48,7 +51,7 @@ interface PlusMenuDropdownProps { export const PlusMenuDropdown = React.memo( React.forwardRef(function PlusMenuDropdown( - { availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, + { workspaceId, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, ref ) { const [open, setOpen] = useState(false) @@ -59,6 +62,9 @@ export const PlusMenuDropdown = React.memo( const searchRef = useRef(null) const contentRef = useRef(null) + // Gated on `open` so an idle chat surface never fetches the workspace lists. + const availableResources = useAvailableResources(workspaceId, { enabled: open }) + const doOpen = useCallback( (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { setAnchorPos(anchor) @@ -74,8 +80,6 @@ export const PlusMenuDropdown = React.memo( setOpen(false) }, []) - // The `+` browse menu hides mention-only resource types; `@`-mention mode - // exposes the full catalog so integrations remain searchable inline. const visibleResources = useMemo( () => isMention diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index f971a60e332..0e4974e20b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -219,7 +219,7 @@ export function PromptEditor({ <> ({ useSkills: () => ({ data: [] }) })) vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workspace-file-folders', () => ({ - useWorkspaceFileFolders: () => ({ data: [] }), -})) -vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) })) vi.mock('@/blocks/integration-matcher', () => ({ getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), - listIntegrations: () => [], })) import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 3cc377bd39a..09e1348db9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' import { chipDisplayToken, @@ -282,21 +281,6 @@ export function usePromptEditor({ seedRef.current = skills.length > 0 || mcpServers.length > 0 ? null : converted }, [skills.length, mcpServers.length, applyAutoMentions]) - const existingResourceKeys = useMemo(() => { - const keys = new Set() - for (const ctx of contextManagement.selectedContexts) { - if (ctx.kind === 'workflow' && ctx.workflowId) keys.add(`workflow:${ctx.workflowId}`) - if (ctx.kind === 'knowledge' && ctx.knowledgeId) keys.add(`knowledgebase:${ctx.knowledgeId}`) - if (ctx.kind === 'table' && ctx.tableId) keys.add(`table:${ctx.tableId}`) - if (ctx.kind === 'file' && ctx.fileId) keys.add(`file:${ctx.fileId}`) - if (ctx.kind === 'folder' && ctx.folderId) keys.add(`folder:${ctx.folderId}`) - if (ctx.kind === 'past_chat' && ctx.chatId) keys.add(`task:${ctx.chatId}`) - } - return keys - }, [contextManagement.selectedContexts]) - - const availableResources = useAvailableResources(workspaceId, existingResourceKeys) - /** * Programmatically replaces the editor text. Chipifies by default so any * seeded prose (template, transcript, queued message) registers its @@ -1041,12 +1025,12 @@ export function usePromptEditor({ textareaRef, /** @internal Wiring consumed by the {@link PromptEditor} view. */ + workspaceId, + /** @internal */ skills, /** @internal */ mcpServers, /** @internal */ - availableResources, - /** @internal */ mentionQuery, /** @internal */ slashQuery, diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 2658c303c48..4d9d3c0ca1d 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -62,12 +62,15 @@ async function fetchFolders( return folders.map(mapFolder) } -export function useFolders(workspaceId?: string, options?: { scope?: FolderQueryScope }) { +export function useFolders( + workspaceId?: string, + options?: { scope?: FolderQueryScope; enabled?: boolean } +) { const scope = options?.scope ?? 'active' return useQuery({ queryKey: folderKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, signal), - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), placeholderData: keepPreviousData, staleTime: FOLDER_LIST_STALE_TIME, }) diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 89a598799a9..870bc3e1f98 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -227,7 +227,7 @@ export async function fetchMothershipChats( */ export function useMothershipChats( workspaceId?: string, - options?: { scope?: MothershipChatScope } + options?: { scope?: MothershipChatScope; enabled?: boolean } ) { const scope = options?.scope ?? 'active' return useQuery({ @@ -235,6 +235,7 @@ export function useMothershipChats( queryFn: workspaceId ? ({ signal }) => fetchMothershipChats(workspaceId, scope, signal) : skipToken, + enabled: options?.enabled ?? true, placeholderData: keepPreviousData, staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, }) diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index 76f769fcccd..58e6fbb2c47 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -76,7 +76,7 @@ async function fetchSchedule( /** * Fetch all schedules for a workspace. */ -export function useWorkspaceSchedules(workspaceId?: string) { +export function useWorkspaceSchedules(workspaceId?: string, options?: { enabled?: boolean }) { return useQuery({ queryKey: scheduleKeys.list(workspaceId ?? ''), queryFn: async ({ signal }) => { @@ -88,7 +88,7 @@ export function useWorkspaceSchedules(workspaceId?: string) { }) return data.schedules || [] }, - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), staleTime: SCHEDULE_LIST_STALE_TIME, placeholderData: keepPreviousData, }) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index aff122b6d34..49a84c6252f 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -245,6 +245,8 @@ export function useTablesList( options?: { /** Poll cadence, or a predicate over the current list that returns a cadence (or `false`). */ refetchInterval?: number | false | ((tables: TableDefinition[] | undefined) => number | false) + /** Defer the fetch (e.g. until a menu that needs the list is open). Defaults to `true`. */ + enabled?: boolean } ) { const refetchInterval = options?.refetchInterval @@ -259,7 +261,7 @@ export function useTablesList( }) return response.data.tables }, - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), staleTime: TABLE_LIST_STALE_TIME, placeholderData: keepPreviousData, refetchInterval: diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index fcffbcc24ea..db46900feba 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -111,12 +111,16 @@ export function useWorkflowStates( return map } -export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { +export function useWorkflows( + workspaceId?: string, + options?: { scope?: WorkflowQueryScope; enabled?: boolean } +) { const { scope = 'active' } = options || {} return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken, + enabled: options?.enabled ?? true, placeholderData: keepPreviousData, staleTime: WORKFLOW_LIST_STALE_TIME, }) diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index bad1c52c0ee..8b8cb87fe6e 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -51,12 +51,13 @@ function invalidateWorkspaceFileBrowsers( export function useWorkspaceFileFolders( workspaceId: string, - scope: WorkspaceFileFolderScope = 'active' + scope: WorkspaceFileFolderScope = 'active', + options?: { enabled?: boolean } ) { return useQuery({ queryKey: workspaceFileFolderKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchWorkspaceFileFolders(workspaceId, scope, signal), - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, placeholderData: keepPreviousData, }) From 8f224ce0b32e8f8371fa6dea7ae0809d77e32c04 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 12:08:56 -0700 Subject: [PATCH 2/3] fix(chat): warm resource lists on editor focus so fast mentions resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferring the lists to menu-open left a window where `selectActive()` saw an empty candidate list, and its `false` return falls through to submitting the message with the mention unresolved. Start hydrating on first focus — the earliest reliable signal a mention may be coming — which closes the window while keeping the lists off the page-load path. --- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 14 +++++++++++--- .../components/prompt-editor/prompt-editor.tsx | 10 +++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) 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 bc240c7c0dc..4b29c54b3ff 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 @@ -41,6 +41,14 @@ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integratio interface PlusMenuDropdownProps { workspaceId: string + /** + * Starts hydrating the resource lists before the menu opens. The editor sets + * this on focus: `@`-mention confirmation reads the candidate list + * synchronously on Enter, and an empty list falls through to submitting the + * message with the mention unresolved. Focus is the earliest reliable signal + * that a mention may be coming, and still keeps these lists off page load. + */ + warm?: boolean onResourceSelect: (resource: MothershipResource) => void onClose: () => void textareaRef: React.RefObject @@ -51,7 +59,7 @@ interface PlusMenuDropdownProps { export const PlusMenuDropdown = React.memo( React.forwardRef(function PlusMenuDropdown( - { workspaceId, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, + { workspaceId, warm, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, ref ) { const [open, setOpen] = useState(false) @@ -62,8 +70,8 @@ export const PlusMenuDropdown = React.memo( const searchRef = useRef(null) const contentRef = useRef(null) - // Gated on `open` so an idle chat surface never fetches the workspace lists. - const availableResources = useAvailableResources(workspaceId, { enabled: open }) + // Gated so an idle chat surface never fetches the workspace lists. + const availableResources = useAvailableResources(workspaceId, { enabled: open || !!warm }) const doOpen = useCallback( (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index 0e4974e20b8..94be2fc3628 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -72,6 +72,12 @@ export function PromptEditor({ }: PromptEditorProps) { const { textareaRef, value } = editor const scrollerRef = useRef(null) + /** + * Latched on first focus and never cleared: it only starts the resource + * lists early so an `@`-mention has candidates by the time Enter is pressed. + * Un-warming on blur would just re-open the race on the next focus. + */ + const [hasFocused, setHasFocused] = useState(false) /** * Autosize: grow the textarea to its full content height; the scroller caps @@ -203,6 +209,7 @@ export function PromptEditor({ onKeyDown={ readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty }) } + onFocus={readOnly ? undefined : () => setHasFocused(true)} onPaste={readOnly ? undefined : editor.handlePaste} onCopy={editor.handleCopy} onCut={readOnly ? undefined : editor.handleCut} @@ -220,6 +227,7 @@ export function PromptEditor({ Date: Mon, 27 Jul 2026 12:17:26 -0700 Subject: [PATCH 3/3] fix(chat): never submit a mention while its candidate lists are loading Warming on focus shrank the race but gave no readiness guarantee: an empty candidate list still meant "no match" to the keydown handler, which falls through and submits the message with the mention as raw text. Make the distinction explicit. `useAvailableResources` now reports `isHydrating`, and `PlusMenuHandle.selectActive` returns `selected | empty | hydrating` instead of a boolean. The editor swallows Tab/Enter while hydrating and only lets them through once the lists have settled, where an empty result is a genuine no-match. `isHydrating` keys off `isPending`, not `data === undefined`, so a failed list settles instead of blocking the key forever. --- .../add-resource-dropdown.tsx | 72 +++++++++++++++---- .../user-input/components/constants.ts | 12 +++- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 13 ++-- .../prompt-editor/use-prompt-editor.test.tsx | 2 +- .../prompt-editor/use-prompt-editor.ts | 10 ++- 5 files changed, 87 insertions(+), 22 deletions(-) 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 6f69e082d80..9fc500f6c84 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 @@ -56,6 +56,16 @@ interface AvailableItemsByType { items: AvailableItem[] } +interface AvailableResources { + groups: AvailableItemsByType[] + /** + * True while enabled and at least one list has yet to produce data. Callers + * that act on "no candidates" must check this first — an empty result during + * hydration means "not known yet", not "no match". + */ + isHydrating: boolean +} + interface UseAvailableResourcesOptions { /** * Skips the underlying list queries and the group construction they feed @@ -94,24 +104,58 @@ const LOG_DROPDOWN_FILTERS = { export function useAvailableResources( workspaceId: string, options?: UseAvailableResourcesOptions -): AvailableItemsByType[] { +): AvailableResources { const enabled = options?.enabled ?? true const excludeTypes = options?.excludeTypes // Destructured without `= []` defaults on purpose: a literal default allocates a // fresh array every render while `data` is undefined (exactly the disabled state), // which would bust the group memo below on every render. Undefined is stable. - const { data: workflows } = useWorkflows(workspaceId, { enabled }) - const { data: tables } = useTablesList(workspaceId, 'active', { enabled }) - const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled }) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { enabled }) - const { data: folders } = useFolders(workspaceId, { enabled }) - const { data: fileFolders } = useWorkspaceFileFolders(workspaceId, 'active', { enabled }) - const { data: tasks } = useMothershipChats(workspaceId, { enabled }) - const { data: schedules } = useWorkspaceSchedules(workspaceId, { enabled }) - const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS, { enabled }) + const { data: workflows, isPending: workflowsPending } = useWorkflows(workspaceId, { enabled }) + const { data: tables, isPending: tablesPending } = useTablesList(workspaceId, 'active', { + enabled, + }) + const { data: files, isPending: filesPending } = useWorkspaceFiles(workspaceId, 'active', { + enabled, + }) + const { data: knowledgeBases, isPending: knowledgeBasesPending } = useKnowledgeBasesQuery( + workspaceId, + { enabled } + ) + const { data: folders, isPending: foldersPending } = useFolders(workspaceId, { enabled }) + const { data: fileFolders, isPending: fileFoldersPending } = useWorkspaceFileFolders( + workspaceId, + 'active', + { enabled } + ) + const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, { enabled }) + const { data: schedules, isPending: schedulesPending } = useWorkspaceSchedules(workspaceId, { + enabled, + }) + const { data: logsData, isPending: logsPending } = useLogsList( + workspaceId, + LOG_DROPDOWN_FILTERS, + { enabled } + ) const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData]) - return useMemo(() => { + /** + * Keyed off `isPending` rather than `data === undefined` so a failed list + * settles to "not hydrating" — an errored query must not block the caller + * forever. + */ + const isHydrating = + enabled && + (workflowsPending || + tablesPending || + filesPending || + knowledgeBasesPending || + foldersPending || + fileFoldersPending || + tasksPending || + schedulesPending || + logsPending) + + const groups = useMemo(() => { if (!enabled) return NO_RESOURCE_GROUPS const excluded = new Set(excludeTypes ?? []) const groups: AvailableItemsByType[] = [ @@ -198,6 +242,10 @@ export function useAvailableResources( logs, excludeTypes, ]) + + // `groups` keeps its own stable identity so the consumers' downstream memos + // still key on it; only this wrapper changes when hydration settles. + return useMemo(() => ({ groups, isHydrating }), [groups, isHydrating]) } export type WorkflowTreeNode = @@ -386,7 +434,7 @@ export function AddResourceDropdown({ const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) // Gated on `open` so an idle tab bar never fetches the workspace lists. - const available = useAvailableResources(workspaceId, { enabled: open, excludeTypes }) + const { groups: available } = useAvailableResources(workspaceId, { enabled: open, excludeTypes }) const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 1e7ee30ce22..875915775bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -41,7 +41,17 @@ export interface PlusMenuHandle { open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void close: () => void moveActive: (delta: number) => void - selectActive: () => boolean + /** + * Confirms the highlighted candidate. + * + * - `selected` — a candidate was inserted. + * - `empty` — the lists are loaded and nothing matches, so the caller should + * let the key through (Enter submits, Tab does its default). + * - `hydrating` — the lists are still loading, so "nothing matches" is not yet + * knowable. The caller must swallow the key rather than submit a message + * with the mention left as raw text. + */ + selectActive: () => 'selected' | 'empty' | 'hydrating' } /** 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 4b29c54b3ff..963b98bf5aa 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 @@ -71,7 +71,9 @@ export const PlusMenuDropdown = React.memo( const contentRef = useRef(null) // Gated so an idle chat surface never fetches the workspace lists. - const availableResources = useAvailableResources(workspaceId, { enabled: open || !!warm }) + const { groups: availableResources, isHydrating } = useAvailableResources(workspaceId, { + enabled: open || !!warm, + }) const doOpen = useCallback( (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { @@ -127,6 +129,8 @@ export const PlusMenuDropdown = React.memo( activeIndexRef.current = activeIndex const isMentionRef = useRef(isMention) isMentionRef.current = isMention + const isHydratingRef = useRef(isHydrating) + isHydratingRef.current = isHydrating // Reset highlight to the top whenever the mention query changes so the user always // sees the best match selected as they type. @@ -161,15 +165,14 @@ export const PlusMenuDropdown = React.memo( }, selectActive: () => { const items = filteredItemsRef.current - if (!items || items.length === 0) return false - const target = items[activeIndexRef.current] ?? items[0] - if (!target) return false + const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined + if (!target) return isHydratingRef.current ? 'hydrating' : 'empty' handleSelectRef.current({ type: target.type, id: target.item.id, title: target.item.name, }) - return true + return 'selected' }, }), [doOpen, doClose] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 9c9ca464d69..46fc461eedb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -79,7 +79,7 @@ describe('usePromptEditor mention menu dismissal', () => { open: vi.fn(), close: vi.fn(), moveActive: vi.fn(), - selectActive: vi.fn(() => false), + selectActive: vi.fn(() => 'empty' as const), } }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 09e1348db9d..a969e39b741 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -673,9 +673,13 @@ export function usePromptEditor({ return } if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) { - // Confirm the highlighted match if there is one. If no items match, fall - // through so Enter still submits and Tab still does its default thing. - if (plusMenuRef.current?.selectActive()) { + // Confirm the highlighted match if there is one. If the lists are still + // loading, swallow the key — "no match" isn't knowable yet, and falling + // through would submit the message with the mention left as raw text. + // Only once they are loaded does an empty result mean a genuine no-match, + // where Enter should submit and Tab should do its default thing. + const result = plusMenuRef.current?.selectActive() + if (result === 'selected' || result === 'hydrating') { e.preventDefault() return }