@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
@@ -22,20 +22,16 @@ import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
22
22
  import { trimContext } from "./context-manager";
23
23
  import { renderCheck } from "./render-critic";
24
24
  import { openFileInTab } from "../files/files";
25
-
26
- const PERSIST_KEY_PREFIX = "jx-ai-chat-history";
27
- const MAX_PERSIST_MESSAGES = 50;
25
+ import * as sessionStore from "./ai-session-store";
28
26
 
29
27
  /**
30
- * Project-scoped localStorage key (uses the project root) so conversations don't bleed across
31
- * projects (ADR §11.5 / §14.2). Falls back to a shared key when no project is open.
28
+ * Project root scoping the session store (ADR §11.5 / §14.2 conversations don't bleed across
29
+ * projects). Falls back to a shared unscoped store when no project is open.
32
30
  *
33
31
  * @returns {string}
34
32
  */
35
- function persistKey() {
36
- return workspace.projectRoot
37
- ? `${PERSIST_KEY_PREFIX}:${workspace.projectRoot}`
38
- : PERSIST_KEY_PREFIX;
33
+ function projectRoot() {
34
+ return workspace.projectRoot || "";
39
35
  }
40
36
 
41
37
  /**
@@ -46,6 +42,10 @@ function persistKey() {
46
42
  * sendMessage: (text: string) => Promise<void>;
47
43
  * stop: () => void;
48
44
  * newChat: () => void;
45
+ * listSessions: () => import("./ai-session-store").SessionMeta[];
46
+ * openSession: (id: string) => void;
47
+ * deleteSession: (id: string) => void;
48
+ * activeSessionId: () => string | null;
49
49
  * }}
50
50
  */
51
51
  export function createDocumentAssistant() {
@@ -69,6 +69,9 @@ export function createDocumentAssistant() {
69
69
 
70
70
  let controller: AbortController | null = null;
71
71
 
72
+ /** The persisted session backing the live chat; null = fresh unsaved chat. */
73
+ let sessionId: string | null = null;
74
+
72
75
  function buildPrompt() {
73
76
  const tab = activeTab.value;
74
77
  return buildSystemPrompt({
@@ -84,6 +87,12 @@ export function createDocumentAssistant() {
84
87
  return;
85
88
  }
86
89
 
90
+ // Lazily create the backing session on the first message so empty "New Chat"
91
+ // Clicks never pollute the session list.
92
+ if (!sessionId) {
93
+ sessionId = sessionStore.createSession(projectRoot(), text).id;
94
+ }
95
+
87
96
  chatState.sendMessage(text);
88
97
 
89
98
  // Trim context before streaming to keep the conversation within token limits.
@@ -93,7 +102,7 @@ export function createDocumentAssistant() {
93
102
  }
94
103
 
95
104
  // Persist after trimming so the saved history reflects what's actually sent.
96
- persistChat(chatState);
105
+ persistChat();
97
106
 
98
107
  try {
99
108
  const plat = getPlatform();
@@ -127,6 +136,9 @@ export function createDocumentAssistant() {
127
136
  chatState.setError(error instanceof Error ? error.message : String(error));
128
137
  } finally {
129
138
  controller = null;
139
+ // Persist again once the stream settled so the completed reply (or the state
140
+ // After an error/abort cleanup) survives a reload without another send.
141
+ persistChat();
130
142
  }
131
143
  }
132
144
 
@@ -138,46 +150,94 @@ export function createDocumentAssistant() {
138
150
  function newChat() {
139
151
  stop();
140
152
  chatState.clearChat();
141
- persistChat(chatState);
153
+ sessionId = null;
154
+ sessionStore.setActiveSession(projectRoot(), null);
155
+ }
156
+
157
+ // ── Sessions ──────────────────────────────────────────────────────────
158
+
159
+ /** The project's persisted sessions, most recently updated first. */
160
+ function listSessions() {
161
+ return sessionStore.listSessions(projectRoot());
162
+ }
163
+
164
+ /** Replace the live chat with a persisted session's messages. */
165
+ function openSession(id: string) {
166
+ const msgs = sessionStore.loadSession(projectRoot(), id);
167
+ if (!msgs) {
168
+ return;
169
+ }
170
+ stop();
171
+ chatState.clearChat();
172
+ pushRestoredMessages(msgs);
173
+ sessionId = id;
174
+ sessionStore.setActiveSession(projectRoot(), id);
175
+ }
176
+
177
+ /** Delete a persisted session; deleting the open one leaves a fresh unsaved chat. */
178
+ function deleteSession(id: string) {
179
+ sessionStore.deleteSession(projectRoot(), id);
180
+ if (sessionId === id) {
181
+ stop();
182
+ chatState.clearChat();
183
+ sessionId = null;
184
+ }
185
+ }
186
+
187
+ function activeSessionId() {
188
+ return sessionId;
142
189
  }
143
190
 
144
191
  // ── Persistence ───────────────────────────────────────────────────────
145
192
 
146
- /** Persist recent messages to localStorage (non-blocking). */
147
- function persistChat(cs: ReturnType<typeof createChatState>) {
148
- try {
149
- const msgs = cs.messages.slice(-MAX_PERSIST_MESSAGES);
150
- localStorage.setItem(persistKey(), JSON.stringify(msgs));
151
- } catch {
152
- // Storage full or unavailable — not critical.
193
+ /** Persist the live conversation into its backing session (non-blocking). */
194
+ function persistChat() {
195
+ if (!sessionId) {
196
+ return;
153
197
  }
198
+ // Skip a still-empty streaming placeholder so reloads don't restore blank bubbles.
199
+ const msgs = chatState.messages.filter(
200
+ (m) => m.role !== "assistant" || m.content || (m.toolCalls?.length ?? 0) > 0,
201
+ );
202
+ sessionStore.saveSession(projectRoot(), sessionId, msgs);
154
203
  }
155
204
 
156
- /** Restore messages from a previous session, if any. */
205
+ /** Push persisted messages into chat state, synthesizing ids where missing. */
206
+ function pushRestoredMessages(msgs: sessionStore.PersistedMessage[]) {
207
+ for (const m of msgs) {
208
+ chatState.messages.push({
209
+ ...m,
210
+ id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
211
+ } as (typeof chatState.messages)[number]);
212
+ }
213
+ }
214
+
215
+ /** Restore the last-active session once on creation, if any. */
157
216
  function restoreChat() {
158
- try {
159
- const raw = localStorage.getItem(persistKey());
160
- if (!raw) {
161
- return;
162
- }
163
- const msgs = JSON.parse(raw) as typeof chatState.messages;
164
- if (!Array.isArray(msgs) || msgs.length === 0) {
165
- return;
166
- }
167
- // Push into chat state (skips streaming state)
168
- for (const m of msgs) {
169
- chatState.messages.push({
170
- ...m,
171
- id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
172
- });
173
- }
174
- } catch {
175
- // Corrupt or empty — ignore.
217
+ const root = projectRoot();
218
+ const activeId = sessionStore.getActiveSessionId(root);
219
+ if (!activeId) {
220
+ return;
221
+ }
222
+ const msgs = sessionStore.loadSession(root, activeId);
223
+ if (!msgs || msgs.length === 0) {
224
+ return;
176
225
  }
226
+ pushRestoredMessages(msgs);
227
+ sessionId = activeId;
177
228
  }
178
229
 
179
230
  // Restore once on creation.
180
231
  restoreChat();
181
232
 
182
- return { chatState, sendMessage, stop, newChat };
233
+ return {
234
+ chatState,
235
+ sendMessage,
236
+ stop,
237
+ newChat,
238
+ listSessions,
239
+ openSession,
240
+ deleteSession,
241
+ activeSessionId,
242
+ };
183
243
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared client for the /__studio/import-site NDJSON stream. Every platform's importSite is "a
3
+ * streaming fetch to some URL" — the dev server posts to its own origin, the desktop platforms post
4
+ * to their token-gated loopback servers — so the request/parse/settle logic lives here once.
5
+ *
6
+ * The endpoint emits one JSON object per line: `progress` lines (forwarded to onProgress),
7
+ * `heartbeat` keep-alives (ignored), and a terminal `done` ({root, config}) or `error` line.
8
+ */
9
+
10
+ import type { ImportProgressEvent, ImportSiteOptions } from "../types";
11
+ import type { ProjectConfig } from "@jxsuite/schema/types";
12
+
13
+ interface StreamLine {
14
+ type: string;
15
+ phase?: string;
16
+ message?: string;
17
+ current?: number;
18
+ total?: number;
19
+ root?: string;
20
+ config?: ProjectConfig;
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * POST an import request and consume its NDJSON progress stream.
26
+ *
27
+ * @param {string} endpoint — the platform's import-site URL (may carry an auth token)
28
+ * @param {ImportSiteOptions} opts — the import request; `apiKey`/`baseUrl` travel as headers
29
+ * @param {(evt: ImportProgressEvent) => void} onProgress
30
+ * @param {AbortSignal} [signal]
31
+ */
32
+ export async function streamImport(
33
+ endpoint: string,
34
+ opts: ImportSiteOptions,
35
+ onProgress: (evt: ImportProgressEvent) => void,
36
+ signal?: AbortSignal,
37
+ ): Promise<{ root: string; config: ProjectConfig }> {
38
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
39
+ if (opts.apiKey) {
40
+ headers["X-Api-Key"] = opts.apiKey;
41
+ }
42
+ if (opts.baseUrl) {
43
+ headers["X-Api-Base-URL"] = opts.baseUrl;
44
+ }
45
+
46
+ const res = await fetch(endpoint, {
47
+ body: JSON.stringify({
48
+ url: opts.url,
49
+ directory: opts.directory,
50
+ depth: opts.depth,
51
+ maxPages: opts.maxPages,
52
+ aiComponents: opts.aiComponents,
53
+ ...(opts.model === undefined ? {} : { aiModel: opts.model }),
54
+ }),
55
+ headers,
56
+ method: "POST",
57
+ ...(signal === undefined ? {} : { signal }),
58
+ });
59
+
60
+ if (!res.ok) {
61
+ let message = `Import failed (${res.status})`;
62
+ try {
63
+ const body = (await res.json()) as { error?: string };
64
+ if (body.error) {
65
+ message = body.error;
66
+ }
67
+ } catch {
68
+ /* Non-JSON error body — keep the status message. */
69
+ }
70
+ throw new Error(message);
71
+ }
72
+ if (!res.body) {
73
+ throw new Error("Import stream had no response body");
74
+ }
75
+
76
+ const reader = res.body.getReader();
77
+ const decoder = new TextDecoder();
78
+ let buffer = "";
79
+ let result: { root: string; config: ProjectConfig } | null = null;
80
+
81
+ const handleLine = (line: string) => {
82
+ const trimmed = line.trim();
83
+ if (!trimmed) {
84
+ return;
85
+ }
86
+ let parsed: StreamLine;
87
+ try {
88
+ parsed = JSON.parse(trimmed) as StreamLine;
89
+ } catch {
90
+ return; // Tolerate a mangled line rather than killing the whole import.
91
+ }
92
+ if (parsed.type === "progress") {
93
+ onProgress({
94
+ phase: parsed.phase ?? "",
95
+ message: parsed.message ?? "",
96
+ ...(parsed.current === undefined ? {} : { current: parsed.current }),
97
+ ...(parsed.total === undefined ? {} : { total: parsed.total }),
98
+ });
99
+ } else if (parsed.type === "done" && parsed.root !== undefined && parsed.config) {
100
+ result = { root: parsed.root, config: parsed.config };
101
+ } else if (parsed.type === "error") {
102
+ throw new Error(parsed.error || "Import failed");
103
+ }
104
+ // Heartbeats and unknown line types are ignored.
105
+ };
106
+
107
+ try {
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done) {
111
+ break;
112
+ }
113
+ buffer += decoder.decode(value, { stream: true });
114
+ // Lines can split across chunks: keep the trailing partial in the buffer.
115
+ const lines = buffer.split("\n");
116
+ buffer = lines.pop() ?? "";
117
+ for (const line of lines) {
118
+ handleLine(line);
119
+ }
120
+ }
121
+ handleLine(buffer);
122
+ } finally {
123
+ try {
124
+ await reader.cancel();
125
+ } catch {
126
+ /* The stream is already closed (e.g. after an abort). */
127
+ }
128
+ }
129
+
130
+ if (!result) {
131
+ throw new Error("Import stream ended without a result");
132
+ }
133
+ return result;
134
+ }
@@ -0,0 +1,99 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Settings-store.js — sync of user settings between localStorage and the platform backend.
4
+ *
5
+ * LocalStorage stays the synchronous source of truth for every consumer (ai-settings getters read
6
+ * it directly). On platforms with a backend user-settings store (desktop/chromium — where
7
+ * localStorage does not roam across browser profiles), the persisted keys are hydrated from the
8
+ * backend at boot and written through on every change. The dev server omits the platform methods
9
+ * and settings remain localStorage-only.
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import { getPlatform, hasPlatform } from "../platform";
15
+
16
+ /** The localStorage keys mirrored into the platform's user-settings store. */
17
+ export const PERSISTED_SETTINGS_KEYS = [
18
+ "jx.ai.openaiKey",
19
+ "jx.ai.baseUrl",
20
+ "jx.ai.model",
21
+ "jx.cf.token",
22
+ "jx.cf.accountId",
23
+ ] as const;
24
+
25
+ /** Read a localStorage value defensively, treating unavailable/throwing storage as empty. */
26
+ function readLocal(key: string): string {
27
+ try {
28
+ return globalThis.localStorage?.getItem(key) ?? "";
29
+ } catch {
30
+ return "";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Pull the persisted settings from the backend store into localStorage. Call once after the
36
+ * platform is registered; a no-op on platforms without a settings store (dev server).
37
+ *
38
+ * Migration: keys the backend does not know yet but localStorage does (an existing user upgrading
39
+ * from localStorage-only builds) are pushed back to the backend once, fire-and-forget.
40
+ */
41
+ export async function hydrateSettings(): Promise<void> {
42
+ if (!hasPlatform()) {
43
+ return;
44
+ }
45
+ const platform = getPlatform();
46
+ if (!platform.getSettings) {
47
+ return;
48
+ }
49
+ let stored: Record<string, string>;
50
+ try {
51
+ stored = await platform.getSettings();
52
+ } catch {
53
+ return;
54
+ }
55
+ const merged: Record<string, string> = { ...stored };
56
+ let needsMigration = false;
57
+ for (const key of PERSISTED_SETTINGS_KEYS) {
58
+ const backendValue = stored[key];
59
+ if (backendValue) {
60
+ try {
61
+ globalThis.localStorage?.setItem(key, backendValue);
62
+ } catch {
63
+ /* Storage unavailable — consumers fall back to defaults. */
64
+ }
65
+ } else {
66
+ const localValue = readLocal(key);
67
+ if (localValue) {
68
+ merged[key] = localValue;
69
+ needsMigration = true;
70
+ }
71
+ }
72
+ }
73
+ if (needsMigration && platform.saveSettings) {
74
+ void platform.saveSettings(merged).catch(() => {});
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Write the current values of the persisted keys through to the backend store, fire-and-forget.
80
+ * Call after mutating any of {@link PERSISTED_SETTINGS_KEYS} in localStorage; a no-op on platforms
81
+ * without a settings store (dev server).
82
+ */
83
+ export function persistSettings(): void {
84
+ if (!hasPlatform()) {
85
+ return;
86
+ }
87
+ const platform = getPlatform();
88
+ if (!platform.saveSettings) {
89
+ return;
90
+ }
91
+ const settings: Record<string, string> = {};
92
+ for (const key of PERSISTED_SETTINGS_KEYS) {
93
+ const value = readLocal(key);
94
+ if (value) {
95
+ settings[key] = value;
96
+ }
97
+ }
98
+ void platform.saveSettings(settings).catch(() => {});
99
+ }
package/src/studio.ts CHANGED
@@ -80,6 +80,8 @@ import { startFsSync } from "./files/fs-events";
80
80
  import { renderImportsTemplate } from "./panels/imports-panel";
81
81
  import { renderHeadTemplate } from "./panels/head-panel";
82
82
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
83
+ import { installAutomationHook } from "./services/automation";
84
+ import { openBrowseModal } from "./browse/browse-modal";
83
85
 
84
86
  import { getPlatform, hasPlatform, registerPlatform } from "./platform";
85
87
  import { parseMediaEntries } from "./utils/canvas-media";
@@ -90,6 +92,7 @@ import { defBadgeLabel, defCategory, renderSignalsTemplate } from "./panels/sign
90
92
  import { loadComponentRegistry } from "./files/components";
91
93
  import { ensureDependenciesInstalled } from "./packages/ensure-deps";
92
94
  import { maybePromptJxsuiteUpdate } from "./packages/jxsuite-update";
95
+ import { autoSyncProjectOnOpen } from "./packages/pull-package-sync";
93
96
 
94
97
  import { html, render as litRender } from "lit-html";
95
98
 
@@ -123,7 +126,9 @@ import {
123
126
  } from "./panels/block-action-bar";
124
127
  import { initCssData } from "./panels/style-utils";
125
128
  import { initQuickSearch } from "./panels/quick-search";
129
+ import { hydrateProjectList } from "./project-list";
126
130
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
131
+ import { hydrateSettings } from "./services/settings-store";
127
132
  import { initWelcome } from "./panels/welcome-screen";
128
133
  import { openNewProjectModal } from "./new-project/new-project-modal";
129
134
  import type { DocumentStackEntry, GitDiffState } from "./types";
@@ -342,6 +347,18 @@ if (!hasPlatform()) {
342
347
  registerPlatform(createDevServerPlatform());
343
348
  }
344
349
 
350
+ // Screenshot/automation runners (scripts/screenshots/) await window.__jxAutomation right after
351
+ // Navigation, so the gated hook must install before the async deep-link project load below.
352
+ installAutomationHook({
353
+ getCanvasMode,
354
+ openBrowseModal,
355
+ openNewProjectModal,
356
+ render,
357
+ renderActivityBar,
358
+ setCanvasMode,
359
+ statusMessage,
360
+ });
361
+
345
362
  mountResizeEdges();
346
363
 
347
364
  // ─── Render loop ──────────────────────────────────────────────────────────────
@@ -619,6 +636,7 @@ if (_projectParam) {
619
636
  selectedPath: siteCtx.fileRelPath || null,
620
637
  });
621
638
 
639
+ await autoSyncProjectOnOpen();
622
640
  await ensureDependenciesInstalled();
623
641
  await loadComponentRegistry();
624
642
 
@@ -727,6 +745,20 @@ void hydrateRecentProjects().then(() => {
727
745
  render();
728
746
  });
729
747
 
748
+ // Hydrate the platform's project catalogue (dev server sites, cloud projects), then refresh the
749
+ // Welcome screen, which reads it synchronously. No-op on platforms without listProjects.
750
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
751
+ void hydrateProjectList().then(() => {
752
+ render();
753
+ });
754
+
755
+ // Hydrate user settings (AI connection parameters) from the backend store, then re-render so
756
+ // Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
757
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
758
+ void hydrateSettings().then(() => {
759
+ render();
760
+ });
761
+
730
762
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
731
763
 
732
764
  function renderLeftPanel() {
@@ -787,6 +819,7 @@ async function openRecentProject(root: string) {
787
819
  selectedPath: null,
788
820
  });
789
821
 
822
+ await autoSyncProjectOnOpen();
790
823
  await ensureDependenciesInstalled();
791
824
  await loadDirectory(".");
792
825
  await loadComponentRegistry();