@jxsuite/studio 3.0.0 → 4.1.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 (197) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  3. package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
  4. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  5. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  6. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  7. package/dist/chunks/content-source-48xg86wj.js +49 -0
  8. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
  9. package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
  10. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
  11. package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
  12. package/dist/chunks/files-3kfkwhk4.js +93 -0
  13. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  14. package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
  15. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  16. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
  17. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  18. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
  19. package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
  20. package/dist/chunks/layers-q75tj2mf.js +45 -0
  21. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  22. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  23. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
  24. package/dist/chunks/notify-yjm79n4s.js +30 -0
  25. package/dist/chunks/parse-24d0gay3.js +19 -0
  26. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  27. package/dist/chunks/publish-panel-v9st96ja.js +29 -0
  28. package/dist/chunks/quick-search-zccjp6gv.js +66 -0
  29. package/dist/chunks/references-3qx8fkyp.js +29 -0
  30. package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
  31. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
  32. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  33. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  34. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
  35. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  36. package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
  37. package/dist/chunks/studio-1ngbejft.js +181 -0
  38. package/dist/chunks/studio-1wagb550.js +118 -0
  39. package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
  40. package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
  41. package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
  42. package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
  43. package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
  44. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  45. package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
  46. package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
  47. package/dist/chunks/studio-6yw4y3my.js +91 -0
  48. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  49. package/dist/chunks/studio-92ab2jqe.js +282 -0
  50. package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
  51. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  52. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  53. package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
  54. package/dist/chunks/studio-b55y6ewx.js +498 -0
  55. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  56. package/dist/chunks/studio-cft9r4r7.js +119 -0
  57. package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
  58. package/dist/chunks/studio-dm19q43r.js +195 -0
  59. package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
  60. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  61. package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
  62. package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
  63. package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
  64. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  65. package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
  66. package/dist/chunks/studio-jc994dnr.js +1066 -0
  67. package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
  68. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  69. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  70. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  71. package/dist/chunks/studio-msd847zn.js +1315 -0
  72. package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
  73. package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
  74. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  75. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  76. package/dist/chunks/studio-q43a5y9h.js +394 -0
  77. package/dist/chunks/studio-q7v10cg2.js +106 -0
  78. package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
  79. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  80. package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
  81. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  82. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  83. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  84. package/dist/chunks/studio-t7qb9db8.js +665 -0
  85. package/dist/chunks/studio-tmc5emrw.js +8 -0
  86. package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
  87. package/dist/chunks/studio-wcy8trfx.js +263 -0
  88. package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
  89. package/dist/chunks/studio-x813zp48.js +195 -0
  90. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  91. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  92. package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
  93. package/dist/chunks/workspace-fjx52ryv.js +89 -0
  94. package/dist/iframe-entry.js +141 -56
  95. package/dist/manifest.json +7 -0
  96. package/dist/studio.js +2605 -1950
  97. package/favicon.ico +0 -0
  98. package/index.html +1 -0
  99. package/package.json +20 -18
  100. package/src/about/about-modal.ts +4 -3
  101. package/src/browse/library-pane.ts +57 -3
  102. package/src/canvas/canvas-render.ts +23 -1
  103. package/src/canvas/iframe-patch.ts +47 -21
  104. package/src/canvas/iframe-render.ts +22 -6
  105. package/src/commands/app-commands.ts +4 -0
  106. package/src/commands/command-args.ts +29 -0
  107. package/src/commands/context.ts +15 -1
  108. package/src/commands/defaults.ts +29 -1
  109. package/src/commands/levels.ts +16 -0
  110. package/src/commands/live-context.ts +3 -0
  111. package/src/content/collection-match.ts +267 -0
  112. package/src/content/entry-commands.ts +38 -29
  113. package/src/content/entry-fields.ts +6 -0
  114. package/src/editor/shortcuts.ts +5 -0
  115. package/src/files/file-ops.ts +10 -42
  116. package/src/files/files.ts +487 -59
  117. package/src/files/fs-events.ts +9 -0
  118. package/src/files/gitignore.ts +432 -0
  119. package/src/files/media-paths.ts +13 -42
  120. package/src/files/media-usage.ts +41 -131
  121. package/src/files/serialize-document.ts +60 -0
  122. package/src/format/convert-file.ts +511 -0
  123. package/src/format/format-choices.ts +199 -0
  124. package/src/grid/grid-view.ts +16 -2
  125. package/src/hosting/document.ts +2 -0
  126. package/src/hosting/layout.ts +8 -0
  127. package/src/media/media-open.ts +58 -0
  128. package/src/media/media-pane.ts +310 -0
  129. package/src/new-project/import-tab.ts +357 -163
  130. package/src/new-project/location-fields.ts +8 -5
  131. package/src/new-project/new-project-modal.ts +27 -34
  132. package/src/packages/ensure-deps.ts +14 -7
  133. package/src/packages/jxsuite-update.ts +11 -11
  134. package/src/page-params.ts +10 -46
  135. package/src/panels/activity-bar.ts +45 -16
  136. package/src/panels/ai-chat/attached-context.ts +6 -1
  137. package/src/panels/ai-chat/chat-view.ts +255 -13
  138. package/src/panels/ai-chat/composer.ts +31 -97
  139. package/src/panels/ai-panel.ts +116 -2
  140. package/src/panels/navigator-panels.ts +7 -6
  141. package/src/panels/settings-menu.ts +799 -0
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +261 -70
  144. package/src/platforms/devserver.ts +67 -33
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/publish/publish-panel.ts +62 -2
  147. package/src/services/ai-ask.ts +247 -0
  148. package/src/services/ai-import-tools.ts +532 -0
  149. package/src/services/ai-models.ts +108 -1
  150. package/src/services/ai-project-tools.ts +20 -26
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/connection.ts +74 -0
  154. package/src/services/context-manager.ts +101 -1
  155. package/src/services/document-assistant.ts +45 -4
  156. package/src/services/import-client.ts +33 -6
  157. package/src/services/import-run.ts +158 -0
  158. package/src/services/import-seed.ts +120 -0
  159. package/src/services/jx-validate.ts +58 -6
  160. package/src/services/project-adoption.ts +71 -0
  161. package/src/services/references.ts +0 -0
  162. package/src/services/settings/definitions.ts +17 -0
  163. package/src/services/tool-executor.ts +45 -2
  164. package/src/settings/contexts-section.ts +45 -3
  165. package/src/settings/dependencies-editor.ts +43 -23
  166. package/src/settings/general-settings.ts +30 -12
  167. package/src/settings/preferences-accounts.ts +193 -12
  168. package/src/settings/preferences-dialog.ts +93 -59
  169. package/src/settings/preferences-sections.ts +57 -0
  170. package/src/settings/settings-document.ts +65 -9
  171. package/src/studio.ts +33 -6
  172. package/src/tabs/transact.ts +42 -19
  173. package/src/types.ts +129 -7
  174. package/src/ui/ai-managed-connect.ts +62 -13
  175. package/src/ui/ai-model-picker.ts +189 -0
  176. package/src/ui/cf-account-picker.ts +200 -0
  177. package/src/ui/choice-field.ts +72 -0
  178. package/src/ui/layers.ts +83 -5
  179. package/src/utils/studio-utils.ts +22 -52
  180. package/styles/forced-colors.css +7 -0
  181. package/styles/overlays.css +256 -0
  182. package/styles/panels.css +13 -1
  183. package/styles/shell.css +147 -0
  184. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  185. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  186. package/dist/chunks/files-xcybanxe.js +0 -70
  187. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  188. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  189. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  190. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  191. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  192. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  193. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  194. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  195. package/dist/chunks/studio-bw5dz6km.js +0 -608
  196. package/dist/chunks/studio-c31yb4t6.js +0 -55
  197. package/src/canvas/site-style-css.ts +0 -117
package/src/types.ts CHANGED
@@ -14,7 +14,9 @@ import type {
14
14
  import type {
15
15
  AppInfo,
16
16
  AssetCapabilities,
17
+ CfAccountSummary,
17
18
  CfConnection,
19
+ CfConnectOutcome,
18
20
  CodeServiceResult,
19
21
  ComponentMeta,
20
22
  DataConnectionsResponse,
@@ -32,10 +34,12 @@ import type {
32
34
  GitLogEntry,
33
35
  GitStatusResult,
34
36
  ImportProgressEvent,
37
+ ImportReadyEvent,
35
38
  ImportSiteOptions,
36
- OutdatedInfo,
39
+ ImportSiteSummary,
37
40
  PackageInfo,
38
41
  PackageOpResult,
42
+ PackageVersionInfo,
39
43
  ProjectListEntry,
40
44
  ProjectSchemasResponse,
41
45
  RecentProjectEntry,
@@ -52,7 +56,9 @@ export type {
52
56
  AiModelInfo,
53
57
  AiModelsResponse,
54
58
  AppInfo,
59
+ CfAccountSummary,
55
60
  CfConnection,
61
+ CfConnectOutcome,
56
62
  CodeServiceResult,
57
63
  ComponentMeta,
58
64
  ComponentSlotMeta,
@@ -79,12 +85,15 @@ export type {
79
85
  GitFileStatus,
80
86
  GitLogEntry,
81
87
  GitStatusResult,
88
+ ImportBreakpointPolicy,
82
89
  ImportProgressEvent,
90
+ ImportReadyEvent,
83
91
  ImportSiteOptions,
92
+ ImportSiteSummary,
84
93
  JsonValue,
85
- OutdatedInfo,
86
94
  PackageInfo,
87
95
  PackageOpResult,
96
+ PackageVersionInfo,
88
97
  ProjectListEntry,
89
98
  ProjectSchemasResponse,
90
99
  PullRequestInfo,
@@ -149,6 +158,20 @@ export interface SiteBuildResult {
149
158
  routes: number;
150
159
  files: number;
151
160
  errors: string[];
161
+ /**
162
+ * How the backend produced what {@link url} serves.
163
+ *
164
+ * `"built"` is compiler output — what the published site will literally be. `"live"` is the
165
+ * working tree rendered as a site: the same pages at the same routes, assembled by the runtime in
166
+ * the reader's browser rather than prerendered, because the backend cannot run a build at all.
167
+ *
168
+ * The distinction is reported rather than hidden because the two differ in ways a reader would
169
+ * otherwise be misled by — a live preview has no prerendered HTML, no optimized images, no
170
+ * islands and no server-timing results — and because a live preview has something a build does
171
+ * not: it shows the tree as it stands, unsaved edits included. Absent means `"built"`, so a
172
+ * backend that predates this keeps its meaning.
173
+ */
174
+ mode?: "built" | "live";
152
175
  /**
153
176
  * Origin the built site is browsable at, e.g. `http://127.0.0.1:41234`.
154
177
  *
@@ -160,6 +183,24 @@ export interface SiteBuildResult {
160
183
  url?: string;
161
184
  }
162
185
 
186
+ /**
187
+ * What a live site preview reports back.
188
+ *
189
+ * Everything {@link SiteBuildResult} carries, plus the one answer only a live preview can give:
190
+ * whether the reader already had a tab on this project.
191
+ */
192
+ export interface SitePreviewResult extends SiteBuildResult {
193
+ /**
194
+ * A tab already holding this project's preview took the route, so open nothing.
195
+ *
196
+ * The caller MUST honour it. One tab per project is the contract, and a caller that opens a
197
+ * second one anyway gives the author two tabs on the same project — which is precisely what
198
+ * retargeting the first one exists to prevent. `false` means the backend has no live client, or
199
+ * had one that did not answer, and the caller should open a tab itself.
200
+ */
201
+ reused?: boolean;
202
+ }
203
+
163
204
  export interface StudioPlatform {
164
205
  id: string;
165
206
  projectRoot: string;
@@ -266,6 +307,15 @@ export interface StudioPlatform {
266
307
  */
267
308
  uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<UploadResult>;
268
309
  deleteFile: (path: string) => Promise<void>;
310
+ /**
311
+ * Move a file and rewrite the project's references to it.
312
+ *
313
+ * `from` and `to` are project-relative, and so is every path in the report — including
314
+ * `references.files[].path` and the `errors[].path` naming a document the refactor could not
315
+ * write. An adapter over a backend with a different path space translates the REQUEST or the
316
+ * REPLY, never both: which one needs translating is a property of that backend's route, and doing
317
+ * both silently assumes it echoes its own input space (specs/desktop.md §3.1).
318
+ */
269
319
  renameFile: (from: string, to: string) => Promise<RenameResult>;
270
320
  /**
271
321
  * Where a file or a component tag is used across the project — the read side of the same walker
@@ -279,6 +329,9 @@ export interface StudioPlatform {
279
329
  *
280
330
  * At least one of `path` / `tagName` must be given. A `path` naming a component document
281
331
  * contributes its own root tag, so one call answers "as a file AND as an element" together.
332
+ *
333
+ * `path` is project-relative going in, and every path coming back is too. A backend that cannot
334
+ * place the target inside the active project MUST fail rather than answer zero.
282
335
  */
283
336
  findReferences?: (target: { path?: string; tagName?: string }) => Promise<ReferencesResult>;
284
337
  createDirectory: (path: string) => Promise<void>;
@@ -298,8 +351,12 @@ export interface StudioPlatform {
298
351
  installDependencies?: () => Promise<PackageOpResult>;
299
352
  /** Whether the project has uninstalled dependencies (node_modules missing). */
300
353
  dependenciesNeedInstall?: () => Promise<boolean>;
301
- /** List dependencies that have a newer version available. */
302
- outdatedPackages?: () => Promise<OutdatedInfo[]>;
354
+ /**
355
+ * The newest version published on the registry for each dependency, whether or not it is behind.
356
+ * Optional: a platform that cannot reach a registry omits it, and Studio shows no Latest column
357
+ * values rather than inventing them.
358
+ */
359
+ packageVersions?: () => Promise<PackageVersionInfo[]>;
303
360
  /**
304
361
  * Rewrite the version range of each named package (preserving its dependencies/devDependencies
305
362
  * placement) and run `bun install`. Used by the @jxsuite bump and per-dependency updates.
@@ -389,6 +446,31 @@ export interface StudioPlatform {
389
446
  * build happened to leave — which for most projects is nothing at all.
390
447
  */
391
448
  buildSite?: () => Promise<SiteBuildResult>;
449
+ /**
450
+ * Preview the site LIVE at `route`, with no build on the path.
451
+ *
452
+ * The sibling of {@link buildSite} and what `View: Open in Browser` reaches first. Nothing is
453
+ * compiled: the backend composes the working tree on demand and the runtime assembles each page
454
+ * in the reader's browser, so what opens carries edits nobody has saved. `buildSite` keeps its
455
+ * own meaning and its own command, because "what does this page look like at its real URL?" and
456
+ * "does my site build?" are different questions and only one of them wants to wait.
457
+ *
458
+ * Optional, and the fallback is `buildSite`: a backend that predates this keeps working, and a
459
+ * backend that can do neither says so rather than opening an origin where half the site is the
460
+ * wrong file.
461
+ */
462
+ previewSite?: (opts: { route: string }) => Promise<SitePreviewResult>;
463
+ /**
464
+ * Publish the bytes a save WOULD write for one document, so a live preview shows the canvas
465
+ * rather than the disk.
466
+ *
467
+ * Bytes rather than a document, and the same bytes `writeFile` would receive: that is what makes
468
+ * "what the reader sees" and "what saving would produce" one answer instead of two that drift. A
469
+ * backend holds them in memory and writes them nowhere.
470
+ */
471
+ setPreviewOverlay?: (path: string, contents: string) => Promise<void>;
472
+ /** Drop one document's unsaved bytes, or every one of this project's when no path is given. */
473
+ clearPreviewOverlay?: (path?: string) => Promise<void>;
392
474
  gitAddRemote: (name: string, url: string) => Promise<void>;
393
475
  /**
394
476
  * How the New Project modal collects a destination, and which `CreateProjectDestination` variant
@@ -429,12 +511,19 @@ export interface StudioPlatform {
429
511
  * AI-guided import of an existing site into a new project. Runs in the backend (headless Chrome +
430
512
  * fs), streaming progress until the project is written. Absent on platforms without a backend
431
513
  * import pipeline — the New Project modal hides its Import tab then.
514
+ *
515
+ * `onReady` fires the moment the destination holds an openable `project.json` — seconds in, where
516
+ * the resolved promise is minutes away on a real crawl. It is what lets the caller OPEN the
517
+ * project and let its file tree fill up while the run continues, instead of showing a welcome
518
+ * screen throughout. Optional on both sides: a backend that never sends the line simply never
519
+ * calls it, and the caller opens the project when the run ends, as it always did.
432
520
  */
433
521
  importSite?: (
434
522
  opts: ImportSiteOptions,
435
523
  onProgress: (evt: ImportProgressEvent) => void,
436
524
  signal?: AbortSignal,
437
- ) => Promise<{ root: string; config: ProjectConfig }>;
525
+ onReady?: (evt: ImportReadyEvent) => void,
526
+ ) => Promise<{ root: string; config: ProjectConfig; result?: ImportSiteSummary }>;
438
527
  /**
439
528
  * Open a native directory picker and return the chosen absolute path (null when cancelled). Backs
440
529
  * the New Project modal's **Browse…** button on `createDestination: "path"` platforms. Desktop
@@ -527,14 +616,34 @@ export interface StudioPlatform {
527
616
  head?: string;
528
617
  base?: string;
529
618
  }) => Promise<{ url: string; number: number }>;
530
- /** Current Cloudflare connection state, when the platform can broker one. */
619
+ /**
620
+ * Current Cloudflare connection state, when the platform can broker one. See {@link CfConnection}
621
+ * for what null, `{connected: false}` and `needsReconnect` each mean — they are three different
622
+ * states and the UI says a different sentence for each.
623
+ */
531
624
  cfConnection?: () => Promise<CfConnection | null>;
532
625
  /**
533
626
  * Interactively connect a Cloudflare account (hosted OAuth on the cloud platform). Local
534
627
  * platforms omit it — the publish UI collects an API token instead and verifies via
535
628
  * cfConnection.
629
+ *
630
+ * Resolves a {@link CfConnectOutcome} rather than a connection, because "connected" is only one of
631
+ * four endings: a blocked popup navigates the whole page (`redirect`), a closed popup is a
632
+ * cancellation, and a passed deadline is a timeout. Rejects only when the flow actually failed —
633
+ * a relayed OAuth error, or a success the platform could not confirm. Resolves null only where
634
+ * there is no DOM to open a popup in.
635
+ */
636
+ cfConnect?: () => Promise<CfConnectOutcome | null>;
637
+ /**
638
+ * Every Cloudflare account the connected grant can reach. Backs the account picker a
639
+ * multi-account user needs before publishing: the broker stores one account id per connection,
640
+ * and until one is chosen every Cloudflare-backed call answers `cf_account_required`.
536
641
  */
537
- cfConnect?: () => Promise<CfConnection | null>;
642
+ cfAccounts?: () => Promise<CfAccountSummary[]>;
643
+ /** Store the chosen account on the brokered connection (the picker's commit). */
644
+ cfSelectAccount?: (account: { id: string; name?: string }) => Promise<void>;
645
+ /** Forget the brokered connection and revoke its tokens upstream. */
646
+ cfDisconnect?: () => Promise<void>;
538
647
  /**
539
648
  * Allowlisted Cloudflare API passthrough (accounts, Pages projects and deployments). The backend
540
649
  * injects credentials — an OAuth token on the cloud platform, the user's pasted API token locally
@@ -568,6 +677,19 @@ export interface StudioPlatform {
568
677
  * showing a stale value until its next boot rather than losing anything.
569
678
  */
570
679
  subscribeSettings?: (handler: (settings: Record<string, string>) => void) => () => void;
680
+ /**
681
+ * Watch the backend connection, on platforms whose transport can lose one.
682
+ *
683
+ * Only a socket-based launcher implements this: the dev server talks over `fetch` and has nothing
684
+ * to lose, and the electrobun launcher's RPC times a request out rather than dropping it. The
685
+ * chromium launcher holds one long-lived WebSocket, and when it died every call after it hung
686
+ * with no error anywhere — so the transport reports the state, and the studio says it out loud.
687
+ *
688
+ * The handler is called on each transition, never repeatedly for one state.
689
+ */
690
+ subscribeConnection?: (
691
+ handler: (state: { online: boolean; reason?: string }) => void,
692
+ ) => () => void;
571
693
  }
572
694
 
573
695
  // ─── Studio Types ───────────────────────────────────────────────────────────
@@ -25,7 +25,9 @@ import {
25
25
  isManagedProxy,
26
26
  isProxyConfigured,
27
27
  proxyStateCode,
28
+ resetModelCache,
28
29
  } from "../services/ai-models";
30
+ import type { CfConnectOutcome } from "../types";
29
31
 
30
32
  export interface ManagedConnectOptions {
31
33
  /** Host re-render scheduler — called on connect start/finish and when the probe settles. */
@@ -52,20 +54,73 @@ export function createManagedConnect(opts: ManagedConnectOptions): ManagedConnec
52
54
  let connectError = "";
53
55
 
54
56
  /*
55
- * Offer the keyless path when the proxy says managed-but-unconfigured and the platform can run
56
- * the hosted OAuth flow (the PAL seam — desktop shells can implement cfConnect later). Guarded on
57
- * hasPlatform() because gates can render from modules that load before registration.
57
+ * Offer the keyless path when the proxy is managed and the platform can run the hosted OAuth flow
58
+ * (the PAL seam — desktop shells can implement cfConnect later). Guarded on hasPlatform() because
59
+ * gates can render from modules that load before registration.
60
+ *
61
+ * `configured` alone does NOT withdraw the offer any more. A backend can answer `configured: true`
62
+ * over a grant it cannot actually use, and hiding the CTA there left the user with a broken
63
+ * assistant and no button at all — the one state in which reconnecting is the whole fix. So the
64
+ * lapsed code overrides `configured`, while `cf_upstream_error` (also `configured: true`, and
65
+ * carrying no reconnect code) still hides it: an unreachable Cloudflare is not fixed by an OAuth
66
+ * round trip.
58
67
  */
59
68
  function canOffer(): boolean {
60
- return (
61
- isManagedProxy() && !isProxyConfigured() && hasPlatform() && Boolean(getPlatform().cfConnect)
62
- );
69
+ if (!isManagedProxy() || !hasPlatform() || !getPlatform().cfConnect) {
70
+ return false;
71
+ }
72
+ return !isProxyConfigured() || proxyStateCode() === "cf_reconnect_required";
63
73
  }
64
74
 
65
75
  function ensureProbe() {
66
76
  ensureProxyProbe(opts.requestRender);
67
77
  }
68
78
 
79
+ /**
80
+ * Act on how the flow ended.
81
+ *
82
+ * Every branch here exists because the old code had ONE: any truthy result was success, so a
83
+ * connect that landed on the same lapsed row re-probed, got the same `cf_reconnect_required`
84
+ * back, and re-rendered a byte-identical CTA. The user clicked Reconnect and observably nothing
85
+ * happened — which is the half of the outage that made the other half impossible to diagnose.
86
+ *
87
+ * @param {CfConnectOutcome | null} outcome - Null where the platform had no DOM to open a popup
88
+ * in.
89
+ */
90
+ async function settle(outcome: CfConnectOutcome | null): Promise<void> {
91
+ /* A blocked popup turns into a full-page redirect, and null is a host with nowhere to open one:
92
+ in both the document is on its way out, so an apology would be the last thing painted before
93
+ it goes. Cancellation is silent for the opposite reason — the user already knows. */
94
+ if (!outcome || outcome.status === "redirect" || outcome.status === "canceled") {
95
+ return;
96
+ }
97
+ if (outcome.status === "timeout") {
98
+ connectError =
99
+ "The Cloudflare window didn't finish. Sign in there, then reconnect — nothing was changed.";
100
+ return;
101
+ }
102
+ if (!outcome.connection.accountId) {
103
+ /* Lazily, because the picker pulls the dialog layer in and this module is imported by every
104
+ credentials gate — including ones that render before layers are bound. */
105
+ const { openCfAccountPicker } = await import("./cf-account-picker");
106
+ if (!(await openCfAccountPicker())) {
107
+ connectError =
108
+ "Cloudflare is connected, but no account is chosen yet — pick one to finish setting up the assistant.";
109
+ return;
110
+ }
111
+ }
112
+ // Re-probe: /models flips to configured once the connection lands, opening the gate.
113
+ resetModelCache();
114
+ await fetchAvailableModels({ force: true });
115
+ /* And then CHECK, because a connect the backend does not honour must not look like one that
116
+ worked. This is the only place that can tell the difference. */
117
+ if (!isProxyConfigured() || proxyStateCode() === "cf_reconnect_required") {
118
+ connectError =
119
+ "Cloudflare connected, but the assistant backend still reports the connection as unusable. " +
120
+ "Try reconnecting, or use your own API key below.";
121
+ }
122
+ }
123
+
69
124
  async function connect() {
70
125
  if (busy) {
71
126
  return;
@@ -74,13 +129,7 @@ export function createManagedConnect(opts: ManagedConnectOptions): ManagedConnec
74
129
  connectError = "";
75
130
  opts.requestRender();
76
131
  try {
77
- const connection = await getPlatform().cfConnect?.();
78
- if (connection) {
79
- // Re-probe: /models flips to configured once the connection lands, opening the gate.
80
- await fetchAvailableModels({ force: true });
81
- } else {
82
- connectError = "Cloudflare connection was not completed.";
83
- }
132
+ await settle((await getPlatform().cfConnect?.()) ?? null);
84
133
  } catch (error) {
85
134
  connectError = error instanceof Error ? error.message : String(error);
86
135
  }
@@ -0,0 +1,189 @@
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
+ modelToolSupport,
30
+ preferredModel,
31
+ } from "../services/ai-models";
32
+ import { setModel } from "../services/ai-settings";
33
+
34
+ import type { TemplateResult } from "lit-html";
35
+ import type { AiCredentials } from "../services/ai-models";
36
+
37
+ /** The `sp-menu-item` value that means "try listing again" rather than "select this model". */
38
+ export const RETRY_MODELS = "__retry_models__";
39
+
40
+ /** The `sp-menu-item` value of the disabled placeholder shown during the first fetch. */
41
+ export const LOADING_MODELS = "__loading__";
42
+
43
+ /** What a listed model's row says when the backend reported it cannot call tools. */
44
+ export const NO_TOOLS_SUFFIX = " — no tools";
45
+
46
+ /**
47
+ * A model's row label: its name, plus a note when it is known to be chat-only.
48
+ *
49
+ * Labelled, never filtered or disabled. Choosing a chat-only model to ask questions of is a
50
+ * legitimate thing to do, and a picker that hides half a managed catalogue would be reporting a
51
+ * capability gap as an outage. Only `toolSupport === false` is labelled — `undefined` is a BYOK
52
+ * provider saying nothing, and a suffix there would be an invention.
53
+ */
54
+ function itemLabel(model: { id: string; name: string }): string {
55
+ return modelToolSupport(model.id) === false ? `${model.name}${NO_TOOLS_SUFFIX}` : model.name;
56
+ }
57
+
58
+ export interface ModelPickerOptions {
59
+ /** Host re-render scheduler — called whenever a fetch settles. */
60
+ requestRender: () => void;
61
+ /**
62
+ * The model to show as selected. Defaults to the stored/proxy-preferred one, which is what the
63
+ * chat composer wants; the Import form passes its own draft, which is not a stored setting until
64
+ * the run starts.
65
+ */
66
+ getModel?: () => string;
67
+ /**
68
+ * Where a choice goes. Defaults to `setModel` (the roaming application preference). The Import
69
+ * form passes its own setter, because choosing a model for one import must not silently retarget
70
+ * the assistant.
71
+ */
72
+ onChange?: (id: string) => void;
73
+ /** Extra class on the `sp-picker`, so a host can size it without touching this module. */
74
+ className?: string;
75
+ /** Picker size; `"s"` (the composer's) by default. */
76
+ size?: "s" | "m";
77
+ }
78
+
79
+ export interface ModelPicker {
80
+ render: () => TemplateResult;
81
+ /** Whether a fetch is in flight — for a host that wants to disable a submit button. */
82
+ isLoading: () => boolean;
83
+ /** The last fetch's failure, or `""`. */
84
+ error: () => string;
85
+ /**
86
+ * Whether the selected model is known NOT to support tools — for a host that wants to say so.
87
+ *
88
+ * `false` while the backend has no opinion, so a host reads it as "warn" rather than as "block".
89
+ */
90
+ selectedLacksTools: () => boolean;
91
+ }
92
+
93
+ /**
94
+ * Create a model picker bound to a host's render scheduler.
95
+ *
96
+ * @param {ModelPickerOptions} opts
97
+ * @returns {ModelPicker}
98
+ */
99
+ export function createModelPicker(opts: ModelPickerOptions): ModelPicker {
100
+ const getModel = opts.getModel ?? preferredModel;
101
+ const onChange = opts.onChange ?? setModel;
102
+
103
+ let loading = false;
104
+ let failure = "";
105
+
106
+ /**
107
+ * The credentials the last fetch was made FOR, so a failure is not retried on every render.
108
+ *
109
+ * The list itself is deliberately not held here — see the module docblock.
110
+ */
111
+ let attempted: AiCredentials | null = null;
112
+
113
+ function sameConnection(a: AiCredentials | null, b: AiCredentials): boolean {
114
+ return a !== null && a.apiKey === b.apiKey && a.baseUrl === b.baseUrl;
115
+ }
116
+
117
+ function ensureModels(force = false) {
118
+ const credentials = aiConnection();
119
+ const settled = cachedModels(credentials) !== null || sameConnection(attempted, credentials);
120
+ if (loading || (settled && !force)) {
121
+ return;
122
+ }
123
+ loading = true;
124
+ failure = "";
125
+ attempted = credentials;
126
+ fetchAvailableModels({ credentials, force })
127
+ .catch((error: unknown) => {
128
+ failure = (error as Error).message || "Failed to fetch models";
129
+ })
130
+ .finally(() => {
131
+ loading = false;
132
+ opts.requestRender();
133
+ });
134
+ }
135
+
136
+ function onPickerChange(e: Event) {
137
+ const { value } = e.target as HTMLInputElement;
138
+ if (value === RETRY_MODELS) {
139
+ attempted = null;
140
+ ensureModels(true);
141
+ // Re-render so the picker snaps back to the current model instead of showing "Retry".
142
+ opts.requestRender();
143
+ return;
144
+ }
145
+ if (value && value !== LOADING_MODELS) {
146
+ onChange(value);
147
+ }
148
+ }
149
+
150
+ function render(): TemplateResult {
151
+ ensureModels();
152
+ const current = getModel();
153
+ const listed = cachedModels(aiConnection()) ?? [];
154
+ /* A catalogue that does not contain the current model still has to show it selected — an
155
+ unlisted id is the normal case for a self-hosted or newly released model. */
156
+ const items = listed.some((m) => m.id === current)
157
+ ? listed
158
+ : [{ id: current, name: current }, ...listed];
159
+ return html`
160
+ <sp-picker
161
+ size=${opts.size ?? "s"}
162
+ quiet
163
+ class=${opts.className ?? "ai-model-picker"}
164
+ title=${failure ? `Couldn't load models: ${failure}` : "Model"}
165
+ .value=${live(current)}
166
+ @change=${onPickerChange}
167
+ >
168
+ ${items.map((m) => html`<sp-menu-item value=${m.id}>${itemLabel(m)}</sp-menu-item>`)}
169
+ ${
170
+ loading
171
+ ? html`<sp-menu-item disabled value=${LOADING_MODELS}>Loading models…</sp-menu-item>`
172
+ : nothing
173
+ }
174
+ ${
175
+ failure
176
+ ? html`<sp-menu-item value=${RETRY_MODELS}>Retry loading models</sp-menu-item>`
177
+ : nothing
178
+ }
179
+ </sp-picker>
180
+ `;
181
+ }
182
+
183
+ return {
184
+ error: () => failure,
185
+ isLoading: () => loading,
186
+ render,
187
+ selectedLacksTools: () => modelToolSupport(getModel()) === false,
188
+ };
189
+ }