@agent-native/core 0.85.5 → 0.85.7

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/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  5. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  6. package/corpus/core/src/collab/presence.ts +63 -3
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  9. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  10. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  11. package/corpus/templates/design/AGENTS.md +40 -4
  12. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  14. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  15. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  16. package/corpus/templates/design/actions/edit-design.ts +5 -3
  17. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  18. package/corpus/templates/design/actions/export-zip.ts +10 -1
  19. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  20. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  21. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  22. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  23. package/corpus/templates/design/actions/present-design-variants.ts +12 -8
  24. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  25. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  26. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  28. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  29. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  30. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
  32. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  34. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  36. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  40. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  41. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  42. package/corpus/templates/design/app/components/design/types.ts +16 -0
  43. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  44. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  45. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  46. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  47. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  48. package/corpus/templates/design/app/i18n-data.ts +18 -0
  49. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  50. package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
  51. package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  61. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  62. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  63. package/corpus/templates/design/shared/board-file.ts +25 -5
  64. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  65. package/corpus/templates/design/shared/code-layer.ts +304 -26
  66. package/corpus/templates/design/shared/color-utils.ts +84 -0
  67. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  68. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  69. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  70. package/corpus/templates/design/shared/pen-path.ts +200 -23
  71. package/dist/client/AssistantChat.js +1 -1
  72. package/dist/client/AssistantChat.js.map +1 -1
  73. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  74. package/dist/client/use-pinch-zoom.js +76 -11
  75. package/dist/client/use-pinch-zoom.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/presence.d.ts.map +1 -1
  79. package/dist/collab/presence.js +54 -3
  80. package/dist/collab/presence.js.map +1 -1
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/collab/struct-routes.d.ts +1 -1
  83. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +2 -2
  85. package/dist/secrets/routes.d.ts +3 -3
  86. package/package.json +1 -1
@@ -3,7 +3,7 @@ import {
3
3
  getRequestOrgId,
4
4
  getRequestUserEmail,
5
5
  } from "@agent-native/core/server/request-context";
6
- import { and, eq } from "drizzle-orm";
6
+ import { eq } from "drizzle-orm";
7
7
  import { nanoid } from "nanoid";
8
8
  import { z } from "zod";
9
9
 
@@ -159,20 +159,26 @@ export default defineAction({
159
159
  status: "available" as const,
160
160
  }));
161
161
 
162
+ // The id may already be taken by a DIFFERENT user (legacy ids were derived
163
+ // from devServerUrl + rootPath without user scoping, so two users on the
164
+ // same devcontainer image collide). Detect that up front and fail with a
165
+ // clear error instead of crashing on the primary-key insert below.
162
166
  const existing = await db
163
167
  .select({
164
- id: schema.designLocalhostConnections.id,
168
+ ownerEmail: schema.designLocalhostConnections.ownerEmail,
165
169
  bridgeToken: schema.designLocalhostConnections.bridgeToken,
166
170
  })
167
171
  .from(schema.designLocalhostConnections)
168
- .where(
169
- and(
170
- eq(schema.designLocalhostConnections.id, id),
171
- eq(schema.designLocalhostConnections.ownerEmail, ownerEmail),
172
- ),
173
- )
172
+ .where(eq(schema.designLocalhostConnections.id, id))
174
173
  .limit(1);
175
174
 
175
+ if (existing[0] && existing[0].ownerEmail !== ownerEmail) {
176
+ throw new Error(
177
+ `Connection id "${id}" already belongs to another user. ` +
178
+ "Omit id so a per-user connection id is derived instead.",
179
+ );
180
+ }
181
+
176
182
  const nextBridgeToken = args.bridgeToken?.trim() || undefined;
177
183
  const values = {
178
184
  id,
@@ -191,17 +197,18 @@ export default defineAction({
191
197
  updatedAt: now,
192
198
  };
193
199
 
194
- if (existing[0]) {
195
- await db
196
- .update(schema.designLocalhostConnections)
197
- .set(values)
198
- .where(eq(schema.designLocalhostConnections.id, id));
199
- } else {
200
- await db.insert(schema.designLocalhostConnections).values({
201
- ...values,
202
- createdAt: now,
200
+ // Atomic upsert keyed on the id primary key — no check-then-insert race.
201
+ // setWhere keeps a concurrent insert by another user (TOCTOU on the
202
+ // ownership check above) from being overwritten: on a cross-user conflict
203
+ // the update filters to a no-op instead of hijacking the other row.
204
+ await db
205
+ .insert(schema.designLocalhostConnections)
206
+ .values({ ...values, createdAt: now })
207
+ .onConflictDoUpdate({
208
+ target: schema.designLocalhostConnections.id,
209
+ set: values,
210
+ setWhere: eq(schema.designLocalhostConnections.ownerEmail, ownerEmail),
203
211
  });
204
- }
205
212
 
206
213
  return {
207
214
  id,
@@ -142,8 +142,10 @@ export default defineAction({
142
142
  "the snapshot with only the requested copy changed. After a variant pick " +
143
143
  "or any other selected-screen follow-up, pass the exact `fileId` from " +
144
144
  '`get-design-snapshot` and use `mode: "replace-file"` when replacing ' +
145
- "the representative placeholder with the full chosen direction. Use " +
146
- "`generate-design` instead only for brand-new files.",
145
+ "the representative placeholder with a complete but compact UI in the chosen " +
146
+ "direction; prioritize the primary workflow and render secondary details " +
147
+ "as visible controls, states, or affordances when needed. Use `generate-design` " +
148
+ "instead only for brand-new files.",
147
149
  schema: z
148
150
  .object({
149
151
  designId: z.string().describe("Design project ID"),
@@ -175,7 +177,7 @@ export default defineAction({
175
177
  .min(1)
176
178
  .optional()
177
179
  .describe(
178
- "Complete updated file content. Use only with mode=replace-file for selected variant expansion or broad copy-only changes; preserve all HTML structure, CSS, scripts, and tweaks from get-design-snapshot.",
180
+ "Complete updated file content. Use only with mode=replace-file for selected variant expansion or broad copy-only changes; preserve all HTML structure, CSS, scripts, and tweaks from get-design-snapshot. For selected variants, keep the replacement complete but compact instead of expanding secondary details into an oversized payload.",
179
181
  ),
180
182
  })
181
183
  .superRefine((value, ctx) => {
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { injectHiddenLayerExportStyle } from "../server/lib/design-export.js";
7
8
  import { isBoardFile } from "../shared/board-file.js";
8
9
  import "../server/db/index.js"; // ensure registerShareableResource runs
9
10
 
@@ -40,7 +41,13 @@ export default defineAction({
40
41
  id: f.id,
41
42
  filename: f.filename,
42
43
  fileType: f.fileType,
43
- content: f.content,
44
+ // Layers toggled hidden in the editor are only suppressed by the live
45
+ // editor bridge; inject the same display:none rule so the client-side
46
+ // PDF render (html2canvas over this HTML) doesn't reveal them.
47
+ content:
48
+ f.fileType === "html" && f.content
49
+ ? injectHiddenLayerExportStyle(f.content)
50
+ : f.content,
44
51
  })),
45
52
  exportInfo: {
46
53
  format: "pdf",
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { getDb, schema } from "../server/db/index.js";
7
7
  import {
8
8
  exportFilename,
9
+ injectHiddenLayerExportStyle,
9
10
  trySaveExportFile,
10
11
  } from "../server/lib/design-export.js";
11
12
  import { isBoardFile } from "../shared/board-file.js";
@@ -71,7 +72,15 @@ export default defineAction({
71
72
  file.filename,
72
73
  `design-file-${index + 1}.txt`,
73
74
  );
74
- zip.file(filename, file.content ?? "");
75
+ // Layers toggled hidden in the editor are only suppressed by the live
76
+ // editor bridge; inject the same display:none rule into exported HTML
77
+ // files so opening them directly from the zip doesn't reveal layers
78
+ // the user hid in the editor.
79
+ const content =
80
+ file.fileType === "html"
81
+ ? injectHiddenLayerExportStyle(file.content ?? "")
82
+ : (file.content ?? "");
83
+ zip.file(filename, content);
75
84
  }
76
85
 
77
86
  // Add design data if present
@@ -1,5 +1,4 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { getText, hasCollabState } from "@agent-native/core/collab";
3
2
  import { assertAccess } from "@agent-native/core/sharing";
4
3
  import { and, desc, eq } from "drizzle-orm";
5
4
  import { z } from "zod";
@@ -10,6 +9,7 @@ import {
10
9
  extractManagedMotionCss,
11
10
  hashCss,
12
11
  parse,
12
+ parseFirstAnimationDurationMs,
13
13
  } from "../shared/motion-compiler.js";
14
14
  import type { MotionTrack } from "../shared/motion-timeline.js";
15
15
 
@@ -30,21 +30,6 @@ interface TimelineResult {
30
30
  updatedAt: string | null;
31
31
  }
32
32
 
33
- async function liveFileContent(
34
- fileId: string,
35
- storedContent: string,
36
- ): Promise<string> {
37
- try {
38
- if (await hasCollabState(fileId)) {
39
- const live = await getText(fileId, "content");
40
- if (typeof live === "string") return live;
41
- }
42
- } catch {
43
- // Best-effort; SQL is the fallback.
44
- }
45
- return storedContent;
46
- }
47
-
48
33
  function parseTrackJson(raw: string | null): MotionTrack[] {
49
34
  try {
50
35
  const parsed: unknown = JSON.parse(raw ?? "[]");
@@ -57,7 +42,12 @@ function parseTrackJson(raw: string | null): MotionTrack[] {
57
42
  async function readManagedCssForSource(args: {
58
43
  designId: string;
59
44
  sourceRef?: string;
60
- }): Promise<{ css: string; hash: string; tracks: MotionTrack[] } | null> {
45
+ }): Promise<{
46
+ css: string;
47
+ hash: string;
48
+ tracks: MotionTrack[];
49
+ durationMs: number | null;
50
+ } | null> {
61
51
  if (!args.sourceRef) return null;
62
52
 
63
53
  const db = getDb();
@@ -77,7 +67,13 @@ async function readManagedCssForSource(args: {
77
67
 
78
68
  if (!file) return null;
79
69
 
80
- const content = await liveFileContent(file.id, file.content ?? "");
70
+ // Read the managed block from the SQL content, NOT a live collab snapshot:
71
+ // apply-motion-edit persists the managed <style> block to SQL only, so a
72
+ // live collab session's text lags the freshest motion CSS. Comparing the
73
+ // stored compiledHash against stale collab CSS would flag phantom
74
+ // "stored-css-drift" right after every save and replace fresh tracks with
75
+ // stale CSS-parsed ones. SQL is the motion block's source of truth.
76
+ const content = file.content ?? "";
81
77
  const css = extractManagedMotionCss(content);
82
78
  if (!css) return null;
83
79
 
@@ -88,7 +84,12 @@ async function readManagedCssForSource(args: {
88
84
  );
89
85
  if (tracks.length === 0) return null;
90
86
 
91
- return { css, hash: hashCss(css), tracks };
87
+ return {
88
+ css,
89
+ hash: hashCss(css),
90
+ tracks,
91
+ durationMs: parseFirstAnimationDurationMs(css),
92
+ };
92
93
  }
93
94
 
94
95
  export default defineAction({
@@ -179,7 +180,9 @@ export default defineAction({
179
180
  sourceRef: sourceRef ?? null,
180
181
  filePath: null,
181
182
  tracks: managedCss.tracks,
182
- durationMs: 1000,
183
+ // Recover the compiled animation-duration instead of inventing a
184
+ // default the next save would silently persist.
185
+ durationMs: managedCss.durationMs ?? 1000,
183
186
  defaultEase: "ease",
184
187
  compiledHash: managedCss.hash,
185
188
  cssHash: managedCss.hash,
@@ -191,6 +194,9 @@ export default defineAction({
191
194
  timelines[0] = {
192
195
  ...first,
193
196
  tracks: managedCss.tracks,
197
+ // In the drift case the CSS is the runtime truth — surface its
198
+ // compiled duration alongside its recovered tracks.
199
+ durationMs: managedCss.durationMs ?? first.durationMs,
194
200
  cssHash: managedCss.hash,
195
201
  source: "stored-css-drift",
196
202
  };
@@ -19,6 +19,16 @@ export default defineAction({
19
19
  "for a specific design + localhost connection. The grant scopes writes to the " +
20
20
  "connection's rootPath and expires after 8 hours. Requires editor access on the design. " +
21
21
  "The LocalhostWriteConsentDialog calls this after the user clicks 'Allow writes'.",
22
+ // This action mints/returns the real bridgeToken that unlocks the local
23
+ // bridge's unrestricted /read-file, /write-file, and /apply-edit. It must
24
+ // only ever be triggered by a human clicking "Allow writes" in
25
+ // LocalhostWriteConsentDialog — never by the agent itself, or an agent
26
+ // could self-grant local filesystem write/read access and bypass the
27
+ // human consent model entirely. `agentTool: false` hides it from every
28
+ // agent tool surface (in-app assistant, MCP, A2A) while keeping it
29
+ // callable from the frontend via `callAction` / `useActionMutation` and
30
+ // the raw `/_agent-native/actions/grant-localhost-write-consent` route.
31
+ agentTool: false,
22
32
  schema: z.object({
23
33
  designId: z.string().describe("Design ID."),
24
34
  connectionId: z
@@ -41,6 +41,27 @@ const schemaInput = z.object({
41
41
  .string()
42
42
  .optional()
43
43
  .describe("Design editor selection owner token from current screen state."),
44
+ targetNodeId: z
45
+ .string()
46
+ .optional()
47
+ .describe(
48
+ "data-agent-native-node-id of an existing element to target. Required " +
49
+ 'for mode "replace-src" and "background-fill". Ignored for the ' +
50
+ 'default "figure" mode.',
51
+ ),
52
+ mode: z
53
+ .enum(["figure", "replace-src", "background-fill"])
54
+ .optional()
55
+ .default("figure")
56
+ .describe(
57
+ 'How to place the asset. "figure" (default) appends a new figure/section ' +
58
+ "at the end of the file, styled with the design's own tokens — use " +
59
+ 'this when there is no existing element to target. "replace-src" sets ' +
60
+ "the src of the <img>/<video> at targetNodeId (e.g. filling a hero " +
61
+ 'image placeholder). "background-fill" sets a background-image style ' +
62
+ "on the element at targetNodeId (e.g. filling a hero/section background). " +
63
+ 'Both "replace-src" and "background-fill" require targetNodeId.',
64
+ ),
44
65
  });
45
66
 
46
67
  function stringFromState(state: unknown, key: string): string | undefined {
@@ -57,6 +78,26 @@ function escapeHtml(value: string): string {
57
78
  .replace(/"/g, "&quot;");
58
79
  }
59
80
 
81
+ /**
82
+ * Prepare a URL for embedding inside a single-quoted CSS `url('...')` value
83
+ * that itself lives inside an HTML attribute. A literal `'` in the URL would
84
+ * otherwise close the CSS string early — and depending on whether the
85
+ * surrounding HTML attribute is single- or double-quoted, everything after it
86
+ * becomes live CSS or, worse, live HTML/JS in the script-enabled preview
87
+ * iframe. Percent-encoding the quote/backslash characters that are meaningful
88
+ * to the CSS string-literal grammar keeps the URL functionally identical
89
+ * (browsers resolve %27/%22/%5C the same as the raw characters) while making
90
+ * it impossible to break out of the `url('...')` string. HTML-escape on top
91
+ * so the value is also safe as an HTML attribute (covers the "..." case).
92
+ */
93
+ function cssUrlValue(value: string): string {
94
+ const cssSafe = value
95
+ .replace(/\\/g, "%5C")
96
+ .replace(/'/g, "%27")
97
+ .replace(/"/g, "%22");
98
+ return escapeHtml(cssSafe);
99
+ }
100
+
60
101
  function createInsertedNodeId(prefix: string): string {
61
102
  const random =
62
103
  typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
@@ -88,13 +129,13 @@ function appendAssetMarkup(
88
129
  : "";
89
130
  const media =
90
131
  args.mediaType === "video"
91
- ? `<video src="${escapedUrl}" controls class="w-full rounded-xl object-cover"></video>`
92
- : `<img src="${escapedUrl}" alt="${escapeHtml(args.altText?.trim() || label)}" class="w-full rounded-xl object-cover" />`;
132
+ ? `<video src="${escapedUrl}" controls class="w-full rounded-[var(--radius,0.75rem)] object-cover"></video>`
133
+ : `<img src="${escapedUrl}" alt="${escapeHtml(args.altText?.trim() || label)}" class="w-full rounded-[var(--radius,0.75rem)] object-cover" />`;
93
134
  const snippet = `
94
135
  <section class="mx-auto my-8 max-w-5xl px-4" data-agent-native-asset data-agent-native-node-id="${escapeHtml(nodeId)}" data-agent-native-layer-name="${escapedLabel}"${assetIdAttr}>
95
- <figure class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
136
+ <figure class="overflow-hidden rounded-[var(--radius,1rem)] border border-[var(--color-border,rgba(0,0,0,0.1))] bg-[var(--color-surface,#fff)] shadow-sm">
96
137
  ${media}
97
- <figcaption class="px-4 py-3 text-sm text-slate-600">${escapedLabel}</figcaption>
138
+ <figcaption class="px-4 py-3 text-sm text-[var(--color-text-muted,#64748b)]">${escapedLabel}</figcaption>
98
139
  </figure>
99
140
  </section>`;
100
141
 
@@ -105,6 +146,119 @@ function appendAssetMarkup(
105
146
  );
106
147
  }
107
148
 
149
+ /** Find the raw tag string for the element carrying the given node id. */
150
+ function findTagByNodeId(html: string, nodeId: string): string | null {
151
+ const escapedId = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152
+ const pattern = new RegExp(
153
+ `<[a-zA-Z][a-zA-Z0-9:-]*\\b[^>]*data-agent-native-node-id\\s*=\\s*(?:"${escapedId}"|'${escapedId}')[^>]*>`,
154
+ );
155
+ const match = html.match(pattern);
156
+ return match ? match[0] : null;
157
+ }
158
+
159
+ /** Replace the src attribute of the <img>/<video> tag at targetNodeId. */
160
+ function replaceSrcAtNode(
161
+ html: string,
162
+ args: z.infer<typeof schemaInput>,
163
+ ): string {
164
+ const nodeId = args.targetNodeId!;
165
+ const tag = findTagByNodeId(html, nodeId);
166
+ if (!tag) {
167
+ throw new Error(
168
+ `No element found with data-agent-native-node-id="${nodeId}".`,
169
+ );
170
+ }
171
+ const escapedUrl = escapeHtml(args.assetUrl);
172
+ let nextTag: string;
173
+ if (/\bsrc\s*=\s*(?:"[^"]*"|'[^']*')/i.test(tag)) {
174
+ nextTag = tag.replace(
175
+ /\bsrc\s*=\s*(?:"[^"]*"|'[^']*')/i,
176
+ `src="${escapedUrl}"`,
177
+ );
178
+ } else {
179
+ nextTag = tag.replace(/\/?>\s*$/, ` src="${escapedUrl}">`);
180
+ }
181
+ if (args.altText?.trim()) {
182
+ const escapedAlt = escapeHtml(args.altText.trim());
183
+ nextTag = /\balt\s*=\s*(?:"[^"]*"|'[^']*')/i.test(nextTag)
184
+ ? nextTag.replace(
185
+ /\balt\s*=\s*(?:"[^"]*"|'[^']*')/i,
186
+ `alt="${escapedAlt}"`,
187
+ )
188
+ : nextTag.replace(/\/?>\s*$/, ` alt="${escapedAlt}">`);
189
+ }
190
+ if (args.assetId?.trim()) {
191
+ const escapedAssetId = escapeHtml(args.assetId.trim());
192
+ nextTag = /\bdata-asset-id\s*=/.test(nextTag)
193
+ ? nextTag.replace(
194
+ /\bdata-asset-id\s*=\s*(?:"[^"]*"|'[^']*')/i,
195
+ `data-asset-id="${escapedAssetId}"`,
196
+ )
197
+ : nextTag.replace(/\/?>\s*$/, ` data-asset-id="${escapedAssetId}">`);
198
+ }
199
+ return html.replace(tag, nextTag);
200
+ }
201
+
202
+ /** Set a background-image inline style on the element at targetNodeId. */
203
+ function backgroundFillAtNode(
204
+ html: string,
205
+ args: z.infer<typeof schemaInput>,
206
+ ): string {
207
+ const nodeId = args.targetNodeId!;
208
+ const tag = findTagByNodeId(html, nodeId);
209
+ if (!tag) {
210
+ throw new Error(
211
+ `No element found with data-agent-native-node-id="${nodeId}".`,
212
+ );
213
+ }
214
+ const cssUrl = cssUrlValue(args.assetUrl);
215
+ const bgDeclaration = `background-image: url('${cssUrl}'); background-size: cover; background-position: center;`;
216
+ let nextTag: string;
217
+ if (/\bstyle\s*=\s*"/.test(tag)) {
218
+ nextTag = tag.replace(
219
+ /\bstyle\s*=\s*"([^"]*)"/i,
220
+ (_match, existing: string) => {
221
+ const trimmed = existing.trim();
222
+ const withoutBgImage = trimmed.replace(
223
+ /background-image\s*:[^;]*;?\s*/gi,
224
+ "",
225
+ );
226
+ const joined = withoutBgImage
227
+ ? `${withoutBgImage.replace(/;\s*$/, "")}; ${bgDeclaration}`
228
+ : bgDeclaration;
229
+ return `style="${joined}"`;
230
+ },
231
+ );
232
+ } else if (/\bstyle\s*=\s*'/.test(tag)) {
233
+ nextTag = tag.replace(
234
+ /\bstyle\s*=\s*'([^']*)'/i,
235
+ (_match, existing: string) => {
236
+ const trimmed = existing.trim();
237
+ const withoutBgImage = trimmed.replace(
238
+ /background-image\s*:[^;]*;?\s*/gi,
239
+ "",
240
+ );
241
+ const joined = withoutBgImage
242
+ ? `${withoutBgImage.replace(/;\s*$/, "")}; ${bgDeclaration}`
243
+ : bgDeclaration;
244
+ return `style='${joined}'`;
245
+ },
246
+ );
247
+ } else {
248
+ nextTag = tag.replace(/\/?>\s*$/, ` style="${bgDeclaration}">`);
249
+ }
250
+ if (args.assetId?.trim()) {
251
+ const escapedAssetId = escapeHtml(args.assetId.trim());
252
+ nextTag = /\bdata-asset-id\s*=/.test(nextTag)
253
+ ? nextTag.replace(
254
+ /\bdata-asset-id\s*=\s*(?:"[^"]*"|'[^']*')/i,
255
+ `data-asset-id="${escapedAssetId}"`,
256
+ )
257
+ : nextTag.replace(/\/?>\s*$/, ` data-asset-id="${escapedAssetId}">`);
258
+ }
259
+ return html.replace(tag, nextTag);
260
+ }
261
+
108
262
  async function resolveTarget(args: z.infer<typeof schemaInput>) {
109
263
  const [navigation, selection] = await Promise.all([
110
264
  readAppStateForCurrentTab("navigation").catch(() => null),
@@ -149,7 +303,18 @@ function isHtmlFile(file: {
149
303
 
150
304
  export default defineAction({
151
305
  description:
152
- "Insert a chosen Assets image or video URL into a Design file. Use this after the Assets picker returns chooseAsset/chooseImage context; pass designId/fileId directly when known, or pass ownerId from view-screen.designSelection when targeting the current editor selection.",
306
+ "Insert a chosen Assets image or video URL into a Design file. Use this " +
307
+ "after the Assets picker returns chooseAsset/chooseImage context; pass " +
308
+ "designId/fileId directly when known, or pass ownerId from " +
309
+ "view-screen.designSelection when targeting the current editor selection. " +
310
+ 'Default `mode: "figure"` appends a new tokened figure/section at the end ' +
311
+ "of the file — use this when there is no existing element to fill. Use " +
312
+ '`mode: "replace-src"` with `targetNodeId` to set the src of an existing ' +
313
+ "<img>/<video> (e.g. filling a hero image placeholder), or " +
314
+ '`mode: "background-fill"` with `targetNodeId` to set a background-image ' +
315
+ "style on an existing element (e.g. filling a hero/section background). " +
316
+ "Both of those require targetNodeId (a data-agent-native-node-id) from " +
317
+ "get-code-layer-projection or the current selection.",
153
318
  schema: schemaInput,
154
319
  publicAgent: { expose: true, readOnly: false, requiresAuth: true },
155
320
  run: async (args) => {
@@ -200,8 +365,25 @@ export default defineAction({
200
365
  // Collab read is best-effort; fall back to stored content.
201
366
  }
202
367
 
203
- const insertedNodeId = createInsertedNodeId("asset");
204
- const content = appendAssetMarkup(base, args, insertedNodeId);
368
+ const mode = args.mode ?? "figure";
369
+ let content: string;
370
+ let insertedNodeId: string;
371
+ if (mode === "replace-src") {
372
+ if (!args.targetNodeId) {
373
+ throw new Error('targetNodeId is required for mode "replace-src".');
374
+ }
375
+ insertedNodeId = args.targetNodeId;
376
+ content = replaceSrcAtNode(base, args);
377
+ } else if (mode === "background-fill") {
378
+ if (!args.targetNodeId) {
379
+ throw new Error('targetNodeId is required for mode "background-fill".');
380
+ }
381
+ insertedNodeId = args.targetNodeId;
382
+ content = backgroundFillAtNode(base, args);
383
+ } else {
384
+ insertedNodeId = createInsertedNodeId("asset");
385
+ content = appendAssetMarkup(base, args, insertedNodeId);
386
+ }
205
387
  const now = new Date().toISOString();
206
388
  await db
207
389
  .update(schema.designFiles)
@@ -223,6 +405,7 @@ export default defineAction({
223
405
  fileId: file.id,
224
406
  filename: file.filename,
225
407
  inserted: true,
408
+ mode,
226
409
  insertedNodeId,
227
410
  insertedSelector: `[data-agent-native-node-id="${insertedNodeId}"]`,
228
411
  assetId: args.assetId ?? null,
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import { defineAction, embedApp } from "@agent-native/core";
4
4
  import { writeAppState } from "@agent-native/core/application-state";
5
5
  import { buildDeepLink } from "@agent-native/core/server";
6
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
6
7
  import { eq } from "drizzle-orm";
7
8
  import { z } from "zod";
8
9
 
@@ -88,10 +89,23 @@ function isLoopbackUrl(value: string): boolean {
88
89
  );
89
90
  }
90
91
 
91
- function stableConnectionId(devServerUrl: string, rootPath?: string) {
92
+ /**
93
+ * Derive a stable per-user connection id for a devServerUrl + rootPath pair.
94
+ *
95
+ * The owner email is embedded in the hash so two users with an identical
96
+ * devServerUrl + rootPath (common with devcontainers/codespaces images) derive
97
+ * DIFFERENT ids and never collide on the shared primary key. Connections
98
+ * created before user scoping keep their old ids; those users simply
99
+ * reconnect fresh under the new per-user id.
100
+ */
101
+ function stableConnectionId(
102
+ devServerUrl: string,
103
+ rootPath: string | undefined,
104
+ ownerEmail: string,
105
+ ) {
92
106
  const hash = crypto
93
107
  .createHash("sha256")
94
- .update(`${devServerUrl}\n${rootPath ?? ""}`)
108
+ .update(`${ownerEmail}\n${devServerUrl}\n${rootPath ?? ""}`)
95
109
  .digest("base64url")
96
110
  .slice(0, 16);
97
111
  return `localhost_${hash}`;
@@ -147,7 +161,7 @@ export default defineAction({
147
161
  .string()
148
162
  .optional()
149
163
  .describe(
150
- "Existing localhost connection. Omit to reuse a stable connection for devServerUrl + rootPath.",
164
+ "Existing localhost connection. Omit to reuse a stable per-user connection for devServerUrl + rootPath.",
151
165
  ),
152
166
  title: z
153
167
  .string()
@@ -240,8 +254,16 @@ export default defineAction({
240
254
  }) ?? [],
241
255
  generatedAt: new Date().toISOString(),
242
256
  };
243
- const connectionId =
244
- args.connectionId ?? stableConnectionId(devServerUrl, args.rootPath);
257
+ let connectionId = args.connectionId;
258
+ if (!connectionId) {
259
+ const ownerEmail = getRequestUserEmail();
260
+ if (!ownerEmail) throw new Error("no authenticated user");
261
+ connectionId = stableConnectionId(
262
+ devServerUrl,
263
+ args.rootPath,
264
+ ownerEmail,
265
+ );
266
+ }
245
267
 
246
268
  const connection = await connectLocalhostAction.run({
247
269
  id: connectionId,
@@ -43,14 +43,18 @@ const FALLBACK_INSTRUCTIONS =
43
43
  "delete each other variant screen at most once, call get-design-snapshot with fileId for " +
44
44
  "the kept screen once, then call edit-design on that same fileId in a bounded pass. " +
45
45
  'Use mode "replace-file" to replace the representative direction screen with ' +
46
- "the actual requested product UI; do not leave a direction board, summary card, " +
47
- "or variant brief as the final result. " +
46
+ "the actual requested product UI; make the result complete but compact and " +
47
+ "prefer visible controls/affordances over exhaustive content if the request is large. " +
48
+ "Do not leave a direction board, summary card, or variant brief as the final result. " +
48
49
  "Do not call generate-design after a variant pick.";
49
50
 
50
51
  const VARIANT_PICK_SUBMIT_MESSAGE =
51
52
  "Use this design direction. Keep the selected screen, clean up each other " +
52
53
  "variant screen at most once, read only the kept screen, then update that " +
53
- "same screen in one bounded pass into the full requested app/product UI. " +
54
+ "same screen in one bounded pass into the requested app/product UI. Make it " +
55
+ "complete but compact: prioritize the primary workflow, and if the full feature " +
56
+ "list is too large for one reliable edit, render secondary details as visible " +
57
+ "controls, states, or affordances instead of expanding the action input. " +
54
58
  "The selected screen is only a representative direction; the final saved " +
55
59
  "screen must not be a direction board, variant brief, or summary card. " +
56
60
  "If a cleanup action reports a screen was " +
@@ -246,7 +250,7 @@ function colorForVariant(
246
250
  ) {
247
251
  const provided = variant.accentColor?.trim();
248
252
  if (provided) return provided;
249
- return ["#8b5cf6", "#06b6d4", "#10b981", "#f43f5e", "#f59e0b"][index % 5]!;
253
+ return ["#f59e0b", "#06b6d4", "#10b981", "#f43f5e", "#d97706"][index % 5]!;
250
254
  }
251
255
 
252
256
  function fallbackVariantContent(
@@ -303,7 +307,7 @@ function fallbackVariantContent(
303
307
  <style>
304
308
  :root { color-scheme: dark; --accent: ${accent}; --bg: #080a0f; --panel: rgba(18, 22, 33, 0.82); --line: rgba(255,255,255,.11); --muted: #94a3b8; }
305
309
  * { box-sizing: border-box; }
306
- body { margin: 0; width: ${screenWidth}px; min-height: ${screenHeight}px; overflow: hidden; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #f8fafc; background:
310
+ body { margin: 0; width: ${screenWidth}px; min-height: ${screenHeight}px; overflow: hidden; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #f8fafc; background:
307
311
  radial-gradient(circle at 18% 8%, color-mix(in srgb, var(--accent) 42%, transparent), transparent 30%),
308
312
  linear-gradient(140deg, #05070b 0%, #111827 48%, #05070b 100%); }
309
313
  .shell { width: ${screenWidth}px; min-height: ${screenHeight}px; padding: ${compact ? "18" : "34"}px; display: grid; grid-template-columns: ${compact ? "1fr" : tablet ? "220px 1fr" : "258px 1fr 304px"}; gap: ${compact ? "14" : "22"}px; }
@@ -429,7 +433,7 @@ export default defineAction({
429
433
  "call get-design-snapshot with fileId for the kept screen before " +
430
434
  "calling edit-design on that same fileId in a bounded pass. Use " +
431
435
  '`mode: "replace-file"` when expanding the representative placeholder ' +
432
- "into the full chosen direction. Do not call generate-design after a " +
436
+ "into a complete but compact product UI in the chosen direction. Do not call generate-design after a " +
433
437
  "variant pick. Stop after the first successful edit-design save. For " +
434
438
  "complex apps, " +
435
439
  "make each variant a " +
@@ -613,7 +617,7 @@ export default defineAction({
613
617
  value:
614
618
  `Keep "${screen.label}" (${screen.filename}, file id ${screen.id}) ` +
615
619
  `from variant set ${variantSetId}. Delete each other variant screen at most once: ${otherScreens}. If delete-file says a screen is already missing, continue. ` +
616
- `Then call get-design-snapshot exactly once with designId ${designId} and fileId ${screen.id} (filename ${screen.filename}), then call edit-design with fileId ${screen.id} on that same kept file in a bounded single-file pass. Use mode "replace-file" to replace the representative direction screen with the full requested app/product UI in the chosen visual style. The final saved screen must be the actual usable UI requested by the user, not a direction board, variant brief, summary card, or description of the direction. Do not call generate-design after this variant pick, do not repeat delete/snapshot cycles, do not create index.html, and do not resend a huge payload. Stop after the first successful edit-design save.`,
620
+ `Then call get-design-snapshot exactly once with designId ${designId} and fileId ${screen.id} (filename ${screen.filename}), then call edit-design with fileId ${screen.id} on that same kept file in a bounded single-file pass. Use mode "replace-file" to replace the representative direction screen with a complete but compact requested app/product UI in the chosen visual style. Prioritize the primary workflow; if the full feature list is too large for one reliable edit, render secondary details as visible controls, states, or affordances instead of expanding the action input. The final saved screen must be the actual usable UI requested by the user, not a direction board, variant brief, summary card, or description of the direction. Do not call generate-design after this variant pick, do not repeat delete/snapshot cycles, do not create index.html, and do not resend a huge payload. Stop after the first successful edit-design save.`,
617
621
  };
618
622
  }),
619
623
  },
@@ -631,7 +635,7 @@ export default defineAction({
631
635
  embed: true,
632
636
  fallbackInstructions: FALLBACK_INSTRUCTIONS,
633
637
  nextRequiredAction:
634
- 'Wait for the user to pick a screen in chat. Then delete each unchosen variant screen with delete-file at most once, call get-design-snapshot exactly once with fileId for the chosen screen, and call edit-design with that same fileId in a bounded pass. Use mode "replace-file" to replace the representative direction screen with the full requested app/product UI in the chosen visual style. Do not leave a direction board, variant brief, or summary card as the final result. Do not repeat delete/snapshot cycles. Do not call generate-design after a variant pick. Stop after the first successful edit-design save.',
638
+ 'Wait for the user to pick a screen in chat. Then delete each unchosen variant screen with delete-file at most once, call get-design-snapshot exactly once with fileId for the chosen screen, and call edit-design with that same fileId in a bounded pass. Use mode "replace-file" to replace the representative direction screen with a complete but compact requested app/product UI in the chosen visual style. Prioritize the primary workflow and render secondary details as visible controls, states, or affordances if the full feature list is too large for one reliable edit. Do not leave a direction board, variant brief, or summary card as the final result. Do not repeat delete/snapshot cycles. Do not call generate-design after a variant pick. Stop after the first successful edit-design save.',
635
639
  };
636
640
  },
637
641
  link: ({ result }) => {