@agent-native/core 0.84.30 → 0.84.34

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 (65) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/session-replay.ts +5 -0
  5. package/corpus/core/src/client/use-action.ts +5 -1
  6. package/corpus/core/src/server/ssr-handler.ts +12 -0
  7. package/corpus/core/src/vite/action-types-plugin.ts +6 -2
  8. package/corpus/core/src/vite/client.ts +152 -17
  9. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +13 -1
  10. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-keep-inlined-css-without-live-resource-loa.md +6 -0
  11. package/corpus/templates/design/AGENTS.md +18 -0
  12. package/corpus/templates/design/actions/apply-source-edit.ts +87 -0
  13. package/corpus/templates/design/actions/import-design-source.ts +158 -0
  14. package/corpus/templates/design/actions/list-source-files.ts +52 -0
  15. package/corpus/templates/design/actions/navigate.ts +4 -2
  16. package/corpus/templates/design/actions/preview-source-edit.ts +85 -0
  17. package/corpus/templates/design/actions/read-source-file.ts +55 -0
  18. package/corpus/templates/design/actions/resolve-selection-source.ts +101 -0
  19. package/corpus/templates/design/actions/view-screen.ts +43 -1
  20. package/corpus/templates/design/app/components/design/CodeWorkbenchHost.tsx +630 -0
  21. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +519 -0
  22. package/corpus/templates/design/app/hooks/use-navigation-state.ts +32 -6
  23. package/corpus/templates/design/app/i18n/zh-TW.ts +40 -0
  24. package/corpus/templates/design/app/i18n-data.ts +508 -0
  25. package/corpus/templates/design/app/pages/DesignEditor.tsx +171 -9
  26. package/corpus/templates/design/changelog/2026-07-01-design-can-import-figma-paste-fig-files-and-standalone-html.md +6 -0
  27. package/corpus/templates/design/changelog/2026-07-01-design-code-workspace.md +6 -0
  28. package/corpus/templates/design/package.json +3 -0
  29. package/corpus/templates/design/server/handlers/import-design-file.ts +168 -0
  30. package/corpus/templates/design/server/lib/figma-import/clipboard.ts +122 -0
  31. package/corpus/templates/design/server/lib/figma-import/decode.ts +455 -0
  32. package/corpus/templates/design/server/lib/figma-import/processor.ts +116 -0
  33. package/corpus/templates/design/server/lib/figma-import/render-html.ts +397 -0
  34. package/corpus/templates/design/server/lib/figma-import/types.ts +60 -0
  35. package/corpus/templates/design/server/lib/import-design-files.ts +314 -0
  36. package/corpus/templates/design/server/routes/api/import-design-file.post.ts +1 -0
  37. package/corpus/templates/design/server/source-workspace.ts +215 -0
  38. package/corpus/templates/design/shared/design-source-capabilities.ts +5 -5
  39. package/corpus/templates/design/shared/source-workspace.ts +149 -0
  40. package/dist/client/session-replay.d.ts +1 -0
  41. package/dist/client/session-replay.d.ts.map +1 -1
  42. package/dist/client/session-replay.js +2 -0
  43. package/dist/client/session-replay.js.map +1 -1
  44. package/dist/client/use-action.d.ts +5 -1
  45. package/dist/client/use-action.d.ts.map +1 -1
  46. package/dist/client/use-action.js.map +1 -1
  47. package/dist/collab/awareness.d.ts +2 -2
  48. package/dist/collab/awareness.d.ts.map +1 -1
  49. package/dist/collab/routes.d.ts +1 -1
  50. package/dist/notifications/routes.d.ts +3 -3
  51. package/dist/observability/routes.d.ts +3 -3
  52. package/dist/progress/routes.d.ts +1 -1
  53. package/dist/resources/handlers.d.ts +3 -3
  54. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  55. package/dist/server/ssr-handler.d.ts.map +1 -1
  56. package/dist/server/ssr-handler.js +7 -0
  57. package/dist/server/ssr-handler.js.map +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/action-types-plugin.d.ts.map +1 -1
  60. package/dist/vite/action-types-plugin.js +6 -2
  61. package/dist/vite/action-types-plugin.js.map +1 -1
  62. package/dist/vite/client.d.ts.map +1 -1
  63. package/dist/vite/client.js +132 -11
  64. package/dist/vite/client.js.map +1 -1
  65. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4973
31
+ - template files: 4994
@@ -1,5 +1,29 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.34
4
+
5
+ ### Patch Changes
6
+
7
+ - 56f3d91: Fix hosted Google Analytics / Tag Manager injection by baking the measurement id into Nitro server bundles and merging the required GA/GTM script, connect, and image hosts into existing stricter document CSPs.
8
+
9
+ ## 0.84.33
10
+
11
+ ### Patch Changes
12
+
13
+ - 8cc9620: Keep Nitro/Vite dev servers responsive when native file watchers hit EMFILE or ENOSPC by falling back to polling instead of replacing failed watches with no-ops.
14
+
15
+ ## 0.84.32
16
+
17
+ ### Patch Changes
18
+
19
+ - da8ac0a: Keep tail-resume reconnect content display-only, recover zero-byte action-prep stalls, and polish Design screen tool labels.
20
+
21
+ ## 0.84.31
22
+
23
+ ### Patch Changes
24
+
25
+ - 3190dea: Inline rrweb stylesheet snapshots by default so Analytics session replay captures can play back styled pages without live CSS fetches.
26
+
3
27
  ## 0.84.30
4
28
 
5
29
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.30",
3
+ "version": "0.84.34",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -19,6 +19,7 @@ interface RrwebRecordOptions {
19
19
  emit: (event: ReplayEvent) => void;
20
20
  checkoutEveryNth?: number;
21
21
  checkoutEveryNms?: number;
22
+ inlineStylesheet?: boolean;
22
23
  blockClass?: string | RegExp;
23
24
  blockSelector?: string;
24
25
  ignoreClass?: string | RegExp;
@@ -82,6 +83,7 @@ export interface SessionReplayOptions {
82
83
  maxBatchBytes?: number;
83
84
  checkoutEveryNth?: number;
84
85
  checkoutEveryNms?: number;
86
+ inlineStylesheet?: boolean;
85
87
  blockSelector?: string;
86
88
  ignoreSelector?: string;
87
89
  maskTextClass?: string | RegExp;
@@ -135,6 +137,7 @@ interface NormalizedSessionReplayOptions {
135
137
  maxBatchBytes: number;
136
138
  checkoutEveryNth?: number;
137
139
  checkoutEveryNms?: number;
140
+ inlineStylesheet: boolean;
138
141
  blockSelector: string;
139
142
  ignoreSelector: string;
140
143
  maskTextClass: string | RegExp;
@@ -509,6 +512,7 @@ function normalizeOptions(
509
512
  ),
510
513
  checkoutEveryNth: options.checkoutEveryNth,
511
514
  checkoutEveryNms: options.checkoutEveryNms,
515
+ inlineStylesheet: options.inlineStylesheet ?? true,
512
516
  blockSelector: options.blockSelector || DEFAULT_BLOCK_SELECTOR,
513
517
  ignoreSelector: options.ignoreSelector || DEFAULT_IGNORE_SELECTOR,
514
518
  maskTextClass: options.maskTextClass || DEFAULT_MASK_TEXT_CLASS,
@@ -1010,6 +1014,7 @@ async function startSessionReplayRecorder(
1010
1014
  sampling: normalized.eventSampling,
1011
1015
  checkoutEveryNth: normalized.checkoutEveryNth,
1012
1016
  checkoutEveryNms: normalized.checkoutEveryNms,
1017
+ inlineStylesheet: normalized.inlineStylesheet,
1013
1018
  blockSelector: normalized.blockSelector,
1014
1019
  ignoreSelector: normalized.ignoreSelector,
1015
1020
  maskTextClass: normalized.maskTextClass,
@@ -60,7 +60,11 @@ function defaultActionQueryRetry(
60
60
  * it maps action names to their parameter and return types, enabling
61
61
  * end-to-end type safety for `useActionQuery` and `useActionMutation`.
62
62
  */
63
- export interface ActionRegistry {}
63
+ declare global {
64
+ interface AgentNativeActionRegistry {}
65
+ }
66
+
67
+ export interface ActionRegistry extends AgentNativeActionRegistry {}
64
68
 
65
69
  /** Resolves to the union of registered action names, or `string` if no registry exists. */
66
70
  type ActionName = keyof ActionRegistry extends never
@@ -402,6 +402,15 @@ function appendToExistingCspDirective(
402
402
  existing.tokens = appendCspTokens(existing.tokens, additions);
403
403
  }
404
404
 
405
+ function ensureCspDirective(
406
+ directives: CspDirective[],
407
+ name: string,
408
+ tokens: readonly string[],
409
+ ): void {
410
+ if (findCspDirective(directives, name)) return;
411
+ directives.push({ name, tokens: [...tokens] });
412
+ }
413
+
405
414
  function hasStrictNonceScriptPolicy(tokens: readonly string[]): boolean {
406
415
  return tokens.some(
407
416
  (token) => token === "'strict-dynamic'" || token.startsWith("'nonce-"),
@@ -480,6 +489,9 @@ function augmentExistingEnforcedCspForFrameworkScripts(
480
489
  }
481
490
  }
482
491
 
492
+ ensureCspDirective(directives, "object-src", ["'none'"]);
493
+ ensureCspDirective(directives, "base-uri", ["'self'"]);
494
+
483
495
  return serializeCsp(directives);
484
496
  }
485
497
 
@@ -280,12 +280,16 @@ type ActionEntry<T> = T extends { default: { run: (...args: infer A) => infer R
280
280
  }
281
281
  : { result: any; params: Record<string, any> };
282
282
 
283
- declare module "@agent-native/core/client" {
284
- interface ActionRegistry {
283
+ declare global {
284
+ interface AgentNativeActionRegistry {
285
285
  ${typeEntries.join("\n")}
286
286
  }
287
287
  }
288
288
 
289
+ declare module "@agent-native/core/client" {
290
+ interface ActionRegistry extends AgentNativeActionRegistry {}
291
+ }
292
+
289
293
  export {};
290
294
  `;
291
295
 
@@ -1,3 +1,4 @@
1
+ import { EventEmitter } from "events";
1
2
  import fs from "fs";
2
3
  import type { IncomingMessage, ServerResponse } from "http";
3
4
  import { createRequire, syncBuiltinESMExports } from "module";
@@ -36,6 +37,137 @@ const require = createRequire(import.meta.url);
36
37
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
38
  let nitroFsWatchGuardInstalled = false;
38
39
 
40
+ type FsWatchArgs = [fs.PathLike, ...any[]];
41
+
42
+ function isFileWatchLimitError(
43
+ error: NodeJS.ErrnoException | undefined,
44
+ ): boolean {
45
+ return error?.code === "EMFILE" || error?.code === "ENOSPC";
46
+ }
47
+
48
+ function watchPollingIntervalMs(): number {
49
+ const raw = Number(process.env.CHOKIDAR_INTERVAL ?? 1000);
50
+ return Number.isFinite(raw) && raw > 0 ? raw : 1000;
51
+ }
52
+
53
+ function fsWatchListener(args: FsWatchArgs): fs.WatchListener<string> | null {
54
+ const maybeOptionsOrListener = args[1];
55
+ const maybeListener = args[2];
56
+ if (typeof maybeOptionsOrListener === "function")
57
+ return maybeOptionsOrListener as fs.WatchListener<string>;
58
+ if (typeof maybeListener === "function")
59
+ return maybeListener as fs.WatchListener<string>;
60
+ return null;
61
+ }
62
+
63
+ function fsWatchPersistent(args: FsWatchArgs): boolean {
64
+ const options = args[1];
65
+ if (!options || typeof options === "function") return true;
66
+ if (typeof options === "string" || Buffer.isBuffer(options)) return true;
67
+ return options.persistent !== false;
68
+ }
69
+
70
+ function directEntrySnapshot(
71
+ target: string,
72
+ ): Map<string, { mtimeMs: number; size: number; directory: boolean }> {
73
+ const snapshot = new Map<
74
+ string,
75
+ { mtimeMs: number; size: number; directory: boolean }
76
+ >();
77
+ const stat = fs.statSync(target);
78
+ if (!stat.isDirectory()) {
79
+ snapshot.set(path.basename(target), {
80
+ mtimeMs: stat.mtimeMs,
81
+ size: stat.size,
82
+ directory: false,
83
+ });
84
+ return snapshot;
85
+ }
86
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
87
+ const entryPath = path.join(target, entry.name);
88
+ try {
89
+ const entryStat = fs.statSync(entryPath);
90
+ snapshot.set(entry.name, {
91
+ mtimeMs: entryStat.mtimeMs,
92
+ size: entryStat.size,
93
+ directory: entryStat.isDirectory(),
94
+ });
95
+ } catch {
96
+ // The entry may have disappeared between readdir and stat.
97
+ }
98
+ }
99
+ return snapshot;
100
+ }
101
+
102
+ function createPollingFsWatcher(args: FsWatchArgs): fs.FSWatcher {
103
+ const target = String(args[0]);
104
+ const listener = fsWatchListener(args);
105
+ const emitter = new EventEmitter() as fs.FSWatcher;
106
+ let closed = false;
107
+ let previous = directEntrySnapshot(target);
108
+
109
+ if (listener) emitter.on("change", listener);
110
+
111
+ const emitChange = (eventName: "change" | "rename", filename: string) => {
112
+ emitter.emit("change", eventName, filename);
113
+ };
114
+
115
+ const timer = setInterval(() => {
116
+ if (closed) return;
117
+ let next: typeof previous;
118
+ try {
119
+ next = directEntrySnapshot(target);
120
+ } catch (error) {
121
+ emitter.emit("error", error);
122
+ return;
123
+ }
124
+
125
+ for (const [filename, current] of next) {
126
+ const old = previous.get(filename);
127
+ if (!old) {
128
+ emitChange("rename", filename);
129
+ continue;
130
+ }
131
+ if (
132
+ old.mtimeMs !== current.mtimeMs ||
133
+ old.size !== current.size ||
134
+ old.directory !== current.directory
135
+ ) {
136
+ emitChange("change", filename);
137
+ }
138
+ }
139
+ for (const filename of previous.keys()) {
140
+ if (!next.has(filename)) emitChange("rename", filename);
141
+ }
142
+ previous = next;
143
+ }, watchPollingIntervalMs());
144
+
145
+ if (!fsWatchPersistent(args)) timer.unref();
146
+
147
+ emitter.close = () => {
148
+ if (closed) return;
149
+ closed = true;
150
+ clearInterval(timer);
151
+ emitter.removeAllListeners();
152
+ };
153
+ emitter.ref = () => {
154
+ timer.ref();
155
+ return emitter;
156
+ };
157
+ emitter.unref = () => {
158
+ timer.unref();
159
+ return emitter;
160
+ };
161
+
162
+ return emitter;
163
+ }
164
+
165
+ function warnNitroFsWatchFallback(target: unknown, err: NodeJS.ErrnoException) {
166
+ console.warn(
167
+ `[agent-native] Falling back to polling Nitro fs.watch for ${String(target)}: ${err.message}`,
168
+ );
169
+ }
170
+
39
171
  function installNitroFsWatchGuard(): void {
40
172
  if (nitroFsWatchGuardInstalled) return;
41
173
  nitroFsWatchGuardInstalled = true;
@@ -49,29 +181,32 @@ function installNitroFsWatchGuard(): void {
49
181
  watcher = originalWatch(...args);
50
182
  } catch (error) {
51
183
  const err = error as NodeJS.ErrnoException;
52
- if (err.code !== "EMFILE" && err.code !== "ENOSPC") throw error;
53
- console.warn(
54
- `[agent-native] Disabled Nitro fs.watch for ${String(args[0])}: ${err.message}`,
55
- );
56
- return {
57
- close() {},
58
- on() {
59
- return this as fs.FSWatcher;
60
- },
61
- } as unknown as fs.FSWatcher;
184
+ if (!isFileWatchLimitError(err)) throw error;
185
+ warnNitroFsWatchFallback(args[0], err);
186
+ return createPollingFsWatcher(args as FsWatchArgs);
62
187
  }
63
188
 
64
189
  const originalEmit = watcher.emit.bind(watcher);
190
+ const originalClose = watcher.close.bind(watcher);
191
+ let pollingFallback: fs.FSWatcher | undefined;
192
+
193
+ watcher.close = (() => {
194
+ pollingFallback?.close();
195
+ return originalClose();
196
+ }) as fs.FSWatcher["close"];
197
+
65
198
  watcher.emit = ((eventName: string | symbol, ...eventArgs: any[]) => {
66
199
  const err = eventArgs[0] as NodeJS.ErrnoException | undefined;
67
- if (
68
- eventName === "error" &&
69
- (err?.code === "EMFILE" || err?.code === "ENOSPC")
70
- ) {
71
- console.warn(
72
- `[agent-native] Disabled Nitro fs.watch for ${String(args[0])}: ${err.message}`,
73
- );
200
+ if (eventName === "error" && isFileWatchLimitError(err) && err) {
201
+ warnNitroFsWatchFallback(args[0], err);
74
202
  watcher.close();
203
+ pollingFallback = createPollingFsWatcher(args as FsWatchArgs);
204
+ pollingFallback.on("change", (changeEvent, filename) => {
205
+ originalEmit("change", changeEvent, filename);
206
+ });
207
+ pollingFallback.on("error", (pollingError) => {
208
+ originalEmit("error", pollingError);
209
+ });
75
210
  return false;
76
211
  }
77
212
  return originalEmit(eventName, ...eventArgs);
@@ -1243,7 +1243,14 @@ function sanitizeCssText(value: string): string {
1243
1243
  if (!containsStylesheetNetworkLoad(value)) return value;
1244
1244
  return value
1245
1245
  .replace(/@import\s+(?:url\s*\()?[^;{}]+;?/gi, "")
1246
- .replace(/\burl\s*\((?:\\.|[^\\)])*\)/gi, "none");
1246
+ .replace(/\burl\s*\(\s*((?:\\.|[^\\)])*)\)/gi, sanitizeCssUrlToken);
1247
+ }
1248
+
1249
+ function sanitizeCssUrlToken(match: string, rawValue: string): string {
1250
+ const urlValue = rawValue.trim();
1251
+ const unquoted = urlValue.replace(/^(['"])(.*)\1$/, "$2").trim();
1252
+ if (/^(?:data:|blob:|#)/i.test(unquoted)) return match;
1253
+ return "none";
1247
1254
  }
1248
1255
 
1249
1256
  function sanitizeAttributes(attributes: AnyRecord): AnyRecord {
@@ -1258,6 +1265,11 @@ function sanitizeAttributes(attributes: AnyRecord): AnyRecord {
1258
1265
  if (style.trim()) next[key] = style;
1259
1266
  continue;
1260
1267
  }
1268
+ if (normalized === "_csstext") {
1269
+ const cssText = sanitizeCssText(String(value));
1270
+ if (cssText.trim()) next[key] = cssText;
1271
+ continue;
1272
+ }
1261
1273
  next[key] = value;
1262
1274
  }
1263
1275
  return next;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Session replay playback keeps rrweb's inlined stylesheet snapshots while stripping live resource loads, reducing blank replay frames for captured pages with external CSS.
@@ -191,6 +191,24 @@ patterns live in `.agents/skills/`.
191
191
  mode can list and resolve local routes now, but file reads/writes remain a
192
192
  bridge contract until explicit permission controls are hardened.
193
193
 
194
+ ## Code Workspace
195
+
196
+ - The editor left rail has a wide `code` workspace panel. Open it with
197
+ `navigate --view editor --designId <id> --leftPanel code` and optionally pass
198
+ `fileId`, `filename`, or `screen` to focus a file.
199
+ - Use `list-source-files` to inspect the source workspace. For the MVP the
200
+ backend is `virtual-inline` over SQL-backed `design_files`, exposed as
201
+ `designfs://<designId>/`.
202
+ - Use `read-source-file` for file contents and preserve its `versionHash` before
203
+ writing. Do not return full file content from `view-screen`; it reports only
204
+ active code file metadata and dirty state.
205
+ - Use `preview-source-edit` to show a diff without saving, then
206
+ `apply-source-edit` with the prior `versionHash` to save either a full replace
207
+ or exact replace. These actions update the same inline file state as the UI.
208
+ - Use `resolve-selection-source` when the user has a canvas element selected and
209
+ you need the best matching inline file location/snippet. Localhost/container
210
+ source reads and writes remain future backend work.
211
+
194
212
  ## Localhost Source Actions
195
213
 
196
214
  - `connect-localhost`: registers or refreshes a localhost source connection
@@ -0,0 +1,87 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+
4
+ import {
5
+ findSourceWorkspaceFile,
6
+ readLiveSourceFile,
7
+ resolveSourceWorkspace,
8
+ writeInlineSourceFile,
9
+ } from "../server/source-workspace.js";
10
+ import {
11
+ applySourceEdit,
12
+ previewSourceDiff,
13
+ } from "../shared/source-workspace.js";
14
+
15
+ const sourceEditSchema = z.discriminatedUnion("kind", [
16
+ z.object({
17
+ kind: z.literal("full-replace"),
18
+ content: z.string().describe("Complete replacement file content"),
19
+ }),
20
+ z.object({
21
+ kind: z.literal("exact-replace"),
22
+ search: z.string().min(1).describe("Unique exact text to replace"),
23
+ replace: z.string().describe("Replacement text"),
24
+ }),
25
+ ]);
26
+
27
+ export default defineAction({
28
+ description:
29
+ "Apply a source-file edit through the shared Design source surface. In the " +
30
+ "MVP this writes inline design_files only and rejects stale version hashes.",
31
+ schema: z
32
+ .object({
33
+ designId: z.string().describe("Design project ID"),
34
+ path: z
35
+ .string()
36
+ .optional()
37
+ .describe("Source path/filename, such as index.html"),
38
+ fileId: z.string().optional().describe("Design file ID"),
39
+ edit: sourceEditSchema,
40
+ expectedVersionHash: z
41
+ .string()
42
+ .optional()
43
+ .describe("Hash returned by read-source-file or preview-source-edit"),
44
+ })
45
+ .refine((args) => args.path || args.fileId, {
46
+ message: "Provide either path or fileId.",
47
+ path: ["path"],
48
+ }),
49
+ run: async ({ designId, path, fileId, edit, expectedVersionHash }) => {
50
+ const workspace = await resolveSourceWorkspace(designId, {
51
+ includeContent: true,
52
+ });
53
+ if (workspace.sourceType !== "inline") {
54
+ throw new Error("Only inline Design files are editable in this MVP.");
55
+ }
56
+ const file = findSourceWorkspaceFile(workspace.files, { fileId, path });
57
+ const live = await readLiveSourceFile(file);
58
+ if (
59
+ expectedVersionHash !== undefined &&
60
+ expectedVersionHash !== live.versionHash
61
+ ) {
62
+ throw new Error(
63
+ "Source file changed since it was read. Re-read the file and retry.",
64
+ );
65
+ }
66
+
67
+ const next = applySourceEdit(live.content, edit);
68
+ const write = await writeInlineSourceFile({
69
+ designId,
70
+ file,
71
+ content: next.content,
72
+ expectedVersionHash: expectedVersionHash ?? live.versionHash,
73
+ });
74
+
75
+ return {
76
+ designId,
77
+ path: file.filename,
78
+ fileId: file.id,
79
+ backendKind: "virtual-inline",
80
+ changed: write.changed,
81
+ editsApplied: next.editsApplied,
82
+ versionHash: write.versionHash,
83
+ updatedAt: write.updatedAt,
84
+ diff: previewSourceDiff(live.content, next.content),
85
+ };
86
+ },
87
+ });
@@ -0,0 +1,158 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { assertAccess } from "@agent-native/core/sharing";
3
+ import { z } from "zod";
4
+
5
+ import { parseFigmaClipboardHtml } from "../server/lib/figma-import/clipboard.js";
6
+ import { importFigmaBuffer } from "../server/lib/figma-import/processor.js";
7
+ import {
8
+ normalizeImportedHtmlDocument,
9
+ resolveImportDesignId,
10
+ saveImportedDesignFiles,
11
+ type ImportedDesignFile,
12
+ } from "../server/lib/import-design-files.js";
13
+
14
+ const MAX_HTML_IMPORT_BYTES = 2 * 1024 * 1024;
15
+
16
+ function ensureHtmlSize(content: string) {
17
+ if (Buffer.byteLength(content, "utf8") > MAX_HTML_IMPORT_BYTES) {
18
+ throw new Error("HTML import content is too large (max 2 MB).");
19
+ }
20
+ }
21
+
22
+ function baseFilename(originalName: string | undefined, fallback: string) {
23
+ return (originalName?.trim() || fallback).replace(/\.[^.]+$/, "") + ".html";
24
+ }
25
+
26
+ export default defineAction({
27
+ description:
28
+ "Import Figma clipboard HTML or standalone HTML into the current Design project as one or more editable screens.",
29
+ schema: z.object({
30
+ designId: z
31
+ .string()
32
+ .optional()
33
+ .describe("Design id. Defaults to the active editor navigation state."),
34
+ sourceType: z.enum(["figma-paste-html", "html-string"]),
35
+ content: z
36
+ .string()
37
+ .max(
38
+ MAX_HTML_IMPORT_BYTES,
39
+ "HTML import content is too large (max 2 MB).",
40
+ ),
41
+ originalName: z.string().optional(),
42
+ }),
43
+ run: async ({ designId, sourceType, content, originalName }) => {
44
+ ensureHtmlSize(content);
45
+ const resolvedDesignId = await resolveImportDesignId(designId);
46
+ await assertAccess("design", resolvedDesignId, "editor");
47
+
48
+ if (sourceType === "html-string") {
49
+ const saved = await saveImportedDesignFiles({
50
+ designId: resolvedDesignId,
51
+ sourceType: "html-import",
52
+ files: [
53
+ {
54
+ filename: baseFilename(originalName, "imported-html"),
55
+ fileType: "html",
56
+ content: normalizeImportedHtmlDocument(content, "HTML source"),
57
+ source: { sourceType: "html-string", originalName },
58
+ },
59
+ ],
60
+ });
61
+ return {
62
+ ...saved,
63
+ stats: { sourceKind: "html-string", frameCount: saved.files.length },
64
+ };
65
+ }
66
+
67
+ const parsed = parseFigmaClipboardHtml(content);
68
+ const warnings: string[] = [];
69
+ if (parsed.buffer && parsed.hasFigmaBuffer) {
70
+ try {
71
+ const imported = await importFigmaBuffer({
72
+ buffer: parsed.buffer,
73
+ filename: originalName ?? "figma-paste.fig",
74
+ sourceKind: "figma-paste",
75
+ selection: { nodeId: parsed.meta?.selectedNodeId },
76
+ meta: parsed.meta,
77
+ });
78
+ const files: ImportedDesignFile[] = imported.files.map((file) => ({
79
+ filename: file.filename,
80
+ fileType: "html",
81
+ content: file.content,
82
+ source: file.source,
83
+ preferredFrame: {
84
+ title:
85
+ typeof file.source?.frameName === "string"
86
+ ? file.source.frameName
87
+ : undefined,
88
+ width: file.width,
89
+ height: file.height,
90
+ },
91
+ }));
92
+ const saved = await saveImportedDesignFiles({
93
+ designId: resolvedDesignId,
94
+ sourceType: "figma-paste",
95
+ files,
96
+ warnings: [...warnings, ...imported.warnings],
97
+ });
98
+ return { ...saved, stats: imported.stats };
99
+ } catch (error) {
100
+ warnings.push(
101
+ error instanceof Error
102
+ ? `Figma binary payload could not be decoded: ${error.message}`
103
+ : "Figma binary payload could not be decoded.",
104
+ );
105
+ }
106
+ } else if (parsed.buffer) {
107
+ warnings.push(
108
+ "The clipboard included a Figma buffer, but it was not a supported fig-kiwi payload.",
109
+ );
110
+ }
111
+
112
+ if (!parsed.fallbackHtml) {
113
+ throw new Error(
114
+ "No importable Figma frame data or visible HTML was found in the clipboard.",
115
+ );
116
+ }
117
+ const saved = await saveImportedDesignFiles({
118
+ designId: resolvedDesignId,
119
+ sourceType: "figma-paste-fallback",
120
+ files: [
121
+ {
122
+ filename: baseFilename(originalName, "figma-paste"),
123
+ fileType: "html",
124
+ content: normalizeImportedHtmlDocument(
125
+ parsed.fallbackHtml,
126
+ "Figma clipboard fallback HTML",
127
+ ),
128
+ source: {
129
+ sourceType: "figma-paste-fallback",
130
+ selectedNodeId: parsed.meta?.selectedNodeId,
131
+ fileKey: parsed.meta?.fileKey,
132
+ },
133
+ },
134
+ ],
135
+ warnings,
136
+ });
137
+ return {
138
+ ...saved,
139
+ stats: {
140
+ sourceKind: "figma-paste",
141
+ format: "html-fallback",
142
+ frameCount: saved.files.length,
143
+ imageCount: 0,
144
+ selectedNodeId: parsed.meta?.selectedNodeId,
145
+ },
146
+ };
147
+ },
148
+ link: ({ result }) => {
149
+ if (!result || typeof result !== "object") return null;
150
+ const designId = (result as { designId?: string }).designId;
151
+ if (!designId) return null;
152
+ return {
153
+ url: `/design/${designId}`,
154
+ label: "Open overview",
155
+ view: "editor",
156
+ };
157
+ },
158
+ });