@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/ask-user/ask-user.d.ts +104 -0
- package/dist/ask-user/ask-user.js +226 -0
- package/dist/ask-user/index.d.ts +3 -0
- package/dist/ask-user/index.js +3 -0
- package/dist/ask-user/index.parts.d.ts +1 -0
- package/dist/ask-user/index.parts.js +5 -0
- package/dist/attachments/attachments.d.ts +41 -0
- package/dist/attachments/attachments.js +91 -0
- package/dist/attachments/index.d.ts +3 -0
- package/dist/attachments/index.js +3 -0
- package/dist/attachments/index.parts.d.ts +1 -0
- package/dist/attachments/index.parts.js +5 -0
- package/dist/chip/chip.d.ts +25 -0
- package/dist/chip/chip.js +25 -0
- package/dist/chip/index.d.ts +2 -0
- package/dist/chip/index.js +2 -0
- package/dist/chip/index.parts.d.ts +1 -0
- package/dist/chip/index.parts.js +5 -0
- package/dist/chip-markdown.d.ts +25 -0
- package/dist/chip-markdown.js +52 -0
- package/dist/composer/actions.d.ts +35 -0
- package/dist/composer/actions.js +75 -0
- package/dist/composer/ask-user-machine.d.ts +52 -0
- package/dist/composer/ask-user-machine.js +164 -0
- package/dist/composer/attachments-machine.d.ts +34 -0
- package/dist/composer/attachments-machine.js +91 -0
- package/dist/composer/attachments.d.ts +16 -0
- package/dist/composer/attachments.js +32 -0
- package/dist/composer/command-list.d.ts +49 -0
- package/dist/composer/command-list.js +357 -0
- package/dist/composer/commands.d.ts +28 -0
- package/dist/composer/commands.js +56 -0
- package/dist/composer/container.d.ts +3 -0
- package/dist/composer/container.js +32 -0
- package/dist/composer/controller.d.ts +3 -0
- package/dist/composer/controller.js +6 -0
- package/dist/composer/editor-dom.d.ts +55 -0
- package/dist/composer/editor-dom.js +431 -0
- package/dist/composer/fuzzy.d.ts +4 -0
- package/dist/composer/fuzzy.js +49 -0
- package/dist/composer/index.d.ts +25 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.parts.d.ts +11 -0
- package/dist/composer/index.parts.js +15 -0
- package/dist/composer/internals.d.ts +29 -0
- package/dist/composer/internals.js +102 -0
- package/dist/composer/keyboard.d.ts +61 -0
- package/dist/composer/keyboard.js +75 -0
- package/dist/composer/panel.d.ts +28 -0
- package/dist/composer/panel.js +80 -0
- package/dist/composer/placeholder.d.ts +10 -0
- package/dist/composer/placeholder.js +8 -0
- package/dist/composer/popover.d.ts +12 -0
- package/dist/composer/popover.js +63 -0
- package/dist/composer/prefix-detection.d.ts +23 -0
- package/dist/composer/prefix-detection.js +58 -0
- package/dist/composer/root.d.ts +21 -0
- package/dist/composer/root.js +107 -0
- package/dist/composer/segments.d.ts +108 -0
- package/dist/composer/segments.js +366 -0
- package/dist/composer/store.d.ts +97 -0
- package/dist/composer/store.js +451 -0
- package/dist/composer/textarea.d.ts +39 -0
- package/dist/composer/textarea.js +64 -0
- package/dist/composer/trigger-tracker.d.ts +12 -0
- package/dist/composer/trigger-tracker.js +90 -0
- package/dist/composer/types.d.ts +126 -0
- package/dist/composer/types.js +4 -0
- package/dist/composer/use-composer-editor.d.ts +43 -0
- package/dist/composer/use-composer-editor.js +649 -0
- package/dist/internal/collapsible.d.ts +22 -0
- package/dist/internal/collapsible.js +90 -0
- package/dist/internal/primitive-props.d.ts +4 -0
- package/dist/internal/primitive-props.js +4 -0
- package/dist/internal/render/baseTypes.d.ts +18 -0
- package/dist/internal/render/baseTypes.js +4 -0
- package/dist/internal/render/empty.d.ts +3 -0
- package/dist/internal/render/empty.js +5 -0
- package/dist/internal/render/getReactElementRef.d.ts +5 -0
- package/dist/internal/render/getReactElementRef.js +15 -0
- package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
- package/dist/internal/render/getStateAttributesProps.js +23 -0
- package/dist/internal/render/mergeObjects.d.ts +1 -0
- package/dist/internal/render/mergeObjects.js +14 -0
- package/dist/internal/render/mergeProps.d.ts +55 -0
- package/dist/internal/render/mergeProps.js +183 -0
- package/dist/internal/render/positioning.d.ts +22 -0
- package/dist/internal/render/positioning.js +78 -0
- package/dist/internal/render/reactVersion.d.ts +3 -0
- package/dist/internal/render/reactVersion.js +7 -0
- package/dist/internal/render/resolveClassName.d.ts +8 -0
- package/dist/internal/render/resolveClassName.js +12 -0
- package/dist/internal/render/resolveStyle.d.ts +8 -0
- package/dist/internal/render/resolveStyle.js +12 -0
- package/dist/internal/render/transition.d.ts +48 -0
- package/dist/internal/render/transition.js +203 -0
- package/dist/internal/render/types.d.ts +74 -0
- package/dist/internal/render/types.js +3 -0
- package/dist/internal/render/useMergedRefs.d.ts +21 -0
- package/dist/internal/render/useMergedRefs.js +102 -0
- package/dist/internal/render/useRefWithInit.d.ts +10 -0
- package/dist/internal/render/useRefWithInit.js +12 -0
- package/dist/internal/render/useRender.d.ts +71 -0
- package/dist/internal/render/useRender.js +12 -0
- package/dist/internal/render/useRenderElement.d.ts +62 -0
- package/dist/internal/render/useRenderElement.js +157 -0
- package/dist/internal/render/warn.d.ts +1 -0
- package/dist/internal/render/warn.js +15 -0
- package/dist/internal/state-mappings.d.ts +8 -0
- package/dist/internal/state-mappings.js +17 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.js +3 -0
- package/dist/message/index.parts.d.ts +1 -0
- package/dist/message/index.parts.js +5 -0
- package/dist/message/message.d.ts +43 -0
- package/dist/message/message.js +78 -0
- package/dist/message-utils.d.ts +55 -0
- package/dist/message-utils.js +79 -0
- package/dist/reasoning/index.d.ts +3 -0
- package/dist/reasoning/index.js +3 -0
- package/dist/reasoning/index.parts.d.ts +1 -0
- package/dist/reasoning/index.parts.js +5 -0
- package/dist/reasoning/reasoning.d.ts +20 -0
- package/dist/reasoning/reasoning.js +49 -0
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/dist/steps/index.parts.d.ts +1 -0
- package/dist/steps/index.parts.js +5 -0
- package/dist/steps/steps.d.ts +53 -0
- package/dist/steps/steps.js +79 -0
- package/dist/thread/geometry.d.ts +18 -0
- package/dist/thread/geometry.js +59 -0
- package/dist/thread/index.d.ts +4 -0
- package/dist/thread/index.js +3 -0
- package/dist/thread/index.parts.d.ts +1 -0
- package/dist/thread/index.parts.js +5 -0
- package/dist/thread/stores.d.ts +21 -0
- package/dist/thread/stores.js +63 -0
- package/dist/thread/thread.d.ts +66 -0
- package/dist/thread/thread.js +639 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +17 -0
- 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
|
+
};
|