@jxsuite/studio 4.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 (86) hide show
  1. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  2. package/dist/chunks/{Overlay.dev-723bv0df.js → Overlay.dev-etqjwyf9.js} +4 -4
  3. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  4. package/dist/chunks/{content-source-t88vb2dr.js → content-source-48xg86wj.js} +17 -17
  5. package/dist/chunks/{convert-to-repeater-dhw1nrz9.js → convert-to-repeater-43tfn7cs.js} +24 -24
  6. package/dist/chunks/{entry-commands-6z6bhxg0.js → entry-commands-e63f1xc0.js} +20 -20
  7. package/dist/chunks/{extension-sections-3ydgjbdy.js → extension-sections-txv08xxg.js} +23 -23
  8. package/dist/chunks/{file-ops-rskmhxyq.js → file-ops-wbh4hhsx.js} +16 -16
  9. package/dist/chunks/{files-0cratsr0.js → files-3kfkwhk4.js} +25 -24
  10. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  11. package/dist/chunks/{format-host-tgcmfs4p.js → format-host-w16q81tf.js} +2 -2
  12. package/dist/chunks/fs-events-hrrg328g.js +1 -1
  13. package/dist/chunks/{git-panel-0yywyk76.js → git-panel-532whr1y.js} +16 -15
  14. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  15. package/dist/chunks/{jx-validate-8q2g05x1.js → jx-validate-8er6gbxy.js} +3 -3
  16. package/dist/chunks/{layers-panel-z3tqe5cd.js → layers-panel-07hm7g5j.js} +19 -19
  17. package/dist/chunks/{monaco-setup-rws7k4br.js → monaco-setup-vx9bhp1d.js} +2 -2
  18. package/dist/chunks/{publish-panel-54jvhskq.js → publish-panel-v9st96ja.js} +11 -10
  19. package/dist/chunks/{quick-search-cgfmjwq5.js → quick-search-zccjp6gv.js} +26 -25
  20. package/dist/chunks/{right-panel-cqp8bc4v.js → right-panel-vm3b6kwy.js} +17 -17
  21. package/dist/chunks/{sp-overlay.dev-yztmtrs1.js → sp-overlay.dev-qgvhnxf4.js} +5 -5
  22. package/dist/chunks/{sp-tooltip.dev-xcvpnqr0.js → sp-tooltip.dev-4sastdnw.js} +3 -3
  23. package/dist/chunks/{studio-txwkm6p9.js → studio-0bq6eh7v.js} +2 -2
  24. package/dist/chunks/studio-1ngbejft.js +181 -0
  25. package/dist/chunks/{studio-gv8vvshc.js → studio-1wagb550.js} +3 -3
  26. package/dist/chunks/{studio-kjjsjak4.js → studio-2b946z26.js} +58 -57
  27. package/dist/chunks/{studio-4pqqxbrt.js → studio-3bqpr0zh.js} +16 -16
  28. package/dist/chunks/{studio-v38hmvx1.js → studio-4b5z1x43.js} +42 -8
  29. package/dist/chunks/{studio-9c4k4vhv.js → studio-4n2b5gzb.js} +4 -4
  30. package/dist/chunks/{studio-p8j86wag.js → studio-4r1pv6zf.js} +2 -2
  31. package/dist/chunks/{studio-mmv72a27.js → studio-5fx3g9jg.js} +29 -48
  32. package/dist/chunks/{studio-5rywrkxz.js → studio-5myknm0a.js} +42 -42
  33. package/dist/chunks/{studio-sf7zjgv1.js → studio-6yw4y3my.js} +3 -3
  34. package/dist/chunks/{studio-pdfjntbk.js → studio-950k0dx6.js} +35 -35
  35. package/dist/chunks/{studio-eenfkt4e.js → studio-ahn3pdgx.js} +5 -5
  36. package/dist/chunks/{studio-eesa1myv.js → studio-b55y6ewx.js} +9 -9
  37. package/dist/chunks/{studio-cb12yz9w.js → studio-cmb03bg5.js} +99 -1264
  38. package/dist/chunks/studio-dm19q43r.js +195 -0
  39. package/dist/chunks/{studio-ahjfdy6e.js → studio-ef49pnzx.js} +6 -6
  40. package/dist/chunks/{studio-wx5609xd.js → studio-ew0djmhg.js} +10 -10
  41. package/dist/chunks/{studio-9r6j5ev1.js → studio-f3wqjtk5.js} +4 -3
  42. package/dist/chunks/{studio-8swrjbny.js → studio-fbf92ckf.js} +14 -14
  43. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  44. package/dist/chunks/{studio-ex8zyp98.js → studio-gfgb8pe6.js} +23 -184
  45. package/dist/chunks/{studio-r1r7afxx.js → studio-jc994dnr.js} +24 -24
  46. package/dist/chunks/{studio-yxrkyv8a.js → studio-jkftxyvz.js} +52 -3416
  47. package/dist/chunks/{studio-hbtp0eh1.js → studio-msd847zn.js} +13 -13
  48. package/dist/chunks/{studio-kyk2v92a.js → studio-mtfnaqs9.js} +14 -14
  49. package/dist/chunks/{studio-vadzgkrz.js → studio-n6j9t10q.js} +2 -2
  50. package/dist/chunks/{studio-68n67094.js → studio-q9jd1a60.js} +4 -4
  51. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  52. package/dist/chunks/{studio-htsf0w6m.js → studio-qjy2z9bv.js} +12 -30
  53. package/dist/chunks/{studio-2zkz8evn.js → studio-trxrabqf.js} +12 -12
  54. package/dist/chunks/{studio-5q69gfag.js → studio-x4qkdat5.js} +12 -11
  55. package/dist/chunks/studio-x813zp48.js +195 -0
  56. package/dist/chunks/{studio-1hbqnt19.js → studio-zqazsct2.js} +29 -29
  57. package/dist/chunks/{workspace-xpres40v.js → workspace-fjx52ryv.js} +4 -4
  58. package/dist/iframe-entry.js +34 -33
  59. package/dist/manifest.json +7 -0
  60. package/dist/studio.js +534 -546
  61. package/favicon.ico +0 -0
  62. package/index.html +1 -0
  63. package/package.json +15 -14
  64. package/src/about/about-modal.ts +4 -3
  65. package/src/files/files.ts +6 -4
  66. package/src/files/media-paths.ts +13 -42
  67. package/src/files/media-usage.ts +41 -131
  68. package/src/hosting/document.ts +2 -0
  69. package/src/hosting/layout.ts +8 -0
  70. package/src/new-project/import-tab.ts +37 -15
  71. package/src/new-project/location-fields.ts +8 -5
  72. package/src/panels/ai-chat/composer.ts +10 -0
  73. package/src/platforms/cloud.ts +241 -70
  74. package/src/publish/publish-panel.ts +62 -2
  75. package/src/services/ai-import-tools.ts +50 -12
  76. package/src/services/ai-models.ts +108 -1
  77. package/src/services/context-manager.ts +12 -1
  78. package/src/services/tool-executor.ts +11 -0
  79. package/src/settings/preferences-accounts.ts +193 -12
  80. package/src/settings/preferences-dialog.ts +75 -18
  81. package/src/types.ts +27 -3
  82. package/src/ui/ai-managed-connect.ts +62 -13
  83. package/src/ui/ai-model-picker.ts +29 -2
  84. package/src/ui/cf-account-picker.ts +200 -0
  85. package/styles/shell.css +36 -0
  86. package/dist/chunks/studio-bw5dz6km.js +0 -608
@@ -20,6 +20,16 @@ import { onSettingsChanged } from "./settings/kernel";
20
20
  export interface AiModel {
21
21
  id: string;
22
22
  name: string;
23
+ /**
24
+ * Whether the backend says the model can call tools.
25
+ *
26
+ * Absent when it did not say, which is the ordinary case for a BYOK provider — only a managed
27
+ * catalogue knows. `false` is therefore a statement and `undefined` is silence, and no gate may
28
+ * collapse the two: a Workers AI chat-only model must be labelled, an OpenAI model must not.
29
+ */
30
+ toolSupport?: boolean;
31
+ /** The backend's declared context window in tokens, when it declares one. */
32
+ contextWindow?: number;
23
33
  }
24
34
 
25
35
  /**
@@ -74,6 +84,23 @@ let proxyCode: AiModelsResponse["code"];
74
84
  let proxyProbe: Promise<void> | null = null;
75
85
  const probeListeners = new Set<() => void>();
76
86
 
87
+ /**
88
+ * How long a settled probe is trusted before a window refocus re-runs it.
89
+ *
90
+ * The probe is one-shot by construction (`proxyProbe ??=`) and the only thing that re-arms it is
91
+ * the settings subscription below — which never fires for a HOSTED grant, because nothing about it
92
+ * is stored in this browser. So a Cloudflare grant that lapsed mid-session left every gate showing
93
+ * the reading it took at boot: the assistant reported itself configured, and every send failed. Ten
94
+ * minutes is well inside the hour a Cloudflare access token lives.
95
+ */
96
+ export const PROBE_STALE_MS = 600_000;
97
+
98
+ /** When the probe last settled (0 = never). The age {@link PROBE_STALE_MS} is measured against. */
99
+ let probeSettledAt = 0;
100
+
101
+ /** Whether {@link installProbeRefresh} has already registered its listener. */
102
+ let probeRefreshInstalled = false;
103
+
77
104
  /**
78
105
  * Drop the cached list and the capability reading.
79
106
  *
@@ -93,6 +120,7 @@ export function resetModelCache() {
93
120
  would strand every gate on a permanent "unconfigured, unmanaged" reading — ensureProxyProbe
94
121
  would no-op forever and the managed option would vanish until a full reload. */
95
122
  proxyProbe = null;
123
+ probeSettledAt = 0;
96
124
  }
97
125
 
98
126
  /** The keys whose value changes what a provider would answer with. */
@@ -117,6 +145,33 @@ export function cachedModels(credentials: AiCredentials): AiModel[] | null {
117
145
  return cache && cacheKey === fingerprint(credentials) ? cache : null;
118
146
  }
119
147
 
148
+ /**
149
+ * A model's entry in whatever list is cached, by id.
150
+ *
151
+ * Deliberately NOT keyed by credentials, unlike {@link cachedModels}. That key exists because
152
+ * SHOWING one provider's catalogue under another's credentials is a lie about what is available;
153
+ * asking "what did the backend say about this id" is not — the answer is either recorded or it is
154
+ * not, and a mismatched key just yields `undefined`, which every caller already has to handle.
155
+ */
156
+ function cachedModel(id: string): AiModel | undefined {
157
+ return cache?.find((model) => model.id === id);
158
+ }
159
+
160
+ /**
161
+ * Whether the backend said this model can call tools — `undefined` when it said nothing.
162
+ *
163
+ * The distinction is the whole point: a chat-only model is a legitimate choice, so this labels and
164
+ * warns rather than gating, and silence must read as "no opinion" rather than as "no tools".
165
+ */
166
+ export function modelToolSupport(id: string): boolean | undefined {
167
+ return cachedModel(id)?.toolSupport;
168
+ }
169
+
170
+ /** The backend's declared context window for a model, in tokens — `undefined` when it declared none. */
171
+ export function modelContextWindow(id: string): number | undefined {
172
+ return cachedModel(id)?.contextWindow;
173
+ }
174
+
120
175
  /**
121
176
  * Run the capability probe once, repainting `onSettled` when it lands. Idempotent: concurrent and
122
177
  * repeated callers share the single in-flight fetch, and every registered host is notified. Probe
@@ -133,12 +188,50 @@ export function ensureProxyProbe(onSettled?: () => void) {
133
188
  // Unreachable proxy — the key gate stays up.
134
189
  })
135
190
  .then(() => {
191
+ probeSettledAt = Date.now();
136
192
  for (const notify of probeListeners) {
137
193
  notify();
138
194
  }
139
195
  });
140
196
  }
141
197
 
198
+ /**
199
+ * Re-run the capability probe when it is older than {@link PROBE_STALE_MS} and the backend holds
200
+ * the credentials. Returns whether it re-armed, so a caller (and a test) can see that it did.
201
+ *
202
+ * Managed-only on purpose. A BYOK reading changes only when the stored key does, and the settings
203
+ * subscription already covers that; a hosted grant expires on a clock this browser cannot see.
204
+ */
205
+ export function refreshStaleProbe(): boolean {
206
+ if (!proxyManaged || probeSettledAt === 0 || Date.now() - probeSettledAt < PROBE_STALE_MS) {
207
+ return false;
208
+ }
209
+ proxyProbe = null;
210
+ probeSettledAt = 0;
211
+ ensureProxyProbe();
212
+ return true;
213
+ }
214
+
215
+ /**
216
+ * Re-check the probe whenever the window regains focus, which is where a lapsed grant is noticed.
217
+ *
218
+ * `focus` rather than a timer: the reading only matters when somebody is looking at Studio, and the
219
+ * OAuth reconnect the stale reading would have hidden happens in another tab — so coming back IS
220
+ * the event. Idempotent, and a no-op outside a browser so the module stays importable under a
221
+ * bare-`bun` runner.
222
+ */
223
+ export function installProbeRefresh(): void {
224
+ if (typeof window === "undefined" || probeRefreshInstalled) {
225
+ return;
226
+ }
227
+ probeRefreshInstalled = true;
228
+ window.addEventListener("focus", () => {
229
+ refreshStaleProbe();
230
+ });
231
+ }
232
+
233
+ installProbeRefresh();
234
+
142
235
  /**
143
236
  * Whether the assistant can run at all: a key stored in this browser, or a backend that holds
144
237
  * credentials itself (managed cloud platforms, env-keyed dev servers). Every credentials gate must
@@ -263,7 +356,21 @@ export async function fetchAvailableModels(
263
356
  proxyManaged = data.managed === true;
264
357
  proxyDefaultModel = data.defaultModel ?? "";
265
358
  proxyCode = data.code;
266
- cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
359
+ /* Capabilities are kept, not dropped. The backend has reported `toolSupport` all along and the
360
+ ingest mapped `{id, name}` only, so a Workers AI model that cannot call tools looked exactly
361
+ like one that can — the agent loop ran, called nothing, and answered as if that were normal.
362
+ The keys are OMITTED rather than set to undefined: absent means "the backend said nothing",
363
+ which is a third state both consumers depend on. */
364
+ cache = (data.models || []).map((m) => {
365
+ const model: AiModel = { id: m.id, name: m.name || m.id };
366
+ if (typeof m.contextWindow === "number") {
367
+ model.contextWindow = m.contextWindow;
368
+ }
369
+ if (typeof m.toolSupport === "boolean") {
370
+ model.toolSupport = m.toolSupport;
371
+ }
372
+ return model;
373
+ });
267
374
  cacheKey = key;
268
375
  return cache;
269
376
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { createChatState } from "@jxsuite/ai/chat-state";
15
+ import { modelContextWindow } from "./ai-models";
15
16
 
16
17
  /** Shape of a single entry returned by `chatState.toMessagesArray()`. */
17
18
  interface MessageArrayEntry {
@@ -52,7 +53,13 @@ const BUDGET_FRACTION = 0.8;
52
53
  const WARN_FRACTION = 0.5;
53
54
 
54
55
  /**
55
- * Resolve the context window (tokens) for a model id via longest-prefix match.
56
+ * Resolve the context window (tokens) for a model id: what the backend reported, else a
57
+ * longest-prefix match on the table above, else the conservative default.
58
+ *
59
+ * The backend's number goes first because the table can only ever know the names it was written
60
+ * with. Every `@cf/*` model missed it and was budgeted at {@link DEFAULT_CONTEXT_WINDOW}, so a
61
+ * managed 128k model started dropping the oldest turns at about 25.6k — a fifth of what it could
62
+ * hold, on the one backend that actually publishes the figure.
56
63
  *
57
64
  * @param {string | undefined} model
58
65
  * @returns {number}
@@ -61,6 +68,10 @@ function contextWindowFor(model: string | undefined): number {
61
68
  if (!model) {
62
69
  return DEFAULT_CONTEXT_WINDOW;
63
70
  }
71
+ const reported = modelContextWindow(model);
72
+ if (reported !== undefined && reported > 0) {
73
+ return reported;
74
+ }
64
75
  const id = model.toLowerCase();
65
76
  let best = 0;
66
77
  let window = DEFAULT_CONTEXT_WINDOW;
@@ -27,6 +27,7 @@ import type { ToolRegistry } from "@jxsuite/ai/tools";
27
27
 
28
28
  import type { Tab } from "../tabs/tab";
29
29
  import { batchTab, beginBatch, endBatch } from "../tabs/transact";
30
+ import { ensureProxyProbe, resetModelCache } from "./ai-models";
30
31
  import { beginTurn, endTurn } from "./ai-writes";
31
32
  import { beginToolCall, beginTurnSignal, endTurnSignal } from "./ai-turn-signal";
32
33
 
@@ -112,6 +113,7 @@ export async function runAgentLoop({
112
113
  const toolCalls = new Map<string, { name: string; arguments: string }>();
113
114
  let stopReason = "stop";
114
115
  let streamError = null;
116
+ let streamErrorCode: string | undefined;
115
117
 
116
118
  for await (const event of streamingClient.streamChat(
117
119
  messages,
@@ -147,6 +149,7 @@ export async function runAgentLoop({
147
149
  }
148
150
  case "error": {
149
151
  streamError = event.message;
152
+ streamErrorCode = event.code;
150
153
  break;
151
154
  }
152
155
  default: {
@@ -158,6 +161,14 @@ export async function runAgentLoop({
158
161
  chatState.finishStream(stopReason);
159
162
 
160
163
  if (streamError) {
164
+ /* A lapsed hosted grant is the one stream error that makes the app's OWN reading wrong.
165
+ The probe settles once at boot, so without this every gate keeps offering an assistant
166
+ that cannot answer, and the send that just failed is the only evidence anyone has. Drop
167
+ the reading and re-probe, so the next paint carries the Reconnect CTA instead. */
168
+ if (streamErrorCode === "cf_reconnect_required") {
169
+ resetModelCache();
170
+ ensureProxyProbe();
171
+ }
161
172
  chatState.setError(streamError);
162
173
  return;
163
174
  }
@@ -17,12 +17,31 @@
17
17
  *
18
18
  * **No secret is ever returned.** A record says whether something is stored and what it is for;
19
19
  * printing a token into a list is how a screenshot leaks one.
20
+ *
21
+ * **Cloudflare has two spellings, and this file used to know only one.** On a platform that brokers
22
+ * the connection (Jx Cloud), there is no local token to enumerate — `getCfToken()` is empty by
23
+ * design — so the row said "Not connected" to every hosted user, named no account, and offered a
24
+ * Disconnect that cleared nothing server-side. The hosted state comes from the PAL instead, cached
25
+ * here because a template must not start a fetch on every repaint.
20
26
  */
21
27
 
22
28
  import { clearGithubToken, githubTokenLocation, githubTokenStored } from "../github/github-auth";
23
29
  import { clearCfConnection, getCfAccountId, getCfToken } from "../services/cf-settings";
24
30
  import { clearAiProvider, getBaseUrl, hasOpenAiKey } from "../services/ai-settings";
25
- import { preferredModel } from "../services/ai-models";
31
+ import { preferredModel, resetModelCache } from "../services/ai-models";
32
+ import { getPlatform, hasPlatform } from "../platform";
33
+ import type { CfConnection } from "../types";
34
+
35
+ /** One thing a row can do besides existing. */
36
+ export interface AccountAction {
37
+ /** Stable key — the button's `data-action`, so a test names an intent rather than a label. */
38
+ id: string;
39
+ label: string;
40
+ /** Spectrum `variant`; omitted for the default treatment. */
41
+ variant?: string;
42
+ /** Do it. Async ones repaint the sheet when they settle. */
43
+ run: () => void | Promise<void>;
44
+ }
26
45
 
27
46
  /** One stored credential, as the Accounts section renders it. */
28
47
  export interface AccountRecord {
@@ -35,6 +54,178 @@ export interface AccountRecord {
35
54
  connected: boolean;
36
55
  /** Forget it. Idempotent: revoking a disconnected account is a no-op, not an error. */
37
56
  revoke: () => void;
57
+ /**
58
+ * What this row offers, when a plain Disconnect is not the whole story.
59
+ *
60
+ * Absent on the rows whose only verb is "forget it", which is every locally stored credential. A
61
+ * brokered Cloudflare connection has three more — reconnect a lapsed grant, choose an account,
62
+ * connect for the first time — and none of them is expressible as `revoke`.
63
+ */
64
+ actions?: AccountAction[];
65
+ }
66
+
67
+ // ─── The brokered Cloudflare connection ──────────────────────────────────────
68
+
69
+ /** What the broker last said, or "checking" before it has been asked. */
70
+ let _cfState: CfConnection | null | "checking" = "checking";
71
+
72
+ /** The in-flight read, so a repaint during one does not start a second. */
73
+ let _cfInFlight: Promise<void> | null = null;
74
+
75
+ /**
76
+ * Whether the platform brokers the Cloudflare connection rather than holding a pasted token.
77
+ *
78
+ * Keyed on `cfConnect` — the hosted OAuth flow — because that is the member whose presence means
79
+ * "there is a connection this app did not store locally".
80
+ */
81
+ export function platformBrokersCf(): boolean {
82
+ return hasPlatform() && Boolean(getPlatform().cfConnect);
83
+ }
84
+
85
+ /**
86
+ * Re-read the brokered connection and announce it. Always refetches; the Accounts section runs it
87
+ * when it is shown and after every action, because the answer changes server-side.
88
+ */
89
+ export async function refreshCfConnection(): Promise<void> {
90
+ if (!platformBrokersCf()) {
91
+ return;
92
+ }
93
+ _cfInFlight ??= (async () => {
94
+ try {
95
+ _cfState = (await getPlatform().cfConnection?.()) ?? null;
96
+ } catch {
97
+ /* An unreachable broker is not a connection. The row says "not connected", which is what the
98
+ app can act on; the error itself belongs to whatever surface made the call the user asked
99
+ for, not to a list that repaints itself. */
100
+ _cfState = null;
101
+ }
102
+ _cfInFlight = null;
103
+ notifyCredentialsChanged();
104
+ })();
105
+ await _cfInFlight;
106
+ }
107
+
108
+ /** Kick the first read, once. Safe to call from a template — later paints are no-ops. */
109
+ export function ensureCfConnection(): void {
110
+ if (platformBrokersCf() && _cfState === "checking" && !_cfInFlight) {
111
+ void refreshCfConnection();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Forget the cached broker answer.
117
+ *
118
+ * Called when Preferences opens, and that is the point: the row would otherwise paint the answer
119
+ * from the LAST visit — an hour-old "Connected — Acme" over a grant that has since lapsed — and
120
+ * only correct itself when the re-read landed. Saying "checking" while it checks is the honest
121
+ * version of the same half-second.
122
+ */
123
+ export function resetCfConnectionCache(): void {
124
+ _cfState = "checking";
125
+ _cfInFlight = null;
126
+ }
127
+
128
+ /** Run the hosted flow, then re-read. The models cache goes because the AI gate keys on it too. */
129
+ async function hostedConnect(): Promise<void> {
130
+ const outcome = await getPlatform().cfConnect?.();
131
+ if (outcome?.status === "connected" && !outcome.connection.accountId) {
132
+ /* Dynamically, and this is the load-bearing half: the picker imports THIS module for
133
+ `notifyCredentialsChanged`, so a static import here would close the cycle. */
134
+ const { openCfAccountPicker } = await import("../ui/cf-account-picker");
135
+ await openCfAccountPicker();
136
+ }
137
+ resetModelCache();
138
+ await refreshCfConnection();
139
+ }
140
+
141
+ /** The brokered row — five states, and each one names the next move. */
142
+ function brokeredCloudflareRecord(): AccountRecord {
143
+ const disconnect: AccountAction = {
144
+ id: "disconnect",
145
+ label: "Disconnect",
146
+ run: async () => {
147
+ await getPlatform().cfDisconnect?.();
148
+ resetModelCache();
149
+ await refreshCfConnection();
150
+ },
151
+ variant: "negative",
152
+ };
153
+ const connect = (label: string): AccountAction => ({
154
+ id: label === "Connect" ? "connect" : "reconnect",
155
+ label,
156
+ run: hostedConnect,
157
+ variant: "accent",
158
+ });
159
+ const base = { id: "cloudflare", label: "Cloudflare", revoke: () => {} };
160
+
161
+ if (_cfState === "checking") {
162
+ return { ...base, actions: [], connected: false, detail: "Checking your connection…" };
163
+ }
164
+ if (!_cfState?.connected) {
165
+ return {
166
+ ...base,
167
+ actions: [connect("Connect")],
168
+ connected: false,
169
+ detail: "Not connected. Connecting publishes this site and runs the assistant on Workers AI.",
170
+ };
171
+ }
172
+ const revoke = () => {
173
+ void disconnect.run();
174
+ };
175
+ if (_cfState.needsReconnect) {
176
+ return {
177
+ ...base,
178
+ actions: [connect("Reconnect"), disconnect],
179
+ connected: true,
180
+ detail: "Connection expired. Reconnect to keep publishing and the assistant working.",
181
+ revoke,
182
+ };
183
+ }
184
+ if (_cfState.needsAccount || !_cfState.accountId) {
185
+ return {
186
+ ...base,
187
+ actions: [
188
+ {
189
+ id: "choose-account",
190
+ label: "Choose account",
191
+ run: async () => {
192
+ const { openCfAccountPicker } = await import("../ui/cf-account-picker");
193
+ if (await openCfAccountPicker()) {
194
+ resetModelCache();
195
+ await refreshCfConnection();
196
+ }
197
+ },
198
+ variant: "accent",
199
+ },
200
+ disconnect,
201
+ ],
202
+ connected: true,
203
+ detail: "Connected, but no account is chosen yet — publishing and the assistant need one.",
204
+ revoke,
205
+ };
206
+ }
207
+ return {
208
+ ...base,
209
+ actions: [disconnect],
210
+ connected: true,
211
+ detail: `Connected — ${_cfState.accountName ?? _cfState.accountId}.`,
212
+ revoke,
213
+ };
214
+ }
215
+
216
+ /** The locally stored row: a pasted API token, on desktop and the dev server. */
217
+ function localCloudflareRecord(): AccountRecord {
218
+ const cfToken = getCfToken();
219
+ const cfAccount = getCfAccountId();
220
+ return {
221
+ connected: Boolean(cfToken),
222
+ detail: cfToken
223
+ ? `Connected${cfAccount ? ` — account ${cfAccount}` : ""}.`
224
+ : "Not connected. Publishing will ask when it needs it.",
225
+ id: "cloudflare",
226
+ label: "Cloudflare",
227
+ revoke: clearCfConnection,
228
+ };
38
229
  }
39
230
 
40
231
  /**
@@ -45,8 +236,6 @@ export interface AccountRecord {
45
236
  */
46
237
  export function listAccounts(): AccountRecord[] {
47
238
  const githubToken = githubTokenStored();
48
- const cfToken = getCfToken();
49
- const cfAccount = getCfAccountId();
50
239
  const model = preferredModel();
51
240
  const endpoint = getBaseUrl();
52
241
  return [
@@ -70,15 +259,7 @@ export function listAccounts(): AccountRecord[] {
70
259
  connected: hasOpenAiKey(),
71
260
  revoke: clearAiProvider,
72
261
  },
73
- {
74
- id: "cloudflare",
75
- label: "Cloudflare",
76
- detail: cfToken
77
- ? `Connected${cfAccount ? ` — account ${cfAccount}` : ""}.`
78
- : "Not connected. Publishing will ask when it needs it.",
79
- connected: Boolean(cfToken),
80
- revoke: clearCfConnection,
81
- },
262
+ platformBrokersCf() ? brokeredCloudflareRecord() : localCloudflareRecord(),
82
263
  ];
83
264
  }
84
265
 
@@ -38,7 +38,15 @@ import { createManagedConnect } from "../ui/ai-managed-connect";
38
38
  import { activeRegistry } from "../commands/active-registry";
39
39
  import { shortcutReference, SCOPE_LABELS } from "../commands/reference";
40
40
  import { chordFromEvent } from "../commands/keymap";
41
- import { listAccounts, notifyCredentialsChanged, revokeAccount } from "./preferences-accounts";
41
+ import {
42
+ ensureCfConnection,
43
+ listAccounts,
44
+ notifyCredentialsChanged,
45
+ platformBrokersCf,
46
+ refreshCfConnection,
47
+ resetCfConnectionCache,
48
+ revokeAccount,
49
+ } from "./preferences-accounts";
42
50
  import { applyKeybindingOverrides, rebindCommand, resetKeybinding } from "./preferences-keymap";
43
51
  import { overlayRegion } from "../ui/regions";
44
52
 
@@ -97,6 +105,19 @@ function credentialsChanged(): void {
97
105
  repaint();
98
106
  }
99
107
 
108
+ /**
109
+ * Showing a section is a chance to find out it is stale.
110
+ *
111
+ * Only Accounts has anything to re-read: the brokered Cloudflare connection lives on the server, so
112
+ * a grant that lapsed while Preferences was closed — or while the author was in Keyboard — would
113
+ * otherwise be reported from a cache taken before it did.
114
+ */
115
+ function sectionShown(): void {
116
+ if (_section === "accounts" && platformBrokersCf()) {
117
+ void refreshCfConnection().then(repaint);
118
+ }
119
+ }
120
+
100
121
  /** The shared credentials form — draft state lives inside the form's closure. */
101
122
  const credsForm = createAiCredentialsForm({
102
123
  intro: html`
@@ -144,7 +165,52 @@ function assistantTpl(): TemplateResult {
144
165
  return html`<div class="prefs-assistant">${managedConnect.render()} ${credsForm.render()}</div>`;
145
166
  }
146
167
 
168
+ /**
169
+ * The buttons on one row.
170
+ *
171
+ * A record that declares `actions` decides its own verbs; everything else keeps the original rule —
172
+ * a connected credential offers Disconnect and a disconnected one offers nothing. The async ones
173
+ * repaint when they settle, because a hosted Reconnect is a round trip through a Cloudflare popup
174
+ * and the row must not still be reading "expired" when it comes back.
175
+ */
176
+ function accountActionsTpl(account: ReturnType<typeof listAccounts>[number]) {
177
+ if (account.actions) {
178
+ return account.actions.map(
179
+ (action) => html`
180
+ <sp-button
181
+ size="s"
182
+ data-action=${action.id}
183
+ variant=${action.variant ?? nothing}
184
+ treatment=${action.variant === "negative" ? "outline" : nothing}
185
+ @click=${() => {
186
+ void Promise.resolve(action.run()).finally(repaint);
187
+ }}
188
+ >
189
+ ${action.label}
190
+ </sp-button>
191
+ `,
192
+ );
193
+ }
194
+ return account.connected
195
+ ? html`
196
+ <sp-button
197
+ size="s"
198
+ variant="negative"
199
+ treatment="outline"
200
+ @click=${() => {
201
+ revokeAccount(account.id);
202
+ repaint();
203
+ }}
204
+ >
205
+ Disconnect
206
+ </sp-button>
207
+ `
208
+ : nothing;
209
+ }
210
+
147
211
  function accountsTpl(): TemplateResult {
212
+ // First paint asks the broker what it holds; the answer arrives as a credentials-changed repaint.
213
+ ensureCfConnection();
148
214
  return html`
149
215
  <div class="prefs-accounts">
150
216
  ${listAccounts().map(
@@ -154,23 +220,7 @@ function accountsTpl(): TemplateResult {
154
220
  <span class="prefs-account-label">${account.label}</span>
155
221
  <span class="prefs-account-detail">${account.detail}</span>
156
222
  </div>
157
- ${
158
- account.connected
159
- ? html`
160
- <sp-button
161
- size="s"
162
- variant="negative"
163
- treatment="outline"
164
- @click=${() => {
165
- revokeAccount(account.id);
166
- repaint();
167
- }}
168
- >
169
- Disconnect
170
- </sp-button>
171
- `
172
- : nothing
173
- }
223
+ <div class="prefs-account-actions">${accountActionsTpl(account)}</div>
174
224
  </div>
175
225
  `,
176
226
  )}
@@ -449,15 +499,21 @@ function sectionBodyTpl(): TemplateResult {
449
499
  * @param section One of {@link PREFERENCES_SECTIONS}' ids. An unknown id falls back to Appearance.
450
500
  */
451
501
  export function openPreferences(section?: string): Promise<null> {
502
+ /* Opening is a fresh look, so nothing may be reported from the last one: a brokered Cloudflare
503
+ grant can lapse between visits, and a row that repaints the previous answer first is stating
504
+ something it has no reason to believe. */
505
+ resetCfConnectionCache();
452
506
  _section = section !== undefined && isPreferencesSection(section) ? section : _section;
453
507
  if (_close) {
454
508
  repaint();
509
+ sectionShown();
455
510
  return Promise.resolve(null);
456
511
  }
457
512
  _section =
458
513
  section !== undefined && isPreferencesSection(section) ? section : DEFAULT_PREFERENCES_SECTION;
459
514
  credsForm.startEdit();
460
515
  resetKeyboardState();
516
+ sectionShown();
461
517
  return showDialog<null>(
462
518
  (done) => {
463
519
  let wrapperEl: HTMLElement | null = null;
@@ -500,6 +556,7 @@ export function openPreferences(section?: string): Promise<null> {
500
556
  // First chord pressed on the author's NEXT visit.
501
557
  resetKeyboardState();
502
558
  repaint();
559
+ sectionShown();
503
560
  }}
504
561
  >
505
562
  ${candidate.title}
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,
@@ -54,7 +56,9 @@ export type {
54
56
  AiModelInfo,
55
57
  AiModelsResponse,
56
58
  AppInfo,
59
+ CfAccountSummary,
57
60
  CfConnection,
61
+ CfConnectOutcome,
58
62
  CodeServiceResult,
59
63
  ComponentMeta,
60
64
  ComponentSlotMeta,
@@ -612,14 +616,34 @@ export interface StudioPlatform {
612
616
  head?: string;
613
617
  base?: string;
614
618
  }) => Promise<{ url: string; number: number }>;
615
- /** 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
+ */
616
624
  cfConnection?: () => Promise<CfConnection | null>;
617
625
  /**
618
626
  * Interactively connect a Cloudflare account (hosted OAuth on the cloud platform). Local
619
627
  * platforms omit it — the publish UI collects an API token instead and verifies via
620
628
  * cfConnection.
621
- */
622
- cfConnect?: () => Promise<CfConnection | null>;
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`.
641
+ */
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>;
623
647
  /**
624
648
  * Allowlisted Cloudflare API passthrough (accounts, Pages projects and deployments). The backend
625
649
  * injects credentials — an OAuth token on the cloud platform, the user's pasted API token locally