@agent-native/core 0.148.1 → 0.149.1

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 (165) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/changelog/2026-08-10-wide-tables-in-analytics-chat-now-scroll-horizontally-within.md +6 -0
  3. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +13 -53
  4. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +11 -70
  5. package/corpus/templates/calendar/app/lib/event-layout.ts +122 -0
  6. package/corpus/templates/calendar/app/routes/_app.availability.tsx +1 -1
  7. package/corpus/templates/calendar/changelog/2026-08-10-opening-availability-settings-from-the-agent-lands-on-the-av.md +6 -0
  8. package/corpus/templates/calendar/vitest.config.ts +6 -1
  9. package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +3 -1
  10. package/corpus/templates/clips/app/components/player/video-player.tsx +44 -4
  11. package/corpus/templates/clips/changelog/2026-08-10-fixed-transcription-failures-for-recordings-without-captured.md +6 -0
  12. package/corpus/templates/clips/changelog/2026-08-10-mobile-clips-can-now-enter-fullscreen-on-iphone-safari.md +6 -0
  13. package/corpus/templates/content/actions/_property-utils.ts +11 -1
  14. package/corpus/templates/content/actions/view-screen.ts +62 -12
  15. package/corpus/templates/content/server/plugins/agent-chat.ts +2 -0
  16. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +22 -0
  17. package/corpus/templates/design/AGENTS.md +3 -0
  18. package/corpus/templates/design/actions/create-design-from-template.ts +19 -0
  19. package/corpus/templates/design/actions/get-design-snapshot.ts +18 -0
  20. package/corpus/templates/design/actions/get-design-template.ts +178 -0
  21. package/corpus/templates/design/actions/list-designs.ts +3 -1
  22. package/corpus/templates/design/actions/view-screen.ts +31 -0
  23. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +225 -214
  24. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
  25. package/corpus/templates/design/app/i18n-data.ts +78 -0
  26. package/corpus/templates/design/app/lib/design-authors.ts +64 -0
  27. package/corpus/templates/design/app/pages/Index.tsx +114 -4
  28. package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +1 -0
  29. package/corpus/templates/design/changelog/2026-08-07-alt-dragging-an-element-to-copy-it-now-keeps-the-copy-visibl.md +6 -0
  30. package/corpus/templates/design/changelog/2026-08-07-design-cards-now-show-who-created-each-design-with-a-filter-.md +6 -0
  31. package/corpus/templates/design/changelog/2026-08-07-drawing-a-shape-on-the-overview-canvas-now-keeps-the-shape-s.md +6 -0
  32. package/corpus/templates/design/changelog/2026-08-07-hold-option-alt-while-dragging-a-frame-on-the-overview-canva.md +6 -0
  33. package/corpus/templates/design/server/lib/design-template-data.ts +143 -0
  34. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -0
  35. package/corpus/templates/forms/AGENTS.md +2 -2
  36. package/corpus/templates/forms/actions/create-form.ts +1 -1
  37. package/corpus/templates/forms/actions/update-form.ts +5 -5
  38. package/corpus/templates/forms/changelog/2026-08-10-forms-now-keep-agent-applied-field-changes-visible-after-an-.md +6 -0
  39. package/corpus/templates/forms/changelog/2026-08-10-published-form-links-always-use-the-working-public-form-url.md +6 -0
  40. package/corpus/templates/forms/server/plugins/agent-chat.ts +2 -2
  41. package/corpus/templates/slides/AGENTS.md +4 -3
  42. package/corpus/templates/slides/actions/import-google-slides-reference.ts +56 -10
  43. package/corpus/templates/slides/actions/patch-deck.ts +18 -2
  44. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +46 -0
  45. package/corpus/templates/slides/app/components/editor/BlockBubbleMenu.tsx +194 -3
  46. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +22 -75
  47. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +5 -3
  48. package/corpus/templates/slides/app/components/editor/GeneratingSlidePreview.tsx +5 -1
  49. package/corpus/templates/slides/app/components/editor/GoogleDriveConnectionCta.tsx +15 -4
  50. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +116 -55
  51. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +27 -4
  52. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +45 -0
  53. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +143 -18
  54. package/corpus/templates/slides/app/i18n/en-US.ts +6 -0
  55. package/corpus/templates/slides/app/lib/export-pdf-client.ts +84 -25
  56. package/corpus/templates/slides/app/lib/generation-state.ts +7 -1
  57. package/corpus/templates/slides/app/lib/import-uploaded-deck.ts +11 -0
  58. package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -14
  59. package/corpus/templates/slides/app/pages/Index.tsx +124 -135
  60. package/corpus/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md +6 -0
  61. package/corpus/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md +6 -0
  62. package/corpus/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md +6 -0
  63. package/corpus/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md +6 -0
  64. package/corpus/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md +6 -0
  65. package/corpus/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md +6 -0
  66. package/corpus/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md +6 -0
  67. package/corpus/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md +6 -0
  68. package/corpus/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md +6 -0
  69. package/corpus/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md +6 -0
  70. package/corpus/templates/slides/changelog/2026-08-10-imported-reference-decks-now-show-clear-success-and-stay-sel.md +6 -0
  71. package/corpus/templates/slides/changelog/2026-08-10-pasted-google-slides-links-import-after-reconnecting-google-.md +6 -0
  72. package/corpus/templates/slides/server/handlers/google-docs-auth.ts +4 -0
  73. package/corpus/templates/slides/server/handlers/import/html-converter.ts +115 -22
  74. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +11 -2
  75. package/corpus/templates/slides/server/lib/fetch-remote-image.ts +192 -0
  76. package/corpus/templates/slides/server/lib/google-docs-oauth.ts +13 -0
  77. package/corpus/templates/slides/server/lib/image-proxy-url.ts +192 -0
  78. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  79. package/corpus/templates/slides/server/routes/api/image-proxy.get.ts +65 -0
  80. package/dist/agent/engine/registry.d.ts +8 -2
  81. package/dist/agent/engine/registry.js +63 -29
  82. package/dist/agent/production-agent.js +7 -0
  83. package/dist/agent/run-manager.d.ts +5 -4
  84. package/dist/agent/run-manager.js +6 -5
  85. package/dist/cli/claude-code-participant.d.ts +2 -1
  86. package/dist/cli/claude-code-participant.js +19 -0
  87. package/dist/cli/code-agent-executor.js +223 -0
  88. package/dist/cli/create-workspace.js +3 -3
  89. package/dist/cli/create.d.ts +3 -2
  90. package/dist/cli/create.js +20 -24
  91. package/dist/client/AgentPanel.js +8 -4
  92. package/dist/client/AssistantChat.js +34 -1
  93. package/dist/client/agent-chat-adapter.d.ts +1 -0
  94. package/dist/client/agent-chat-adapter.js +10 -2
  95. package/dist/client/chat/markdown-renderer.d.ts +1 -0
  96. package/dist/client/chat/markdown-renderer.js +4 -0
  97. package/dist/client/chat/message-components.d.ts +1 -0
  98. package/dist/client/chat/message-components.js +9 -1
  99. package/dist/client/chat/tool-call-display.d.ts +8 -0
  100. package/dist/client/chat/tool-call-display.js +30 -21
  101. package/dist/client/chat-first/app-pane.js +1 -1
  102. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  103. package/dist/client/chat-first/apps-rail.js +11 -11
  104. package/dist/client/chat-first/chat-history.d.ts +1 -1
  105. package/dist/client/chat-first/chat-history.js +2 -0
  106. package/dist/client/chat-first/primary-nav.js +1 -1
  107. package/dist/client/progress/RunsTray.js +14 -1
  108. package/dist/client/resources/McpConnectionSuggestion.js +2 -2
  109. package/dist/client/settings/SettingsTabsPage.d.ts +3 -1
  110. package/dist/client/settings/SettingsTabsPage.js +2 -2
  111. package/dist/client/sse-event-processor.d.ts +8 -0
  112. package/dist/client/sse-event-processor.js +13 -6
  113. package/dist/collab/awareness.d.ts +2 -2
  114. package/dist/collab/routes.d.ts +1 -1
  115. package/dist/deploy/build.d.ts +5 -7
  116. package/dist/deploy/build.js +19 -12
  117. package/dist/localization/default-messages.js +1 -1
  118. package/dist/notifications/routes.d.ts +3 -3
  119. package/dist/observability/routes.d.ts +6 -6
  120. package/dist/provider-api/actions/provider-api.d.ts +2 -2
  121. package/dist/scripts/call-agent.js +3 -3
  122. package/dist/scripts/describe-workspace-apps.js +1 -1
  123. package/dist/server/agent-capabilities.js +0 -0
  124. package/dist/server/agent-chat/context-tools.js +1 -1
  125. package/dist/server/agent-chat/prompt-resources.js +1 -1
  126. package/dist/server/agent-chat-plugin.js +1 -1
  127. package/dist/server/auth.js +190 -42
  128. package/dist/server/better-auth-migrations.js +53 -0
  129. package/dist/server/http-response-telemetry.js +7 -1
  130. package/dist/server/onboarding-html.js +119 -33
  131. package/dist/server/transcribe-voice.d.ts +1 -1
  132. package/dist/shared/password-policy.d.ts +2 -1
  133. package/dist/shared/password-policy.js +2 -1
  134. package/dist/styles/agent-conversation.css +20 -3
  135. package/dist/styles/agent-native.css +14 -2
  136. package/dist/templates/default/app/i18n/ar-SA.ts +1 -1
  137. package/dist/templates/default/app/i18n/de-DE.ts +1 -1
  138. package/dist/templates/default/app/i18n/en-US.ts +1 -1
  139. package/dist/templates/default/app/i18n/es-ES.ts +1 -1
  140. package/dist/templates/default/app/i18n/fr-FR.ts +1 -1
  141. package/dist/templates/default/app/i18n/hi-IN.ts +1 -1
  142. package/dist/templates/default/app/i18n/ja-JP.ts +1 -1
  143. package/dist/templates/default/app/i18n/ko-KR.ts +1 -1
  144. package/dist/templates/default/app/i18n/pt-BR.ts +1 -1
  145. package/dist/templates/default/app/i18n/zh-CN.ts +1 -1
  146. package/dist/templates/default/app/i18n/zh-TW.ts +1 -1
  147. package/dist/templates/default/pnpm-workspace.yaml +1 -0
  148. package/dist/templates/workspace-root/pnpm-workspace.yaml +2 -0
  149. package/docs/content/cloneable-saas.mdx +6 -6
  150. package/docs/content/deployment.mdx +10 -8
  151. package/docs/content/environment-variables.mdx +4 -3
  152. package/package.json +2 -2
  153. package/src/templates/default/app/i18n/ar-SA.ts +1 -1
  154. package/src/templates/default/app/i18n/de-DE.ts +1 -1
  155. package/src/templates/default/app/i18n/en-US.ts +1 -1
  156. package/src/templates/default/app/i18n/es-ES.ts +1 -1
  157. package/src/templates/default/app/i18n/fr-FR.ts +1 -1
  158. package/src/templates/default/app/i18n/hi-IN.ts +1 -1
  159. package/src/templates/default/app/i18n/ja-JP.ts +1 -1
  160. package/src/templates/default/app/i18n/ko-KR.ts +1 -1
  161. package/src/templates/default/app/i18n/pt-BR.ts +1 -1
  162. package/src/templates/default/app/i18n/zh-CN.ts +1 -1
  163. package/src/templates/default/app/i18n/zh-TW.ts +1 -1
  164. package/src/templates/default/pnpm-workspace.yaml +1 -0
  165. package/src/templates/workspace-root/pnpm-workspace.yaml +2 -0
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-10
4
+ ---
5
+
6
+ Published form links always use the working public form URL
@@ -6,7 +6,7 @@ import {
6
6
 
7
7
  import actionsRegistry from "../../.generated/actions-registry.js";
8
8
 
9
- const FORMS_SYSTEM_PROMPT = `## Agent-Native Forms
9
+ export const FORMS_SYSTEM_PROMPT = `## Agent-Native Forms
10
10
 
11
11
  You are the in-app agent for Agent-Native Forms, a chat-first form builder and response workspace. Help users create, edit, publish, share, and analyze forms through the registered actions. The actions are the source of truth for database access, validation, permissions, UI sync, and business logic.
12
12
 
@@ -15,7 +15,7 @@ Core rules:
15
15
  - Never hardcode API keys, tokens, webhook URLs, signing secrets, customer data, or credential-looking literals. Use placeholders or configured integrations.
16
16
  - Inspect the current state when it matters. Use \`view-screen\` when the active form, selected field, publish state, response table, or current route is unclear.
17
17
  - Use \`create-form\`, \`update-form\`, \`patch-form-fields\`, \`restore-form\`, and \`delete-form\` for form lifecycle and field edits. Treat \`delete-form\` as Archive by default; pass \`purge: true\` only when the user explicitly asks to permanently delete a form and its responses.
18
- - When the user asks for an anonymous feedback form or survey, create the complete form atomically with \`status: "published"\`, then return the exact public response URL from the successful action result. Do not return only the private editor link, and do not claim it is live without verifying the saved status and fields.
18
+ - When a form is published, copy the returned \`publicUrl\` verbatim into the response. Never derive a public link from \`slug\` or remove the \`/f/\` path segment; if \`publicUrl\` is missing, say that the public link could not be retrieved instead of inventing one. For an anonymous feedback form or survey, create the complete form atomically with \`status: "published"\` and verify the saved status and fields before claiming it is live.
19
19
  - Use \`preview-form\` when the user asks about a form's setup, fields, configuration, publish state, or response count. It renders a native chat summary.
20
20
  - When the user wants email notifications for new responses, set \`settings.emailOnNewResponses: true\` with \`create-form\` or \`update-form\`; notifications go to the form owner's account email.
21
21
  - When the user wants Slack, Discord, Google Sheets, or webhook notifications, explain that Forms already supports them from the form builder's Integrations tab and navigate there with \`tab: "integrations"\`. Slack uses an Incoming Webhook URL; Google Sheets uses a deployed Apps Script \`/exec\` URL that parses \`e.postData.contents\`. Do not redirect users to the managed Messaging integration for these form destinations.
@@ -42,9 +42,10 @@ Before building common workspace or agent UI, read `agent-native-toolkit` and
42
42
  Drive API needs, use `provider-api-catalog`, `provider-api-docs`, and
43
43
  `provider-api-request`; auth comes from the user's Google Docs OAuth. Stage
44
44
  large scans with `stageAs` and analyze them via `query-staged-dataset`.
45
- - `import-google-slides-reference` accepts either a Picker `fileId` or a
46
- `presentationUrl`; imported PPTX timing metadata, including by-paragraph
47
- reveals, stays on the deck's slide records.
45
+ - `import-google-slides-reference` accepts a Picker `fileId` or `presentationUrl`;
46
+ pasted URLs may need a one-time Google reconnect for Drive export. Preserve
47
+ imported PPTX timing metadata, including by-paragraph reveals, on slide
48
+ records.
48
49
  - For images, use `generate-image-api` with provenance; show results as
49
50
  `![alt](url)`.
50
51
  - Ask a sibling app's agent with a natural-language `call-agent` message by
@@ -64,9 +64,7 @@ async function exportGoogleSlidesAsPptx(fileId: string, accessToken: string) {
64
64
  );
65
65
 
66
66
  if (!response.ok) {
67
- throw new Error(
68
- `Google Drive returned HTTP ${response.status} while exporting the presentation.`,
69
- );
67
+ throw googleSlidesExportError(response.status);
70
68
  }
71
69
 
72
70
  const bytes = await response.arrayBuffer();
@@ -80,14 +78,15 @@ async function exportGoogleSlidesAsPptx(fileId: string, accessToken: string) {
80
78
  interface GoogleSlidesImageElement {
81
79
  objectId?: string;
82
80
  size?: {
83
- width?: { magnitude?: number };
84
- height?: { magnitude?: number };
81
+ width?: { magnitude?: number; unit?: string };
82
+ height?: { magnitude?: number; unit?: string };
85
83
  };
86
84
  transform?: {
87
85
  scaleX?: number;
88
86
  scaleY?: number;
89
87
  translateX?: number;
90
88
  translateY?: number;
89
+ unit?: string;
91
90
  };
92
91
  image?: {
93
92
  contentUrl?: string;
@@ -124,6 +123,41 @@ function finiteNumber(value: number | undefined): number | undefined {
124
123
  return value != null && Number.isFinite(value) ? value : undefined;
125
124
  }
126
125
 
126
+ const EMU_PER_POINT = 12700;
127
+
128
+ export function googleSlidesMeasurementToEmu(
129
+ value: number | undefined,
130
+ unit?: string,
131
+ ): number | undefined {
132
+ const finite = finiteNumber(value);
133
+ if (finite == null) return undefined;
134
+ return unit === "PT" ? finite * EMU_PER_POINT : finite;
135
+ }
136
+
137
+ export function googleSlidesExportError(
138
+ status: number,
139
+ ): Error & { statusCode?: number } {
140
+ if (status === 401) {
141
+ return Object.assign(
142
+ new Error(
143
+ "Google Drive connection expired. Connect Google again, then retry the import.",
144
+ ),
145
+ { statusCode: 401 },
146
+ );
147
+ }
148
+ if (status === 403) {
149
+ return Object.assign(
150
+ new Error(
151
+ "Google Drive denied access to this presentation. Connect Google again to enable pasted-link imports, or choose the deck through Google Picker.",
152
+ ),
153
+ { statusCode: 403 },
154
+ );
155
+ }
156
+ return new Error(
157
+ `Google Drive returned HTTP ${status} while exporting the presentation.`,
158
+ );
159
+ }
160
+
127
161
  /**
128
162
  * Google Drive's PPTX export can omit image page elements that remain present
129
163
  * in the native Slides document. Read those native objects as a fidelity
@@ -135,7 +169,7 @@ async function fetchGoogleSlidesImageFallbacks(
135
169
  presentation: ParsedPresentation,
136
170
  ): Promise<ImportedImageFallback[]> {
137
171
  const fields =
138
- "slides(pageElements(objectId,size,transform,image(contentUrl,imageProperties(cropProperties))))";
172
+ "slides(pageElements(objectId,size(width(magnitude,unit),height(magnitude,unit)),transform(scaleX,scaleY,translateX,translateY,unit),image(contentUrl,imageProperties(cropProperties))))";
139
173
  const response = await ssrfSafeFetch(
140
174
  `https://slides.googleapis.com/v1/presentations/${encodeURIComponent(fileId)}?fields=${encodeURIComponent(fields)}`,
141
175
  { headers: { Authorization: `Bearer ${accessToken}` } },
@@ -156,12 +190,24 @@ async function fetchGoogleSlidesImageFallbacks(
156
190
  ).entries()) {
157
191
  for (const [imageIndex, element] of (slide.pageElements ?? []).entries()) {
158
192
  const contentUrl = element.image?.contentUrl;
159
- const baseWidth = finiteNumber(element.size?.width?.magnitude);
160
- const baseHeight = finiteNumber(element.size?.height?.magnitude);
193
+ const baseWidth = googleSlidesMeasurementToEmu(
194
+ element.size?.width?.magnitude,
195
+ element.size?.width?.unit,
196
+ );
197
+ const baseHeight = googleSlidesMeasurementToEmu(
198
+ element.size?.height?.magnitude,
199
+ element.size?.height?.unit,
200
+ );
161
201
  const scaleX = finiteNumber(element.transform?.scaleX) ?? 1;
162
202
  const scaleY = finiteNumber(element.transform?.scaleY) ?? 1;
163
- const x = finiteNumber(element.transform?.translateX);
164
- const y = finiteNumber(element.transform?.translateY);
203
+ const x = googleSlidesMeasurementToEmu(
204
+ element.transform?.translateX,
205
+ element.transform?.unit,
206
+ );
207
+ const y = googleSlidesMeasurementToEmu(
208
+ element.transform?.translateY,
209
+ element.transform?.unit,
210
+ );
165
211
  const width =
166
212
  baseWidth == null ? undefined : baseWidth * Math.abs(scaleX);
167
213
  const height =
@@ -370,6 +370,18 @@ export function resolveDeckColumnUpdates(
370
370
  };
371
371
  }
372
372
 
373
+ /**
374
+ * The source-preservation guards (`assertSourceImportOperationsPreserved`,
375
+ * `assertSourceSlidePreserved`) exist for one failure mode: an agent asked to
376
+ * "make it prettier" silently dropping the original PDF/PPTX artwork or
377
+ * factual copy. A human editing their own imported deck in the browser isn't
378
+ * that failure mode, and the browser editor has no way to pass
379
+ * `preserveSource` — so these guards must only run for agent callers.
380
+ */
381
+ export function isAgentPatchCaller(caller: string | undefined): boolean {
382
+ return caller === "tool" || caller === "mcp" || caller === "a2a";
383
+ }
384
+
373
385
  // ---------------------------------------------------------------------------
374
386
  // Action definition
375
387
  // ---------------------------------------------------------------------------
@@ -402,8 +414,9 @@ export default defineAction({
402
414
  ),
403
415
  }),
404
416
  agentInputSchema: AgentPatchDeckInputSchema,
405
- run: async ({ deckId, operations, creativeContext }) => {
417
+ run: async ({ deckId, operations, creativeContext }, ctx) => {
406
418
  await assertAccess("deck", deckId, "editor");
419
+ const isAgentCaller = isAgentPatchCaller(ctx?.caller);
407
420
 
408
421
  return withDeckLock(deckId, async () => {
409
422
  const db = getDb();
@@ -435,9 +448,12 @@ export default defineAction({
435
448
  }
436
449
 
437
450
  const sourceImport = sourceImportForDeck(deck.sourceImport);
438
- assertSourceImportOperationsPreserved(sourceImport, operations);
451
+ if (isAgentCaller) {
452
+ assertSourceImportOperationsPreserved(sourceImport, operations);
453
+ }
439
454
  for (const op of operations) {
440
455
  if (
456
+ !isAgentCaller ||
441
457
  op.op !== "patch-slide" ||
442
458
  (op.fields.content === undefined && op.fields.notes === undefined)
443
459
  ) {
@@ -385,6 +385,29 @@ function useSlideAutofit(
385
385
 
386
386
  let raf = 0;
387
387
  let disposed = false;
388
+ // Measuring costs a full-document reflow per slide (every descendant is
389
+ // read with getBoundingClientRect, interleaved with style writes). A deck
390
+ // with dozens of slides mounts that many renderers at once, so off-screen
391
+ // thumbnails are left unmeasured until they scroll into view. Without an
392
+ // IntersectionObserver there is nothing to defer against, so measure
393
+ // eagerly as before.
394
+ const canDefer = typeof IntersectionObserver !== "undefined";
395
+ // Resolved synchronously rather than waiting for the observer's first
396
+ // callback: a slide that is already on screen must be measured on this
397
+ // pass, and nothing may depend on a callback that a given environment
398
+ // might never deliver. This reads one rect, not one per descendant.
399
+ const isNearViewport = () => {
400
+ const rect = root.getBoundingClientRect();
401
+ const margin = 200;
402
+ return (
403
+ rect.bottom >= -margin &&
404
+ rect.right >= -margin &&
405
+ rect.top <= (window.innerHeight || 0) + margin &&
406
+ rect.left <= (window.innerWidth || 0) + margin
407
+ );
408
+ };
409
+ let visible = !canDefer || isNearViewport();
410
+ let measurePending = false;
388
411
 
389
412
  const resetTarget = (target: HTMLElement) => {
390
413
  target.style.setProperty("--fmd-fit-scale", "1");
@@ -482,6 +505,10 @@ function useSlideAutofit(
482
505
 
483
506
  const scheduleMeasure = () => {
484
507
  if (disposed) return;
508
+ if (!visible) {
509
+ measurePending = true;
510
+ return;
511
+ }
485
512
  cancelAnimationFrame(raf);
486
513
  raf = requestAnimationFrame(measureNow);
487
514
  };
@@ -502,11 +529,30 @@ function useSlideAutofit(
502
529
  root.addEventListener("load", scheduleMeasure, true);
503
530
  document.fonts?.ready.then(scheduleMeasure).catch(() => {});
504
531
 
532
+ // `rootMargin` measures a thumbnail just before it scrolls in, so the fit
533
+ // transform is already applied by the time it is on screen.
534
+ const visibilityObserver = canDefer
535
+ ? new IntersectionObserver(
536
+ (entries) => {
537
+ const isVisible = entries.some((entry) => entry.isIntersecting);
538
+ if (isVisible === visible) return;
539
+ visible = isVisible;
540
+ if (visible && measurePending) {
541
+ measurePending = false;
542
+ scheduleMeasure();
543
+ }
544
+ },
545
+ { rootMargin: "200px" },
546
+ )
547
+ : null;
548
+ visibilityObserver?.observe(root);
549
+
505
550
  return () => {
506
551
  disposed = true;
507
552
  cancelAnimationFrame(raf);
508
553
  resizeObserver.disconnect();
509
554
  mutationObserver.disconnect();
555
+ visibilityObserver?.disconnect();
510
556
  root.removeEventListener("load", scheduleMeasure, true);
511
557
  };
512
558
  }, [canvasWidth, canvasHeight, fitKey, ref]);
@@ -1,3 +1,4 @@
1
+ import { sendToAgentChatAndConfirm } from "@agent-native/core/client/agent-chat";
1
2
  import { useT } from "@agent-native/core/client/i18n";
2
3
  import {
3
4
  IconBold,
@@ -6,11 +7,15 @@ import {
6
7
  IconStrikethrough,
7
8
  IconLink,
8
9
  IconPalette,
10
+ IconPencilStar,
9
11
  IconCheck,
10
12
  IconX,
13
+ IconArrowUp,
14
+ IconLoader2,
11
15
  } from "@tabler/icons-react";
12
16
  import { useEffect, useState, useRef } from "react";
13
17
  import { createPortal } from "react-dom";
18
+ import { toast } from "sonner";
14
19
 
15
20
  import {
16
21
  Tooltip,
@@ -22,6 +27,17 @@ import { shortcutLabel } from "@/lib/utils";
22
27
  interface BlockBubbleMenuProps {
23
28
  /** The element currently in contentEditable mode. Menu only shows while selection is inside it. */
24
29
  editingEl: HTMLElement | null;
30
+ /** Slide the edited block belongs to, so an AI revision can target it. */
31
+ slideId?: string;
32
+ /** Deck that owns the slide — pins the revision to the right deck. */
33
+ deckId?: string;
34
+ /**
35
+ * Ends the inline edit session and persists whatever is in the DOM now.
36
+ * Required before handing work to the agent: otherwise the still-open
37
+ * contentEditable serializes its stale text on the user's next click and
38
+ * overwrites the revision the agent just wrote.
39
+ */
40
+ onCommitInlineEdit?: () => void;
25
41
  }
26
42
 
27
43
  interface Position {
@@ -44,32 +60,89 @@ const COLORS = [
44
60
  "#EF4444",
45
61
  ];
46
62
 
63
+ /** Shown above the input so the user can confirm what the agent will rewrite. */
64
+ const AI_TARGET_PREVIEW_LIMIT = 160;
65
+
66
+ const AI_SEND_BUTTON_CLASS =
67
+ // guard:allow-raw-color — same accent as the link Apply button below.
68
+ "rounded p-1.5 text-[#609FF8] hover:bg-accent disabled:pointer-events-none disabled:opacity-40";
69
+
70
+ export function buildReviseSelectionPrompt({
71
+ selectedText,
72
+ instruction,
73
+ slideId,
74
+ deckId,
75
+ }: {
76
+ selectedText: string;
77
+ instruction: string;
78
+ slideId?: string;
79
+ deckId?: string;
80
+ }): string {
81
+ // The deck is named explicitly rather than left to "the current slide": the
82
+ // request is queued, and if the user opens another deck before the agent
83
+ // runs, an implicit target would pair this slide id with the wrong deck.
84
+ const target = [
85
+ deckId ? `Deck id: \`${deckId}\`` : null,
86
+ slideId ? `Slide id: \`${slideId}\`` : null,
87
+ ].filter((line) => line !== null);
88
+
89
+ return [
90
+ `Revise this exact text:`,
91
+ ``,
92
+ `"""`,
93
+ selectedText,
94
+ `"""`,
95
+ ``,
96
+ `How to revise it: ${instruction}`,
97
+ ...(target.length > 0 ? [``, ...target] : []),
98
+ ``,
99
+ `Read that slide first with \`view-screen\`, then make one bounded \`update-slide --fullContent\` edit that replaces only the quoted text. Leave the surrounding HTML, inline styles, and layout untouched, and keep the replacement close to the original length so the slide still fits its canvas.`,
100
+ ]
101
+ .filter((line) => line !== null)
102
+ .join("\n");
103
+ }
104
+
47
105
  /**
48
106
  * Floating formatting toolbar for contentEditable text blocks. Shows on
49
107
  * non-empty selection inside the editing element and applies inline
50
108
  * formatting (bold, italic, underline, strike, link, color) directly to
51
109
  * the DOM. Designed to work with the in-place per-block editing in
52
110
  * SlideEditor — it never mutates anything outside the editing element.
111
+ *
112
+ * The "Revise with AI" action is the exception: it does not touch the DOM.
113
+ * It hands the selected text plus the user's instruction to the agent, which
114
+ * rewrites the slide through `update-slide`.
53
115
  */
54
- export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
116
+ export function BlockBubbleMenu({
117
+ editingEl,
118
+ slideId,
119
+ deckId,
120
+ onCommitInlineEdit,
121
+ }: BlockBubbleMenuProps) {
55
122
  const t = useT();
56
123
  const [pos, setPos] = useState<Position | null>(null);
57
124
  const [showColors, setShowColors] = useState(false);
58
125
  const [showLinkInput, setShowLinkInput] = useState(false);
59
126
  const [linkValue, setLinkValue] = useState("");
127
+ const [showAiInput, setShowAiInput] = useState(false);
128
+ const [aiInstruction, setAiInstruction] = useState("");
129
+ const [aiTargetText, setAiTargetText] = useState("");
130
+ const [aiSending, setAiSending] = useState(false);
60
131
  const savedRangeRef = useRef<Range | null>(null);
61
132
  // True while a popup/input has the user's focus — keeps the menu pinned
62
133
  // even when the contentEditable selection collapses behind the scenes.
63
134
  const interactingRef = useRef(false);
64
135
  useEffect(() => {
65
- interactingRef.current = showColors || showLinkInput;
66
- }, [showColors, showLinkInput]);
136
+ interactingRef.current = showColors || showLinkInput || showAiInput;
137
+ }, [showColors, showLinkInput, showAiInput]);
67
138
 
68
139
  // Hide menu when the editing element changes
69
140
  useEffect(() => {
70
141
  setPos(null);
71
142
  setShowColors(false);
72
143
  setShowLinkInput(false);
144
+ setShowAiInput(false);
145
+ setAiInstruction("");
73
146
  }, [editingEl]);
74
147
 
75
148
  // Track selection and position the menu
@@ -158,6 +231,61 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
158
231
  setLinkValue("");
159
232
  };
160
233
 
234
+ const openAiInput = () => {
235
+ if (showAiInput) {
236
+ setShowAiInput(false);
237
+ return;
238
+ }
239
+ // Snapshot the text now: opening the input moves focus out of the
240
+ // contentEditable and the live selection collapses.
241
+ const selected = savedRangeRef.current?.toString().trim() ?? "";
242
+ if (!selected) return;
243
+ setAiTargetText(selected);
244
+ setAiInstruction("");
245
+ interactingRef.current = true;
246
+ setShowAiInput(true);
247
+ setShowColors(false);
248
+ setShowLinkInput(false);
249
+ };
250
+
251
+ const submitAiRevision = async () => {
252
+ const instruction = aiInstruction.trim();
253
+ if (!instruction || !aiTargetText || aiSending) return;
254
+
255
+ // Close the inline edit first. The block is still a live contentEditable
256
+ // session; leaving it open means the next click away serializes the old
257
+ // text over whatever the agent writes.
258
+ onCommitInlineEdit?.();
259
+
260
+ setAiSending(true);
261
+ try {
262
+ const delivery = await sendToAgentChatAndConfirm({
263
+ message: buildReviseSelectionPrompt({
264
+ selectedText: aiTargetText,
265
+ instruction,
266
+ slideId,
267
+ deckId,
268
+ }),
269
+ submit: true,
270
+ chatTarget: "local",
271
+ });
272
+
273
+ if (!delivery.delivered) {
274
+ // Keep the typed instruction so the user can retry without retyping.
275
+ toast.error(t("raw.sendToAgent"), {
276
+ description: delivery.reason ?? "The agent did not receive this.",
277
+ });
278
+ return;
279
+ }
280
+
281
+ toast.success(t("raw.sentToAgent"), { description: instruction });
282
+ setShowAiInput(false);
283
+ setAiInstruction("");
284
+ } finally {
285
+ setAiSending(false);
286
+ }
287
+ };
288
+
161
289
  return createPortal(
162
290
  <div
163
291
  data-block-bubble-menu="true"
@@ -168,6 +296,13 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
168
296
  e.preventDefault();
169
297
  }}
170
298
  >
299
+ <ToolbarButton
300
+ icon={IconPencilStar}
301
+ tooltip="Revise with AI"
302
+ onClick={openAiInput}
303
+ active={showAiInput}
304
+ />
305
+ <div className="w-px h-4 bg-border mx-0.5" />
171
306
  <ToolbarButton
172
307
  icon={IconBold}
173
308
  tooltip={`Bold (${shortcutLabel("cmd+b")})`}
@@ -199,6 +334,7 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
199
334
  if (!showColors) interactingRef.current = true;
200
335
  setShowColors((v) => !v);
201
336
  setShowLinkInput(false);
337
+ setShowAiInput(false);
202
338
  }}
203
339
  active={showColors}
204
340
  />
@@ -229,9 +365,64 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
229
365
  if (!showLinkInput) interactingRef.current = true;
230
366
  setShowLinkInput((v) => !v);
231
367
  setShowColors(false);
368
+ setShowAiInput(false);
232
369
  }}
233
370
  active={showLinkInput}
234
371
  />
372
+ {showAiInput && (
373
+ <div
374
+ data-ai-revise-input="true"
375
+ // Own mousedown handler: the toolbar above blocks the default to keep
376
+ // the contentEditable focused, which would also stop this input from
377
+ // ever receiving a caret.
378
+ onMouseDown={(e) => e.stopPropagation()}
379
+ className="absolute top-full left-1/2 -translate-x-1/2 mt-1 w-80 p-2 rounded-lg bg-popover border border-border shadow-2xl shadow-black/60"
380
+ >
381
+ <p className="mb-1.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
382
+ {aiTargetText.length > AI_TARGET_PREVIEW_LIMIT
383
+ ? `${aiTargetText.slice(0, AI_TARGET_PREVIEW_LIMIT)}…`
384
+ : aiTargetText}
385
+ </p>
386
+ <div className="flex items-end gap-1">
387
+ <textarea
388
+ value={aiInstruction}
389
+ onChange={(e) => setAiInstruction(e.target.value)}
390
+ onKeyDown={(e) => {
391
+ if (e.key === "Enter" && !e.shiftKey) {
392
+ e.preventDefault();
393
+ void submitAiRevision();
394
+ } else if (e.key === "Escape") {
395
+ e.preventDefault();
396
+ setShowAiInput(false);
397
+ }
398
+ }}
399
+ placeholder={t("raw.tellAgentDo")}
400
+ rows={2}
401
+ disabled={aiSending}
402
+ className="flex-1 resize-none rounded border border-border bg-muted px-2 py-1.5 text-xs text-foreground outline-none focus:border-ring disabled:opacity-60"
403
+ autoFocus
404
+ />
405
+ <Tooltip>
406
+ <TooltipTrigger asChild>
407
+ <button
408
+ type="button"
409
+ onClick={() => void submitAiRevision()}
410
+ disabled={!aiInstruction.trim() || aiSending}
411
+ aria-label={t("raw.sendToAgent")}
412
+ className={AI_SEND_BUTTON_CLASS}
413
+ >
414
+ {aiSending ? (
415
+ <IconLoader2 className="w-3.5 h-3.5 animate-spin" />
416
+ ) : (
417
+ <IconArrowUp className="w-3.5 h-3.5" />
418
+ )}
419
+ </button>
420
+ </TooltipTrigger>
421
+ <TooltipContent>{t("raw.sendToAgent")}</TooltipContent>
422
+ </Tooltip>
423
+ </div>
424
+ </div>
425
+ )}
235
426
  {showLinkInput && (
236
427
  <div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 flex items-center gap-1 p-1 rounded-lg bg-popover border border-border shadow-2xl shadow-black/60">
237
428
  <input