@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
@@ -0,0 +1,216 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Formula chips — a horizontal presentation layer summarizing an expression tree as left-to-right
4
+ * chips (spec §19.9). The `target` chain unrolls deepest-first: the head chip is the innermost
5
+ * target operand (ref/literal), followed by each operator up to the root. Nested non-target
6
+ * operands render as parenthesized group chips. Pure presentation — no editing logic; clicking a
7
+ * chip reports the node path to the caller.
8
+ */
9
+
10
+ import { html, nothing } from "lit-html";
11
+ import { isJsonObject, isRef } from "@jxsuite/schema/guards";
12
+
13
+ import type { TemplateResult } from "lit-html";
14
+ import type { EditorPreview } from "./expression-editor";
15
+
16
+ type NodePath = (string | number)[];
17
+
18
+ interface ChainLink {
19
+ node: Record<string, unknown>;
20
+ path: NodePath;
21
+ }
22
+
23
+ interface Chain {
24
+ /** The innermost target operand (ref/literal), absent for target-less roots. */
25
+ head: { value: unknown; path: NodePath } | null;
26
+ /** Operator nodes, deepest target first → outermost operator last. */
27
+ links: ChainLink[];
28
+ }
29
+
30
+ function isExprNode(value: unknown): value is Record<string, unknown> {
31
+ return isJsonObject(value) && typeof value.operator === "string";
32
+ }
33
+
34
+ /** Human label for a pointer ref: strips the #/state/ and window#/ prefixes. */
35
+ function refLabel(ref: string): string {
36
+ if (ref.startsWith("#/state/")) {
37
+ return ref.slice("#/state/".length);
38
+ }
39
+ if (ref.startsWith("window#/")) {
40
+ return ref.slice("window#/".length).replaceAll("/", ".");
41
+ }
42
+ return ref || "?";
43
+ }
44
+
45
+ /** Compact label for a non-node operand (ref or literal). */
46
+ function operandLabel(value: unknown): string {
47
+ if (isRef(value)) {
48
+ return refLabel(value.$ref);
49
+ }
50
+ if (isExprNode(value)) {
51
+ return `(${chipSummary(value)})`;
52
+ }
53
+ if (typeof value === "string") {
54
+ return JSON.stringify(value);
55
+ }
56
+ return String(value);
57
+ }
58
+
59
+ /** The chain path for `hops` target descents below `basePath`. */
60
+ function targetPath(basePath: NodePath, hops: number): NodePath {
61
+ return [...basePath, ...Array.from({ length: hops }, () => "target")];
62
+ }
63
+
64
+ /** Unroll a node's target chain into head operand + operator links (deepest first). */
65
+ function unrollChain(node: Record<string, unknown>, basePath: NodePath): Chain {
66
+ // Collect the chain root-first, then materialize each link's path from its descent depth.
67
+ const nodes: Record<string, unknown>[] = [];
68
+ let current: Record<string, unknown> = node;
69
+ for (;;) {
70
+ nodes.push(current);
71
+ const next = current.target;
72
+ if (isExprNode(next)) {
73
+ current = next;
74
+ } else {
75
+ break;
76
+ }
77
+ }
78
+ const deepest = nodes.at(-1)!;
79
+ const head: Chain["head"] =
80
+ "target" in deepest || deepest.target !== undefined
81
+ ? { path: targetPath(basePath, nodes.length), value: deepest.target }
82
+ : null;
83
+ const rootFirst = nodes.map((n, i) => ({ node: n, path: targetPath(basePath, i) }));
84
+ return { head, links: rootFirst.toReversed() };
85
+ }
86
+
87
+ /** Compact one-line text form of a node: head operand followed by the operator chain. */
88
+ export function chipSummary(node: unknown): string {
89
+ if (!isExprNode(node)) {
90
+ return operandLabel(node);
91
+ }
92
+ const { head, links } = unrollChain(node, []);
93
+ const parts: string[] = [];
94
+ if (head) {
95
+ parts.push(operandLabel(head.value));
96
+ }
97
+ for (const link of links) {
98
+ parts.push(String(link.node.operator));
99
+ }
100
+ return parts.join(" › ");
101
+ }
102
+
103
+ // ─── Rendering ──────────────────────────────────────────────────────────────
104
+
105
+ const CHIP_STYLE =
106
+ "display:inline-flex;align-items:center;gap:4px;max-width:180px;padding:1px 7px;" +
107
+ "border:1px solid var(--spectrum-gray-300, #3c3c3c);border-radius:10px;cursor:pointer;" +
108
+ "background:var(--spectrum-gray-100, #232323);color:var(--spectrum-gray-800, #d0d0d0);" +
109
+ "font-size:11px;font-family:var(--spectrum-code-font-family, monospace);line-height:18px";
110
+
111
+ /** Live value badge — same styling convention as the expression editor's `.expr-live-badge`. */
112
+ function renderChipBadge(preview: EditorPreview | null | undefined, pathKey: string) {
113
+ const text = preview?.values.get(pathKey);
114
+ if (text === undefined) {
115
+ return nothing;
116
+ }
117
+ return html`
118
+ <span
119
+ class="expr-live-badge"
120
+ title=${text}
121
+ style="font-family:var(--spectrum-code-font-family, monospace);font-size:10px;line-height:16px;padding:0 5px;border-radius:4px;background:var(--spectrum-gray-200, #323232);color:var(--spectrum-seafoam-900, #35a690);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px;flex-shrink:1"
122
+ >${text}</span
123
+ >
124
+ `;
125
+ }
126
+
127
+ function renderChip(
128
+ label: string,
129
+ path: NodePath,
130
+ onSelect: (path: NodePath) => void,
131
+ preview: EditorPreview | null | undefined,
132
+ group = false,
133
+ ) {
134
+ return html`
135
+ <button
136
+ type="button"
137
+ class=${group ? "formula-chip formula-chip--group" : "formula-chip"}
138
+ data-path=${path.join("/")}
139
+ style=${CHIP_STYLE}
140
+ title=${label}
141
+ @click=${() => onSelect(path)}
142
+ >
143
+ <span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px"
144
+ >${label}</span
145
+ >
146
+ ${renderChipBadge(preview, path.join("/"))}
147
+ </button>
148
+ `;
149
+ }
150
+
151
+ /** Collect group chips for a link's non-target expression operands (value/initial/cases). */
152
+ function groupChips(
153
+ link: ChainLink,
154
+ onSelect: (path: NodePath) => void,
155
+ preview: EditorPreview | null | undefined,
156
+ ): TemplateResult[] {
157
+ const chips: TemplateResult[] = [];
158
+ const add = (operand: unknown, path: NodePath) => {
159
+ if (isExprNode(operand)) {
160
+ chips.push(renderChip(`(${chipSummary(operand)})`, path, onSelect, preview, true));
161
+ }
162
+ };
163
+ const { value, initial, cases } = link.node;
164
+ if (Array.isArray(value)) {
165
+ for (const [i, item] of value.entries()) {
166
+ add(item, [...link.path, "value", i]);
167
+ }
168
+ } else {
169
+ add(value, [...link.path, "value"]);
170
+ }
171
+ add(initial, [...link.path, "initial"]);
172
+ if (isJsonObject(cases)) {
173
+ for (const [key, operand] of Object.entries(cases)) {
174
+ add(operand, [...link.path, "cases", key]);
175
+ }
176
+ }
177
+ add(link.node.default, [...link.path, "default"]);
178
+ return chips;
179
+ }
180
+
181
+ /**
182
+ * Render an expression node as a horizontal chip pipeline. Each chip carries the node's live value
183
+ * badge when a preview is supplied; clicking a chip calls `onSelect` with the node path.
184
+ */
185
+ export function renderFormulaChips(
186
+ node: unknown,
187
+ onSelect: (path: NodePath) => void,
188
+ opts: { preview?: EditorPreview | null; path?: NodePath } = {},
189
+ ): TemplateResult {
190
+ if (!isExprNode(node)) {
191
+ return html`${nothing}`;
192
+ }
193
+ const preview = opts.preview ?? null;
194
+ const basePath = opts.path ?? [];
195
+ const { head, links } = unrollChain(node, basePath);
196
+
197
+ const chips: TemplateResult[] = [];
198
+ if (head) {
199
+ chips.push(renderChip(operandLabel(head.value), head.path, onSelect, preview));
200
+ }
201
+ for (const link of links) {
202
+ chips.push(
203
+ renderChip(String(link.node.operator), link.path, onSelect, preview),
204
+ ...groupChips(link, onSelect, preview),
205
+ );
206
+ }
207
+
208
+ return html`
209
+ <div
210
+ class="formula-chips"
211
+ style="display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:2px 0 6px"
212
+ >
213
+ ${chips}
214
+ </div>
215
+ `;
216
+ }
@@ -0,0 +1,211 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Formula palette — a command-palette overlay for browsing/searching the formula catalog (spec
4
+ * §19.9). Clones the quick-search pattern: a plain overlay div (no nested sp-overlay), a search
5
+ * field, grouped results, and keyboard navigation. Picking an entry calls `onPick` with it; the
6
+ * caller decides what to insert.
7
+ */
8
+
9
+ import { html, render as litRender, nothing } from "lit-html";
10
+ import { classMap } from "lit-html/directives/class-map.js";
11
+ import { live } from "lit-html/directives/live.js";
12
+ import { ref } from "lit-html/directives/ref.js";
13
+ import { getLayerSlot } from "./layers";
14
+ import { rectOf } from "../utils/geometry";
15
+
16
+ import type { FormulaCatalogEntry } from "./formula-catalog";
17
+
18
+ export interface FormulaPaletteOpts {
19
+ entries: FormulaCatalogEntry[];
20
+ onPick: (entry: FormulaCatalogEntry) => void;
21
+ /** Optional anchor element the panel is positioned under; centered when absent. */
22
+ anchor?: HTMLElement | null;
23
+ }
24
+
25
+ let _open = false;
26
+ let _opts: FormulaPaletteOpts | null = null;
27
+ let _query = "";
28
+ let _selectedIndex = 0;
29
+
30
+ function getContainer() {
31
+ return getLayerSlot("popover", "formula-palette");
32
+ }
33
+
34
+ export function openFormulaPalette(opts: FormulaPaletteOpts) {
35
+ _open = true;
36
+ _opts = opts;
37
+ _query = "";
38
+ _selectedIndex = 0;
39
+ renderOverlay();
40
+ }
41
+
42
+ export function closeFormulaPalette() {
43
+ _open = false;
44
+ _opts = null;
45
+ renderOverlay();
46
+ }
47
+
48
+ /** Entries matching the current query (label, name, group, or description substring). */
49
+ function filteredEntries(): FormulaCatalogEntry[] {
50
+ const entries = _opts?.entries ?? [];
51
+ const needle = _query.trim().toLowerCase();
52
+ if (!needle) {
53
+ return entries;
54
+ }
55
+ return entries.filter(
56
+ (e) =>
57
+ e.label.toLowerCase().includes(needle) ||
58
+ e.name.toLowerCase().includes(needle) ||
59
+ e.group.toLowerCase().includes(needle) ||
60
+ e.description.toLowerCase().includes(needle),
61
+ );
62
+ }
63
+
64
+ /** Group entries for display, preserving first-seen group order. */
65
+ function groupedEntries(entries: FormulaCatalogEntry[]): [string, FormulaCatalogEntry[]][] {
66
+ const groups = new Map<string, FormulaCatalogEntry[]>();
67
+ for (const entry of entries) {
68
+ const bucket = groups.get(entry.group);
69
+ if (bucket) {
70
+ bucket.push(entry);
71
+ } else {
72
+ groups.set(entry.group, [entry]);
73
+ }
74
+ }
75
+ return [...groups.entries()];
76
+ }
77
+
78
+ function pickEntry(entry: FormulaCatalogEntry) {
79
+ const opts = _opts;
80
+ closeFormulaPalette();
81
+ opts?.onPick(entry);
82
+ }
83
+
84
+ function onInput(e: Event) {
85
+ _query = (e.target as HTMLInputElement).value;
86
+ _selectedIndex = 0;
87
+ renderOverlay();
88
+ }
89
+
90
+ function onKeydown(e: KeyboardEvent) {
91
+ const entries = filteredEntries();
92
+ switch (e.key) {
93
+ case "ArrowDown": {
94
+ e.preventDefault();
95
+ _selectedIndex = Math.min(_selectedIndex + 1, entries.length - 1);
96
+ renderOverlay();
97
+ break;
98
+ }
99
+ case "ArrowUp": {
100
+ e.preventDefault();
101
+ _selectedIndex = Math.max(_selectedIndex - 1, 0);
102
+ renderOverlay();
103
+ break;
104
+ }
105
+ case "Enter": {
106
+ e.preventDefault();
107
+ if (entries[_selectedIndex]) {
108
+ pickEntry(entries[_selectedIndex]!);
109
+ }
110
+ break;
111
+ }
112
+ case "Escape": {
113
+ e.preventDefault();
114
+ closeFormulaPalette();
115
+ break;
116
+ }
117
+ default: {
118
+ break;
119
+ }
120
+ }
121
+ }
122
+
123
+ /** Inline position style for the panel when anchored to an element. */
124
+ function panelStyle(): string {
125
+ const anchor = _opts?.anchor;
126
+ if (!anchor) {
127
+ return "";
128
+ }
129
+ const rect = rectOf(anchor);
130
+ if (!rect || (rect.top === 0 && rect.left === 0 && rect.width === 0)) {
131
+ return "";
132
+ }
133
+ const left = Math.max(8, Math.min(rect.left, (globalThis.innerWidth || 1200) - 568));
134
+ return `position:fixed;left:${left}px;top:${rect.bottom + 4}px;margin:0`;
135
+ }
136
+
137
+ function renderOverlay() {
138
+ const container = getContainer();
139
+ if (!_open) {
140
+ litRender(nothing, container);
141
+ return;
142
+ }
143
+
144
+ const entries = filteredEntries();
145
+ const groups = groupedEntries(entries);
146
+ const hasQuery = _query.trim().length > 0;
147
+
148
+ const tpl = html`
149
+ <div class="quick-search-overlay formula-palette-overlay" @click=${closeFormulaPalette}>
150
+ <div
151
+ class="quick-search-panel formula-palette"
152
+ style=${panelStyle()}
153
+ @click=${(e: Event) => e.stopPropagation()}
154
+ >
155
+ <input
156
+ class="quick-search-input formula-palette-input"
157
+ type="text"
158
+ placeholder="Search formulas, operators, globals…"
159
+ .value=${live(_query)}
160
+ @input=${onInput}
161
+ @keydown=${onKeydown}
162
+ ${ref((el) => {
163
+ if (el) {
164
+ requestAnimationFrame(() => (el as HTMLInputElement).focus());
165
+ }
166
+ })}
167
+ />
168
+ <div class="quick-search-results">
169
+ ${entries.length === 0
170
+ ? html`<div class="quick-search-empty">
171
+ ${hasQuery ? "No results" : "No entries available"}
172
+ </div>`
173
+ : nothing}
174
+ ${groups.map(
175
+ ([group, groupEntries]) => html`
176
+ <div class="quick-search-section-label">${group}</div>
177
+ ${groupEntries.map((entry) => {
178
+ const index = entries.indexOf(entry);
179
+ return html`
180
+ <div
181
+ class=${classMap({
182
+ "quick-search-item": true,
183
+ selected: index === _selectedIndex,
184
+ })}
185
+ @click=${() => pickEntry(entry)}
186
+ @mouseenter=${() => {
187
+ _selectedIndex = index;
188
+ renderOverlay();
189
+ }}
190
+ >
191
+ <span
192
+ class="quick-search-name"
193
+ style="font-family:var(--spectrum-code-font-family, monospace)"
194
+ >${entry.label}</span
195
+ >
196
+ <span class="quick-search-path" title=${entry.description}
197
+ >${entry.description}</span
198
+ >
199
+ <span class="quick-search-badge">${entry.kind}</span>
200
+ </div>
201
+ `;
202
+ })}
203
+ `,
204
+ )}
205
+ </div>
206
+ </div>
207
+ </div>
208
+ `;
209
+
210
+ litRender(tpl, container);
211
+ }
package/src/ui/layers.ts CHANGED
@@ -78,6 +78,46 @@ export function showConfirmDialog(
78
78
  );
79
79
  }
80
80
 
81
+ /**
82
+ * Show a three-way Save / Discard / Cancel dialog. Resolves "save", "discard", or "cancel"
83
+ * (dismiss/close counts as cancel).
84
+ *
85
+ * @param {string} headline
86
+ * @param {string | import("lit-html").TemplateResult} message
87
+ * @param {{ saveLabel?: string; discardLabel?: string; cancelLabel?: string }} [opts]
88
+ * @returns {Promise<"save" | "discard" | "cancel">}
89
+ */
90
+ export function showSaveDiscardDialog(
91
+ headline: string,
92
+ message: string | TemplateResult,
93
+ opts: {
94
+ saveLabel?: string;
95
+ discardLabel?: string;
96
+ cancelLabel?: string;
97
+ } = {},
98
+ ): Promise<"save" | "discard" | "cancel"> {
99
+ const { saveLabel = "Save", discardLabel = "Discard", cancelLabel = "Cancel" } = opts;
100
+ return showDialog<"save" | "discard" | "cancel">(
101
+ (done) => html`
102
+ <sp-dialog-wrapper
103
+ open
104
+ underlay
105
+ headline=${headline}
106
+ confirm-label=${saveLabel}
107
+ secondary-label=${discardLabel}
108
+ cancel-label=${cancelLabel}
109
+ size="s"
110
+ @confirm=${() => done("save")}
111
+ @secondary=${() => done("discard")}
112
+ @cancel=${() => done("cancel")}
113
+ @close=${() => done("cancel")}
114
+ >
115
+ <p>${message}</p>
116
+ </sp-dialog-wrapper>
117
+ `,
118
+ );
119
+ }
120
+
81
121
  /**
82
122
  * Open a persistent modal. Returns a handle with update() and close() methods.
83
123
  *
@@ -93,7 +93,7 @@ async function loadMediaCache() {
93
93
  // Re-render the host panels so the Browse button (gated on mediaCache.length) appears once the
94
94
  // Async listing resolves — including when an image value is already set, so the current image can
95
95
  // Be replaced. Mirrors loadLayoutEntries()'s renderOnly() in head-panel.
96
- renderOnly("leftPanel", "rightPanel");
96
+ renderOnly("leftPanel", "rightPanel", "frontmatterPanel");
97
97
  }
98
98
 
99
99
  /** Force media cache reload (e.g. after upload). */
@@ -13,6 +13,7 @@ const MIN_WIDTH = 160;
13
13
  const MAX_RATIO = 0.5; // Max 50% of viewport
14
14
  const DEFAULT_LEFT = 240;
15
15
  const DEFAULT_RIGHT = 280;
16
+ const DEFAULT_CHAT = 320;
16
17
 
17
18
  const root = document.documentElement;
18
19
 
@@ -27,8 +28,10 @@ try {
27
28
  const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") as {
28
29
  left?: number;
29
30
  right?: number;
31
+ chat?: number;
30
32
  leftCollapsed?: boolean;
31
33
  rightCollapsed?: boolean;
34
+ chatCollapsed?: boolean;
32
35
  };
33
36
  if (saved.left) {
34
37
  root.style.setProperty("--panel-w-left", `${saved.left}px`);
@@ -36,12 +39,18 @@ try {
36
39
  if (saved.right) {
37
40
  root.style.setProperty("--panel-w-right", `${saved.right}px`);
38
41
  }
42
+ if (saved.chat) {
43
+ root.style.setProperty("--panel-w-chat", `${saved.chat}px`);
44
+ }
39
45
  if (saved.leftCollapsed) {
40
46
  view.leftPanelCollapsed = true;
41
47
  }
42
48
  if (saved.rightCollapsed) {
43
49
  view.rightPanelCollapsed = true;
44
50
  }
51
+ if (saved.chatCollapsed) {
52
+ view.chatPanelCollapsed = true;
53
+ }
45
54
  applyPanelCollapse();
46
55
  } catch {
47
56
  // Ignore
@@ -111,8 +120,9 @@ function setupHandle(
111
120
  function persistWidths() {
112
121
  const left = readPxVar("--panel-w-left") || DEFAULT_LEFT;
113
122
  const right = readPxVar("--panel-w-right") || DEFAULT_RIGHT;
123
+ const chat = readPxVar("--panel-w-chat") || DEFAULT_CHAT;
114
124
  try {
115
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
125
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ chat, left, right }));
116
126
  } catch {
117
127
  // Storage full or unavailable
118
128
  }
@@ -122,6 +132,7 @@ function persistWidths() {
122
132
 
123
133
  const resizeLeft = document.querySelector<HTMLElement>("#resize-left");
124
134
  const resizeRight = document.querySelector<HTMLElement>("#resize-right");
135
+ const resizeChat = document.querySelector<HTMLElement>("#resize-chat");
125
136
 
126
137
  if (resizeLeft) {
127
138
  setupHandle(resizeLeft, "--panel-w-left", "left", DEFAULT_LEFT);
@@ -129,3 +140,6 @@ if (resizeLeft) {
129
140
  if (resizeRight) {
130
141
  setupHandle(resizeRight, "--panel-w-right", "right", DEFAULT_RIGHT);
131
142
  }
143
+ if (resizeChat) {
144
+ setupHandle(resizeChat, "--panel-w-chat", "right", DEFAULT_CHAT);
145
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Display formatting for live expression-preview values, shared by the parent-side snapshot
3
+ * evaluator (services/preview-eval.ts) and the in-iframe live evaluator (canvas/iframe-eval.ts) so
4
+ * both realms badge values with identical truncation rules. Dependency-light and DOM-free (the
5
+ * strip-events pattern) so importing it never grows the iframe bundle.
6
+ */
7
+
8
+ const MAX_BADGE_LENGTH = 48;
9
+
10
+ /** Format a runtime value as a short badge string. */
11
+ export function formatPreviewValue(value?: unknown): string {
12
+ if (value === undefined) {
13
+ return "undefined";
14
+ }
15
+ let text: string;
16
+ try {
17
+ text =
18
+ typeof value === "string" ? JSON.stringify(value) : (JSON.stringify(value) ?? "undefined");
19
+ } catch {
20
+ text = String(value);
21
+ }
22
+ if (text.length > MAX_BADGE_LENGTH) {
23
+ text = `${text.slice(0, MAX_BADGE_LENGTH - 1)}…`;
24
+ }
25
+ return text;
26
+ }
package/src/view.ts CHANGED
@@ -46,7 +46,7 @@ interface ViewState {
46
46
  leftTab: string;
47
47
  leftPanelCollapsed: boolean;
48
48
  rightPanelCollapsed: boolean;
49
- autosaveTimer: ReturnType<typeof setTimeout> | null;
49
+ chatPanelCollapsed: boolean;
50
50
  _layersCollapsed: Set<string> | null;
51
51
  [key: string]: unknown;
52
52
  }
@@ -104,9 +104,7 @@ export const view: ViewState = {
104
104
  leftTab: "layers",
105
105
  leftPanelCollapsed: false,
106
106
  rightPanelCollapsed: false,
107
-
108
- // Autosave
109
- autosaveTimer: null,
107
+ chatPanelCollapsed: false,
110
108
 
111
109
  // Layers panel collapsed state
112
110
  _layersCollapsed: null,
@@ -121,6 +119,7 @@ export function applyPanelCollapse() {
121
119
  }
122
120
  app.classList.toggle("left-collapsed", view.leftPanelCollapsed);
123
121
  app.classList.toggle("right-collapsed", view.rightPanelCollapsed);
122
+ app.classList.toggle("chat-collapsed", view.chatPanelCollapsed);
124
123
  try {
125
124
  const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}") as Record<
126
125
  string,
@@ -128,6 +127,7 @@ export function applyPanelCollapse() {
128
127
  >;
129
128
  saved.leftCollapsed = view.leftPanelCollapsed;
130
129
  saved.rightCollapsed = view.rightPanelCollapsed;
130
+ saved.chatCollapsed = view.chatPanelCollapsed;
131
131
  localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(saved));
132
132
  } catch {
133
133
  // Storage unavailable
@@ -57,6 +57,20 @@ export const activeTab = computed(() =>
57
57
  workspace.activeTabId ? (workspace.tabs.get(workspace.activeTabId) ?? null) : null,
58
58
  ) as unknown as ComputedRef<Tab | null>;
59
59
 
60
+ /**
61
+ * Record the open project on the reactive workspace. `root` must be the absolute project root (the
62
+ * same value `createProject` returns and the pending-agent-prompt handoff is keyed by) — not the
63
+ * "."-relative root projectState sometimes holds. Consumers: AI session scoping, system-prompt
64
+ * project context, and the chat panel's pending-prompt effect.
65
+ *
66
+ * @param {string | null} root
67
+ * @param {object | null} [config]
68
+ */
69
+ export function setWorkspaceProject(root: string | null, config: object | null = null) {
70
+ workspace.projectRoot = root;
71
+ workspace.projectConfig = config;
72
+ }
73
+
60
74
  /**
61
75
  * Whether `tab` is the active tab. Identity check survives reactive-proxy wrapping (activeTab.value
62
76
  * is a proxy; callers may hold either the raw tab or a proxy of it).