@agent-native/core 0.84.38 → 0.84.40

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/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/action-continuation-guidance.ts +38 -0
  5. package/corpus/core/src/agent/production-agent.ts +209 -4
  6. package/corpus/core/src/agent/run-loop-with-resume.ts +128 -16
  7. package/corpus/core/src/agent/types.ts +8 -0
  8. package/corpus/core/src/client/agent-chat-adapter.ts +2 -40
  9. package/corpus/templates/design/app/components/design/CodeWorkbenchHost.tsx +486 -341
  10. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +13 -0
  11. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +76 -58
  12. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +6 -0
  13. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +80 -8
  14. package/corpus/templates/design/app/i18n/zh-TW.ts +2 -2
  15. package/corpus/templates/design/app/i18n-data.ts +22 -30
  16. package/corpus/templates/design/app/lib/design-import.ts +42 -0
  17. package/corpus/templates/design/app/pages/DesignEditor.tsx +180 -109
  18. package/corpus/templates/design/changelog/2026-07-01-code-workspace-now-opens-a-vs-code-like-editor-with-real-cod.md +6 -0
  19. package/corpus/templates/design/changelog/2026-07-01-import-is-more-compact-local-app-setup-is-clearer-and-figma.md +6 -0
  20. package/corpus/templates/design/package.json +1 -0
  21. package/dist/agent/action-continuation-guidance.d.ts +3 -0
  22. package/dist/agent/action-continuation-guidance.d.ts.map +1 -0
  23. package/dist/agent/action-continuation-guidance.js +38 -0
  24. package/dist/agent/action-continuation-guidance.js.map +1 -0
  25. package/dist/agent/production-agent.d.ts +6 -2
  26. package/dist/agent/production-agent.d.ts.map +1 -1
  27. package/dist/agent/production-agent.js +160 -4
  28. package/dist/agent/production-agent.js.map +1 -1
  29. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  30. package/dist/agent/run-loop-with-resume.js +69 -18
  31. package/dist/agent/run-loop-with-resume.js.map +1 -1
  32. package/dist/agent/types.d.ts +2 -0
  33. package/dist/agent/types.d.ts.map +1 -1
  34. package/dist/agent/types.js.map +1 -1
  35. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  36. package/dist/client/agent-chat-adapter.js +2 -39
  37. package/dist/client/agent-chat-adapter.js.map +1 -1
  38. package/dist/collab/routes.d.ts +1 -1
  39. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  40. package/dist/observability/routes.d.ts +5 -5
  41. package/package.json +1 -1
@@ -1,6 +1,27 @@
1
1
  import { useActionMutation, useActionQuery } from "@agent-native/core/client";
2
- import { IconCode, IconDeviceFloppy } from "@tabler/icons-react";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ IconCode,
4
+ IconDeviceFloppy,
5
+ IconFileCode,
6
+ IconFolder,
7
+ IconRefresh,
8
+ IconSearch,
9
+ } from "@tabler/icons-react";
10
+ import * as monaco from "monaco-editor";
11
+
12
+ import "monaco-editor/min/vs/editor/editor.main.css";
13
+ import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
14
+ import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
15
+ import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
16
+ import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
17
+ import TypeScriptWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
18
+ import {
19
+ type CSSProperties,
20
+ useCallback,
21
+ useEffect,
22
+ useRef,
23
+ useState,
24
+ } from "react";
4
25
  import { toast } from "sonner";
5
26
 
6
27
  import { Button } from "@/components/ui/button";
@@ -65,6 +86,21 @@ function normalizeThemeColorValue(value: string): string {
65
86
  return trimmed;
66
87
  }
67
88
 
89
+ function resolveCssColorValue(value: string): string {
90
+ if (typeof window === "undefined" || typeof document === "undefined") {
91
+ return value;
92
+ }
93
+ const probe = document.createElement("span");
94
+ probe.style.color = value;
95
+ probe.style.position = "fixed";
96
+ probe.style.pointerEvents = "none";
97
+ probe.style.visibility = "hidden";
98
+ document.body.appendChild(probe);
99
+ const resolved = window.getComputedStyle(probe).color;
100
+ probe.remove();
101
+ return resolved || value;
102
+ }
103
+
68
104
  function readThemeVar(
69
105
  elementStyles: CSSStyleDeclaration,
70
106
  rootStyles: CSSStyleDeclaration,
@@ -74,7 +110,7 @@ function readThemeVar(
74
110
  const value =
75
111
  elementStyles.getPropertyValue(name) || rootStyles.getPropertyValue(name);
76
112
  const normalized = normalizeThemeColorValue(value);
77
- if (normalized) return normalized;
113
+ if (normalized) return resolveCssColorValue(normalized);
78
114
  }
79
115
  return undefined;
80
116
  }
@@ -101,198 +137,95 @@ function readCodeWorkbenchTheme(
101
137
  return { colorScheme, values };
102
138
  }
103
139
 
104
- export const WORKBENCH_SRC_DOC = `<!DOCTYPE html>
105
- <html>
106
- <head>
107
- <meta charset="utf-8" />
108
- <meta name="viewport" content="width=device-width, initial-scale=1" />
109
- <style>
110
- :root {
111
- color-scheme: light;
112
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
113
- --workbench-bg: Canvas;
114
- --workbench-sidebar-bg: Canvas;
115
- --workbench-editor-bg: Canvas;
116
- --workbench-surface-bg: ButtonFace;
117
- --workbench-border: color-mix(in srgb, CanvasText 16%, transparent);
118
- --workbench-fg: CanvasText;
119
- --workbench-muted-fg: color-mix(in srgb, CanvasText 56%, transparent);
120
- --workbench-hover-bg: color-mix(in srgb, Highlight 10%, transparent);
121
- --workbench-active-bg: color-mix(in srgb, Highlight 16%, transparent);
122
- --workbench-active-fg: Highlight;
123
- --workbench-accent: Highlight;
124
- --workbench-button-bg: ButtonFace;
125
- --workbench-button-fg: ButtonText;
126
- --workbench-selection-bg: color-mix(in srgb, Highlight 28%, transparent);
127
- --workbench-dirty: Mark;
128
- background: var(--workbench-bg);
129
- color: var(--workbench-fg);
130
- }
131
- * { box-sizing: border-box; }
132
- body { margin: 0; height: 100vh; overflow: hidden; background: var(--workbench-bg); }
133
- button { font: inherit; }
134
- .shell { display: grid; grid-template-columns: 188px minmax(0, 1fr); height: 100vh; }
135
- .explorer { border-right: 1px solid var(--workbench-border); background: var(--workbench-sidebar-bg); min-width: 0; display: flex; flex-direction: column; }
136
- .title { height: 38px; display: flex; align-items: center; padding: 0 12px; gap: 8px; border-bottom: 1px solid var(--workbench-border); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--workbench-muted-fg); }
137
- .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--workbench-accent); box-shadow: 0 0 18px color-mix(in srgb, var(--workbench-accent) 55%, transparent); }
138
- .files { min-height: 0; overflow: auto; padding: 8px 6px; }
139
- .file { width: 100%; min-width: 0; border: 0; border-radius: 7px; background: transparent; color: var(--workbench-muted-fg); display: flex; align-items: center; gap: 7px; padding: 7px 8px; cursor: pointer; text-align: left; }
140
- .file:hover { background: var(--workbench-hover-bg); color: var(--workbench-fg); }
141
- .file.active { background: var(--workbench-active-bg); color: var(--workbench-active-fg); }
142
- .file .icon { width: 22px; color: var(--workbench-accent); font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
143
- .file .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
144
- .status { border-top: 1px solid var(--workbench-border); color: var(--workbench-muted-fg); font-size: 11px; line-height: 1.35; padding: 9px 10px; }
145
- .editor { min-width: 0; display: flex; flex-direction: column; background: var(--workbench-editor-bg); }
146
- .tabbar { height: 38px; display: flex; align-items: center; border-bottom: 1px solid var(--workbench-border); background: var(--workbench-surface-bg); }
147
- .tab { height: 38px; max-width: 260px; display: flex; align-items: center; gap: 8px; padding: 0 13px; border-right: 1px solid var(--workbench-border); color: var(--workbench-fg); font-size: 12px; }
148
- .dirty { width: 7px; height: 7px; border-radius: 999px; background: var(--workbench-dirty); }
149
- .toolbar { margin-left: auto; display: flex; align-items: center; gap: 8px; padding: 0 10px; color: var(--workbench-muted-fg); font-size: 11px; }
150
- .toolbar button { height: 26px; border: 1px solid var(--workbench-border); border-radius: 6px; background: var(--workbench-button-bg); color: var(--workbench-button-fg); padding: 0 9px; cursor: pointer; }
151
- .toolbar button:disabled { opacity: .4; cursor: default; }
152
- textarea { flex: 1; width: 100%; min-width: 0; resize: none; border: 0; outline: 0; padding: 18px 20px 28px; background: var(--workbench-editor-bg); color: var(--workbench-fg); font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; tab-size: 2; }
153
- textarea::selection { background: var(--workbench-selection-bg); }
154
- .empty { flex: 1; display: grid; place-items: center; color: var(--workbench-muted-fg); font-size: 13px; text-align: center; padding: 24px; }
155
- </style>
156
- </head>
157
- <body>
158
- <div class="shell">
159
- <aside class="explorer">
160
- <div class="title"><span class="dot"></span><span>DesignFS</span></div>
161
- <div id="files" class="files"></div>
162
- <div id="status" class="status">Waiting for workspace...</div>
163
- </aside>
164
- <main class="editor">
165
- <div class="tabbar">
166
- <div id="tab" class="tab">No file</div>
167
- <div class="toolbar">
168
- <span id="meta"></span>
169
- <button id="revert" type="button" disabled>Revert</button>
170
- <button id="save" type="button" disabled>Save</button>
171
- </div>
172
- </div>
173
- <textarea id="editor" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
174
- </main>
175
- </div>
176
- <script>
177
- const filesEl = document.getElementById("files");
178
- const statusEl = document.getElementById("status");
179
- const tabEl = document.getElementById("tab");
180
- const metaEl = document.getElementById("meta");
181
- const editorEl = document.getElementById("editor");
182
- const saveEl = document.getElementById("save");
183
- const revertEl = document.getElementById("revert");
184
- let state = { files: [], activePath: null, content: "", savedContent: "", dirty: false, canEdit: false };
185
- let lastSelectionKey = "";
186
-
187
- function applyTheme(theme) {
188
- const root = document.documentElement;
189
- if (!theme) return;
190
- root.style.colorScheme = theme.colorScheme === "dark" ? "dark" : "light";
191
- const values = theme.values || {};
192
- for (const name of Object.keys(values)) {
193
- root.style.setProperty(name, values[name]);
194
- }
195
- }
196
-
197
- function iconFor(path) {
198
- if (/\\.css$/i.test(path)) return "#";
199
- if (/\\.jsx?$/i.test(path)) return "JS";
200
- if (/\\.tsx?$/i.test(path)) return "TS";
201
- return "<>";
202
- }
140
+ let monacoEnvironmentInstalled = false;
203
141
 
204
- function renderFiles() {
205
- filesEl.innerHTML = "";
206
- state.files.forEach((file) => {
207
- const button = document.createElement("button");
208
- button.type = "button";
209
- button.className = "file" + (file.path === state.activePath ? " active" : "");
210
- button.title = file.path;
211
- const icon = document.createElement("span");
212
- icon.className = "icon";
213
- icon.textContent = iconFor(file.path);
214
- const name = document.createElement("span");
215
- name.className = "name";
216
- name.textContent = file.displayName || file.path;
217
- button.append(icon, name);
218
- button.addEventListener("click", () => {
219
- parent.postMessage({ type: "design-code-workbench:select-file", path: file.path }, "*");
220
- });
221
- filesEl.appendChild(button);
222
- });
223
- }
224
-
225
- function focusSelection() {
226
- const selection = state.selection || {};
227
- const key = [state.activePath, selection.nodeId || "", selection.selector || "", state.versionHash || ""].join(":");
228
- if (!state.content || key === lastSelectionKey) return;
229
- lastSelectionKey = key;
230
- const targets = [];
231
- if (selection.nodeId) {
232
- targets.push('data-agent-native-node-id="' + selection.nodeId + '"');
233
- targets.push('data-code-layer-id="' + selection.nodeId + '"');
234
- targets.push(selection.nodeId);
235
- }
236
- if (selection.selector) targets.push(selection.selector);
237
- for (const target of targets) {
238
- const index = state.content.indexOf(target);
239
- if (index >= 0) {
240
- editorEl.focus();
241
- editorEl.setSelectionRange(index, Math.min(state.content.length, index + target.length));
242
- return;
142
+ function ensureMonacoEnvironment() {
143
+ if (monacoEnvironmentInstalled || typeof window === "undefined") return;
144
+ (
145
+ globalThis as typeof globalThis & { MonacoEnvironment?: unknown }
146
+ ).MonacoEnvironment = {
147
+ getWorker(_moduleId: string, label: string) {
148
+ if (label === "css" || label === "scss" || label === "less") {
149
+ return new CssWorker();
243
150
  }
244
- }
245
- }
246
-
247
- function render() {
248
- renderFiles();
249
- const active = state.files.find((file) => file.path === state.activePath);
250
- tabEl.replaceChildren();
251
- if (active) {
252
- const label = document.createElement("span");
253
- label.textContent = active.path;
254
- tabEl.appendChild(label);
255
- if (state.dirty) {
256
- const dirty = document.createElement("span");
257
- dirty.className = "dirty";
258
- tabEl.appendChild(dirty);
151
+ if (label === "html" || label === "handlebars" || label === "razor") {
152
+ return new HtmlWorker();
259
153
  }
260
- } else {
261
- tabEl.textContent = "No file";
262
- }
263
- metaEl.textContent = state.dirty ? "Unsaved changes" : (state.versionHash ? "Saved " + state.versionHash : "");
264
- saveEl.disabled = !state.canEdit || !state.dirty || state.saving || !active;
265
- revertEl.disabled = !state.dirty || !active;
266
- statusEl.textContent = active
267
- ? (state.backendKind || "virtual-inline") + " / " + state.workspaceUri
268
- : state.files.length ? "Choose a file" : "No inline files";
269
- if (editorEl.value !== state.content) editorEl.value = state.content || "";
270
- editorEl.readOnly = !state.canEdit || !active;
271
- focusSelection();
272
- }
154
+ if (label === "json") return new JsonWorker();
155
+ if (label === "typescript" || label === "javascript") {
156
+ return new TypeScriptWorker();
157
+ }
158
+ return new EditorWorker();
159
+ },
160
+ };
161
+ monacoEnvironmentInstalled = true;
162
+ }
273
163
 
274
- window.addEventListener("message", (event) => {
275
- const message = event.data || {};
276
- if (message.type !== "design-code-workbench:state") return;
277
- state = message.state || state;
278
- applyTheme(state.theme);
279
- render();
280
- });
164
+ function languageForPath(path: string, language?: string): string {
165
+ if (language === "html" || /\.html?$/i.test(path)) return "html";
166
+ if (language === "css" || /\.css$/i.test(path)) return "css";
167
+ if (language === "json" || /\.json$/i.test(path)) return "json";
168
+ if (language === "typescript" || /\.tsx?$/i.test(path)) return "typescript";
169
+ if (language === "javascript" || /\.jsx?$/i.test(path)) return "javascript";
170
+ return language || "plaintext";
171
+ }
281
172
 
282
- editorEl.addEventListener("input", () => {
283
- parent.postMessage({ type: "design-code-workbench:content-change", content: editorEl.value }, "*");
284
- });
285
- saveEl.addEventListener("click", () => {
286
- parent.postMessage({ type: "design-code-workbench:save" }, "*");
173
+ function extensionBadgeForPath(path: string): string {
174
+ if (/\.css$/i.test(path)) return "CSS";
175
+ if (/\.tsx$/i.test(path)) return "TSX";
176
+ if (/\.ts$/i.test(path)) return "TS";
177
+ if (/\.jsx$/i.test(path)) return "JSX";
178
+ if (/\.js$/i.test(path)) return "JS";
179
+ if (/\.json$/i.test(path)) return "{}";
180
+ if (/\.html?$/i.test(path)) return "<>";
181
+ return "TXT";
182
+ }
183
+
184
+ function editorUriForPath(designId: string, path: string) {
185
+ return monaco.Uri.from({
186
+ scheme: "designfs",
187
+ authority: designId,
188
+ path: `/${path.replace(/^\/+/, "")}`,
287
189
  });
288
- revertEl.addEventListener("click", () => {
289
- parent.postMessage({ type: "design-code-workbench:revert" }, "*");
190
+ }
191
+
192
+ function defineMonacoTheme(theme: CodeWorkbenchTheme): string {
193
+ const dark = theme.colorScheme === "dark";
194
+ const values = theme.values;
195
+ const name = dark
196
+ ? "design-code-workbench-dark"
197
+ : "design-code-workbench-light";
198
+ const colors = Object.fromEntries(
199
+ Object.entries({
200
+ "editor.background": values["--workbench-editor-bg"],
201
+ "editor.foreground": values["--workbench-fg"],
202
+ "editor.lineHighlightBackground": values["--workbench-hover-bg"],
203
+ "editor.selectionBackground": values["--workbench-selection-bg"],
204
+ "editor.inactiveSelectionBackground": values["--workbench-active-bg"],
205
+ "editorCursor.foreground": values["--workbench-accent"],
206
+ "editorLineNumber.foreground": values["--workbench-muted-fg"],
207
+ "editorLineNumber.activeForeground": values["--workbench-fg"],
208
+ "editorIndentGuide.background1": values["--workbench-border"],
209
+ "editorIndentGuide.activeBackground1": values["--workbench-muted-fg"],
210
+ "editorWidget.background": values["--workbench-surface-bg"],
211
+ "editorWidget.border": values["--workbench-border"],
212
+ focusBorder: values["--workbench-accent"],
213
+ }).filter(
214
+ (entry): entry is [string, string] =>
215
+ typeof entry[1] === "string" && entry[1].length > 0,
216
+ ),
217
+ );
218
+ monaco.editor.defineTheme(name, {
219
+ base: dark ? "vs-dark" : "vs",
220
+ inherit: true,
221
+ rules: [],
222
+ colors,
290
223
  });
224
+ return name;
225
+ }
291
226
 
292
- parent.postMessage({ type: "design-code-workbench:ready" }, "*");
293
- </script>
294
- </body>
295
- </html>`;
227
+ const MONACO_FONT_FAMILY =
228
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace';
296
229
 
297
230
  export function CodeWorkbenchHost({
298
231
  designId,
@@ -304,13 +237,21 @@ export function CodeWorkbenchHost({
304
237
  onActiveFileChange,
305
238
  }: CodeWorkbenchHostProps) {
306
239
  const containerRef = useRef<HTMLDivElement | null>(null);
307
- const iframeRef = useRef<HTMLIFrameElement | null>(null);
240
+ const editorHostRef = useRef<HTMLDivElement | null>(null);
241
+ const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
242
+ const modelRef = useRef<monaco.editor.ITextModel | null>(null);
243
+ const applyingModelContentRef = useRef(false);
244
+ const contentChangeRef = useRef<(content: string) => void>(() => {});
245
+ const saveCommandRef = useRef<(() => void) | null>(null);
308
246
  const lastExternalTargetKeyRef = useRef<string | null>(null);
247
+ const lastSelectionKeyRef = useRef<string | null>(null);
309
248
  const [activePath, setActivePath] = useState<string | null>(null);
310
249
  const [draftsByPath, setDraftsByPath] = useState<
311
250
  Record<string, CodeWorkbenchDraft>
312
251
  >({});
313
- const [ready, setReady] = useState(false);
252
+ const [editorReady, setEditorReady] = useState(false);
253
+ const [cursorLabel, setCursorLabel] = useState("Ln 1, Col 1");
254
+ const [previewSummary, setPreviewSummary] = useState<string | null>(null);
314
255
  const [theme, setTheme] = useState<CodeWorkbenchTheme>(() => ({
315
256
  colorScheme: "light",
316
257
  values: {},
@@ -327,6 +268,7 @@ export function CodeWorkbenchHost({
327
268
  { enabled: Boolean(selectedPath) },
328
269
  );
329
270
  const readSource = readSourceQuery.data as any;
271
+ const previewSourceEditMutation = useActionMutation("preview-source-edit");
330
272
  const applySourceEditMutation = useActionMutation("apply-source-edit");
331
273
  const savedContent = readSource?.content ?? "";
332
274
  const activeDraft = selectedPath ? draftsByPath[selectedPath] : undefined;
@@ -340,6 +282,14 @@ export function CodeWorkbenchHost({
340
282
  file.path === selectedPath ||
341
283
  (activeFileId && file.fileId === activeFileId),
342
284
  );
285
+ const activeDisplayName =
286
+ activeSourceFile?.displayName ?? readSource?.displayName ?? selectedPath;
287
+ const activeLanguage = languageForPath(selectedPath, readSource?.language);
288
+ const canEditSource = canEdit && readSource?.readonly !== true;
289
+ const saving =
290
+ previewSourceEditMutation.isPending || applySourceEditMutation.isPending;
291
+ const workspaceUri = backend?.workspaceUri ?? `designfs://${designId}/`;
292
+ const backendKind = backend?.kind ?? "virtual-inline";
343
293
 
344
294
  useEffect(() => {
345
295
  const updateTheme = () => {
@@ -369,6 +319,7 @@ export function CodeWorkbenchHost({
369
319
  setActivePath(null);
370
320
  setDraftsByPath({});
371
321
  lastExternalTargetKeyRef.current = null;
322
+ lastSelectionKeyRef.current = null;
372
323
  onActiveFileChange?.(null);
373
324
  }, [designId, onActiveFileChange]);
374
325
 
@@ -411,6 +362,97 @@ export function CodeWorkbenchHost({
411
362
  [readSource?.versionHash, savedContent, selectedPath],
412
363
  );
413
364
 
365
+ const revertSelectedFile = useCallback(() => {
366
+ setSelectedDraftContent(savedContent);
367
+ setPreviewSummary(null);
368
+ }, [savedContent, setSelectedDraftContent]);
369
+
370
+ useEffect(() => {
371
+ contentChangeRef.current = (content: string) => {
372
+ setSelectedDraftContent(content);
373
+ setPreviewSummary(null);
374
+ };
375
+ }, [setSelectedDraftContent]);
376
+
377
+ const saveSelectedFile = useCallback(() => {
378
+ if (!selectedPath || !dirty || !canEditSource || saving) return;
379
+ const edit = { kind: "full-replace" as const, content: draftContent };
380
+ previewSourceEditMutation.mutate(
381
+ {
382
+ designId,
383
+ path: selectedPath,
384
+ expectedVersionHash,
385
+ edit,
386
+ } as any,
387
+ {
388
+ onSuccess: (preview: any) => {
389
+ if (preview?.okToApply === false) {
390
+ toast.error(
391
+ preview.message ||
392
+ "Source file changed since it was read" /* i18n-ignore */,
393
+ );
394
+ return;
395
+ }
396
+ setPreviewSummary(
397
+ preview?.diff?.summary ||
398
+ `${preview?.editsApplied ?? 1} edit previewed` /* i18n-ignore */,
399
+ );
400
+ applySourceEditMutation.mutate(
401
+ {
402
+ designId,
403
+ path: selectedPath,
404
+ expectedVersionHash:
405
+ preview?.currentVersionHash ?? expectedVersionHash,
406
+ edit,
407
+ } as any,
408
+ {
409
+ onSuccess: (result: any) => {
410
+ setDraftsByPath((current) => {
411
+ const next = { ...current };
412
+ delete next[selectedPath];
413
+ return next;
414
+ });
415
+ setPreviewSummary(
416
+ result?.diff?.summary ||
417
+ "Saved through source actions" /* i18n-ignore */,
418
+ );
419
+ toast.success("Source file saved" /* i18n-ignore */);
420
+ },
421
+ onError: (error) => {
422
+ toast.error(
423
+ error instanceof Error
424
+ ? error.message
425
+ : "Could not save source file" /* i18n-ignore */,
426
+ );
427
+ },
428
+ },
429
+ );
430
+ },
431
+ onError: (error) => {
432
+ toast.error(
433
+ error instanceof Error
434
+ ? error.message
435
+ : "Could not preview source edit" /* i18n-ignore */,
436
+ );
437
+ },
438
+ },
439
+ );
440
+ }, [
441
+ applySourceEditMutation,
442
+ canEditSource,
443
+ designId,
444
+ dirty,
445
+ draftContent,
446
+ expectedVersionHash,
447
+ previewSourceEditMutation,
448
+ saving,
449
+ selectedPath,
450
+ ]);
451
+
452
+ useEffect(() => {
453
+ saveCommandRef.current = saveSelectedFile;
454
+ }, [saveSelectedFile]);
455
+
414
456
  useEffect(() => {
415
457
  onActiveFileChange?.(
416
458
  selectedPath
@@ -432,176 +474,179 @@ export function CodeWorkbenchHost({
432
474
  selectedPath,
433
475
  ]);
434
476
 
435
- const workbenchState = useMemo(
436
- () => ({
437
- files: sourceFiles,
438
- activePath: selectedPath || null,
439
- content: draftContent,
440
- savedContent,
441
- dirty,
442
- canEdit: canEdit && readSource?.readonly !== true,
443
- saving: applySourceEditMutation.isPending,
444
- versionHash: readSource?.versionHash,
445
- workspaceUri: backend?.workspaceUri ?? `designfs://${designId}/`,
446
- backendKind: backend?.kind ?? "virtual-inline",
447
- theme,
448
- selection: {
449
- nodeId: selectedNodeId,
450
- selector: selectedSelector,
451
- },
452
- }),
453
- [
454
- applySourceEditMutation.isPending,
455
- backend?.kind,
456
- backend?.workspaceUri,
457
- canEdit,
458
- designId,
459
- dirty,
460
- draftContent,
461
- readSource?.readonly,
462
- readSource?.versionHash,
463
- savedContent,
464
- selectedNodeId,
465
- selectedPath,
466
- selectedSelector,
467
- sourceFiles,
468
- theme,
469
- ],
470
- );
471
-
472
477
  useEffect(() => {
473
- if (!ready) return;
474
- iframeRef.current?.contentWindow?.postMessage(
475
- { type: "design-code-workbench:state", state: workbenchState },
476
- "*",
478
+ ensureMonacoEnvironment();
479
+ if (!editorHostRef.current || editorRef.current) return;
480
+ const editor = monaco.editor.create(editorHostRef.current, {
481
+ value: "",
482
+ language: "html",
483
+ theme: defineMonacoTheme(theme),
484
+ automaticLayout: true,
485
+ contextmenu: true,
486
+ fontFamily: MONACO_FONT_FAMILY,
487
+ fontSize: 12,
488
+ lineHeight: 20,
489
+ minimap: { enabled: true, scale: 0.75, showSlider: "mouseover" },
490
+ bracketPairColorization: { enabled: true },
491
+ guides: { bracketPairs: true, indentation: true },
492
+ lineNumbers: "on",
493
+ renderLineHighlight: "all",
494
+ renderWhitespace: "selection",
495
+ scrollBeyondLastLine: false,
496
+ stickyScroll: { enabled: true },
497
+ tabSize: 2,
498
+ insertSpaces: true,
499
+ wordWrap: "off",
500
+ });
501
+ editorRef.current = editor;
502
+ setEditorReady(true);
503
+
504
+ const updateCursorLabel = () => {
505
+ const position = editor.getPosition();
506
+ setCursorLabel(
507
+ position
508
+ ? `Ln ${position.lineNumber}, Col ${position.column}` /* i18n-ignore */
509
+ : "Ln 1, Col 1" /* i18n-ignore */,
510
+ );
511
+ };
512
+ const disposables = [
513
+ editor.onDidChangeModelContent(() => {
514
+ if (applyingModelContentRef.current) return;
515
+ contentChangeRef.current(editor.getValue());
516
+ }),
517
+ editor.onDidChangeCursorPosition(updateCursorLabel),
518
+ ];
519
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () =>
520
+ saveCommandRef.current?.(),
477
521
  );
478
- }, [ready, workbenchState]);
522
+ updateCursorLabel();
523
+
524
+ return () => {
525
+ disposables.forEach((disposable) => disposable?.dispose?.());
526
+ editor.dispose();
527
+ modelRef.current?.dispose();
528
+ modelRef.current = null;
529
+ editorRef.current = null;
530
+ };
531
+ }, []);
479
532
 
480
533
  useEffect(() => {
481
- const handleMessage = (event: MessageEvent) => {
482
- if (event.source !== iframeRef.current?.contentWindow) return;
483
- const message = event.data as
484
- | { type?: string; path?: string; content?: string }
485
- | undefined;
486
- if (!message?.type) return;
487
- if (message.type === "design-code-workbench:ready") {
488
- setReady(true);
489
- return;
490
- }
491
- if (
492
- message.type === "design-code-workbench:select-file" &&
493
- message.path
494
- ) {
495
- setActivePath(message.path);
496
- return;
497
- }
498
- if (
499
- message.type === "design-code-workbench:content-change" &&
500
- typeof message.content === "string"
501
- ) {
502
- setSelectedDraftContent(message.content);
503
- return;
504
- }
505
- if (message.type === "design-code-workbench:revert") {
506
- setSelectedDraftContent(savedContent);
507
- return;
508
- }
509
- if (message.type === "design-code-workbench:save") {
510
- if (!selectedPath || !dirty) return;
511
- applySourceEditMutation.mutate(
512
- {
513
- designId,
514
- path: selectedPath,
515
- expectedVersionHash,
516
- edit: { kind: "full-replace", content: draftContent },
517
- } as any,
518
- {
519
- onSuccess: () => {
520
- setDraftsByPath((current) => {
521
- const next = { ...current };
522
- delete next[selectedPath];
523
- return next;
524
- });
525
- toast.success("Source file saved" /* i18n-ignore */);
526
- },
527
- onError: (error) => {
528
- toast.error(
529
- error instanceof Error
530
- ? error.message
531
- : "Could not save source file" /* i18n-ignore */,
532
- );
533
- },
534
- },
535
- );
534
+ if (!editorRef.current) return;
535
+ monaco.editor.setTheme(defineMonacoTheme(theme));
536
+ }, [theme]);
537
+
538
+ useEffect(() => {
539
+ const editor = editorRef.current;
540
+ if (!editor || !selectedPath) return;
541
+ const uri = editorUriForPath(designId, selectedPath);
542
+ const language = activeLanguage;
543
+ const currentModel = modelRef.current;
544
+ if (currentModel?.uri.toString() !== uri.toString()) {
545
+ currentModel?.dispose();
546
+ const nextModel = monaco.editor.createModel(draftContent, language, uri);
547
+ modelRef.current = nextModel;
548
+ editor.setModel(nextModel);
549
+ } else if (currentModel) {
550
+ monaco.editor.setModelLanguage(currentModel, language);
551
+ if (currentModel.getValue() !== draftContent) {
552
+ applyingModelContentRef.current = true;
553
+ currentModel.setValue(draftContent);
554
+ applyingModelContentRef.current = false;
536
555
  }
537
- };
538
- window.addEventListener("message", handleMessage);
539
- return () => window.removeEventListener("message", handleMessage);
556
+ }
557
+ editor.updateOptions({
558
+ readOnly: !canEditSource,
559
+ readOnlyMessage: {
560
+ value: canEdit
561
+ ? "This source backend is read-only in the current workspace." /* i18n-ignore */
562
+ : "Ask an owner for edit access before changing this file." /* i18n-ignore */,
563
+ },
564
+ });
540
565
  }, [
541
- applySourceEditMutation,
566
+ activeLanguage,
567
+ canEdit,
568
+ canEditSource,
542
569
  designId,
543
- dirty,
544
570
  draftContent,
545
- expectedVersionHash,
571
+ selectedPath,
572
+ ]);
573
+
574
+ useEffect(() => {
575
+ const editor = editorRef.current;
576
+ const model = modelRef.current;
577
+ if (!editor || !model || !selectedPath) return;
578
+ const key = [
579
+ selectedPath,
580
+ selectedNodeId ?? "",
581
+ selectedSelector ?? "",
582
+ readSource?.versionHash ?? "",
583
+ ].join(":");
584
+ if (lastSelectionKeyRef.current === key) return;
585
+ lastSelectionKeyRef.current = key;
586
+ const targets: string[] = [];
587
+ if (selectedNodeId) {
588
+ targets.push(`data-agent-native-node-id="${selectedNodeId}"`);
589
+ targets.push(`data-code-layer-id="${selectedNodeId}"`);
590
+ targets.push(selectedNodeId);
591
+ }
592
+ if (selectedSelector) targets.push(selectedSelector);
593
+ for (const target of targets) {
594
+ const index = draftContent.indexOf(target);
595
+ if (index < 0) continue;
596
+ const start = model.getPositionAt(index);
597
+ const end = model.getPositionAt(index + target.length);
598
+ const range = new monaco.Range(
599
+ start.lineNumber,
600
+ start.column,
601
+ end.lineNumber,
602
+ end.column,
603
+ );
604
+ editor.setSelection(range);
605
+ editor.revealRangeInCenter(range, monaco.editor.ScrollType.Smooth);
606
+ return;
607
+ }
608
+ }, [
609
+ draftContent,
546
610
  readSource?.versionHash,
547
- savedContent,
611
+ selectedNodeId,
548
612
  selectedPath,
549
- setSelectedDraftContent,
613
+ selectedSelector,
550
614
  ]);
551
615
 
552
616
  return (
553
617
  <div
554
618
  ref={containerRef}
555
- className="flex min-h-0 flex-1 flex-col bg-[var(--design-editor-panel-bg)]"
619
+ className="flex min-h-0 flex-1 flex-col bg-[var(--workbench-bg)] text-[var(--workbench-fg)]"
620
+ style={theme.values as CSSProperties}
556
621
  >
557
- <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/60 px-3">
622
+ <div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--workbench-border)] bg-[var(--workbench-surface-bg)] px-3">
558
623
  <IconCode className="size-4 text-[var(--design-editor-accent-color)]" />
559
624
  <div className="min-w-0 flex-1">
560
- <h3 className="truncate text-xs font-semibold text-foreground">
625
+ <h3 className="truncate text-xs font-semibold text-[var(--workbench-fg)]">
561
626
  {"Code" /* i18n-ignore */}
562
627
  </h3>
563
- <p className="truncate text-[10px] text-muted-foreground">
564
- {backend?.workspaceUri ?? `designfs://${designId}/`}
628
+ <p className="truncate text-[10px] text-[var(--workbench-muted-fg)]">
629
+ {workspaceUri}
565
630
  </p>
566
631
  </div>
632
+ <Button
633
+ size="sm"
634
+ variant="ghost"
635
+ className="h-7 gap-1.5 px-2 text-[11px]"
636
+ disabled={!dirty || saving}
637
+ onClick={revertSelectedFile}
638
+ >
639
+ <IconRefresh className="size-3" />
640
+ {"Revert" /* i18n-ignore */}
641
+ </Button>
567
642
  <Button
568
643
  size="sm"
569
644
  variant="outline"
570
645
  className="h-7 gap-1.5 px-2 text-[11px]"
571
- disabled={!dirty || !canEdit || applySourceEditMutation.isPending}
572
- onClick={() => {
573
- iframeRef.current?.contentWindow?.postMessage(
574
- { type: "design-code-workbench:state", state: workbenchState },
575
- "*",
576
- );
577
- applySourceEditMutation.mutate(
578
- {
579
- designId,
580
- path: selectedPath,
581
- expectedVersionHash,
582
- edit: { kind: "full-replace", content: draftContent },
583
- } as any,
584
- {
585
- onSuccess: () => {
586
- setDraftsByPath((current) => {
587
- const next = { ...current };
588
- delete next[selectedPath];
589
- return next;
590
- });
591
- toast.success("Source file saved" /* i18n-ignore */);
592
- },
593
- onError: (error) => {
594
- toast.error(
595
- error instanceof Error
596
- ? error.message
597
- : "Could not save source file" /* i18n-ignore */,
598
- );
599
- },
600
- },
601
- );
602
- }}
646
+ disabled={!dirty || !canEditSource || saving}
647
+ onClick={saveSelectedFile}
603
648
  >
604
- {applySourceEditMutation.isPending ? (
649
+ {saving ? (
605
650
  <Spinner className="size-3" />
606
651
  ) : (
607
652
  <IconDeviceFloppy className="size-3" />
@@ -611,19 +656,119 @@ export function CodeWorkbenchHost({
611
656
  </div>
612
657
  <div
613
658
  className={cn(
614
- "min-h-0 flex-1 bg-[var(--design-editor-panel-bg)]",
659
+ "grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)] bg-[var(--workbench-editor-bg)]",
615
660
  (sourceFilesQuery.isLoading || readSourceQuery.isLoading) &&
616
661
  "opacity-80",
617
662
  )}
618
663
  >
619
- <iframe
620
- ref={iframeRef}
621
- title={"Design code workspace" /* i18n-ignore */}
622
- className="h-full w-full border-0"
623
- srcDoc={WORKBENCH_SRC_DOC}
624
- sandbox="allow-scripts"
625
- allow="clipboard-read; clipboard-write"
626
- />
664
+ <aside className="flex min-h-0 flex-col border-r border-[var(--workbench-border)] bg-[var(--workbench-sidebar-bg)]">
665
+ <div className="flex h-9 shrink-0 items-center gap-2 border-b border-[var(--workbench-border)] px-3 text-[11px] font-semibold uppercase tracking-[0.11em] text-[var(--workbench-muted-fg)]">
666
+ <IconFolder className="size-3.5" />
667
+ {"Explorer" /* i18n-ignore */}
668
+ </div>
669
+ <div className="flex h-8 shrink-0 items-center gap-2 border-b border-[var(--workbench-border)] px-3 text-[11px] text-[var(--workbench-muted-fg)]">
670
+ <IconSearch className="size-3.5" />
671
+ <span className="truncate">
672
+ {"Use Cmd/Ctrl+F in editor" /* i18n-ignore */}
673
+ </span>
674
+ </div>
675
+ <div className="min-h-0 flex-1 overflow-auto py-1">
676
+ {sourceFiles.length > 0 ? (
677
+ sourceFiles.map((file: any) => {
678
+ const active = file.path === selectedPath;
679
+ const disabled = file.readonly && !canEdit;
680
+ return (
681
+ <button
682
+ key={file.path}
683
+ type="button"
684
+ title={file.path}
685
+ aria-current={active ? "page" : undefined}
686
+ className={cn(
687
+ "flex h-7 w-full min-w-0 cursor-pointer items-center gap-2 px-2 text-left text-xs text-[var(--workbench-muted-fg)] outline-none hover:bg-[var(--workbench-hover-bg)] hover:text-[var(--workbench-fg)] focus-visible:ring-1 focus-visible:ring-[var(--workbench-accent)]",
688
+ active &&
689
+ "bg-[var(--workbench-active-bg)] text-[var(--workbench-active-fg)]",
690
+ disabled && "opacity-60",
691
+ )}
692
+ onClick={() => {
693
+ setActivePath(file.path);
694
+ setPreviewSummary(null);
695
+ }}
696
+ >
697
+ <span className="w-7 shrink-0 font-mono text-[10px] text-[var(--workbench-accent)]">
698
+ {extensionBadgeForPath(file.path)}
699
+ </span>
700
+ <span className="min-w-0 flex-1 truncate">
701
+ {file.displayName || file.path}
702
+ </span>
703
+ {draftsByPath[file.path] ? (
704
+ <span className="size-1.5 shrink-0 rounded-full bg-[var(--workbench-dirty)]" />
705
+ ) : null}
706
+ </button>
707
+ );
708
+ })
709
+ ) : (
710
+ <p className="px-3 py-4 text-xs text-[var(--workbench-muted-fg)]">
711
+ {"No inline source files" /* i18n-ignore */}
712
+ </p>
713
+ )}
714
+ </div>
715
+ <div className="border-t border-[var(--workbench-border)] px-3 py-2 text-[11px] leading-4 text-[var(--workbench-muted-fg)]">
716
+ <div className="truncate">{backendKind}</div>
717
+ <div className="truncate">{workspaceUri}</div>
718
+ </div>
719
+ </aside>
720
+
721
+ <main className="flex min-h-0 min-w-0 flex-col bg-[var(--workbench-editor-bg)]">
722
+ <div className="flex h-9 shrink-0 items-center border-b border-[var(--workbench-border)] bg-[var(--workbench-surface-bg)]">
723
+ <div className="flex h-full max-w-[360px] items-center gap-2 border-r border-[var(--workbench-border)] px-3 text-xs text-[var(--workbench-fg)]">
724
+ <IconFileCode className="size-3.5 text-[var(--workbench-accent)]" />
725
+ <span className="min-w-0 truncate">
726
+ {activeDisplayName || "No file" /* i18n-ignore */}
727
+ </span>
728
+ {dirty ? (
729
+ <span className="size-1.5 shrink-0 rounded-full bg-[var(--workbench-dirty)]" />
730
+ ) : null}
731
+ </div>
732
+ <div className="ml-auto flex min-w-0 items-center gap-3 px-3 text-[11px] text-[var(--workbench-muted-fg)]">
733
+ <span className="truncate">
734
+ {dirty
735
+ ? "Unsaved changes" /* i18n-ignore */
736
+ : readSource?.versionHash
737
+ ? `Saved ${readSource.versionHash}` /* i18n-ignore */
738
+ : previewSummary || ""}
739
+ </span>
740
+ </div>
741
+ </div>
742
+ <div className="relative min-h-0 flex-1">
743
+ <div
744
+ ref={editorHostRef}
745
+ data-testid="design-code-monaco-editor"
746
+ className="absolute inset-0"
747
+ />
748
+ {!selectedPath && !sourceFilesQuery.isLoading ? (
749
+ <div className="absolute inset-0 grid place-items-center px-6 text-center text-sm text-[var(--workbench-muted-fg)]">
750
+ {"Choose a source file to start editing." /* i18n-ignore */}
751
+ </div>
752
+ ) : null}
753
+ {!editorReady ? (
754
+ <div className="absolute inset-0 grid place-items-center bg-[var(--workbench-editor-bg)] text-[var(--workbench-muted-fg)]">
755
+ <Spinner className="size-4" />
756
+ </div>
757
+ ) : null}
758
+ </div>
759
+ <div className="flex h-6 shrink-0 items-center gap-4 border-t border-[var(--workbench-border)] bg-[var(--workbench-surface-bg)] px-3 text-[11px] text-[var(--workbench-muted-fg)]">
760
+ <span>{activeLanguage}</span>
761
+ <span>{cursorLabel}</span>
762
+ <span className="ml-auto truncate">
763
+ {
764
+ previewSummary ??
765
+ (canEditSource
766
+ ? "Preview on save, apply with version check" /* i18n-ignore */
767
+ : "Read-only source") /* i18n-ignore */
768
+ }
769
+ </span>
770
+ </div>
771
+ </main>
627
772
  </div>
628
773
  </div>
629
774
  );