@agent-native/core 0.70.0 → 0.70.2

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 (172) hide show
  1. package/README.md +15 -15
  2. package/corpus/README.md +2 -2
  3. package/corpus/core/CHANGELOG.md +67 -0
  4. package/corpus/core/docs/design/durable-agent-runs.md +217 -0
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +89 -0
  7. package/corpus/core/src/agent/model-config.ts +0 -2
  8. package/corpus/core/src/agent/run-loop-with-resume.ts +48 -0
  9. package/corpus/core/src/cli/skills.ts +2 -2
  10. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +275 -0
  11. package/corpus/core/src/client/AgentPanel.tsx +5 -3
  12. package/corpus/core/src/client/AssistantChat.tsx +8 -0
  13. package/corpus/core/src/client/client-surface.ts +41 -0
  14. package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
  15. package/corpus/core/src/client/feedback-context.ts +4 -0
  16. package/corpus/core/src/client/index.ts +1 -0
  17. package/corpus/core/src/client/sse-event-processor.ts +7 -1
  18. package/corpus/core/src/mcp/build-server.ts +57 -1
  19. package/corpus/core/src/mcp/embed-app.ts +86 -2
  20. package/corpus/core/src/mcp/oauth-route.ts +109 -13
  21. package/corpus/core/src/server/core-routes-plugin.ts +78 -0
  22. package/corpus/core/src/server/onboarding-html.ts +35 -0
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  24. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
  25. package/corpus/templates/analytics/AGENTS.md +8 -1
  26. package/corpus/templates/analytics/actions/install-dashboard-template.ts +97 -2
  27. package/corpus/templates/analytics/actions/update-dashboard.ts +36 -7
  28. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
  29. package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
  30. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -4
  31. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +0 -3
  32. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
  33. package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
  35. package/corpus/templates/assets/actions/generate-image.ts +24 -0
  36. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
  37. package/corpus/templates/assets/app/global.css +17 -1
  38. package/corpus/templates/assets/app/routes/_index.tsx +94 -14
  39. package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
  40. package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
  41. package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
  42. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
  43. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
  44. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
  45. package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
  51. package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
  52. package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
  53. package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
  54. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
  55. package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
  56. package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
  57. package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
  58. package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
  59. package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
  60. package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
  61. package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
  62. package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
  63. package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
  64. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
  65. package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
  66. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
  67. package/corpus/templates/clips/desktop/src/styles.css +70 -2
  68. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +73 -0
  69. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +34 -5
  70. package/corpus/templates/content/AGENTS.md +20 -3
  71. package/corpus/templates/content/actions/_database-utils.ts +15 -0
  72. package/corpus/templates/content/actions/_property-utils.ts +371 -4
  73. package/corpus/templates/content/actions/configure-document-property.ts +35 -1
  74. package/corpus/templates/content/actions/create-content-database.ts +5 -1
  75. package/corpus/templates/content/actions/delete-document-property.ts +52 -2
  76. package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
  77. package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
  78. package/corpus/templates/content/actions/set-document-property.ts +39 -0
  79. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
  80. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
  81. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
  82. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
  83. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
  84. package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
  85. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
  86. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
  87. package/corpus/templates/content/app/global.css +9 -0
  88. package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
  89. package/corpus/templates/content/server/db/schema.ts +30 -0
  90. package/corpus/templates/content/server/plugins/db.ts +87 -0
  91. package/corpus/templates/content/shared/api.ts +7 -0
  92. package/corpus/templates/content/shared/properties.ts +109 -0
  93. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
  94. package/corpus/templates/forms/actions/export-responses.ts +6 -0
  95. package/corpus/templates/forms/actions/list-responses.ts +2 -0
  96. package/corpus/templates/forms/actions/response-insights.ts +2 -0
  97. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
  98. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
  99. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
  100. package/corpus/templates/forms/server/db/schema.ts +7 -0
  101. package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
  102. package/corpus/templates/forms/server/lib/integrations.ts +76 -2
  103. package/corpus/templates/forms/server/plugins/db.ts +20 -0
  104. package/corpus/templates/forms/shared/types.ts +12 -0
  105. package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
  106. package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
  107. package/dist/action.js +87 -0
  108. package/dist/action.js.map +1 -1
  109. package/dist/agent/engine/builder-engine.d.ts +1 -1
  110. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  111. package/dist/agent/model-config.d.ts +2 -2
  112. package/dist/agent/model-config.d.ts.map +1 -1
  113. package/dist/agent/model-config.js +0 -2
  114. package/dist/agent/model-config.js.map +1 -1
  115. package/dist/agent/run-loop-with-resume.d.ts +13 -0
  116. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  117. package/dist/agent/run-loop-with-resume.js +44 -0
  118. package/dist/agent/run-loop-with-resume.js.map +1 -1
  119. package/dist/cli/skills.js +2 -2
  120. package/dist/cli/skills.js.map +1 -1
  121. package/dist/cli/sync-builder-starter-manifest.d.ts +32 -0
  122. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -0
  123. package/dist/cli/sync-builder-starter-manifest.js +179 -0
  124. package/dist/cli/sync-builder-starter-manifest.js.map +1 -0
  125. package/dist/client/AgentPanel.d.ts.map +1 -1
  126. package/dist/client/AgentPanel.js +5 -3
  127. package/dist/client/AgentPanel.js.map +1 -1
  128. package/dist/client/AssistantChat.d.ts +6 -1
  129. package/dist/client/AssistantChat.d.ts.map +1 -1
  130. package/dist/client/AssistantChat.js +2 -2
  131. package/dist/client/AssistantChat.js.map +1 -1
  132. package/dist/client/client-surface.d.ts +17 -0
  133. package/dist/client/client-surface.d.ts.map +1 -0
  134. package/dist/client/client-surface.js +24 -0
  135. package/dist/client/client-surface.js.map +1 -0
  136. package/dist/client/composer/TiptapComposer.d.ts +26 -1
  137. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  138. package/dist/client/composer/TiptapComposer.js +27 -5
  139. package/dist/client/composer/TiptapComposer.js.map +1 -1
  140. package/dist/client/feedback-context.d.ts +3 -0
  141. package/dist/client/feedback-context.d.ts.map +1 -1
  142. package/dist/client/feedback-context.js +2 -0
  143. package/dist/client/feedback-context.js.map +1 -1
  144. package/dist/client/index.d.ts +1 -0
  145. package/dist/client/index.d.ts.map +1 -1
  146. package/dist/client/index.js +1 -0
  147. package/dist/client/index.js.map +1 -1
  148. package/dist/client/sse-event-processor.d.ts.map +1 -1
  149. package/dist/client/sse-event-processor.js +7 -1
  150. package/dist/client/sse-event-processor.js.map +1 -1
  151. package/dist/mcp/build-server.d.ts +21 -0
  152. package/dist/mcp/build-server.d.ts.map +1 -1
  153. package/dist/mcp/build-server.js +43 -1
  154. package/dist/mcp/build-server.js.map +1 -1
  155. package/dist/mcp/embed-app.d.ts.map +1 -1
  156. package/dist/mcp/embed-app.js +86 -2
  157. package/dist/mcp/embed-app.js.map +1 -1
  158. package/dist/mcp/oauth-route.d.ts.map +1 -1
  159. package/dist/mcp/oauth-route.js +95 -12
  160. package/dist/mcp/oauth-route.js.map +1 -1
  161. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  162. package/dist/server/core-routes-plugin.js +63 -0
  163. package/dist/server/core-routes-plugin.js.map +1 -1
  164. package/dist/server/onboarding-html.d.ts.map +1 -1
  165. package/dist/server/onboarding-html.js +35 -0
  166. package/dist/server/onboarding-html.js.map +1 -1
  167. package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  168. package/docs/design/durable-agent-runs.md +217 -0
  169. package/package.json +1 -1
  170. package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  171. package/corpus/templates/analytics/app/pages/About.tsx +0 -108
  172. package/corpus/templates/analytics/app/routes/about.tsx +0 -9
@@ -138,6 +138,13 @@ import {
138
138
  } from "./DocumentProperties";
139
139
  import { EmojiPicker } from "./EmojiPicker";
140
140
  import { VisualEditor } from "./VisualEditor";
141
+ import { DocumentBlockFields } from "./DocumentBlockFields";
142
+ import { createPreviewDocumentSaveController } from "./previewDocumentSaveController";
143
+ import {
144
+ acquirePreviewDocumentSaveController,
145
+ peekPreviewDocumentSaveController,
146
+ releasePreviewDocumentSaveController,
147
+ } from "./previewDocumentSaveRegistry";
141
148
  import { BuilderSourceReviewDialog } from "./database-sources/BuilderSourceReviewDialog";
142
149
  import {
143
150
  BUILDER_CMS_SAFE_WRITE_MODEL,
@@ -168,9 +175,11 @@ import {
168
175
  } from "@shared/api";
169
176
  import {
170
177
  type DocumentPropertyOptionColor,
178
+ countWords,
171
179
  documentPropertyDateKey,
172
180
  documentPropertyDatePart,
173
181
  evaluateNormalizationFormula,
182
+ formatWordCount,
174
183
  formulaValueText,
175
184
  isComputedPropertyType,
176
185
  isEmptyPropertyValue,
@@ -1969,6 +1978,26 @@ function DatabaseItemPreviewSheet({
1969
1978
  );
1970
1979
  }
1971
1980
 
1981
+ function previewPayloadsEqual(
1982
+ a: { title: string; content: string },
1983
+ b: { title: string; content: string },
1984
+ ) {
1985
+ return a.title === b.title && a.content === b.content;
1986
+ }
1987
+
1988
+ function retainedPreviewPayload(
1989
+ documentId: string,
1990
+ serverPayload: { title: string; content: string },
1991
+ ) {
1992
+ const controller = peekPreviewDocumentSaveController(documentId);
1993
+ if (!controller) return null;
1994
+ const dirty = !previewPayloadsEqual(controller.pending, controller.lastSaved);
1995
+ const savedAheadOfServer =
1996
+ controller.hasSavedLocally &&
1997
+ !previewPayloadsEqual(controller.lastSaved, serverPayload);
1998
+ return dirty || savedAheadOfServer ? controller.pending : null;
1999
+ }
2000
+
1972
2001
  function DatabaseItemPreview({
1973
2002
  item,
1974
2003
  previousItem,
@@ -2000,47 +2029,161 @@ function DatabaseItemPreview({
2000
2029
  const previewTitle = databaseItemPreviewTitle(item);
2001
2030
  const canEdit = document?.canEdit ?? item.document.canEdit ?? true;
2002
2031
  const canManage = document?.canManage ?? item.document.canManage ?? false;
2003
- const [localTitle, setLocalTitle] = useState(item.document.title);
2004
- const [localContent, setLocalContent] = useState(item.document.content);
2032
+ // Seed the displayed title/content from a RETAINED dirty controller's pending
2033
+ // edit if one exists for this doc (reopen-before-evict), so an unsaved peek
2034
+ // edit is restored on remount instead of showing stale server content; else
2035
+ // from the server/item value.
2036
+ const [localTitle, setLocalTitle] = useState(() => {
2037
+ const retained = retainedPreviewPayload(item.document.id, {
2038
+ title: item.document.title,
2039
+ content: item.document.content,
2040
+ });
2041
+ return retained?.title ?? item.document.title;
2042
+ });
2043
+ const [localContent, setLocalContent] = useState(() => {
2044
+ const retained = retainedPreviewPayload(item.document.id, {
2045
+ title: item.document.title,
2046
+ content: item.document.content,
2047
+ });
2048
+ return retained?.content ?? item.document.content;
2049
+ });
2005
2050
  const [localIcon, setLocalIcon] = useState(item.document.icon);
2006
2051
  const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
2007
2052
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
2008
- const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2009
2053
  const titleInputRef = useRef<HTMLTextAreaElement>(null);
2010
- const lastSavedRef = useRef({
2011
- title: item.document.title,
2012
- content: item.document.content,
2013
- });
2014
2054
 
2055
+ // The peek's primary title+body save runs through a flush-on-release controller
2056
+ // so a pending debounced edit is PERSISTED — not dropped — when the row
2057
+ // switches, the editor unmounts, or the sheet closes / Open-page navigates.
2058
+ //
2059
+ // ONE CONTROLLER PER DOCUMENT ID (mirrors the additional Blocks fields): the
2060
+ // peek is a SINGLE component instance whose `item` prop changes on row-switch.
2061
+ // Rather than rebasing one controller's target id across rows (which produced a
2062
+ // class of timing races), we ACQUIRE a per-doc controller for the current row
2063
+ // and RELEASE it on switch. A controller's doc id is fixed for its life, so a
2064
+ // flush always lands on the correct document and a stale completion can only
2065
+ // ever touch its own row's state. See previewDocumentSaveRegistry.
2066
+ const updateDocumentRef = useRef(updateDocument);
2067
+ updateDocumentRef.current = updateDocument;
2068
+ const queryClientRef = useRef(queryClient);
2069
+ queryClientRef.current = queryClient;
2070
+ // Doc ids that have been deleted in this peek's lifetime. A pending save must
2071
+ // never resurrect a deleted document, so dispatch is suppressed for these.
2072
+ const deletedIdsRef = useRef<Set<string>>(new Set());
2073
+
2074
+ const documentId = item.document.id;
2075
+
2076
+ // Build the factory for THIS row's controller. It closes over the stable
2077
+ // component-scoped refs (updateDocument, queryClient, deletedIds), so the
2078
+ // freshest mutation impl is always used while the controller's save TARGET
2079
+ // (`documentId`) is fixed by the registry key.
2080
+ const makeController = () =>
2081
+ createPreviewDocumentSaveController({
2082
+ documentId,
2083
+ initial: {
2084
+ title: item.document.title,
2085
+ content: item.document.content,
2086
+ },
2087
+ save: (id, payload) =>
2088
+ new Promise((resolve, reject) => {
2089
+ // A just-deleted doc must not be re-dispatched (resurrection guard).
2090
+ if (deletedIdsRef.current.has(id)) {
2091
+ resolve(undefined);
2092
+ return;
2093
+ }
2094
+ updateDocumentRef.current.mutate(
2095
+ { id, title: payload.title, content: payload.content },
2096
+ { onSuccess: () => resolve(undefined), onError: reject },
2097
+ );
2098
+ }),
2099
+ onSaved: () => {
2100
+ void queryClientRef.current.invalidateQueries({
2101
+ queryKey: ["action", "get-content-database"],
2102
+ });
2103
+ void queryClientRef.current.invalidateQueries({
2104
+ queryKey: ["action", "list-documents"],
2105
+ });
2106
+ },
2107
+ onError: (err) => {
2108
+ toast.error("Failed to save page preview", {
2109
+ description:
2110
+ err instanceof Error ? err.message : "Something went wrong",
2111
+ });
2112
+ },
2113
+ });
2114
+
2115
+ // Acquire the controller for the current row, and release it on row-switch /
2116
+ // unmount. Release flush-then-evicts: the OLD row's latest dirty payload is
2117
+ // dispatched SYNCHRONOUSLY (bound to the OLD doc id) before the new row's
2118
+ // controller takes over, so a pending edit is persisted, not dropped, and never
2119
+ // retargeted. A quick reopen before the flush settles reuses the live instance.
2120
+ // The current controller is held in a ref so the change handlers reach it
2121
+ // synchronously.
2122
+ const saveControllerRef = useRef<ReturnType<typeof makeController> | null>(
2123
+ null,
2124
+ );
2125
+ useEffect(() => {
2126
+ saveControllerRef.current = acquirePreviewDocumentSaveController(
2127
+ documentId,
2128
+ makeController,
2129
+ );
2130
+ return () => {
2131
+ saveControllerRef.current = null;
2132
+ releasePreviewDocumentSaveController(documentId);
2133
+ };
2134
+ // makeController is rebuilt every render but intentionally not a dep: the
2135
+ // registry only invokes it on the FIRST acquire of a doc id, and the doc id
2136
+ // (the registry key) is the only thing that should drive re-acquire.
2137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2138
+ }, [documentId]);
2139
+
2140
+ // Sync displayed state to the current row, and adopt fresh server content
2141
+ // (e.g. an agent edit) as the controller's new confirmed baseline. mark()
2142
+ // touches only THIS row's controller — never another row's — because the
2143
+ // controller's doc id is fixed. The acquire effect above runs first, so the
2144
+ // controller for `documentId` is registered before this fires.
2015
2145
  useEffect(() => {
2016
2146
  const nextTitle = document?.title ?? item.document.title;
2017
2147
  const nextContent = document?.content ?? item.document.content;
2018
2148
  const nextIcon = document?.icon ?? item.document.icon;
2019
- setLocalTitle(nextTitle);
2020
- setLocalContent(nextContent);
2149
+ // Icon isn't tracked by the title/content save controller, so it can always
2150
+ // follow the server.
2021
2151
  setLocalIcon(nextIcon);
2022
- lastSavedRef.current = { title: nextTitle, content: nextContent };
2023
- if (saveTimerRef.current) {
2024
- clearTimeout(saveTimerRef.current);
2025
- saveTimerRef.current = null;
2152
+ const controller = peekPreviewDocumentSaveController(documentId);
2153
+ const serverPayload = { title: nextTitle, content: nextContent };
2154
+ const dirty =
2155
+ !!controller &&
2156
+ !previewPayloadsEqual(controller.pending, controller.lastSaved);
2157
+ const savedAheadOfServer =
2158
+ !!controller &&
2159
+ controller.hasSavedLocally &&
2160
+ !previewPayloadsEqual(controller.lastSaved, serverPayload);
2161
+ // Only adopt the server's title/content — into BOTH the displayed editor
2162
+ // state and the controller baseline — when the user hasn't typed something
2163
+ // newer on this row. If a dirty in-progress edit exists, preserve it: don't
2164
+ // clobber the visible text (the controller already holds the unsaved edit,
2165
+ // so nothing is lost, but the editor must keep showing what the user typed).
2166
+ if (!dirty && !savedAheadOfServer) {
2167
+ setLocalTitle(nextTitle);
2168
+ setLocalContent(nextContent);
2169
+ controller?.mark(serverPayload);
2170
+ } else if (controller) {
2171
+ // A dirty in-progress edit or a clean local save that the query has not
2172
+ // echoed yet is newer than stale server props.
2173
+ setLocalTitle(controller.pending.title);
2174
+ setLocalContent(controller.pending.content);
2026
2175
  }
2027
2176
  }, [
2177
+ documentId,
2028
2178
  document?.id,
2029
2179
  document?.title,
2030
2180
  document?.content,
2031
2181
  document?.icon,
2032
- item.document.id,
2033
2182
  item.document.title,
2034
2183
  item.document.content,
2035
2184
  item.document.icon,
2036
2185
  ]);
2037
2186
 
2038
- useEffect(() => {
2039
- return () => {
2040
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
2041
- };
2042
- }, []);
2043
-
2044
2187
  useEffect(() => {
2045
2188
  if (!focusTitle || !canEdit || isLoading || !document) return;
2046
2189
 
@@ -2053,48 +2196,16 @@ function DatabaseItemPreview({
2053
2196
  return () => cancelAnimationFrame(frame);
2054
2197
  }, [canEdit, document, focusTitle, isLoading, onTitleFocused]);
2055
2198
 
2056
- function savePreviewDocument(next: { title: string; content: string }) {
2057
- if (!canEdit || !document) return;
2058
- if (
2059
- next.title === lastSavedRef.current.title &&
2060
- next.content === lastSavedRef.current.content
2061
- ) {
2062
- return;
2063
- }
2064
-
2065
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
2066
- saveTimerRef.current = setTimeout(() => {
2067
- updateDocument.mutate(
2068
- { id: document.id, title: next.title, content: next.content },
2069
- {
2070
- onSuccess: () => {
2071
- lastSavedRef.current = next;
2072
- void queryClient.invalidateQueries({
2073
- queryKey: ["action", "get-content-database"],
2074
- });
2075
- void queryClient.invalidateQueries({
2076
- queryKey: ["action", "list-documents"],
2077
- });
2078
- },
2079
- onError: (err) => {
2080
- toast.error("Failed to save page preview", {
2081
- description:
2082
- err instanceof Error ? err.message : "Something went wrong",
2083
- });
2084
- },
2085
- },
2086
- );
2087
- }, 450);
2088
- }
2089
-
2090
2199
  function handleTitleChange(nextTitle: string) {
2091
2200
  setLocalTitle(nextTitle);
2092
- savePreviewDocument({ title: nextTitle, content: localContent });
2201
+ if (!canEdit || !document) return;
2202
+ saveControllerRef.current?.changeTitle(nextTitle);
2093
2203
  }
2094
2204
 
2095
2205
  function handleContentChange(nextContent: string) {
2096
2206
  setLocalContent(nextContent);
2097
- savePreviewDocument({ title: localTitle, content: nextContent });
2207
+ if (!canEdit || !document) return;
2208
+ saveControllerRef.current?.changeContent(nextContent);
2098
2209
  }
2099
2210
 
2100
2211
  function handleIconChange(nextIcon: string | null) {
@@ -2142,6 +2253,18 @@ function DatabaseItemPreview({
2142
2253
  }
2143
2254
 
2144
2255
  async function deletePreviewRow() {
2256
+ // Cancel (do NOT flush) before any row switch/close: we're deleting this
2257
+ // document, so a pending save must not resurrect it. Reset pending onto the
2258
+ // last-saved baseline so the controller's release flush is a no-op, and
2259
+ // record the id so any save already in flight is a no-op too (the
2260
+ // controller's save impl skips deleted ids).
2261
+ deletedIdsRef.current.add(item.document.id);
2262
+ const controller = saveControllerRef.current;
2263
+ if (controller) {
2264
+ controller.cancel();
2265
+ controller.mark(controller.lastSaved);
2266
+ }
2267
+
2145
2268
  const nextPreviewItem = nextItem ?? previousItem;
2146
2269
  if (nextPreviewItem) {
2147
2270
  onPreviewItem?.(nextPreviewItem);
@@ -2150,10 +2273,6 @@ function DatabaseItemPreview({
2150
2273
  }
2151
2274
 
2152
2275
  try {
2153
- if (saveTimerRef.current) {
2154
- clearTimeout(saveTimerRef.current);
2155
- saveTimerRef.current = null;
2156
- }
2157
2276
  await deleteDocument.mutateAsync({ id: item.document.id });
2158
2277
  await queryClient.invalidateQueries({
2159
2278
  queryKey: [
@@ -2166,6 +2285,7 @@ function DatabaseItemPreview({
2166
2285
  queryKey: ["action", "list-documents"],
2167
2286
  });
2168
2287
  } catch (err) {
2288
+ deletedIdsRef.current.delete(item.document.id);
2169
2289
  onPreviewItem?.(item);
2170
2290
  toast.error("Failed to delete row", {
2171
2291
  description:
@@ -2327,14 +2447,38 @@ function DatabaseItemPreview({
2327
2447
  />
2328
2448
  ) : null}
2329
2449
  <div className="pt-6">
2330
- <VisualEditor
2331
- key={document.id}
2332
- documentId={document.id}
2333
- content={localContent}
2334
- onChange={handleContentChange}
2335
- ydoc={null}
2336
- editable={canEdit}
2337
- />
2450
+ {(() => {
2451
+ // The peek's primary "Content" Blocks field is the document body.
2452
+ // No collab in the peek (ydoc=null), so it's a plain rich-text
2453
+ // editor saving through the preview document save path.
2454
+ const primaryEditor = (
2455
+ <VisualEditor
2456
+ key={document.id}
2457
+ documentId={document.id}
2458
+ content={localContent}
2459
+ onChange={handleContentChange}
2460
+ ydoc={null}
2461
+ editable={canEdit}
2462
+ />
2463
+ );
2464
+
2465
+ // Render the peek body through the SAME component the full page
2466
+ // uses so ALL Blocks fields (Content + any others) appear with
2467
+ // identical loading/empty/solo/multi behavior — including the
2468
+ // empty state (no editable body when there are zero Blocks
2469
+ // fields). Only database rows have Blocks fields.
2470
+ if (document.databaseMembership) {
2471
+ return (
2472
+ <DocumentBlockFields
2473
+ documentId={document.id}
2474
+ canEdit={canEdit}
2475
+ primaryEditor={primaryEditor}
2476
+ />
2477
+ );
2478
+ }
2479
+
2480
+ return primaryEditor;
2481
+ })()}
2338
2482
  </div>
2339
2483
  </div>
2340
2484
  </div>
@@ -9120,6 +9264,16 @@ function isTablePropertyVisible(
9120
9264
  }
9121
9265
 
9122
9266
  function databaseTableCellDisplayValue(property: DocumentProperty) {
9267
+ // Blocks columns show a word count, never the dumped body content.
9268
+ if (property.definition.type === "blocks") {
9269
+ const content = typeof property.value === "string" ? property.value : "";
9270
+ const words = countWords(content);
9271
+ if (words === 0) return <span aria-hidden="true">&nbsp;</span>;
9272
+ return (
9273
+ <span className="text-muted-foreground">{formatWordCount(content)}</span>
9274
+ );
9275
+ }
9276
+
9123
9277
  if (isEmptyPropertyValue(property.value)) {
9124
9278
  return <span aria-hidden="true">&nbsp;</span>;
9125
9279
  }
@@ -12958,6 +13112,10 @@ function DatabaseTableRow({
12958
13112
  >
12959
13113
  {value}
12960
13114
  </button>
13115
+ ) : itemProperty.definition.type === "blocks" ? (
13116
+ // Blocks cells are a read-only word count in the table; the body
13117
+ // is edited on the page, not inline.
13118
+ value
12961
13119
  ) : canEdit && itemProperty.editable ? (
12962
13120
  <PropertyValuePopover
12963
13121
  property={itemProperty}
@@ -13,6 +13,7 @@ import { VisualEditor } from "./VisualEditor";
13
13
  import { DocumentToolbar } from "./DocumentToolbar";
14
14
  import { DocumentDatabase } from "./DocumentDatabase";
15
15
  import { DocumentProperties } from "./DocumentProperties";
16
+ import { DocumentBlockFields } from "./DocumentBlockFields";
16
17
  import { NotionConflictBanner } from "./NotionConflictBanner";
17
18
  import { EmojiPicker } from "./EmojiPicker";
18
19
  import {
@@ -958,37 +959,65 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
958
959
  }
959
960
  }}
960
961
  >
961
- <VisualEditor
962
- key={documentId}
963
- documentId={documentId}
964
- content={isLocalFileDocument ? localContent : document.content}
965
- contentUpdatedAt={
966
- isLocalFileDocument
967
- ? (localContentUpdatedAt ?? document.updatedAt)
968
- : document.updatedAt
969
- }
970
- onChange={handleContentChange}
971
- ydoc={canEdit && !isLocalFileDocument ? ydoc : null}
972
- awareness={canEdit && !isLocalFileDocument ? awareness : null}
973
- user={currentUser}
974
- editable={canEdit}
975
- localFileMode={isLocalFileDocument}
976
- onComment={
977
- canEdit && !isLocalFileDocument ? handleComment : undefined
978
- }
979
- commentThreads={threads ?? []}
980
- activeThreadId={activeThreadId}
981
- pendingHighlight={pendingComment?.range ?? null}
982
- onActivateThread={
983
- canEdit && !isLocalFileDocument
984
- ? setActiveThreadId
985
- : undefined
962
+ {(() => {
963
+ // The primary "Content" Blocks field IS the document body, with
964
+ // the full collaborative editor. It renders chromeless when it's
965
+ // the only Blocks field, or inside a header/collapsible shell
966
+ // when the row has multiple Blocks fields.
967
+ const primaryEditor = (
968
+ <VisualEditor
969
+ key={documentId}
970
+ documentId={documentId}
971
+ content={
972
+ isLocalFileDocument ? localContent : document.content
973
+ }
974
+ contentUpdatedAt={
975
+ isLocalFileDocument
976
+ ? (localContentUpdatedAt ?? document.updatedAt)
977
+ : document.updatedAt
978
+ }
979
+ onChange={handleContentChange}
980
+ ydoc={canEdit && !isLocalFileDocument ? ydoc : null}
981
+ awareness={
982
+ canEdit && !isLocalFileDocument ? awareness : null
983
+ }
984
+ user={currentUser}
985
+ editable={canEdit}
986
+ localFileMode={isLocalFileDocument}
987
+ onComment={
988
+ canEdit && !isLocalFileDocument
989
+ ? handleComment
990
+ : undefined
991
+ }
992
+ commentThreads={threads ?? []}
993
+ activeThreadId={activeThreadId}
994
+ pendingHighlight={pendingComment?.range ?? null}
995
+ onActivateThread={
996
+ canEdit && !isLocalFileDocument
997
+ ? setActiveThreadId
998
+ : undefined
999
+ }
1000
+ onJoinTitle={joinFirstBodyBlockToTitle}
1001
+ notionPageLinks={notionPageLinks}
1002
+ onOpenNotionPageLink={handleOpenNotionPageLink}
1003
+ notionPageId={document.notionPageId}
1004
+ />
1005
+ );
1006
+
1007
+ // Only database rows have Blocks fields. Standalone pages and
1008
+ // local-file documents keep the plain chromeless body.
1009
+ if (document.databaseMembership && !isLocalFileDocument) {
1010
+ return (
1011
+ <DocumentBlockFields
1012
+ documentId={documentId}
1013
+ canEdit={canEdit}
1014
+ primaryEditor={primaryEditor}
1015
+ />
1016
+ );
986
1017
  }
987
- onJoinTitle={joinFirstBodyBlockToTitle}
988
- notionPageLinks={notionPageLinks}
989
- onOpenNotionPageLink={handleOpenNotionPageLink}
990
- notionPageId={document.notionPageId}
991
- />
1018
+
1019
+ return primaryEditor;
1020
+ })()}
992
1021
  </div>
993
1022
  </div>
994
1023
  </div>
@@ -19,6 +19,7 @@ import {
19
19
  IconEdit,
20
20
  IconEye,
21
21
  IconEyeOff,
22
+ IconFileText,
22
23
  IconHash,
23
24
  IconLink,
24
25
  IconList,
@@ -93,6 +94,7 @@ import {
93
94
  documentPropertyDatePart,
94
95
  isEmptyPropertyValue,
95
96
  isComputedPropertyType,
97
+ isOnlyBlocksFieldDeletion,
96
98
  normalizeDatePropertyValue,
97
99
  type DocumentPropertyDateValue,
98
100
  type DocumentPropertyOption,
@@ -132,6 +134,7 @@ export const TYPE_ICONS: Record<DocumentPropertyType, Icon> = {
132
134
  email: IconAt,
133
135
  phone: IconPhone,
134
136
  relation: IconLink,
137
+ blocks: IconFileText,
135
138
  id: IconNumber,
136
139
  created_time: IconClockFilled,
137
140
  created_by: IconUserCircle,
@@ -171,6 +174,7 @@ const PROPERTY_TYPE_SEARCH_ALIASES: Partial<
171
174
  formula: ["calculate", "calculation", "computed", "equation"],
172
175
  relation: ["relationship", "linked", "link", "database"],
173
176
  rollup: ["aggregate", "aggregation", "sum", "count", "relation"],
177
+ blocks: ["content", "body", "rich text", "rich-text", "page", "notes"],
174
178
  };
175
179
 
176
180
  function slugify(value: string) {
@@ -642,7 +646,11 @@ export function DocumentProperties({
642
646
  popoversPortalled = true,
643
647
  }: DocumentPropertiesProps) {
644
648
  const { data, isLoading } = useDocumentProperties(documentId);
645
- const properties = data?.properties ?? [];
649
+ // Blocks fields are rendered as body content (below the database/title), not
650
+ // as scalar property rows in this panel — exclude them here.
651
+ const properties = (data?.properties ?? []).filter(
652
+ (property) => property.definition.type !== "blocks",
653
+ );
646
654
  const databaseId = data?.databaseId ?? null;
647
655
  const visibleProperties = properties.filter(isPropertyVisible);
648
656
  const hiddenProperties = properties.filter(
@@ -824,6 +832,16 @@ export function PropertyManagementPopover({
824
832
  const configure = useConfigureDocumentProperty(documentId);
825
833
  const duplicate = useDuplicateDocumentProperty(documentId);
826
834
  const remove = useDeleteDocumentProperty(documentId);
835
+ const { data: propertiesData } = useDocumentProperties(documentId);
836
+ // Whether deleting THIS property removes the last Blocks field of the type —
837
+ // i.e. the body. Drives the yellow warning in the delete dialog.
838
+ const blocksFieldCount = (propertiesData?.properties ?? []).filter(
839
+ (item) => item.definition.type === "blocks",
840
+ ).length;
841
+ const isOnlyBlocksField = isOnlyBlocksFieldDeletion({
842
+ type: property.definition.type,
843
+ blocksFieldCount,
844
+ });
827
845
  const [open, setOpen] = useState(false);
828
846
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
829
847
  const [name, setName] = useState(property.definition.name);
@@ -1173,6 +1191,12 @@ export function PropertyManagementPopover({
1173
1191
  every document in this workspace.
1174
1192
  </AlertDialogDescription>
1175
1193
  </AlertDialogHeader>
1194
+ {isOnlyBlocksField ? (
1195
+ <div className="rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-800 dark:text-yellow-200">
1196
+ This is the only blocks property present in this object type. This
1197
+ will delete the main content (body) for all objects of this type.
1198
+ </div>
1199
+ ) : null}
1176
1200
  <AlertDialogFooter>
1177
1201
  <AlertDialogCancel>Cancel</AlertDialogCancel>
1178
1202
  <AlertDialogAction