mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(chat): defer resource-menu list queries until the menu opens (#5973)
* improvement(chat): defer resource-menu list queries until the menu opens 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 * fix(chat): warm resource lists on editor focus so fast mentions resolve 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. * 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.
This commit is contained in:
+119
-81
@@ -42,17 +42,51 @@ export interface AddResourceDropdownProps {
|
||||
existingKeys: Set<string>
|
||||
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 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
|
||||
* 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 +103,99 @@ const LOG_DROPDOWN_FILTERS = {
|
||||
|
||||
export function useAvailableResources(
|
||||
workspaceId: string,
|
||||
existingKeys: Set<string>,
|
||||
excludeTypes?: readonly MothershipResourceType[]
|
||||
): 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)
|
||||
options?: UseAvailableResourcesOptions
|
||||
): 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, 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<MothershipResourceType>(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 +204,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 +224,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 +240,16 @@ export function useAvailableResources(
|
||||
tasks,
|
||||
schedules,
|
||||
logs,
|
||||
existingKeys,
|
||||
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 =
|
||||
| { 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 +271,6 @@ export function buildWorkflowFolderTree(
|
||||
kind: 'workflow',
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
isOpen: w.isOpen,
|
||||
})
|
||||
|
||||
const buildLevel = (parentId: string | null): WorkflowTreeNode[] => {
|
||||
@@ -262,7 +310,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 +320,7 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI
|
||||
node.kind === 'workflow' ? (
|
||||
<DropdownMenuItem
|
||||
key={node.id}
|
||||
onClick={() =>
|
||||
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 +343,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 +365,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 +383,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 +393,7 @@ export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProp
|
||||
node.kind === 'file' ? (
|
||||
<DropdownMenuItem
|
||||
key={node.id}
|
||||
onClick={() => 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 +407,7 @@ export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProp
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen)
|
||||
}
|
||||
onClick={() => onSelect({ type: 'filefolder', id: node.id, title: node.name })}
|
||||
>
|
||||
<Folder className='size-[14px]' />
|
||||
<span>{node.name}</span>
|
||||
@@ -391,10 +433,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 { groups: available } = useAvailableResources(workspaceId, { enabled: open, excludeTypes })
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
@@ -403,8 +443,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 +486,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 +534,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 })}
|
||||
</DropdownMenuItem>
|
||||
@@ -553,9 +593,7 @@ export function AddResourceDropdown({
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
select({ type, id: item.id, title: item.name }, item.isOpen)
|
||||
}
|
||||
onClick={() => select({ type, id: item.id, title: item.name })}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
|
||||
-6
@@ -1,9 +1,3 @@
|
||||
export type {
|
||||
AddResourceDropdownProps,
|
||||
AvailableItem,
|
||||
FileFolderTreeNode,
|
||||
WorkflowTreeNode,
|
||||
} from './add-resource-dropdown'
|
||||
export {
|
||||
AddResourceDropdown,
|
||||
buildFileFolderTree,
|
||||
|
||||
-1
@@ -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'
|
||||
|
||||
+38
-19
@@ -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`
|
||||
@@ -112,25 +123,36 @@ const PREVIEW_MODE_LABELS: Record<PreviewMode, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `type:id` -> current name lookup from live query data so resource
|
||||
* tabs always reflect the latest name even after a rename.
|
||||
* 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.
|
||||
*/
|
||||
function useResourceNameLookup(workspaceId: string): Map<string, string> {
|
||||
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 NO_RESOURCE_NAMES = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Builds a `type:id` -> current name lookup from live query data so resource
|
||||
* 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, enabled: boolean): Map<string, string> {
|
||||
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<string, string>()
|
||||
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) => {
|
||||
|
||||
+11
-1
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { type AvailableResourceGroup, PlusMenuDropdown } from './plus-menu-dropdown'
|
||||
export { PlusMenuDropdown } from './plus-menu-dropdown'
|
||||
|
||||
+26
-11
@@ -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,28 @@ import type {
|
||||
MothershipResourceType,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
export type AvailableResourceGroup = ReturnType<typeof useAvailableResources>[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<MothershipResourceType>(['integration'])
|
||||
|
||||
interface PlusMenuDropdownProps {
|
||||
availableResources: AvailableResourceGroup[]
|
||||
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<HTMLTextAreaElement | null>
|
||||
@@ -48,7 +59,7 @@ interface PlusMenuDropdownProps {
|
||||
|
||||
export const PlusMenuDropdown = React.memo(
|
||||
React.forwardRef<PlusMenuHandle, PlusMenuDropdownProps>(function PlusMenuDropdown(
|
||||
{ availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery },
|
||||
{ workspaceId, warm, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery },
|
||||
ref
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -59,6 +70,11 @@ export const PlusMenuDropdown = React.memo(
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Gated so an idle chat surface never fetches the workspace lists.
|
||||
const { groups: availableResources, isHydrating } = useAvailableResources(workspaceId, {
|
||||
enabled: open || !!warm,
|
||||
})
|
||||
|
||||
const doOpen = useCallback(
|
||||
(anchor: { left: number; top: number }, options?: { mention?: boolean }) => {
|
||||
setAnchorPos(anchor)
|
||||
@@ -74,8 +90,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
|
||||
@@ -115,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.
|
||||
@@ -149,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]
|
||||
|
||||
+10
-2
@@ -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<HTMLDivElement>(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}
|
||||
@@ -219,7 +226,8 @@ export function PromptEditor({
|
||||
<>
|
||||
<PlusMenuDropdown
|
||||
ref={editor.plusMenuRef}
|
||||
availableResources={editor.availableResources}
|
||||
workspaceId={editor.workspaceId}
|
||||
warm={hasFocused}
|
||||
onResourceSelect={editor.insertResource}
|
||||
onClose={editor.handlePlusMenuClose}
|
||||
textareaRef={editor.textareaRef}
|
||||
|
||||
+1
-13
@@ -7,20 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/hooks/queries/skills', () => ({ 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'
|
||||
@@ -91,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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+9
-21
@@ -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<string>()
|
||||
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
|
||||
@@ -689,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
|
||||
}
|
||||
@@ -1041,12 +1029,12 @@ export function usePromptEditor({
|
||||
textareaRef,
|
||||
|
||||
/** @internal Wiring consumed by the {@link PromptEditor} view. */
|
||||
workspaceId,
|
||||
/** @internal */
|
||||
skills,
|
||||
/** @internal */
|
||||
mcpServers,
|
||||
/** @internal */
|
||||
availableResources,
|
||||
/** @internal */
|
||||
mentionQuery,
|
||||
/** @internal */
|
||||
slashQuery,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user