@agent-native/core 0.80.10 → 0.80.11

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 (66) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  6. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  7. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  8. package/corpus/templates/assets/AGENTS.md +4 -0
  9. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  10. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  11. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  12. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  13. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  14. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  15. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  16. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  17. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  18. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  19. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  20. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  21. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  22. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  23. package/corpus/templates/assets/shared/api.ts +4 -0
  24. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  25. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  26. package/corpus/templates/design/actions/apply-component-prop-edit.ts +13 -5
  27. package/corpus/templates/design/actions/apply-design-state.ts +17 -4
  28. package/corpus/templates/design/actions/apply-motion-edit.ts +9 -46
  29. package/corpus/templates/design/actions/delete-design.ts +20 -0
  30. package/corpus/templates/design/actions/get-component-details.ts +16 -7
  31. package/corpus/templates/design/actions/index-design-tokens.ts +8 -4
  32. package/corpus/templates/design/actions/list-design-states.ts +19 -1
  33. package/corpus/templates/design/actions/open-component-source.ts +7 -2
  34. package/corpus/templates/design/actions/preview-component-prop-edit.ts +32 -26
  35. package/corpus/templates/design/actions/preview-shader-fill.ts +9 -11
  36. package/corpus/templates/design/actions/run-design-audit.ts +21 -6
  37. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +139 -14
  38. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +20 -3
  39. package/corpus/templates/design/app/components/design/EditPanel.tsx +428 -31
  40. package/corpus/templates/design/app/components/design/LayersPanel.tsx +110 -30
  41. package/corpus/templates/design/app/components/design/MotionDock.tsx +9 -0
  42. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1 -1
  43. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +12 -5
  44. package/corpus/templates/design/app/components/design/StatesPanel.tsx +4 -2
  45. package/corpus/templates/design/app/components/design/TokensPanel.tsx +13 -8
  46. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  47. package/corpus/templates/design/app/i18n-data.ts +11 -0
  48. package/corpus/templates/design/app/pages/DesignEditor.tsx +684 -179
  49. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  50. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  51. package/corpus/templates/design/shared/component-model.ts +35 -0
  52. package/corpus/templates/design/shared/motion-compiler.ts +79 -8
  53. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  54. package/dist/agent/production-agent.d.ts.map +1 -1
  55. package/dist/agent/production-agent.js +94 -10
  56. package/dist/agent/production-agent.js.map +1 -1
  57. package/dist/client/AssistantChat.d.ts +14 -0
  58. package/dist/client/AssistantChat.d.ts.map +1 -1
  59. package/dist/client/AssistantChat.js +115 -22
  60. package/dist/client/AssistantChat.js.map +1 -1
  61. package/dist/client/sse-event-processor.d.ts.map +1 -1
  62. package/dist/client/sse-event-processor.js +10 -0
  63. package/dist/client/sse-event-processor.js.map +1 -1
  64. package/dist/observability/routes.d.ts +3 -3
  65. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  66. package/package.json +1 -1
@@ -11,6 +11,7 @@ import {
11
11
  rgbaToHex,
12
12
  withColorOpacity,
13
13
  } from "@shared/color-utils";
14
+ import { propNameToDataAttribute } from "@shared/component-model";
14
15
  import {
15
16
  IconAlignCenter,
16
17
  IconAlignJustified,
@@ -69,6 +70,7 @@ import {
69
70
  type ReactNode,
70
71
  } from "react";
71
72
  import { useParams } from "react-router";
73
+ import { toast } from "sonner";
72
74
 
73
75
  import { Button } from "@/components/ui/button";
74
76
  import {
@@ -186,6 +188,11 @@ interface EditPanelProps {
186
188
  // -------------------------------------------------------------------------
187
189
  /** The active design's id — required to mount Tokens / States / Review. */
188
190
  designId?: string;
191
+ /**
192
+ * Called after a component prop edit returns the patched source so the parent
193
+ * editor can sync local/Yjs content instead of waiting for query invalidation.
194
+ */
195
+ onComponentPropApplied?: (fileId: string, content: string) => void;
189
196
  /**
190
197
  * Called after a token edit is applied so the parent can push the resolved
191
198
  * CSS-var map into the iframe via the tweak-values postMessage.
@@ -2149,6 +2156,292 @@ export function serializeAlpineDataObject(obj: Record<string, string>): string {
2149
2156
  return parts.length ? `{ ${parts.join(", ")} }` : "{}";
2150
2157
  }
2151
2158
 
2159
+ /**
2160
+ * Format a single editable prop value as an `x-data` literal: bare for
2161
+ * boolean / number values, single-quoted (with escaping) for strings.
2162
+ *
2163
+ * Pure — exported for tests.
2164
+ */
2165
+ export function alpineDataValueLiteral(value: string): string {
2166
+ const isBoolean = value === "true" || value === "false";
2167
+ const isNumber = /^-?\d+(\.\d+)?$/.test(value);
2168
+ return isBoolean || isNumber ? value : `'${value.replace(/'/g, "\\'")}'`;
2169
+ }
2170
+
2171
+ /**
2172
+ * Surgically replace a single top-level key's value inside an Alpine `x-data`
2173
+ * object literal, preserving everything else byte-for-byte — methods
2174
+ * (`toggle() { … }`), nested objects, escaped strings, quoted keys, comments,
2175
+ * and whitespace are all left untouched.
2176
+ *
2177
+ * Unlike a `parseAlpineDataObject` → mutate → `serializeAlpineDataObject`
2178
+ * round-trip (which only understands a flat object of simple literals and so
2179
+ * *drops* anything it can't model), this walks the original string, finds the
2180
+ * `key:` token at the top level (depth 0, not inside a string/comment), and
2181
+ * rewrites only the value literal that immediately follows it.
2182
+ *
2183
+ * Returns `null` when the key cannot be located surgically (e.g. the value is
2184
+ * an expression/function/object rather than a simple string/boolean/number, or
2185
+ * the literal isn't a `{ … }` object) so the caller can fail safe instead of
2186
+ * persisting a lossy rewrite.
2187
+ *
2188
+ * Pure — exported for tests.
2189
+ */
2190
+ export function replaceAlpineDataKeyValue(
2191
+ xData: string | null | undefined,
2192
+ key: string,
2193
+ nextValue: string,
2194
+ ): string | null {
2195
+ if (!xData) return null;
2196
+ const trimmed = xData.trim();
2197
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
2198
+
2199
+ const s = xData;
2200
+ const n = s.length;
2201
+ // Walk the whole string tracking nesting depth and skipping over strings,
2202
+ // template literals, regex-ish slashes are not handled (Alpine x-data does
2203
+ // not use them at the object-key level), and line / block comments. Only at
2204
+ // object depth 1 (directly inside the outermost `{ … }`) do we look for the
2205
+ // target `key :` token.
2206
+ let depth = 0;
2207
+ let i = 0;
2208
+
2209
+ /** Advance `i` past a quoted string starting at `i` (handles escapes). */
2210
+ const skipString = (quote: string): void => {
2211
+ i += 1; // opening quote
2212
+ while (i < n) {
2213
+ const c = s[i];
2214
+ if (c === "\\") {
2215
+ i += 2;
2216
+ continue;
2217
+ }
2218
+ if (c === quote) {
2219
+ i += 1;
2220
+ return;
2221
+ }
2222
+ i += 1;
2223
+ }
2224
+ };
2225
+
2226
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2227
+ // Bare identifier key at a token boundary: `key` then optional ws then `:`.
2228
+ const bareRe = new RegExp(`^(${escapedKey})(\\s*:\\s*)`);
2229
+ // Quoted key: `'key'` or `"key"` then optional ws then `:`.
2230
+ const quotedRe = new RegExp(`^(['"]${escapedKey}['"])(\\s*:\\s*)`);
2231
+
2232
+ while (i < n) {
2233
+ const c = s[i];
2234
+
2235
+ // At the top level, a `{` / `,` (or the string start) opens a fresh value
2236
+ // slot. Try to match the target key here *before* treating a quote as an
2237
+ // opaque string — this is how quoted keys (`'size': …`) are recognised.
2238
+ if (depth === 1) {
2239
+ const prev = lastNonSpaceBefore(s, i);
2240
+ if (prev === "{" || prev === ",") {
2241
+ const rest = s.slice(i);
2242
+ const m = bareRe.exec(rest) ?? quotedRe.exec(rest);
2243
+ if (m) {
2244
+ const valueStart = i + m[1].length + m[2].length;
2245
+ const valueEnd = simpleValueEnd(s, valueStart);
2246
+ if (valueEnd === null) return null; // value is not a simple literal
2247
+ return (
2248
+ s.slice(0, valueStart) +
2249
+ alpineDataValueLiteral(nextValue) +
2250
+ s.slice(valueEnd)
2251
+ );
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ // Skip strings / template literals wholesale.
2257
+ if (c === '"' || c === "'" || c === "`") {
2258
+ skipString(c);
2259
+ continue;
2260
+ }
2261
+ // Skip comments.
2262
+ if (c === "/" && s[i + 1] === "/") {
2263
+ i += 2;
2264
+ while (i < n && s[i] !== "\n") i += 1;
2265
+ continue;
2266
+ }
2267
+ if (c === "/" && s[i + 1] === "*") {
2268
+ i += 2;
2269
+ while (i < n && !(s[i] === "*" && s[i + 1] === "/")) i += 1;
2270
+ i += 2;
2271
+ continue;
2272
+ }
2273
+
2274
+ if (c === "{" || c === "[" || c === "(") {
2275
+ depth += 1;
2276
+ i += 1;
2277
+ continue;
2278
+ }
2279
+ if (c === "}" || c === "]" || c === ")") {
2280
+ depth -= 1;
2281
+ i += 1;
2282
+ continue;
2283
+ }
2284
+
2285
+ i += 1;
2286
+ }
2287
+
2288
+ return null;
2289
+ }
2290
+
2291
+ /** Last non-whitespace char strictly before index `i` (or `""`). */
2292
+ function lastNonSpaceBefore(s: string, i: number): string {
2293
+ let j = i - 1;
2294
+ while (j >= 0 && /\s/.test(s[j]!)) j -= 1;
2295
+ return j >= 0 ? s[j]! : "";
2296
+ }
2297
+
2298
+ /**
2299
+ * Given the start index of a value in an `x-data` literal, return the index
2300
+ * just past a *simple* literal value (single/double-quoted string with
2301
+ * escapes, boolean, or number). Returns `null` when the value is anything else
2302
+ * (an expression, function, object, array, template literal, etc.) so the
2303
+ * caller can fail safe rather than mangle it.
2304
+ */
2305
+ function simpleValueEnd(s: string, start: number): number | null {
2306
+ const c = s[start];
2307
+ if (c === "'" || c === '"') {
2308
+ let i = start + 1;
2309
+ while (i < s.length) {
2310
+ if (s[i] === "\\") {
2311
+ i += 2;
2312
+ continue;
2313
+ }
2314
+ if (s[i] === c) return i + 1;
2315
+ i += 1;
2316
+ }
2317
+ return null; // unterminated string
2318
+ }
2319
+ // Boolean / number: read the bare token, then confirm it is exactly one.
2320
+ const m = /^[A-Za-z0-9_.+-]+/.exec(s.slice(start));
2321
+ if (!m) return null;
2322
+ const token = m[0];
2323
+ const isBoolean = token === "true" || token === "false";
2324
+ const isNumber = /^-?\d+(\.\d+)?$/.test(token);
2325
+ if (!isBoolean && !isNumber) return null;
2326
+ return start + token.length;
2327
+ }
2328
+
2329
+ /**
2330
+ * True when an `x-data` literal can be rebuilt from its flat parsed map with
2331
+ * no loss — i.e. there is nothing richer than the simple `key: literal` pairs
2332
+ * that `serializeAlpineDataObject` already round-trips. Used as the gate for
2333
+ * falling back to a full rebuild when a surgical single-key replace is not
2334
+ * possible (e.g. when adding a brand-new key).
2335
+ *
2336
+ * Returns `true` for an empty / absent literal (nothing to lose) and for a
2337
+ * flat object whose `parse → serialize` round-trip is semantically stable.
2338
+ * Returns `false` when the original holds methods, nested objects, comments,
2339
+ * or expressions that a rebuild would silently drop.
2340
+ *
2341
+ * Pure — exported for tests.
2342
+ */
2343
+ export function canRebuildAlpineDataLosslessly(
2344
+ xData: string | null | undefined,
2345
+ ): boolean {
2346
+ const trimmed = (xData ?? "").trim();
2347
+ // No object literal at all → there is nothing richer to preserve.
2348
+ if (!trimmed || trimmed === "{}" || trimmed === "{ }") return true;
2349
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
2350
+
2351
+ const parsed = parseAlpineDataObject(trimmed);
2352
+ if (!parsed) return false;
2353
+
2354
+ // Re-serialize and re-parse; if the round-trip is stable AND the parsed map
2355
+ // accounts for every top-level key actually present in the original, a
2356
+ // rebuild loses nothing.
2357
+ const reserialized = serializeAlpineDataObject(parsed);
2358
+ const reparsed = parseAlpineDataObject(reserialized);
2359
+ if (!reparsed) return false;
2360
+ const keysA = Object.keys(parsed).sort().join(",");
2361
+ const keysB = Object.keys(reparsed).sort().join(",");
2362
+ if (keysA !== keysB) return false;
2363
+
2364
+ // Guard against dropped content the flat parser ignores (e.g. a trailing
2365
+ // method): the number of top-level `key:` tokens in the original must match
2366
+ // the number of parsed keys. Count top-level `:` separators conservatively.
2367
+ return countTopLevelKeys(trimmed) === Object.keys(parsed).length;
2368
+ }
2369
+
2370
+ /**
2371
+ * Count top-level `key:` entries in an `x-data` object literal, skipping
2372
+ * strings, comments, and nested braces/brackets/parens. A method like
2373
+ * `toggle() { … }` is counted as a key too (its `:`-less form still occupies a
2374
+ * top-level slot), so a mismatch against the flat parser's key count reveals
2375
+ * dropped content.
2376
+ */
2377
+ function countTopLevelKeys(xData: string): number {
2378
+ const s = xData;
2379
+ const n = s.length;
2380
+ let depth = 0;
2381
+ let i = 0;
2382
+ let count = 0;
2383
+ let sawTokenInSlot = false;
2384
+
2385
+ const skipString = (quote: string): void => {
2386
+ i += 1;
2387
+ while (i < n) {
2388
+ if (s[i] === "\\") {
2389
+ i += 2;
2390
+ continue;
2391
+ }
2392
+ if (s[i] === quote) {
2393
+ i += 1;
2394
+ return;
2395
+ }
2396
+ i += 1;
2397
+ }
2398
+ };
2399
+
2400
+ while (i < n) {
2401
+ const c = s[i]!;
2402
+ if (c === '"' || c === "'" || c === "`") {
2403
+ if (depth === 1) sawTokenInSlot = true;
2404
+ skipString(c);
2405
+ continue;
2406
+ }
2407
+ if (c === "/" && s[i + 1] === "/") {
2408
+ i += 2;
2409
+ while (i < n && s[i] !== "\n") i += 1;
2410
+ continue;
2411
+ }
2412
+ if (c === "/" && s[i + 1] === "*") {
2413
+ i += 2;
2414
+ while (i < n && !(s[i] === "*" && s[i + 1] === "/")) i += 1;
2415
+ i += 2;
2416
+ continue;
2417
+ }
2418
+ if (c === "{" || c === "[" || c === "(") {
2419
+ depth += 1;
2420
+ i += 1;
2421
+ continue;
2422
+ }
2423
+ if (c === "}" || c === "]" || c === ")") {
2424
+ if (depth === 1 && c === "}" && sawTokenInSlot) {
2425
+ count += 1;
2426
+ sawTokenInSlot = false;
2427
+ }
2428
+ depth -= 1;
2429
+ i += 1;
2430
+ continue;
2431
+ }
2432
+ if (depth === 1) {
2433
+ if (c === ",") {
2434
+ if (sawTokenInSlot) count += 1;
2435
+ sawTokenInSlot = false;
2436
+ } else if (!/\s/.test(c)) {
2437
+ sawTokenInSlot = true;
2438
+ }
2439
+ }
2440
+ i += 1;
2441
+ }
2442
+ return count;
2443
+ }
2444
+
2152
2445
  /** A boolean-ish prop value (`"true"` / `"false"`), case-insensitive. */
2153
2446
  export function isBooleanPropValue(value: string): boolean {
2154
2447
  const v = value.trim().toLowerCase();
@@ -5372,6 +5665,7 @@ function MakeItRealCard({
5372
5665
  * typed here; the action may return additional fields.
5373
5666
  */
5374
5667
  interface ComponentDetailsResult {
5668
+ nodeId: string;
5375
5669
  name: string;
5376
5670
  sourceType: string;
5377
5671
  observedProps: Array<{ name: string; value: string }>;
@@ -5405,32 +5699,57 @@ interface ComponentDetailsResult {
5405
5699
  *
5406
5700
  * Matches the workbench artboard spec in DESIGN-STUDIO-PLAN.md §6.1.
5407
5701
  */
5408
- function ComponentSection({
5702
+ export function ComponentSection({
5409
5703
  designId,
5704
+ fileId,
5410
5705
  nodeId,
5706
+ onComponentPropApplied,
5411
5707
  sourceCapabilities = [],
5412
5708
  }: {
5413
5709
  designId: string;
5710
+ fileId?: string;
5414
5711
  nodeId: string;
5712
+ onComponentPropApplied?: (fileId: string, content: string) => void;
5415
5713
  /** Capability names advertised by the current source. */
5416
5714
  sourceCapabilities?: string[];
5417
5715
  }) {
5418
5716
  const queryClient = useQueryClient();
5419
- const detailsKey = ["action", "get-component-details", { designId, nodeId }];
5420
-
5421
- const { data, isLoading, error } = useActionQuery<ComponentDetailsResult>(
5422
- "get-component-details",
5423
- { designId, nodeId },
5424
- );
5717
+ const detailsParams = { designId, nodeId, ...(fileId ? { fileId } : {}) };
5718
+ const detailsKey = ["action", "get-component-details", detailsParams];
5719
+
5720
+ const { data, isLoading, error, refetch } =
5721
+ useActionQuery<ComponentDetailsResult>(
5722
+ "get-component-details",
5723
+ detailsParams,
5724
+ { refetchOnMount: "always" },
5725
+ );
5425
5726
 
5426
5727
  const openSourceMutation = useActionMutation("open-component-source");
5427
5728
  const applyPropMutation = useActionMutation("apply-component-prop-edit");
5428
5729
 
5429
- // Persist a single prop change through apply-component-prop-edit. The canvas
5430
- // re-renders from the freshly written design content (get-design refetch),
5431
- // so no postMessage into the iframe is needed. The get-component-details
5432
- // cache is optimistically patched so the control reflects the new value
5433
- // immediately, then reconciled with the server on settle.
5730
+ const postComponentPropPreview = useCallback(
5731
+ (attribute: string, value: string) => {
5732
+ if (typeof document === "undefined") return;
5733
+
5734
+ const iframe = document.querySelector<HTMLIFrameElement>(
5735
+ "iframe[data-design-preview-iframe]",
5736
+ );
5737
+ iframe?.contentWindow?.postMessage(
5738
+ {
5739
+ type: "style-change",
5740
+ selector: data?.instance?.selector ?? "",
5741
+ nodeId: data?.instance?.nodeId ?? nodeId,
5742
+ attributeOverrides: { [attribute]: value },
5743
+ },
5744
+ "*",
5745
+ );
5746
+ },
5747
+ [data?.instance?.nodeId, data?.instance?.selector, nodeId],
5748
+ );
5749
+
5750
+ // Persist a single prop change through apply-component-prop-edit. Attribute
5751
+ // props also preview immediately in the iframe so the selected component
5752
+ // changes without waiting for the write/refetch round-trip.
5434
5753
  const persistPropEdit = (
5435
5754
  edit:
5436
5755
  | { kind: "alpineData"; value: string }
@@ -5440,20 +5759,51 @@ function ComponentSection({
5440
5759
  queryClient.setQueryData<ComponentDetailsResult>(detailsKey, (prev) =>
5441
5760
  prev ? optimistic(prev) : prev,
5442
5761
  );
5762
+ if (edit.kind === "attribute") {
5763
+ postComponentPropPreview(edit.attribute, edit.value);
5764
+ }
5443
5765
  applyPropMutation.mutate(
5444
- { designId, nodeId, edit },
5766
+ { designId, nodeId, ...(fileId ? { fileId } : {}), edit },
5445
5767
  {
5768
+ onSuccess: (result) => {
5769
+ const response = result as {
5770
+ content?: unknown;
5771
+ fileId?: unknown;
5772
+ };
5773
+ if (
5774
+ typeof response.fileId === "string" &&
5775
+ typeof response.content === "string"
5776
+ ) {
5777
+ onComponentPropApplied?.(response.fileId, response.content);
5778
+ }
5779
+ },
5446
5780
  onSettled: () => {
5447
- // Re-render the canvas from the new content and re-read the props.
5448
5781
  void queryClient.invalidateQueries({
5449
5782
  queryKey: ["action", "get-design"],
5450
5783
  });
5451
5784
  void queryClient.invalidateQueries({ queryKey: detailsKey });
5785
+ void refetch();
5452
5786
  },
5453
5787
  },
5454
5788
  );
5455
5789
  };
5456
5790
 
5791
+ useEffect(() => {
5792
+ if (typeof document === "undefined") return;
5793
+
5794
+ const handleMessage = (event: MessageEvent) => {
5795
+ if (
5796
+ (event.data as { type?: unknown } | null)?.type === "element-select"
5797
+ ) {
5798
+ void refetch();
5799
+ }
5800
+ };
5801
+ window.addEventListener("message", handleMessage);
5802
+ return () => {
5803
+ window.removeEventListener("message", handleMessage);
5804
+ };
5805
+ }, [refetch]);
5806
+
5457
5807
  // While loading, show a compact skeleton that matches the section width.
5458
5808
  if (isLoading) {
5459
5809
  return (
@@ -5491,7 +5841,7 @@ function ComponentSection({
5491
5841
  // Real-app sources keep the deeper source-prop controls gated as-is, so for
5492
5842
  // non-inline sources the controls are read-only here.
5493
5843
  const isInline = sourceType === "inline";
5494
- const editingEnabled = isInline; // gated; real-app stays read-only for now
5844
+ const editingEnabled = isInline && capabilities.canEditProps; // gated; real-app stays read-only for now
5495
5845
  const alpineData = parseAlpineDataObject(instance?.alpineData);
5496
5846
 
5497
5847
  // Each editable row: name + current value + how it persists + its options.
@@ -5552,22 +5902,51 @@ function ComponentSection({
5552
5902
  if (!editingEnabled || nextValue === row.value) return;
5553
5903
 
5554
5904
  if (row.surface === "alpineData") {
5555
- const nextData = { ...(alpineData ?? {}), [row.name]: nextValue };
5556
- const serialized = serializeAlpineDataObject(nextData);
5557
- persistPropEdit({ kind: "alpineData", value: serialized }, (prev) => ({
5558
- ...prev,
5559
- instance: { ...(prev.instance ?? {}), alpineData: serialized },
5560
- observedProps: prev.observedProps.map((p) =>
5561
- p.name === row.name ? { ...p, value: nextValue } : p,
5562
- ),
5563
- }));
5905
+ // Surgically replace only the edited key's value inside the original
5906
+ // x-data string so methods, nested objects, escaped strings, quoted
5907
+ // keys, and whitespace survive byte-for-byte. A full
5908
+ // parse→mutate→serialize round-trip would drop anything
5909
+ // parseAlpineDataObject can't model (e.g. `toggle() { }`).
5910
+ const original = instance?.alpineData ?? "";
5911
+ const surgical = replaceAlpineDataKeyValue(original, row.name, nextValue);
5912
+
5913
+ let serialized: string;
5914
+ if (surgical != null) {
5915
+ serialized = surgical;
5916
+ } else if (canRebuildAlpineDataLosslessly(original)) {
5917
+ // The key isn't present yet (or there is no original literal). Rebuild
5918
+ // from the flat map — safe here precisely because the original holds
5919
+ // nothing richer than the flat literals serialize already preserves.
5920
+ const nextData = { ...(alpineData ?? {}), [row.name]: nextValue };
5921
+ serialized = serializeAlpineDataObject(nextData);
5922
+ } else {
5923
+ // The original carries content (methods / nested / expressions) we
5924
+ // can't rewrite for this key without dropping it. Fail safe: skip the
5925
+ // edit rather than persist a lossy rewrite, and tell the user why so
5926
+ // the change doesn't silently vanish.
5927
+ toast.error(
5928
+ // i18n-ignore
5929
+ "Can’t safely edit this prop inline — this component’s Alpine state is too complex. Edit the source instead.",
5930
+ );
5931
+ return;
5932
+ }
5933
+
5934
+ const nextSerialized = serialized;
5935
+ persistPropEdit(
5936
+ { kind: "alpineData", value: nextSerialized },
5937
+ (prev) => ({
5938
+ ...prev,
5939
+ instance: { ...(prev.instance ?? {}), alpineData: nextSerialized },
5940
+ observedProps: prev.observedProps.map((p) =>
5941
+ p.name === row.name ? { ...p, value: nextValue } : p,
5942
+ ),
5943
+ }),
5944
+ );
5564
5945
  } else {
5565
- // camelCase prop name → data-agent-native-prop-<kebab>
5566
- const kebab = row.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
5567
5946
  persistPropEdit(
5568
5947
  {
5569
5948
  kind: "attribute",
5570
- attribute: `data-agent-native-prop-${kebab}`,
5949
+ attribute: propNameToDataAttribute(row.name),
5571
5950
  value: nextValue,
5572
5951
  },
5573
5952
  (prev) => {
@@ -5624,7 +6003,11 @@ function ComponentSection({
5624
6003
  "Edit component source" /* i18n-ignore design inspector action */
5625
6004
  }
5626
6005
  onClick={() => {
5627
- openSourceMutation.mutate({ designId, nodeId });
6006
+ openSourceMutation.mutate({
6007
+ designId,
6008
+ nodeId,
6009
+ ...(fileId ? { fileId } : {}),
6010
+ });
5628
6011
  }}
5629
6012
  >
5630
6013
  <IconExternalLink className="size-3.5" />
@@ -5667,6 +6050,7 @@ function ComponentSection({
5667
6050
  {rows.map((row) => {
5668
6051
  const hasOptions = (row.options?.length ?? 0) > 0;
5669
6052
  const isBoolean = !hasOptions && isBooleanPropValue(row.value);
6053
+ const disabled = !editingEnabled || applyPropMutation.isPending;
5670
6054
  return (
5671
6055
  <div key={row.name} className="flex items-center gap-1.5">
5672
6056
  <Label className="w-[64px] shrink-0 truncate text-[11px] font-medium capitalize text-muted-foreground">
@@ -5677,7 +6061,7 @@ function ComponentSection({
5677
6061
  <Select
5678
6062
  value={row.value || row.options![0] || ""}
5679
6063
  onValueChange={(v) => commitProp(row, v)}
5680
- disabled={!editingEnabled}
6064
+ disabled={disabled}
5681
6065
  >
5682
6066
  <SelectTrigger className="h-6 min-w-0 flex-1 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
5683
6067
  <SelectValue />
@@ -5702,7 +6086,7 @@ function ComponentSection({
5702
6086
  onCheckedChange={(checked) =>
5703
6087
  commitProp(row, checked ? "true" : "false")
5704
6088
  }
5705
- disabled={!editingEnabled}
6089
+ disabled={disabled}
5706
6090
  className="h-4 w-7 [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3"
5707
6091
  aria-label={
5708
6092
  row.name /* i18n-ignore dynamic prop name */
@@ -5714,7 +6098,7 @@ function ComponentSection({
5714
6098
  <Input
5715
6099
  defaultValue={row.value}
5716
6100
  key={`${row.name}:${row.value}`}
5717
- disabled={!editingEnabled}
6101
+ disabled={disabled}
5718
6102
  onBlur={(e) => commitProp(row, e.target.value)}
5719
6103
  onKeyDown={(e) => {
5720
6104
  if (e.key === "Enter") {
@@ -5764,6 +6148,7 @@ export function EditPanel({
5764
6148
  fileId,
5765
6149
  filename,
5766
6150
  designId,
6151
+ onComponentPropApplied,
5767
6152
  onTokensApplied,
5768
6153
  statesPanelProps,
5769
6154
  reviewPanelProps,
@@ -5825,6 +6210,7 @@ export function EditPanel({
5825
6210
  // over-scroll bounce, could briefly un-shield the canvas and allow a stray
5826
6211
  // pointer event to deselect the selected canvas element (R3 regression).
5827
6212
  const scrolledRecentlyRef = useRef(false);
6213
+ const userScrollIntentRef = useRef(false);
5828
6214
  const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
5829
6215
 
5830
6216
  if (readOnly) {
@@ -5904,7 +6290,14 @@ export function EditPanel({
5904
6290
 
5905
6291
  <div
5906
6292
  className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain"
6293
+ onWheelCapture={() => {
6294
+ userScrollIntentRef.current = true;
6295
+ }}
6296
+ onTouchMoveCapture={() => {
6297
+ userScrollIntentRef.current = true;
6298
+ }}
5907
6299
  onScroll={() => {
6300
+ if (!userScrollIntentRef.current) return;
5908
6301
  // Mark that a scroll just happened so the click that some
5909
6302
  // browsers fire at the end of a scroll gesture (or after an
5910
6303
  // overscroll/rubber-band bounce) is suppressed. Crucially this
@@ -5919,6 +6312,7 @@ export function EditPanel({
5919
6312
  }
5920
6313
  scrollTimerRef.current = setTimeout(() => {
5921
6314
  scrolledRecentlyRef.current = false;
6315
+ userScrollIntentRef.current = false;
5922
6316
  scrollTimerRef.current = null;
5923
6317
  }, 300);
5924
6318
  }}
@@ -5930,6 +6324,7 @@ export function EditPanel({
5930
6324
  // browsers generate after a touch-scroll ends.
5931
6325
  if (!scrolledRecentlyRef.current) return;
5932
6326
  scrolledRecentlyRef.current = false;
6327
+ userScrollIntentRef.current = false;
5933
6328
  if (scrollTimerRef.current !== null) {
5934
6329
  clearTimeout(scrollTimerRef.current);
5935
6330
  scrollTimerRef.current = null;
@@ -5971,7 +6366,9 @@ export function EditPanel({
5971
6366
  {designId && componentNodeId && (
5972
6367
  <ComponentSection
5973
6368
  designId={designId}
6369
+ fileId={fileId}
5974
6370
  nodeId={componentNodeId}
6371
+ onComponentPropApplied={onComponentPropApplied}
5975
6372
  sourceCapabilities={sourceCapabilities}
5976
6373
  />
5977
6374
  )}