@jxsuite/studio 2.4.3 → 4.0.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 (208) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
  33. package/dist/chunks/studio-2zkz8evn.js +3163 -0
  34. package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
  57. package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/studio-kyh9avvv.js +28 -0
  60. package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
  81. package/dist/chunks/studio-xrapxwwc.js +1539 -0
  82. package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +450 -48
  86. package/dist/studio.js +2455 -1761
  87. package/package.json +16 -15
  88. package/src/browse/library-layouts.ts +3 -3
  89. package/src/browse/library-pane.ts +59 -5
  90. package/src/browse/library-preview.ts +19 -3
  91. package/src/canvas/asset-refs.ts +119 -0
  92. package/src/canvas/asset-resolve.ts +256 -0
  93. package/src/canvas/canvas-live-render.ts +23 -15
  94. package/src/canvas/canvas-render.ts +23 -1
  95. package/src/canvas/iframe-entry.ts +1 -0
  96. package/src/canvas/iframe-host.ts +4 -0
  97. package/src/canvas/iframe-patch.ts +47 -21
  98. package/src/canvas/iframe-protocol.ts +9 -0
  99. package/src/canvas/iframe-render.ts +50 -16
  100. package/src/commands/app-commands.ts +4 -0
  101. package/src/commands/command-args.ts +29 -0
  102. package/src/commands/context.ts +15 -1
  103. package/src/commands/defaults.ts +29 -1
  104. package/src/commands/levels.ts +16 -0
  105. package/src/commands/live-context.ts +3 -0
  106. package/src/content/collection-match.ts +267 -0
  107. package/src/content/entry-commands.ts +38 -29
  108. package/src/content/entry-fields.ts +6 -0
  109. package/src/editor/file-drop-action.ts +2 -1
  110. package/src/editor/shortcuts.ts +5 -0
  111. package/src/files/file-ops.ts +10 -42
  112. package/src/files/files.ts +487 -61
  113. package/src/files/fs-events.ts +9 -0
  114. package/src/files/gitignore.ts +432 -0
  115. package/src/files/media-paths.ts +37 -14
  116. package/src/files/media-upload.ts +47 -6
  117. package/src/files/serialize-document.ts +60 -0
  118. package/src/format/convert-file.ts +511 -0
  119. package/src/format/format-choices.ts +199 -0
  120. package/src/grid/grid-idle.ts +45 -0
  121. package/src/grid/grid-view.ts +35 -0
  122. package/src/grid/schema-columns.ts +2 -1
  123. package/src/grid/sources/content-source.ts +24 -3
  124. package/src/media/media-open.ts +58 -0
  125. package/src/media/media-pane.ts +310 -0
  126. package/src/new-project/import-tab.ts +333 -160
  127. package/src/new-project/new-project-modal.ts +27 -34
  128. package/src/packages/ensure-deps.ts +14 -7
  129. package/src/packages/jxsuite-update.ts +11 -11
  130. package/src/page-params.ts +10 -46
  131. package/src/panels/activity-bar.ts +45 -16
  132. package/src/panels/ai-chat/attached-context.ts +6 -1
  133. package/src/panels/ai-chat/chat-view.ts +255 -13
  134. package/src/panels/ai-chat/composer.ts +21 -80
  135. package/src/panels/ai-panel.ts +116 -2
  136. package/src/panels/frontmatter-fields.ts +6 -2
  137. package/src/panels/navigator-panels.ts +7 -6
  138. package/src/panels/properties-panel.ts +8 -2
  139. package/src/panels/seo-modal.ts +5 -6
  140. package/src/panels/settings-menu.ts +799 -0
  141. package/src/panels/signals-panel.ts +2 -1
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +34 -3
  144. package/src/platforms/devserver.ts +72 -34
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/services/ai-ask.ts +247 -0
  147. package/src/services/ai-import-tools.ts +494 -0
  148. package/src/services/ai-models.ts +101 -11
  149. package/src/services/ai-project-tools.ts +20 -26
  150. package/src/services/ai-settings.ts +69 -71
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/cf-settings.ts +19 -37
  154. package/src/services/connection.ts +74 -0
  155. package/src/services/context-manager.ts +89 -0
  156. package/src/services/document-assistant.ts +49 -7
  157. package/src/services/idle.ts +6 -1
  158. package/src/services/import-client.ts +33 -6
  159. package/src/services/import-run.ts +158 -0
  160. package/src/services/import-seed.ts +120 -0
  161. package/src/services/jx-validate.ts +58 -6
  162. package/src/services/project-adoption.ts +71 -0
  163. package/src/services/references.ts +0 -0
  164. package/src/services/settings/definitions.ts +181 -0
  165. package/src/services/settings/kernel.ts +335 -0
  166. package/src/services/settings/write-queue.ts +116 -0
  167. package/src/services/tool-executor.ts +34 -2
  168. package/src/settings/contexts-section.ts +45 -3
  169. package/src/settings/dependencies-editor.ts +43 -23
  170. package/src/settings/general-settings.ts +30 -12
  171. package/src/settings/preferences-accounts.ts +6 -19
  172. package/src/settings/preferences-dialog.ts +18 -41
  173. package/src/settings/preferences-keymap.ts +8 -18
  174. package/src/settings/preferences-sections.ts +57 -0
  175. package/src/settings/schema-field-ui.ts +2 -2
  176. package/src/settings/settings-document.ts +65 -9
  177. package/src/shell.ts +6 -14
  178. package/src/studio.ts +65 -7
  179. package/src/tabs/transact.ts +42 -19
  180. package/src/types.ts +164 -8
  181. package/src/ui/ai-credentials-form.ts +52 -27
  182. package/src/ui/ai-model-picker.ts +162 -0
  183. package/src/ui/choice-field.ts +72 -0
  184. package/src/ui/layers.ts +83 -5
  185. package/src/ui/media-picker.ts +6 -11
  186. package/src/utils/studio-utils.ts +43 -31
  187. package/styles/forced-colors.css +7 -0
  188. package/styles/overlays.css +256 -0
  189. package/styles/panels.css +13 -1
  190. package/styles/shell.css +111 -0
  191. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  192. package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
  193. package/dist/chunks/content-source-k9724vcv.js +0 -39
  194. package/dist/chunks/files-yew27sn9.js +0 -69
  195. package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
  196. package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
  197. package/dist/chunks/quick-search-9avyc4pc.js +0 -54
  198. package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
  199. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  200. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  201. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  202. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  203. package/dist/chunks/studio-a7pzveq1.js +0 -110
  204. package/dist/chunks/studio-c31yb4t6.js +0 -55
  205. package/dist/chunks/studio-ra7ar50f.js +0 -6682
  206. package/src/canvas/content-assets.ts +0 -263
  207. package/src/canvas/site-style-css.ts +0 -121
  208. package/src/services/settings-store.ts +0 -99
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Publishing what the author is looking at, so a live preview shows the canvas and not the disk.
3
+ *
4
+ * A live preview composes a page from the project's working TREE, and the tree is what has been
5
+ * saved. The document the author is actually editing lives in a tab's reactive memory and nowhere
6
+ * else, so without this the one thing that makes a live preview worth having — seeing an edit
7
+ * before it is written — would be exactly the thing it could not show.
8
+ *
9
+ * What travels is BYTES, and specifically {@link serializeDocument}'s: the same function `saveFile`
10
+ * writes through, grid controllers, format round-trips, frontmatter and all. That is what makes
11
+ * "what the reader sees" and "what saving would produce" one answer rather than two that drift. A
12
+ * document object would bypass the format layer, and a `.md` page's bytes are not
13
+ * `JSON.stringify(doc)`.
14
+ *
15
+ * **Why an effect and not `transactDoc`.** `transact.ts` has exactly one observer slot and the
16
+ * collab layer owns it. More importantly a transaction hook would MISS three of the most common
17
+ * ways a document changes: a Monaco source commit, a content-entry field edit, and every grid edit
18
+ * route around `transactDoc` entirely. "I edited it in Source and the preview did not change" is
19
+ * the bug this shape avoids.
20
+ *
21
+ * **Why one effect over the whole tab set rather than one per tab.** Reading the map tracks
22
+ * additions, so a tab opened later needs no registration and a tab closed needs no teardown. And
23
+ * the publish is a DIFF against what was last published, which means save, close, discard and undo
24
+ * back to clean all resolve through the same line instead of four hooks that can each be
25
+ * forgotten.
26
+ *
27
+ * **Armed, not always on.** Serializing dirty documents on every edit is not free, and before the
28
+ * author has ever opened a preview there is nothing to publish to. Nothing happens until a preview
29
+ * origin exists.
30
+ *
31
+ * @docs studio/interface
32
+ */
33
+
34
+ import { effect, effectScope } from "../reactivity";
35
+ import { getPlatform, hasPlatform } from "../platform";
36
+ import { serializeDocument } from "../files/serialize-document";
37
+ import { workspace } from "../workspace/workspace";
38
+ import type { Tab } from "../tabs/tab";
39
+
40
+ /**
41
+ * Trailing debounce on a publish.
42
+ *
43
+ * No leading edge: a leading publish would send the first keystroke of a burst and the last, which
44
+ * is two reloads for one thought. The max wait is what keeps a continuous drag from starving the
45
+ * trailing timer forever.
46
+ */
47
+ const PUBLISH_DEBOUNCE_MS = 200;
48
+ const PUBLISH_MAX_WAIT_MS = 500;
49
+
50
+ /** Bytes one document may publish before it is left showing its saved state instead. */
51
+ const MAX_ENTRY_BYTES = 2 * 1024 * 1024;
52
+
53
+ /**
54
+ * Path to the bytes last published for it, so identical bytes cost nothing.
55
+ *
56
+ * The TEXT rather than a digest of it. An edit that lands back on the same bytes — a character
57
+ * typed and deleted — must not cost the reader a reload, and comparing what was sent is exact where
58
+ * a hash would be a collision away from being wrong for no saving anyone can measure. Memory is
59
+ * bounded by the same per-entry cap the publish is.
60
+ */
61
+ const published = new Map<string, string>();
62
+
63
+ let armed = false;
64
+ let scope: ReturnType<typeof effectScope> | null = null;
65
+ let timer: ReturnType<typeof setTimeout> | null = null;
66
+ let burstStartedAt: number | null = null;
67
+ /** The publish in flight, so a flush awaits it rather than racing it. */
68
+ let inFlight: Promise<void> | null = null;
69
+
70
+ /** The documents whose unsaved bytes a preview needs, in the order they were opened. */
71
+ function dirtyTabs(): Tab[] {
72
+ const tabs: Tab[] = [];
73
+ for (const tab of workspace.tabs.values()) {
74
+ /* Dirty only. A clean tab's bytes are on disk by definition, and publishing them would create
75
+ a second source of truth for identical content. */
76
+ if (tab.doc.dirty && tab.documentPath) {
77
+ tabs.push(tab);
78
+ }
79
+ }
80
+ return tabs;
81
+ }
82
+
83
+ /**
84
+ * Publish what changed and retract what no longer applies.
85
+ *
86
+ * A diff rather than a stream of events: whatever reason a document has for no longer being unsaved
87
+ * — saved, closed, discarded, undone back to its file — it is simply absent from this pass, and the
88
+ * retraction follows without anywhere else having to remember to ask for it.
89
+ */
90
+ async function publish(): Promise<void> {
91
+ /* Not `armed` too: `flushPreviewOverlay` refuses when disarmed and `disarmPreviewOverlay` clears
92
+ the pending timer, so there is no path into here without it. A host with no platform at all is
93
+ real though — `getPlatform()` throws rather than answering null. */
94
+ if (!hasPlatform()) {
95
+ return;
96
+ }
97
+ const platform = getPlatform();
98
+ if (!platform.setPreviewOverlay || !platform.clearPreviewOverlay) {
99
+ return;
100
+ }
101
+ const live = new Set<string>();
102
+ for (const tab of dirtyTabs()) {
103
+ const path = tab.documentPath!.replace(/^\.\//, "");
104
+ let text: string;
105
+ try {
106
+ text = await serializeDocument(tab);
107
+ } catch {
108
+ /* A half-typed document that will not serialize is not a reason to stop previewing the layout
109
+ beside it. Skipped, and the next edit that does serialize publishes it. */
110
+ continue;
111
+ }
112
+ if (text.length > MAX_ENTRY_BYTES) {
113
+ continue;
114
+ }
115
+ live.add(path);
116
+ if (published.get(path) === text) {
117
+ continue;
118
+ }
119
+ published.set(path, text);
120
+ await platform.setPreviewOverlay(path, text);
121
+ }
122
+ for (const path of published.keys()) {
123
+ if (!live.has(path)) {
124
+ published.delete(path);
125
+ await platform.clearPreviewOverlay(path);
126
+ }
127
+ }
128
+ }
129
+
130
+ /** Run a publish, coalescing with any already scheduled. */
131
+ function schedule(): void {
132
+ if (!armed) {
133
+ return;
134
+ }
135
+ const now = Date.now();
136
+ burstStartedAt ??= now;
137
+ if (timer) {
138
+ clearTimeout(timer);
139
+ }
140
+ const waited = now - burstStartedAt;
141
+ const delay = Math.max(0, Math.min(PUBLISH_DEBOUNCE_MS, PUBLISH_MAX_WAIT_MS - waited));
142
+ timer = setTimeout(() => {
143
+ timer = null;
144
+ burstStartedAt = null;
145
+ inFlight = publish().finally(() => {
146
+ inFlight = null;
147
+ });
148
+ }, delay);
149
+ }
150
+
151
+ /**
152
+ * Note an edit the reactive effect cannot see.
153
+ *
154
+ * `transactDoc` replaces the document's ROOT REFERENCE on every transaction, and a Monaco source
155
+ * commit assigns a fresh one, so a shallow read catches both. A content-entry field edit writes a
156
+ * key in place on an already-dirty tab, which changes nothing the effect reads. This is the seam
157
+ * for exactly that: an explicit call from the few places that mutate without swapping.
158
+ */
159
+ export function notePreviewOverlayEdit(): void {
160
+ schedule();
161
+ }
162
+
163
+ /**
164
+ * Start publishing, because a preview origin now exists to publish to.
165
+ *
166
+ * Idempotent: `View: Open in Browser` calls it on every invocation and only the first does work.
167
+ */
168
+ export function armPreviewOverlay(): void {
169
+ if (armed) {
170
+ return;
171
+ }
172
+ armed = true;
173
+ scope = effectScope();
174
+ scope.run(() => {
175
+ effect(() => {
176
+ for (const tab of workspace.tabs.values()) {
177
+ /* Two reads, both shallow. `document` is a fresh reference on every transaction — see
178
+ `transactDoc`, which assigns one specifically so effects re-run — so this costs a
179
+ property read rather than a walk of the document. */
180
+ void tab.doc.dirty;
181
+ void tab.doc.document;
182
+ }
183
+ schedule();
184
+ });
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Stop publishing and forget what was published, without retracting it.
190
+ *
191
+ * Called when the window changes project. The published map is keyed by project-relative path, so
192
+ * carrying it across a switch would let one project's `pages/index.json` be read as the answer for
193
+ * another's — and the backend lets go of the old root's overlay on its own re-root anyway.
194
+ */
195
+ export function disarmPreviewOverlay(): void {
196
+ armed = false;
197
+ if (timer) {
198
+ clearTimeout(timer);
199
+ timer = null;
200
+ }
201
+ burstStartedAt = null;
202
+ scope?.stop();
203
+ scope = null;
204
+ published.clear();
205
+ }
206
+
207
+ /**
208
+ * Publish now and wait for it.
209
+ *
210
+ * Two callers, and both are moments where the debounce is exactly wrong: on the way to opening a
211
+ * browser tab, so the page the author is about to look at already carries their last keystroke; and
212
+ * after a save, so the retraction rides with the write instead of arriving a debounce later and
213
+ * costing a second reload.
214
+ */
215
+ export async function flushPreviewOverlay(): Promise<void> {
216
+ if (!armed) {
217
+ return;
218
+ }
219
+ if (timer) {
220
+ clearTimeout(timer);
221
+ timer = null;
222
+ burstStartedAt = null;
223
+ }
224
+ await inFlight;
225
+ await publish();
226
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Ai-ask.ts — the assistant's one way to stop and put a question to the author.
3
+ *
4
+ * The agent loop `await`s `toolRegistry.execute` (`services/tool-executor.ts`), so a tool that
5
+ * returns a pending promise suspends the turn. That is the whole mechanism: `ask_user` registers a
6
+ * question, hands back a promise, and the panel resolves it when the human answers. No new chat
7
+ * status, no pre-execution hook, and nothing in the published `@jxsuite/ai` changes —
8
+ * `finishStream` has already set the state to `"idle"` by the time tools run, so a suspended turn
9
+ * is idle by construction and this store is what "waiting" means.
10
+ *
11
+ * Two decisions are load-bearing and easy to get wrong on a rewrite:
12
+ *
13
+ * - **A skip is a SUCCESS.** "You decide" is a real answer to a question; only a stopped turn is a
14
+ * failure. Reporting a skip as an error would make the model apologise for having asked.
15
+ * - **The answer never becomes a user message.** `toMessagesArray` serialises the array verbatim
16
+ * and a provider requires a `tool` reply to follow its `tool_calls` request; a user turn spliced
17
+ * between them is a 400. The answer travels as the tool result, and the chat view renders it into
18
+ * the question's own chip.
19
+ *
20
+ * Exactly one question can be outstanding, because `runAgentLoop` executes a round's tool calls
21
+ * serially. The guard below is defensive, not load-bearing.
22
+ *
23
+ * @license MIT
24
+ */
25
+
26
+ import { createToolDefinition, toolError, toolSuccess } from "@jxsuite/ai/tools";
27
+ import { reactive } from "../reactivity";
28
+ import { currentToolCallId, turnSignal } from "./ai-turn-signal";
29
+
30
+ import type { ToolRegistry } from "@jxsuite/ai/tools";
31
+
32
+ /** A question the assistant is waiting on. `id` is the tool-call id, so the chip and this agree. */
33
+ export interface PendingAsk {
34
+ id: string;
35
+ question: string;
36
+ /** Short choices rendered as buttons. Free text is always allowed alongside them. */
37
+ options: string[];
38
+ /** One line of why-I'm-asking, rendered under the question. */
39
+ context: string;
40
+ }
41
+
42
+ /** How a question was settled. `answer: null` with `skipped` is the author saying "you decide". */
43
+ export interface AskAnswer {
44
+ answer: string | null;
45
+ skipped: boolean;
46
+ }
47
+
48
+ /** Max choices rendered as buttons; beyond this the question wants free text, not a menu. */
49
+ const MAX_OPTIONS = 6;
50
+
51
+ const state = reactive<{ pending: PendingAsk | null }>({ pending: null });
52
+
53
+ /** The resolver for {@link state.pending}, kept out of the reactive object (it is not render data). */
54
+ let settle: ((answer: AskAnswer) => void) | null = null;
55
+
56
+ /** Torn down with the question so a stopped turn does not leave a listener on a dead signal. */
57
+ let detachAbort: (() => void) | null = null;
58
+
59
+ function clear() {
60
+ state.pending = null;
61
+ settle = null;
62
+ detachAbort?.();
63
+ detachAbort = null;
64
+ }
65
+
66
+ /**
67
+ * The outstanding question, or null. Reactive — read it inside an `effect()` to repaint on it.
68
+ *
69
+ * @returns {PendingAsk | null}
70
+ */
71
+ export function pendingAsk(): PendingAsk | null {
72
+ return state.pending;
73
+ }
74
+
75
+ /** Whether a turn is suspended on the author. The composer's Send becomes Answer on this. */
76
+ export function isAwaitingAnswer(): boolean {
77
+ return state.pending !== null;
78
+ }
79
+
80
+ /**
81
+ * Register a question and suspend until it is settled.
82
+ *
83
+ * @param {PendingAsk} ask
84
+ * @returns {Promise<AskAnswer>} Resolves on an answer, a skip, or the turn being stopped (`{
85
+ * answer: null, skipped: false }` — the shape the tool reports as a failure).
86
+ */
87
+ export function askUser(ask: PendingAsk): Promise<AskAnswer> {
88
+ return new Promise<AskAnswer>((resolve) => {
89
+ state.pending = ask;
90
+ settle = resolve;
91
+
92
+ /* The turn's signal, not a parameter: `ToolRegistry.execute` has nowhere to pass one. Without
93
+ this, Stop would leave the loop awaiting a promise nothing could ever resolve. */
94
+ const signal = turnSignal();
95
+ if (signal?.aborted) {
96
+ clear();
97
+ resolve({ answer: null, skipped: false });
98
+ return;
99
+ }
100
+ if (signal) {
101
+ const onAbort = () => {
102
+ cancelAsk();
103
+ };
104
+ signal.addEventListener("abort", onAbort, { once: true });
105
+ detachAbort = () => signal.removeEventListener("abort", onAbort);
106
+ }
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Answer the outstanding question.
112
+ *
113
+ * @param {string} text
114
+ * @returns {boolean} False when nothing was pending, so a caller can fall through to a normal send.
115
+ */
116
+ export function answerAsk(text: string): boolean {
117
+ if (!settle) {
118
+ return false;
119
+ }
120
+ const resolve = settle;
121
+ clear();
122
+ resolve({ answer: text, skipped: false });
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Decline to answer — "you decide". Settles as a success, so the turn carries on.
128
+ *
129
+ * @returns {boolean} False when nothing was pending.
130
+ */
131
+ export function skipAsk(): boolean {
132
+ if (!settle) {
133
+ return false;
134
+ }
135
+ const resolve = settle;
136
+ clear();
137
+ resolve({ answer: null, skipped: true });
138
+ return true;
139
+ }
140
+
141
+ /** Settle an outstanding question as unanswered. Called from `stop()` and from the turn's abort. */
142
+ export function cancelAsk(): void {
143
+ if (!settle) {
144
+ return;
145
+ }
146
+ const resolve = settle;
147
+ clear();
148
+ resolve({ answer: null, skipped: false });
149
+ }
150
+
151
+ /** Drop any outstanding question without settling a turn. For New Chat and for tests. */
152
+ export function resetAsk(): void {
153
+ cancelAsk();
154
+ clear();
155
+ }
156
+
157
+ /**
158
+ * Register `ask_user` into a tool registry.
159
+ *
160
+ * The description carries the discipline the tool needs to be worth having: a question the model
161
+ * could have answered itself is noise, and a question about a setting the pipeline never receives
162
+ * is worse than noise, because the answer cannot be honoured.
163
+ *
164
+ * @param {Pick<ToolRegistry, "register">} registry
165
+ */
166
+ export function registerAskTool(registry: Pick<ToolRegistry, "register">): void {
167
+ registry.register(
168
+ createToolDefinition({
169
+ name: "ask_user",
170
+ description:
171
+ "Pause and put a question to the user; the turn waits for their reply and resumes with " +
172
+ "it. Use it ONLY for a judgement that is genuinely theirs — which pages matter, whether to " +
173
+ "keep the original design, whether a low-fidelity page is acceptable. Never ask something " +
174
+ "you can decide yourself or discover with another tool, and never ask about an option you " +
175
+ "cannot act on. One question at a time; offer options when the sensible answers are a " +
176
+ "short list, and the user may always reply in their own words instead.",
177
+ parameters: {
178
+ type: "object",
179
+ properties: {
180
+ question: {
181
+ type: "string",
182
+ description: "The question, in one sentence, addressed to the user.",
183
+ },
184
+ options: {
185
+ type: "array",
186
+ items: { type: "string" },
187
+ description:
188
+ `Up to ${MAX_OPTIONS} short answers, rendered as buttons. Omit when the answer is ` +
189
+ "open-ended.",
190
+ },
191
+ context: {
192
+ type: "string",
193
+ description:
194
+ "One line of why you are asking — what you found that made this a judgement call.",
195
+ },
196
+ },
197
+ required: ["question"],
198
+ },
199
+ execute: async (args) => {
200
+ /* Everything `unknown`, and every field narrowed by `typeof` rather than by `?.`. The
201
+ registry's `validate()` is "a lightweight structural check, not a JSON Schema validator",
202
+ `strict: false` skips it entirely, and this tool may be registered into someone else's
203
+ registry — so an argument that is a number is a case, not an impossibility. Optional
204
+ chaining guards null, not type: `context?.trim()` threw on one. */
205
+ const { question, options, context } = args as {
206
+ question?: unknown;
207
+ options?: unknown;
208
+ context?: unknown;
209
+ };
210
+ if (typeof question !== "string" || !question.trim()) {
211
+ return toolError('Pass "question" — the question to put to the user.');
212
+ }
213
+ if (isAwaitingAnswer()) {
214
+ return toolError("A question is already waiting for the user.");
215
+ }
216
+ const choices = Array.isArray(options)
217
+ ? options
218
+ .filter((o): o is string => typeof o === "string" && o.trim().length > 0)
219
+ .slice(0, MAX_OPTIONS)
220
+ : [];
221
+
222
+ const { answer, skipped } = await askUser({
223
+ context: typeof context === "string" ? context.trim() : "",
224
+ /* The tool-call id, so the chip the transcript draws and the question this registers are
225
+ the same thing. Without it a restored, permanently unanswered question would render
226
+ identically to the live one. */
227
+ id: currentToolCallId(),
228
+ options: choices,
229
+ question: question.trim(),
230
+ });
231
+
232
+ if (skipped) {
233
+ /* A success: the model asked, and "you decide" is an answer. Rendering it as a failure
234
+ would have the model apologise for having asked a fair question. */
235
+ return toolSuccess(
236
+ { answer: null, skipped: true },
237
+ "The user declined to answer; proceed with your best judgment.",
238
+ );
239
+ }
240
+ if (answer === null) {
241
+ return toolError("The user did not answer — the turn was stopped.");
242
+ }
243
+ return toolSuccess({ answer, skipped: false }, `The user answered: "${answer}"`);
244
+ },
245
+ }),
246
+ );
247
+ }