@jxsuite/studio 2.4.2 → 3.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 (82) hide show
  1. package/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
  2. package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
  3. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
  4. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
  5. package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
  6. package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
  7. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
  8. package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
  9. package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
  10. package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
  11. package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
  12. package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
  13. package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
  14. package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
  15. package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
  16. package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
  17. package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
  18. package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
  19. package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
  20. package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
  21. package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
  22. package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
  23. package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
  24. package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
  25. package/dist/chunks/studio-kfpek536.js +28 -0
  26. package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
  27. package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
  28. package/dist/chunks/studio-qpfq7xhm.js +1802 -0
  29. package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
  30. package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
  31. package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
  32. package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
  33. package/dist/iframe-entry.js +344 -26
  34. package/dist/studio.js +374 -248
  35. package/package.json +9 -9
  36. package/src/browse/library-layouts.ts +3 -3
  37. package/src/browse/library-pane.ts +2 -2
  38. package/src/browse/library-preview.ts +19 -3
  39. package/src/canvas/asset-refs.ts +119 -0
  40. package/src/canvas/asset-resolve.ts +256 -0
  41. package/src/canvas/canvas-live-render.ts +23 -15
  42. package/src/canvas/iframe-entry.ts +1 -0
  43. package/src/canvas/iframe-host.ts +4 -0
  44. package/src/canvas/iframe-protocol.ts +9 -0
  45. package/src/canvas/iframe-render.ts +28 -10
  46. package/src/canvas/site-style-css.ts +2 -6
  47. package/src/editor/file-drop-action.ts +2 -1
  48. package/src/files/files.ts +2 -2
  49. package/src/files/media-paths.ts +37 -14
  50. package/src/files/media-upload.ts +47 -6
  51. package/src/grid/grid-idle.ts +45 -0
  52. package/src/grid/grid-view.ts +21 -0
  53. package/src/grid/schema-columns.ts +2 -1
  54. package/src/grid/sources/content-source.ts +24 -3
  55. package/src/new-project/import-tab.ts +3 -2
  56. package/src/panels/ai-chat/composer.ts +30 -13
  57. package/src/panels/frontmatter-fields.ts +6 -2
  58. package/src/panels/properties-panel.ts +8 -2
  59. package/src/panels/seo-modal.ts +5 -6
  60. package/src/panels/signals-panel.ts +2 -1
  61. package/src/platforms/cloud.ts +75 -5
  62. package/src/platforms/devserver.ts +5 -1
  63. package/src/services/ai-models.ts +101 -11
  64. package/src/services/ai-settings.ts +69 -71
  65. package/src/services/cf-settings.ts +19 -37
  66. package/src/services/document-assistant.ts +4 -3
  67. package/src/services/idle.ts +6 -1
  68. package/src/services/settings/definitions.ts +164 -0
  69. package/src/services/settings/kernel.ts +335 -0
  70. package/src/services/settings/write-queue.ts +116 -0
  71. package/src/settings/preferences-accounts.ts +6 -19
  72. package/src/settings/preferences-keymap.ts +8 -18
  73. package/src/settings/schema-field-ui.ts +2 -2
  74. package/src/shell.ts +6 -14
  75. package/src/studio.ts +32 -1
  76. package/src/types.ts +61 -3
  77. package/src/ui/ai-credentials-form.ts +52 -27
  78. package/src/ui/media-picker.ts +6 -11
  79. package/src/utils/studio-utils.ts +45 -3
  80. package/dist/chunks/studio-a7pzveq1.js +0 -110
  81. package/src/canvas/content-assets.ts +0 -263
  82. package/src/services/settings-store.ts +0 -99
@@ -58,6 +58,7 @@ import { getExtensions, loadExtensions } from "../format/format-host";
58
58
  import { optionalStringArg, stringProperty } from "../commands/command-args";
59
59
  import type { TemplateResult } from "lit-html";
60
60
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
61
+ import { isMediaFormat } from "../utils/studio-utils";
61
62
 
62
63
  interface SignalsPanelState {
63
64
  document: JxMutableNode;
@@ -915,7 +916,7 @@ function renderSignalEditorTemplate(
915
916
  : nothing
916
917
  }
917
918
  ${
918
- def.format === "image"
919
+ isMediaFormat(def.format)
919
920
  ? renderFieldRow({
920
921
  hasValue: false,
921
922
  label: "Default",
@@ -15,8 +15,10 @@ import { negotiateCollab } from "@jxsuite/collab/negotiate";
15
15
  import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
16
16
  import type { WsCollabConnection } from "@jxsuite/collab/client";
17
17
  import type { ProjectConfig } from "@jxsuite/schema/types";
18
+ import { componentMetaFrom } from "@jxsuite/schema/component-meta";
18
19
  import type {
19
20
  AccountStatus,
21
+ ComponentMeta,
20
22
  CreateProjectDestination,
21
23
  DirEntry,
22
24
  ExtensionsInfo,
@@ -34,6 +36,7 @@ import type {
34
36
  StudioPlatform,
35
37
  } from "../types";
36
38
  import { problemDetail, problemSlug } from "@jxsuite/protocol";
39
+ import type { UploadResult } from "@jxsuite/protocol";
37
40
 
38
41
  export interface CloudProject {
39
42
  owner: string;
@@ -230,8 +233,15 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
230
233
  The session serves the project tree at /raw.
231
234
 
232
235
  Conditional because `base` is "" until a project is bound, and "/raw/" would be a confident
233
- wrong answer — worse than the default, which at least fails visibly. */
234
- ...(base ? { documentBaseUrl: `${base}/raw/` } : {}),
236
+ wrong answer — worse than the default, which at least fails visibly.
237
+
238
+ `assetSpace` rides in the SAME spread, and that is the whole of its correctness: studio.jx-
239
+ suite.com is a multi-tenant SPA origin, so `/hero.jpg` misses Workers Static Assets and the
240
+ single-page-app fallback answers index.html at HTTP 200 — the <img> gets HTML, renders
241
+ broken, and logs nothing. Declaring "repo" tells Studio to resolve each reference to the
242
+ project file it names and address it under /raw. Until a project is bound there is no /raw to
243
+ address, so the project-less hub declares neither and stays exactly as it was. */
244
+ ...(base ? { assetSpace: "repo" as const, documentBaseUrl: `${base}/raw/` } : {}),
235
245
  /* Open Project routes through Studio's repo picker (write-access repos via listRepos +
236
246
  importProject) — sessions are URL-bound, so openProject() below never opens a dialog. */
237
247
  openProjectPicker: "repo-list",
@@ -312,7 +322,10 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
312
322
  method: "POST",
313
323
  body: data,
314
324
  });
315
- return okJson<unknown>(res, `Upload failed: ${path}`);
325
+ const body = await okJson<Partial<UploadResult>>(res, `Upload failed: ${path}`);
326
+ // The session echoes the path today; falling back to the request keeps a backend that says
327
+ // Nothing working rather than writing `undefined` into a document.
328
+ return { path: body.path ?? path, ...(body.size === undefined ? {} : { size: body.size }) };
316
329
  },
317
330
 
318
331
  async deleteFile(path: string) {
@@ -518,9 +531,66 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
518
531
 
519
532
  // ─── Components / code services (cloud: static-only posture) ──────────
520
533
 
534
+ /**
535
+ * Component discovery, by READING rather than executing.
536
+ *
537
+ * This returned `[]` under "no execution of project JS in the cloud", and the cost was not the
538
+ * loss of discovery — it was the canvas. `canvas-live-render` injects the `$elements` a
539
+ * document's tags need only when the registry is non-empty, so an empty registry meant nothing
540
+ * was ever registered, no component was ever fetched, and every component instance rendered as
541
+ * an unregistered custom element: an empty inline box. A page built from components came up as
542
+ * blank space between the layout's chrome.
543
+ *
544
+ * Discovery does not need to execute anything. For a JSON component it is a file read and a
545
+ * property lookup — `componentMetaFrom` is that function, shared with the two Bun backends so
546
+ * "which `state` entries are props" has one answer. Non-JSON component formats DO need a
547
+ * project-supplied parser, and those stay out: the posture is kept where it actually applies
548
+ * rather than across the whole feature.
549
+ *
550
+ * Bounded rather than unbounded: the walk skips the directories a scan has no business in and
551
+ * stops at a depth and a file count, because this runs against a remote session over HTTP and a
552
+ * pathological tree must not turn opening a project into thousands of requests.
553
+ */
521
554
  async discoverComponents() {
522
- // No execution of project JS in the cloud (MVP security posture).
523
- return [];
555
+ const SKIP = new Set(["node_modules", "dist", ".git", ".claude", ".obsidian", "build"]);
556
+ const MAX_FILES = 400;
557
+ const MAX_DEPTH = 6;
558
+ const jsonPaths: string[] = [];
559
+
560
+ const walk = async (dir: string, depth: number): Promise<void> => {
561
+ if (depth > MAX_DEPTH || jsonPaths.length >= MAX_FILES) {
562
+ return;
563
+ }
564
+ let entries: DirEntry[];
565
+ try {
566
+ entries = await platform.listDirectory(dir);
567
+ } catch {
568
+ return; // A directory that vanished mid-walk is not a discovery failure.
569
+ }
570
+ const dirs: string[] = [];
571
+ for (const entry of entries) {
572
+ if (entry.type === "directory") {
573
+ if (!SKIP.has(entry.name) && !entry.name.startsWith(".")) {
574
+ dirs.push(entry.path);
575
+ }
576
+ } else if (entry.name.endsWith(".json") && jsonPaths.length < MAX_FILES) {
577
+ jsonPaths.push(entry.path);
578
+ }
579
+ }
580
+ await Promise.all(dirs.map(async (d) => walk(d, depth + 1)));
581
+ };
582
+ await walk("", 0);
583
+
584
+ const metas = await Promise.all(
585
+ jsonPaths.map(async (path) => {
586
+ try {
587
+ return componentMetaFrom(JSON.parse(await platform.readFile(path)), path);
588
+ } catch {
589
+ return null; // Not JSON, not readable, or not a component — all the same answer here.
590
+ }
591
+ }),
592
+ );
593
+ return metas.filter((m): m is NonNullable<typeof m> => m !== null) as ComponentMeta[];
524
594
  },
525
595
 
526
596
  async codeService() {
@@ -38,6 +38,7 @@ import type {
38
38
  StarterInfo,
39
39
  } from "../types";
40
40
  import { problemDetail, problemMessage } from "@jxsuite/protocol";
41
+ import type { UploadResult } from "@jxsuite/protocol";
41
42
 
42
43
  /** A directory entry from the server, tolerating extra wire fields. */
43
44
  type WireDirEntry = DirEntry & Record<string, unknown>;
@@ -391,7 +392,10 @@ export function createDevServerPlatform() {
391
392
  if (!res.ok) {
392
393
  throw new Error(`Upload failed: ${path}`);
393
394
  }
394
- return await res.json();
395
+ const body = (await res.json()) as Partial<UploadResult>;
396
+ // The dev server echoes the path today; falling back to the request keeps a backend that says
397
+ // Nothing working rather than writing `undefined` into a document.
398
+ return { path: body.path ?? path, ...(body.size === undefined ? {} : { size: body.size }) };
395
399
  },
396
400
 
397
401
  /** @param {string} path */
@@ -13,14 +13,54 @@
13
13
 
14
14
  import type { AiModelsResponse } from "@jxsuite/protocol";
15
15
  import { getPlatform } from "../platform";
16
- import { getBaseUrl, getOpenAiKey, hasOpenAiKey } from "./ai-settings";
16
+ import { getBaseUrl, getOpenAiKey, hasOpenAiKey, storedModel } from "./ai-settings";
17
+ import { SETTINGS } from "./settings/definitions";
18
+ import { onSettingsChanged } from "./settings/kernel";
17
19
 
18
20
  export interface AiModel {
19
21
  id: string;
20
22
  name: string;
21
23
  }
22
24
 
25
+ /**
26
+ * The credentials a model list belongs to.
27
+ *
28
+ * Two fields, taken together or not at all. The per-field overrides this replaces let a caller pass
29
+ * a draft key with a stored base URL, which is a pair that was never configured anywhere.
30
+ */
31
+ export interface AiCredentials {
32
+ apiKey: string;
33
+ baseUrl: string;
34
+ }
35
+
36
+ /** The credentials the app is configured with right now. */
37
+ export function aiConnection(): AiCredentials {
38
+ return { apiKey: getOpenAiKey(), baseUrl: getBaseUrl() };
39
+ }
40
+
41
+ /**
42
+ * An opaque identity for a credential pair.
43
+ *
44
+ * NUL-joined rather than concatenated: a key ending in the base URL's first characters would
45
+ * otherwise be indistinguishable from a shorter key and a longer URL.
46
+ */
47
+ function fingerprint(credentials: AiCredentials): string {
48
+ return `${credentials.apiKey}\u0000${credentials.baseUrl}`;
49
+ }
50
+
23
51
  let cache: AiModel[] | null = null;
52
+
53
+ /**
54
+ * Which credentials {@link cache} was listed under.
55
+ *
56
+ * The cache used to be unkeyed, and two callers with different credentials wrote to it: the
57
+ * credentials form fetches with the drafts being edited, while the capability probe fetches with
58
+ * whatever is stored. Whichever landed last won, so the chat composer's picker could list a
59
+ * different provider's models than the one actually configured — which is exactly what a user saw
60
+ * after configuring a provider and finding the shell offering someone else's catalogue.
61
+ */
62
+ let cacheKey = "";
63
+
24
64
  let proxyConfigured = false;
25
65
  let proxyManaged = false;
26
66
  let proxyDefaultModel = "";
@@ -34,9 +74,17 @@ let proxyCode: AiModelsResponse["code"];
34
74
  let proxyProbe: Promise<void> | null = null;
35
75
  const probeListeners = new Set<() => void>();
36
76
 
37
- /** Drop the cached model list (call after credentials/endpoint changes). */
38
- export function invalidateModelCache() {
77
+ /**
78
+ * Drop the cached list and the capability reading.
79
+ *
80
+ * No surface calls this: it runs off the settings subscription below, because "the credentials
81
+ * changed" is a fact about the store rather than something each form has to remember to announce —
82
+ * and a form that forgot left every gate reading a stale capability probe. It stays exported only
83
+ * so a test can start from a cold module.
84
+ */
85
+ export function resetModelCache() {
39
86
  cache = null;
87
+ cacheKey = "";
40
88
  proxyConfigured = false;
41
89
  proxyManaged = false;
42
90
  proxyDefaultModel = "";
@@ -47,6 +95,28 @@ export function invalidateModelCache() {
47
95
  proxyProbe = null;
48
96
  }
49
97
 
98
+ /** The keys whose value changes what a provider would answer with. */
99
+ const CONNECTION_KEYS = new Set<string>([
100
+ SETTINGS.aiOpenAiKey.key,
101
+ SETTINGS.aiBaseUrl.key,
102
+ SETTINGS.cfToken.key,
103
+ SETTINGS.cfAccountId.key,
104
+ ]);
105
+
106
+ onSettingsChanged((keys) => {
107
+ if (keys.some((key) => CONNECTION_KEYS.has(key))) {
108
+ resetModelCache();
109
+ }
110
+ });
111
+
112
+ /**
113
+ * The cached list, if it was listed under `credentials`. Null when it was not, or when there is
114
+ * none — a caller must not show one provider's catalogue for another's key.
115
+ */
116
+ export function cachedModels(credentials: AiCredentials): AiModel[] | null {
117
+ return cache && cacheKey === fingerprint(credentials) ? cache : null;
118
+ }
119
+
50
120
  /**
51
121
  * Run the capability probe once, repainting `onSettled` when it lands. Idempotent: concurrent and
52
122
  * repeated callers share the single in-flight fetch, and every registered host is notified. Probe
@@ -108,6 +178,23 @@ export function getProxyDefaultModel(): string {
108
178
  return proxyDefaultModel;
109
179
  }
110
180
 
181
+ /**
182
+ * The model to SEND: what the user chose, else what the backend says it prefers, else the built-in
183
+ * default.
184
+ *
185
+ * The middle term is the point. A managed Workers AI backend declares its own `defaultModel`, and
186
+ * that value was fetched, stored and never read — so a user who had chosen nothing got the
187
+ * hardcoded `"gpt-4o"`, which Workers AI does not serve. Asking the backend what it prefers before
188
+ * falling back to a name invented here is the difference between working and a 404 on the first
189
+ * message.
190
+ *
191
+ * It lives here rather than in `ai-settings.ts` because only this module knows what the proxy said,
192
+ * and `ai-settings` must not import it — this module already imports `ai-settings`.
193
+ */
194
+ export function preferredModel(): string {
195
+ return storedModel() || getProxyDefaultModel() || SETTINGS.aiModel.default;
196
+ }
197
+
111
198
  /**
112
199
  * The sibling of the chat route, replacing the last path segment and keeping everything else.
113
200
  *
@@ -146,9 +233,13 @@ export function siblingRoute(chatUrl: string, segment: string): string {
146
233
  * @returns {Promise<AiModel[]>}
147
234
  */
148
235
  export async function fetchAvailableModels(
149
- opts: { apiKey?: string; baseUrl?: string; force?: boolean } = {},
236
+ opts: { credentials?: AiCredentials; force?: boolean } = {},
150
237
  ): Promise<AiModel[]> {
151
- if (cache && !opts.force) {
238
+ /* Resolve the credentials BEFORE consulting the cache. Checking the cache first is what let a
239
+ list fetched under one provider be handed to a caller asking about another. */
240
+ const credentials = opts.credentials ?? aiConnection();
241
+ const key = fingerprint(credentials);
242
+ if (cache && cacheKey === key && !opts.force) {
152
243
  return cache;
153
244
  }
154
245
  const plat = getPlatform();
@@ -156,13 +247,11 @@ export async function fetchAvailableModels(
156
247
  const modelsUrl = siblingRoute(chatUrl, "models");
157
248
 
158
249
  const headers: Record<string, string> = {};
159
- const apiKey = opts.apiKey || getOpenAiKey();
160
- if (apiKey) {
161
- headers["X-Api-Key"] = apiKey;
250
+ if (credentials.apiKey) {
251
+ headers["X-Api-Key"] = credentials.apiKey;
162
252
  }
163
- const baseUrl = opts.baseUrl || getBaseUrl();
164
- if (baseUrl) {
165
- headers["X-Api-Base-URL"] = baseUrl;
253
+ if (credentials.baseUrl) {
254
+ headers["X-Api-Base-URL"] = credentials.baseUrl;
166
255
  }
167
256
 
168
257
  const resp = await fetch(modelsUrl, { headers });
@@ -175,5 +264,6 @@ export async function fetchAvailableModels(
175
264
  proxyDefaultModel = data.defaultModel ?? "";
176
265
  proxyCode = data.code;
177
266
  cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
267
+ cacheKey = key;
178
268
  return cache;
179
269
  }
@@ -1,52 +1,51 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Ai-settings.js — local persistence for the AI assistant's provider settings.
3
+ * Ai-settings.js — the AI assistant's provider settings, over the settings kernel.
4
4
  *
5
5
  * The Stack B proxy reads the OpenAI key from the `X-Api-Key` header (falling back to the server's
6
- * `OPENAI_API_KEY` env var). Studio stores a user-supplied key in localStorage so the browser/dev
7
- * build works without an env var. The key never leaves the machine except to the same-origin proxy.
6
+ * `OPENAI_API_KEY` env var). Studio stores a user-supplied key so the browser/dev build works
7
+ * without an env var. The key never leaves the machine except to the same-origin proxy.
8
+ *
9
+ * This module is now a typed façade over `settings/kernel.ts` — the storage keys, defaults and
10
+ * normalisation live in `settings/definitions.ts`, and the kernel owns the cache, the write queue
11
+ * and change notification. What it keeps is the vocabulary: callers say `getOpenAiKey()`, not
12
+ * `readSetting(SETTINGS.aiOpenAiKey)`.
13
+ *
14
+ * **Blank no longer deletes.** `setOpenAiKey("")` stores an empty string; {@link clearAiProvider}
15
+ * is the revoke path. The old conflation read as a convenience right up until a form that blanked
16
+ * its own drafts on Save called these setters with them, and a second Save revoked the credentials
17
+ * the first had stored.
8
18
  *
9
19
  * @license MIT
10
20
  */
11
21
 
12
- import { persistSettings } from "./settings-store";
13
-
14
- const KEY_STORAGE = "jx.ai.openaiKey";
15
- const BASE_URL_STORAGE = "jx.ai.baseUrl";
16
- const MODEL_STORAGE = "jx.ai.model";
17
- const DEFAULT_MODEL = "gpt-4o";
22
+ import { SETTINGS } from "./settings/definitions";
23
+ import {
24
+ clearSettings,
25
+ hasSetting,
26
+ readStoredSetting,
27
+ setSetting,
28
+ setSettings,
29
+ } from "./settings/kernel";
18
30
 
19
- /** @returns {string} The stored OpenAI key, or "" if none/unavailable. */
31
+ /** @returns {string} The stored OpenAI key, or "" if none. */
20
32
  export function getOpenAiKey() {
21
- try {
22
- return globalThis.localStorage?.getItem(KEY_STORAGE) ?? "";
23
- } catch {
24
- return "";
25
- }
33
+ return readStoredSetting(SETTINGS.aiOpenAiKey);
26
34
  }
27
35
 
28
36
  /**
29
- * Persist (or clear) the OpenAI key.
37
+ * Persist the OpenAI key.
30
38
  *
31
- * @param {string} key - The key to store; an empty/blank value clears it.
39
+ * @param {string} key - The key to store. A blank value stores blank; it does NOT clear — see
40
+ * {@link clearAiProvider}.
32
41
  */
33
42
  export function setOpenAiKey(key: string) {
34
- try {
35
- const trimmed = (key || "").trim();
36
- if (trimmed) {
37
- globalThis.localStorage?.setItem(KEY_STORAGE, trimmed);
38
- } else {
39
- globalThis.localStorage?.removeItem(KEY_STORAGE);
40
- }
41
- persistSettings();
42
- } catch {
43
- /* LocalStorage unavailable — settings are not persisted. */
44
- }
43
+ setSetting(SETTINGS.aiOpenAiKey, key || "");
45
44
  }
46
45
 
47
46
  /** @returns {boolean} Whether a non-empty key is stored. */
48
47
  export function hasOpenAiKey() {
49
- return getOpenAiKey().length > 0;
48
+ return hasSetting(SETTINGS.aiOpenAiKey);
50
49
  }
51
50
 
52
51
  /**
@@ -54,59 +53,58 @@ export function hasOpenAiKey() {
54
53
  * or "" to use the proxy's default (`https://api.openai.com/v1` / server `OPENAI_BASE_URL`).
55
54
  */
56
55
  export function getBaseUrl() {
57
- try {
58
- return globalThis.localStorage?.getItem(BASE_URL_STORAGE) ?? "";
59
- } catch {
60
- return "";
61
- }
56
+ return readStoredSetting(SETTINGS.aiBaseUrl);
62
57
  }
63
58
 
59
+ // ─── Model selection ────────────────────────────────────────────────────────
60
+
64
61
  /**
65
- * Persist (or clear) the base URL override.
62
+ * The model the user actually chose, with no default standing in for silence.
66
63
  *
67
- * @param {string} url - The base URL; an empty/blank value clears the override.
64
+ * The getter this replaced masked _unset_ as `"gpt-4o"`, which is right for a sender and wrong for
65
+ * anything that writes back: a form prefilled from it and then saved persists a choice nobody made,
66
+ * which is how `jx.ai.model: "gpt-4o"` came to be the only surviving key in a settings file whose
67
+ * owner had configured a different provider entirely. Senders want
68
+ * {@link ../services/ai-models!preferredModel} instead.
69
+ *
70
+ * @returns {string} The stored model ID, or "" when none has been chosen.
68
71
  */
69
- export function setBaseUrl(url: string) {
70
- try {
71
- const trimmed = (url || "").trim().replace(/\/+$/, "");
72
- if (trimmed) {
73
- globalThis.localStorage?.setItem(BASE_URL_STORAGE, trimmed);
74
- } else {
75
- globalThis.localStorage?.removeItem(BASE_URL_STORAGE);
76
- }
77
- persistSettings();
78
- } catch {
79
- /* LocalStorage unavailable — settings are not persisted. */
80
- }
72
+ export function storedModel() {
73
+ return readStoredSetting(SETTINGS.aiModel);
81
74
  }
82
75
 
83
- // ─── Model selection ────────────────────────────────────────────────────────
76
+ /**
77
+ * Persist the selected model ID.
78
+ *
79
+ * @param {string} modelId - The model ID to store. A blank value stores blank; it does NOT clear.
80
+ */
81
+ export function setModel(modelId: string) {
82
+ setSetting(SETTINGS.aiModel, modelId || "");
83
+ }
84
84
 
85
- /** @returns {string} The stored model ID, or the default ("gpt-4o"). */
86
- export function getModel() {
87
- try {
88
- return globalThis.localStorage?.getItem(MODEL_STORAGE) || DEFAULT_MODEL;
89
- } catch {
90
- return DEFAULT_MODEL;
91
- }
85
+ /**
86
+ * Store the provider's key, endpoint and model as ONE change.
87
+ *
88
+ * What a credentials form's Save should call. Three separate setters coalesce into one write
89
+ * anyway, but they announce three times, and each intermediate announcement describes a provider
90
+ * the user never asked for — a new key against the old endpoint, then against no model.
91
+ *
92
+ * @param {{ apiKey: string; baseUrl: string; model: string }} provider
93
+ */
94
+ export function saveAiProvider(provider: { apiKey: string; baseUrl: string; model: string }) {
95
+ setSettings([
96
+ [SETTINGS.aiOpenAiKey, provider.apiKey],
97
+ [SETTINGS.aiBaseUrl, provider.baseUrl],
98
+ [SETTINGS.aiModel, provider.model],
99
+ ]);
92
100
  }
93
101
 
94
102
  /**
95
- * Persist the selected model ID.
103
+ * Forget the provider entirely — key, endpoint and model.
96
104
  *
97
- * @param {string} modelId - The model ID to store; an empty/blank value clears it, reverting to
98
- * default.
105
+ * The one deletion path, and the one the Accounts list's Disconnect uses. Deliberately all three
106
+ * together: a key with an orphaned endpoint is a state no surface knows how to describe.
99
107
  */
100
- export function setModel(modelId: string) {
101
- try {
102
- const trimmed = (modelId || "").trim();
103
- if (trimmed) {
104
- globalThis.localStorage?.setItem(MODEL_STORAGE, trimmed);
105
- } else {
106
- globalThis.localStorage?.removeItem(MODEL_STORAGE);
107
- }
108
- persistSettings();
109
- } catch {
110
- /* LocalStorage unavailable — settings are not persisted. */
111
- }
108
+ export function clearAiProvider() {
109
+ clearSettings([SETTINGS.aiOpenAiKey, SETTINGS.aiBaseUrl, SETTINGS.aiModel]);
112
110
  }
@@ -1,58 +1,40 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Cf-settings — local persistence for the Cloudflare publish connection on
4
- * platforms without a hosted OAuth broker (dev server, desktop). The API
5
- * token is stored like the AI key: localStorage first, mirrored through the
6
- * platform settings backend where one exists. It only ever leaves the machine
7
- * to the same-origin `/__studio/cf/proxy`, which forwards it as a bearer to
3
+ * Cf-settings — the Cloudflare publish connection on platforms without a hosted OAuth broker (dev
4
+ * server, desktop). The API token is stored like the AI key, over `settings/kernel.ts`. It only ever
5
+ * leaves the machine to the same-origin `/__studio/cf/proxy`, which forwards it as a bearer to
8
6
  * api.cloudflare.com (not CORS-enabled, hence the proxy).
9
7
  *
8
+ * As with the AI provider, a blank value is a VALUE: {@link clearCfConnection} is the deletion path,
9
+ * so nothing can revoke a connection by rendering an empty field into a setter.
10
+ *
10
11
  * @license MIT
11
12
  */
12
13
 
13
- import { persistSettings } from "./settings-store";
14
-
15
- const TOKEN_STORAGE = "jx.cf.token";
16
- const ACCOUNT_STORAGE = "jx.cf.accountId";
17
-
18
- function read(key: string): string {
19
- try {
20
- return globalThis.localStorage?.getItem(key) ?? "";
21
- } catch {
22
- return "";
23
- }
24
- }
25
-
26
- function write(key: string, value: string): void {
27
- try {
28
- const trimmed = (value || "").trim();
29
- if (trimmed) {
30
- globalThis.localStorage?.setItem(key, trimmed);
31
- } else {
32
- globalThis.localStorage?.removeItem(key);
33
- }
34
- } catch {
35
- // Storage unavailable (private mode) — the connection just won't persist.
36
- }
37
- persistSettings();
38
- }
14
+ import { SETTINGS } from "./settings/definitions";
15
+ import { clearSettings, readStoredSetting, setSetting } from "./settings/kernel";
39
16
 
40
17
  /** The stored Cloudflare API token, or "" when not connected. */
41
18
  export function getCfToken(): string {
42
- return read(TOKEN_STORAGE);
19
+ return readStoredSetting(SETTINGS.cfToken);
43
20
  }
44
21
 
45
- /** Persist (or clear, with a blank value) the Cloudflare API token. */
22
+ /** Persist the Cloudflare API token. Blank stores blank — see {@link clearCfConnection}. */
46
23
  export function setCfToken(token: string): void {
47
- write(TOKEN_STORAGE, token);
24
+ setSetting(SETTINGS.cfToken, token || "");
48
25
  }
49
26
 
50
27
  /** The selected Cloudflare account id, or "" when none chosen yet. */
51
28
  export function getCfAccountId(): string {
52
- return read(ACCOUNT_STORAGE);
29
+ return readStoredSetting(SETTINGS.cfAccountId);
53
30
  }
54
31
 
55
- /** Persist (or clear) the selected Cloudflare account id. */
32
+ /** Persist the selected Cloudflare account id. Blank stores blank. */
56
33
  export function setCfAccountId(accountId: string): void {
57
- write(ACCOUNT_STORAGE, accountId);
34
+ setSetting(SETTINGS.cfAccountId, accountId || "");
35
+ }
36
+
37
+ /** Forget the connection — token and account together. What Disconnect calls. */
38
+ export function clearCfConnection(): void {
39
+ clearSettings([SETTINGS.cfToken, SETTINGS.cfAccountId]);
58
40
  }
@@ -26,7 +26,8 @@ import type { ToolAvailability } from "./gated-registry";
26
26
  import { adoptProject } from "./project-adoption";
27
27
  import { runAgentLoop } from "./tool-executor";
28
28
  import { AI_TOOL_TIERS, buildSystemPrompt, tierActive } from "./ai-system-prompt";
29
- import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
29
+ import { getBaseUrl, getOpenAiKey } from "./ai-settings";
30
+ import { preferredModel } from "./ai-models";
30
31
  import { trimContext } from "./context-manager";
31
32
  import { renderCheck } from "./render-critic";
32
33
  import { openFileInTab, reloadFileInTab } from "../files/files";
@@ -58,7 +59,7 @@ function projectRoot() {
58
59
  * }}
59
60
  */
60
61
  export function createDocumentAssistant() {
61
- const chatState = createChatState({ model: getModel() });
62
+ const chatState = createChatState({ model: preferredModel() });
62
63
 
63
64
  /** The persisted session backing the live chat; null = fresh unsaved chat. */
64
65
  let sessionId: string | null = null;
@@ -212,7 +213,7 @@ export function createDocumentAssistant() {
212
213
  const chatUrl = await Promise.resolve(plat.aiChatUrl());
213
214
  // Re-read the persisted model each send: the session is constructed once at module load
214
215
  // (before the user sets a key/model), so the picker's choice must be picked up here.
215
- chatState.setModel(getModel());
216
+ chatState.setModel(preferredModel());
216
217
  const streamingClient = createProxyStreamingClient({
217
218
  chatUrl,
218
219
  model: chatState.model,
@@ -27,6 +27,9 @@
27
27
  * duplicate of `platform`: an activity spans a whole operation (an install, a deploy, a clone)
28
28
  * across many PAL calls, so the gaps between them look quiet at the seam while the operation is
29
29
  * plainly mid-flight. A finished entry never blocks — the list stays on screen forever.
30
+ * 7. `grid-idle.gridIdleBlockers()` — tables still building, or built but not yet showing the
31
+ * selection range `selectableRange: 1` gives them. Nothing else covered Tabulator: a grid
32
+ * command resolves when the panel mounts, which is several frames before the table is drawn.
30
33
  *
31
34
  * The consumers are the `packages/studio:verify` skill, the screenshot runner and P4.2's Activity
32
35
  * tracker (this is a read-only projection of the same in-flight set that dock renders).
@@ -38,6 +41,7 @@ import { pendingSchedulers } from "../panels/panel-scheduler";
38
41
  import { canvasIdleBlockers } from "../canvas/iframe-host";
39
42
  import { platformInFlight } from "../platform";
40
43
  import { overlayIdleBlockers } from "../ui/layers";
44
+ import { gridIdleBlockers } from "../grid/grid-idle";
41
45
 
42
46
  /** One subsystem that can be outstanding, and its account of why. */
43
47
  export interface IdleSource {
@@ -69,7 +73,7 @@ export class NotIdleError extends Error {
69
73
  }
70
74
 
71
75
  /**
72
- * The six live sources.
76
+ * The seven live sources.
73
77
  *
74
78
  * Built per call rather than held in a module constant so a test can substitute one without
75
79
  * unpicking the others, and so a second window's hosts are never read through the first window's
@@ -101,6 +105,7 @@ export function defaultIdleSources(): IdleSource[] {
101
105
  },
102
106
  { blockers: overlayIdleBlockers, name: "overlay" },
103
107
  { blockers: activityIdleBlockers, name: "activity" },
108
+ { blockers: gridIdleBlockers, name: "grid" },
104
109
  ];
105
110
  }
106
111