@agent-native/core 0.182.1 → 0.182.2-nightly-20260917225811

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 (119) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/root.tsx +59 -10
  3. package/corpus/templates/clips/changelog/2026-09-17-desktop-popover-closes-when-clicking-away.md +6 -0
  4. package/corpus/templates/clips/changelog/2026-09-17-self-hosted-extension-sign-in.md +6 -0
  5. package/corpus/templates/clips/chrome-extension/src/background.ts +45 -25
  6. package/corpus/templates/clips/chrome-extension/src/content-script.ts +43 -0
  7. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +55 -6
  8. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +107 -19
  9. package/corpus/templates/design/.generated/bridge/hit-test.generated.ts +1 -0
  10. package/corpus/templates/design/actions/add-localhost-screens.ts +54 -22
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +174 -14
  12. package/corpus/templates/design/actions/apply-visual-edit.ts +3 -1
  13. package/corpus/templates/design/actions/create-component.ts +173 -12
  14. package/corpus/templates/design/actions/create-design-from-template.ts +50 -35
  15. package/corpus/templates/design/actions/create-design-version.ts +21 -0
  16. package/corpus/templates/design/actions/create-file.ts +35 -33
  17. package/corpus/templates/design/actions/delete-design.ts +29 -28
  18. package/corpus/templates/design/actions/delete-file.ts +154 -23
  19. package/corpus/templates/design/actions/generate-design.ts +28 -16
  20. package/corpus/templates/design/actions/get-component-details.ts +272 -33
  21. package/corpus/templates/design/actions/get-design-version.ts +9 -1
  22. package/corpus/templates/design/actions/go-to-main-component.ts +37 -35
  23. package/corpus/templates/design/actions/index-components.ts +241 -126
  24. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +12 -9
  25. package/corpus/templates/design/actions/present-design-variants.ts +68 -72
  26. package/corpus/templates/design/actions/rename-component.ts +294 -0
  27. package/corpus/templates/design/actions/rename-screen.ts +10 -10
  28. package/corpus/templates/design/actions/update-file.ts +327 -330
  29. package/corpus/templates/design/actions/update-screen-source.ts +2 -43
  30. package/corpus/templates/design/app/components/design/EditPanel.tsx +17 -1
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +228 -24
  32. package/corpus/templates/design/app/components/design/ResponsiveInteractBar.tsx +17 -1
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +151 -32
  34. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +4 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +1 -1
  36. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +8 -1
  37. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +55 -33
  38. package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +2 -0
  39. package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +4 -2
  40. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +273 -60
  41. package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +9 -2
  42. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +57 -9
  43. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +13 -0
  44. package/corpus/templates/design/app/components/design/multi-screen/types.ts +14 -0
  45. package/corpus/templates/design/app/components/design/types.ts +9 -1
  46. package/corpus/templates/design/app/hooks/use-design-versions.ts +3 -2
  47. package/corpus/templates/design/app/i18n-breakpoints.ts +0 -110
  48. package/corpus/templates/design/app/i18n-data.ts +0 -10
  49. package/corpus/templates/design/app/pages/DesignEditor.tsx +669 -161
  50. package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +20 -0
  51. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +2 -0
  52. package/corpus/templates/design/app/pages/design-editor/commands/apply-design-editor-command.ts +21 -3
  53. package/corpus/templates/design/app/pages/design-editor/commands/apply-file-content-update.ts +2 -0
  54. package/corpus/templates/design/app/pages/design-editor/commands/create-component.ts +44 -3
  55. package/corpus/templates/design/app/pages/design-editor/commands/delete-files.ts +69 -47
  56. package/corpus/templates/design/app/pages/design-editor/commands/duplicate-screen.ts +157 -52
  57. package/corpus/templates/design/app/pages/design-editor/commands/duplicate-selection.ts +76 -1
  58. package/corpus/templates/design/app/pages/design-editor/commands/redo.ts +152 -44
  59. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +131 -2
  60. package/corpus/templates/design/app/pages/design-editor/commands/screen-visual-duplicate-change.ts +5 -1
  61. package/corpus/templates/design/app/pages/design-editor/commands/undo.ts +26 -1
  62. package/corpus/templates/design/app/pages/design-editor/commands/visual-duplicate-change.ts +1 -0
  63. package/corpus/templates/design/app/pages/design-editor/editor-helpers.ts +58 -8
  64. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +63 -2
  65. package/corpus/templates/design/app/pages/design-editor/history.ts +3 -0
  66. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +74 -22
  67. package/corpus/templates/design/app/pages/design-editor/source-publication.ts +14 -2
  68. package/corpus/templates/design/changelog/2026-09-16-design-components-retain-linked-identities-when-edited-and-d.md +6 -0
  69. package/corpus/templates/design/changelog/2026-09-16-fix-design-nested-layer-dragging-and-screen-duplication-pari.md +6 -0
  70. package/corpus/templates/design/changelog/2026-09-17-design-drag-and-drop-keeps-duplicates-cross-screen-moves-and.md +6 -0
  71. package/corpus/templates/design/changelog/2026-09-17-design-preserves-reusable-component-identity-and-props-acros.md +6 -0
  72. package/corpus/templates/design/changelog/2026-09-17-open-vector-paths-no-longer-show-fills-and-interact-dimensio.md +6 -0
  73. package/corpus/templates/design/changelog/2026-09-17-screen-deletion-is-immediate-and-undoable-with-history-that-.md +6 -0
  74. package/corpus/templates/design/e2e/fixtures/design-component-react/index.html +98 -0
  75. package/corpus/templates/design/e2e/fixtures/design-component-react/inline-component.html +65 -0
  76. package/corpus/templates/design/e2e/helpers.ts +6 -1
  77. package/corpus/templates/design/server/lib/design-data-mutation.ts +9 -5
  78. package/corpus/templates/design/server/lib/design-snapshot.ts +28 -16
  79. package/corpus/templates/design/server/lib/design-versions.ts +208 -130
  80. package/corpus/templates/design/server/lib/fusion-screens.ts +46 -20
  81. package/corpus/templates/design/server/lib/import-design-files.ts +5 -1
  82. package/corpus/templates/design/server/lib/localhost-connection.ts +54 -0
  83. package/corpus/templates/design/server/source-workspace.ts +412 -356
  84. package/corpus/templates/design/shared/component-library.ts +36 -6
  85. package/corpus/templates/design/shared/component-model.ts +12 -2
  86. package/corpus/templates/design/shared/local-jsx-visual-edit.ts +231 -5
  87. package/corpus/templates/design/shared/source-mode.ts +22 -0
  88. package/dist/automation/index.d.ts +1 -1
  89. package/dist/client/AssistantChat.d.ts +4 -1
  90. package/dist/client/AssistantChat.js +26 -3
  91. package/dist/client/chat/agentkit-protocol.js +148 -34
  92. package/dist/client/chat/message-components.js +7 -5
  93. package/dist/client/chat/tool-call-display.js +4 -3
  94. package/dist/client/conversation/AgentConversation.js +4 -2
  95. package/dist/client/tool-display.d.ts +9 -0
  96. package/dist/client/tool-display.js +15 -0
  97. package/dist/collab/awareness.d.ts +2 -2
  98. package/dist/collab/routes.d.ts +1 -1
  99. package/dist/localization/core-messages/ar-SA.js +2 -0
  100. package/dist/localization/core-messages/de-DE.js +2 -0
  101. package/dist/localization/core-messages/en-US.d.ts +2 -0
  102. package/dist/localization/core-messages/en-US.js +2 -0
  103. package/dist/localization/core-messages/es-ES.js +2 -0
  104. package/dist/localization/core-messages/fr-FR.js +2 -0
  105. package/dist/localization/core-messages/hi-IN.js +2 -0
  106. package/dist/localization/core-messages/ja-JP.js +2 -0
  107. package/dist/localization/core-messages/ko-KR.js +2 -0
  108. package/dist/localization/core-messages/pt-BR.js +2 -0
  109. package/dist/localization/core-messages/zh-CN.js +2 -0
  110. package/dist/localization/core-messages/zh-TW.js +2 -0
  111. package/dist/localization/core-messages.d.ts +2 -0
  112. package/dist/notifications/routes.d.ts +3 -3
  113. package/dist/observability/routes.d.ts +3 -3
  114. package/dist/resources/handlers.d.ts +1 -1
  115. package/dist/secrets/routes.d.ts +3 -3
  116. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  117. package/dist/server/core-routes-plugin.d.ts +1 -1
  118. package/package.json +3 -3
  119. package/corpus/templates/design/app/components/design/editor/PendingScreenDeletionDialog.tsx +0 -66
@@ -43,6 +43,13 @@ export interface DesignSnapshot {
43
43
  resolvedCssVars: Record<string, string>;
44
44
  }
45
45
 
46
+ export interface BuildDesignSnapshotOptions {
47
+ /** Read the persisted SQL rows even when a live collaboration document exists. */
48
+ preferStoredFileContent?: boolean;
49
+ }
50
+
51
+ type SnapshotDatabase = Pick<ReturnType<typeof getDb>, "select">;
52
+
46
53
  function parseDesignData(data?: string | null): Record<string, unknown> {
47
54
  if (!data) return {};
48
55
  try {
@@ -62,8 +69,10 @@ function parseDesignData(data?: string | null): Record<string, unknown> {
62
69
  export async function buildDesignSnapshot(
63
70
  designId: string,
64
71
  designData?: string | null,
72
+ options: BuildDesignSnapshotOptions = {},
73
+ database?: SnapshotDatabase,
65
74
  ): Promise<DesignSnapshot> {
66
- const db = getDb();
75
+ const db = database ?? getDb();
67
76
 
68
77
  const rows = await db
69
78
  .select()
@@ -76,23 +85,26 @@ export async function buildDesignSnapshot(
76
85
  let source: "collab" | "stored" = "stored";
77
86
  // Prefer live collab text when an editing session exists for this file so
78
87
  // an external agent sees in-flight edits, not the last persisted snapshot.
79
- try {
80
- if (await hasCollabState(f.id)) {
81
- const live = await getText(f.id, "content");
82
- if (
83
- typeof live === "string" &&
84
- shouldUseLiveFileContent({
85
- liveContent: live,
86
- storedContent: f.content,
87
- fileType: f.fileType,
88
- })
89
- ) {
90
- content = live;
91
- source = "collab";
88
+ if (!options.preferStoredFileContent) {
89
+ try {
90
+ if (await hasCollabState(f.id)) {
91
+ const live = await getText(f.id, "content");
92
+ if (
93
+ typeof live === "string" &&
94
+ shouldUseLiveFileContent({
95
+ liveContent: live,
96
+ storedContent: f.content,
97
+ fileType: f.fileType,
98
+ })
99
+ ) {
100
+ content = live;
101
+ source = "collab";
102
+ }
92
103
  }
104
+ } catch {
105
+ // coercion-ok: live collaboration is optional; stored content remains explicitly tagged.
106
+ // Collab read is best-effort; fall back to stored content.
93
107
  }
94
- } catch {
95
- // Collab read is best-effort; fall back to stored content.
96
108
  }
97
109
  files.push({
98
110
  id: f.id,
@@ -16,8 +16,8 @@ import {
16
16
  type PrivateBlobHandle,
17
17
  } from "@agent-native/core/private-blob";
18
18
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
19
- import { assertAccess } from "@agent-native/core/sharing";
20
- import { and, asc, desc, eq, isNull } from "drizzle-orm";
19
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
20
+ import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
21
21
  import { nanoid } from "nanoid";
22
22
 
23
23
  import {
@@ -25,7 +25,12 @@ import {
25
25
  type ComponentDeletionGeometry,
26
26
  } from "../../shared/component-archive.js";
27
27
  import { getDb, schema } from "../db/index.js";
28
- import { withSourceFileWriteLock } from "../source-workspace.js";
28
+ import {
29
+ affectedRowCount,
30
+ designSourceMutationLockKey,
31
+ lockDesignFilesTable,
32
+ withSourceFileWriteLock,
33
+ } from "../source-workspace.js";
29
34
  import { buildDesignSnapshot } from "./design-snapshot.js";
30
35
 
31
36
  const CHAT_VERSION_LOOKBACK = 100;
@@ -36,6 +41,7 @@ export interface DesignVersionChatContext {
36
41
  runId?: string;
37
42
  turnId?: string;
38
43
  actionName?: string;
44
+ surface?: "editor";
39
45
  }
40
46
 
41
47
  export interface DesignVersionFile {
@@ -66,7 +72,7 @@ export interface DesignVersionListEntry {
66
72
  designId: string;
67
73
  label: string | null;
68
74
  createdAt: string | null;
69
- source: "chat" | "legacy";
75
+ source: "chat" | "editor" | "legacy";
70
76
  fileCount: number;
71
77
  chatContext: DesignVersionChatContext | null;
72
78
  editable: boolean;
@@ -136,6 +142,7 @@ function parseChatContext(
136
142
  context[key] = candidate;
137
143
  }
138
144
  }
145
+ if (value.surface === "editor") context.surface = "editor";
139
146
  return Object.keys(context).length > 0 ? context : undefined;
140
147
  }
141
148
 
@@ -337,9 +344,9 @@ export async function readDesignVersionSnapshot(
337
344
  }
338
345
 
339
346
  function chatContextKey(
340
- context: DesignVersionChatContext | undefined,
347
+ context: DesignVersionChatContext | null | undefined,
341
348
  ): string | null {
342
- if (!context) return null;
349
+ if (!context || context.surface === "editor") return null;
343
350
  const scope = context.threadId ?? "";
344
351
  const turn = context.turnId ?? context.runId ?? "";
345
352
  return turn ? `${scope}:${turn}` : null;
@@ -358,6 +365,18 @@ function actionChatContext(
358
365
  };
359
366
  }
360
367
 
368
+ function editorActionContext(
369
+ context: ActionRunContext,
370
+ ): DesignVersionChatContext | null {
371
+ if (context.caller !== "frontend" && context.caller !== "webmcp") {
372
+ return null;
373
+ }
374
+ return {
375
+ surface: "editor",
376
+ ...(context.actionName ? { actionName: context.actionName } : {}),
377
+ };
378
+ }
379
+
361
380
  function versionTime(value: string | null): number {
362
381
  if (!value) return 0;
363
382
  const timestamp = Date.parse(value);
@@ -372,18 +391,6 @@ function nextUpdatedAt(current: string | null, now: Date): string {
372
391
  return new Date(nextMs).toISOString();
373
392
  }
374
393
 
375
- function affectedRowCount(result: unknown): number | undefined {
376
- if (!result || typeof result !== "object") return undefined;
377
- const candidate = result as {
378
- rowsAffected?: unknown;
379
- rowCount?: unknown;
380
- changes?: unknown;
381
- };
382
- const value =
383
- candidate.rowsAffected ?? candidate.rowCount ?? candidate.changes;
384
- return typeof value === "number" ? value : undefined;
385
- }
386
-
387
394
  function designFileRevisionWhere(file: {
388
395
  id: string;
389
396
  designId: string;
@@ -448,10 +455,13 @@ async function assertNoUnpersistedCollaborativeEdits(
448
455
  designId: string,
449
456
  storedFiles: ReadonlyArray<{ id: string; content: string }>,
450
457
  designData?: string | null,
458
+ database?: DesignDatabase,
451
459
  ): Promise<void> {
452
460
  let liveSnapshot;
453
461
  try {
454
- liveSnapshot = await buildDesignSnapshot(designId, designData);
462
+ liveSnapshot = database
463
+ ? await buildDesignSnapshot(designId, designData, {}, database)
464
+ : await buildDesignSnapshot(designId, designData);
455
465
  } catch {
456
466
  throw new DesignVersionRestoreConflictError(
457
467
  "Design history cannot verify live editing state right now. Refresh and try again.",
@@ -483,6 +493,7 @@ async function withDesignFileLocks<T>(
483
493
  }
484
494
 
485
495
  type DesignAccess = Awaited<ReturnType<typeof assertAccess>>;
496
+ type DesignDatabase = Pick<ReturnType<typeof getDb>, "select" | "insert">;
486
497
 
487
498
  const designVersionLocks = new Map<string, Promise<unknown>>();
488
499
 
@@ -492,10 +503,12 @@ async function captureDesignVersion(
492
503
  label: string;
493
504
  chatContext?: DesignVersionChatContext;
494
505
  deletionGeometry?: ComponentDeletionGeometry;
506
+ preferStoredFileContent?: boolean;
495
507
  },
496
508
  access: DesignAccess,
509
+ database?: DesignDatabase,
497
510
  ): Promise<{ id: string; createdAt: string; label: string }> {
498
- const design = access.resource as {
511
+ let design = access.resource as {
499
512
  data?: unknown;
500
513
  title?: unknown;
501
514
  description?: unknown;
@@ -503,8 +516,29 @@ async function captureDesignVersion(
503
516
  designSystemId?: unknown;
504
517
  ownerEmail?: unknown;
505
518
  };
519
+ if (database) {
520
+ const [currentDesign] = await database
521
+ .select()
522
+ .from(schema.designs)
523
+ .where(
524
+ and(
525
+ eq(schema.designs.id, designId),
526
+ accessFilter(schema.designs, schema.designShares),
527
+ ),
528
+ )
529
+ .limit(1);
530
+ if (!currentDesign) {
531
+ throw new Error(`Design "${designId}" not found.`);
532
+ }
533
+ design = { ...design, ...currentDesign };
534
+ }
506
535
  const designData = parseDesignData(designId, design.data);
507
- const liveSnapshot = await buildDesignSnapshot(designId, designData);
536
+ const snapshotOptions = options.preferStoredFileContent
537
+ ? { preferStoredFileContent: true }
538
+ : undefined;
539
+ const liveSnapshot = database
540
+ ? await buildDesignSnapshot(designId, designData, snapshotOptions, database)
541
+ : await buildDesignSnapshot(designId, designData, snapshotOptions);
508
542
  const designTitle = typeof design.title === "string" ? design.title : "";
509
543
  const designDescription =
510
544
  typeof design.description === "string" || design.description === null
@@ -536,7 +570,7 @@ async function captureDesignVersion(
536
570
  resolvedCssVars: liveSnapshot.resolvedCssVars,
537
571
  deletionGeometry: options.deletionGeometry,
538
572
  };
539
- const db = getDb();
573
+ const db = database ?? getDb();
540
574
  const [latest] = await db
541
575
  .select({
542
576
  id: schema.designVersions.id,
@@ -745,69 +779,103 @@ export async function createDesignVersionSnapshot(
745
779
  * retries and multi-action turns converge on the earliest checkpoint instead
746
780
  * of filling history with one copy per tool call.
747
781
  */
748
- export async function snapshotDesignBeforeAgentEdit(
782
+ async function snapshotDesignBeforeAgentEditInLock(
749
783
  designId: string,
750
- context?: ActionRunContext,
784
+ context: ActionRunContext,
785
+ database?: DesignDatabase,
751
786
  ): Promise<{ id: string; createdAt: string; label: string } | null> {
752
- if (!context) return null;
753
787
  const chatContext = actionChatContext(context);
754
- if (!chatContext) return null;
755
-
756
- return withDesignVersionLock(designId, async () => {
757
- const access = await assertAccess("design", designId, "editor");
758
- const key = chatContextKey(chatContext);
759
- if (!key) return null;
760
-
761
- const rows = await getDb()
762
- .select({
763
- id: schema.designVersions.id,
764
- createdAt: schema.designVersions.createdAt,
765
- label: schema.designVersions.label,
766
- chatContext: schema.designVersions.chatContext,
767
- })
768
- .from(schema.designVersions)
769
- .where(eq(schema.designVersions.designId, designId))
770
- .orderBy(
771
- asc(isNull(schema.designVersions.createdAt)),
772
- desc(schema.designVersions.createdAt),
773
- desc(schema.designVersions.id),
774
- )
775
- .limit(CHAT_VERSION_LOOKBACK);
776
-
777
- let existing: { id: string; createdAt: string; label: string } | undefined;
778
- for (const row of rows) {
779
- let rowChatContext: DesignVersionChatContext | undefined;
780
- try {
781
- rowChatContext = parseStoredChatContext(row.chatContext);
782
- } catch {
783
- continue;
784
- }
785
- if (chatContextKey(rowChatContext) !== key) continue;
786
- const candidate = {
787
- id: row.id,
788
- createdAt: row.createdAt ?? new Date().toISOString(),
789
- label: row.label ?? "Before chat edit",
790
- };
791
- if (
792
- !existing ||
793
- versionTime(candidate.createdAt) < versionTime(existing.createdAt)
794
- ) {
795
- existing = candidate;
796
- }
797
- }
798
- if (existing) return existing;
788
+ const editorContext = editorActionContext(context);
789
+ if (!chatContext && !editorContext) return null;
799
790
 
791
+ const access = await assertAccess("design", designId, "editor");
792
+ if (editorContext) {
800
793
  return captureDesignVersion(
801
794
  designId,
802
795
  {
803
796
  label: context.actionName
804
- ? `Before chat edit: ${context.actionName}`
805
- : "Before chat edit",
806
- chatContext,
797
+ ? `Before editor edit: ${context.actionName}`
798
+ : "Before editor edit",
799
+ chatContext: editorContext,
807
800
  },
808
801
  access,
802
+ database,
809
803
  );
810
- });
804
+ }
805
+ if (!chatContext) return null;
806
+ const key = chatContextKey(chatContext);
807
+ if (!key) return null;
808
+
809
+ const rows = await (database ?? getDb())
810
+ .select({
811
+ id: schema.designVersions.id,
812
+ createdAt: schema.designVersions.createdAt,
813
+ label: schema.designVersions.label,
814
+ chatContext: schema.designVersions.chatContext,
815
+ })
816
+ .from(schema.designVersions)
817
+ .where(eq(schema.designVersions.designId, designId))
818
+ .orderBy(
819
+ asc(isNull(schema.designVersions.createdAt)),
820
+ desc(schema.designVersions.createdAt),
821
+ desc(schema.designVersions.id),
822
+ )
823
+ .limit(CHAT_VERSION_LOOKBACK);
824
+
825
+ let existing: { id: string; createdAt: string; label: string } | undefined;
826
+ for (const row of rows) {
827
+ let rowChatContext: DesignVersionChatContext | undefined;
828
+ try {
829
+ rowChatContext = parseStoredChatContext(row.chatContext);
830
+ } catch {
831
+ continue;
832
+ }
833
+ if (chatContextKey(rowChatContext) !== key) continue;
834
+ const candidate = {
835
+ id: row.id,
836
+ createdAt: row.createdAt ?? new Date().toISOString(),
837
+ label: row.label ?? "Before chat edit",
838
+ };
839
+ if (
840
+ !existing ||
841
+ versionTime(candidate.createdAt) < versionTime(existing.createdAt)
842
+ ) {
843
+ existing = candidate;
844
+ }
845
+ }
846
+ if (existing) return existing;
847
+
848
+ return captureDesignVersion(
849
+ designId,
850
+ {
851
+ label: context.actionName
852
+ ? `Before chat edit: ${context.actionName}`
853
+ : "Before chat edit",
854
+ chatContext,
855
+ },
856
+ access,
857
+ database,
858
+ );
859
+ }
860
+
861
+ export async function snapshotDesignBeforeAgentEdit(
862
+ designId: string,
863
+ context?: ActionRunContext,
864
+ ): Promise<{ id: string; createdAt: string; label: string } | null> {
865
+ if (!context) return null;
866
+ return withDesignVersionLock(designId, () =>
867
+ snapshotDesignBeforeAgentEditInLock(designId, context),
868
+ );
869
+ }
870
+
871
+ /** Call only while the caller owns withDesignVersionLock(designId, ...). */
872
+ export async function snapshotDesignBeforeAgentEditInVersionLock(
873
+ designId: string,
874
+ context?: ActionRunContext,
875
+ database?: DesignDatabase,
876
+ ): Promise<{ id: string; createdAt: string; label: string } | null> {
877
+ if (!context) return null;
878
+ return snapshotDesignBeforeAgentEditInLock(designId, context, database);
811
879
  }
812
880
 
813
881
  export async function listDesignVersions(
@@ -853,7 +921,12 @@ export async function listDesignVersions(
853
921
  designId,
854
922
  label: row.label,
855
923
  createdAt: row.createdAt,
856
- source: chatContext ? "chat" : "legacy",
924
+ source:
925
+ chatContext?.surface === "editor"
926
+ ? "editor"
927
+ : chatContext
928
+ ? "chat"
929
+ : "legacy",
857
930
  fileCount: row.fileCount ?? 0,
858
931
  chatContext: chatContext ?? null,
859
932
  editable: Boolean(chatContext),
@@ -899,7 +972,7 @@ export async function restoreDesignVersion(args: {
899
972
  collaborationReconcilePending: string[];
900
973
  }> {
901
974
  return withDesignVersionLock(args.designId, async () => {
902
- await assertAccess("design", args.designId, "editor");
975
+ const access = await assertAccess("design", args.designId, "editor");
903
976
  const db = getDb();
904
977
  const [version] = await db
905
978
  .select({
@@ -950,49 +1023,54 @@ export async function restoreDesignVersion(args: {
950
1023
  ...target.files.flatMap((file) => (file.id ? [file.id] : [])),
951
1024
  ],
952
1025
  async () => {
953
- const lockedFiles = await db
954
- .select()
955
- .from(schema.designFiles)
956
- .where(eq(schema.designFiles.designId, args.designId));
957
- await assertNoForeignCollaborators(
958
- [
959
- ...lockedFiles.map((file) => file.id),
960
- ...target.files.flatMap((file) => (file.id ? [file.id] : [])),
961
- ],
962
- currentEmail,
963
- );
964
-
965
- const lockedAccess = await assertAccess(
966
- "design",
967
- args.designId,
968
- "editor",
969
- );
970
- const currentDesign = lockedAccess.resource as {
971
- data?: unknown;
972
- updatedAt?: string | null;
973
- };
974
- await assertNoUnpersistedCollaborativeEdits(
975
- args.designId,
976
- lockedFiles,
977
- typeof currentDesign.data === "string"
978
- ? currentDesign.data
979
- : undefined,
980
- );
981
- const before = await captureDesignVersion(
982
- args.designId,
983
- { label: "Before restore" },
984
- lockedAccess,
985
- );
986
- const now = new Date();
987
- const updatedAt = nextUpdatedAt(currentDesign.updatedAt ?? null, now);
988
- const currentById = new Map(lockedFiles.map((file) => [file.id, file]));
989
- const currentByFilename = new Map(
990
- lockedFiles.map((file) => [file.filename, file]),
991
- );
992
- const claimedIds = new Set<string>();
993
- const restoreFiles: RestoreFile[] = [];
994
-
995
- await db.transaction(async (tx) => {
1026
+ return db.transaction(async (tx) => {
1027
+ await tx.execute(
1028
+ sql`SELECT pg_advisory_xact_lock(hashtextextended(${designSourceMutationLockKey(args.designId)}, 0::bigint))`,
1029
+ );
1030
+ await lockDesignFilesTable(tx);
1031
+ const lockedFiles = await tx
1032
+ .select()
1033
+ .from(schema.designFiles)
1034
+ .where(eq(schema.designFiles.designId, args.designId));
1035
+ await assertNoForeignCollaborators(
1036
+ [
1037
+ ...lockedFiles.map((file) => file.id),
1038
+ ...target.files.flatMap((file) => (file.id ? [file.id] : [])),
1039
+ ],
1040
+ currentEmail,
1041
+ );
1042
+
1043
+ const [currentDesign] = await tx
1044
+ .select()
1045
+ .from(schema.designs)
1046
+ .where(eq(schema.designs.id, args.designId))
1047
+ .limit(1);
1048
+ if (!currentDesign) {
1049
+ throw new Error(`Design "${args.designId}" not found.`);
1050
+ }
1051
+ await assertNoUnpersistedCollaborativeEdits(
1052
+ args.designId,
1053
+ lockedFiles,
1054
+ currentDesign.data,
1055
+ tx,
1056
+ );
1057
+ const before = await captureDesignVersion(
1058
+ args.designId,
1059
+ { label: "Before restore" },
1060
+ access,
1061
+ tx,
1062
+ );
1063
+ const now = new Date();
1064
+ const updatedAt = nextUpdatedAt(currentDesign.updatedAt ?? null, now);
1065
+ const currentById = new Map(
1066
+ lockedFiles.map((file) => [file.id, file]),
1067
+ );
1068
+ const currentByFilename = new Map(
1069
+ lockedFiles.map((file) => [file.filename, file]),
1070
+ );
1071
+ const claimedIds = new Set<string>();
1072
+ const restoreFiles: RestoreFile[] = [];
1073
+
996
1074
  for (const targetFile of target.files) {
997
1075
  const byId = targetFile.id
998
1076
  ? currentById.get(targetFile.id)
@@ -1152,18 +1230,18 @@ export async function restoreDesignVersion(args: {
1152
1230
  "Design changed while history was being restored. Refresh and try again.",
1153
1231
  );
1154
1232
  }
1155
- });
1156
1233
 
1157
- return {
1158
- beforeRestoreVersionId: before.id,
1159
- restore: {
1160
- updatedAt,
1161
- files: restoreFiles,
1162
- deletedFileIds: lockedFiles
1163
- .filter((file) => !claimedIds.has(file.id))
1164
- .map((file) => file.id),
1165
- },
1166
- };
1234
+ return {
1235
+ beforeRestoreVersionId: before.id,
1236
+ restore: {
1237
+ updatedAt,
1238
+ files: restoreFiles,
1239
+ deletedFileIds: lockedFiles
1240
+ .filter((file) => !claimedIds.has(file.id))
1241
+ .map((file) => file.id),
1242
+ },
1243
+ };
1244
+ });
1167
1245
  },
1168
1246
  );
1169
1247
 
@@ -21,7 +21,7 @@ import {
21
21
  applyText,
22
22
  seedFromText,
23
23
  } from "@agent-native/core/collab";
24
- import { eq } from "drizzle-orm";
24
+ import { and, eq } from "drizzle-orm";
25
25
  import { nanoid } from "nanoid";
26
26
 
27
27
  import {
@@ -30,6 +30,7 @@ import {
30
30
  type CanvasFramePlacement,
31
31
  } from "../../shared/canvas-frames.js";
32
32
  import { getDb, schema } from "../db/index.js";
33
+ import { withDesignSourceMutationTransaction } from "../source-workspace.js";
33
34
  import { mutateDesignData } from "./design-data-mutation.js";
34
35
 
35
36
  const PATH_BASE_PLACEHOLDER = "http://fusion-screen-base.invalid";
@@ -149,31 +150,56 @@ export async function upsertFusionScreens(args: {
149
150
  ? `${screenUrl.pathname}${screenUrl.search}`
150
151
  : screenUrl.toString();
151
152
  const preferredFilename = `fusion-${slugForPath(path)}.html`;
152
- const existing = existingByFilename.get(preferredFilename);
153
+ let existing = existingByFilename.get(preferredFilename);
153
154
  const filename = existing?.filename ?? uniqueFilename(path, usedFilenames);
154
- const fileId = existing?.id ?? nanoid();
155
+ let fileId = existing?.id ?? nanoid();
155
156
  const title = titleFromPath(path);
156
157
 
157
158
  if (existing) {
158
- await db
159
- .update(schema.designFiles)
160
- .set({ content: url, fileType: "html", updatedAt: now })
161
- .where(eq(schema.designFiles.id, existing.id));
162
- if (await hasCollabState(existing.id)) {
163
- await applyText(existing.id, url, "content", "agent");
159
+ const updated = await withDesignSourceMutationTransaction(
160
+ designId,
161
+ async (tx) => {
162
+ const [current] = await tx
163
+ .select({ id: schema.designFiles.id })
164
+ .from(schema.designFiles)
165
+ .where(
166
+ and(
167
+ eq(schema.designFiles.id, existing!.id),
168
+ eq(schema.designFiles.designId, designId),
169
+ ),
170
+ )
171
+ .limit(1);
172
+ if (!current) return false;
173
+ await tx
174
+ .update(schema.designFiles)
175
+ .set({ content: url, fileType: "html", updatedAt: now })
176
+ .where(eq(schema.designFiles.id, existing!.id));
177
+ return true;
178
+ },
179
+ );
180
+ if (updated) {
181
+ if (await hasCollabState(existing.id)) {
182
+ await applyText(existing.id, url, "content", "agent");
183
+ } else {
184
+ await seedFromText(existing.id, url);
185
+ }
164
186
  } else {
165
- await seedFromText(existing.id, url);
187
+ existing = undefined;
188
+ fileId = nanoid();
166
189
  }
167
- } else {
168
- await db.insert(schema.designFiles).values({
169
- id: fileId,
170
- designId,
171
- filename,
172
- fileType: "html",
173
- content: url,
174
- createdAt: now,
175
- updatedAt: now,
176
- });
190
+ }
191
+ if (!existing) {
192
+ await withDesignSourceMutationTransaction(designId, (tx) =>
193
+ tx.insert(schema.designFiles).values({
194
+ id: fileId,
195
+ designId,
196
+ filename,
197
+ fileType: "html",
198
+ content: url,
199
+ createdAt: now,
200
+ updatedAt: now,
201
+ }),
202
+ );
177
203
  await seedFromText(fileId, url);
178
204
  }
179
205
 
@@ -5,7 +5,7 @@ import {
5
5
  seedFromText,
6
6
  } from "@agent-native/core/collab";
7
7
  import { assertAccess, resolveAccess } from "@agent-native/core/sharing";
8
- import { and, eq } from "drizzle-orm";
8
+ import { and, eq, sql } from "drizzle-orm";
9
9
  import { nanoid } from "nanoid";
10
10
 
11
11
  import {
@@ -15,6 +15,7 @@ import {
15
15
  } from "../../shared/canvas-frames.js";
16
16
  import { annotateScreenHtmlForPersist } from "../../shared/screen-annotation.js";
17
17
  import { getDb, schema } from "../db/index.js";
18
+ import { designSourceMutationLockKey } from "../source-workspace.js";
18
19
  import { mutateDesignData } from "./design-data-mutation.js";
19
20
 
20
21
  const DEFAULT_FRAME_WIDTH = 1440;
@@ -299,6 +300,9 @@ export async function saveImportedDesignFiles(
299
300
 
300
301
  try {
301
302
  await db.transaction(async (tx) => {
303
+ await tx.execute(
304
+ sql`SELECT pg_advisory_xact_lock(hashtextextended(${designSourceMutationLockKey(designId)}, 0::bigint))`,
305
+ );
302
306
  const [design] = await tx
303
307
  .select()
304
308
  .from(schema.designs)