@jxsuite/studio 3.0.0 → 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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  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-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
  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-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
  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-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
  33. package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
  34. package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -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-xntfdy1x.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-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
  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-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-hcxzyk3x.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-7p35254k.js → studio-ex8zyp98.js} +30 -26
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
  57. package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  60. package/dist/chunks/{studio-g5gdqnyd.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-p5dja96s.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
  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-k4jvjcfx.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-5efrtn1m.js → studio-wx5609xd.js} +21 -17
  81. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  82. package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
  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 +108 -24
  86. package/dist/studio.js +2362 -1695
  87. package/package.json +16 -15
  88. package/src/browse/library-pane.ts +57 -3
  89. package/src/canvas/canvas-render.ts +23 -1
  90. package/src/canvas/iframe-patch.ts +47 -21
  91. package/src/canvas/iframe-render.ts +22 -6
  92. package/src/commands/app-commands.ts +4 -0
  93. package/src/commands/command-args.ts +29 -0
  94. package/src/commands/context.ts +15 -1
  95. package/src/commands/defaults.ts +29 -1
  96. package/src/commands/levels.ts +16 -0
  97. package/src/commands/live-context.ts +3 -0
  98. package/src/content/collection-match.ts +267 -0
  99. package/src/content/entry-commands.ts +38 -29
  100. package/src/content/entry-fields.ts +6 -0
  101. package/src/editor/shortcuts.ts +5 -0
  102. package/src/files/file-ops.ts +10 -42
  103. package/src/files/files.ts +485 -59
  104. package/src/files/fs-events.ts +9 -0
  105. package/src/files/gitignore.ts +432 -0
  106. package/src/files/serialize-document.ts +60 -0
  107. package/src/format/convert-file.ts +511 -0
  108. package/src/format/format-choices.ts +199 -0
  109. package/src/grid/grid-view.ts +16 -2
  110. package/src/media/media-open.ts +58 -0
  111. package/src/media/media-pane.ts +310 -0
  112. package/src/new-project/import-tab.ts +332 -160
  113. package/src/new-project/new-project-modal.ts +27 -34
  114. package/src/packages/ensure-deps.ts +14 -7
  115. package/src/packages/jxsuite-update.ts +11 -11
  116. package/src/page-params.ts +10 -46
  117. package/src/panels/activity-bar.ts +45 -16
  118. package/src/panels/ai-chat/attached-context.ts +6 -1
  119. package/src/panels/ai-chat/chat-view.ts +255 -13
  120. package/src/panels/ai-chat/composer.ts +21 -97
  121. package/src/panels/ai-panel.ts +116 -2
  122. package/src/panels/navigator-panels.ts +7 -6
  123. package/src/panels/settings-menu.ts +799 -0
  124. package/src/panels/toolbar.ts +135 -15
  125. package/src/platforms/cloud.ts +20 -0
  126. package/src/platforms/devserver.ts +67 -33
  127. package/src/preview/preview-overlay.ts +226 -0
  128. package/src/services/ai-ask.ts +247 -0
  129. package/src/services/ai-import-tools.ts +494 -0
  130. package/src/services/ai-project-tools.ts +20 -26
  131. package/src/services/ai-system-prompt.ts +88 -2
  132. package/src/services/ai-turn-signal.ts +70 -0
  133. package/src/services/connection.ts +74 -0
  134. package/src/services/context-manager.ts +89 -0
  135. package/src/services/document-assistant.ts +45 -4
  136. package/src/services/import-client.ts +33 -6
  137. package/src/services/import-run.ts +158 -0
  138. package/src/services/import-seed.ts +120 -0
  139. package/src/services/jx-validate.ts +58 -6
  140. package/src/services/project-adoption.ts +71 -0
  141. package/src/services/references.ts +0 -0
  142. package/src/services/settings/definitions.ts +17 -0
  143. package/src/services/tool-executor.ts +34 -2
  144. package/src/settings/contexts-section.ts +45 -3
  145. package/src/settings/dependencies-editor.ts +43 -23
  146. package/src/settings/general-settings.ts +30 -12
  147. package/src/settings/preferences-dialog.ts +18 -41
  148. package/src/settings/preferences-sections.ts +57 -0
  149. package/src/settings/settings-document.ts +65 -9
  150. package/src/studio.ts +33 -6
  151. package/src/tabs/transact.ts +42 -19
  152. package/src/types.ts +103 -5
  153. package/src/ui/ai-model-picker.ts +162 -0
  154. package/src/ui/choice-field.ts +72 -0
  155. package/src/ui/layers.ts +83 -5
  156. package/src/utils/studio-utils.ts +22 -52
  157. package/styles/forced-colors.css +7 -0
  158. package/styles/overlays.css +256 -0
  159. package/styles/panels.css +13 -1
  160. package/styles/shell.css +111 -0
  161. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  162. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  163. package/dist/chunks/files-xcybanxe.js +0 -70
  164. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  165. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  166. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  167. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  168. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  169. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  170. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  171. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  172. package/dist/chunks/studio-c31yb4t6.js +0 -55
  173. package/src/canvas/site-style-css.ts +0 -117
@@ -41,6 +41,8 @@ import { renderMarkdown } from "./chat-markdown";
41
41
  import { summarizeWrites, writesForTurn } from "../../services/ai-writes";
42
42
  import { activeRegistry } from "../../commands/active-registry";
43
43
 
44
+ import type { ImportRunRecord } from "../../services/import-run";
45
+
44
46
  // ─── Helpers (moved from ai-panel.ts) ────────────────────────────────────────
45
47
 
46
48
  /**
@@ -255,15 +257,93 @@ function renderUserMessage(msg: Message): TemplateResult {
255
257
  `;
256
258
  }
257
259
 
260
+ /** The tool whose chip is a question card rather than a chip. */
261
+ const ASK_TOOL = "ask_user";
262
+
263
+ /** The tool whose chip grows a live progress line while it runs. */
264
+ const IMPORT_TOOL = "import_site";
265
+
266
+ /** Log lines drawn under a running import. The tail is what a reader wants. */
267
+ /**
268
+ * How close to the bottom counts as "following along".
269
+ *
270
+ * The same threshold and the same reasoning as the transcript's own scroller
271
+ * (`panels/ai-panel.ts`): a reader who has scrolled up is reading something, and yanking them back
272
+ * on every new line is the behaviour that makes a live log unreadable.
273
+ */
274
+ const IMPORT_STICK_THRESHOLD = 24;
275
+
258
276
  /**
259
- * What became of one tool call: `"pending"` while the loop has not reported, else ok/failed.
277
+ * Keep an import log pinned to its newest line, unless the reader has scrolled away from it.
278
+ *
279
+ * The log is a scroller now rather than a six-line tail, and a scroller that does not follow is a
280
+ * box that shows the same six lines it started with while the interesting ones pile up below.
281
+ */
282
+ function stickToBottom(element: Element | undefined): void {
283
+ if (!(element instanceof HTMLElement)) {
284
+ return;
285
+ }
286
+ const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
287
+ if (distance <= IMPORT_STICK_THRESHOLD || element.scrollTop === 0) {
288
+ element.scrollTop = element.scrollHeight;
289
+ }
290
+ }
291
+
292
+ /** What `ask_user` was called with, as far as the arguments actually parse. */
293
+ interface AskArgs {
294
+ question: string;
295
+ options: string[];
296
+ context: string;
297
+ }
298
+
299
+ /**
300
+ * Read a question out of a tool call's arguments.
301
+ *
302
+ * Tolerant on purpose: the arguments are assembled from streamed fragments, so a chip can be asked
303
+ * to render a call whose JSON is still half-written. A card with an empty question renders as an
304
+ * ordinary chip rather than as an empty box.
260
305
  *
261
306
  * @param {ToolCallRecord} tc
262
- * @returns {"pending" | "ok" | "failed"}
307
+ * @returns {AskArgs | null}
263
308
  */
264
- export function toolOutcome(tc: ToolCallRecord): "pending" | "ok" | "failed" {
309
+ export function parseAsk(tc: ToolCallRecord): AskArgs | null {
310
+ let parsed: { question?: unknown; options?: unknown; context?: unknown };
311
+ try {
312
+ parsed = tc.arguments ? (JSON.parse(tc.arguments) as typeof parsed) : {};
313
+ } catch {
314
+ return null; // Still streaming, or malformed — the generic chip is the honest fallback.
315
+ }
316
+ if (typeof parsed.question !== "string" || !parsed.question.trim()) {
317
+ return null;
318
+ }
319
+ return {
320
+ context: typeof parsed.context === "string" ? parsed.context : "",
321
+ options: Array.isArray(parsed.options)
322
+ ? parsed.options.filter((o): o is string => typeof o === "string" && o.trim().length > 0)
323
+ : [],
324
+ question: parsed.question,
325
+ };
326
+ }
327
+
328
+ /**
329
+ * What became of one tool call.
330
+ *
331
+ * `"unanswered"` is the fourth value and it exists for `ask_user`. A question's promise lives in
332
+ * memory, so a reload kills it while the transcript survives: without this the restored card is
333
+ * indistinguishable from a live one and spins forever waiting for a loop that is gone. It is a
334
+ * property of the RUN, not of the record, so the caller supplies whether this call is still live.
335
+ *
336
+ * @param {ToolCallRecord} tc
337
+ * @param {boolean} [live] - Whether the loop is still waiting on this call. Only consulted for a
338
+ * result-less interactive call; every other pending call belongs to a turn still in flight.
339
+ * @returns {"pending" | "unanswered" | "ok" | "failed"}
340
+ */
341
+ export function toolOutcome(
342
+ tc: ToolCallRecord,
343
+ live = true,
344
+ ): "pending" | "unanswered" | "ok" | "failed" {
265
345
  if (!tc.result) {
266
- return "pending";
346
+ return tc.name === ASK_TOOL && !live ? "unanswered" : "pending";
267
347
  }
268
348
  return tc.result.success ? "ok" : "failed";
269
349
  }
@@ -286,27 +366,180 @@ export function toolOutcomeText(tc: ToolCallRecord): string {
286
366
  return (result.success ? result.summary : result.error) ?? "";
287
367
  }
288
368
 
289
- function renderToolChips(toolCalls: ToolCallRecord[]): TemplateResult | typeof nothing {
369
+ /**
370
+ * A running import, under the chip that started it.
371
+ *
372
+ * An import takes minutes and says a line at a time. Rendering it here rather than in the modal it
373
+ * used to live in is what stops a successful run destroying its own account of what it did: the
374
+ * chip and the progress are one thing, joined by the tool-call id.
375
+ */
376
+ function renderImportProgress(record: ImportRunRecord): TemplateResult {
377
+ const running = record.status === "running";
378
+ const determinate = record.total !== null && record.current !== null;
379
+ const outcome =
380
+ record.status === "done"
381
+ ? `Imported ${record.url}`
382
+ : record.status === "failed"
383
+ ? `Import failed — ${record.error || "the run did not say why"}`
384
+ : record.status === "stopped"
385
+ ? "Import stopped"
386
+ : record.message;
387
+ return html`
388
+ <details class="ai-import-progress" ?open=${running}>
389
+ <summary class="ai-import-head">
390
+ ${
391
+ running
392
+ ? determinate
393
+ ? html`<sp-progress-circle
394
+ size="s"
395
+ progress=${Math.round((record.current! / Math.max(record.total!, 1)) * 100)}
396
+ ></sp-progress-circle>`
397
+ : html`<sp-progress-circle indeterminate size="s"></sp-progress-circle>`
398
+ : nothing
399
+ }
400
+ ${running ? html`<span class="ai-import-phase">${record.phase}</span>` : nothing}
401
+ <span class="ai-import-message">${outcome}</span>
402
+ <span class="ai-import-count">${record.log.length}</span>
403
+ </summary>
404
+ <div class="ai-import-log" ${ref(stickToBottom)}>
405
+ ${repeat(
406
+ record.log,
407
+ (evt, index) => `${index}:${evt.phase}`,
408
+ (evt) => html`
409
+ <div class="ai-import-log-line">
410
+ <span class="ai-import-phase">${evt.phase}</span>${evt.message}
411
+ </div>
412
+ `,
413
+ )}
414
+ </div>
415
+ </details>
416
+ `;
417
+ }
418
+
419
+ /** How a question card reaches the store that resolves it. */
420
+ export interface AskHandlers {
421
+ /** The id of the question the loop is still waiting on, or null. */
422
+ pendingId?: string | null;
423
+ onAnswer?: (text: string) => void;
424
+ onSkip?: () => void;
425
+ /** The live record for an `import_site` call, if this renderer's host has one. */
426
+ importRun?: (id: string) => ImportRunRecord | null;
427
+ }
428
+
429
+ /**
430
+ * A question, as the last thing in the transcript, waiting on the reader.
431
+ *
432
+ * The card IS the tool chip rather than a row beside it: the question, its options and its answer
433
+ * are all facts about one tool call, and rendering them anywhere else would give the transcript two
434
+ * accounts of the same event that can disagree after a reload.
435
+ *
436
+ * The options are a shortcut, never the whole answer — the composer is always live beneath, and its
437
+ * placeholder says so. A question whose real answer is "neither, do this instead" must stay
438
+ * answerable.
439
+ */
440
+ function renderAskCard(
441
+ tc: ToolCallRecord,
442
+ ask: AskArgs,
443
+ outcome: ReturnType<typeof toolOutcome>,
444
+ handlers: AskHandlers,
445
+ ): TemplateResult {
446
+ const answered = tc.result?.success
447
+ ? ((tc.result.data as { answer?: string | null; skipped?: boolean } | undefined) ?? null)
448
+ : null;
449
+ return html`
450
+ <div class="ai-ask" data-outcome=${outcome}>
451
+ <div class="ai-ask-question">${ask.question}</div>
452
+ ${ask.context ? html`<div class="ai-ask-context">${ask.context}</div>` : nothing}
453
+ ${
454
+ outcome === "pending"
455
+ ? html`
456
+ <div class="ai-ask-options">
457
+ ${ask.options.map(
458
+ (option) => html`
459
+ <sp-button
460
+ size="s"
461
+ variant="secondary"
462
+ treatment="outline"
463
+ @click=${() => handlers.onAnswer?.(option)}
464
+ >
465
+ ${option}
466
+ </sp-button>
467
+ `,
468
+ )}
469
+ <sp-action-button
470
+ size="s"
471
+ quiet
472
+ class="ai-ask-skip"
473
+ title="Let the assistant decide"
474
+ @click=${() => handlers.onSkip?.()}
475
+ >
476
+ You decide
477
+ </sp-action-button>
478
+ </div>
479
+ `
480
+ : nothing
481
+ }
482
+ ${
483
+ outcome === "unanswered"
484
+ ? html`<div class="ai-ask-outcome">
485
+ This question was still open when the session was reloaded — reply below to carry on.
486
+ </div>`
487
+ : nothing
488
+ }
489
+ ${
490
+ answered
491
+ ? html`<div class="ai-ask-answer">
492
+ ${answered.skipped ? "You decide" : answered.answer}
493
+ </div>`
494
+ : nothing
495
+ }
496
+ ${
497
+ outcome === "failed"
498
+ ? html`<div class="ai-ask-outcome">${toolOutcomeText(tc)}</div>`
499
+ : nothing
500
+ }
501
+ </div>
502
+ `;
503
+ }
504
+
505
+ function renderToolChips(
506
+ toolCalls: ToolCallRecord[],
507
+ handlers: AskHandlers = {},
508
+ ): TemplateResult | typeof nothing {
290
509
  if (toolCalls.length === 0) {
291
510
  return nothing;
292
511
  }
293
512
  return html`
294
513
  <div class="ai-msg-tools">
295
514
  ${toolCalls.map((tc) => {
296
- const outcome = toolOutcome(tc);
515
+ const ask = tc.name === ASK_TOOL ? parseAsk(tc) : null;
516
+ const outcome = toolOutcome(tc, tc.id === handlers.pendingId || Boolean(tc.result));
517
+ if (ask) {
518
+ return renderAskCard(tc, ask, outcome, handlers);
519
+ }
297
520
  const text = toolOutcomeText(tc);
521
+ /*
522
+ * For EVERY import chip, not only a running one.
523
+ *
524
+ * A finished run used to lose its whole account of itself at the instant it finished — the
525
+ * record was fetched only while the call was pending, so the log vanished on success. That
526
+ * is the failure the hand-off from the wizard to the assistant was made to fix, reproduced
527
+ * one layer in. It stays, collapsed, under the chip that produced it.
528
+ */
529
+ const run = tc.name === IMPORT_TOOL ? (handlers.importRun?.(tc.id) ?? null) : null;
298
530
  return html`
299
531
  <span class="ai-tool-chip" data-outcome=${outcome} title=${text || formatToolLabel(tc)}>
300
532
  <sp-icon-gears size="xs"></sp-icon-gears>
301
533
  <span class="ai-tool-chip-name">${formatToolLabel(tc)}</span>
302
534
  ${
303
- outcome === "pending"
535
+ outcome === "pending" || outcome === "unanswered"
304
536
  ? nothing
305
537
  : html`<span class="ai-tool-chip-outcome"
306
538
  >${outcome === "ok" ? "✓" : "✗"} ${text}</span
307
539
  >`
308
540
  }
309
541
  </span>
542
+ ${run ? renderImportProgress(run) : nothing}
310
543
  `;
311
544
  })}
312
545
  </div>
@@ -373,6 +606,7 @@ function renderChangedFiles(
373
606
  function renderAssistantMessage(
374
607
  msg: Message,
375
608
  onRestore?: (messageId: string) => void,
609
+ ask: AskHandlers = {},
376
610
  ): TemplateResult | typeof nothing {
377
611
  const toolCalls = msg.toolCalls ?? [];
378
612
  if (!msg.content && toolCalls.length === 0) {
@@ -380,8 +614,8 @@ function renderAssistantMessage(
380
614
  }
381
615
  return html`
382
616
  <div class="ai-msg-assistant">
383
- ${msg.content ? renderMarkdown(msg.id, msg.content) : nothing} ${renderToolChips(toolCalls)}
384
- ${renderChangedFiles(msg, onRestore)}
617
+ ${msg.content ? renderMarkdown(msg.id, msg.content) : nothing}
618
+ ${renderToolChips(toolCalls, ask)} ${renderChangedFiles(msg, onRestore)}
385
619
  </div>
386
620
  `;
387
621
  }
@@ -396,7 +630,7 @@ function renderToolMessage(msg: Message): TemplateResult | typeof nothing {
396
630
  return html`<div class="ai-msg-tool-error">⚠️ ${parsed.error || "Tool call failed"}</div>`;
397
631
  }
398
632
 
399
- function renderStreamingTail(msg: Message): TemplateResult {
633
+ function renderStreamingTail(msg: Message, ask: AskHandlers = {}): TemplateResult {
400
634
  if (!msg.content) {
401
635
  return html`
402
636
  <div class="ai-msg-typing">
@@ -409,7 +643,7 @@ function renderStreamingTail(msg: Message): TemplateResult {
409
643
  return html`
410
644
  <div class="ai-msg-assistant">
411
645
  <span class="ai-msg-streaming">${msg.content}</span>
412
- ${renderToolChips(msg.toolCalls ?? [])}
646
+ ${renderToolChips(msg.toolCalls ?? [], ask)}
413
647
  </div>
414
648
  `;
415
649
  }
@@ -426,11 +660,19 @@ export interface MessageListOptions {
426
660
  listRef: (el: Element | undefined) => void;
427
661
  /** Undo everything one turn changed. Offered only for turns whose changes are all transactional. */
428
662
  onRestore?: ((messageId: string) => void) | undefined;
663
+ /**
664
+ * The outstanding question and how to settle it.
665
+ *
666
+ * Passed down rather than read from the store here, because this module renders templates and
667
+ * holds no state — the same reason the three header buttons are command records.
668
+ */
669
+ ask?: AskHandlers | undefined;
429
670
  }
430
671
 
431
672
  /** The scrollable message list — THE scroller of the chat view. */
432
673
  export function renderMessageList(opts: MessageListOptions): TemplateResult {
433
674
  const { messages, status } = opts;
675
+ const ask = opts.ask ?? {};
434
676
  const lastIdx = messages.length - 1;
435
677
  return html`
436
678
  <div class="ai-chat-messages" ${ref(opts.listRef)} @scroll=${opts.onScroll}>
@@ -462,9 +704,9 @@ export function renderMessageList(opts: MessageListOptions): TemplateResult {
462
704
  }
463
705
  if (msg.role === "assistant") {
464
706
  if (status === "streaming" && i === lastIdx) {
465
- return renderStreamingTail(msg);
707
+ return renderStreamingTail(msg, ask);
466
708
  }
467
- return renderAssistantMessage(msg, opts.onRestore);
709
+ return renderAssistantMessage(msg, opts.onRestore, ask);
468
710
  }
469
711
  return nothing;
470
712
  },
@@ -17,26 +17,17 @@
17
17
  import { html, nothing } from "lit-html";
18
18
  import { displayTagName } from "@jxsuite/schema/guards";
19
19
  import type { TemplateResult } from "lit-html";
20
- import { live } from "lit-html/directives/live.js";
21
20
  import { ref } from "lit-html/directives/ref.js";
22
21
  import { getNodeAtPath } from "../../state";
23
- import {
24
- aiConnection,
25
- cachedModels,
26
- fetchAvailableModels,
27
- preferredModel,
28
- } from "../../services/ai-models";
29
- import { setModel } from "../../services/ai-settings";
22
+ import { createModelPicker } from "../../ui/ai-model-picker";
30
23
  import { activeTab } from "../../workspace/workspace";
31
24
  import { primarySelection } from "../../tabs/selection";
32
25
  import { buildMessageWithContext } from "./attached-context";
33
26
  import type { ContextChip } from "./attached-context";
34
- import type { AiCredentials } from "../../services/ai-models";
35
27
  import type { JxMutableNode } from "@jxsuite/schema/types";
36
28
 
37
29
  /** Tallest the textarea auto-grows before it scrolls internally. */
38
30
  const MAX_INPUT_HEIGHT = 120;
39
- const RETRY_MODELS = "__retry_models__";
40
31
 
41
32
  export interface ComposerOptions {
42
33
  /** Receives the full message content (typed text + serialized context). */
@@ -45,6 +36,13 @@ export interface ComposerOptions {
45
36
  /** Opens the credentials form. */
46
37
  onOpenSettings: () => void;
47
38
  isStreaming: () => boolean;
39
+ /**
40
+ * Whether the turn is suspended on a question. The composer becomes the answer field: Enter still
41
+ * sends, but the host routes it to the pending question instead of opening a new turn.
42
+ *
43
+ * Optional so the evals harness and tests that predate `ask_user` keep compiling.
44
+ */
45
+ isAwaiting?: () => boolean;
48
46
  /** Host re-render scheduler — called whenever composer state changes. */
49
47
  requestRender: () => void;
50
48
  }
@@ -76,89 +74,10 @@ export function createComposer(opts: ComposerOptions): Composer {
76
74
  let hasText = false;
77
75
  let chips: ContextChip[] = [];
78
76
 
79
- /** Null until the first fetch settles; kept on error so the picker can offer Retry. */
80
- let modelsLoading = false;
81
- let modelsError = "";
82
-
83
- /**
84
- * The connection the last fetch was made for, so a failure is not retried on every render.
85
- *
86
- * The list itself is NOT held here. A private copy is how the picker came to show one provider's
87
- * catalogue while another was configured: it was filled once and never reconsidered. Reading it
88
- * from `cachedModels(connection)` means a list can only ever be shown for the credentials it was
89
- * listed under, and a credential change makes it unavailable rather than stale.
90
- */
91
- let attempted: AiCredentials | null = null;
92
-
93
- // ── Model picker ──────────────────────────────────────────────────────
94
-
95
- function sameConnection(a: AiCredentials | null, b: AiCredentials): boolean {
96
- return a !== null && a.apiKey === b.apiKey && a.baseUrl === b.baseUrl;
97
- }
98
-
99
- function ensureModels(force = false) {
100
- const credentials = aiConnection();
101
- const settled = cachedModels(credentials) !== null || sameConnection(attempted, credentials);
102
- if (modelsLoading || (settled && !force)) {
103
- return;
104
- }
105
- modelsLoading = true;
106
- modelsError = "";
107
- attempted = credentials;
108
- fetchAvailableModels({ credentials, force })
109
- .catch((error: unknown) => {
110
- modelsError = (error as Error).message || "Failed to fetch models";
111
- })
112
- .finally(() => {
113
- modelsLoading = false;
114
- opts.requestRender();
115
- });
116
- }
117
-
118
- function onModelChange(e: Event) {
119
- const { value } = e.target as HTMLInputElement;
120
- if (value === RETRY_MODELS) {
121
- attempted = null;
122
- ensureModels(true);
123
- // Re-render so the picker snaps back to the stored model instead of "Retry".
124
- opts.requestRender();
125
- return;
126
- }
127
- if (value) {
128
- setModel(value);
129
- }
130
- }
131
-
132
- function renderModelPicker(): TemplateResult {
133
- ensureModels();
134
- const current = preferredModel();
135
- const listed = cachedModels(aiConnection()) ?? [];
136
- const items = listed.some((m) => m.id === current)
137
- ? listed
138
- : [{ id: current, name: current }, ...listed];
139
- return html`
140
- <sp-picker
141
- size="s"
142
- quiet
143
- class="ai-model-picker"
144
- title=${modelsError ? `Couldn't load models: ${modelsError}` : "Model"}
145
- .value=${live(current)}
146
- @change=${onModelChange}
147
- >
148
- ${items.map((m) => html`<sp-menu-item value=${m.id}>${m.name}</sp-menu-item>`)}
149
- ${
150
- modelsLoading
151
- ? html`<sp-menu-item disabled value="__loading__">Loading models…</sp-menu-item>`
152
- : nothing
153
- }
154
- ${
155
- modelsError
156
- ? html`<sp-menu-item value=${RETRY_MODELS}>Retry loading models</sp-menu-item>`
157
- : nothing
158
- }
159
- </sp-picker>
160
- `;
161
- }
77
+ /* The model picker is `ui/ai-model-picker.ts` now, not forty lines here: the New Project Import
78
+ source chooses a model too, and the "never hold the list privately" invariant is the kind that
79
+ only breaks in the copy nobody looked at. */
80
+ const modelPicker = createModelPicker({ requestRender: opts.requestRender });
162
81
 
163
82
  // ── Context attach ────────────────────────────────────────────────────
164
83
 
@@ -317,13 +236,18 @@ export function createComposer(opts: ComposerOptions): Composer {
317
236
 
318
237
  function render(): TemplateResult {
319
238
  const streaming = opts.isStreaming();
239
+ const awaiting = opts.isAwaiting?.() ?? false;
320
240
  return html`
321
- <div class="ai-composer">
241
+ <div class="ai-composer" ?data-awaiting=${awaiting}>
322
242
  ${renderChips()}
323
243
  <textarea
324
244
  class="ai-composer-input"
325
245
  rows="1"
326
- placeholder="Ask the assistant… (Enter to send)"
246
+ placeholder=${
247
+ awaiting
248
+ ? "Answer the assistant… (Enter to reply)"
249
+ : "Ask the assistant… (Enter to send)"
250
+ }
327
251
  ${ref((el) => {
328
252
  textareaEl = (el as HTMLTextAreaElement | null) || null;
329
253
  })}
@@ -331,7 +255,7 @@ export function createComposer(opts: ComposerOptions): Composer {
331
255
  @keydown=${onKeydown}
332
256
  ></textarea>
333
257
  <div class="ai-composer-row">
334
- ${renderAttachMenu()} ${renderModelPicker()}
258
+ ${renderAttachMenu()} ${modelPicker.render()}
335
259
  <span class="ai-header-spacer"></span>
336
260
  <sp-action-button size="s" quiet title="API key & endpoint" @click=${opts.onOpenSettings}>
337
261
  <sp-icon-settings slot="icon"></sp-icon-settings>
@@ -347,7 +271,7 @@ export function createComposer(opts: ComposerOptions): Composer {
347
271
  <sp-action-button
348
272
  size="s"
349
273
  class="ai-send-btn"
350
- title="Send"
274
+ title=${awaiting ? "Answer" : "Send"}
351
275
  ?disabled=${!hasText}
352
276
  @click=${trySend}
353
277
  >