@intentface/chat 0.1.0

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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ask-user/ask-user.d.ts +104 -0
  4. package/dist/ask-user/ask-user.js +226 -0
  5. package/dist/ask-user/index.d.ts +3 -0
  6. package/dist/ask-user/index.js +3 -0
  7. package/dist/ask-user/index.parts.d.ts +1 -0
  8. package/dist/ask-user/index.parts.js +5 -0
  9. package/dist/attachments/attachments.d.ts +41 -0
  10. package/dist/attachments/attachments.js +91 -0
  11. package/dist/attachments/index.d.ts +3 -0
  12. package/dist/attachments/index.js +3 -0
  13. package/dist/attachments/index.parts.d.ts +1 -0
  14. package/dist/attachments/index.parts.js +5 -0
  15. package/dist/chip/chip.d.ts +25 -0
  16. package/dist/chip/chip.js +25 -0
  17. package/dist/chip/index.d.ts +2 -0
  18. package/dist/chip/index.js +2 -0
  19. package/dist/chip/index.parts.d.ts +1 -0
  20. package/dist/chip/index.parts.js +5 -0
  21. package/dist/chip-markdown.d.ts +25 -0
  22. package/dist/chip-markdown.js +52 -0
  23. package/dist/composer/actions.d.ts +35 -0
  24. package/dist/composer/actions.js +75 -0
  25. package/dist/composer/ask-user-machine.d.ts +52 -0
  26. package/dist/composer/ask-user-machine.js +164 -0
  27. package/dist/composer/attachments-machine.d.ts +34 -0
  28. package/dist/composer/attachments-machine.js +91 -0
  29. package/dist/composer/attachments.d.ts +16 -0
  30. package/dist/composer/attachments.js +32 -0
  31. package/dist/composer/command-list.d.ts +49 -0
  32. package/dist/composer/command-list.js +357 -0
  33. package/dist/composer/commands.d.ts +28 -0
  34. package/dist/composer/commands.js +56 -0
  35. package/dist/composer/container.d.ts +3 -0
  36. package/dist/composer/container.js +32 -0
  37. package/dist/composer/controller.d.ts +3 -0
  38. package/dist/composer/controller.js +6 -0
  39. package/dist/composer/editor-dom.d.ts +55 -0
  40. package/dist/composer/editor-dom.js +431 -0
  41. package/dist/composer/fuzzy.d.ts +4 -0
  42. package/dist/composer/fuzzy.js +49 -0
  43. package/dist/composer/index.d.ts +25 -0
  44. package/dist/composer/index.js +13 -0
  45. package/dist/composer/index.parts.d.ts +11 -0
  46. package/dist/composer/index.parts.js +15 -0
  47. package/dist/composer/internals.d.ts +29 -0
  48. package/dist/composer/internals.js +102 -0
  49. package/dist/composer/keyboard.d.ts +61 -0
  50. package/dist/composer/keyboard.js +75 -0
  51. package/dist/composer/panel.d.ts +28 -0
  52. package/dist/composer/panel.js +80 -0
  53. package/dist/composer/placeholder.d.ts +10 -0
  54. package/dist/composer/placeholder.js +8 -0
  55. package/dist/composer/popover.d.ts +12 -0
  56. package/dist/composer/popover.js +63 -0
  57. package/dist/composer/prefix-detection.d.ts +23 -0
  58. package/dist/composer/prefix-detection.js +58 -0
  59. package/dist/composer/root.d.ts +21 -0
  60. package/dist/composer/root.js +107 -0
  61. package/dist/composer/segments.d.ts +108 -0
  62. package/dist/composer/segments.js +366 -0
  63. package/dist/composer/store.d.ts +97 -0
  64. package/dist/composer/store.js +451 -0
  65. package/dist/composer/textarea.d.ts +39 -0
  66. package/dist/composer/textarea.js +64 -0
  67. package/dist/composer/trigger-tracker.d.ts +12 -0
  68. package/dist/composer/trigger-tracker.js +90 -0
  69. package/dist/composer/types.d.ts +126 -0
  70. package/dist/composer/types.js +4 -0
  71. package/dist/composer/use-composer-editor.d.ts +43 -0
  72. package/dist/composer/use-composer-editor.js +649 -0
  73. package/dist/internal/collapsible.d.ts +22 -0
  74. package/dist/internal/collapsible.js +90 -0
  75. package/dist/internal/primitive-props.d.ts +4 -0
  76. package/dist/internal/primitive-props.js +4 -0
  77. package/dist/internal/render/baseTypes.d.ts +18 -0
  78. package/dist/internal/render/baseTypes.js +4 -0
  79. package/dist/internal/render/empty.d.ts +3 -0
  80. package/dist/internal/render/empty.js +5 -0
  81. package/dist/internal/render/getReactElementRef.d.ts +5 -0
  82. package/dist/internal/render/getReactElementRef.js +15 -0
  83. package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
  84. package/dist/internal/render/getStateAttributesProps.js +23 -0
  85. package/dist/internal/render/mergeObjects.d.ts +1 -0
  86. package/dist/internal/render/mergeObjects.js +14 -0
  87. package/dist/internal/render/mergeProps.d.ts +55 -0
  88. package/dist/internal/render/mergeProps.js +183 -0
  89. package/dist/internal/render/positioning.d.ts +22 -0
  90. package/dist/internal/render/positioning.js +78 -0
  91. package/dist/internal/render/reactVersion.d.ts +3 -0
  92. package/dist/internal/render/reactVersion.js +7 -0
  93. package/dist/internal/render/resolveClassName.d.ts +8 -0
  94. package/dist/internal/render/resolveClassName.js +12 -0
  95. package/dist/internal/render/resolveStyle.d.ts +8 -0
  96. package/dist/internal/render/resolveStyle.js +12 -0
  97. package/dist/internal/render/transition.d.ts +48 -0
  98. package/dist/internal/render/transition.js +203 -0
  99. package/dist/internal/render/types.d.ts +74 -0
  100. package/dist/internal/render/types.js +3 -0
  101. package/dist/internal/render/useMergedRefs.d.ts +21 -0
  102. package/dist/internal/render/useMergedRefs.js +102 -0
  103. package/dist/internal/render/useRefWithInit.d.ts +10 -0
  104. package/dist/internal/render/useRefWithInit.js +12 -0
  105. package/dist/internal/render/useRender.d.ts +71 -0
  106. package/dist/internal/render/useRender.js +12 -0
  107. package/dist/internal/render/useRenderElement.d.ts +62 -0
  108. package/dist/internal/render/useRenderElement.js +157 -0
  109. package/dist/internal/render/warn.d.ts +1 -0
  110. package/dist/internal/render/warn.js +15 -0
  111. package/dist/internal/state-mappings.d.ts +8 -0
  112. package/dist/internal/state-mappings.js +17 -0
  113. package/dist/message/index.d.ts +3 -0
  114. package/dist/message/index.js +3 -0
  115. package/dist/message/index.parts.d.ts +1 -0
  116. package/dist/message/index.parts.js +5 -0
  117. package/dist/message/message.d.ts +43 -0
  118. package/dist/message/message.js +78 -0
  119. package/dist/message-utils.d.ts +55 -0
  120. package/dist/message-utils.js +79 -0
  121. package/dist/reasoning/index.d.ts +3 -0
  122. package/dist/reasoning/index.js +3 -0
  123. package/dist/reasoning/index.parts.d.ts +1 -0
  124. package/dist/reasoning/index.parts.js +5 -0
  125. package/dist/reasoning/reasoning.d.ts +20 -0
  126. package/dist/reasoning/reasoning.js +49 -0
  127. package/dist/steps/index.d.ts +2 -0
  128. package/dist/steps/index.js +2 -0
  129. package/dist/steps/index.parts.d.ts +1 -0
  130. package/dist/steps/index.parts.js +5 -0
  131. package/dist/steps/steps.d.ts +53 -0
  132. package/dist/steps/steps.js +79 -0
  133. package/dist/thread/geometry.d.ts +18 -0
  134. package/dist/thread/geometry.js +59 -0
  135. package/dist/thread/index.d.ts +4 -0
  136. package/dist/thread/index.js +3 -0
  137. package/dist/thread/index.parts.d.ts +1 -0
  138. package/dist/thread/index.parts.js +5 -0
  139. package/dist/thread/stores.d.ts +21 -0
  140. package/dist/thread/stores.js +63 -0
  141. package/dist/thread/thread.d.ts +66 -0
  142. package/dist/thread/thread.js +639 -0
  143. package/dist/types.d.ts +58 -0
  144. package/dist/types.js +17 -0
  145. package/package.json +106 -0
@@ -0,0 +1,451 @@
1
+ "use client";
2
+ // Composer store — all reactive composer state in one store, so useComposer
3
+ // can offer Zustand-style selectors and components re-render only for the
4
+ // slice they read. Actions and refs are created once and survive every
5
+ // update; a slice's identity changes only when that slice's data changes.
6
+ //
7
+ // Instance model: every Composer.Root owns a store (an explicit
8
+ // Composer.createStore() handle, or one created per mount) and provides it via
9
+ // ComposerStoreContext. Inside the tree, useComposer resolves it implicitly;
10
+ // outside, useComposerStore(store, selector) and store.controller take an
11
+ // explicit handle — no global fallback, so state is never read or driven by
12
+ // accident. SSR-safe by invariant: every write happens in an effect or event
13
+ // handler (client-only), so server renders only ever read the pristine
14
+ // snapshot.
15
+ import { createContext, use, useSyncExternalStore } from "react";
16
+ import { revokeAttachmentUrl, toAttachmentItem, } from "../attachments/index.js";
17
+ import { INITIAL_ASK_USER_STATE, isLastStep, transitionAskUser, } from "./ask-user-machine.js";
18
+ import { attachmentReducer, INITIAL_ATTACHMENT_STATE, } from "./attachments-machine.js";
19
+ import { interpretAskUserKey } from "./keyboard.js";
20
+ const createEditorController = (editorRef) => ({
21
+ focus: () => editorRef.current?.focus(),
22
+ blur: () => editorRef.current?.blur(),
23
+ clear: () => editorRef.current?.clear(),
24
+ insertText: (text) => editorRef.current?.insertText(text),
25
+ insertChip: (chip) => editorRef.current?.insertChip(chip),
26
+ getText: () => editorRef.current?.getText() ?? "",
27
+ setText: (text) => editorRef.current?.setText(text),
28
+ serialize: () => editorRef.current?.serialize() ?? { text: "" },
29
+ ensureFocus: () => {
30
+ const editor = editorRef.current;
31
+ if (editor && !editor.isFocused())
32
+ editor.focus();
33
+ },
34
+ });
35
+ // ---------------------------------------------------------------------------
36
+ // Ask-user keydown — the document-level handler for question mode, kept out of
37
+ // the store factory so activateAskUser reads as just "blur, listen, unlisten".
38
+ // ---------------------------------------------------------------------------
39
+ // Scope a document-level listener to this composer: keystrokes aimed at
40
+ // another editable or inside another composer's options are theirs; our own
41
+ // options/editor and unclaimed targets (body — e.g. after a click on panel
42
+ // chrome dropped focus) are ours, so arrows and "type to answer" keep working.
43
+ const isEventForComposer = (event, editorDom, optionsElement) => {
44
+ const target = event.target;
45
+ const editableHost = target?.closest('input, textarea, [contenteditable="true"]');
46
+ if (editableHost)
47
+ return editableHost === editorDom;
48
+ const optionsHost = target?.closest("[data-ask-user-options]");
49
+ if (optionsHost)
50
+ return optionsHost === optionsElement;
51
+ return true;
52
+ };
53
+ // Interpret a key (pure) and drive the machine + editor controller. Mirrors the
54
+ // action union from interpretAskUserKey one-to-one.
55
+ const createAskUserKeydownHandler = ({ controller, optionsRef, editorRef, dispatch }) => (event) => {
56
+ const scopedOptionsElement = optionsRef.current?.getElement() ?? null;
57
+ if (!isEventForComposer(event, editorRef.current?.getRootElement() ?? undefined, scopedOptionsElement)) {
58
+ return;
59
+ }
60
+ const optionsHandle = optionsRef.current;
61
+ const action = interpretAskUserKey({
62
+ key: event.key,
63
+ ctrlKey: event.ctrlKey,
64
+ metaKey: event.metaKey,
65
+ altKey: event.altKey,
66
+ defaultPrevented: event.defaultPrevented,
67
+ }, { hasHighlight: optionsHandle?.highlightedValue != null });
68
+ if (!action)
69
+ return;
70
+ event.preventDefault();
71
+ switch (action.type) {
72
+ case "dismiss-step":
73
+ dispatch({ type: "dismiss-step" });
74
+ return;
75
+ case "navigate-options": {
76
+ const newValue = optionsHandle?.navigate(action.direction);
77
+ if (newValue === null)
78
+ controller.focus();
79
+ return;
80
+ }
81
+ case "select-option": {
82
+ const item = optionsHandle?.select();
83
+ if (item)
84
+ dispatch({ type: "select-option", label: item.value });
85
+ return;
86
+ }
87
+ case "go-back":
88
+ dispatch({ type: "step-back", currentText: controller.getText() });
89
+ return;
90
+ case "go-next":
91
+ dispatch({ type: "step-forward", currentText: controller.getText() });
92
+ return;
93
+ case "insert-character":
94
+ optionsHandle?.clearHighlight();
95
+ controller.focus();
96
+ controller.insertText(action.character);
97
+ return;
98
+ }
99
+ };
100
+ // ---------------------------------------------------------------------------
101
+ // Store factory
102
+ // ---------------------------------------------------------------------------
103
+ export const createComposerStore = () => {
104
+ // --- Subscriptions & refs
105
+ const listeners = new Set();
106
+ const notify = () => {
107
+ for (const listener of listeners)
108
+ listener();
109
+ };
110
+ // Per-store editor engine behind a null-safe controller.
111
+ const editorRef = { current: null };
112
+ const controller = createEditorController(editorRef);
113
+ const registerEditor = (editor) => {
114
+ editorRef.current = editor;
115
+ return () => {
116
+ if (editorRef.current === editor)
117
+ editorRef.current = null;
118
+ };
119
+ };
120
+ // Imperative refs co-located with the store; not reactive.
121
+ const containerRef = { current: null };
122
+ const optionsRef = { current: null };
123
+ const fileInputRef = { current: null };
124
+ const globalDropRef = { current: false };
125
+ // Permissive defaults: accept everything, no caps, platform-default blob
126
+ // ingestion. Composer.Attachments overwrites this with the consumer's
127
+ // policy and any custom convert/destroy.
128
+ const attachmentConfigRef = {
129
+ current: {
130
+ accept: "",
131
+ maxFiles: Number.POSITIVE_INFINITY,
132
+ maxFileSize: Number.POSITIVE_INFINITY,
133
+ convert: toAttachmentItem,
134
+ destroy: revokeAttachmentUrl,
135
+ },
136
+ };
137
+ const submitAnswersRef = {
138
+ current: null,
139
+ };
140
+ const commandSelectRef = { current: null };
141
+ // Canonical machine states; the snapshot mirrors them on every update.
142
+ let attachmentState = INITIAL_ATTACHMENT_STATE;
143
+ let askUserMachine = INITIAL_ASK_USER_STATE;
144
+ let snapshot;
145
+ // --- Setters
146
+ const setHasContent = (value) => {
147
+ if (snapshot.textarea.hasContent === value)
148
+ return;
149
+ snapshot = { ...snapshot, textarea: { ...snapshot.textarea, hasContent: value } };
150
+ notify();
151
+ };
152
+ const setIsSubmitting = (value) => {
153
+ if (snapshot.isSubmitting === value)
154
+ return;
155
+ snapshot = { ...snapshot, isSubmitting: value };
156
+ notify();
157
+ };
158
+ const setCommands = (next) => {
159
+ const current = snapshot.commands;
160
+ if (current.active === next.active &&
161
+ current.trigger === next.trigger &&
162
+ current.query === next.query) {
163
+ return;
164
+ }
165
+ // Re-point the highlight at the first row whenever the active token or its
166
+ // query changes, so filtering always lands on the top match.
167
+ const resetHighlight = current.trigger !== next.trigger || current.query !== next.query;
168
+ snapshot = {
169
+ ...snapshot,
170
+ commands: {
171
+ ...next,
172
+ // Sticky: opening sets present; closing leaves it set until the panel's
173
+ // exit animation finishes and finalizePanelClose() clears it.
174
+ present: next.active || current.present,
175
+ highlightIndex: resetHighlight ? 0 : current.highlightIndex,
176
+ // A closed popup has no active descendant; while open, the mounted
177
+ // Command re-derives it as the highlight resolves.
178
+ activeOptionId: next.active ? current.activeOptionId : null,
179
+ },
180
+ };
181
+ notify();
182
+ };
183
+ // The editor's aria-activedescendant target — written by the mounted
184
+ // Command, which is where highlight index resolves against the item list.
185
+ const setActiveOptionId = (id) => {
186
+ if (snapshot.commands.activeOptionId === id)
187
+ return;
188
+ snapshot = { ...snapshot, commands: { ...snapshot.commands, activeOptionId: id } };
189
+ notify();
190
+ };
191
+ // Move the highlight by ±1 (raw, unbounded). The active CommandList wraps it
192
+ // by its item count when reading, so the store needs no item knowledge.
193
+ const moveHighlight = (direction) => {
194
+ const { commands } = snapshot;
195
+ snapshot = {
196
+ ...snapshot,
197
+ commands: { ...commands, highlightIndex: commands.highlightIndex + direction },
198
+ };
199
+ notify();
200
+ };
201
+ // Set the highlight to an absolute index (hover).
202
+ const setHighlight = (index) => {
203
+ if (snapshot.commands.highlightIndex === index)
204
+ return;
205
+ snapshot = { ...snapshot, commands: { ...snapshot.commands, highlightIndex: index } };
206
+ notify();
207
+ };
208
+ // --- Attachments
209
+ const dispatchAttachments = (action) => {
210
+ const next = attachmentReducer(attachmentState, action, attachmentConfigRef.current);
211
+ if (next === attachmentState)
212
+ return;
213
+ attachmentState = next;
214
+ snapshot = {
215
+ ...snapshot,
216
+ attachments: { ...snapshot.attachments, items: next.items, error: next.error },
217
+ };
218
+ notify();
219
+ };
220
+ const setDragging = (active) => {
221
+ if (snapshot.attachments.isDragging === active)
222
+ return;
223
+ snapshot = { ...snapshot, attachments: { ...snapshot.attachments, isDragging: active } };
224
+ notify();
225
+ };
226
+ // --- Ask-user
227
+ // The execute half of the ask-user flow: replay a transition's effects
228
+ // against the editor controller, the options handle, and the submit
229
+ // callback. Input-content state is the editor engine's own job — engines
230
+ // report programmatic setText/clear through their update path.
231
+ const executeAskUserEffects = (effects) => {
232
+ for (const effect of effects) {
233
+ switch (effect.type) {
234
+ case "clear-input":
235
+ controller.clear();
236
+ break;
237
+ case "set-input-text":
238
+ controller.setText(effect.text);
239
+ break;
240
+ case "focus-input":
241
+ controller.focus();
242
+ break;
243
+ case "focus-options":
244
+ // Step advance / navigation: focus lands on the new step's
245
+ // highlighted option (roving tabindex), never on <body>. Deferred a
246
+ // microtask so the incoming step's options have registered
247
+ // (reset-highlight runs first; auto-highlight re-seats on mount).
248
+ queueMicrotask(() => optionsRef.current?.focusHighlighted());
249
+ break;
250
+ case "reset-highlight":
251
+ optionsRef.current?.resetHighlight();
252
+ break;
253
+ case "submit-answers":
254
+ submitAnswersRef.current?.(effect.answers);
255
+ break;
256
+ }
257
+ }
258
+ };
259
+ const dispatchAskUser = (action) => {
260
+ const questions = snapshot.askUser.questions;
261
+ if (!questions || questions.length === 0)
262
+ return;
263
+ const { next, effects } = transitionAskUser(askUserMachine, questions, action);
264
+ if (next !== askUserMachine) {
265
+ askUserMachine = next;
266
+ snapshot = {
267
+ ...snapshot,
268
+ askUser: {
269
+ ...snapshot.askUser,
270
+ step: next.step,
271
+ answers: next.answers,
272
+ isLastStep: isLastStep(next, questions),
273
+ },
274
+ };
275
+ notify();
276
+ }
277
+ executeAskUserEffects(effects);
278
+ };
279
+ const setQuestions = (questions) => {
280
+ if (snapshot.askUser.questions === questions)
281
+ return;
282
+ askUserMachine = INITIAL_ASK_USER_STATE;
283
+ snapshot = {
284
+ ...snapshot,
285
+ askUser: {
286
+ ...snapshot.askUser,
287
+ active: questions != null,
288
+ // Sticky, same as commands: cleared by finalizePanelClose() after the exit.
289
+ present: questions != null || snapshot.askUser.present,
290
+ questions,
291
+ step: askUserMachine.step,
292
+ answers: askUserMachine.answers,
293
+ isLastStep: questions ? isLastStep(askUserMachine, questions) : false,
294
+ isSingle: questions ? questions.length === 1 : false,
295
+ },
296
+ };
297
+ notify();
298
+ };
299
+ // Ask-user mode: while questions are active the options own the keyboard.
300
+ // Entering moves DOM focus onto the highlighted option (roving tabindex),
301
+ // and the keydown listener sits at the document, scoped by containment
302
+ // (isEventForComposer) — so keys keep flowing after a chrome click drops
303
+ // focus to <body>, while a second composer's editor/options never hear them.
304
+ const activateAskUser = () => {
305
+ const handleKeyDown = createAskUserKeydownHandler({
306
+ controller,
307
+ optionsRef,
308
+ editorRef,
309
+ dispatch: dispatchAskUser,
310
+ });
311
+ document.addEventListener("keydown", handleKeyDown);
312
+ // The Options part renders in reaction to setQuestions' notify — one
313
+ // commit after this call — so the entry focus defers a frame.
314
+ const frame = requestAnimationFrame(() => {
315
+ optionsRef.current?.focusHighlighted();
316
+ });
317
+ return () => {
318
+ cancelAnimationFrame(frame);
319
+ document.removeEventListener("keydown", handleKeyDown);
320
+ };
321
+ };
322
+ // --- Initial snapshot
323
+ snapshot = {
324
+ textarea: { hasContent: false },
325
+ isSubmitting: false,
326
+ commands: {
327
+ active: false,
328
+ present: false,
329
+ trigger: null,
330
+ query: "",
331
+ highlightIndex: 0,
332
+ activeOptionId: null,
333
+ },
334
+ attachments: {
335
+ items: attachmentState.items,
336
+ error: attachmentState.error,
337
+ isDragging: false,
338
+ add: (files) => dispatchAttachments({ type: "add", files }),
339
+ remove: (id) => dispatchAttachments({ type: "remove", id }),
340
+ openFileDialog: () => fileInputRef.current?.click(),
341
+ fileInputRef,
342
+ globalDropRef,
343
+ },
344
+ askUser: {
345
+ active: false,
346
+ present: false,
347
+ questions: null,
348
+ step: askUserMachine.step,
349
+ answers: askUserMachine.answers,
350
+ isLastStep: false,
351
+ isSingle: false,
352
+ toggleOption: (label) => dispatchAskUser({ type: "toggle-option", label }),
353
+ continueStep: (freeText) => dispatchAskUser({ type: "continue-step", freeText: freeText ?? "" }),
354
+ dismissStep: () => dispatchAskUser({ type: "dismiss-step" }),
355
+ clearSelections: () => dispatchAskUser({ type: "clear-selections" }),
356
+ goBack: () => dispatchAskUser({ type: "step-back", currentText: controller.getText() }),
357
+ goNext: () => dispatchAskUser({ type: "step-forward", currentText: controller.getText() }),
358
+ optionsRef,
359
+ },
360
+ };
361
+ // Pristine state for reset() — slice actions and refs are reused, so action
362
+ // identities stay stable across resets.
363
+ const initialSnapshot = snapshot;
364
+ // Clear `present` on any native panel slice whose `active` is now false — called by
365
+ // the Panel once its close animation finishes, so the last content stays mounted
366
+ // (and keeps animating) until then. Identity-guarded so it no-ops when nothing changed.
367
+ const finalizePanelClose = () => {
368
+ const { commands, askUser } = snapshot;
369
+ if (commands.present === commands.active && askUser.present === askUser.active)
370
+ return;
371
+ snapshot = {
372
+ ...snapshot,
373
+ commands: commands.present === commands.active ? commands : { ...commands, present: commands.active },
374
+ askUser: askUser.present === askUser.active ? askUser : { ...askUser, present: askUser.active },
375
+ };
376
+ notify();
377
+ };
378
+ // --- Lifecycle
379
+ // Drop everything mount-scoped when the Composer unmounts (route change):
380
+ // revoke attachment object URLs, then restore the pristine snapshot.
381
+ const reset = () => {
382
+ dispatchAttachments({ type: "reset" });
383
+ askUserMachine = INITIAL_ASK_USER_STATE;
384
+ commandSelectRef.current = null;
385
+ snapshot = initialSnapshot;
386
+ notify();
387
+ };
388
+ return {
389
+ subscribe: (listener) => {
390
+ listeners.add(listener);
391
+ return () => {
392
+ listeners.delete(listener);
393
+ };
394
+ },
395
+ getSnapshot: () => snapshot,
396
+ setHasContent,
397
+ setIsSubmitting,
398
+ setCommands,
399
+ setActiveOptionId,
400
+ listboxId: "",
401
+ moveHighlight,
402
+ setHighlight,
403
+ setQuestions,
404
+ setDragging,
405
+ resetAttachments: () => dispatchAttachments({ type: "reset" }),
406
+ activateAskUser,
407
+ finalizePanelClose,
408
+ reset,
409
+ editorRef,
410
+ controller,
411
+ registerEditor,
412
+ containerRef,
413
+ attachmentConfigRef,
414
+ submitAnswersRef,
415
+ commandSelectRef,
416
+ };
417
+ };
418
+ // ---------------------------------------------------------------------------
419
+ // Instance resolution — the nearest <Composer>, explicitly. No global
420
+ // fallback: outside a composer tree, state comes from an explicit
421
+ // Composer.createStore() handle via useComposerStore.
422
+ // ---------------------------------------------------------------------------
423
+ export const ComposerStoreContext = createContext(null);
424
+ // Internal: parts resolve the store their Composer.Root provided.
425
+ export const useComposerContextStore = () => {
426
+ const store = use(ComposerStoreContext);
427
+ if (!store) {
428
+ throw new Error("Composer components must be used within <Composer>");
429
+ }
430
+ return store;
431
+ };
432
+ // Subscribe to an explicit Composer.createStore() instance — the
433
+ // outside-the-tree twin of useComposer (toolbars, status bars, shortcut
434
+ // handlers). Wrap it once per instance to drop the store argument at call
435
+ // sites:
436
+ // const useChatComposer = <T,>(selector: (c: ComposerState) => T) =>
437
+ // useComposerStore(chatComposerStore, selector);
438
+ export const useComposerStore = (store, selector) => {
439
+ const getValue = () => {
440
+ const state = store.getSnapshot();
441
+ // Safe: without a selector, Selected defaults to ComposerState.
442
+ return selector ? selector(state) : state;
443
+ };
444
+ return useSyncExternalStore(store.subscribe, getValue, getValue);
445
+ };
446
+ // Subscribe to composer state from inside the tree. With a selector, the
447
+ // component re-renders only when the selected value changes identity (slices
448
+ // are identity-stable):
449
+ // const askUser = useComposer((composer) => composer.askUser);
450
+ // Without one, it returns the full snapshot and re-renders on any change.
451
+ export const useComposer = (selector) => useComposerStore(useComposerContextStore(), selector);
@@ -0,0 +1,39 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import type { ChipData } from "../chip-markdown.js";
3
+ import type { PrimitiveProps } from "../internal/primitive-props.js";
4
+ import type { ComposerSubmitOn } from "./keyboard.js";
5
+ export type ComposerTextareaState = {
6
+ /** Present as data-disabled while the editor is non-editable. */
7
+ disabled: boolean;
8
+ /** Present as data-filled while the editor has content. */
9
+ filled: boolean;
10
+ };
11
+ export type ComposerTextareaProps = Omit<PrimitiveProps<"div", ComposerTextareaState>, "children"> & {
12
+ value?: string;
13
+ onValueChange?: (text: string) => void;
14
+ disabled?: boolean;
15
+ autoFocus?: boolean;
16
+ /** Native-textarea-style placeholder string. For rich placeholder content, pass children instead (children win when both are set). */
17
+ placeholder?: string;
18
+ /** Which Enter chord sends the message; the other inserts a soft break. Consider pairing "shift-enter" with enterKeyHint="enter". */
19
+ submitOn?: ComposerSubmitOn;
20
+ /** Logical-length cap: one per character, one per chip. IME input is capped at composition commit. */
21
+ maxLength?: number;
22
+ /** Native form validation via the hidden input — blocks empty submits. */
23
+ required?: boolean;
24
+ /** Mirrors the serialized text (chips as chip-markdown) into FormData. */
25
+ name?: string;
26
+ spellCheck?: boolean;
27
+ autoCapitalize?: string;
28
+ enterKeyHint?: HTMLAttributes<HTMLElement>["enterKeyHint"];
29
+ inputMode?: HTMLAttributes<HTMLElement>["inputMode"];
30
+ dir?: string;
31
+ "aria-label"?: string;
32
+ "aria-labelledby"?: string;
33
+ "aria-describedby"?: string;
34
+ /** Custom renderer for committed chips in the editor. Defaults to a label-only Chip. */
35
+ renderChip?: (chip: ChipData) => ReactNode;
36
+ /** Placeholder overlay content, shown while the editor is empty. */
37
+ children?: ReactNode;
38
+ };
39
+ export declare const ComposerTextarea: ({ value, onValueChange, className, render, style, disabled, autoFocus, placeholder, submitOn, maxLength, required, name, spellCheck, autoCapitalize, enterKeyHint, inputMode, dir, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, onFocus, onBlur, onKeyDown, onKeyUp, onPaste, onCopy, onCut, renderChip, children, ...elementProps }: ComposerTextareaProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useRenderElement } from "../internal/render/useRenderElement.js";
4
+ import { useComposer, useComposerContextStore } from "./store.js";
5
+ import { useComposerEditor } from "./use-composer-editor.js";
6
+ // Focusable-but-invisible (not display:none — Chrome refuses to submit forms
7
+ // whose invalid control can't be focused for the validation bubble).
8
+ const visuallyHiddenStyle = {
9
+ position: "absolute",
10
+ width: 1,
11
+ height: 1,
12
+ margin: -1,
13
+ padding: 0,
14
+ overflow: "hidden",
15
+ clipPath: "inset(50%)",
16
+ border: 0,
17
+ };
18
+ export const ComposerTextarea = ({ value, onValueChange, className, render, style, disabled = false, autoFocus = false, placeholder, submitOn, maxLength, required = false, name, spellCheck = false, autoCapitalize, enterKeyHint, inputMode, dir, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy,
19
+ // Interactive callbacks route to the editable element (not the wrapper),
20
+ // native-textarea style: they run before the engine, and preventDefault in
21
+ // onKeyDown/onPaste/onCopy/onCut overrides the engine's handling.
22
+ onFocus, onBlur, onKeyDown, onKeyUp, onPaste, onCopy, onCut, renderChip, children, ...elementProps }) => {
23
+ const store = useComposerContextStore();
24
+ // Combobox wiring: expanded state and the highlighted option's id flow from
25
+ // the store mirrors the command machinery already maintains.
26
+ const commandsActive = useComposer((composer) => composer.commands.active);
27
+ const activeOptionId = useComposer((composer) => composer.commands.activeOptionId);
28
+ const { attachRoot, editableProps, chipPortals, hasContent, isComposing, serializedText } = useComposerEditor({
29
+ disabled,
30
+ autoFocus,
31
+ submitOn,
32
+ maxLength,
33
+ value,
34
+ onValueChange,
35
+ renderChip,
36
+ onFocus,
37
+ onBlur,
38
+ onKeyDown,
39
+ onKeyUp,
40
+ onPaste,
41
+ onCopy,
42
+ onCut,
43
+ });
44
+ const editorContent = (
45
+ // Relative wrapper so the placeholder overlay sizes to the editable, not
46
+ // the outer slot (which may carry padding/scroll from the styled layer).
47
+ _jsxs("div", { style: { position: "relative" }, children: [_jsx("div", { ref: attachRoot,
48
+ // contenteditable="true" is load-bearing: the store's ask-user event
49
+ // scoping and the container's click-passthrough both key off it.
50
+ contentEditable: !disabled, suppressContentEditableWarning: true, role: "textbox",
51
+ // contenteditable is natively focusable; the explicit tabIndex keeps a
52
+ // disabled editor out of the tab order like a native disabled textarea.
53
+ tabIndex: disabled ? -1 : 0, "aria-multiline": "true", "aria-placeholder": placeholder, "aria-disabled": disabled || undefined, "aria-required": required || undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-controls": commandsActive ? store.listboxId : undefined, "aria-activedescendant": activeOptionId ?? undefined, "data-composer-editor": "", spellCheck: spellCheck, autoCapitalize: autoCapitalize, enterKeyHint: enterKeyHint, inputMode: inputMode, dir: dir, style: { whiteSpace: "break-spaces", overflowWrap: "break-word" }, ...editableProps }), !hasContent && !isComposing && (children ?? placeholder) && (_jsx("div", { "data-composer-placeholder": "", style: { position: "absolute", inset: 0, pointerEvents: "none" }, "aria-hidden": "true", children: children ?? placeholder }))] }));
54
+ return useRenderElement("div", { className, render, style }, {
55
+ state: { disabled, filled: hasContent },
56
+ props: [
57
+ {
58
+ "data-composer-textarea": "",
59
+ children: (_jsxs(_Fragment, { children: [editorContent, chipPortals, name != null && (_jsx("input", { type: "text", readOnly: true, tabIndex: -1, "aria-hidden": "true", name: name, required: required, value: serializedText, onFocus: () => store.controller.focus(), style: visuallyHiddenStyle }))] })),
60
+ },
61
+ elementProps,
62
+ ],
63
+ });
64
+ };
@@ -0,0 +1,12 @@
1
+ import { type ActiveTokenState, type RegisteredPrefix } from "./prefix-detection.js";
2
+ import { type TextChange } from "./segments.js";
3
+ export type TrackerUpdate = {
4
+ registered: RegisteredPrefix[];
5
+ /** toScanText(doc) — chips as " ". */
6
+ scanText: string;
7
+ caret: number;
8
+ /** The doc edit, or null for a selection-only update. */
9
+ change: TextChange | null;
10
+ };
11
+ export declare const closeActiveToken: (previous: ActiveTokenState) => ActiveTokenState;
12
+ export declare const trackActiveToken: (previous: ActiveTokenState, update: TrackerUpdate) => ActiveTokenState;
@@ -0,0 +1,90 @@
1
+ // Trigger tracker — sticky active-token state over the flat position model.
2
+ // Pure: consumes the scan text (chips read as " ", so a token can never span
3
+ // a chip and positions stay 1:1) plus an optional TextChange, and produces the
4
+ // ActiveTokenState shape the store mirrors and the command list consumes.
5
+ //
6
+ // Where detectActivePrefix is a stateless scan of the caret's surroundings,
7
+ // this layer carries state across edits: it maps the tracked range forward
8
+ // through each change and remembers explicit dismissals.
9
+ import { CLOSED_COMMAND_STATE, detectActivePrefix, } from "./prefix-detection.js";
10
+ import { lineBoundsAt, mapPosition, mapPositionResult } from "./segments.js";
11
+ // Escape / Dismiss / blur-close: close and remember the token's start so
12
+ // re-entering it won't reopen the popup (only when something was open).
13
+ export const closeActiveToken = (previous) => ({
14
+ ...CLOSED_COMMAND_STATE,
15
+ dismissedAt: previous.isOpen ? previous.triggerStartPosition : null,
16
+ });
17
+ export const trackActiveToken = (previous, update) => {
18
+ const { registered, scanText, caret, change } = update;
19
+ if (registered.length === 0)
20
+ return CLOSED_COMMAND_STATE;
21
+ // Track the dismissed marker across edits; drop it once its prefix is gone
22
+ // so retyping the trigger starts a fresh attempt.
23
+ let dismissedAt = previous.dismissedAt;
24
+ if (dismissedAt !== null && change !== null) {
25
+ const mapped = mapPositionResult(dismissedAt, change, -1);
26
+ dismissedAt = mapped.deleted ? null : mapped.position;
27
+ if (dismissedAt !== null) {
28
+ const markerPosition = dismissedAt;
29
+ const stillPrefixed = registered.some((entry) => markerPosition + entry.prefix.length <= scanText.length &&
30
+ scanText.slice(markerPosition, markerPosition + entry.prefix.length) === entry.prefix);
31
+ if (!stillPrefixed)
32
+ dismissedAt = null;
33
+ }
34
+ }
35
+ // Already-open token: track its range stickily through the change rather
36
+ // than re-scanning (a scan stops at whitespace and would drop typed
37
+ // spaces). The end grows on insert (+1 bias) and shrinks on delete; the
38
+ // query is read from the fixed range, so the filter holds steady — and
39
+ // accepts spaces — as the caret moves inside the token.
40
+ if (previous.isOpen && previous.trigger !== null) {
41
+ const trigger = previous.trigger;
42
+ let start = previous.triggerStartPosition;
43
+ let end = previous.triggerEndPosition;
44
+ if (change !== null) {
45
+ start = mapPosition(start, change, -1);
46
+ end = mapPosition(end, change, 1);
47
+ }
48
+ const prefixIntact = start + trigger.length <= scanText.length &&
49
+ scanText.slice(start, start + trigger.length) === trigger;
50
+ const tokenText = scanText.slice(start, end);
51
+ // Stay open while the prefix survives and the caret is still inside — but
52
+ // a token can never span a line break: the sticky end would otherwise
53
+ // absorb an inserted "\n" (soft break, pasted newline) and drag the badge
54
+ // across lines.
55
+ if (prefixIntact &&
56
+ end >= start + trigger.length &&
57
+ caret >= start &&
58
+ caret <= end &&
59
+ !tokenText.includes("\n")) {
60
+ return {
61
+ isOpen: true,
62
+ trigger,
63
+ query: tokenText.slice(trigger.length),
64
+ triggerStartPosition: start,
65
+ triggerEndPosition: end,
66
+ dismissedAt,
67
+ };
68
+ }
69
+ // Prefix deleted or caret left the token → fall through to a fresh scan.
70
+ }
71
+ const { lineStart, lineEnd } = lineBoundsAt(scanText, caret);
72
+ const detected = detectActivePrefix({
73
+ registered,
74
+ blockStart: lineStart,
75
+ blockEnd: lineEnd,
76
+ cursorPosition: caret,
77
+ textBeforeCursor: scanText.slice(lineStart, caret),
78
+ textAfterCursor: scanText.slice(caret, lineEnd),
79
+ fullDocText: scanText,
80
+ });
81
+ if (detected.isOpen) {
82
+ // Same token the user dismissed → stay closed. A different token → open
83
+ // and forget the prior dismissal.
84
+ if (dismissedAt !== null && detected.triggerStartPosition === dismissedAt) {
85
+ return { ...CLOSED_COMMAND_STATE, dismissedAt };
86
+ }
87
+ return { ...detected, dismissedAt: null };
88
+ }
89
+ return { ...CLOSED_COMMAND_STATE, dismissedAt };
90
+ };