Skip to content

Commit 6a44068

Browse files
committed
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.
1 parent 8f224ce commit 6a44068

5 files changed

Lines changed: 87 additions & 22 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ interface AvailableItemsByType {
5656
items: AvailableItem[]
5757
}
5858

59+
interface AvailableResources {
60+
groups: AvailableItemsByType[]
61+
/**
62+
* True while enabled and at least one list has yet to produce data. Callers
63+
* that act on "no candidates" must check this first — an empty result during
64+
* hydration means "not known yet", not "no match".
65+
*/
66+
isHydrating: boolean
67+
}
68+
5969
interface UseAvailableResourcesOptions {
6070
/**
6171
* Skips the underlying list queries and the group construction they feed
@@ -94,24 +104,58 @@ const LOG_DROPDOWN_FILTERS = {
94104
export function useAvailableResources(
95105
workspaceId: string,
96106
options?: UseAvailableResourcesOptions
97-
): AvailableItemsByType[] {
107+
): AvailableResources {
98108
const enabled = options?.enabled ?? true
99109
const excludeTypes = options?.excludeTypes
100110
// Destructured without `= []` defaults on purpose: a literal default allocates a
101111
// fresh array every render while `data` is undefined (exactly the disabled state),
102112
// which would bust the group memo below on every render. Undefined is stable.
103-
const { data: workflows } = useWorkflows(workspaceId, { enabled })
104-
const { data: tables } = useTablesList(workspaceId, 'active', { enabled })
105-
const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled })
106-
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { enabled })
107-
const { data: folders } = useFolders(workspaceId, { enabled })
108-
const { data: fileFolders } = useWorkspaceFileFolders(workspaceId, 'active', { enabled })
109-
const { data: tasks } = useMothershipChats(workspaceId, { enabled })
110-
const { data: schedules } = useWorkspaceSchedules(workspaceId, { enabled })
111-
const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS, { enabled })
113+
const { data: workflows, isPending: workflowsPending } = useWorkflows(workspaceId, { enabled })
114+
const { data: tables, isPending: tablesPending } = useTablesList(workspaceId, 'active', {
115+
enabled,
116+
})
117+
const { data: files, isPending: filesPending } = useWorkspaceFiles(workspaceId, 'active', {
118+
enabled,
119+
})
120+
const { data: knowledgeBases, isPending: knowledgeBasesPending } = useKnowledgeBasesQuery(
121+
workspaceId,
122+
{ enabled }
123+
)
124+
const { data: folders, isPending: foldersPending } = useFolders(workspaceId, { enabled })
125+
const { data: fileFolders, isPending: fileFoldersPending } = useWorkspaceFileFolders(
126+
workspaceId,
127+
'active',
128+
{ enabled }
129+
)
130+
const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, { enabled })
131+
const { data: schedules, isPending: schedulesPending } = useWorkspaceSchedules(workspaceId, {
132+
enabled,
133+
})
134+
const { data: logsData, isPending: logsPending } = useLogsList(
135+
workspaceId,
136+
LOG_DROPDOWN_FILTERS,
137+
{ enabled }
138+
)
112139
const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData])
113140

114-
return useMemo(() => {
141+
/**
142+
* Keyed off `isPending` rather than `data === undefined` so a failed list
143+
* settles to "not hydrating" — an errored query must not block the caller
144+
* forever.
145+
*/
146+
const isHydrating =
147+
enabled &&
148+
(workflowsPending ||
149+
tablesPending ||
150+
filesPending ||
151+
knowledgeBasesPending ||
152+
foldersPending ||
153+
fileFoldersPending ||
154+
tasksPending ||
155+
schedulesPending ||
156+
logsPending)
157+
158+
const groups = useMemo(() => {
115159
if (!enabled) return NO_RESOURCE_GROUPS
116160
const excluded = new Set<MothershipResourceType>(excludeTypes ?? [])
117161
const groups: AvailableItemsByType[] = [
@@ -198,6 +242,10 @@ export function useAvailableResources(
198242
logs,
199243
excludeTypes,
200244
])
245+
246+
// `groups` keeps its own stable identity so the consumers' downstream memos
247+
// still key on it; only this wrapper changes when hydration settles.
248+
return useMemo(() => ({ groups, isHydrating }), [groups, isHydrating])
201249
}
202250

203251
export type WorkflowTreeNode =
@@ -386,7 +434,7 @@ export function AddResourceDropdown({
386434
const [search, setSearch] = useState('')
387435
const [activeIndex, setActiveIndex] = useState(0)
388436
// Gated on `open` so an idle tab bar never fetches the workspace lists.
389-
const available = useAvailableResources(workspaceId, { enabled: open, excludeTypes })
437+
const { groups: available } = useAvailableResources(workspaceId, { enabled: open, excludeTypes })
390438
const handleOpenChange = (next: boolean) => {
391439
setOpen(next)
392440
if (!next) {

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,17 @@ export interface PlusMenuHandle {
4141
open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void
4242
close: () => void
4343
moveActive: (delta: number) => void
44-
selectActive: () => boolean
44+
/**
45+
* Confirms the highlighted candidate.
46+
*
47+
* - `selected` — a candidate was inserted.
48+
* - `empty` — the lists are loaded and nothing matches, so the caller should
49+
* let the key through (Enter submits, Tab does its default).
50+
* - `hydrating` — the lists are still loading, so "nothing matches" is not yet
51+
* knowable. The caller must swallow the key rather than submit a message
52+
* with the mention left as raw text.
53+
*/
54+
selectActive: () => 'selected' | 'empty' | 'hydrating'
4555
}
4656

4757
/**

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ export const PlusMenuDropdown = React.memo(
7171
const contentRef = useRef<HTMLDivElement>(null)
7272

7373
// Gated so an idle chat surface never fetches the workspace lists.
74-
const availableResources = useAvailableResources(workspaceId, { enabled: open || !!warm })
74+
const { groups: availableResources, isHydrating } = useAvailableResources(workspaceId, {
75+
enabled: open || !!warm,
76+
})
7577

7678
const doOpen = useCallback(
7779
(anchor: { left: number; top: number }, options?: { mention?: boolean }) => {
@@ -127,6 +129,8 @@ export const PlusMenuDropdown = React.memo(
127129
activeIndexRef.current = activeIndex
128130
const isMentionRef = useRef(isMention)
129131
isMentionRef.current = isMention
132+
const isHydratingRef = useRef(isHydrating)
133+
isHydratingRef.current = isHydrating
130134

131135
// Reset highlight to the top whenever the mention query changes so the user always
132136
// sees the best match selected as they type.
@@ -161,15 +165,14 @@ export const PlusMenuDropdown = React.memo(
161165
},
162166
selectActive: () => {
163167
const items = filteredItemsRef.current
164-
if (!items || items.length === 0) return false
165-
const target = items[activeIndexRef.current] ?? items[0]
166-
if (!target) return false
168+
const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined
169+
if (!target) return isHydratingRef.current ? 'hydrating' : 'empty'
167170
handleSelectRef.current({
168171
type: target.type,
169172
id: target.item.id,
170173
title: target.item.name,
171174
})
172-
return true
175+
return 'selected'
173176
},
174177
}),
175178
[doOpen, doClose]

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe('usePromptEditor mention menu dismissal', () => {
7979
open: vi.fn(),
8080
close: vi.fn(),
8181
moveActive: vi.fn(),
82-
selectActive: vi.fn(() => false),
82+
selectActive: vi.fn(() => 'empty' as const),
8383
}
8484
})
8585

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -673,9 +673,13 @@ export function usePromptEditor({
673673
return
674674
}
675675
if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) {
676-
// Confirm the highlighted match if there is one. If no items match, fall
677-
// through so Enter still submits and Tab still does its default thing.
678-
if (plusMenuRef.current?.selectActive()) {
676+
// Confirm the highlighted match if there is one. If the lists are still
677+
// loading, swallow the key — "no match" isn't knowable yet, and falling
678+
// through would submit the message with the mention left as raw text.
679+
// Only once they are loaded does an empty result mean a genuine no-match,
680+
// where Enter should submit and Tab should do its default thing.
681+
const result = plusMenuRef.current?.selectActive()
682+
if (result === 'selected' || result === 'hydrating') {
679683
e.preventDefault()
680684
return
681685
}

0 commit comments

Comments
 (0)