@agent-native/core 0.80.7 → 0.80.8

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 (55) 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/client/AssistantChat.tsx +70 -27
  5. package/corpus/core/src/client/analytics.ts +3 -1
  6. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  7. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  8. package/corpus/templates/design/AGENTS.md +4 -2
  9. package/corpus/templates/design/actions/edit-design.ts +6 -3
  10. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  11. package/corpus/templates/design/actions/update-design.ts +47 -10
  12. package/corpus/templates/design/actions/update-file.ts +33 -8
  13. package/corpus/templates/design/actions/view-screen.ts +14 -0
  14. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  15. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  16. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  17. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  18. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  19. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  20. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  21. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  22. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  23. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  24. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  25. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  26. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  27. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  28. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  29. package/corpus/templates/design/app/i18n-data.ts +10 -0
  30. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  31. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  32. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  33. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  34. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  35. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  36. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  37. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  38. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  39. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  40. package/corpus/templates/design/e2e/README.md +1 -0
  41. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  42. package/corpus/templates/design/playwright.config.ts +10 -1
  43. package/corpus/templates/design/shared/color-utils.ts +159 -0
  44. package/corpus/templates/design/shared/generation-session.ts +1 -0
  45. package/dist/client/AssistantChat.d.ts.map +1 -1
  46. package/dist/client/AssistantChat.js +54 -24
  47. package/dist/client/AssistantChat.js.map +1 -1
  48. package/dist/client/analytics.d.ts.map +1 -1
  49. package/dist/client/analytics.js +2 -1
  50. package/dist/client/analytics.js.map +1 -1
  51. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  52. package/dist/notifications/routes.d.ts +2 -2
  53. package/dist/observability/routes.d.ts +8 -8
  54. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  55. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2041
31
- - template files: 4588
31
+ - template files: 4598
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.80.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 24deb20: Reduce noisy browser Sentry captures by filtering public-site source-less errors that only report their page URL as a Sentry tag, delaying reconnect aborts until active runs are truly stuck on the server clock, and recovering assistant-ui duplicate message-id append races before they escape.
8
+
3
9
  ## 0.80.7
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.80.7",
3
+ "version": "0.80.8",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -228,6 +228,7 @@ function createUserMessageRunConfig(
228
228
 
229
229
  const PENDING_SELECTION_KEY = "pending-selection-context";
230
230
  const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
231
+ const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
231
232
  const ACTIVE_RUN_POLL_INTERVAL_MS = 150;
232
233
  const SUBMIT_ENGINE_STATUS_TIMEOUT_MS = 1000;
233
234
 
@@ -237,15 +238,27 @@ type ActiveRunLookup = {
237
238
  threadId?: string;
238
239
  status?: string;
239
240
  heartbeatAt?: number | null;
241
+ lastProgressAt?: number | null;
242
+ serverNow?: number;
240
243
  };
241
244
 
242
245
  function activeRunLooksStale(runInfo: ActiveRunLookup): boolean {
243
- const heartbeatAt =
244
- typeof runInfo.heartbeatAt === "number" ? runInfo.heartbeatAt : null;
246
+ const lastProgressAt =
247
+ typeof runInfo.lastProgressAt === "number" ? runInfo.lastProgressAt : null;
248
+ const nowMs =
249
+ typeof runInfo.serverNow === "number" ? runInfo.serverNow : Date.now();
245
250
  return (
246
251
  runInfo.status === "running" &&
247
- heartbeatAt != null &&
248
- Date.now() - heartbeatAt > 5000
252
+ lastProgressAt != null &&
253
+ nowMs - lastProgressAt > ACTIVE_RUN_STUCK_THRESHOLD_MS
254
+ );
255
+ }
256
+
257
+ function isAssistantUiDuplicateMessageIdError(error: unknown): boolean {
258
+ const message = error instanceof Error ? error.message : String(error ?? "");
259
+ return (
260
+ message.includes("MessageRepository") &&
261
+ message.includes("same id already exists")
249
262
  );
250
263
  }
251
264
 
@@ -295,14 +308,13 @@ async function waitForThreadRunToClear(apiUrl: string, threadId?: string) {
295
308
  `${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`,
296
309
  );
297
310
  if (res.ok) {
298
- const info = await res.json();
299
- const heartbeatAt =
300
- typeof info?.heartbeatAt === "number" ? info.heartbeatAt : null;
301
- const stale =
302
- info?.status === "running" &&
303
- heartbeatAt != null &&
304
- Date.now() - heartbeatAt > 5000;
305
- if (!info?.active || info?.status !== "running" || stale) return;
311
+ const info = (await res.json()) as ActiveRunLookup;
312
+ if (
313
+ !info?.active ||
314
+ info?.status !== "running" ||
315
+ activeRunLooksStale(info)
316
+ )
317
+ return;
306
318
  }
307
319
  } catch {
308
320
  // Transient poll failure — try again until the short grace period ends.
@@ -1467,9 +1479,40 @@ const AssistantChatInner = forwardRef<
1467
1479
  }
1468
1480
  }, [apiUrl, importThreadData, loadHistoryRepository, threadId]);
1469
1481
 
1482
+ const exportCleanThreadRepo = useCallback(
1483
+ () =>
1484
+ ensureMessageMetadata(normalizeThreadRepository(threadRuntime.export())),
1485
+ [threadRuntime],
1486
+ );
1487
+
1488
+ const appendThreadMessage = useCallback(
1489
+ (message: Parameters<typeof threadRuntime.append>[0]) => {
1490
+ try {
1491
+ threadRuntime.append(message);
1492
+ return;
1493
+ } catch (error) {
1494
+ if (!isAssistantUiDuplicateMessageIdError(error)) throw error;
1495
+ }
1496
+
1497
+ try {
1498
+ threadRuntime.import(exportCleanThreadRepo());
1499
+ } catch {
1500
+ // Best effort cleanup; retry below handles the still-duplicated case.
1501
+ }
1502
+
1503
+ try {
1504
+ threadRuntime.append(message);
1505
+ } catch (retryError) {
1506
+ if (isAssistantUiDuplicateMessageIdError(retryError)) return;
1507
+ throw retryError;
1508
+ }
1509
+ },
1510
+ [exportCleanThreadRepo, threadRuntime],
1511
+ );
1512
+
1470
1513
  const cacheCurrentThreadSnapshot = useCallback(() => {
1471
1514
  if (!threadId || messages.length === 0) return;
1472
- const repo = threadRuntime.export();
1515
+ const repo = exportCleanThreadRepo();
1473
1516
  const threadData = JSON.stringify(stripBase64FromRepo(repo));
1474
1517
  const { title, preview } = extractThreadMeta(repo);
1475
1518
  writeCachedThreadSnapshot(apiUrl, threadId, {
@@ -1478,7 +1521,7 @@ const AssistantChatInner = forwardRef<
1478
1521
  preview,
1479
1522
  messageCount: messages.length,
1480
1523
  });
1481
- }, [apiUrl, messages.length, threadId, threadRuntime]);
1524
+ }, [apiUrl, exportCleanThreadRepo, messages.length, threadId]);
1482
1525
 
1483
1526
  useBrowserLayoutEffect(() => {
1484
1527
  if (hasImportedInitialCachedSnapshotRef.current) return;
@@ -1975,7 +2018,7 @@ const AssistantChatInner = forwardRef<
1975
2018
  const timeSinceLastSave = now - lastSaveTimeRef.current;
1976
2019
  if (timeSinceLastSave < 5000) return;
1977
2020
 
1978
- const repo = threadRuntime.export();
2021
+ const repo = exportCleanThreadRepo();
1979
2022
  const { title, preview } = extractThreadMeta(repo);
1980
2023
  const threadData = JSON.stringify(stripBase64FromRepo(repo));
1981
2024
  const snapshot = {
@@ -1989,7 +2032,7 @@ const AssistantChatInner = forwardRef<
1989
2032
  savedTitleRef.current = title;
1990
2033
  writeCachedThreadSnapshot(apiUrl, threadId, snapshot);
1991
2034
  onSaveThreadRef.current(threadId, snapshot);
1992
- }, [apiUrl, messages, isRunning, threadId, threadRuntime]);
2035
+ }, [apiUrl, exportCleanThreadRepo, messages, isRunning, threadId]);
1993
2036
 
1994
2037
  // Persist full thread data after each completed response
1995
2038
  useEffect(() => {
@@ -1997,7 +2040,7 @@ const AssistantChatInner = forwardRef<
1997
2040
  if (isRunning) return;
1998
2041
  if (messages.length === 0) return;
1999
2042
 
2000
- const repo = threadRuntime.export();
2043
+ const repo = exportCleanThreadRepo();
2001
2044
 
2002
2045
  if (threadId && onSaveThreadRef.current) {
2003
2046
  // Save to server via the hook callback
@@ -2019,7 +2062,7 @@ const AssistantChatInner = forwardRef<
2019
2062
  sessionStorage.setItem(storageKey, JSON.stringify(repo));
2020
2063
  } catch {}
2021
2064
  }
2022
- }, [apiUrl, messages, isRunning, threadId, tabId, threadRuntime]);
2065
+ }, [apiUrl, exportCleanThreadRepo, messages, isRunning, threadId, tabId]);
2023
2066
 
2024
2067
  useEffect(() => {
2025
2068
  onMessageCountChange?.(messages.length);
@@ -2280,7 +2323,7 @@ const AssistantChatInner = forwardRef<
2280
2323
  next.attachments && next.attachments.length > 0
2281
2324
  ? next.attachments
2282
2325
  : (imageAttachments ?? []);
2283
- threadRuntime.append({
2326
+ appendThreadMessage({
2284
2327
  role: "user",
2285
2328
  content: [{ type: "text", text: next.text }],
2286
2329
  ...(messageAttachments.length > 0
@@ -2334,12 +2377,12 @@ const AssistantChatInner = forwardRef<
2334
2377
  };
2335
2378
  }, [
2336
2379
  apiUrl,
2380
+ appendThreadMessage,
2337
2381
  applyLocalQueuedMessages,
2338
2382
  isRestoring,
2339
2383
  isRunning,
2340
2384
  queuedMessages,
2341
2385
  threadId,
2342
- threadRuntime,
2343
2386
  ]);
2344
2387
 
2345
2388
  // Clear frozen reconnect content + forceStopped only on the false→true
@@ -2683,7 +2726,7 @@ const AssistantChatInner = forwardRef<
2683
2726
  },
2684
2727
  ]);
2685
2728
  } else {
2686
- threadRuntime.append({
2729
+ appendThreadMessage({
2687
2730
  role: "user",
2688
2731
  content: [{ type: "text", text: submittedText }],
2689
2732
  ...(messageAttachments.length > 0
@@ -2708,7 +2751,7 @@ const AssistantChatInner = forwardRef<
2708
2751
  execMode,
2709
2752
  isRunning,
2710
2753
  materializeFrozenReconnectContent,
2711
- threadRuntime,
2754
+ appendThreadMessage,
2712
2755
  updateComposerContextItems,
2713
2756
  ],
2714
2757
  );
@@ -2774,7 +2817,7 @@ const AssistantChatInner = forwardRef<
2774
2817
  },
2775
2818
  exportThreadSnapshot() {
2776
2819
  if (messages.length === 0) return null;
2777
- const repo = threadRuntime.export();
2820
+ const repo = exportCleanThreadRepo();
2778
2821
  const { title, preview } = extractThreadMeta(repo);
2779
2822
  return {
2780
2823
  threadData: JSON.stringify(repo),
@@ -2786,10 +2829,10 @@ const AssistantChatInner = forwardRef<
2786
2829
  }),
2787
2830
  [
2788
2831
  addToQueue,
2832
+ exportCleanThreadRepo,
2789
2833
  messages.length,
2790
2834
  stageComposerContextItem,
2791
2835
  thread.isRunning,
2792
- threadRuntime,
2793
2836
  ],
2794
2837
  );
2795
2838
 
@@ -2986,7 +3029,7 @@ const AssistantChatInner = forwardRef<
2986
3029
  const approvalCtx = useMemo<ApprovalContextValue>(
2987
3030
  () => ({
2988
3031
  onApprove: (approvalKey: string) => {
2989
- threadRuntime.append({
3032
+ appendThreadMessage({
2990
3033
  role: "user",
2991
3034
  content: [
2992
3035
  {
@@ -3008,7 +3051,7 @@ const AssistantChatInner = forwardRef<
3008
3051
  } as Parameters<typeof threadRuntime.append>[0]);
3009
3052
  },
3010
3053
  }),
3011
- [threadRuntime, execMode],
3054
+ [appendThreadMessage, execMode],
3012
3055
  );
3013
3056
 
3014
3057
  return (
@@ -3201,7 +3244,7 @@ const AssistantChatInner = forwardRef<
3201
3244
  setMissingKeyBouncePulse((p) => p + 1);
3202
3245
  return;
3203
3246
  }
3204
- threadRuntime.append({
3247
+ appendThreadMessage({
3205
3248
  role: "user",
3206
3249
  content: [{ type: "text", text: suggestion }],
3207
3250
  });
@@ -610,7 +610,9 @@ function isAgentNativeDocsUrl(url: string): boolean {
610
610
 
611
611
  function shouldDropBrowserSentryNoise(event: Sentry.Event): boolean {
612
612
  const exceptionValues = event.exception?.values ?? [];
613
- const requestUrl = event.request?.url?.toLowerCase() ?? "";
613
+ const taggedUrl =
614
+ typeof event.tags?.url === "string" ? event.tags.url : undefined;
615
+ const requestUrl = (event.request?.url ?? taggedUrl ?? "").toLowerCase();
614
616
  const isDocsPage = isAgentNativeDocsUrl(requestUrl);
615
617
  // AgentAutoContinueSignal is a control-flow sentinel thrown to bubble
616
618
  // out of the SSE stream parser when the agent run needs to be
@@ -157,10 +157,19 @@ interface DocumentEditorBodyProps {
157
157
  type PendingDocumentSave = {
158
158
  title: string;
159
159
  content: string;
160
- save: (title: string, content: string) => unknown | Promise<unknown>;
160
+ save: (
161
+ title: string,
162
+ content: string,
163
+ options?: DocumentSaveOptions,
164
+ ) => unknown | Promise<unknown>;
165
+ canEditWhenQueued: boolean;
161
166
  timeout: ReturnType<typeof setTimeout>;
162
167
  };
163
168
 
169
+ type DocumentSaveOptions = {
170
+ allowQueuedSave?: boolean;
171
+ };
172
+
164
173
  type DocumentSaveResult = {
165
174
  contentPersisted: boolean;
166
175
  };
@@ -274,6 +283,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
274
283
  const updateDocument = useUpdateDocument();
275
284
  const queryClient = useQueryClient();
276
285
  const canEdit = document.canEdit ?? true;
286
+ const canEditRef = useRef(canEdit);
287
+ canEditRef.current = canEdit;
277
288
  // The block render context (asset/upload resolvers, inline markdown reader,
278
289
  // panel popover) is stable for the editor's lifetime. Created once here and
279
290
  // provided alongside the content block registry so every registry block in the
@@ -324,6 +335,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
324
335
  document.updatedAt ?? null,
325
336
  );
326
337
  documentUpdatedAtRef.current = document.updatedAt ?? null;
338
+ const handleBackgroundSaveError = useCallback(
339
+ (error: unknown) => {
340
+ toast.error(t("empty.genericError"), {
341
+ description:
342
+ error instanceof Error ? error.message : t("empty.genericError"),
343
+ });
344
+ },
345
+ [t],
346
+ );
327
347
  const titleFocusedRef = useRef(false);
328
348
  const scrollContainerRef = useRef<HTMLDivElement>(null);
329
349
  const titleInputRef = useRef<HTMLTextAreaElement>(null);
@@ -375,7 +395,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
375
395
  }
376
396
  : undefined;
377
397
 
378
- // Collaborative editing stable Y.Doc per document, always-on
398
+ // Collaborative editing is write-only for now. Viewers get the SQL snapshot
399
+ // and skip collab endpoints that reject non-editor access.
379
400
  const {
380
401
  ydoc,
381
402
  awareness,
@@ -384,7 +405,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
384
405
  agentActive,
385
406
  agentPresent,
386
407
  } = useCollaborativeDoc({
387
- docId: isLocalFileDocument ? "" : documentId,
408
+ docId: canEdit && !isLocalFileDocument ? documentId : "",
388
409
  requestSource: TAB_ID,
389
410
  user: currentUser,
390
411
  });
@@ -566,11 +587,16 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
566
587
  }, [document, isLinkedLocalSourceDocument, localTitle, localContent]);
567
588
 
568
589
  const persistDocumentUpdates = useCallback(
569
- async (updates: {
570
- title?: string;
571
- content?: string;
572
- icon?: string | null;
573
- }): Promise<Document> => {
590
+ async (
591
+ updates: {
592
+ title?: string;
593
+ content?: string;
594
+ icon?: string | null;
595
+ },
596
+ options: DocumentSaveOptions = {},
597
+ ): Promise<Document> => {
598
+ if (!options.allowQueuedSave && !canEditRef.current) return document;
599
+
574
600
  const localSource = document.source;
575
601
  const isLinkedLocalSource = canWriteLinkedLocalSource(
576
602
  documentId,
@@ -629,7 +655,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
629
655
  );
630
656
 
631
657
  const saveDocumentImmediately = useCallback(
632
- async (title: string, content: string): Promise<DocumentSaveResult> => {
658
+ async (
659
+ title: string,
660
+ content: string,
661
+ options: DocumentSaveOptions = {},
662
+ ): Promise<DocumentSaveResult> => {
633
663
  // Never clobber a newer server version (e.g. an agent edit we haven't
634
664
  // reconciled into the editor yet) with the editor's current — possibly
635
665
  // stale — content. Guard per-field using the field's own watermark.
@@ -653,7 +683,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
653
683
  return { contentPersisted: !contentChanged };
654
684
  }
655
685
 
656
- const saved = await persistDocumentUpdates(updates);
686
+ const saved = await persistDocumentUpdates(updates, options);
657
687
  // Adopt the server updatedAt per saved field.
658
688
  const savedAt = saved?.updatedAt ?? new Date().toISOString();
659
689
  if (updates.title !== undefined) {
@@ -700,25 +730,38 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
700
730
  queryClient,
701
731
  ],
702
732
  );
733
+ const flushPendingDocumentSave = useCallback(
734
+ (pending: PendingDocumentSave) => {
735
+ if (!pending.canEditWhenQueued) return;
736
+ void Promise.resolve(
737
+ pending.save(pending.title, pending.content, {
738
+ allowQueuedSave: true,
739
+ }),
740
+ ).catch(handleBackgroundSaveError);
741
+ },
742
+ [handleBackgroundSaveError],
743
+ );
703
744
  const debouncedSave = useCallback(
704
745
  (title: string, content: string) => {
746
+ if (!canEditRef.current) return;
705
747
  if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
706
748
  const pending: PendingDocumentSave = {
707
749
  title,
708
750
  content,
709
751
  save: saveDocumentImmediately,
752
+ canEditWhenQueued: canEditRef.current,
710
753
  timeout: setTimeout(() => {
711
754
  if (pendingDocumentSaveRef.current === pending) {
712
755
  pendingDocumentSaveRef.current = null;
713
756
  }
714
757
  saveTimeoutRef.current = null;
715
- void pending.save(pending.title, pending.content);
758
+ flushPendingDocumentSave(pending);
716
759
  }, 500),
717
760
  };
718
761
  pendingDocumentSaveRef.current = pending;
719
762
  saveTimeoutRef.current = pending.timeout;
720
763
  },
721
- [saveDocumentImmediately],
764
+ [flushPendingDocumentSave, saveDocumentImmediately],
722
765
  );
723
766
 
724
767
  useEffect(() => {
@@ -728,9 +771,19 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
728
771
  clearTimeout(pending.timeout);
729
772
  saveTimeoutRef.current = null;
730
773
  pendingDocumentSaveRef.current = null;
731
- void pending.save(pending.title, pending.content);
774
+ flushPendingDocumentSave(pending);
732
775
  };
733
- }, [documentId]);
776
+ }, [documentId, flushPendingDocumentSave]);
777
+
778
+ useEffect(() => {
779
+ if (canEdit) return;
780
+ const pending = pendingDocumentSaveRef.current;
781
+ if (!pending) return;
782
+ clearTimeout(pending.timeout);
783
+ saveTimeoutRef.current = null;
784
+ pendingDocumentSaveRef.current = null;
785
+ flushPendingDocumentSave(pending);
786
+ }, [canEdit, documentId, flushPendingDocumentSave]);
734
787
 
735
788
  // Collab-aware ingest flush: the `pull-document` action writes a one-shot
736
789
  // `flush-request-<id>` app-state key when an external agent wants to ingest
@@ -846,7 +899,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
846
899
  const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
847
900
  const activeThreadId = hoveredThreadId ?? selectedThreadId;
848
901
  const { data: threads, isLoading: commentsLoading } = useComments(
849
- isLocalFileDocument ? null : documentId,
902
+ canEdit && !isLocalFileDocument ? documentId : null,
850
903
  );
851
904
  const hasComments =
852
905
  !isLocalFileDocument &&
@@ -1061,7 +1114,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1061
1114
  onSelect={(emoji) => {
1062
1115
  void (async () => {
1063
1116
  await persistDocumentUpdates({ icon: emoji });
1064
- })();
1117
+ })().catch(handleBackgroundSaveError);
1065
1118
  }}
1066
1119
  />
1067
1120
  ) : document.icon ? (
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Read-only pages no longer try to autosave or open editor-only realtime connections.
@@ -127,8 +127,10 @@ patterns live in `.agents/skills/`.
127
127
  - `navigate` moves the UI and is auto-deleted after the client consumes it.
128
128
  - `design-selection` includes active screen, selected element, overview mode,
129
129
  inspector tab, zoom, and screen list for the current tab.
130
- - `design-generation-session:<designId>` contains visible multi-screen
131
- generation frames created by `generate-screens`.
130
+ - `design-generation-session:<designId>` contains agent-facing multi-screen
131
+ generation planning state created by `generate-screens` (canvas region
132
+ assignments and per-frame instructions consumed by `generate-design` and
133
+ `view-screen`; not rendered as canvas overlays).
132
134
  - `show-design-questions` opens focused pre-generation questions in the main
133
135
  design canvas (`show-questions` application state).
134
136
  - `design-variants` contains in-progress candidates for the variant picker.
@@ -91,11 +91,14 @@ function findUniqueStableIdAgnosticSpan(
91
91
  }
92
92
  if (count !== 1) return null;
93
93
 
94
+ // Anchor `end` to one byte past the LAST matched stripped character rather than
95
+ // the mapped index of the NEXT character. Mapping the next index can land before
96
+ // a stripped node-id attribute that sits right after the match, so the splice
97
+ // would cross it and corrupt the file (e.g. duplicate/mangled tags).
98
+ const lastMatched = onlyIndex + strippedSearch.length - 1;
94
99
  return {
95
100
  start: strippedContent.indexMap[onlyIndex] ?? 0,
96
- end:
97
- strippedContent.indexMap[onlyIndex + strippedSearch.length] ??
98
- content.length,
101
+ end: (strippedContent.indexMap[lastMatched] ?? content.length - 1) + 1,
99
102
  };
100
103
  }
101
104
 
@@ -66,10 +66,11 @@ const requestedScreenSchema = z
66
66
 
67
67
  export default defineAction({
68
68
  description:
69
- "Start a visible multi-agent generation session on the Design canvas. " +
69
+ "Start a multi-screen generation session on the Design canvas. " +
70
70
  "Use this before generating multiple screens or variations in parallel: it " +
71
- "assigns non-overlapping canvas regions, publishes named agent/frame " +
72
- "status to application state, and returns per-frame generation instructions. " +
71
+ "assigns non-overlapping canvas regions and returns per-frame generation " +
72
+ "instructions including canvasFrame placements. The session state is " +
73
+ "agent-facing planning state consumed by generate-design and view-screen. " +
73
74
  "After this action, fan out calls to generate-design for each returned " +
74
75
  "frame, passing the returned canvasFrame values to generate-design so " +
75
76
  "screens appear in the infinite overview canvas.",
@@ -167,6 +168,7 @@ export default defineAction({
167
168
  prompt,
168
169
  contextRefs,
169
170
  frames,
171
+ startedAt: new Date().toISOString(),
170
172
  };
171
173
 
172
174
  await writeAppState(
@@ -49,17 +49,54 @@ export default defineAction({
49
49
  const db = getDb();
50
50
  const now = new Date().toISOString();
51
51
 
52
- const updates: Record<string, unknown> = { updatedAt: now };
53
- if (title !== undefined) updates.title = title;
54
- if (description !== undefined) updates.description = description;
55
- if (data !== undefined) updates.data = data;
56
- if (projectType !== undefined) updates.projectType = projectType;
57
- if (designSystemId !== undefined) updates.designSystemId = designSystemId;
52
+ const asRecord = (raw: string | null | undefined) => {
53
+ if (!raw) return {} as Record<string, unknown>;
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
57
+ ? (parsed as Record<string, unknown>)
58
+ : ({} as Record<string, unknown>);
59
+ } catch {
60
+ return {} as Record<string, unknown>;
61
+ }
62
+ };
63
+
64
+ // Read-merge-write inside one transaction so a partial `data` update can't
65
+ // race a concurrent write and either destroy framework-owned keys (e.g.
66
+ // canvasFrames/tweaks) or overwrite newer server-side keys. Mirrors
67
+ // generate-design's transactional read-merge.
68
+ await db.transaction(async (tx) => {
69
+ const updates: Record<string, unknown> = { updatedAt: now };
70
+ if (title !== undefined) updates.title = title;
71
+ if (description !== undefined) updates.description = description;
72
+ if (data !== undefined) {
73
+ const [existing] = await tx
74
+ .select({ data: schema.designs.data })
75
+ .from(schema.designs)
76
+ .where(eq(schema.designs.id, id));
77
+ const incomingParsed = JSON.parse(data);
78
+ if (
79
+ incomingParsed &&
80
+ typeof incomingParsed === "object" &&
81
+ !Array.isArray(incomingParsed)
82
+ ) {
83
+ updates.data = JSON.stringify({
84
+ ...asRecord(existing?.data),
85
+ ...(incomingParsed as Record<string, unknown>),
86
+ });
87
+ } else {
88
+ // Non-object JSON (array/primitive): keep the original verbatim write.
89
+ updates.data = data;
90
+ }
91
+ }
92
+ if (projectType !== undefined) updates.projectType = projectType;
93
+ if (designSystemId !== undefined) updates.designSystemId = designSystemId;
58
94
 
59
- await db
60
- .update(schema.designs)
61
- .set(updates)
62
- .where(eq(schema.designs.id, id));
95
+ await tx
96
+ .update(schema.designs)
97
+ .set(updates)
98
+ .where(eq(schema.designs.id, id));
99
+ });
63
100
 
64
101
  return { id, updated: true };
65
102
  },
@@ -70,15 +70,40 @@ export default defineAction({
70
70
 
71
71
  await assertAccess("design", file.designId, "editor");
72
72
 
73
- const updates: Record<string, unknown> = { updatedAt: now };
74
- if (content !== undefined) updates.content = content;
75
- if (filename !== undefined) updates.filename = filename;
76
- if (fileType !== undefined) updates.fileType = fileType;
73
+ // Reject a rename that would collide with an existing filename in the same
74
+ // design. The collision check and the write run in one transaction so they
75
+ // can't be interleaved by a concurrent rename. (A DB-level UNIQUE index on
76
+ // (designId, filename) would be the strongest guarantee but is a non-additive
77
+ // schema change on existing data, so it's deferred.)
78
+ await db.transaction(async (tx) => {
79
+ if (filename !== undefined) {
80
+ const [collision] = await tx
81
+ .select({ id: schema.designFiles.id })
82
+ .from(schema.designFiles)
83
+ .where(
84
+ and(
85
+ eq(schema.designFiles.designId, file.designId),
86
+ eq(schema.designFiles.filename, filename),
87
+ ),
88
+ )
89
+ .limit(1);
90
+ if (collision && collision.id !== id) {
91
+ throw new Error(
92
+ `File "${filename}" already exists in design ${file.designId}`,
93
+ );
94
+ }
95
+ }
77
96
 
78
- await db
79
- .update(schema.designFiles)
80
- .set(updates)
81
- .where(eq(schema.designFiles.id, id));
97
+ const updates: Record<string, unknown> = { updatedAt: now };
98
+ if (content !== undefined) updates.content = content;
99
+ if (filename !== undefined) updates.filename = filename;
100
+ if (fileType !== undefined) updates.fileType = fileType;
101
+
102
+ await tx
103
+ .update(schema.designFiles)
104
+ .set(updates)
105
+ .where(eq(schema.designFiles.id, id));
106
+ });
82
107
 
83
108
  // Push content through the collab layer so live editors see the change
84
109
  if (content !== undefined && syncCollab) {
@@ -120,7 +120,21 @@ export default defineAction({
120
120
  'A variant picker is open. Wait for the user to choose a direction before generating further. In an inline MCP app their pick returns to you automatically; if it opened as a browser tab (a CLI or code editor), they paste an auto-copied summary or just tell you which one (e.g. "use variant A"). Once you know the choice, read the saved index.html with get-design-snapshot. Do not call generate-design while this picker is open.';
121
121
  }
122
122
  if (generationSession) {
123
+ const GENERATION_SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
124
+ const startedAt =
125
+ typeof (generationSession as { startedAt?: unknown }).startedAt ===
126
+ "string"
127
+ ? new Date(
128
+ (generationSession as { startedAt: string }).startedAt,
129
+ ).getTime()
130
+ : 0;
131
+ const isStale =
132
+ startedAt > 0 && Date.now() - startedAt > GENERATION_SESSION_TTL_MS;
123
133
  screen.generationSession = generationSession;
134
+ if (isStale) {
135
+ screen.generationSessionNote =
136
+ "This generation session may be stale or abandoned (started more than 10 minutes ago). Verify saved screens via the design file list rather than assuming generation is still in progress.";
137
+ }
124
138
  }
125
139
 
126
140
  if (Object.keys(screen).length === 0) {