@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,61 @@
1
+ /**
2
+ * The built-in project templates shown on the New Project modal's Template tab. The ids are the
3
+ * contract with `@jxsuite/create/templates` (createProject validates them server-side); the copy
4
+ * here is presentation only. Hardcoded rather than imported: `@jxsuite/create` is a Node CLI
5
+ * package and Studio runs in the browser.
6
+ */
7
+
8
+ export interface TemplateInfo {
9
+ id: string;
10
+ name: string;
11
+ tagline: string;
12
+ /** Glyph shown in the card's preview area. */
13
+ glyph: string;
14
+ /** The template's default $media map (mirrors @jxsuite/create's mediaForTemplate). */
15
+ media: Record<string, string>;
16
+ }
17
+
18
+ const DESKTOP_FIRST_MEDIA = {
19
+ "--": "1280px",
20
+ "--lg": "(max-width: 1024px)",
21
+ "--md": "(max-width: 768px)",
22
+ "--sm": "(max-width: 640px)",
23
+ };
24
+
25
+ const MOBILE_FIRST_MEDIA = {
26
+ "--": "375px",
27
+ "--sm": "(min-width: 640px)",
28
+ "--md": "(min-width: 768px)",
29
+ "--lg": "(min-width: 1024px)",
30
+ };
31
+
32
+ export const PROJECT_TEMPLATES: readonly TemplateInfo[] = [
33
+ {
34
+ glyph: "+",
35
+ id: "blank",
36
+ media: DESKTOP_FIRST_MEDIA,
37
+ name: "Blank",
38
+ tagline: "Start from scratch",
39
+ },
40
+ {
41
+ glyph: "πŸ–₯",
42
+ id: "desktop-first",
43
+ media: DESKTOP_FIRST_MEDIA,
44
+ name: "Desktop First",
45
+ tagline: "Max-width breakpoints",
46
+ },
47
+ {
48
+ glyph: "πŸ“±",
49
+ id: "mobile-first",
50
+ media: MOBILE_FIRST_MEDIA,
51
+ name: "Mobile First",
52
+ tagline: "Min-width breakpoints",
53
+ },
54
+ {
55
+ glyph: "πŸ“²",
56
+ id: "mobile-app",
57
+ media: MOBILE_FIRST_MEDIA,
58
+ name: "Mobile App",
59
+ tagline: "App shell with bottom navigation",
60
+ },
61
+ ];
@@ -7,8 +7,14 @@
7
7
 
8
8
  import { getPlatform } from "../platform";
9
9
  import { showProgressModal } from "../ui/progress-modal";
10
+ import { shouldInstallAutomation } from "../services/automation";
10
11
 
11
12
  export async function ensureDependenciesInstalled(): Promise<void> {
13
+ // Automation/screenshot runs open projects read-only to drive the canvas β€” they must never mutate
14
+ // The project on disk (a `bun install` would litter node_modules/bun.lock into starter templates).
15
+ if (shouldInstallAutomation(location.search)) {
16
+ return;
17
+ }
12
18
  const platform = getPlatform();
13
19
  if (!platform.dependenciesNeedInstall || !platform.installDependencies) {
14
20
  return;
@@ -0,0 +1,339 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Pull orchestration that keeps project sync from being blocked by Studio's own automated package
4
+ * updates. The on-open @jxsuite update rewrites package.json and bun.lock without committing, so a
5
+ * teammate pushing the same update makes a later `git pull` refuse to merge. Since those local
6
+ * changes are machine-generated and reproducible, we discard them, pull, and re-run the package
7
+ * sync (re-apply the update if the pulled project is still behind, or `bun install` if the pull
8
+ * brought it). Detection is state-based (git status + content comparison against `@{u}`), never git
9
+ * stderr parsing, so it works identically across backends and locales.
10
+ */
11
+
12
+ import { errorMessage } from "@jxsuite/schema/parse";
13
+ import { getPlatform } from "../platform";
14
+ import { showConfirmDialog } from "../ui/layers";
15
+ import { showProgressModal } from "../ui/progress-modal";
16
+ import { statusMessage } from "../panels/statusbar";
17
+ import { applyJxsuiteUpdate, checkJxsuiteUpdate } from "./jxsuite-update";
18
+ import type { GitStatusResult } from "../types";
19
+
20
+ const PKG_PATHS = ["package.json", "bun.lock", "bun.lockb"];
21
+
22
+ /** Untracked markers across backends: desktop porcelain v1 emits "??", server porcelain v2 "U". */
23
+ const UNTRACKED_STATUSES = new Set(["??", "U"]);
24
+
25
+ /** Above this many dirty files, skip the per-file upstream comparison and leave the error as-is. */
26
+ const MAX_CONFLICT_SCAN = 25;
27
+
28
+ const JXSUITE_PREFIX = "@jxsuite/";
29
+
30
+ function isRecord(value: unknown): value is Record<string, unknown> {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ function sortKeys(value: unknown): unknown {
35
+ if (Array.isArray(value)) {
36
+ return value.map((entry) => sortKeys(entry));
37
+ }
38
+ if (isRecord(value)) {
39
+ const out: Record<string, unknown> = {};
40
+ for (const key of Object.keys(value).toSorted()) {
41
+ out[key] = sortKeys(value[key]);
42
+ }
43
+ return out;
44
+ }
45
+ return value;
46
+ }
47
+
48
+ /** Replace every @jxsuite/* version in dependencies/devDependencies so they compare equal. */
49
+ function maskJxsuiteVersions(pkg: Record<string, unknown>): Record<string, unknown> {
50
+ const out = { ...pkg };
51
+ for (const section of ["dependencies", "devDependencies"]) {
52
+ const deps = out[section];
53
+ if (!isRecord(deps)) {
54
+ continue;
55
+ }
56
+ const masked = { ...deps };
57
+ for (const name of Object.keys(masked)) {
58
+ if (name.startsWith(JXSUITE_PREFIX)) {
59
+ masked[name] = "*";
60
+ }
61
+ }
62
+ out[section] = masked;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * True when the working package.json differs from the HEAD copy only in the version strings of
69
+ * jxsuite-scoped entries β€” i.e. exactly what Studio's automated update writes. Added/removed deps
70
+ * or any other edit means a human touched the file.
71
+ */
72
+ export function isAutomatedPackageDiff(
73
+ headText: string | null,
74
+ workingText: string | null,
75
+ ): boolean {
76
+ if (headText === null || workingText === null) {
77
+ return false;
78
+ }
79
+ let head: unknown;
80
+ let working: unknown;
81
+ try {
82
+ head = JSON.parse(headText);
83
+ working = JSON.parse(workingText);
84
+ } catch {
85
+ return false;
86
+ }
87
+ if (!isRecord(head) || !isRecord(working)) {
88
+ return false;
89
+ }
90
+ return (
91
+ JSON.stringify(sortKeys(maskJxsuiteVersions(head))) ===
92
+ JSON.stringify(sortKeys(maskJxsuiteVersions(working)))
93
+ );
94
+ }
95
+
96
+ async function tryShow(path: string, ref: string): Promise<string | null> {
97
+ try {
98
+ return await getPlatform().gitShow({ path, ref });
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ async function tryRead(path: string): Promise<string | null> {
105
+ try {
106
+ return await getPlatform().readFile(path);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ export interface PackagePullPlan {
113
+ /** Tracked dirty package files whose upstream (@{u}) content differs from HEAD. */
114
+ discard: string[];
115
+ /** Untracked package files that exist at @{u} ("untracked would be overwritten" case). */
116
+ removeUntracked: string[];
117
+ /** Whether the package.json working copy diverges from HEAD only by automated @jxsuite bumps. */
118
+ automated: boolean;
119
+ }
120
+
121
+ /**
122
+ * Decide which local package files stand in the way of a merge. Only files the upstream actually
123
+ * changed can block the pull, so files whose @{u} content matches HEAD are left alone.
124
+ */
125
+ export async function planPackageDiscard(status: GitStatusResult): Promise<PackagePullPlan> {
126
+ const discard: string[] = [];
127
+ const removeUntracked: string[] = [];
128
+ let automated = true;
129
+ for (const path of PKG_PATHS) {
130
+ const entries = status.files.filter((f) => f.path === path);
131
+ if (entries.length === 0) {
132
+ continue;
133
+ }
134
+ const upstream = await tryShow(path, "@{u}");
135
+ if (entries.every((f) => UNTRACKED_STATUSES.has(f.status))) {
136
+ if (upstream !== null) {
137
+ removeUntracked.push(path);
138
+ }
139
+ continue;
140
+ }
141
+ const head = await tryShow(path, "HEAD");
142
+ if (upstream === null || upstream === head) {
143
+ continue;
144
+ }
145
+ discard.push(path);
146
+ if (path === "package.json") {
147
+ automated &&= isAutomatedPackageDiff(head, await tryRead(path));
148
+ }
149
+ }
150
+ return { automated, discard, removeUntracked };
151
+ }
152
+
153
+ function planIsEmpty(plan: PackagePullPlan): boolean {
154
+ return plan.discard.length === 0 && plan.removeUntracked.length === 0;
155
+ }
156
+
157
+ /** True when a dirty non-package file also differs upstream β€” recovery can't unblock that pull. */
158
+ async function otherFilesConflict(status: GitStatusResult): Promise<boolean> {
159
+ const paths = [
160
+ ...new Set(status.files.filter((f) => !PKG_PATHS.includes(f.path)).map((f) => f.path)),
161
+ ];
162
+ if (paths.length > MAX_CONFLICT_SCAN) {
163
+ return true;
164
+ }
165
+ for (const path of paths) {
166
+ const upstream = await tryShow(path, "@{u}");
167
+ if (upstream !== null && upstream !== (await tryShow(path, "HEAD"))) {
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+
174
+ async function applyDiscard(plan: PackagePullPlan): Promise<void> {
175
+ const platform = getPlatform();
176
+ if (plan.discard.length > 0) {
177
+ // Unstage unconditionally: desktop porcelain v1 doesn't report a staged flag.
178
+ // Restoring an unstaged tracked file is a no-op.
179
+ await platform.gitUnstage(plan.discard);
180
+ await platform.gitDiscard(plan.discard);
181
+ }
182
+ for (const path of plan.removeUntracked) {
183
+ await platform.deleteFile(path);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Bring packages back in line after a pull that touched (or followed a discard of) package files:
189
+ * re-apply the @jxsuite update if the pulled project is still behind, otherwise `bun install` so
190
+ * node_modules matches the pulled lockfile. Never throws β€” a successful pull must not turn into an
191
+ * error banner; failures surface through the progress modal.
192
+ */
193
+ async function syncPackagesAfterPull(): Promise<void> {
194
+ const platform = getPlatform();
195
+ let check: Awaited<ReturnType<typeof checkJxsuiteUpdate>> = null;
196
+ try {
197
+ check = await checkJxsuiteUpdate();
198
+ } catch {
199
+ check = null;
200
+ }
201
+ if (check && platform.setPackageVersions) {
202
+ // No re-prompt: the discarded local changes came from an update the user already accepted.
203
+ await applyJxsuiteUpdate(check.outdated, check.target);
204
+ return;
205
+ }
206
+ if (!platform.installDependencies) {
207
+ return;
208
+ }
209
+ const progress = showProgressModal({
210
+ status: "Running bun install…",
211
+ title: "Syncing dependencies",
212
+ });
213
+ try {
214
+ const result = await platform.installDependencies();
215
+ if (result.ok) {
216
+ progress.done();
217
+ } else {
218
+ progress.fail(result.log ?? "bun install failed");
219
+ }
220
+ } catch (error) {
221
+ progress.fail(errorMessage(error));
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Discard per plan, pull, and re-sync. A discard is always followed by a package re-sync β€” even
227
+ * when the pull fails β€” so the project is never stranded without the update it had before.
228
+ */
229
+ async function discardPullAndSync(plan: PackagePullPlan): Promise<void> {
230
+ const platform = getPlatform();
231
+ try {
232
+ await applyDiscard(plan);
233
+ } catch (error) {
234
+ throw new Error(`Could not reset local package files: ${errorMessage(error)}`, {
235
+ cause: error,
236
+ });
237
+ }
238
+ try {
239
+ await platform.gitPull();
240
+ } catch (error) {
241
+ await syncPackagesAfterPull();
242
+ throw error;
243
+ }
244
+ statusMessage("Local package updates were superseded by pulled changes");
245
+ await syncPackagesAfterPull();
246
+ }
247
+
248
+ async function recoverAfterFailedPull(original: unknown): Promise<void> {
249
+ const platform = getPlatform();
250
+ let status: GitStatusResult;
251
+ try {
252
+ status = await platform.gitStatus();
253
+ } catch {
254
+ throw original;
255
+ }
256
+ if (!(status.behind > 0)) {
257
+ throw original;
258
+ }
259
+ const plan = await planPackageDiscard(status);
260
+ if (planIsEmpty(plan) || (await otherFilesConflict(status))) {
261
+ throw original;
262
+ }
263
+ if (!plan.automated) {
264
+ const confirmed = await showConfirmDialog(
265
+ "Discard local package changes?",
266
+ "package.json / bun.lock have local edits that conflict with incoming changes. Discard them, pull, and re-sync packages?",
267
+ { confirmLabel: "Discard and pull", destructive: true },
268
+ );
269
+ if (!confirmed) {
270
+ throw original;
271
+ }
272
+ }
273
+ await discardPullAndSync(plan);
274
+ }
275
+
276
+ async function snapshotPackageFiles(): Promise<(string | null)[]> {
277
+ return Promise.all(PKG_PATHS.map((path) => tryRead(path)));
278
+ }
279
+
280
+ /**
281
+ * Pull with automatic recovery from conflicts caused by Studio's own package updates. Throws only
282
+ * when the pull genuinely fails for reasons recovery can't (or shouldn't) fix.
283
+ */
284
+ export async function pullWithPackageSync(): Promise<void> {
285
+ const platform = getPlatform();
286
+ const before = await snapshotPackageFiles();
287
+
288
+ // Preemptive: fetch so `behind` is current, then reset automated package edits that would
289
+ // Block the merge β€” manual package.json edits fall through to the plain pull + confirm path.
290
+ let status: GitStatusResult | null = null;
291
+ try {
292
+ await platform.gitFetch();
293
+ status = await platform.gitStatus();
294
+ } catch {
295
+ status = null; // Offline or status failure β€” the plain pull will report the real error
296
+ }
297
+ if (status && status.behind > 0) {
298
+ const plan = await planPackageDiscard(status);
299
+ if (!planIsEmpty(plan) && plan.automated) {
300
+ await discardPullAndSync(plan);
301
+ return;
302
+ }
303
+ }
304
+
305
+ try {
306
+ await platform.gitPull();
307
+ } catch (error) {
308
+ await recoverAfterFailedPull(error);
309
+ return;
310
+ }
311
+ const after = await snapshotPackageFiles();
312
+ if (before.some((content, i) => content !== after[i])) {
313
+ await syncPackagesAfterPull();
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Pull remote changes as soon as a project opens, ahead of the install check and update prompt, so
319
+ * a session starts from the current remote state. Never throws and never blocks the open β€” failures
320
+ * (offline, no upstream, unrelated conflicts) surface as a status message only.
321
+ */
322
+ export async function autoSyncProjectOnOpen(): Promise<void> {
323
+ const platform = getPlatform();
324
+ let status: GitStatusResult;
325
+ try {
326
+ status = await platform.gitStatus();
327
+ } catch {
328
+ return;
329
+ }
330
+ if (!status?.isRepo || (status.remotes?.length ?? 0) === 0) {
331
+ return;
332
+ }
333
+ statusMessage("Syncing project…");
334
+ try {
335
+ await pullWithPackageSync();
336
+ } catch (error) {
337
+ statusMessage(`Sync skipped: ${errorMessage(error)}`);
338
+ }
339
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Attached-context.js β€” the composer's "attach context" message-content convention.
3
+ *
4
+ * Attached context (current page, selected element) is embedded into the user message
5
+ * content after a delimiter line: it's the only channel the LLM sees (the streaming
6
+ * payload carries message content only) and it persists with the session for free.
7
+ * The chat view splits the content back apart to show chips instead of the raw block.
8
+ *
9
+ * The delimiter is a soft convention β€” a model echoing it would at worst render stray
10
+ * chips (no security impact; assistant markdown is sanitized separately).
11
+ *
12
+ * @license MIT
13
+ */
14
+
15
+ export const ATTACHED_CONTEXT_DELIMITER = "---- attached context ----";
16
+
17
+ export interface ContextChip {
18
+ kind: "page" | "selection";
19
+ /** Short chip label, e.g. the page path or `<h1>`. */
20
+ label: string;
21
+ /** The context line embedded into the message content. */
22
+ detail: string;
23
+ }
24
+
25
+ /** Serialize the composer text plus attached-context chips into one message content. */
26
+ export function buildMessageWithContext(text: string, chips: ContextChip[]): string {
27
+ if (chips.length === 0) {
28
+ return text;
29
+ }
30
+ const lines = chips.map((c) => c.detail).join("\n");
31
+ return `${text}\n\n${ATTACHED_CONTEXT_DELIMITER}\n${lines}`;
32
+ }
33
+
34
+ /** Split a user message back into its typed body and any attached-context lines. */
35
+ export function splitAttachedContext(content: string): { body: string; contextLines: string[] } {
36
+ const idx = content.indexOf(`\n\n${ATTACHED_CONTEXT_DELIMITER}\n`);
37
+ if (idx === -1) {
38
+ return { body: content, contextLines: [] };
39
+ }
40
+ const body = content.slice(0, idx);
41
+ const rest = content.slice(idx + ATTACHED_CONTEXT_DELIMITER.length + 3);
42
+ return {
43
+ body,
44
+ contextLines: rest
45
+ .split("\n")
46
+ .map((l) => l.trim())
47
+ .filter((l) => l.length > 0),
48
+ };
49
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Chat-markdown.js β€” memoized markdown rendering for assistant chat messages.
3
+ *
4
+ * Wraps @jxsuite/parser/md-html (sanitized markdown β†’ HTML) with a per-message cache
5
+ * keyed by message id + content length, so re-renders during streaming only re-parse
6
+ * the message that actually grew. The HTML goes through unsafeHTML, which is safe here
7
+ * because md-html sanitizes (raw HTML dropped, javascript: URLs stripped).
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import { html } from "lit-html";
13
+ import type { TemplateResult } from "lit-html";
14
+ import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
15
+ import { markdownToHtml } from "@jxsuite/parser/md-html";
16
+
17
+ const cache = new Map<string, { len: number; html: string }>();
18
+
19
+ /**
20
+ * Render a message's markdown content, memoized by message id.
21
+ *
22
+ * @param {string} id - Stable message id (cache key).
23
+ * @param {string} content
24
+ * @returns {TemplateResult}
25
+ */
26
+ export function renderMarkdown(id: string, content: string): TemplateResult {
27
+ let entry = cache.get(id);
28
+ if (!entry || entry.len !== content.length) {
29
+ entry = { len: content.length, html: markdownToHtml(content) };
30
+ cache.set(id, entry);
31
+ }
32
+ return html`<div class="ai-msg-md">${unsafeHTML(entry.html)}</div>`;
33
+ }
34
+
35
+ /** Drop all cached renders (call on session switch / new chat). */
36
+ export function clearMarkdownCache() {
37
+ cache.clear();
38
+ }