@particle-academy/react-fancy 5.64.1 → 5.65.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/CHANGELOG.md CHANGED
@@ -11,6 +11,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [5.65.0] - 2026-10-27
15
+
16
+ ### Added
17
+
18
+ - **`PromptInput`: controlled state and draft save/restore** (genie#1261).
19
+ Before this, nothing outside the composer could read or set what had been
20
+ typed, so a host could not keep a draft across a restart. That also broke the
21
+ component contract, which requires controlled state.
22
+ - `value` / `defaultValue` / `onChange(text)` cover the simple case. With
23
+ paste pills on, `value` carries a `pasteMarker(id)` for each pill, the same
24
+ as `Composer`'s `value`.
25
+ - `draft` / `defaultDraft` / `onDraftChange(draft)` cover the whole draft.
26
+ A `PromptDraft` is plain JSON:
27
+ `{ v: 1, text, pills: [{ kind: "text", id, text } | { kind: "image", id, label, file }], attachments: [{ id, name, type, bytes, ref? }] }`.
28
+ - A `ref` now resolves to a `PromptInputHandle`: `getDraft()`,
29
+ `setDraft(draft, { focus?: false })` and `focus()`.
30
+ - **Restoring a draft never moves focus or a caret** (genie#1235). This holds
31
+ when nothing is focused and when another input is. Pass `{ focus: true }`
32
+ to focus the composer.
33
+ - `setDraft` does **not** call `onChange` / `onDraftChange`. The host set the
34
+ draft and already has it, and firing the callbacks would make a restore
35
+ look like typing (a dirty flag, a typing indicator, a save over what was
36
+ just loaded). This is the same as assigning `input.value` in the DOM.
37
+ - The file bytes are not in the draft. `persistAttachment(file, entry)`
38
+ stores each attached or pasted file wherever the host chooses and returns
39
+ a handle, which becomes the entry's `ref`. It can be sync or async.
40
+ `restoreAttachment(entry)` gets the File back on restore. To keep the bytes
41
+ inline, use the new `promptFileToDataUrl` / `promptDataUrlToFile` helpers.
42
+ A restored entry with no File still shows its chip or pill, and reaches
43
+ `onSubmit` with its `ref` and `file: undefined`.
44
+ - `PromptAttachment` gains an optional `ref`. Each attachment chip gets a
45
+ stable `data-react-fancy-prompt-input-attachment="<id>"` handle.
46
+ - A draft whose `v` is unknown is refused with a console warning rather than
47
+ guessed at. Pills restored into a composer with no pills turned on are
48
+ expanded in place, and image pills become attachments, so no raw markers
49
+ show.
50
+
51
+ **What you must do: nothing, unless you opt in.** Existing usage renders the
52
+ same markup as 5.64.1, checked against a snapshot written by 5.64.1 itself.
53
+ Two edge cases. `ref`, `onChange` and `defaultValue` used to fall through
54
+ `...rest` to the root `<div>`, but none of them was in the props type, so a
55
+ TypeScript consumer could not have passed them. If you passed one anyway
56
+ (an untyped `onChange` that listened for DOM change events bubbling to the
57
+ root), it now means the new prop.
58
+
14
59
  ## [5.64.1] - 2026-10-27
15
60
 
16
61
  ### Fixed
@@ -8,6 +8,95 @@ var chunkZU5Q77OT_cjs = require('./chunk-ZU5Q77OT.cjs');
8
8
  var react = require('react');
9
9
  var jsxRuntime = require('react/jsx-runtime');
10
10
 
11
+ // src/components/PromptInput/prompt-draft.ts
12
+ var EMPTY_DRAFT = { v: 1, text: "", pills: [], attachments: [] };
13
+ function readFile(raw, id) {
14
+ if (!raw || typeof raw !== "object") return null;
15
+ const f = raw;
16
+ const fileId = id ?? f.id;
17
+ if (typeof fileId !== "string" || typeof f.name !== "string") return null;
18
+ const out = {
19
+ id: fileId,
20
+ name: f.name,
21
+ type: typeof f.type === "string" ? f.type : "",
22
+ bytes: typeof f.bytes === "number" && Number.isFinite(f.bytes) ? f.bytes : 0
23
+ };
24
+ if (typeof f.ref === "string") out.ref = f.ref;
25
+ return out;
26
+ }
27
+ function readPill(raw) {
28
+ if (!raw || typeof raw !== "object") return null;
29
+ const p = raw;
30
+ if (typeof p.id !== "string") return null;
31
+ if (p.kind === "text" && typeof p.text === "string") {
32
+ return { kind: "text", id: p.id, text: p.text };
33
+ }
34
+ if (p.kind === "image" && typeof p.label === "string") {
35
+ const file = readFile(p.file, p.id);
36
+ return file ? { kind: "image", id: p.id, label: p.label, file } : null;
37
+ }
38
+ return null;
39
+ }
40
+ function readPromptDraft(raw, inline) {
41
+ if (!raw || typeof raw !== "object" || raw.v !== 1) return null;
42
+ const d = raw;
43
+ let text = typeof d.text === "string" ? d.text : "";
44
+ let pills = (Array.isArray(d.pills) ? d.pills : []).map(readPill).filter((p) => p !== null);
45
+ let attachments = (Array.isArray(d.attachments) ? d.attachments : []).map((a) => readFile(a)).filter((a) => a !== null);
46
+ const marked = new Set(chunkQNQNGARW_cjs.markedIds(text));
47
+ pills = pills.filter((p) => marked.has(p.id));
48
+ if (!inline && pills.length > 0) {
49
+ const texts = pills.flatMap((p) => p.kind === "text" ? [{ id: p.id, text: p.text }] : []);
50
+ const images = pills.flatMap((p) => p.kind === "image" ? [p] : []);
51
+ text = chunkQNQNGARW_cjs.expandMarkers(
52
+ text,
53
+ texts,
54
+ images.map((p) => ({ id: p.id, label: p.label }))
55
+ );
56
+ attachments = [...attachments, ...images.map((p) => p.file)];
57
+ pills = [];
58
+ }
59
+ return { v: 1, text, pills, attachments };
60
+ }
61
+ function clonePromptDraft(draft) {
62
+ return JSON.parse(JSON.stringify(draft));
63
+ }
64
+ function promptDraftFiles(draft) {
65
+ return [
66
+ ...draft.attachments,
67
+ ...draft.pills.flatMap((p) => p.kind === "image" ? [p.file] : [])
68
+ ];
69
+ }
70
+ function promptFileToDataUrl(file) {
71
+ return new Promise((resolve, reject) => {
72
+ const reader = new FileReader();
73
+ reader.onload = () => resolve(String(reader.result));
74
+ reader.onerror = () => reject(reader.error);
75
+ reader.readAsDataURL(file);
76
+ });
77
+ }
78
+ function promptDataUrlToFile(dataUrl, name, type) {
79
+ if (!dataUrl.startsWith("data:")) return void 0;
80
+ const comma = dataUrl.indexOf(",");
81
+ if (comma < 0) return void 0;
82
+ const meta = dataUrl.slice(5, comma);
83
+ const body = dataUrl.slice(comma + 1);
84
+ const mime = type || meta.split(";")[0] || "application/octet-stream";
85
+ try {
86
+ let bytes;
87
+ if (meta.endsWith(";base64")) {
88
+ const bin = atob(body);
89
+ bytes = new Uint8Array(bin.length);
90
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
91
+ } else {
92
+ bytes = new TextEncoder().encode(decodeURIComponent(body));
93
+ }
94
+ return new File([bytes], name, { type: mime });
95
+ } catch {
96
+ return void 0;
97
+ }
98
+ }
99
+
11
100
  // src/utils/platform.ts
12
101
  var APPLE_UA_DATA_PLATFORMS = /* @__PURE__ */ new Set(["macos", "ios", "ipados"]);
13
102
  var APPLE_FREE_TEXT = /\b(mac|macintosh|iphone|ipad|ipod|darwin)/i;
@@ -34,7 +123,15 @@ var DEFAULT_MENTION_COLOR = {
34
123
  file: "#10b981",
35
124
  person: "#3b82f6"
36
125
  };
37
- function PromptInput({
126
+ var PromptInput = react.forwardRef(function PromptInput2({
127
+ value: controlledValue,
128
+ defaultValue,
129
+ onChange,
130
+ draft: controlledDraft,
131
+ defaultDraft,
132
+ onDraftChange,
133
+ persistAttachment,
134
+ restoreAttachment,
38
135
  budgetTokens,
39
136
  commands = [],
40
137
  mentions = [],
@@ -60,9 +157,146 @@ function PromptInput({
60
157
  meterShowAt = 0.75,
61
158
  meterWarnAt = 0.9,
62
159
  ...rest
63
- }) {
64
- const [text, setText] = react.useState("");
160
+ }, ref) {
65
161
  const inline = pasteThreshold !== void 0 || pasteImages;
162
+ const draftControlled = controlledDraft !== void 0;
163
+ const [innerDraft, setInnerDraft] = react.useState(
164
+ () => defaultDraft && readPromptDraft(defaultDraft, inline) || {
165
+ ...EMPTY_DRAFT,
166
+ text: defaultValue ?? ""
167
+ }
168
+ );
169
+ const baseDraft = draftControlled ? controlledDraft : innerDraft;
170
+ const model = !draftControlled && controlledValue !== void 0 && controlledValue !== baseDraft.text ? { ...baseDraft, text: controlledValue } : baseDraft;
171
+ const modelRef = react.useRef(model);
172
+ modelRef.current = model;
173
+ const text = model.text;
174
+ const onChangeRef = react.useRef(onChange);
175
+ onChangeRef.current = onChange;
176
+ const onDraftChangeRef = react.useRef(onDraftChange);
177
+ onDraftChangeRef.current = onDraftChange;
178
+ const persistRef = react.useRef(persistAttachment);
179
+ persistRef.current = persistAttachment;
180
+ const commit = react.useCallback(
181
+ (update, notify = true) => {
182
+ const prev = modelRef.current;
183
+ const next = update(prev);
184
+ if (next === prev) return;
185
+ modelRef.current = next;
186
+ setInnerDraft(next);
187
+ if (!notify) return;
188
+ if (next.text !== prev.text) onChangeRef.current?.(next.text);
189
+ onDraftChangeRef.current?.(next);
190
+ },
191
+ []
192
+ );
193
+ const filesRef = react.useRef(/* @__PURE__ */ new Map());
194
+ const placeholdersRef = react.useRef(/* @__PURE__ */ new Map());
195
+ const requestedRef = react.useRef(/* @__PURE__ */ new Set());
196
+ const [filesVersion, bumpFiles] = react.useReducer((n) => n + 1, 0);
197
+ const setFileRef = react.useCallback(
198
+ (id, fileRef2) => commit((m) => {
199
+ const withRef = (f) => f.id === id ? { ...f, ref: fileRef2 } : f;
200
+ if (!promptDraftFiles(m).some((f) => f.id === id)) return m;
201
+ return {
202
+ ...m,
203
+ attachments: m.attachments.map(withRef),
204
+ pills: m.pills.map((p) => p.kind === "image" ? { ...p, file: withRef(p.file) } : p)
205
+ };
206
+ }),
207
+ [commit]
208
+ );
209
+ const persist = react.useCallback(
210
+ (file, entry) => {
211
+ const store = persistRef.current;
212
+ if (!store) return void 0;
213
+ let out;
214
+ try {
215
+ out = store(file, entry);
216
+ } catch (error) {
217
+ console.error("PromptInput: persistAttachment threw", error);
218
+ return void 0;
219
+ }
220
+ if (typeof out === "string") return out;
221
+ if (out && typeof out.then === "function") {
222
+ out.then(
223
+ (fileRef2) => {
224
+ if (typeof fileRef2 === "string") setFileRef(entry.id, fileRef2);
225
+ },
226
+ (error) => console.error("PromptInput: persistAttachment rejected", error)
227
+ );
228
+ }
229
+ return void 0;
230
+ },
231
+ [setFileRef]
232
+ );
233
+ const fileMeta = (id, file) => ({
234
+ id,
235
+ name: file.name,
236
+ type: file.type,
237
+ bytes: file.size
238
+ });
239
+ const fileFor = react.useCallback((entry) => {
240
+ const real = filesRef.current.get(entry.id);
241
+ if (real) return real;
242
+ let stand = placeholdersRef.current.get(entry.id);
243
+ if (!stand) {
244
+ stand = new File([], entry.name, { type: entry.type });
245
+ placeholdersRef.current.set(entry.id, stand);
246
+ }
247
+ return stand;
248
+ }, []);
249
+ const heldPastes = react.useMemo(
250
+ () => model.pills.flatMap((p) => p.kind === "text" ? [{ id: p.id, text: p.text }] : []),
251
+ [model.pills]
252
+ );
253
+ const heldImages = react.useMemo(
254
+ () => model.pills.flatMap(
255
+ (p) => p.kind === "image" ? [{ id: p.id, label: p.label, file: fileFor(p.file) }] : []
256
+ ),
257
+ // filesVersion: a restored File arrived, so the stand-in can be swapped out.
258
+ // eslint-disable-next-line react-hooks/exhaustive-deps
259
+ [model.pills, fileFor, filesVersion]
260
+ );
261
+ const onHeldPastesChange = react.useCallback(
262
+ (next) => commit((m) => {
263
+ const current = m.pills.filter((p) => p.kind === "text");
264
+ if (current.length === next.length && current.every((p, i) => p.id === next[i].id && p.kind === "text" && p.text === next[i].text)) {
265
+ return m;
266
+ }
267
+ return {
268
+ ...m,
269
+ pills: [
270
+ ...next.map((p) => ({ kind: "text", id: p.id, text: p.text })),
271
+ ...m.pills.filter((p) => p.kind === "image")
272
+ ]
273
+ };
274
+ }),
275
+ [commit]
276
+ );
277
+ const onHeldImagesChange = react.useCallback(
278
+ (next) => commit((m) => {
279
+ const before = new Map(
280
+ m.pills.flatMap((p) => p.kind === "image" ? [[p.id, p]] : [])
281
+ );
282
+ if (before.size === next.length && next.every((i) => before.has(i.id))) return m;
283
+ const images = next.map((image) => {
284
+ const known = before.get(image.id);
285
+ if (known && known.kind === "image") return known;
286
+ filesRef.current.set(image.id, image.file);
287
+ const entry = fileMeta(image.id, image.file);
288
+ const fileRef2 = persist(image.file, entry);
289
+ return {
290
+ kind: "image",
291
+ id: image.id,
292
+ label: image.label,
293
+ file: fileRef2 === void 0 ? entry : { ...entry, ref: fileRef2 }
294
+ };
295
+ });
296
+ return { ...m, pills: [...m.pills.filter((p) => p.kind === "text"), ...images] };
297
+ }),
298
+ [commit, persist]
299
+ );
66
300
  const editorRef = react.useRef(null);
67
301
  const paste = chunkQNQNGARW_cjs.useInlinePaste({
68
302
  pasteThreshold,
@@ -71,9 +305,55 @@ function PromptInput({
71
305
  renderPastePreview,
72
306
  classNames: pasteClassNames,
73
307
  disabled: false,
308
+ heldPastes,
309
+ onHeldPastesChange,
310
+ heldImages,
311
+ onHeldImagesChange,
74
312
  editorRef
75
313
  });
76
- const [attachments, setAttachments] = react.useState([]);
314
+ const attachments = react.useMemo(
315
+ () => model.attachments.map((a) => ({
316
+ id: a.id,
317
+ name: a.name,
318
+ bytes: a.bytes,
319
+ file: filesRef.current.get(a.id),
320
+ type: a.type,
321
+ ...a.ref !== void 0 ? { ref: a.ref } : {}
322
+ })),
323
+ // eslint-disable-next-line react-hooks/exhaustive-deps
324
+ [model.attachments, filesVersion]
325
+ );
326
+ const restoreRef = react.useRef(restoreAttachment);
327
+ restoreRef.current = restoreAttachment;
328
+ react.useEffect(() => {
329
+ const restore = restoreRef.current;
330
+ if (!restore) return;
331
+ for (const entry of promptDraftFiles(model)) {
332
+ if (filesRef.current.has(entry.id) || requestedRef.current.has(entry.id)) continue;
333
+ requestedRef.current.add(entry.id);
334
+ const accept = (file) => {
335
+ if (!(file instanceof Blob)) return;
336
+ filesRef.current.set(entry.id, file);
337
+ bumpFiles();
338
+ };
339
+ let out;
340
+ try {
341
+ out = restore(entry);
342
+ } catch (error) {
343
+ console.error("PromptInput: restoreAttachment threw", error);
344
+ continue;
345
+ }
346
+ if (out && typeof out.then === "function") {
347
+ out.then(
348
+ accept,
349
+ (error) => console.error("PromptInput: restoreAttachment rejected", error)
350
+ );
351
+ } else {
352
+ accept(out);
353
+ }
354
+ }
355
+ }, [model]);
356
+ const setText = (next) => commit((m) => m.text === next ? m : { ...m, text: next });
77
357
  const [picker, setPicker] = react.useState(null);
78
358
  const taRef = react.useRef(null);
79
359
  const fileRef = react.useRef(null);
@@ -162,22 +442,68 @@ function PromptInput({
162
442
  const submit = () => {
163
443
  if (!text.trim() && attachments.length === 0) return;
164
444
  if (inline) {
165
- const pastedImages = paste.imagesIn(text).map((image) => ({
166
- id: image.id,
167
- name: image.file.name,
168
- bytes: image.file.size,
169
- file: image.file,
170
- type: image.file.type
171
- }));
445
+ const pastedImages = paste.imagesIn(text).map((image) => {
446
+ const entry = model.pills.find((p) => p.id === image.id);
447
+ const meta = entry?.kind === "image" ? entry.file : fileMeta(image.id, image.file);
448
+ return {
449
+ id: image.id,
450
+ name: meta.name,
451
+ bytes: meta.bytes,
452
+ file: filesRef.current.get(image.id),
453
+ type: meta.type,
454
+ ...meta.ref !== void 0 ? { ref: meta.ref } : {}
455
+ };
456
+ });
172
457
  onSubmit(paste.expand(text), [...attachments, ...pastedImages]);
173
- paste.clear();
174
458
  } else {
175
459
  onSubmit(text, attachments);
176
460
  }
177
- setText("");
178
- setAttachments([]);
461
+ commit(() => EMPTY_DRAFT);
462
+ filesRef.current.clear();
463
+ placeholdersRef.current.clear();
464
+ requestedRef.current.clear();
179
465
  setPicker(null);
180
466
  };
467
+ const pendingFocus = react.useRef(false);
468
+ const focusInput = react.useCallback(() => {
469
+ if (inline) {
470
+ editorRef.current?.focus();
471
+ return;
472
+ }
473
+ const ta = taRef.current;
474
+ if (!ta) return;
475
+ ta.focus();
476
+ ta.setSelectionRange(ta.value.length, ta.value.length);
477
+ }, [inline]);
478
+ react.useLayoutEffect(() => {
479
+ if (!pendingFocus.current) return;
480
+ pendingFocus.current = false;
481
+ focusInput();
482
+ });
483
+ react.useImperativeHandle(
484
+ ref,
485
+ () => ({
486
+ getDraft: () => clonePromptDraft(modelRef.current),
487
+ setDraft(next, options) {
488
+ const read = readPromptDraft(next, inline);
489
+ if (!read) {
490
+ console.warn(
491
+ "PromptInput: setDraft ignored a draft it does not recognise (expected { v: 1, ... }).",
492
+ next
493
+ );
494
+ return;
495
+ }
496
+ for (const entry of promptDraftFiles(read)) {
497
+ if (!filesRef.current.has(entry.id)) requestedRef.current.delete(entry.id);
498
+ }
499
+ setPicker(null);
500
+ commit(() => read, false);
501
+ if (options?.focus) pendingFocus.current = true;
502
+ },
503
+ focus: focusInput
504
+ }),
505
+ [inline, commit, focusInput]
506
+ );
181
507
  const onKeyDown = (e) => {
182
508
  if (picker) {
183
509
  if (e.key === "ArrowDown") {
@@ -211,16 +537,14 @@ function PromptInput({
211
537
  const attach = (list) => {
212
538
  const files = Array.from(list ?? []);
213
539
  if (files.length === 0) return;
214
- setAttachments((cur) => [
215
- ...cur,
216
- ...files.map((f) => ({
217
- id: crypto.randomUUID(),
218
- name: f.name,
219
- bytes: f.size,
220
- file: f,
221
- type: f.type
222
- }))
223
- ]);
540
+ const added = files.map((f) => {
541
+ const id = crypto.randomUUID();
542
+ filesRef.current.set(id, f);
543
+ const entry = fileMeta(id, f);
544
+ const fileRef2 = persist(f, entry);
545
+ return fileRef2 === void 0 ? entry : { ...entry, ref: fileRef2 };
546
+ });
547
+ commit((m) => ({ ...m, attachments: [...m.attachments, ...added] }));
224
548
  };
225
549
  const onDrop = (e) => {
226
550
  e.preventDefault();
@@ -256,6 +580,7 @@ function PromptInput({
256
580
  attachments.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1.5 border-b border-zinc-200 px-3 py-2 dark:border-zinc-800", children: attachments.map((a) => /* @__PURE__ */ jsxRuntime.jsxs(
257
581
  "span",
258
582
  {
583
+ "data-react-fancy-prompt-input-attachment": a.id,
259
584
  className: "inline-flex items-center gap-1.5 rounded-md bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] px-2 py-0.5 text-[11px] dark:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))]",
260
585
  children: [
261
586
  /* @__PURE__ */ jsxRuntime.jsx(chunkMXWR3T3N_cjs.Icon, { name: "paperclip", size: "xs", className: "text-zinc-400" }),
@@ -264,7 +589,10 @@ function PromptInput({
264
589
  /* @__PURE__ */ jsxRuntime.jsx(
265
590
  "button",
266
591
  {
267
- onClick: () => setAttachments((cur) => cur.filter((x) => x.id !== a.id)),
592
+ onClick: () => commit((m) => ({
593
+ ...m,
594
+ attachments: m.attachments.filter((x) => x.id !== a.id)
595
+ })),
268
596
  className: "opacity-50 hover:opacity-100",
269
597
  "aria-label": "Remove attachment",
270
598
  children: "\xD7"
@@ -306,7 +634,7 @@ function PromptInput({
306
634
  chunkQNQNGARW_cjs.PastedFilesInput,
307
635
  {
308
636
  name: pasteFilesName,
309
- files: paste.imagesIn(text).map((i) => i.file)
637
+ files: paste.imagesIn(text).flatMap((i) => filesRef.current.has(i.id) ? [i.file] : [])
310
638
  }
311
639
  )
312
640
  ] }) : /* @__PURE__ */ jsxRuntime.jsx(
@@ -471,7 +799,7 @@ function PromptInput({
471
799
  ]
472
800
  }
473
801
  );
474
- }
802
+ });
475
803
  function fmtTokens(n) {
476
804
  if (n < 1e3) return `${n}`;
477
805
  return `${(n / 1e3).toFixed(1)}k`;
@@ -486,5 +814,7 @@ exports.PromptInput = PromptInput;
486
814
  exports.browserPlatformHints = browserPlatformHints;
487
815
  exports.detectPlatform = detectPlatform;
488
816
  exports.modifierKeyLabel = modifierKeyLabel;
489
- //# sourceMappingURL=chunk-Z6LYBL5L.cjs.map
490
- //# sourceMappingURL=chunk-Z6LYBL5L.cjs.map
817
+ exports.promptDataUrlToFile = promptDataUrlToFile;
818
+ exports.promptFileToDataUrl = promptFileToDataUrl;
819
+ //# sourceMappingURL=chunk-C463C4MM.cjs.map
820
+ //# sourceMappingURL=chunk-C463C4MM.cjs.map