@ttt-productions/chat-react 0.13.2 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +13 -11
  2. package/dist/context/ChatNameResolverContext.d.ts +3 -3
  3. package/dist/context/ChatNameResolverContext.js +3 -3
  4. package/dist/context/ChatNameResolverContext.js.map +1 -1
  5. package/dist/hooks/useChatMessages.d.ts.map +1 -1
  6. package/dist/hooks/useChatMessages.js +4 -13
  7. package/dist/hooks/useChatMessages.js.map +1 -1
  8. package/dist/index.d.ts +2 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/realtime/channel-client.d.ts +21 -4
  13. package/dist/realtime/channel-client.d.ts.map +1 -1
  14. package/dist/realtime/channel-client.js +109 -12
  15. package/dist/realtime/channel-client.js.map +1 -1
  16. package/dist/realtime/diagnostics.d.ts +7 -0
  17. package/dist/realtime/diagnostics.d.ts.map +1 -1
  18. package/dist/realtime/diagnostics.js +7 -0
  19. package/dist/realtime/diagnostics.js.map +1 -1
  20. package/dist/realtime/inbox-client.d.ts.map +1 -1
  21. package/dist/realtime/inbox-client.js +29 -8
  22. package/dist/realtime/inbox-client.js.map +1 -1
  23. package/dist/realtime/map.d.ts +3 -5
  24. package/dist/realtime/map.d.ts.map +1 -1
  25. package/dist/realtime/map.js +3 -18
  26. package/dist/realtime/map.js.map +1 -1
  27. package/dist/realtime/shared.d.ts +21 -0
  28. package/dist/realtime/shared.d.ts.map +1 -1
  29. package/dist/realtime/shared.js +22 -0
  30. package/dist/realtime/shared.js.map +1 -1
  31. package/dist/realtime/useRealtimeChatMessages.d.ts +1 -4
  32. package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
  33. package/dist/realtime/useRealtimeChatMessages.js +1 -3
  34. package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
  35. package/dist/realtime/wire.d.ts +6 -5
  36. package/dist/realtime/wire.d.ts.map +1 -1
  37. package/dist/realtime/wire.js.map +1 -1
  38. package/dist/types.d.ts +1 -52
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js.map +1 -1
  41. package/dist/ui/ChatShell.d.ts +2 -2
  42. package/dist/ui/ChatShell.d.ts.map +1 -1
  43. package/dist/ui/ChatShell.js +6 -10
  44. package/dist/ui/ChatShell.js.map +1 -1
  45. package/dist/ui/Composer.d.ts +7 -6
  46. package/dist/ui/Composer.d.ts.map +1 -1
  47. package/dist/ui/Composer.js +2 -18
  48. package/dist/ui/Composer.js.map +1 -1
  49. package/dist/ui/MessageItemDefault.d.ts.map +1 -1
  50. package/dist/ui/MessageItemDefault.js +7 -12
  51. package/dist/ui/MessageItemDefault.js.map +1 -1
  52. package/dist/ui/MessageText.d.ts +19 -0
  53. package/dist/ui/MessageText.d.ts.map +1 -0
  54. package/dist/ui/MessageText.js +7 -0
  55. package/dist/ui/MessageText.js.map +1 -0
  56. package/package.json +6 -6
  57. package/src/styles/chat.css +0 -40
  58. package/dist/mentions/MentionAutocomplete.d.ts +0 -22
  59. package/dist/mentions/MentionAutocomplete.d.ts.map +0 -1
  60. package/dist/mentions/MentionAutocomplete.js +0 -21
  61. package/dist/mentions/MentionAutocomplete.js.map +0 -1
  62. package/dist/mentions/MessageText.d.ts +0 -27
  63. package/dist/mentions/MessageText.d.ts.map +0 -1
  64. package/dist/mentions/MessageText.js +0 -20
  65. package/dist/mentions/MessageText.js.map +0 -1
  66. package/dist/mentions/use-mention-autocomplete.d.ts +0 -82
  67. package/dist/mentions/use-mention-autocomplete.d.ts.map +0 -1
  68. package/dist/mentions/use-mention-autocomplete.js +0 -323
  69. package/dist/mentions/use-mention-autocomplete.js.map +0 -1
@@ -1,323 +0,0 @@
1
- 'use client';
2
- /**
3
- * Composer-side mention autocomplete state hook.
4
- *
5
- * Detects when the user types the trigger character (default `@`) inside the
6
- * provided textarea, captures the live query (the text between the trigger
7
- * and the cursor), debounces searches across all configured providers, and
8
- * tracks the anchor positions of inserted mentions so the composer can
9
- * convert display text back to wire tokens on send.
10
- *
11
- * Returns an `AutocompleteState` value tailored for the `<MentionAutocomplete>`
12
- * UI component, plus an `insertMention` action and a `getValueWithTokens`
13
- * accessor the composer calls on send.
14
- *
15
- * Generic over `TKind` and `TContext`; chat-core inspects neither.
16
- */
17
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
- import { formatMentionToken } from '@ttt-productions/chat-core';
19
- function isWordBoundary(ch) {
20
- return ch === undefined || /\s/.test(ch);
21
- }
22
- export function useMentionAutocomplete(args) {
23
- const { textareaRef, value, onChange, providers, context, recent, trigger = '@', minQueryLength = 0, searchDebounceMs = 200, } = args;
24
- if (trigger.length !== 1) {
25
- throw new Error(`useMentionAutocomplete: trigger must be a single character, got ${JSON.stringify(trigger)}`);
26
- }
27
- const [open, setOpen] = useState(false);
28
- const [triggerIndex, setTriggerIndex] = useState(null);
29
- const [query, setQuery] = useState('');
30
- const [groups, setGroups] = useState([]);
31
- const [showingRecent, setShowingRecent] = useState(false);
32
- const [highlightedIndex, setHighlightedIndex] = useState(0);
33
- const [anchors, setAnchors] = useState([]);
34
- // Refs for the latest values so the debounced search reads fresh.
35
- const valueRef = useRef(value);
36
- const triggerIndexRef = useRef(triggerIndex);
37
- const providersRef = useRef(providers);
38
- const contextRef = useRef(context);
39
- useEffect(() => {
40
- valueRef.current = value;
41
- triggerIndexRef.current = triggerIndex;
42
- providersRef.current = providers;
43
- contextRef.current = context;
44
- });
45
- const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
46
- const close = useCallback(() => {
47
- setOpen(false);
48
- setTriggerIndex(null);
49
- setQuery('');
50
- setGroups([]);
51
- setHighlightedIndex(0);
52
- setShowingRecent(false);
53
- }, []);
54
- /**
55
- * Detect trigger state based on current value + cursor position.
56
- * Called from useEffect on value changes.
57
- */
58
- useEffect(() => {
59
- const ta = textareaRef.current;
60
- if (!ta)
61
- return;
62
- const cursor = ta.selectionStart;
63
- if (cursor === null)
64
- return;
65
- // Walk backwards from the cursor to find the trigger character, stopping
66
- // at a whitespace boundary or the start of the string.
67
- let i = cursor - 1;
68
- while (i >= 0) {
69
- const ch = value[i];
70
- if (ch === trigger) {
71
- // Trigger only counts if the character before it is whitespace or BoL.
72
- if (i === 0 || /\s/.test(value[i - 1])) {
73
- const q = value.slice(i + 1, cursor);
74
- // If the slice contains whitespace, the trigger run has been broken.
75
- if (/\s/.test(q)) {
76
- close();
77
- return;
78
- }
79
- setTriggerIndex(i);
80
- setQuery(q);
81
- setOpen(true);
82
- setHighlightedIndex(0);
83
- return;
84
- }
85
- // Inline `@` not at word boundary — not a trigger.
86
- close();
87
- return;
88
- }
89
- if (/\s/.test(ch)) {
90
- close();
91
- return;
92
- }
93
- i -= 1;
94
- }
95
- close();
96
- // We intentionally watch value AND the textarea's cursor position via the
97
- // event chain — selectionStart updates synchronously with input/key events.
98
- // eslint-disable-next-line react-hooks/exhaustive-deps
99
- }, [value, trigger]);
100
- /**
101
- * Recompute anchor offsets when the textarea value changes externally
102
- * (e.g. user deletes text). Drop anchors whose displayed text no longer
103
- * matches the underlying displayText at their range.
104
- */
105
- useEffect(() => {
106
- setAnchors((prev) => prev.filter((a) => {
107
- const expected = `${trigger}${a.ref.displayText}`;
108
- return value.slice(a.start, a.end) === expected;
109
- }));
110
- }, [value, trigger]);
111
- /**
112
- * Search: fires whenever query opens / changes. Debounced. Cancels in-flight
113
- * via AbortController.
114
- */
115
- useEffect(() => {
116
- if (!open) {
117
- setGroups([]);
118
- setShowingRecent(false);
119
- return;
120
- }
121
- let cancelled = false;
122
- const controller = new AbortController();
123
- if (query.length < minQueryLength) {
124
- // Below threshold: show recent picks if adapter supplied.
125
- if (recent) {
126
- Promise.resolve(recent.getRecent())
127
- .then((items) => {
128
- if (cancelled)
129
- return;
130
- // Group recent by kind, preserving provider order.
131
- const byKind = new Map();
132
- for (const item of items) {
133
- const arr = byKind.get(item.kind) ?? [];
134
- arr.push(item);
135
- byKind.set(item.kind, arr);
136
- }
137
- const recentGroups = providers
138
- .map((p) => ({
139
- kind: p.kind,
140
- label: p.label,
141
- results: byKind.get(p.kind) ?? [],
142
- loading: false,
143
- renderResult: p.renderResult,
144
- }))
145
- .filter((g) => g.results.length > 0);
146
- setGroups(recentGroups);
147
- setShowingRecent(true);
148
- setHighlightedIndex(0);
149
- })
150
- .catch(() => {
151
- if (!cancelled) {
152
- setGroups([]);
153
- setShowingRecent(false);
154
- }
155
- });
156
- }
157
- else {
158
- setGroups([]);
159
- setShowingRecent(false);
160
- }
161
- return () => {
162
- cancelled = true;
163
- controller.abort();
164
- };
165
- }
166
- setShowingRecent(false);
167
- // Initial: each group loading.
168
- setGroups(providers.map((p) => ({
169
- kind: p.kind,
170
- label: p.label,
171
- results: [],
172
- loading: true,
173
- renderResult: p.renderResult,
174
- })));
175
- const timer = setTimeout(() => {
176
- providers.forEach((provider, i) => {
177
- provider
178
- .search({ query, context, signal: controller.signal })
179
- .then((results) => {
180
- if (cancelled)
181
- return;
182
- setGroups((prev) => {
183
- const next = prev.slice();
184
- next[i] = { ...next[i], results, loading: false };
185
- return next;
186
- });
187
- })
188
- .catch(() => {
189
- if (cancelled)
190
- return;
191
- setGroups((prev) => {
192
- const next = prev.slice();
193
- next[i] = { ...next[i], results: [], loading: false };
194
- return next;
195
- });
196
- });
197
- });
198
- }, searchDebounceMs);
199
- return () => {
200
- cancelled = true;
201
- controller.abort();
202
- clearTimeout(timer);
203
- };
204
- // We deliberately depend on `query` + `open` only; `providers` / `context`
205
- // are read via refs to avoid re-firing on identity changes.
206
- // eslint-disable-next-line react-hooks/exhaustive-deps
207
- }, [open, query, minQueryLength, searchDebounceMs, recent]);
208
- const insertMention = useCallback((ref) => {
209
- const ti = triggerIndexRef.current;
210
- if (ti === null)
211
- return;
212
- const ta = textareaRef.current;
213
- if (!ta)
214
- return;
215
- const before = valueRef.current.slice(0, ti);
216
- // Cursor at point of insertion — current selection end captures the
217
- // typed query.
218
- const cursor = ta.selectionStart ?? ti + 1 + query.length;
219
- const after = valueRef.current.slice(cursor);
220
- const insertion = `${trigger}${ref.displayText}`;
221
- // Add a trailing space unless there is an explicit whitespace character
222
- // immediately after the insertion point (don't double-space). End-of-string
223
- // (after[0] === undefined) always adds a space so the cursor clears the
224
- // trigger run and the autocomplete doesn't re-open.
225
- const trailing = (after[0] !== undefined && isWordBoundary(after[0])) ? '' : ' ';
226
- const next = `${before}${insertion}${trailing}${after}`;
227
- const anchorStart = ti;
228
- const anchorEnd = ti + insertion.length;
229
- onChange(next);
230
- setAnchors((prev) => {
231
- // Shift any later anchors by the net change in length.
232
- const delta = insertion.length + trailing.length - (cursor - ti);
233
- const shifted = prev.map((a) => a.start >= cursor ? { ...a, start: a.start + delta, end: a.end + delta } : a);
234
- return [...shifted, { start: anchorStart, end: anchorEnd, ref }];
235
- });
236
- // Fire recent-use without awaiting.
237
- if (recent?.recordUse) {
238
- try {
239
- const result = recent.recordUse(ref);
240
- if (result && typeof result.then === 'function') {
241
- result.catch(() => undefined);
242
- }
243
- }
244
- catch {
245
- // Swallow — recent-use is best-effort.
246
- }
247
- }
248
- close();
249
- // Move cursor to just after the inserted mention (+ optional trailing space).
250
- // Defer so the controlled value flush completes first.
251
- const newCursor = anchorEnd + trailing.length;
252
- queueMicrotask(() => {
253
- const t = textareaRef.current;
254
- if (t) {
255
- t.focus();
256
- t.setSelectionRange(newCursor, newCursor);
257
- }
258
- });
259
- }, [trigger, query.length, onChange, recent, close, textareaRef]);
260
- const handleKeyDown = useCallback((event) => {
261
- if (!open)
262
- return false;
263
- if (flatResults.length === 0 && event.key !== 'Escape') {
264
- // Allow Escape to close even with no results; otherwise pass through.
265
- return false;
266
- }
267
- switch (event.key) {
268
- case 'ArrowDown':
269
- event.preventDefault();
270
- setHighlightedIndex((i) => (i + 1) % Math.max(flatResults.length, 1));
271
- return true;
272
- case 'ArrowUp':
273
- event.preventDefault();
274
- setHighlightedIndex((i) => (i - 1 + flatResults.length) % Math.max(flatResults.length, 1));
275
- return true;
276
- case 'Enter':
277
- case 'Tab': {
278
- if (flatResults.length === 0)
279
- return false;
280
- event.preventDefault();
281
- const pick = flatResults[Math.min(highlightedIndex, flatResults.length - 1)];
282
- if (pick)
283
- insertMention(pick);
284
- return true;
285
- }
286
- case 'Escape':
287
- event.preventDefault();
288
- close();
289
- return true;
290
- default:
291
- return false;
292
- }
293
- }, [open, flatResults, highlightedIndex, insertMention, close]);
294
- const getValueWithTokens = useCallback(() => {
295
- if (anchors.length === 0)
296
- return valueRef.current;
297
- // Sort anchors by start ASC, then substitute. Walk the value left → right
298
- // building the result with mention tokens swapped in at each anchor's range.
299
- const sorted = [...anchors].sort((a, b) => a.start - b.start);
300
- const v = valueRef.current;
301
- let out = '';
302
- let cursor = 0;
303
- for (const a of sorted) {
304
- if (a.start < cursor)
305
- continue; // overlapping; drop (shouldn't happen)
306
- out += v.slice(cursor, a.start);
307
- out += formatMentionToken(a.ref);
308
- cursor = a.end;
309
- }
310
- out += v.slice(cursor);
311
- return out;
312
- }, [anchors]);
313
- const state = {
314
- open,
315
- query,
316
- groups,
317
- showingRecent,
318
- highlightedIndex,
319
- flatResults,
320
- };
321
- return { state, handleKeyDown, insertMention, getValueWithTokens, anchors, close };
322
- }
323
- //# sourceMappingURL=use-mention-autocomplete.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-mention-autocomplete.js","sourceRoot":"","sources":["../../src/mentions/use-mention-autocomplete.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\n/**\n * Composer-side mention autocomplete state hook.\n *\n * Detects when the user types the trigger character (default `@`) inside the\n * provided textarea, captures the live query (the text between the trigger\n * and the cursor), debounces searches across all configured providers, and\n * tracks the anchor positions of inserted mentions so the composer can\n * convert display text back to wire tokens on send.\n *\n * Returns an `AutocompleteState` value tailored for the `<MentionAutocomplete>`\n * UI component, plus an `insertMention` action and a `getValueWithTokens`\n * accessor the composer calls on send.\n *\n * Generic over `TKind` and `TContext`; chat-core inspects neither.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';\nimport { formatMentionToken } from '@ttt-productions/chat-core';\nimport type {\n MentionAnchor,\n MentionRef,\n RecentMentionsAdapter,\n} from '@ttt-productions/chat-core';\nimport type { RenderableMentionProvider } from '../types.js';\n\nexport type AutocompleteResultGroup<TKind extends string = string> = {\n kind: TKind;\n label: string;\n results: MentionRef<TKind>[];\n /** True if `search` is in flight for this provider. */\n loading: boolean;\n /** Provider-supplied custom renderer for individual results, if any. */\n renderResult?: (ref: MentionRef<TKind>) => import('react').ReactNode;\n};\n\nexport type AutocompleteState<TKind extends string = string> = {\n /** True iff the dropdown should be visible. */\n open: boolean;\n /** Current query text (everything after the trigger up to the cursor). */\n query: string;\n /** Results grouped by provider, in the order the consumer passed providers. */\n groups: AutocompleteResultGroup<TKind>[];\n /** When true, groups are recent picks (`getRecent`) rather than search results. */\n showingRecent: boolean;\n /** Index of the currently highlighted result, flattened across all groups. */\n highlightedIndex: number;\n /** Flat array of all results in display order. Used internally for keyboard nav. */\n flatResults: MentionRef<TKind>[];\n};\n\nexport interface UseMentionAutocompleteArgs<TKind extends string, TContext> {\n textareaRef: RefObject<HTMLTextAreaElement | null>;\n /** Current textarea value (controlled). */\n value: string;\n /** Setter for the textarea value. */\n onChange: (next: string) => void;\n /** Providers in display order. Empty list disables the system entirely. */\n providers: RenderableMentionProvider<TKind, TContext>[];\n /** Consumer-controlled context passed to each provider's `search`. */\n context: TContext;\n /** Optional recent-mentions adapter. */\n recent?: RecentMentionsAdapter<TKind>;\n /** Trigger character. Defaults to `'@'`. Must be exactly one character. */\n trigger?: string;\n /**\n * Minimum query length to fire `search`. Below this, recent picks are shown\n * (if `recent` is supplied) or the dropdown stays empty. Default: 0 (search\n * fires immediately on trigger).\n */\n minQueryLength?: number;\n /** Debounce window in ms for search calls. Default: 200. */\n searchDebounceMs?: number;\n}\n\nexport interface UseMentionAutocompleteResult<TKind extends string = string> {\n state: AutocompleteState<TKind>;\n /** Handle keydown from the textarea — returns true if the event was handled. */\n handleKeyDown: (event: import('react').KeyboardEvent<HTMLTextAreaElement>) => boolean;\n /** Programmatically insert a mention at the trigger position (called by the autocomplete UI). */\n insertMention: (ref: MentionRef<TKind>) => void;\n /**\n * Returns the current textarea value with all tracked mention anchors\n * substituted back to wire tokens. Call this from the composer's send path.\n */\n getValueWithTokens: () => string;\n /** Anchor list — exposed for tests and consumers that need the raw positions. */\n anchors: MentionAnchor<TKind>[];\n /** Close the dropdown imperatively (e.g. on send). */\n close: () => void;\n}\n\nfunction isWordBoundary(ch: string | undefined): boolean {\n return ch === undefined || /\\s/.test(ch);\n}\n\nexport function useMentionAutocomplete<TKind extends string, TContext>(\n args: UseMentionAutocompleteArgs<TKind, TContext>,\n): UseMentionAutocompleteResult<TKind> {\n const {\n textareaRef,\n value,\n onChange,\n providers,\n context,\n recent,\n trigger = '@',\n minQueryLength = 0,\n searchDebounceMs = 200,\n } = args;\n\n if (trigger.length !== 1) {\n throw new Error(`useMentionAutocomplete: trigger must be a single character, got ${JSON.stringify(trigger)}`);\n }\n\n const [open, setOpen] = useState(false);\n const [triggerIndex, setTriggerIndex] = useState<number | null>(null);\n const [query, setQuery] = useState('');\n const [groups, setGroups] = useState<AutocompleteResultGroup<TKind>[]>([]);\n const [showingRecent, setShowingRecent] = useState(false);\n const [highlightedIndex, setHighlightedIndex] = useState(0);\n const [anchors, setAnchors] = useState<MentionAnchor<TKind>[]>([]);\n\n // Refs for the latest values so the debounced search reads fresh.\n const valueRef = useRef(value);\n const triggerIndexRef = useRef(triggerIndex);\n const providersRef = useRef(providers);\n const contextRef = useRef(context);\n useEffect(() => {\n valueRef.current = value;\n triggerIndexRef.current = triggerIndex;\n providersRef.current = providers;\n contextRef.current = context;\n });\n\n const flatResults = useMemo(\n () => groups.flatMap((g) => g.results),\n [groups],\n );\n\n const close = useCallback(() => {\n setOpen(false);\n setTriggerIndex(null);\n setQuery('');\n setGroups([]);\n setHighlightedIndex(0);\n setShowingRecent(false);\n }, []);\n\n /**\n * Detect trigger state based on current value + cursor position.\n * Called from useEffect on value changes.\n */\n useEffect(() => {\n const ta = textareaRef.current;\n if (!ta) return;\n const cursor = ta.selectionStart;\n if (cursor === null) return;\n\n // Walk backwards from the cursor to find the trigger character, stopping\n // at a whitespace boundary or the start of the string.\n let i = cursor - 1;\n while (i >= 0) {\n const ch = value[i];\n if (ch === trigger) {\n // Trigger only counts if the character before it is whitespace or BoL.\n if (i === 0 || /\\s/.test(value[i - 1])) {\n const q = value.slice(i + 1, cursor);\n // If the slice contains whitespace, the trigger run has been broken.\n if (/\\s/.test(q)) {\n close();\n return;\n }\n setTriggerIndex(i);\n setQuery(q);\n setOpen(true);\n setHighlightedIndex(0);\n return;\n }\n // Inline `@` not at word boundary — not a trigger.\n close();\n return;\n }\n if (/\\s/.test(ch)) {\n close();\n return;\n }\n i -= 1;\n }\n close();\n // We intentionally watch value AND the textarea's cursor position via the\n // event chain — selectionStart updates synchronously with input/key events.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, trigger]);\n\n /**\n * Recompute anchor offsets when the textarea value changes externally\n * (e.g. user deletes text). Drop anchors whose displayed text no longer\n * matches the underlying displayText at their range.\n */\n useEffect(() => {\n setAnchors((prev) =>\n prev.filter((a) => {\n const expected = `${trigger}${a.ref.displayText}`;\n return value.slice(a.start, a.end) === expected;\n }),\n );\n }, [value, trigger]);\n\n /**\n * Search: fires whenever query opens / changes. Debounced. Cancels in-flight\n * via AbortController.\n */\n useEffect(() => {\n if (!open) {\n setGroups([]);\n setShowingRecent(false);\n return;\n }\n\n let cancelled = false;\n const controller = new AbortController();\n\n if (query.length < minQueryLength) {\n // Below threshold: show recent picks if adapter supplied.\n if (recent) {\n Promise.resolve(recent.getRecent())\n .then((items) => {\n if (cancelled) return;\n // Group recent by kind, preserving provider order.\n const byKind = new Map<TKind, MentionRef<TKind>[]>();\n for (const item of items) {\n const arr = byKind.get(item.kind) ?? [];\n arr.push(item);\n byKind.set(item.kind, arr);\n }\n const recentGroups: AutocompleteResultGroup<TKind>[] = providers\n .map((p) => ({\n kind: p.kind,\n label: p.label,\n results: byKind.get(p.kind) ?? [],\n loading: false,\n renderResult: p.renderResult,\n }))\n .filter((g) => g.results.length > 0);\n setGroups(recentGroups);\n setShowingRecent(true);\n setHighlightedIndex(0);\n })\n .catch(() => {\n if (!cancelled) {\n setGroups([]);\n setShowingRecent(false);\n }\n });\n } else {\n setGroups([]);\n setShowingRecent(false);\n }\n return () => {\n cancelled = true;\n controller.abort();\n };\n }\n\n setShowingRecent(false);\n // Initial: each group loading.\n setGroups(\n providers.map((p) => ({\n kind: p.kind,\n label: p.label,\n results: [],\n loading: true,\n renderResult: p.renderResult,\n })),\n );\n\n const timer = setTimeout(() => {\n providers.forEach((provider, i) => {\n provider\n .search({ query, context, signal: controller.signal })\n .then((results) => {\n if (cancelled) return;\n setGroups((prev) => {\n const next = prev.slice();\n next[i] = { ...next[i], results, loading: false };\n return next;\n });\n })\n .catch(() => {\n if (cancelled) return;\n setGroups((prev) => {\n const next = prev.slice();\n next[i] = { ...next[i], results: [], loading: false };\n return next;\n });\n });\n });\n }, searchDebounceMs);\n\n return () => {\n cancelled = true;\n controller.abort();\n clearTimeout(timer);\n };\n // We deliberately depend on `query` + `open` only; `providers` / `context`\n // are read via refs to avoid re-firing on identity changes.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open, query, minQueryLength, searchDebounceMs, recent]);\n\n const insertMention = useCallback(\n (ref: MentionRef<TKind>) => {\n const ti = triggerIndexRef.current;\n if (ti === null) return;\n const ta = textareaRef.current;\n if (!ta) return;\n\n const before = valueRef.current.slice(0, ti);\n // Cursor at point of insertion — current selection end captures the\n // typed query.\n const cursor = ta.selectionStart ?? ti + 1 + query.length;\n const after = valueRef.current.slice(cursor);\n const insertion = `${trigger}${ref.displayText}`;\n // Add a trailing space unless there is an explicit whitespace character\n // immediately after the insertion point (don't double-space). End-of-string\n // (after[0] === undefined) always adds a space so the cursor clears the\n // trigger run and the autocomplete doesn't re-open.\n const trailing = (after[0] !== undefined && isWordBoundary(after[0])) ? '' : ' ';\n const next = `${before}${insertion}${trailing}${after}`;\n\n const anchorStart = ti;\n const anchorEnd = ti + insertion.length;\n\n onChange(next);\n setAnchors((prev) => {\n // Shift any later anchors by the net change in length.\n const delta = insertion.length + trailing.length - (cursor - ti);\n const shifted = prev.map((a) =>\n a.start >= cursor ? { ...a, start: a.start + delta, end: a.end + delta } : a,\n );\n return [...shifted, { start: anchorStart, end: anchorEnd, ref }];\n });\n // Fire recent-use without awaiting.\n if (recent?.recordUse) {\n try {\n const result = recent.recordUse(ref);\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n (result as Promise<unknown>).catch(() => undefined);\n }\n } catch {\n // Swallow — recent-use is best-effort.\n }\n }\n close();\n // Move cursor to just after the inserted mention (+ optional trailing space).\n // Defer so the controlled value flush completes first.\n const newCursor = anchorEnd + trailing.length;\n queueMicrotask(() => {\n const t = textareaRef.current;\n if (t) {\n t.focus();\n t.setSelectionRange(newCursor, newCursor);\n }\n });\n },\n [trigger, query.length, onChange, recent, close, textareaRef],\n );\n\n const handleKeyDown = useCallback(\n (event: import('react').KeyboardEvent<HTMLTextAreaElement>): boolean => {\n if (!open) return false;\n if (flatResults.length === 0 && event.key !== 'Escape') {\n // Allow Escape to close even with no results; otherwise pass through.\n return false;\n }\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n setHighlightedIndex((i) => (i + 1) % Math.max(flatResults.length, 1));\n return true;\n case 'ArrowUp':\n event.preventDefault();\n setHighlightedIndex(\n (i) => (i - 1 + flatResults.length) % Math.max(flatResults.length, 1),\n );\n return true;\n case 'Enter':\n case 'Tab': {\n if (flatResults.length === 0) return false;\n event.preventDefault();\n const pick = flatResults[Math.min(highlightedIndex, flatResults.length - 1)];\n if (pick) insertMention(pick);\n return true;\n }\n case 'Escape':\n event.preventDefault();\n close();\n return true;\n default:\n return false;\n }\n },\n [open, flatResults, highlightedIndex, insertMention, close],\n );\n\n const getValueWithTokens = useCallback(() => {\n if (anchors.length === 0) return valueRef.current;\n // Sort anchors by start ASC, then substitute. Walk the value left → right\n // building the result with mention tokens swapped in at each anchor's range.\n const sorted = [...anchors].sort((a, b) => a.start - b.start);\n const v = valueRef.current;\n let out = '';\n let cursor = 0;\n for (const a of sorted) {\n if (a.start < cursor) continue; // overlapping; drop (shouldn't happen)\n out += v.slice(cursor, a.start);\n out += formatMentionToken(a.ref);\n cursor = a.end;\n }\n out += v.slice(cursor);\n return out;\n }, [anchors]);\n\n const state: AutocompleteState<TKind> = {\n open,\n query,\n groups,\n showingRecent,\n highlightedIndex,\n flatResults,\n };\n\n return { state, handleKeyDown, insertMention, getValueWithTokens, anchors, close };\n}\n"]}