@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
package/src/types.ts CHANGED
@@ -32,10 +32,12 @@ import type {
32
32
  GitLogEntry,
33
33
  GitStatusResult,
34
34
  ImportProgressEvent,
35
+ ImportReadyEvent,
35
36
  ImportSiteOptions,
36
- OutdatedInfo,
37
+ ImportSiteSummary,
37
38
  PackageInfo,
38
39
  PackageOpResult,
40
+ PackageVersionInfo,
39
41
  ProjectListEntry,
40
42
  ProjectSchemasResponse,
41
43
  RecentProjectEntry,
@@ -79,12 +81,15 @@ export type {
79
81
  GitFileStatus,
80
82
  GitLogEntry,
81
83
  GitStatusResult,
84
+ ImportBreakpointPolicy,
82
85
  ImportProgressEvent,
86
+ ImportReadyEvent,
83
87
  ImportSiteOptions,
88
+ ImportSiteSummary,
84
89
  JsonValue,
85
- OutdatedInfo,
86
90
  PackageInfo,
87
91
  PackageOpResult,
92
+ PackageVersionInfo,
88
93
  ProjectListEntry,
89
94
  ProjectSchemasResponse,
90
95
  PullRequestInfo,
@@ -149,6 +154,20 @@ export interface SiteBuildResult {
149
154
  routes: number;
150
155
  files: number;
151
156
  errors: string[];
157
+ /**
158
+ * How the backend produced what {@link url} serves.
159
+ *
160
+ * `"built"` is compiler output — what the published site will literally be. `"live"` is the
161
+ * working tree rendered as a site: the same pages at the same routes, assembled by the runtime in
162
+ * the reader's browser rather than prerendered, because the backend cannot run a build at all.
163
+ *
164
+ * The distinction is reported rather than hidden because the two differ in ways a reader would
165
+ * otherwise be misled by — a live preview has no prerendered HTML, no optimized images, no
166
+ * islands and no server-timing results — and because a live preview has something a build does
167
+ * not: it shows the tree as it stands, unsaved edits included. Absent means `"built"`, so a
168
+ * backend that predates this keeps its meaning.
169
+ */
170
+ mode?: "built" | "live";
152
171
  /**
153
172
  * Origin the built site is browsable at, e.g. `http://127.0.0.1:41234`.
154
173
  *
@@ -160,6 +179,24 @@ export interface SiteBuildResult {
160
179
  url?: string;
161
180
  }
162
181
 
182
+ /**
183
+ * What a live site preview reports back.
184
+ *
185
+ * Everything {@link SiteBuildResult} carries, plus the one answer only a live preview can give:
186
+ * whether the reader already had a tab on this project.
187
+ */
188
+ export interface SitePreviewResult extends SiteBuildResult {
189
+ /**
190
+ * A tab already holding this project's preview took the route, so open nothing.
191
+ *
192
+ * The caller MUST honour it. One tab per project is the contract, and a caller that opens a
193
+ * second one anyway gives the author two tabs on the same project — which is precisely what
194
+ * retargeting the first one exists to prevent. `false` means the backend has no live client, or
195
+ * had one that did not answer, and the caller should open a tab itself.
196
+ */
197
+ reused?: boolean;
198
+ }
199
+
163
200
  export interface StudioPlatform {
164
201
  id: string;
165
202
  projectRoot: string;
@@ -266,6 +303,15 @@ export interface StudioPlatform {
266
303
  */
267
304
  uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<UploadResult>;
268
305
  deleteFile: (path: string) => Promise<void>;
306
+ /**
307
+ * Move a file and rewrite the project's references to it.
308
+ *
309
+ * `from` and `to` are project-relative, and so is every path in the report — including
310
+ * `references.files[].path` and the `errors[].path` naming a document the refactor could not
311
+ * write. An adapter over a backend with a different path space translates the REQUEST or the
312
+ * REPLY, never both: which one needs translating is a property of that backend's route, and doing
313
+ * both silently assumes it echoes its own input space (specs/desktop.md §3.1).
314
+ */
269
315
  renameFile: (from: string, to: string) => Promise<RenameResult>;
270
316
  /**
271
317
  * Where a file or a component tag is used across the project — the read side of the same walker
@@ -279,6 +325,9 @@ export interface StudioPlatform {
279
325
  *
280
326
  * At least one of `path` / `tagName` must be given. A `path` naming a component document
281
327
  * contributes its own root tag, so one call answers "as a file AND as an element" together.
328
+ *
329
+ * `path` is project-relative going in, and every path coming back is too. A backend that cannot
330
+ * place the target inside the active project MUST fail rather than answer zero.
282
331
  */
283
332
  findReferences?: (target: { path?: string; tagName?: string }) => Promise<ReferencesResult>;
284
333
  createDirectory: (path: string) => Promise<void>;
@@ -298,8 +347,12 @@ export interface StudioPlatform {
298
347
  installDependencies?: () => Promise<PackageOpResult>;
299
348
  /** Whether the project has uninstalled dependencies (node_modules missing). */
300
349
  dependenciesNeedInstall?: () => Promise<boolean>;
301
- /** List dependencies that have a newer version available. */
302
- outdatedPackages?: () => Promise<OutdatedInfo[]>;
350
+ /**
351
+ * The newest version published on the registry for each dependency, whether or not it is behind.
352
+ * Optional: a platform that cannot reach a registry omits it, and Studio shows no Latest column
353
+ * values rather than inventing them.
354
+ */
355
+ packageVersions?: () => Promise<PackageVersionInfo[]>;
303
356
  /**
304
357
  * Rewrite the version range of each named package (preserving its dependencies/devDependencies
305
358
  * placement) and run `bun install`. Used by the @jxsuite bump and per-dependency updates.
@@ -389,6 +442,31 @@ export interface StudioPlatform {
389
442
  * build happened to leave — which for most projects is nothing at all.
390
443
  */
391
444
  buildSite?: () => Promise<SiteBuildResult>;
445
+ /**
446
+ * Preview the site LIVE at `route`, with no build on the path.
447
+ *
448
+ * The sibling of {@link buildSite} and what `View: Open in Browser` reaches first. Nothing is
449
+ * compiled: the backend composes the working tree on demand and the runtime assembles each page
450
+ * in the reader's browser, so what opens carries edits nobody has saved. `buildSite` keeps its
451
+ * own meaning and its own command, because "what does this page look like at its real URL?" and
452
+ * "does my site build?" are different questions and only one of them wants to wait.
453
+ *
454
+ * Optional, and the fallback is `buildSite`: a backend that predates this keeps working, and a
455
+ * backend that can do neither says so rather than opening an origin where half the site is the
456
+ * wrong file.
457
+ */
458
+ previewSite?: (opts: { route: string }) => Promise<SitePreviewResult>;
459
+ /**
460
+ * Publish the bytes a save WOULD write for one document, so a live preview shows the canvas
461
+ * rather than the disk.
462
+ *
463
+ * Bytes rather than a document, and the same bytes `writeFile` would receive: that is what makes
464
+ * "what the reader sees" and "what saving would produce" one answer instead of two that drift. A
465
+ * backend holds them in memory and writes them nowhere.
466
+ */
467
+ setPreviewOverlay?: (path: string, contents: string) => Promise<void>;
468
+ /** Drop one document's unsaved bytes, or every one of this project's when no path is given. */
469
+ clearPreviewOverlay?: (path?: string) => Promise<void>;
392
470
  gitAddRemote: (name: string, url: string) => Promise<void>;
393
471
  /**
394
472
  * How the New Project modal collects a destination, and which `CreateProjectDestination` variant
@@ -429,12 +507,19 @@ export interface StudioPlatform {
429
507
  * AI-guided import of an existing site into a new project. Runs in the backend (headless Chrome +
430
508
  * fs), streaming progress until the project is written. Absent on platforms without a backend
431
509
  * import pipeline — the New Project modal hides its Import tab then.
510
+ *
511
+ * `onReady` fires the moment the destination holds an openable `project.json` — seconds in, where
512
+ * the resolved promise is minutes away on a real crawl. It is what lets the caller OPEN the
513
+ * project and let its file tree fill up while the run continues, instead of showing a welcome
514
+ * screen throughout. Optional on both sides: a backend that never sends the line simply never
515
+ * calls it, and the caller opens the project when the run ends, as it always did.
432
516
  */
433
517
  importSite?: (
434
518
  opts: ImportSiteOptions,
435
519
  onProgress: (evt: ImportProgressEvent) => void,
436
520
  signal?: AbortSignal,
437
- ) => Promise<{ root: string; config: ProjectConfig }>;
521
+ onReady?: (evt: ImportReadyEvent) => void,
522
+ ) => Promise<{ root: string; config: ProjectConfig; result?: ImportSiteSummary }>;
438
523
  /**
439
524
  * Open a native directory picker and return the chosen absolute path (null when cancelled). Backs
440
525
  * the New Project modal's **Browse…** button on `createDestination: "path"` platforms. Desktop
@@ -568,6 +653,19 @@ export interface StudioPlatform {
568
653
  * showing a stale value until its next boot rather than losing anything.
569
654
  */
570
655
  subscribeSettings?: (handler: (settings: Record<string, string>) => void) => () => void;
656
+ /**
657
+ * Watch the backend connection, on platforms whose transport can lose one.
658
+ *
659
+ * Only a socket-based launcher implements this: the dev server talks over `fetch` and has nothing
660
+ * to lose, and the electrobun launcher's RPC times a request out rather than dropping it. The
661
+ * chromium launcher holds one long-lived WebSocket, and when it died every call after it hung
662
+ * with no error anywhere — so the transport reports the state, and the studio says it out loud.
663
+ *
664
+ * The handler is called on each transition, never repeatedly for one state.
665
+ */
666
+ subscribeConnection?: (
667
+ handler: (state: { online: boolean; reason?: string }) => void,
668
+ ) => () => void;
571
669
  }
572
670
 
573
671
  // ─── Studio Types ───────────────────────────────────────────────────────────
@@ -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}