@jxsuite/studio 0.34.0 → 0.36.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 (41) hide show
  1. package/dist/iframe-entry.js +9 -1
  2. package/dist/iframe-entry.js.map +3 -3
  3. package/dist/studio.js +18382 -3686
  4. package/dist/studio.js.map +265 -26
  5. package/package.json +9 -6
  6. package/src/files/files.ts +3 -0
  7. package/src/new-project/design-fields.ts +260 -0
  8. package/src/new-project/import-tab.ts +322 -0
  9. package/src/new-project/new-project-modal.ts +472 -89
  10. package/src/new-project/templates.ts +61 -0
  11. package/src/packages/ensure-deps.ts +6 -0
  12. package/src/packages/pull-package-sync.ts +339 -0
  13. package/src/panels/ai-chat/attached-context.ts +49 -0
  14. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  15. package/src/panels/ai-chat/chat-view.ts +250 -0
  16. package/src/panels/ai-chat/composer.ts +315 -0
  17. package/src/panels/ai-chat/sessions-view.ts +98 -0
  18. package/src/panels/ai-panel.ts +214 -458
  19. package/src/panels/drag-ghost.ts +1 -1
  20. package/src/panels/git-panel.ts +16 -1
  21. package/src/panels/right-panel.ts +21 -5
  22. package/src/panels/toolbar.ts +2 -0
  23. package/src/panels/welcome-screen.ts +26 -0
  24. package/src/platforms/cloud.ts +774 -0
  25. package/src/platforms/devserver.ts +100 -1
  26. package/src/project-list.ts +38 -0
  27. package/src/publish/pages-service.ts +186 -0
  28. package/src/publish/publish-panel.ts +360 -0
  29. package/src/services/agent-seed.ts +91 -0
  30. package/src/services/ai-models.ts +93 -0
  31. package/src/services/ai-session-store.ts +250 -0
  32. package/src/services/ai-settings.ts +5 -0
  33. package/src/services/automation.ts +140 -0
  34. package/src/services/cf-settings.ts +58 -0
  35. package/src/services/document-assistant.ts +98 -38
  36. package/src/services/import-client.ts +134 -0
  37. package/src/services/settings-store.ts +99 -0
  38. package/src/studio.ts +33 -0
  39. package/src/types.ts +130 -133
  40. package/src/ui/ai-credentials-form.ts +205 -0
  41. package/src/ui/spectrum.ts +8 -0
@@ -0,0 +1,91 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Agent-seed.ts — cross-window handoff of a pending AI assistant prompt.
4
+ *
5
+ * The New Project flow stores the user's project brief here; the Studio window that opens the
6
+ * project consumes it and seeds the Assistant tab. localStorage (not module state) is deliberate:
7
+ * on desktop the new project opens in a NEW window, and the per-root localStorage key is the
8
+ * cross-window channel (precedent: the chat-history key `jx-ai-chat-history:<root>`).
9
+ *
10
+ * @license MIT
11
+ */
12
+
13
+ const PENDING_PROMPT_PREFIX = "jx.ai.pendingAgentPrompt";
14
+
15
+ /** Entries older than this are considered stale and dropped on read. */
16
+ const MAX_AGE_MS = 15 * 60 * 1000;
17
+
18
+ function storageKey(root: string) {
19
+ return `${PENDING_PROMPT_PREFIX}:${root}`;
20
+ }
21
+
22
+ /**
23
+ * Read the pending entry for a project root, dropping stale or malformed entries.
24
+ *
25
+ * @param {string} root
26
+ * @returns {{ prompt: string; ts: number } | null}
27
+ */
28
+ function readEntry(root: string): { prompt: string; ts: number } | null {
29
+ try {
30
+ const raw = globalThis.localStorage?.getItem(storageKey(root));
31
+ if (!raw) {
32
+ return null;
33
+ }
34
+ const parsed = JSON.parse(raw) as { prompt?: unknown; ts?: unknown };
35
+ const valid =
36
+ typeof parsed.prompt === "string" &&
37
+ typeof parsed.ts === "number" &&
38
+ Date.now() - parsed.ts <= MAX_AGE_MS;
39
+ if (!valid) {
40
+ // Stale or malformed — remove so it never seeds a later session.
41
+ globalThis.localStorage?.removeItem(storageKey(root));
42
+ return null;
43
+ }
44
+ return { prompt: parsed.prompt as string, ts: parsed.ts as number };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Store a pending assistant prompt for the project at `root`.
52
+ *
53
+ * @param {string} root
54
+ * @param {string} prompt
55
+ */
56
+ export function setPendingAgentPrompt(root: string, prompt: string): void {
57
+ try {
58
+ globalThis.localStorage?.setItem(storageKey(root), JSON.stringify({ prompt, ts: Date.now() }));
59
+ } catch {
60
+ /* LocalStorage unavailable — the prompt is simply not handed off. */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Whether a fresh (younger than 15 min) pending prompt exists for `root`.
66
+ *
67
+ * @param {string} root
68
+ */
69
+ export function hasPendingAgentPrompt(root: string): boolean {
70
+ return readEntry(root) !== null;
71
+ }
72
+
73
+ /**
74
+ * Read AND delete the pending prompt for `root` (consume-on-read keeps repeated renders
75
+ * idempotent). Returns null when there is no fresh entry.
76
+ *
77
+ * @param {string} root
78
+ * @returns {string | null}
79
+ */
80
+ export function consumePendingAgentPrompt(root: string): string | null {
81
+ const entry = readEntry(root);
82
+ if (!entry) {
83
+ return null;
84
+ }
85
+ try {
86
+ globalThis.localStorage?.removeItem(storageKey(root));
87
+ } catch {
88
+ /* LocalStorage unavailable — nothing to delete. */
89
+ }
90
+ return entry.prompt;
91
+ }
@@ -0,0 +1,93 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-models.js — available-model listing from the studio AI proxy.
4
+ *
5
+ * Fetches the proxy's /models endpoint (the chat endpoint's sibling), forwarding the
6
+ * stored API key as X-Api-Key and the endpoint override as X-Api-Base-URL so the proxy
7
+ * lists models from the user's chosen provider. Shared by the credentials form and the
8
+ * chat composer's model picker; results are cached module-wide until invalidated (e.g.
9
+ * after credentials change).
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import type { AiModelsResponse } from "@jxsuite/protocol";
15
+ import { getPlatform } from "../platform";
16
+ import { getBaseUrl, getOpenAiKey } from "./ai-settings";
17
+
18
+ export interface AiModel {
19
+ id: string;
20
+ name: string;
21
+ }
22
+
23
+ let cache: AiModel[] | null = null;
24
+ let proxyConfigured = false;
25
+ let proxyManaged = false;
26
+ let proxyDefaultModel = "";
27
+
28
+ /** Drop the cached model list (call after credentials/endpoint changes). */
29
+ export function invalidateModelCache() {
30
+ cache = null;
31
+ proxyConfigured = false;
32
+ proxyManaged = false;
33
+ proxyDefaultModel = "";
34
+ }
35
+
36
+ /**
37
+ * Whether the proxy reported itself configured on the last fetch — true when the backend holds
38
+ * credentials (managed platforms, OPENAI_API_KEY env), so the assistant works without a locally
39
+ * stored key.
40
+ */
41
+ export function isProxyConfigured(): boolean {
42
+ return proxyConfigured;
43
+ }
44
+
45
+ /** Whether the platform manages AI credentials itself (cloud Workers AI). */
46
+ export function isManagedProxy(): boolean {
47
+ return proxyManaged;
48
+ }
49
+
50
+ /** The proxy's preferred model id ("" when it does not declare one). */
51
+ export function getProxyDefaultModel(): string {
52
+ return proxyDefaultModel;
53
+ }
54
+
55
+ /**
56
+ * Fetch the models available through the AI proxy. Returns the cached list when present unless
57
+ * `force` is set; throws on HTTP/network failure (callers surface the error).
58
+ *
59
+ * @param {{ apiKey?: string; baseUrl?: string; force?: boolean }} [opts] - Credential overrides for
60
+ * drafts not yet saved to ai-settings.
61
+ * @returns {Promise<AiModel[]>}
62
+ */
63
+ export async function fetchAvailableModels(
64
+ opts: { apiKey?: string; baseUrl?: string; force?: boolean } = {},
65
+ ): Promise<AiModel[]> {
66
+ if (cache && !opts.force) {
67
+ return cache;
68
+ }
69
+ const plat = getPlatform();
70
+ const chatUrl = await Promise.resolve(plat.aiChatUrl());
71
+ const modelsUrl = chatUrl.replace(/\/chat$/, "/models");
72
+
73
+ const headers: Record<string, string> = {};
74
+ const apiKey = opts.apiKey || getOpenAiKey();
75
+ if (apiKey) {
76
+ headers["X-Api-Key"] = apiKey;
77
+ }
78
+ const baseUrl = opts.baseUrl || getBaseUrl();
79
+ if (baseUrl) {
80
+ headers["X-Api-Base-URL"] = baseUrl;
81
+ }
82
+
83
+ const resp = await fetch(modelsUrl, { headers });
84
+ if (!resp.ok) {
85
+ throw new Error(`HTTP ${resp.status}`);
86
+ }
87
+ const data = (await resp.json()) as Partial<AiModelsResponse>;
88
+ proxyConfigured = data.configured === true;
89
+ proxyManaged = data.managed === true;
90
+ proxyDefaultModel = data.defaultModel ?? "";
91
+ cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
92
+ return cache;
93
+ }
@@ -0,0 +1,250 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-session-store.js — project-scoped multi-session chat history in localStorage.
4
+ *
5
+ * Pure storage CRUD for the assistant's conversations: a small index of session
6
+ * metadata plus one payload key per session, so per-send writes touch a single
7
+ * conversation. No reactivity and no chat-state knowledge — document-assistant
8
+ * owns the live session; this module only loads/saves plain message arrays.
9
+ *
10
+ * Cross-window note: multiple studio windows share these keys; index writes are
11
+ * last-write-wins, which is accepted for chat history.
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ const INDEX_KEY_PREFIX = "jx-ai-chat-sessions";
17
+ const PAYLOAD_KEY_PREFIX = "jx-ai-chat-session";
18
+ const LEGACY_KEY_PREFIX = "jx-ai-chat-history";
19
+
20
+ /** Most sessions kept per project — oldest by updatedAt evicted beyond this. */
21
+ export const MAX_SESSIONS = 20;
22
+ /** Most messages persisted per session (matches the pre-session store's cap). */
23
+ export const MAX_PERSIST_MESSAGES = 50;
24
+
25
+ const DEFAULT_TITLE = "New chat";
26
+ const MAX_TITLE_LENGTH = 60;
27
+
28
+ export interface SessionMeta {
29
+ id: string;
30
+ title: string;
31
+ createdAt: number;
32
+ updatedAt: number;
33
+ messageCount: number;
34
+ }
35
+
36
+ interface SessionIndex {
37
+ version: 1;
38
+ activeId: string | null;
39
+ /** Sorted by updatedAt descending. */
40
+ sessions: SessionMeta[];
41
+ }
42
+
43
+ /** The persisted message shape — structurally the chat-state Message JSON. */
44
+ export interface PersistedMessage {
45
+ id?: string;
46
+ role: string;
47
+ content: string;
48
+ toolCalls?: unknown[];
49
+ toolCallId?: string;
50
+ timestamp?: number;
51
+ }
52
+
53
+ // ─── Keys ───────────────────────────────────────────────────────────────────
54
+
55
+ /** Project-scoped index key; falls back to a shared key when no project is open. */
56
+ function indexKey(root: string) {
57
+ return root ? `${INDEX_KEY_PREFIX}:${root}` : INDEX_KEY_PREFIX;
58
+ }
59
+
60
+ function payloadKey(root: string, id: string) {
61
+ return root ? `${PAYLOAD_KEY_PREFIX}:${root}:${id}` : `${PAYLOAD_KEY_PREFIX}:${id}`;
62
+ }
63
+
64
+ function legacyKey(root: string) {
65
+ return root ? `${LEGACY_KEY_PREFIX}:${root}` : LEGACY_KEY_PREFIX;
66
+ }
67
+
68
+ // ─── Storage helpers (defensive: quota/unavailable/corrupt → absent) ────────
69
+
70
+ function readJson<T>(key: string): T | null {
71
+ try {
72
+ const raw = globalThis.localStorage?.getItem(key);
73
+ return raw ? (JSON.parse(raw) as T) : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function writeJson(key: string, value: unknown) {
80
+ try {
81
+ globalThis.localStorage?.setItem(key, JSON.stringify(value));
82
+ } catch {
83
+ // Storage full or unavailable — chat history is not critical.
84
+ }
85
+ }
86
+
87
+ function removeKey(key: string) {
88
+ try {
89
+ globalThis.localStorage?.removeItem(key);
90
+ } catch {
91
+ /* Ignore */
92
+ }
93
+ }
94
+
95
+ // ─── Index ──────────────────────────────────────────────────────────────────
96
+
97
+ function emptyIndex(): SessionIndex {
98
+ return { version: 1, activeId: null, sessions: [] };
99
+ }
100
+
101
+ /**
102
+ * Read the session index for a project, migrating the pre-session single-conversation store
103
+ * (`jx-ai-chat-history:<root>`) into the first session on first access.
104
+ */
105
+ function readIndex(root: string): SessionIndex {
106
+ const stored = readJson<SessionIndex>(indexKey(root));
107
+ if (stored && Array.isArray(stored.sessions)) {
108
+ return { ...emptyIndex(), ...stored };
109
+ }
110
+ return migrateLegacy(root) ?? emptyIndex();
111
+ }
112
+
113
+ function writeIndex(root: string, index: SessionIndex) {
114
+ index.sessions.sort((a, b) => b.updatedAt - a.updatedAt);
115
+ writeJson(indexKey(root), index);
116
+ }
117
+
118
+ /** Convert a legacy single-conversation store into a one-session index, if present. */
119
+ function migrateLegacy(root: string): SessionIndex | null {
120
+ const msgs = readJson<PersistedMessage[]>(legacyKey(root));
121
+ removeKey(legacyKey(root));
122
+ if (!Array.isArray(msgs) || msgs.length === 0) {
123
+ return null;
124
+ }
125
+ const timestamps = msgs.map((m) => m.timestamp).filter((t): t is number => typeof t === "number");
126
+ const firstUser = msgs.find((m) => m.role === "user");
127
+ const meta: SessionMeta = {
128
+ id: newSessionId(),
129
+ title: deriveTitle(firstUser?.content ?? ""),
130
+ createdAt: timestamps.length > 0 ? Math.min(...timestamps) : Date.now(),
131
+ updatedAt: timestamps.length > 0 ? Math.max(...timestamps) : Date.now(),
132
+ messageCount: msgs.length,
133
+ };
134
+ writeJson(payloadKey(root, meta.id), msgs.slice(-MAX_PERSIST_MESSAGES));
135
+ const index: SessionIndex = { version: 1, activeId: meta.id, sessions: [meta] };
136
+ writeIndex(root, index);
137
+ return index;
138
+ }
139
+
140
+ // ─── Public API ─────────────────────────────────────────────────────────────
141
+
142
+ function newSessionId() {
143
+ return `s_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
144
+ }
145
+
146
+ /**
147
+ * Derive a session title from its first user message: first non-empty line, whitespace-collapsed,
148
+ * capped at {@link MAX_TITLE_LENGTH} characters.
149
+ *
150
+ * @param {string} text
151
+ * @returns {string}
152
+ */
153
+ export function deriveTitle(text: string): string {
154
+ const line =
155
+ text
156
+ .split("\n")
157
+ .map((l) => l.replaceAll(/\s+/g, " ").trim())
158
+ .find((l) => l.length > 0) ?? "";
159
+ if (!line) {
160
+ return DEFAULT_TITLE;
161
+ }
162
+ return line.length > MAX_TITLE_LENGTH ? `${line.slice(0, MAX_TITLE_LENGTH - 1)}…` : line;
163
+ }
164
+
165
+ /** List a project's sessions, most recently updated first. */
166
+ export function listSessions(root: string): SessionMeta[] {
167
+ return readIndex(root).sessions;
168
+ }
169
+
170
+ /** The session restored on startup, or null for a fresh unsaved chat. */
171
+ export function getActiveSessionId(root: string): string | null {
172
+ return readIndex(root).activeId;
173
+ }
174
+
175
+ /** Point the index at the session to restore next startup (null = fresh chat). */
176
+ export function setActiveSession(root: string, id: string | null) {
177
+ const index = readIndex(root);
178
+ index.activeId = id;
179
+ writeIndex(root, index);
180
+ }
181
+
182
+ /**
183
+ * Create a new session (as the active one), evicting the oldest sessions beyond
184
+ * {@link MAX_SESSIONS}. The title derives from the first user message when given.
185
+ */
186
+ export function createSession(root: string, firstUserText?: string): SessionMeta {
187
+ const index = readIndex(root);
188
+ const now = Date.now();
189
+ const meta: SessionMeta = {
190
+ id: newSessionId(),
191
+ title: deriveTitle(firstUserText ?? ""),
192
+ createdAt: now,
193
+ updatedAt: now,
194
+ messageCount: 0,
195
+ };
196
+ index.sessions.unshift(meta);
197
+ while (index.sessions.length > MAX_SESSIONS) {
198
+ let oldest = 0;
199
+ for (let i = 1; i < index.sessions.length; i++) {
200
+ if (index.sessions[i]!.updatedAt < index.sessions[oldest]!.updatedAt) {
201
+ oldest = i;
202
+ }
203
+ }
204
+ const [evicted] = index.sessions.splice(oldest, 1);
205
+ removeKey(payloadKey(root, evicted!.id));
206
+ }
207
+ index.activeId = meta.id;
208
+ writeIndex(root, index);
209
+ return meta;
210
+ }
211
+
212
+ /** Load a session's messages, or null when it doesn't exist / is corrupt. */
213
+ export function loadSession(root: string, id: string): PersistedMessage[] | null {
214
+ const msgs = readJson<PersistedMessage[]>(payloadKey(root, id));
215
+ return Array.isArray(msgs) ? msgs : null;
216
+ }
217
+
218
+ /**
219
+ * Persist a session's messages (trimmed to {@link MAX_PERSIST_MESSAGES}) and refresh its index
220
+ * entry (updatedAt, messageCount, and the title while still the default).
221
+ */
222
+ export function saveSession(root: string, id: string, messages: PersistedMessage[]) {
223
+ const trimmed = messages.slice(-MAX_PERSIST_MESSAGES);
224
+ writeJson(payloadKey(root, id), trimmed);
225
+ const index = readIndex(root);
226
+ const meta = index.sessions.find((s) => s.id === id);
227
+ if (!meta) {
228
+ return;
229
+ }
230
+ meta.updatedAt = Date.now();
231
+ meta.messageCount = trimmed.length;
232
+ if (meta.title === DEFAULT_TITLE) {
233
+ const firstUser = trimmed.find((m) => m.role === "user");
234
+ if (firstUser) {
235
+ meta.title = deriveTitle(firstUser.content);
236
+ }
237
+ }
238
+ writeIndex(root, index);
239
+ }
240
+
241
+ /** Delete a session's payload and index entry; clears activeId when it was active. */
242
+ export function deleteSession(root: string, id: string) {
243
+ removeKey(payloadKey(root, id));
244
+ const index = readIndex(root);
245
+ index.sessions = index.sessions.filter((s) => s.id !== id);
246
+ if (index.activeId === id) {
247
+ index.activeId = null;
248
+ }
249
+ writeIndex(root, index);
250
+ }
@@ -9,6 +9,8 @@
9
9
  * @license MIT
10
10
  */
11
11
 
12
+ import { persistSettings } from "./settings-store";
13
+
12
14
  const KEY_STORAGE = "jx.ai.openaiKey";
13
15
  const BASE_URL_STORAGE = "jx.ai.baseUrl";
14
16
  const MODEL_STORAGE = "jx.ai.model";
@@ -36,6 +38,7 @@ export function setOpenAiKey(key: string) {
36
38
  } else {
37
39
  globalThis.localStorage?.removeItem(KEY_STORAGE);
38
40
  }
41
+ persistSettings();
39
42
  } catch {
40
43
  /* LocalStorage unavailable — settings are not persisted. */
41
44
  }
@@ -71,6 +74,7 @@ export function setBaseUrl(url: string) {
71
74
  } else {
72
75
  globalThis.localStorage?.removeItem(BASE_URL_STORAGE);
73
76
  }
77
+ persistSettings();
74
78
  } catch {
75
79
  /* LocalStorage unavailable — settings are not persisted. */
76
80
  }
@@ -101,6 +105,7 @@ export function setModel(modelId: string) {
101
105
  } else {
102
106
  globalThis.localStorage?.removeItem(MODEL_STORAGE);
103
107
  }
108
+ persistSettings();
104
109
  } catch {
105
110
  /* LocalStorage unavailable — settings are not persisted. */
106
111
  }
@@ -0,0 +1,140 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Automation hook — a small, gated scripting surface for driving Studio from a browser-automation
4
+ * runner (see scripts/screenshots/ at the repo root). Installed as `window.__jxAutomation` only
5
+ * when the page URL carries `?automation=1`; without the flag production behavior is untouched.
6
+ *
7
+ * The API mutates the same reactive state the real UI mutates (canvas mode, activity tab,
8
+ * selection, inspector tab, function editor) instead of clicking through Spectrum shadow DOM, so
9
+ * shot definitions stay stable across chrome refactors.
10
+ */
11
+
12
+ import { renderOnly, updateUi } from "../store";
13
+ import { activeTab, workspace } from "../workspace/workspace";
14
+ import { applyPanelCollapse, view } from "../view";
15
+ import type { JxPath } from "../state";
16
+
17
+ /** Callbacks injected from studio.ts (module-local helpers or imports kept out of this module). */
18
+ export interface AutomationDeps {
19
+ getCanvasMode: () => string;
20
+ openBrowseModal: () => void;
21
+ openNewProjectModal: () => void;
22
+ render: () => void;
23
+ renderActivityBar: () => void;
24
+ setCanvasMode: (mode: string) => void;
25
+ statusMessage: (text: string) => void;
26
+ }
27
+
28
+ export interface AutomationApi {
29
+ editDef: (defName: string) => void;
30
+ editFunction: (path: JxPath, eventKey: string) => void;
31
+ openBrowse: () => void;
32
+ openNewProject: () => void;
33
+ getState: () => {
34
+ activeTabId: string | null;
35
+ canvasMode: string;
36
+ canvasStatus: string | null;
37
+ leftTab: string;
38
+ };
39
+ select: (path: JxPath | null) => void;
40
+ setActivity: (tab: string) => void;
41
+ setCanvasMode: (mode: string) => void;
42
+ setRightTab: (tab: string) => void;
43
+ setStatus: (text: string) => void;
44
+ setTheme: (color: string) => void;
45
+ setZoom: (zoom: number) => void;
46
+ waitForCanvasReady: (timeoutMs?: number) => Promise<void>;
47
+ }
48
+
49
+ /** The hook is opt-in per page load: only `?automation=1` installs it. */
50
+ export function shouldInstallAutomation(search: string): boolean {
51
+ return new URLSearchParams(search).get("automation") === "1";
52
+ }
53
+
54
+ export function createAutomationApi(deps: AutomationDeps): AutomationApi {
55
+ return {
56
+ editDef(defName: string) {
57
+ updateUi("editingFunction", { defName, type: "def" });
58
+ deps.render();
59
+ },
60
+ editFunction(path: JxPath, eventKey: string) {
61
+ updateUi("editingFunction", { eventKey, path, type: "event" });
62
+ deps.render();
63
+ },
64
+ openBrowse() {
65
+ deps.openBrowseModal();
66
+ },
67
+ openNewProject() {
68
+ deps.openNewProjectModal();
69
+ },
70
+ getState() {
71
+ return {
72
+ activeTabId: workspace.activeTabId,
73
+ canvasMode: deps.getCanvasMode(),
74
+ canvasStatus: activeTab.value?.session.canvas.status ?? null,
75
+ leftTab: view.leftTab,
76
+ };
77
+ },
78
+ select(path: JxPath | null) {
79
+ const tab = activeTab.value;
80
+ if (tab) {
81
+ tab.session.selection = path;
82
+ }
83
+ deps.render();
84
+ },
85
+ setActivity(tab: string) {
86
+ view.leftTab = tab;
87
+ view.leftPanelCollapsed = false;
88
+ applyPanelCollapse();
89
+ renderOnly("leftPanel");
90
+ deps.renderActivityBar();
91
+ },
92
+ setCanvasMode(mode: string) {
93
+ deps.setCanvasMode(mode);
94
+ deps.render();
95
+ },
96
+ setRightTab(tab: string) {
97
+ updateUi("rightTab", tab);
98
+ deps.render();
99
+ },
100
+ setStatus(text: string) {
101
+ deps.statusMessage(text);
102
+ },
103
+ setTheme(color: string) {
104
+ document.querySelector("sp-theme")?.setAttribute("color", color);
105
+ },
106
+ setZoom(zoom: number) {
107
+ updateUi("zoom", zoom);
108
+ deps.render();
109
+ },
110
+ waitForCanvasReady(timeoutMs = 30_000) {
111
+ return new Promise((resolve, reject) => {
112
+ const started = Date.now();
113
+ const tick = () => {
114
+ if (activeTab.value?.session.canvas.status === "ready") {
115
+ resolve();
116
+ return;
117
+ }
118
+ if (Date.now() - started > timeoutMs) {
119
+ reject(new Error(`canvas not ready after ${timeoutMs}ms`));
120
+ return;
121
+ }
122
+ setTimeout(tick, 50);
123
+ };
124
+ tick();
125
+ });
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Install the hook on globalThis when the current URL opts in. Returns whether it installed, so
132
+ * callers (and tests) can assert the gate.
133
+ */
134
+ export function installAutomationHook(deps: AutomationDeps): boolean {
135
+ if (!shouldInstallAutomation(location.search)) {
136
+ return false;
137
+ }
138
+ (globalThis as Record<string, unknown>).__jxAutomation = createAutomationApi(deps);
139
+ return true;
140
+ }
@@ -0,0 +1,58 @@
1
+ /// <reference lib="dom" />
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
8
+ * api.cloudflare.com (not CORS-enabled, hence the proxy).
9
+ *
10
+ * @license MIT
11
+ */
12
+
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
+ }
39
+
40
+ /** The stored Cloudflare API token, or "" when not connected. */
41
+ export function getCfToken(): string {
42
+ return read(TOKEN_STORAGE);
43
+ }
44
+
45
+ /** Persist (or clear, with a blank value) the Cloudflare API token. */
46
+ export function setCfToken(token: string): void {
47
+ write(TOKEN_STORAGE, token);
48
+ }
49
+
50
+ /** The selected Cloudflare account id, or "" when none chosen yet. */
51
+ export function getCfAccountId(): string {
52
+ return read(ACCOUNT_STORAGE);
53
+ }
54
+
55
+ /** Persist (or clear) the selected Cloudflare account id. */
56
+ export function setCfAccountId(accountId: string): void {
57
+ write(ACCOUNT_STORAGE, accountId);
58
+ }