@jxsuite/studio 1.0.0 → 1.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/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +53069 -31746
  5. package/dist/studio.js.map +109 -74
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +20 -0
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +131 -5
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +43 -3
  15. package/src/canvas/iframe-render.ts +18 -0
  16. package/src/collab/collab-session.ts +23 -8
  17. package/src/collab/collab-state.ts +6 -3
  18. package/src/component-props.ts +104 -0
  19. package/src/editor/inline-edit-apply.ts +36 -1
  20. package/src/editor/inline-edit.ts +58 -1
  21. package/src/editor/shortcuts.ts +41 -12
  22. package/src/files/file-ops.ts +14 -0
  23. package/src/files/files.ts +53 -1
  24. package/src/grid/cell-editors.ts +288 -0
  25. package/src/grid/cell-popovers.ts +108 -0
  26. package/src/grid/csv-codec.ts +122 -0
  27. package/src/grid/edit-buffer.ts +490 -0
  28. package/src/grid/grid-controller.ts +417 -0
  29. package/src/grid/grid-layout.ts +83 -0
  30. package/src/grid/grid-open.ts +167 -0
  31. package/src/grid/grid-panel.ts +302 -0
  32. package/src/grid/grid-source.ts +210 -0
  33. package/src/grid/grid-view.ts +367 -0
  34. package/src/grid/schema-columns.ts +261 -0
  35. package/src/grid/sources/connector-source.ts +217 -0
  36. package/src/grid/sources/content-source.ts +542 -0
  37. package/src/grid/sources/csv-file-source.ts +247 -0
  38. package/src/grid/tabulator-tables.d.ts +120 -0
  39. package/src/panels/block-action-bar.ts +8 -3
  40. package/src/panels/chat-panel.ts +98 -0
  41. package/src/panels/data-grid.ts +9 -387
  42. package/src/panels/editors.ts +43 -17
  43. package/src/panels/events-panel.ts +137 -44
  44. package/src/panels/formula-workspace.ts +379 -0
  45. package/src/panels/frontmatter-fields.ts +231 -0
  46. package/src/panels/frontmatter-panel.ts +132 -0
  47. package/src/panels/git-panel.ts +1 -1
  48. package/src/panels/head-panel.ts +11 -175
  49. package/src/panels/properties-panel.ts +154 -144
  50. package/src/panels/right-panel.ts +9 -47
  51. package/src/panels/signals-panel.ts +115 -23
  52. package/src/panels/statement-editor.ts +710 -0
  53. package/src/panels/style-panel.ts +25 -1
  54. package/src/panels/stylebook-panel.ts +0 -2
  55. package/src/panels/tab-bar.ts +175 -6
  56. package/src/panels/tab-strip.ts +62 -6
  57. package/src/panels/toolbar.ts +37 -3
  58. package/src/services/ai-project-tools.ts +541 -0
  59. package/src/services/ai-session-store.ts +28 -0
  60. package/src/services/ai-system-prompt.ts +194 -24
  61. package/src/services/ai-tools.ts +88 -21
  62. package/src/services/automation.ts +16 -0
  63. package/src/services/document-assistant.ts +81 -11
  64. package/src/services/gated-registry.ts +72 -0
  65. package/src/services/live-preview.ts +0 -0
  66. package/src/services/preview-eval.ts +68 -0
  67. package/src/services/project-adoption.ts +31 -0
  68. package/src/site-context.ts +2 -0
  69. package/src/store.ts +4 -0
  70. package/src/studio.ts +83 -52
  71. package/src/tabs/patch-ops.ts +25 -0
  72. package/src/tabs/tab.ts +39 -1
  73. package/src/tabs/transact.ts +60 -13
  74. package/src/types.ts +12 -0
  75. package/src/ui/dynamic-slot.ts +272 -0
  76. package/src/ui/expression-editor.ts +423 -125
  77. package/src/ui/field-row.ts +5 -0
  78. package/src/ui/formula-catalog.ts +557 -0
  79. package/src/ui/formula-chips.ts +216 -0
  80. package/src/ui/formula-palette.ts +211 -0
  81. package/src/ui/layers.ts +40 -0
  82. package/src/ui/media-picker.ts +1 -1
  83. package/src/ui/panel-resize.ts +15 -1
  84. package/src/utils/preview-format.ts +26 -0
  85. package/src/view.ts +4 -4
  86. package/src/workspace/workspace.ts +14 -0
@@ -12,16 +12,22 @@
12
12
  import { createChatState, createProxyStreamingClient, createToolRegistry } from "@jxsuite/ai";
13
13
  import type { ProjectConfig } from "@jxsuite/schema/types";
14
14
  import { getPlatform } from "../platform";
15
- import { activeTab, workspace } from "../workspace/workspace";
15
+ import { activeTab, setWorkspaceProject, workspace } from "../workspace/workspace";
16
16
  import { toRaw } from "../reactivity";
17
+ import { projectState, setProjectState } from "../store";
18
+ import type { Tab } from "../tabs/tab";
17
19
  import { componentRegistry } from "../files/components";
18
20
  import { registerAiTools } from "./ai-tools";
21
+ import { registerProjectTools } from "./ai-project-tools";
22
+ import { createGatedToolRegistry } from "./gated-registry";
23
+ import type { ToolAvailability } from "./gated-registry";
24
+ import { adoptProject } from "./project-adoption";
19
25
  import { runAgentLoop } from "./tool-executor";
20
- import { buildSystemPrompt } from "./ai-system-prompt";
26
+ import { AI_TOOL_TIERS, buildSystemPrompt, tierActive } from "./ai-system-prompt";
21
27
  import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
22
28
  import { trimContext } from "./context-manager";
23
29
  import { renderCheck } from "./render-critic";
24
- import { openFileInTab } from "../files/files";
30
+ import { openFileInTab, reloadFileInTab } from "../files/files";
25
31
  import * as sessionStore from "./ai-session-store";
26
32
 
27
33
  /**
@@ -51,8 +57,23 @@ function projectRoot() {
51
57
  export function createDocumentAssistant() {
52
58
  const chatState = createChatState({ model: getModel() });
53
59
 
54
- const toolRegistry = createToolRegistry();
55
- registerAiTools(toolRegistry, {
60
+ /** The persisted session backing the live chat; null = fresh unsaved chat. */
61
+ let sessionId: string | null = null;
62
+
63
+ const getProjectStyle = () =>
64
+ (workspace.projectConfig as ProjectConfig | null)?.style as Record<string, string> | undefined;
65
+
66
+ const findOpenTab = (path: string): Tab | null => {
67
+ for (const tab of workspace.tabs.values()) {
68
+ if (tab.documentPath === path) {
69
+ return tab;
70
+ }
71
+ }
72
+ return null;
73
+ };
74
+
75
+ const innerRegistry = createToolRegistry();
76
+ registerAiTools(innerRegistry, {
56
77
  getTab: () => activeTab.value,
57
78
  saveFile: async (relPath: string, content: string) => {
58
79
  const plat = getPlatform();
@@ -62,23 +83,72 @@ export function createDocumentAssistant() {
62
83
  doc: unknown,
63
84
  ) => Promise<{ ok: true } | { ok: false; error: string }>,
64
85
  openDocument: openFileInTab,
65
- projectStyle: (workspace.projectConfig as ProjectConfig | null)?.style as
66
- | Record<string, string>
67
- | undefined,
86
+ getProjectStyle,
87
+ findOpenTab,
88
+ reloadTab: reloadFileInTab,
89
+ });
90
+ registerProjectTools(innerRegistry, {
91
+ getTab: () => activeTab.value,
92
+ renderCheck: renderCheck as (
93
+ doc: unknown,
94
+ ) => Promise<{ ok: true } | { ok: false; error: string }>,
95
+ getProjectStyle,
96
+ findOpenTab,
97
+ reloadTab: reloadFileInTab,
98
+ adoptProject,
99
+ // Re-key the live chat from the unscoped store to the adopted project so the bootstrap
100
+ // Conversation keeps persisting (saveSession drops writes for ids missing from the index).
101
+ onProjectAdopted: (root: string) => {
102
+ if (sessionId) {
103
+ sessionStore.moveSession("", root, sessionId);
104
+ }
105
+ },
106
+ onProjectConfigWritten: (config: object) => {
107
+ setWorkspaceProject(workspace.projectRoot, config);
108
+ if (projectState) {
109
+ setProjectState({ ...projectState, projectConfig: config as ProjectConfig });
110
+ }
111
+ },
68
112
  });
69
113
 
70
- let controller: AbortController | null = null;
114
+ /*
115
+ * Gate tool advertisement/execution on live studio state, derived from the same tier table the
116
+ * system prompt renders — the model is never shown a tool it cannot execute. listForLLM() runs
117
+ * every agent-loop round, so a mid-loop create_project unlocks the higher tiers immediately.
118
+ */
119
+ const TIER_REQUIREMENTS = {
120
+ "no-project": "no project to be open (it bootstraps one)",
121
+ project: "an open project",
122
+ document: "an open document (use open_document first)",
123
+ } as const;
124
+ const availability = new Map<string, ToolAvailability>(
125
+ AI_TOOL_TIERS.map((t) => [
126
+ t.name,
127
+ {
128
+ when: () => tierActive(t.tier, Boolean(workspace.projectRoot), Boolean(activeTab.value)),
129
+ requires: TIER_REQUIREMENTS[t.tier],
130
+ },
131
+ ]),
132
+ );
133
+ const toolRegistry = createGatedToolRegistry(innerRegistry, availability);
71
134
 
72
- /** The persisted session backing the live chat; null = fresh unsaved chat. */
73
- let sessionId: string | null = null;
135
+ let controller: AbortController | null = null;
74
136
 
75
137
  function buildPrompt() {
76
138
  const tab = activeTab.value;
139
+ const inventory = projectState
140
+ ? [...projectState.dirs.values()]
141
+ .flat()
142
+ .filter((e) => e.type === "file")
143
+ .map((e) => e.path)
144
+ : undefined;
77
145
  return buildSystemPrompt({
78
146
  document: tab ? toRaw(tab.doc.document) : undefined,
79
147
  projectConfig: (workspace.projectConfig as ProjectConfig | null) || undefined,
80
148
  components: componentRegistry.length > 0 ? componentRegistry : undefined,
81
149
  projectRoot: workspace.projectRoot || undefined,
150
+ hasProject: Boolean(workspace.projectRoot),
151
+ ...(inventory && inventory.length > 0 ? { fileInventory: inventory } : {}),
82
152
  });
83
153
  }
84
154
 
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gated-registry.ts — state-aware tool advertisement for the AI assistant.
3
+ *
4
+ * Wraps a `@jxsuite/ai` ToolRegistry so each tool is only advertised to the LLM while its
5
+ * availability predicate holds (no project open → bootstrap tools only; project open → file tools;
6
+ * document open → document tools). `runAgentLoop` calls `listForLLM()` every round, so availability
7
+ * flips mid-loop: after `create_project` succeeds, the very next round advertises the file and
8
+ * document tools without any extra plumbing.
9
+ *
10
+ * Executing a tool whose predicate currently fails returns a tool error naming the missing state
11
+ * instead of throwing, so the agent loop can self-correct.
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ import type { ToolDefinition, ToolRegistry } from "@jxsuite/ai/tools";
17
+
18
+ export interface ToolAvailability {
19
+ /** Whether the tool is currently advertised/executable. */
20
+ when: () => boolean;
21
+ /** Human-readable requirement, surfaced when execution is refused (e.g. "a project is open"). */
22
+ requires: string;
23
+ }
24
+
25
+ /**
26
+ * Wrap `inner` so tools listed in `availability` are only advertised/executable while their
27
+ * predicate holds. Tools without an entry are always available.
28
+ *
29
+ * @param {ToolRegistry} inner
30
+ * @param {Map<string, ToolAvailability>} availability
31
+ * @returns {ToolRegistry}
32
+ */
33
+ export function createGatedToolRegistry(
34
+ inner: ToolRegistry,
35
+ availability: Map<string, ToolAvailability>,
36
+ ): ToolRegistry {
37
+ function isAvailable(name: string): boolean {
38
+ const gate = availability.get(name);
39
+ return gate ? gate.when() : true;
40
+ }
41
+
42
+ return {
43
+ register(tool: ToolDefinition) {
44
+ inner.register(tool);
45
+ },
46
+ list() {
47
+ return inner.list().filter((t) => isAvailable(t.name));
48
+ },
49
+ listForLLM() {
50
+ const available = new Set(this.list().map((t) => t.name));
51
+ return inner
52
+ .listForLLM()
53
+ .filter((t) => available.has((t as { function?: { name?: string } }).function?.name ?? ""));
54
+ },
55
+ getDefinition(toolName: string) {
56
+ return inner.getDefinition(toolName);
57
+ },
58
+ validate(toolName: string, args: object) {
59
+ return inner.validate(toolName, args);
60
+ },
61
+ async execute(toolName: string, args: object) {
62
+ if (inner.getDefinition(toolName) && !isAvailable(toolName)) {
63
+ const gate = availability.get(toolName);
64
+ return {
65
+ success: false,
66
+ error: `Tool "${toolName}" is not available right now — it requires ${gate?.requires ?? "a different studio state"}.`,
67
+ };
68
+ }
69
+ return inner.execute(toolName, args);
70
+ },
71
+ };
72
+ }
Binary file
@@ -0,0 +1,68 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Parent-side live expression preview (spec §19.9).
4
+ *
5
+ * Evaluates an expression node against the canvas iframe's last dataScope snapshot
6
+ * (tab.session.canvas.scope) on a structuredClone, so mutating operators are safe — they mutate the
7
+ * clone, never the live canvas. The engine's trace hook reports every sub-node's value keyed by its
8
+ * path within the tree; values are formatted to display strings at report time so later mutations
9
+ * of the clone cannot retroactively change a badge.
10
+ */
11
+
12
+ import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
13
+ import { toRaw } from "../reactivity";
14
+ // Imported for local use AND re-exported below so existing consumers keep their import site.
15
+ // oxlint-disable-next-line unicorn/prefer-export-from
16
+ import { formatPreviewValue } from "../utils/preview-format";
17
+
18
+ import type { ExpressionNode } from "@jxsuite/runtime/expression";
19
+
20
+ export interface ExpressionPreview {
21
+ /** Path-keyed display values: "" is the root node, "value/target" a nested operand, etc. */
22
+ values: Map<string, string>;
23
+ /** Evaluation error message, if the expression threw. */
24
+ error: string | null;
25
+ /** Whether the root operator mutates its target (badge renders as an effect, not a value). */
26
+ mutating: boolean;
27
+ }
28
+
29
+ // The formatter is shared with the in-iframe live evaluator: it lives in utils/preview-format.ts
30
+ // (dependency-light, safe for the iframe bundle) and is re-exported here so existing consumers
31
+ // Keep their import site.
32
+ export { formatPreviewValue };
33
+
34
+ /**
35
+ * Evaluate `node` against a scope snapshot for display. Returns null when no snapshot is available
36
+ * (canvas not yet rendered) — the editor renders no badges rather than wrong ones.
37
+ */
38
+ export function previewExpression(
39
+ node: unknown,
40
+ scope?: Record<string, unknown> | null,
41
+ ): ExpressionPreview | null {
42
+ if (!scope || !node || typeof node !== "object" || !("operator" in node)) {
43
+ return null;
44
+ }
45
+
46
+ let state: Record<string, unknown>;
47
+ try {
48
+ // Callers hand over tab.session.canvas.scope, which the reactive session tree wraps in a
49
+ // Proxy — structuredClone rejects proxies, so unwrap to the raw snapshot first.
50
+ state = structuredClone(toRaw(scope));
51
+ } catch {
52
+ // Snapshot values are JSON-safe by construction (serialize-scope), but stay defensive.
53
+ return null;
54
+ }
55
+
56
+ const values = new Map<string, string>();
57
+ const expr = node as ExpressionNode;
58
+ let errorMessage: string | null = null;
59
+ try {
60
+ evaluateExpression(expr, state, null, undefined, {
61
+ report: (path, value) => values.set(path.join("/"), formatPreviewValue(value)),
62
+ });
63
+ } catch (error) {
64
+ errorMessage = error instanceof Error ? error.message : String(error);
65
+ }
66
+
67
+ return { error: errorMessage, mutating: isMutating(expr.operator), values };
68
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Project-adoption.ts — a late-bound slot for opening a freshly created project in this window.
3
+ *
4
+ * The AI `create_project` tool (services/ai-project-tools.ts) needs to run the full project-open
5
+ * flow (`openRecentProject` in studio.ts), but services must not import studio.ts — the entry
6
+ * module imports the services. studio.ts registers the adopter at boot; the tool calls
7
+ * `adoptProject(root)`.
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ let _adopter: ((root: string) => Promise<void>) | null = null;
13
+
14
+ /** Register the project-open flow (studio.ts boot). */
15
+ export function setProjectAdopter(fn: (root: string) => Promise<void>): void {
16
+ _adopter = fn;
17
+ }
18
+
19
+ /**
20
+ * Open the project at `root` in this window via the registered flow.
21
+ *
22
+ * @param {string} root - Absolute project root.
23
+ */
24
+ export async function adoptProject(root: string): Promise<void> {
25
+ if (!_adopter) {
26
+ throw new Error(
27
+ "Project adoption is not available in this environment — open the project manually from the welcome screen.",
28
+ );
29
+ }
30
+ await _adopter(root);
31
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { projectState, requireProjectState, setProjectState } from "./store";
10
10
  import { getPlatform } from "./platform";
11
+ import { setWorkspaceProject, workspace } from "./workspace/workspace";
11
12
 
12
13
  import type {
13
14
  JxElement,
@@ -272,6 +273,7 @@ export async function updateSiteConfig(patch: Partial<ProjectConfig>) {
272
273
  } as ProjectConfig;
273
274
  await platform.writeFile("project.json", JSON.stringify(config, null, 2));
274
275
  setProjectState({ ...requireProjectState(), projectConfig: config });
276
+ setWorkspaceProject(workspace.projectRoot, config);
275
277
  if ("extensions" in patch) {
276
278
  const { loadFormats, refreshExtensionUi, refreshFormats } =
277
279
  await import("./format/format-host");
package/src/store.ts CHANGED
@@ -37,16 +37,20 @@ export let canvasWrap = null as unknown as HTMLElement;
37
37
  export let activityBar = null as unknown as HTMLElement;
38
38
  export let leftPanel = null as unknown as HTMLElement;
39
39
  export let rightPanel = null as unknown as HTMLElement;
40
+ export let chatPanelEl = null as unknown as HTMLElement;
40
41
  export let toolbarEl = null as unknown as HTMLElement;
41
42
  export let statusbarEl = null as unknown as HTMLElement;
43
+ export let frontmatterPanelEl = null as unknown as HTMLElement;
42
44
 
43
45
  export function initShellRefs() {
44
46
  canvasWrap = document.querySelector("#canvas-wrap") as HTMLElement;
45
47
  activityBar = document.querySelector("#activity-bar") as HTMLElement;
46
48
  leftPanel = document.querySelector("#left-panel") as HTMLElement;
47
49
  rightPanel = document.querySelector("#right-panel") as HTMLElement;
50
+ chatPanelEl = document.querySelector("#chat-panel") as HTMLElement;
48
51
  toolbarEl = document.querySelector("#toolbar") as HTMLElement;
49
52
  statusbarEl = document.querySelector("#statusbar") as HTMLElement;
53
+ frontmatterPanelEl = document.querySelector("#frontmatter-panel") as HTMLElement;
50
54
  }
51
55
 
52
56
  // ─── Shared containers (mutated in place by owner modules) ───────────────────
package/src/studio.ts CHANGED
@@ -11,6 +11,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
11
11
 
12
12
  import {
13
13
  canvasWrap,
14
+ chatPanelEl,
14
15
  getNodeAtPath,
15
16
  initShellRefs,
16
17
  projectState,
@@ -23,14 +24,20 @@ import {
23
24
  updateUi,
24
25
  } from "./store";
25
26
 
26
- import { activeTab, closeAllTabs, openTab } from "./workspace/workspace";
27
+ import {
28
+ activeTab,
29
+ closeAllTabs,
30
+ openTab,
31
+ setWorkspaceProject,
32
+ workspace,
33
+ } from "./workspace/workspace";
27
34
  import { mutateUpdateDef, mutateUpdateProperty, transactDoc } from "./tabs/transact";
28
35
  import { effect } from "./reactivity";
29
36
 
30
37
  import { view } from "./view";
31
38
 
32
39
  import { isEditing } from "./editor/inline-edit";
33
- import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
40
+ import { applyTransform, initCanvasUtils } from "./canvas/canvas-utils";
34
41
  import {
35
42
  initCanvasRender,
36
43
  renderCanvas,
@@ -113,12 +120,15 @@ import { components as _swc } from "./ui/spectrum";
113
120
  import "./ui/panel-resize.js";
114
121
  // Built-in schema-form controls (schema-builder, secret) register on import
115
122
  import "./ui/form-controls.js";
116
- import { initLayers } from "./ui/layers";
123
+ import { initLayers, showSaveDiscardDialog } from "./ui/layers";
117
124
  import { initShortcuts } from "./editor/shortcuts";
118
125
  import { renderActivityBar, mount as mountActivityBar } from "./panels/activity-bar";
119
126
  import * as toolbarPanel from "./panels/toolbar";
120
127
  import * as overlaysPanel from "./panels/overlays";
128
+ import * as frontmatterPanelMod from "./panels/frontmatter-panel";
121
129
  import * as rightPanelMod from "./panels/right-panel";
130
+ import * as chatPanelMod from "./panels/chat-panel";
131
+ import { setProjectAdopter } from "./services/project-adoption";
122
132
  import * as leftPanelMod from "./panels/left-panel";
123
133
  import * as tabStrip from "./panels/tab-strip";
124
134
  import * as tabBar from "./panels/tab-bar";
@@ -127,6 +137,7 @@ import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from ".
127
137
  import { registerCanvasDndBridge } from "./panels/canvas-dnd-bridge";
128
138
  import { defaultDef } from "./panels/shared";
129
139
  import { registerFunctionCompletions } from "./panels/editors";
140
+ import { closeFormulaWorkspace } from "./panels/formula-workspace";
130
141
  import {
131
142
  initBlockActionBar,
132
143
  isEditChromeTarget,
@@ -142,6 +153,7 @@ import { initWelcome } from "./panels/welcome-screen";
142
153
  import { openAddRepoModal } from "./new-project/add-repo-modal";
143
154
  import { openNewProjectModal } from "./new-project/new-project-modal";
144
155
  import type { DocumentStackEntry, GitDiffState } from "./types";
156
+ import type { Tab } from "./tabs/tab";
145
157
  import type { JxPath } from "./state";
146
158
  import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
147
159
 
@@ -223,19 +235,41 @@ async function navigateToComponent(componentPath: string) {
223
235
  }
224
236
  }
225
237
 
238
+ /**
239
+ * Leaving a drilled-in component discards its edits (the pop restores the parent). Because saving
240
+ * is explicit, prompt when the child is dirty: Save writes it, Discard drops it, Cancel stays.
241
+ * Returns false to abort navigation.
242
+ *
243
+ * @param {Tab} tab
244
+ */
245
+ async function confirmLeaveDirtyChild(tab: Tab): Promise<boolean> {
246
+ if (!tab.doc.dirty || !tab.documentPath) {
247
+ return true;
248
+ }
249
+ const name = tab.documentPath.split("/").pop() || "component";
250
+ const choice = await showSaveDiscardDialog("Unsaved Changes", `"${name}" has unsaved changes.`);
251
+ if (choice === "cancel") {
252
+ return false;
253
+ }
254
+ if (choice === "save") {
255
+ try {
256
+ await getPlatform().writeFile(tab.documentPath, await serializeDocument(tab));
257
+ } catch (error) {
258
+ statusMessage(`Save error: ${(error as Error).message}`);
259
+ return false;
260
+ }
261
+ }
262
+ // "discard": leave without writing — the child's edits are dropped with the popped frame.
263
+ return true;
264
+ }
265
+
226
266
  async function navigateBack() {
227
267
  const tab = activeTab.value;
228
268
  if (!tab?.session.documentStack || tab.session.documentStack.length === 0) {
229
269
  return;
230
270
  }
231
- if (tab.doc.dirty && tab.documentPath) {
232
- try {
233
- const platform = getPlatform();
234
- await platform.writeFile(tab.documentPath, await serializeDocument(tab));
235
- } catch (error) {
236
- const err = error as Error;
237
- statusMessage(`Save error: ${err.message}`);
238
- }
271
+ if (!(await confirmLeaveDirtyChild(tab))) {
272
+ return;
239
273
  }
240
274
 
241
275
  // Pop the stack
@@ -262,14 +296,8 @@ async function navigateToLevel(targetIndex: number) {
262
296
  if (!stack || targetIndex < 0 || targetIndex >= stack.length) {
263
297
  return;
264
298
  }
265
- if (tab.doc.dirty && tab.documentPath) {
266
- try {
267
- const platform = getPlatform();
268
- await platform.writeFile(tab.documentPath, await serializeDocument(tab));
269
- } catch (error) {
270
- const err = error as Error;
271
- statusMessage(`Save error: ${err.message}`);
272
- }
299
+ if (!(await confirmLeaveDirtyChild(tab))) {
300
+ return;
273
301
  }
274
302
 
275
303
  const frame = stack[targetIndex] as DocumentStackEntry;
@@ -393,6 +421,7 @@ initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) }
393
421
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
394
422
 
395
423
  tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
424
+ closeFormulaWorkspace: () => closeFormulaWorkspace(),
396
425
  closeFunctionEditor: () => closeFunctionEditor(),
397
426
  exportFile,
398
427
  getCanvasMode,
@@ -442,6 +471,26 @@ document.addEventListener(
442
471
  true,
443
472
  );
444
473
 
474
+ // Unsaved-changes guard: saving is explicit (no idle autosave), so warn before the window unloads
475
+ // While any open tab has unsaved edits. For collab tabs, `dirty` reflects the room-level unsaved
476
+ // State; closing loses in-memory edits that were never flushed to disk.
477
+ export function hasUnsavedTabs(): boolean {
478
+ for (const tab of workspace.tabs.values()) {
479
+ if (tab.doc.dirty) {
480
+ return true;
481
+ }
482
+ }
483
+ return false;
484
+ }
485
+
486
+ window.addEventListener("beforeunload", (e: BeforeUnloadEvent) => {
487
+ if (hasUnsavedTabs()) {
488
+ e.preventDefault();
489
+ // Legacy browsers require a truthy returnValue to trigger the native confirm prompt.
490
+ e.returnValue = "";
491
+ }
492
+ });
493
+
445
494
  initCanvasUtils({
446
495
  getCanvasMode,
447
496
  getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
@@ -518,10 +567,12 @@ effect(() => {
518
567
  if (tab) {
519
568
  void tab.doc.mode;
520
569
  void tab.session.ui.canvasMode;
570
+ void tab.session.ui.editingFormula;
521
571
  void tab.session.ui.editingFunction;
522
572
  void tab.session.ui.featureToggles;
523
573
  void tab.session.ui.preview;
524
574
  void tab.session.ui.previewParams;
575
+ void tab.session.ui.previewProps;
525
576
  void tab.session.ui.settingsTab;
526
577
  void tab.session.ui.showLayout;
527
578
  void tab.session.ui.stylebookTab;
@@ -537,6 +588,15 @@ rightPanelMod.mount({
537
588
  renderCanvas: () => renderCanvas(),
538
589
  });
539
590
 
591
+ // Above-canvas frontmatter Properties panel (content-collection docs, edit mode).
592
+ frontmatterPanelMod.mount({ getCanvasMode });
593
+
594
+ // The persistent AI chat sidebar — mounts once, available with or without a project/document.
595
+ chatPanelMod.mount(chatPanelEl);
596
+ // The assistant's create_project tool adopts freshly scaffolded projects through the same
597
+ // Flow as the recent-projects list.
598
+ setProjectAdopter(openRecentProject);
599
+
540
600
  leftPanelMod.mount({
541
601
  cloneRepository: () => cloneRepository({ openRecentProject }),
542
602
  defBadgeLabel,
@@ -568,6 +628,8 @@ leftPanelMod.mount({
568
628
  registerRenderer("leftPanel", () => leftPanelMod.render());
569
629
  registerRenderer("canvas", () => renderCanvas());
570
630
  registerRenderer("rightPanel", () => rightPanelMod.render());
631
+ registerRenderer("frontmatterPanel", () => frontmatterPanelMod.render());
632
+ registerRenderer("chatPanel", () => chatPanelMod.render());
571
633
  registerRenderer("overlays", () => overlaysPanel.render());
572
634
  setStatusbarRenderer(() => renderStatusbar());
573
635
  mountStatusbar();
@@ -667,6 +729,7 @@ if (_projectParam) {
667
729
  searchQuery: "",
668
730
  selectedPath: siteCtx.fileRelPath || null,
669
731
  });
732
+ setWorkspaceProject(siteCtx.sitePath, siteCtx.projectConfig || null);
670
733
 
671
734
  refreshExtensionUi(platform);
672
735
  await autoSyncProjectOnOpen();
@@ -859,6 +922,7 @@ async function openRecentProject(root: string) {
859
922
  searchQuery: "",
860
923
  selectedPath: null,
861
924
  });
925
+ setWorkspaceProject(root, config);
862
926
 
863
927
  await autoSyncProjectOnOpen();
864
928
  await ensureDependenciesInstalled();
@@ -918,7 +982,6 @@ initShortcuts(() => ({
918
982
  openProject,
919
983
  panX: view.panX,
920
984
  panY: view.panY,
921
- positionZoomIndicator,
922
985
  saveFile,
923
986
  setPan: (x, y) => {
924
987
  view.panX = x;
@@ -926,35 +989,3 @@ initShortcuts(() => ({
926
989
  view.needsCenter = false;
927
990
  },
928
991
  }));
929
-
930
- // ─── Autosave (registered as update middleware) ──────────────────────────────
931
-
932
- const AUTO_SAVE_DELAY = 2000;
933
-
934
- function scheduleAutosave() {
935
- const tab = activeTab.value;
936
- if (!tab?.fileHandle || !tab.doc.dirty) {
937
- return;
938
- }
939
- if (view.autosaveTimer) {
940
- clearTimeout(view.autosaveTimer);
941
- }
942
- view.autosaveTimer = setTimeout(async () => {
943
- const t = activeTab.value;
944
- if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
945
- try {
946
- const writable = await t.fileHandle.createWritable();
947
- await writable.write(await serializeDocument(t));
948
- await writable.close();
949
- t.doc.dirty = false;
950
- statusMessage("Auto-saved");
951
- } catch {}
952
- }
953
- }, AUTO_SAVE_DELAY);
954
- }
955
-
956
- effect(() => {
957
- if (activeTab.value?.doc.dirty) {
958
- scheduleAutosave();
959
- }
960
- });
@@ -40,12 +40,26 @@ export type JxPatchOp =
40
40
  export type { JxDocOp } from "@jxsuite/collab/ops";
41
41
  export type { JxDocOpPair };
42
42
 
43
+ /**
44
+ * A frontmatter key change, replayable in either direction. Frontmatter lives outside the document
45
+ * tree (`tab.doc.content.frontmatter`, split off by the format host), so these are studio-local and
46
+ * never enter the shared `JxDocOp` vocabulary the collab bridge and canvas iframe mirror.
47
+ * `undefined` means the key was/becomes absent.
48
+ */
49
+ export interface JxFmOp {
50
+ field: string;
51
+ before?: unknown;
52
+ after?: unknown;
53
+ }
54
+
43
55
  /** Everything recorded during one transaction. */
44
56
  export interface TransactionRecord {
45
57
  /** Canvas patch ops (path-only) for surgical DOM updates. */
46
58
  ops: JxPatchOp[];
47
59
  /** Replayable forward/inverse document ops for patch-based history. */
48
60
  docOps: JxDocOpPair[];
61
+ /** Replayable frontmatter key changes (content-mode docs) for history undo/redo. */
62
+ fmOps: JxFmOp[];
49
63
  /**
50
64
  * False when some mutation in the transaction could not produce a guaranteed-correct inverse
51
65
  * (e.g. a move whose parents' paths interact) — history falls back to a checkpoint snapshot.
@@ -86,12 +100,14 @@ export function getPatchConsumer(): PatchConsumer | null {
86
100
 
87
101
  let _recording: JxPatchOp[] | null = null;
88
102
  let _docOps: JxDocOpPair[] | null = null;
103
+ let _fmOps: JxFmOp[] | null = null;
89
104
  let _invertible = true;
90
105
 
91
106
  /** Begin recording patch ops for a transaction. */
92
107
  export function beginRecording() {
93
108
  _recording = [];
94
109
  _docOps = [];
110
+ _fmOps = [];
95
111
  _invertible = true;
96
112
  }
97
113
 
@@ -109,6 +125,13 @@ export function recordDocOp(pair: JxDocOpPair) {
109
125
  }
110
126
  }
111
127
 
128
+ /** Record a replayable frontmatter key change. No-op outside a transaction. */
129
+ export function recordFmOp(op: JxFmOp) {
130
+ if (_fmOps) {
131
+ _fmOps.push(op);
132
+ }
133
+ }
134
+
112
135
  /** Mark the current transaction as non-invertible — history stores a checkpoint instead. */
113
136
  export function markNonInvertible() {
114
137
  _invertible = false;
@@ -121,11 +144,13 @@ export function markNonInvertible() {
121
144
  export function endRecording(): TransactionRecord {
122
145
  const record: TransactionRecord = {
123
146
  docOps: _docOps ?? [],
147
+ fmOps: _fmOps ?? [],
124
148
  invertible: _invertible,
125
149
  ops: _recording ?? [],
126
150
  };
127
151
  _recording = null;
128
152
  _docOps = null;
153
+ _fmOps = null;
129
154
  _invertible = true;
130
155
  return record;
131
156
  }