@agent-native/core 0.80.6 → 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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +70 -27
  5. package/corpus/core/src/client/analytics.ts +3 -1
  6. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
  7. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
  8. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
  9. package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
  10. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
  11. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  12. package/corpus/templates/analytics/app/i18n-data.ts +34 -0
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
  15. package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
  16. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  17. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  18. package/corpus/templates/design/AGENTS.md +4 -2
  19. package/corpus/templates/design/actions/edit-design.ts +6 -3
  20. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  21. package/corpus/templates/design/actions/update-design.ts +47 -10
  22. package/corpus/templates/design/actions/update-file.ts +33 -8
  23. package/corpus/templates/design/actions/view-screen.ts +14 -0
  24. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  25. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  26. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  27. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  28. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  29. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  30. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  31. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  32. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  33. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  34. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  35. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  36. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  37. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  38. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  39. package/corpus/templates/design/app/i18n-data.ts +10 -0
  40. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  41. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  42. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  43. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  44. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  45. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  46. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  47. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  48. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  49. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  50. package/corpus/templates/design/e2e/README.md +1 -0
  51. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  52. package/corpus/templates/design/playwright.config.ts +10 -1
  53. package/corpus/templates/design/shared/color-utils.ts +159 -0
  54. package/corpus/templates/design/shared/generation-session.ts +1 -0
  55. package/dist/client/AssistantChat.d.ts.map +1 -1
  56. package/dist/client/AssistantChat.js +54 -24
  57. package/dist/client/AssistantChat.js.map +1 -1
  58. package/dist/client/analytics.d.ts.map +1 -1
  59. package/dist/client/analytics.js +2 -1
  60. package/dist/client/analytics.js.map +1 -1
  61. package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
  62. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  63. package/dist/client/extensions/EmbeddedExtension.js +34 -3
  64. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  65. package/dist/collab/routes.d.ts +2 -2
  66. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  67. package/dist/notifications/routes.d.ts +2 -2
  68. package/dist/observability/routes.d.ts +5 -5
  69. package/dist/resources/handlers.d.ts +2 -2
  70. package/dist/server/transcribe-voice.d.ts +1 -1
  71. package/package.json +1 -1
@@ -392,6 +392,9 @@ const enUS = {
392
392
  off: "Off",
393
393
  since: "Since",
394
394
  sectionOptions: "Section options",
395
+ extensionMissingId: "This extension panel has no extension selected.",
396
+ extensionUnavailable:
397
+ "This extension isn't shared with you, or it no longer exists.",
395
398
  panelOptions: "Panel options",
396
399
  fullScreen: "Full screen",
397
400
  refresh: "Refresh",
@@ -5249,6 +5252,8 @@ const translatedAnalyticsDebtTranslations = {
5249
5252
  saveView: "保存视图",
5250
5253
  savedViews: "已保存的视图",
5251
5254
  sectionOptions: "部分选项",
5255
+ extensionMissingId: "此扩展面板未选择任何扩展。",
5256
+ extensionUnavailable: "此扩展未与您共享,或已不存在。",
5252
5257
  sharedWithOrg: "与组织共享",
5253
5258
  unhideFailed: "无法取消隐藏仪表板",
5254
5259
  untitledDashboard: "无标题仪表板",
@@ -5424,6 +5429,10 @@ const translatedAnalyticsDebtTranslations = {
5424
5429
  saveView: "Guardar vista",
5425
5430
  savedViews: "Vistas guardadas",
5426
5431
  sectionOptions: "Opciones de sección",
5432
+ extensionMissingId:
5433
+ "Este panel de extensión no tiene ninguna extensión seleccionada.",
5434
+ extensionUnavailable:
5435
+ "Esta extensión no está compartida contigo o ya no existe.",
5427
5436
  sharedWithOrg: "Compartido con la organización",
5428
5437
  unhideFailed: "No se pudo mostrar el panel",
5429
5438
  untitledDashboard: "Panel de control sin título",
@@ -5601,6 +5610,10 @@ const translatedAnalyticsDebtTranslations = {
5601
5610
  saveView: "Enregistrer la vue",
5602
5611
  savedViews: "Vues enregistrées",
5603
5612
  sectionOptions: "Options de sections",
5613
+ extensionMissingId:
5614
+ "Ce panneau d'extension n'a aucune extension sélectionnée.",
5615
+ extensionUnavailable:
5616
+ "Cette extension n'est pas partagée avec vous ou n'existe plus.",
5604
5617
  sharedWithOrg: "Partagé avec l'organisation",
5605
5618
  unhideFailed: "Impossible d'afficher le tableau de bord",
5606
5619
  untitledDashboard: "Tableau de bord sans titre",
@@ -5779,6 +5792,10 @@ const translatedAnalyticsDebtTranslations = {
5779
5792
  saveView: "Ansicht speichern",
5780
5793
  savedViews: "Gespeicherte Ansichten",
5781
5794
  sectionOptions: "Abschnittsoptionen",
5795
+ extensionMissingId:
5796
+ "Für dieses Erweiterungs-Panel ist keine Erweiterung ausgewählt.",
5797
+ extensionUnavailable:
5798
+ "Diese Erweiterung ist nicht für Sie freigegeben oder existiert nicht mehr.",
5782
5799
  sharedWithOrg: "Mit Org geteilt",
5783
5800
  unhideFailed: "Das Dashboard konnte nicht eingeblendet werden",
5784
5801
  untitledDashboard: "Unbenanntes Dashboard",
@@ -5952,6 +5969,10 @@ const translatedAnalyticsDebtTranslations = {
5952
5969
  saveView: "ビューを保存",
5953
5970
  savedViews: "保存されたビュー",
5954
5971
  sectionOptions: "セクションのオプション",
5972
+ extensionMissingId:
5973
+ "この拡張機能パネルには拡張機能が選択されていません。",
5974
+ extensionUnavailable:
5975
+ "この拡張機能はあなたと共有されていないか、存在しません。",
5955
5976
  sharedWithOrg: "組織と共有",
5956
5977
  unhideFailed: "ダッシュボードを再表示できませんでした",
5957
5978
  untitledDashboard: "無題のダッシュボード",
@@ -6124,6 +6145,10 @@ const translatedAnalyticsDebtTranslations = {
6124
6145
  saveView: "보기 저장",
6125
6146
  savedViews: "저장된 보기",
6126
6147
  sectionOptions: "섹션 옵션",
6148
+ extensionMissingId:
6149
+ "이 확장 프로그램 패널에 선택된 확장 프로그램이 없습니다.",
6150
+ extensionUnavailable:
6151
+ "이 확장 프로그램이 공유되지 않았거나 더 이상 존재하지 않습니다.",
6127
6152
  sharedWithOrg: "조직과 공유됨",
6128
6153
  unhideFailed: "대시보드를 숨기기 해제할 수 없습니다.",
6129
6154
  untitledDashboard: "제목 없는 대시보드",
@@ -6300,6 +6325,10 @@ const translatedAnalyticsDebtTranslations = {
6300
6325
  saveView: "Salvar visualização",
6301
6326
  savedViews: "Visualizações salvas",
6302
6327
  sectionOptions: "Opções de seção",
6328
+ extensionMissingId:
6329
+ "Este painel de extensão não tem nenhuma extensão selecionada.",
6330
+ extensionUnavailable:
6331
+ "Esta extensão não foi compartilhada com você ou não existe mais.",
6303
6332
  sharedWithOrg: "Compartilhado com a organização",
6304
6333
  unhideFailed: "Não foi possível exibir o painel",
6305
6334
  untitledDashboard: "Painel sem título",
@@ -6472,6 +6501,9 @@ const translatedAnalyticsDebtTranslations = {
6472
6501
  saveView: "दृश्य सहेजें",
6473
6502
  savedViews: "सहेजे गए दृश्य",
6474
6503
  sectionOptions: "अनुभाग विकल्प",
6504
+ extensionMissingId: "इस एक्सटेंशन पैनल में कोई एक्सटेंशन चयनित नहीं है।",
6505
+ extensionUnavailable:
6506
+ "यह एक्सटेंशन आपके साथ साझा नहीं किया गया है, या अब मौजूद नहीं है।",
6475
6507
  sharedWithOrg: "संगठन के साथ साझा किया गया",
6476
6508
  unhideFailed: "डैशबोर्ड को उजागर नहीं किया जा सका",
6477
6509
  untitledDashboard: "शीर्षक रहित डैशबोर्ड",
@@ -6644,6 +6676,8 @@ const translatedAnalyticsDebtTranslations = {
6644
6676
  saveView: "حفظ العرض",
6645
6677
  savedViews: "المشاهدات المحفوظة",
6646
6678
  sectionOptions: "خيارات القسم",
6679
+ extensionMissingId: "لم يتم تحديد أي إضافة في لوحة الإضافة هذه.",
6680
+ extensionUnavailable: "هذه الإضافة غير مشاركة معك، أو لم تعد موجودة.",
6647
6681
  sharedWithOrg: "تمت المشاركة مع منظمة",
6648
6682
  unhideFailed: "تعذر إظهار لوحة البيانات",
6649
6683
  untitledDashboard: "لوحة تحكم بلا عنوان",
@@ -121,7 +121,9 @@ export function SqlChartCard({
121
121
  const [expanded, setExpanded] = useState(false);
122
122
  const [exportCsv, setExportCsv] = useState<(() => void) | null>(null);
123
123
  const [shouldLoadData, setShouldLoadData] = useState(
124
- eagerLoad || panel.chartType === "section",
124
+ eagerLoad ||
125
+ panel.chartType === "section" ||
126
+ panel.chartType === "extension",
125
127
  );
126
128
  const cardRef = useRef<HTMLDivElement | null>(null);
127
129
  const chartQueryKey = useMemo(
@@ -159,7 +161,9 @@ export function SqlChartCard({
159
161
  setShouldLoadData(true);
160
162
  return;
161
163
  }
162
- if (panel.chartType === "section") {
164
+ // Sections are layout-only and extensions render their own iframe — neither
165
+ // waits on the intersection observer that gates SQL panels.
166
+ if (panel.chartType === "section" || panel.chartType === "extension") {
163
167
  setShouldLoadData(true);
164
168
  return;
165
169
  }
@@ -286,6 +290,88 @@ export function SqlChartCard({
286
290
  );
287
291
  }
288
292
 
293
+ // Extension panels render their sandboxed iframe full-bleed with no card chrome
294
+ // or title — the extension owns its own UI. Editable dashboards still get a
295
+ // hover overlay for delete/drag. Editing routes through the agent (the manual
296
+ // panel editor has no extension picker), so no inline edit action here.
297
+ if (panel.chartType === "extension") {
298
+ return (
299
+ <div
300
+ ref={setCardNodeRef}
301
+ style={isDragSource ? { zIndex: 50 } : undefined}
302
+ data-dragging={isDragSource ? "true" : undefined}
303
+ className="dashboard-extension-card group relative h-full"
304
+ >
305
+ <SqlChart panel={panel} resolvedSql={resolvedSql} loadData />
306
+ {editable ? (
307
+ <div className="absolute right-1 top-1 flex items-center gap-1 opacity-0 group-hover:opacity-100">
308
+ <DropdownMenu>
309
+ <Tooltip>
310
+ <TooltipTrigger asChild>
311
+ <DropdownMenuTrigger asChild>
312
+ <button
313
+ className="p-1 rounded bg-background/80 text-muted-foreground hover:text-foreground"
314
+ aria-label={t("sqlDashboard.panelOptions")}
315
+ >
316
+ <IconDotsVertical className="h-3.5 w-3.5" />
317
+ </button>
318
+ </DropdownMenuTrigger>
319
+ </TooltipTrigger>
320
+ <TooltipContent>
321
+ {t("sqlDashboard.panelOptions")}
322
+ </TooltipContent>
323
+ </Tooltip>
324
+ <DropdownMenuContent align="end" className="w-40">
325
+ <DropdownMenuItem
326
+ onSelect={(e) => {
327
+ e.preventDefault();
328
+ setConfirmOpen(true);
329
+ }}
330
+ >
331
+ <IconTrash className="h-4 w-4 mr-2" />
332
+ {t("sidebar.delete")}
333
+ </DropdownMenuItem>
334
+ </DropdownMenuContent>
335
+ </DropdownMenu>
336
+ <PanelDragHandle
337
+ panelId={panel.id}
338
+ label={t("sqlDashboard.dragToReorder")}
339
+ className="p-1 rounded bg-background/80 cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground"
340
+ iconClassName="h-3.5 w-3.5"
341
+ />
342
+ </div>
343
+ ) : null}
344
+ {editable ? (
345
+ <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
346
+ <AlertDialogContent>
347
+ <AlertDialogHeader>
348
+ <AlertDialogTitle>
349
+ {t("sqlDashboard.deletePanelTitle")}
350
+ </AlertDialogTitle>
351
+ <AlertDialogDescription>
352
+ {t("sqlDashboard.deletePanelDescription", {
353
+ title: panel.title,
354
+ })}
355
+ </AlertDialogDescription>
356
+ </AlertDialogHeader>
357
+ <AlertDialogFooter>
358
+ <AlertDialogCancel>{t("sidebar.cancel")}</AlertDialogCancel>
359
+ <AlertDialogAction
360
+ onClick={() => {
361
+ setConfirmOpen(false);
362
+ onRemove();
363
+ }}
364
+ >
365
+ {t("sidebar.delete")}
366
+ </AlertDialogAction>
367
+ </AlertDialogFooter>
368
+ </AlertDialogContent>
369
+ </AlertDialog>
370
+ ) : null}
371
+ </div>
372
+ );
373
+ }
374
+
289
375
  // Every non-section panel exposes at least the Full screen view action, so the
290
376
  // options menu always renders — including on read-only / shared dashboards.
291
377
  const showPanelMenu = true;
@@ -15,7 +15,8 @@ export type ChartType =
15
15
  | "pie"
16
16
  | "section"
17
17
  | "heatmap"
18
- | "callout";
18
+ | "callout"
19
+ | "extension";
19
20
 
20
21
  export type FilterType =
21
22
  | "date"
@@ -79,6 +80,11 @@ export interface SqlPanelConfig {
79
80
  sortable?: boolean;
80
81
  columns?: TableColumnConfig[];
81
82
  limit?: number;
83
+ /**
84
+ * Extension panels only (`chartType: "extension"`): id of the extension to
85
+ * render inline as a sandboxed iframe instead of running the SQL pipeline.
86
+ */
87
+ extensionId?: string;
82
88
  }
83
89
 
84
90
  export interface SqlPanel {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Dashboards can now include extension panels that embed a sandboxed extension inline instead of a SQL chart
@@ -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) {