@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
@@ -19,16 +19,15 @@
19
19
  */
20
20
 
21
21
  import { html, nothing } from "lit-html";
22
+ import { live } from "lit-html/directives/live.js";
22
23
  import type { TemplateResult } from "lit-html";
23
- import { fetchAvailableModels, invalidateModelCache } from "../services/ai-models";
24
+ import { fetchAvailableModels } from "../services/ai-models";
24
25
  import {
25
26
  getBaseUrl,
26
- getModel,
27
27
  getOpenAiKey,
28
28
  hasOpenAiKey,
29
- setBaseUrl,
30
- setModel,
31
- setOpenAiKey,
29
+ saveAiProvider,
30
+ storedModel,
32
31
  } from "../services/ai-settings";
33
32
 
34
33
  export interface AiCredentialsFormOptions {
@@ -64,11 +63,23 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
64
63
  let modelsLoading = false;
65
64
  let modelsError = "";
66
65
 
67
- /** Open the form pre-filled with the current settings, and load the model list. */
68
- function startEdit() {
66
+ /**
67
+ * Load the drafts from what is stored.
68
+ *
69
+ * The model comes from {@link storedModel} rather than `getModel()`, so a user who has never
70
+ * picked one drafts an empty field instead of the `"gpt-4o"` default. Saving a prefilled default
71
+ * writes a choice nobody made, and `jx.ai.model: "gpt-4o"` is exactly what a broken install was
72
+ * left holding.
73
+ */
74
+ function loadDrafts() {
69
75
  keyDraft = getOpenAiKey();
70
76
  baseUrlDraft = getBaseUrl();
71
- modelDraft = getModel();
77
+ modelDraft = storedModel();
78
+ }
79
+
80
+ /** Open the form pre-filled with the current settings, and load the model list. */
81
+ function startEdit() {
82
+ loadDrafts();
72
83
  opts.requestRender();
73
84
  // Auto-fetch available models if not already loaded.
74
85
  if (availableModels.length === 0 && !modelsLoading) {
@@ -76,26 +87,33 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
76
87
  }
77
88
  }
78
89
 
79
- /** Persist the drafted key + endpoint + model and notify the host. */
90
+ /**
91
+ * Persist the drafted key + endpoint + model and notify the host.
92
+ *
93
+ * **Re-seeds the drafts; it must never blank them.** Preferences is a place rather than a wizard
94
+ * step, so the sheet stays open across Save and `startEdit` is not called again — which meant
95
+ * every field emptied the instant a save succeeded. That reads as "it didn't take", and the
96
+ * obvious response to it destroyed the credentials: a blank draft is what
97
+ * `setOpenAiKey`/`setBaseUrl` treat as _clear_, so pressing Save a second time on the emptied
98
+ * form deleted the key and endpoint that the first press had just stored. Reading back through
99
+ * the getters also shows what was actually kept — trimmed, and with the endpoint's trailing slash
100
+ * stripped — rather than what was typed.
101
+ */
80
102
  function save() {
81
- setOpenAiKey(keyDraft);
82
- setBaseUrl(baseUrlDraft);
83
- setModel(modelDraft);
84
- keyDraft = "";
85
- baseUrlDraft = "";
86
- modelDraft = "";
87
- // Clear fetched models so they're re-fetched with the new credentials next time.
88
- availableModels = [];
89
- invalidateModelCache();
103
+ saveAiProvider({ apiKey: keyDraft, baseUrl: baseUrlDraft, model: modelDraft });
104
+ loadDrafts();
105
+ modelsError = "";
106
+ /* The fetched list stays: it was listed under exactly these credentials, and dropping it
107
+ collapsed the model combobox back to a free-text field on every save. The module cache is
108
+ dropped by ai-models' own settings subscription, so this form does not have to remember to. */
90
109
  opts.onSaved?.();
91
110
  opts.requestRender();
92
111
  }
93
112
 
94
113
  /** Dismiss the form without saving (only offered when a key already exists). */
95
114
  function cancel() {
96
- keyDraft = "";
97
- baseUrlDraft = "";
98
- modelDraft = "";
115
+ loadDrafts();
116
+ modelsError = "";
99
117
  opts.onCancel?.();
100
118
  opts.requestRender();
101
119
  }
@@ -104,6 +122,11 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
104
122
  * Fetch available models via src/services/ai-models.ts, preferring the in-form drafts over the
105
123
  * stored settings so the list reflects the credentials being edited. Always forces past the
106
124
  * module cache — this runs on explicit user action (or first open) with possibly-new drafts.
125
+ *
126
+ * Both lines below read draft-first. The key used to read stored-first, against this paragraph
127
+ * and against the line beside it: editing a key in place and pressing Fetch models listed the OLD
128
+ * key's models, and — worse — a form whose drafts had been blanked still fetched successfully
129
+ * from storage, which is what made an emptied form look like it was working.
107
130
  */
108
131
  async function fetchModels() {
109
132
  modelsLoading = true;
@@ -111,8 +134,10 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
111
134
  opts.requestRender();
112
135
  try {
113
136
  availableModels = await fetchAvailableModels({
114
- apiKey: getOpenAiKey() || keyDraft,
115
- baseUrl: baseUrlDraft || getBaseUrl(),
137
+ credentials: {
138
+ apiKey: keyDraft || getOpenAiKey(),
139
+ baseUrl: baseUrlDraft || getBaseUrl(),
140
+ },
116
141
  force: true,
117
142
  });
118
143
  } catch (error: unknown) {
@@ -144,7 +169,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
144
169
  type="password"
145
170
  size="s"
146
171
  placeholder="sk-… or any compatible key"
147
- .value=${keyDraft}
172
+ .value=${live(keyDraft)}
148
173
  @input=${(e: Event) => {
149
174
  keyDraft = (e.target as HTMLInputElement).value;
150
175
  }}
@@ -157,7 +182,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
157
182
  class="ai-creds-field"
158
183
  size="s"
159
184
  allows-custom-value
160
- .value=${modelDraft}
185
+ .value=${live(modelDraft)}
161
186
  @change=${(e: Event) => {
162
187
  modelDraft = (e.target as HTMLInputElement).value;
163
188
  }}
@@ -175,7 +200,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
175
200
  class="ai-creds-field"
176
201
  size="s"
177
202
  placeholder="Model ID (e.g. gpt-4o, claude-sonnet-4-20250514, etc.)"
178
- .value=${modelDraft}
203
+ .value=${live(modelDraft)}
179
204
  @input=${(e: Event) => {
180
205
  modelDraft = (e.target as HTMLInputElement).value;
181
206
  }}
@@ -198,7 +223,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
198
223
  class="ai-creds-field"
199
224
  size="s"
200
225
  placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
201
- .value=${baseUrlDraft}
226
+ .value=${live(baseUrlDraft)}
202
227
  @input=${(e: Event) => {
203
228
  baseUrlDraft = (e.target as HTMLInputElement).value;
204
229
  }}
@@ -0,0 +1,162 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-model-picker.ts — the provider's model catalogue as one `sp-picker`, for every surface that
4
+ * chooses a model.
5
+ *
6
+ * It was private to `panels/ai-chat/composer.ts`, which was correct while the chat composer was the
7
+ * only place a model could be chosen. The New Project **Import** source now chooses one too — and
8
+ * that run spends more tokens than any single chat turn — so the choice needed a second host. A
9
+ * copy would have been two implementations of the invariant below, which is exactly the kind that
10
+ * only breaks in the copy nobody looked at.
11
+ *
12
+ * **The list is never held privately.** It is read from `cachedModels(credentials)` on every
13
+ * render, so a list can only ever be shown for the credentials it was listed under: change the key
14
+ * and the catalogue becomes UNAVAILABLE rather than stale. Holding it in a closure is what once let
15
+ * the picker offer one provider's models while another was configured.
16
+ *
17
+ * A closure factory (precedent: `createComposer`, `createAiCredentialsForm`) so two hosts never
18
+ * share a loading flag or a failed-fetch record.
19
+ *
20
+ * @license MIT
21
+ */
22
+
23
+ import { html, nothing } from "lit-html";
24
+ import { live } from "lit-html/directives/live.js";
25
+ import {
26
+ aiConnection,
27
+ cachedModels,
28
+ fetchAvailableModels,
29
+ preferredModel,
30
+ } from "../services/ai-models";
31
+ import { setModel } from "../services/ai-settings";
32
+
33
+ import type { TemplateResult } from "lit-html";
34
+ import type { AiCredentials } from "../services/ai-models";
35
+
36
+ /** The `sp-menu-item` value that means "try listing again" rather than "select this model". */
37
+ export const RETRY_MODELS = "__retry_models__";
38
+
39
+ /** The `sp-menu-item` value of the disabled placeholder shown during the first fetch. */
40
+ export const LOADING_MODELS = "__loading__";
41
+
42
+ export interface ModelPickerOptions {
43
+ /** Host re-render scheduler — called whenever a fetch settles. */
44
+ requestRender: () => void;
45
+ /**
46
+ * The model to show as selected. Defaults to the stored/proxy-preferred one, which is what the
47
+ * chat composer wants; the Import form passes its own draft, which is not a stored setting until
48
+ * the run starts.
49
+ */
50
+ getModel?: () => string;
51
+ /**
52
+ * Where a choice goes. Defaults to `setModel` (the roaming application preference). The Import
53
+ * form passes its own setter, because choosing a model for one import must not silently retarget
54
+ * the assistant.
55
+ */
56
+ onChange?: (id: string) => void;
57
+ /** Extra class on the `sp-picker`, so a host can size it without touching this module. */
58
+ className?: string;
59
+ /** Picker size; `"s"` (the composer's) by default. */
60
+ size?: "s" | "m";
61
+ }
62
+
63
+ export interface ModelPicker {
64
+ render: () => TemplateResult;
65
+ /** Whether a fetch is in flight — for a host that wants to disable a submit button. */
66
+ isLoading: () => boolean;
67
+ /** The last fetch's failure, or `""`. */
68
+ error: () => string;
69
+ }
70
+
71
+ /**
72
+ * Create a model picker bound to a host's render scheduler.
73
+ *
74
+ * @param {ModelPickerOptions} opts
75
+ * @returns {ModelPicker}
76
+ */
77
+ export function createModelPicker(opts: ModelPickerOptions): ModelPicker {
78
+ const getModel = opts.getModel ?? preferredModel;
79
+ const onChange = opts.onChange ?? setModel;
80
+
81
+ let loading = false;
82
+ let failure = "";
83
+
84
+ /**
85
+ * The credentials the last fetch was made FOR, so a failure is not retried on every render.
86
+ *
87
+ * The list itself is deliberately not held here — see the module docblock.
88
+ */
89
+ let attempted: AiCredentials | null = null;
90
+
91
+ function sameConnection(a: AiCredentials | null, b: AiCredentials): boolean {
92
+ return a !== null && a.apiKey === b.apiKey && a.baseUrl === b.baseUrl;
93
+ }
94
+
95
+ function ensureModels(force = false) {
96
+ const credentials = aiConnection();
97
+ const settled = cachedModels(credentials) !== null || sameConnection(attempted, credentials);
98
+ if (loading || (settled && !force)) {
99
+ return;
100
+ }
101
+ loading = true;
102
+ failure = "";
103
+ attempted = credentials;
104
+ fetchAvailableModels({ credentials, force })
105
+ .catch((error: unknown) => {
106
+ failure = (error as Error).message || "Failed to fetch models";
107
+ })
108
+ .finally(() => {
109
+ loading = false;
110
+ opts.requestRender();
111
+ });
112
+ }
113
+
114
+ function onPickerChange(e: Event) {
115
+ const { value } = e.target as HTMLInputElement;
116
+ if (value === RETRY_MODELS) {
117
+ attempted = null;
118
+ ensureModels(true);
119
+ // Re-render so the picker snaps back to the current model instead of showing "Retry".
120
+ opts.requestRender();
121
+ return;
122
+ }
123
+ if (value && value !== LOADING_MODELS) {
124
+ onChange(value);
125
+ }
126
+ }
127
+
128
+ function render(): TemplateResult {
129
+ ensureModels();
130
+ const current = getModel();
131
+ const listed = cachedModels(aiConnection()) ?? [];
132
+ /* A catalogue that does not contain the current model still has to show it selected — an
133
+ unlisted id is the normal case for a self-hosted or newly released model. */
134
+ const items = listed.some((m) => m.id === current)
135
+ ? listed
136
+ : [{ id: current, name: current }, ...listed];
137
+ return html`
138
+ <sp-picker
139
+ size=${opts.size ?? "s"}
140
+ quiet
141
+ class=${opts.className ?? "ai-model-picker"}
142
+ title=${failure ? `Couldn't load models: ${failure}` : "Model"}
143
+ .value=${live(current)}
144
+ @change=${onPickerChange}
145
+ >
146
+ ${items.map((m) => html`<sp-menu-item value=${m.id}>${m.name}</sp-menu-item>`)}
147
+ ${
148
+ loading
149
+ ? html`<sp-menu-item disabled value=${LOADING_MODELS}>Loading models…</sp-menu-item>`
150
+ : nothing
151
+ }
152
+ ${
153
+ failure
154
+ ? html`<sp-menu-item value=${RETRY_MODELS}>Retry loading models</sp-menu-item>`
155
+ : nothing
156
+ }
157
+ </sp-picker>
158
+ `;
159
+ }
160
+
161
+ return { error: () => failure, isLoading: () => loading, render };
162
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `choiceField` — one labelled choice, rendered the way every Spectrum picker in Studio is.
3
+ *
4
+ * It exists so the New File dialog's format picker and the Convert dialog's target picker are the
5
+ * SAME control rather than two that drift: same divider idiom for a sentinel row, same `live()`
6
+ * guard, same "one kind is not a choice" collapse to static text.
7
+ *
8
+ * **It imports lit only.** Both callers live in modules the other imports — `ui/layers.ts` is
9
+ * imported by `format/convert-file.ts`, which also wants this fragment — so a shared control that
10
+ * reached into either would be a cycle, and `import/no-cycle` is an error here.
11
+ *
12
+ * `.value=${live(...)}` is not stylistic. `sp-picker` writes its own `value` when the reader picks
13
+ * a row, so lit's dirty check sees the value it last committed and skips the re-commit that would
14
+ * put the control back where the model says it is. `scripts/check-lit-conventions.ts` fails on the
15
+ * unguarded form.
16
+ */
17
+
18
+ import { html, nothing } from "lit-html";
19
+ import { live } from "lit-html/directives/live.js";
20
+ import type { TemplateResult } from "lit-html";
21
+
22
+ /** One row of a {@link choiceField}. */
23
+ export interface ChoiceOption {
24
+ value: string;
25
+ label: string;
26
+ /** Draw an `sp-menu-divider` immediately before this row — how a sentinel row is set apart. */
27
+ dividerBefore?: boolean;
28
+ }
29
+
30
+ /** What a {@link choiceField} renders and reports. */
31
+ export interface ChoiceSpec {
32
+ label: string;
33
+ options: readonly ChoiceOption[];
34
+ value: string;
35
+ onChange: (next: string) => void;
36
+ }
37
+
38
+ /**
39
+ * A labelled picker, or static text when there is nothing to choose.
40
+ *
41
+ * The collapse is the rule `panels/pane-context.ts` already states about editor kinds: **one kind
42
+ * is not a choice**. A picker whose menu holds a single row is a control that cannot do anything,
43
+ * and drawing it invites the reader to open it and find out.
44
+ *
45
+ * @param spec - The label, the rows, the current value and the change sink.
46
+ * @returns The fragment, for a caller's own template.
47
+ */
48
+ export function choiceField(spec: ChoiceSpec): TemplateResult {
49
+ const current = spec.options.find((option) => option.value === spec.value);
50
+ if (spec.options.length < 2) {
51
+ return html`<div class="choice-row">
52
+ <sp-field-label size="s">${spec.label}</sp-field-label>
53
+ <span class="choice-static">${current?.label ?? spec.value}</span>
54
+ </div>`;
55
+ }
56
+ return html`<div class="choice-row">
57
+ <sp-field-label size="s">${spec.label}</sp-field-label>
58
+ <sp-picker
59
+ size="s"
60
+ label=${spec.label}
61
+ .value=${live(spec.value)}
62
+ @change=${(e: Event) => spec.onChange((e.target as HTMLInputElement).value)}
63
+ >
64
+ ${spec.options.map(
65
+ (option) =>
66
+ html`${
67
+ option.dividerBefore === true ? html`<sp-menu-divider></sp-menu-divider>` : nothing
68
+ }<sp-menu-item value=${option.value}>${option.label}</sp-menu-item>`,
69
+ )}
70
+ </sp-picker>
71
+ </div>`;
72
+ }
package/src/ui/layers.ts CHANGED
@@ -15,10 +15,12 @@
15
15
  import { html, render as litRender, nothing } from "lit-html";
16
16
  import { ref } from "lit-html/directives/ref.js";
17
17
  import { repeat } from "lit-html/directives/repeat.js";
18
+ import { choiceField } from "./choice-field";
18
19
  import { overlayRegion, REGION_ATTR } from "./regions";
19
20
  import { dismiss, toasts } from "../services/notify";
20
21
  import { activeRegistry } from "../commands/active-registry";
21
22
  import { effect, effectScope } from "../reactivity";
23
+ import type { ChoiceOption } from "./choice-field";
22
24
  import type { Notification, Severity } from "../services/notify";
23
25
  import type { EffectScope } from "@vue/reactivity";
24
26
  import type { TemplateResult } from "lit-html";
@@ -338,6 +340,31 @@ export function showSaveDiscardDialog(
338
340
  );
339
341
  }
340
342
 
343
+ /**
344
+ * A picker rendered above the field, whose selection the dialog OWNS.
345
+ *
346
+ * Owned, not merely displayed: the choice reaches `validate` as its second argument, and picking a
347
+ * row re-runs it. That is the whole reason this lives here rather than being smuggled in through
348
+ * `message` — `check` and `rerender` are private to {@link showPromptDialog}, so a caller-built
349
+ * control could change the selection but could never make the field's refusal catch up with it. The
350
+ * New File dialog's format picker is exactly that case: switching from Markdown to JSON changes
351
+ * whether the typed name is already taken, with no keystroke to notice.
352
+ */
353
+ export interface PromptChoice {
354
+ /** Label above the picker. */
355
+ label: string;
356
+ /**
357
+ * The rows, re-read on EVERY render rather than captured once. A snapshot taken when the options
358
+ * object is built freezes a list the project can still be loading — the same defect
359
+ * `content/entry-commands.ts`'s `derivedEnumProperty` exists to prevent one layer up.
360
+ */
361
+ options: () => readonly ChoiceOption[];
362
+ /** Which row starts selected. */
363
+ initial: string;
364
+ /** Told what was picked, for a caller keeping its own derived state. */
365
+ onChange?: (next: string) => void;
366
+ }
367
+
341
368
  /** Options accepted by {@link showPromptDialog}. */
342
369
  export interface PromptDialogOptions {
343
370
  /** Label on the confirming button. */
@@ -346,17 +373,21 @@ export interface PromptDialogOptions {
346
373
  cancelLabel?: string;
347
374
  /** Explanatory copy rendered above the text field. */
348
375
  message?: string | TemplateResult;
349
- /** Placeholder shown while the field is empty. */
350
- placeholder?: string;
376
+ /** Placeholder shown while the field is empty. Read on every render, so it may follow the choice. */
377
+ placeholder?: string | (() => string);
351
378
  /** How much of the pre-filled value to select once the field takes focus. */
352
379
  select?: "all" | "stem" | "none";
353
380
  /**
354
381
  * Validate the raw field value. Return an empty string when valid, or a message to show as
355
382
  * negative help text (which also blocks confirmation). Defaults to "must not be blank".
383
+ *
384
+ * `chosen` is the {@link PromptChoice} selection, or `""` when the dialog offers no choice.
356
385
  */
357
- validate?: (value: string) => string;
386
+ validate?: (value: string, chosen: string) => string;
358
387
  /** Pre-filled value. */
359
388
  value?: string;
389
+ /** A picker above the field whose selection this dialog owns. */
390
+ choice?: PromptChoice;
360
391
  }
361
392
 
362
393
  /**
@@ -375,6 +406,7 @@ export function showPromptDialog(
375
406
  ): Promise<string | null> {
376
407
  const {
377
408
  cancelLabel = "Cancel",
409
+ choice,
378
410
  confirmLabel = "OK",
379
411
  message,
380
412
  placeholder = "",
@@ -384,12 +416,22 @@ export function showPromptDialog(
384
416
  } = opts;
385
417
 
386
418
  const check = (candidate: string) =>
387
- validate ? validate(candidate) : candidate.trim() ? "" : "Enter a value.";
419
+ validate ? validate(candidate, chosen) : candidate.trim() ? "" : "Enter a value.";
388
420
 
389
421
  let value = initialValue;
422
+ let chosen = choice?.initial ?? "";
390
423
  let error = "";
391
424
  let wrapperEl: HTMLElement | null = null;
392
425
  let focusRequested = false;
426
+ /*
427
+ * Whether the reader has typed yet.
428
+ *
429
+ * A dialog opens with a valid prefill and NO error, and picking a format must not be the thing
430
+ * that first paints one under a field nobody has touched — "untitled" is not yet a mistake. So a
431
+ * pick refreshes an error that is already showing and never mints the first one; the confirm
432
+ * still refuses, because `confirm()` runs `check` unconditionally.
433
+ */
434
+ let touched = false;
393
435
 
394
436
  return showDialog<string | null>((done) => {
395
437
  function rerender() {
@@ -411,6 +453,7 @@ export function showPromptDialog(
411
453
  }
412
454
 
413
455
  function onInput(e: Event) {
456
+ touched = true;
414
457
  value = (e.target as HTMLInputElement).value || "";
415
458
  const next = check(value);
416
459
  if (next !== error) {
@@ -419,6 +462,23 @@ export function showPromptDialog(
419
462
  }
420
463
  }
421
464
 
465
+ /**
466
+ * Take a pick, and re-render UNCONDITIONALLY.
467
+ *
468
+ * `onInput` re-renders only when the error string changed, which is right for a keystroke: the
469
+ * template it would rebuild is identical. A pick is not that. It can change the placeholder,
470
+ * the picker's own selected row, and — for the New File dialog — whether the composed filename
471
+ * is already taken, all with the error text unchanged. Comparing error strings here would leave
472
+ * a dialog showing "about.md already exists" after the reader switched the format to JSON.
473
+ */
474
+ function onPick(next: string) {
475
+ chosen = next;
476
+ choice?.onChange?.(next);
477
+ const candidate = check(value);
478
+ error = touched || error ? candidate : "";
479
+ rerender();
480
+ }
481
+
422
482
  function onKeydown(e: KeyboardEvent) {
423
483
  if (e.key === "Enter") {
424
484
  confirm();
@@ -472,9 +532,27 @@ export function showPromptDialog(
472
532
  // Spectrum resets <p> margins to 0, so without this the copy sits flush on the field.
473
533
  message ? html`<p style="margin:0 0 8px">${message}</p>` : nothing
474
534
  }
535
+ ${
536
+ /*
537
+ * Above the field, and in ONE template position in every mode.
538
+ *
539
+ * The `sp-textfield` below must never move or be rebuilt by a conditional branch: lit
540
+ * would commit a new element, `onFieldRef` would fire again, the `focusRequested` latch
541
+ * would refuse to re-focus it, and the caret would be stranded mid-name. Only the
542
+ * placeholder, the validation rules and the help text vary between modes.
543
+ */
544
+ choice
545
+ ? choiceField({
546
+ label: choice.label,
547
+ onChange: onPick,
548
+ options: choice.options(),
549
+ value: chosen,
550
+ })
551
+ : nothing
552
+ }
475
553
  <sp-textfield
476
554
  style="width:100%"
477
- placeholder=${placeholder}
555
+ placeholder=${typeof placeholder === "function" ? placeholder() : placeholder}
478
556
  value=${value}
479
557
  ?invalid=${Boolean(error)}
480
558
  @input=${onInput}
@@ -26,12 +26,11 @@ import { debouncedStyleCommit, renderOnly } from "../store";
26
26
  import { getLayerSlot, popoverLayerFor } from "./layers";
27
27
  import type { LayerKind } from "./layers";
28
28
  import { rectOf } from "../utils/geometry";
29
- import { loopbackAssetSrc } from "../canvas/canvas-origin";
30
- import { previewAssetSrc } from "../canvas/content-assets";
29
+ import { previewAssetSrc } from "../canvas/asset-refs";
31
30
  import {
32
31
  IMAGE_EXTENSIONS,
33
32
  MEDIA_EXTENSIONS,
34
- UPLOAD_ACCEPT,
33
+ uploadAccept,
35
34
  extensionOf,
36
35
  uploadAssets,
37
36
  } from "../files/media-upload";
@@ -42,7 +41,7 @@ import {
42
41
  recordImageSize,
43
42
  seedMediaMeta,
44
43
  } from "../files/media-meta";
45
- import { mediaSiteUrl } from "../files/media-paths";
44
+ import { mediaSiteUrl, previewFileSrc } from "../files/media-paths";
46
45
 
47
46
  // ─── Media file cache ────────────────────────────────────────────────────────
48
47
 
@@ -283,7 +282,7 @@ function renderMediaPickerPopover() {
283
282
  m.isImage
284
283
  ? html`<img
285
284
  slot="icon"
286
- src=${loopbackAssetSrc(m.path)}
285
+ src=${previewFileSrc(m.file)}
287
286
  alt=""
288
287
  style="width:24px;height:24px;object-fit:cover;border-radius:var(--spectrum-corner-radius-75, 2px)"
289
288
  @load=${(e: Event) => noteImageSize(m.file, e.target)}
@@ -394,7 +393,7 @@ function pickAndUpload(onCommit: (val: string) => void) {
394
393
  const input = document.createElement("input");
395
394
  input.type = "file";
396
395
  input.multiple = true;
397
- input.accept = UPLOAD_ACCEPT;
396
+ input.accept = uploadAccept();
398
397
  input.addEventListener("change", () => {
399
398
  if (input.files?.length) {
400
399
  void uploadAndAssign(input.files, onCommit);
@@ -422,11 +421,7 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
422
421
  <div class="media-picker">
423
422
  ${
424
423
  isImage && currentValue
425
- ? html`<img
426
- class="media-picker-thumb"
427
- src=${loopbackAssetSrc(previewAssetSrc(currentValue))}
428
- alt=""
429
- />`
424
+ ? html`<img class="media-picker-thumb" src=${previewAssetSrc(currentValue)} alt="" />`
430
425
  : nothing
431
426
  }
432
427
  <sp-textfield