@agent-native/core 0.125.0 → 0.127.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 (188) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +79 -0
  3. package/corpus/core/docs/content/external-agents-catalog.mdx +14 -11
  4. package/corpus/core/docs/content/mcp-protocol.mdx +9 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/a2a/index.ts +2 -0
  7. package/corpus/core/src/agent/production-agent.ts +6 -1
  8. package/corpus/core/src/agent/run-manager.ts +37 -0
  9. package/corpus/core/src/agent/run-store.ts +18 -0
  10. package/corpus/core/src/client/MultiTabAssistantChat.tsx +75 -43
  11. package/corpus/core/src/client/agent-chat.ts +19 -1
  12. package/corpus/core/src/client/chat/tool-call-display.tsx +25 -0
  13. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +23 -0
  14. package/corpus/core/src/client/settings/AgentsSection.tsx +565 -71
  15. package/corpus/core/src/client/settings/SecretsSection.tsx +71 -36
  16. package/corpus/core/src/client/use-chat-models.ts +54 -37
  17. package/corpus/core/src/localization/default-messages.ts +11 -0
  18. package/corpus/core/src/mcp/ask-app-task-handle.ts +183 -0
  19. package/corpus/core/src/mcp/builtin-tools.ts +146 -34
  20. package/corpus/core/src/server/agent-capabilities.ts +8 -1
  21. package/corpus/core/src/server/agent-chat-plugin.ts +17 -0
  22. package/corpus/core/src/server/agent-discovery.ts +1 -1
  23. package/corpus/core/src/server/agent-peer-probe.ts +133 -0
  24. package/corpus/core/src/server/core-routes-plugin.ts +67 -0
  25. package/corpus/core/src/server/email-template.ts +13 -0
  26. package/corpus/core/src/server/email.ts +40 -4
  27. package/corpus/core/src/shared/mcp-embed-headers.ts +4 -0
  28. package/corpus/core/src/sharing/actions/share-resource.ts +55 -5
  29. package/corpus/core/src/sharing/registry.ts +41 -1
  30. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
  31. package/corpus/templates/assets/actions/edit-image.ts +31 -18
  32. package/corpus/templates/assets/actions/refine-image.ts +43 -29
  33. package/corpus/templates/assets/actions/restyle-image.ts +30 -19
  34. package/corpus/templates/assets/actions/variant-slots.ts +26 -0
  35. package/corpus/templates/assets/app/components/generation/GenerationResults.tsx +85 -17
  36. package/corpus/templates/clips/changelog/2026-07-27-share-notification-emails-now-include-a-clickable-recording-.md +6 -0
  37. package/corpus/templates/clips/server/db/index.ts +28 -6
  38. package/corpus/templates/clips/server/lib/share-email-hero.ts +105 -0
  39. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +100 -12
  40. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +17 -0
  41. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +169 -22
  42. package/corpus/templates/design/app/hooks/use-question-flow.ts +21 -1
  43. package/corpus/templates/design/changelog/2026-07-28-the-model-you-pick-when-starting-a-new-design-is-now-used-fo.md +6 -0
  44. package/corpus/templates/dispatch/changelog/2026-07-28-pending-apps-are-hidden-by-default-and-app-ownership-details.md +6 -0
  45. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +5 -1
  46. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +5 -0
  47. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +31 -8
  48. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +15 -0
  49. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +78 -8
  50. package/corpus/templates/slides/app/components/presentation/PresenterView.tsx +179 -0
  51. package/corpus/templates/slides/app/components/presentation/present-channel.ts +15 -0
  52. package/corpus/templates/slides/app/i18n/en-US.ts +10 -1
  53. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +67 -0
  54. package/corpus/templates/slides/app/lib/export-pptx-client.ts +16 -3
  55. package/corpus/templates/slides/app/pages/DeckEditor.tsx +11 -0
  56. package/corpus/templates/slides/app/pages/Presentation.tsx +11 -3
  57. package/corpus/templates/slides/changelog/2026-07-28-export-to-google-slides-now-creates-the-deck-directly-in-you.md +6 -0
  58. package/corpus/templates/slides/changelog/2026-07-28-opening-a-presentation-link-directly-no-longer-bounces-back-.md +6 -0
  59. package/corpus/templates/slides/changelog/2026-07-28-presenter-view-open-a-second-window-while-presenting-to-see-.md +6 -0
  60. package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +90 -0
  61. package/corpus/toolkit/CHANGELOG.md +6 -0
  62. package/corpus/toolkit/package.json +1 -1
  63. package/corpus/toolkit/src/composer/TiptapComposer.tsx +143 -111
  64. package/corpus/toolkit/src/composer/runtime-adapters.tsx +2 -0
  65. package/dist/a2a/index.d.ts +2 -0
  66. package/dist/a2a/index.d.ts.map +1 -1
  67. package/dist/a2a/index.js +1 -0
  68. package/dist/a2a/index.js.map +1 -1
  69. package/dist/agent/production-agent.d.ts +2 -2
  70. package/dist/agent/production-agent.d.ts.map +1 -1
  71. package/dist/agent/production-agent.js +6 -3
  72. package/dist/agent/production-agent.js.map +1 -1
  73. package/dist/agent/run-manager.d.ts +11 -0
  74. package/dist/agent/run-manager.d.ts.map +1 -1
  75. package/dist/agent/run-manager.js +34 -1
  76. package/dist/agent/run-manager.js.map +1 -1
  77. package/dist/agent/run-store.d.ts +8 -0
  78. package/dist/agent/run-store.d.ts.map +1 -1
  79. package/dist/agent/run-store.js +16 -0
  80. package/dist/agent/run-store.js.map +1 -1
  81. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  82. package/dist/client/MultiTabAssistantChat.js +60 -31
  83. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  84. package/dist/client/agent-chat.d.ts +6 -0
  85. package/dist/client/agent-chat.d.ts.map +1 -1
  86. package/dist/client/agent-chat.js +13 -1
  87. package/dist/client/agent-chat.js.map +1 -1
  88. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  89. package/dist/client/chat/tool-call-display.js +14 -0
  90. package/dist/client/chat/tool-call-display.js.map +1 -1
  91. package/dist/client/resources/mcp-integration-catalog.d.ts +6 -0
  92. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  93. package/dist/client/resources/mcp-integration-catalog.js +16 -0
  94. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  95. package/dist/client/settings/AgentsSection.d.ts.map +1 -1
  96. package/dist/client/settings/AgentsSection.js +250 -30
  97. package/dist/client/settings/AgentsSection.js.map +1 -1
  98. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  99. package/dist/client/settings/SecretsSection.js +16 -2
  100. package/dist/client/settings/SecretsSection.js.map +1 -1
  101. package/dist/client/use-chat-models.d.ts.map +1 -1
  102. package/dist/client/use-chat-models.js +46 -27
  103. package/dist/client/use-chat-models.js.map +1 -1
  104. package/dist/collab/routes.d.ts +2 -2
  105. package/dist/collab/struct-routes.d.ts +1 -1
  106. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  107. package/dist/localization/default-messages.d.ts +9 -0
  108. package/dist/localization/default-messages.d.ts.map +1 -1
  109. package/dist/localization/default-messages.js +9 -0
  110. package/dist/localization/default-messages.js.map +1 -1
  111. package/dist/mcp/ask-app-task-handle.d.ts +25 -0
  112. package/dist/mcp/ask-app-task-handle.d.ts.map +1 -0
  113. package/dist/mcp/ask-app-task-handle.js +136 -0
  114. package/dist/mcp/ask-app-task-handle.js.map +1 -0
  115. package/dist/mcp/builtin-tools.d.ts +3 -3
  116. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  117. package/dist/mcp/builtin-tools.js +115 -38
  118. package/dist/mcp/builtin-tools.js.map +1 -1
  119. package/dist/observability/routes.d.ts +1 -1
  120. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  121. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  122. package/dist/secrets/routes.d.ts +9 -9
  123. package/dist/server/agent-capabilities.d.ts +7 -1
  124. package/dist/server/agent-capabilities.d.ts.map +1 -1
  125. package/dist/server/agent-capabilities.js +1 -0
  126. package/dist/server/agent-capabilities.js.map +1 -1
  127. package/dist/server/agent-chat-plugin.d.ts +7 -0
  128. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  129. package/dist/server/agent-chat-plugin.js +16 -1
  130. package/dist/server/agent-chat-plugin.js.map +1 -1
  131. package/dist/server/agent-discovery.d.ts +1 -0
  132. package/dist/server/agent-discovery.d.ts.map +1 -1
  133. package/dist/server/agent-discovery.js +1 -1
  134. package/dist/server/agent-discovery.js.map +1 -1
  135. package/dist/server/agent-peer-probe.d.ts +37 -0
  136. package/dist/server/agent-peer-probe.d.ts.map +1 -0
  137. package/dist/server/agent-peer-probe.js +90 -0
  138. package/dist/server/agent-peer-probe.js.map +1 -0
  139. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  140. package/dist/server/core-routes-plugin.js +53 -0
  141. package/dist/server/core-routes-plugin.js.map +1 -1
  142. package/dist/server/email-template.d.ts +6 -0
  143. package/dist/server/email-template.d.ts.map +1 -1
  144. package/dist/server/email-template.js +6 -0
  145. package/dist/server/email-template.js.map +1 -1
  146. package/dist/server/email.d.ts +7 -0
  147. package/dist/server/email.d.ts.map +1 -1
  148. package/dist/server/email.js +31 -6
  149. package/dist/server/email.js.map +1 -1
  150. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  151. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  152. package/dist/shared/mcp-embed-headers.js +4 -0
  153. package/dist/shared/mcp-embed-headers.js.map +1 -1
  154. package/dist/sharing/actions/share-resource.d.ts.map +1 -1
  155. package/dist/sharing/actions/share-resource.js +49 -5
  156. package/dist/sharing/actions/share-resource.js.map +1 -1
  157. package/dist/sharing/registry.d.ts +40 -1
  158. package/dist/sharing/registry.d.ts.map +1 -1
  159. package/dist/sharing/registry.js.map +1 -1
  160. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
  161. package/docs/content/external-agents-catalog.mdx +14 -11
  162. package/docs/content/mcp-protocol.mdx +9 -4
  163. package/package.json +2 -2
  164. package/src/a2a/index.ts +2 -0
  165. package/src/agent/production-agent.ts +6 -1
  166. package/src/agent/run-manager.ts +37 -0
  167. package/src/agent/run-store.ts +18 -0
  168. package/src/client/MultiTabAssistantChat.tsx +75 -43
  169. package/src/client/agent-chat.ts +19 -1
  170. package/src/client/chat/tool-call-display.tsx +25 -0
  171. package/src/client/resources/mcp-integration-catalog.ts +23 -0
  172. package/src/client/settings/AgentsSection.tsx +565 -71
  173. package/src/client/settings/SecretsSection.tsx +71 -36
  174. package/src/client/use-chat-models.ts +54 -37
  175. package/src/localization/default-messages.ts +11 -0
  176. package/src/mcp/ask-app-task-handle.ts +183 -0
  177. package/src/mcp/builtin-tools.ts +146 -34
  178. package/src/server/agent-capabilities.ts +8 -1
  179. package/src/server/agent-chat-plugin.ts +17 -0
  180. package/src/server/agent-discovery.ts +1 -1
  181. package/src/server/agent-peer-probe.ts +133 -0
  182. package/src/server/core-routes-plugin.ts +67 -0
  183. package/src/server/email-template.ts +13 -0
  184. package/src/server/email.ts +40 -4
  185. package/src/shared/mcp-embed-headers.ts +4 -0
  186. package/src/sharing/actions/share-resource.ts +55 -5
  187. package/src/sharing/registry.ts +41 -1
  188. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
@@ -18,6 +18,11 @@ import {
18
18
  DropdownMenuSeparator,
19
19
  DropdownMenuTrigger,
20
20
  } from "@/components/ui/dropdown-menu";
21
+ import type { GoogleSlidesExportResult } from "@/lib/export-google-slides-client";
22
+
23
+ /** Google Slides' File → Import dialog, primed to ask for a file. */
24
+ const GOOGLE_SLIDES_IMPORT_URL =
25
+ "https://docs.google.com/presentation/u/0/?usp=import";
21
26
 
22
27
  interface ExportMenuProps {
23
28
  deckId: string;
@@ -25,6 +30,7 @@ interface ExportMenuProps {
25
30
  onDuplicate: () => void;
26
31
  onExportPdf: () => void;
27
32
  onExportPptx: () => Promise<void> | void;
33
+ onExportGoogleSlides?: () => Promise<GoogleSlidesExportResult>;
28
34
  onShareLink?: () => void;
29
35
  onShareTeam?: () => void;
30
36
  }
@@ -35,6 +41,7 @@ export function ExportMenu({
35
41
  onDuplicate,
36
42
  onExportPdf,
37
43
  onExportPptx,
44
+ onExportGoogleSlides,
38
45
  onShareLink,
39
46
  onShareTeam,
40
47
  }: ExportMenuProps) {
@@ -84,12 +91,26 @@ export function ExportMenu({
84
91
  };
85
92
 
86
93
  const handleExportGoogleSlides = async () => {
94
+ if (!onExportGoogleSlides) return;
95
+ // Opened up-front: browsers only honour window.open() inside the click
96
+ // gesture, and building the PPTX is async.
97
+ const target = window.open("", "_blank");
87
98
  try {
88
- await onExportPptx();
99
+ const result = await onExportGoogleSlides();
100
+ if (result.url !== null) {
101
+ if (target) target.location.href = result.url;
102
+ toast.success(t("editorExport.googleSlidesCreated"), {
103
+ description: t("editorExport.googleSlidesCreatedHint"),
104
+ });
105
+ return;
106
+ }
107
+ console.warn("Google Slides upload unavailable:", result.reason);
108
+ if (target) target.location.href = GOOGLE_SLIDES_IMPORT_URL;
89
109
  toast.success(t("editorExport.googleSlidesDownloaded"), {
90
110
  description: t("editorExport.googleSlidesImportHint"),
91
111
  });
92
112
  } catch (err) {
113
+ target?.close();
93
114
  console.error("Export failed:", err);
94
115
  toast.error(t("editorExport.exportFailed"), {
95
116
  description:
@@ -166,13 +187,15 @@ export function ExportMenu({
166
187
  <IconDownload className="w-4 h-4 mr-2" />
167
188
  {t("editorExport.exportPptx")}
168
189
  </DropdownMenuItem>
169
- <DropdownMenuItem
170
- onClick={handleExportGoogleSlides}
171
- className="cursor-pointer"
172
- >
173
- <IconBrandGoogle className="w-4 h-4 mr-2" />
174
- {t("editorExport.downloadGoogleSlides")}
175
- </DropdownMenuItem>
190
+ {onExportGoogleSlides && (
191
+ <DropdownMenuItem
192
+ onClick={handleExportGoogleSlides}
193
+ className="cursor-pointer"
194
+ >
195
+ <IconBrandGoogle className="w-4 h-4 mr-2" />
196
+ {t("editorExport.openInGoogleSlides")}
197
+ </DropdownMenuItem>
198
+ )}
176
199
  <DropdownMenuSeparator />
177
200
  <DropdownMenuItem onClick={onDuplicate} className="cursor-pointer">
178
201
  <IconCopy className="w-4 h-4 mr-2" />
@@ -16,6 +16,20 @@ export interface UploadedFile {
16
16
  size: number;
17
17
  }
18
18
 
19
+ /**
20
+ * Radix popovers portal to `document.body`, so a mousedown inside the model
21
+ * picker or attachment menu reads as "outside" any panel that hosts a composer.
22
+ * Closing on it unmounts the popover before its own click fires, which looks
23
+ * exactly like a dead button.
24
+ */
25
+ export function isInsidePortaledLayer(target: EventTarget | null): boolean {
26
+ return Boolean(
27
+ (target as Element | null)?.closest?.(
28
+ "[data-radix-popper-content-wrapper]",
29
+ ),
30
+ );
31
+ }
32
+
19
33
  interface PromptPopoverProps {
20
34
  open: boolean;
21
35
  onOpenChange: (open: boolean) => void;
@@ -98,6 +112,7 @@ export default function PromptPopover({
98
112
  useEffect(() => {
99
113
  if (!open) return;
100
114
  const handleClick = (e: MouseEvent) => {
115
+ if (isInsidePortaledLayer(e.target)) return;
101
116
  if (
102
117
  panelRef.current &&
103
118
  !panelRef.current.contains(e.target as Node) &&
@@ -3,6 +3,7 @@ import {
3
3
  IconChevronLeft,
4
4
  IconChevronRight,
5
5
  IconMaximize,
6
+ IconNotes,
6
7
  IconX,
7
8
  } from "@tabler/icons-react";
8
9
  import { useState, useEffect, useCallback, useMemo, useRef } from "react";
@@ -20,6 +21,8 @@ import {
20
21
  resolveSlideAnimationElement,
21
22
  } from "@/lib/slide-animation-elements";
22
23
 
24
+ import { openPresentChannel, type PresentMessage } from "./present-channel";
25
+
23
26
  interface PresentationViewProps {
24
27
  slides: Slide[];
25
28
  deckId: string;
@@ -271,6 +274,58 @@ export default function PresentationView({
271
274
  }
272
275
  }, [navigate, deckId, isShared]);
273
276
 
277
+ // Presenter window: it owns no navigation state of its own, it just sends
278
+ // commands and mirrors whatever we echo back — so build steps stay
279
+ // authoritative here.
280
+ const channelRef = useRef<BroadcastChannel | null>(null);
281
+ const goNextRef = useRef(goNext);
282
+ const goPrevRef = useRef(goPrev);
283
+ goNextRef.current = goNext;
284
+ goPrevRef.current = goPrev;
285
+
286
+ const broadcastState = useCallback(() => {
287
+ channelRef.current?.postMessage({
288
+ type: "state",
289
+ index: currentIndex,
290
+ } satisfies PresentMessage);
291
+ }, [currentIndex]);
292
+ const broadcastStateRef = useRef(broadcastState);
293
+ broadcastStateRef.current = broadcastState;
294
+
295
+ useEffect(() => {
296
+ const channel = openPresentChannel(deckId);
297
+ channelRef.current = channel;
298
+ if (!channel) return;
299
+ channel.onmessage = (event: MessageEvent<PresentMessage>) => {
300
+ const message = event.data;
301
+ if (message?.type === "hello") {
302
+ broadcastStateRef.current();
303
+ } else if (message?.type === "command") {
304
+ if (message.command === "next") goNextRef.current();
305
+ else goPrevRef.current();
306
+ }
307
+ };
308
+ return () => {
309
+ channel.close();
310
+ channelRef.current = null;
311
+ };
312
+ }, [deckId]);
313
+
314
+ useEffect(() => {
315
+ broadcastState();
316
+ }, [broadcastState]);
317
+
318
+ const openPresenterWindow = useCallback(() => {
319
+ const url = new URL(window.location.href);
320
+ url.searchParams.set("presenter", "1");
321
+ url.searchParams.set("slide", String(currentIndex + 1));
322
+ window.open(
323
+ url.toString(),
324
+ `slides-presenter-${deckId}`,
325
+ "width=1200,height=760",
326
+ );
327
+ }, [currentIndex, deckId]);
328
+
274
329
  useEffect(() => {
275
330
  const handleKey = (e: KeyboardEvent) => {
276
331
  switch (e.key) {
@@ -294,6 +349,11 @@ export default function PresentationView({
294
349
  document.exitFullscreen().catch(() => {});
295
350
  }
296
351
  break;
352
+ case "s":
353
+ case "S":
354
+ e.preventDefault();
355
+ openPresenterWindow();
356
+ break;
297
357
  case "Escape":
298
358
  exit();
299
359
  break;
@@ -301,7 +361,7 @@ export default function PresentationView({
301
361
  };
302
362
  window.addEventListener("keydown", handleKey);
303
363
  return () => window.removeEventListener("keydown", handleKey);
304
- }, [goNext, goPrev, exit]);
364
+ }, [goNext, goPrev, exit, openPresenterWindow]);
305
365
 
306
366
  // Try to enter fullscreen. Browsers require a user gesture; the click that
307
367
  // navigated to /present often counts, but Safari/Firefox sometimes block
@@ -501,13 +561,23 @@ export default function PresentationView({
501
561
  </button>
502
562
  </div>
503
563
 
504
- <button
505
- onClick={exit}
506
- className="p-3 sm:p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
507
- aria-label={t("presentation.exitPresentation")}
508
- >
509
- <IconX className="w-5 h-5 sm:w-4 sm:h-4 text-white" />
510
- </button>
564
+ <div className="flex items-center gap-2">
565
+ <button
566
+ onClick={openPresenterWindow}
567
+ className="p-3 sm:p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors cursor-pointer"
568
+ aria-label={t("presentation.presenterView")}
569
+ title={t("presentation.presenterView")}
570
+ >
571
+ <IconNotes className="w-5 h-5 sm:w-4 sm:h-4 text-white" />
572
+ </button>
573
+ <button
574
+ onClick={exit}
575
+ className="p-3 sm:p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
576
+ aria-label={t("presentation.exitPresentation")}
577
+ >
578
+ <IconX className="w-5 h-5 sm:w-4 sm:h-4 text-white" />
579
+ </button>
580
+ </div>
511
581
  </div>
512
582
 
513
583
  {/* Progress bar */}
@@ -0,0 +1,179 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { IconChevronLeft, IconChevronRight, IconX } from "@tabler/icons-react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ import SlideRenderer from "@/components/deck/SlideRenderer";
6
+ import type { Slide } from "@/context/DeckContext";
7
+ import type { AspectRatio } from "@/lib/aspect-ratios";
8
+
9
+ import { openPresentChannel, type PresentMessage } from "./present-channel";
10
+
11
+ interface PresenterViewProps {
12
+ slides: Slide[];
13
+ deckId: string;
14
+ startIndex?: number;
15
+ aspectRatio?: AspectRatio;
16
+ }
17
+
18
+ function formatElapsed(seconds: number) {
19
+ const mins = Math.floor(seconds / 60);
20
+ const secs = seconds % 60;
21
+ return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
22
+ }
23
+
24
+ export default function PresenterView({
25
+ slides,
26
+ deckId,
27
+ startIndex = 0,
28
+ aspectRatio,
29
+ }: PresenterViewProps) {
30
+ const t = useT();
31
+ const [index, setIndex] = useState(startIndex);
32
+ const [elapsed, setElapsed] = useState(0);
33
+ const channelRef = useRef<BroadcastChannel | null>(null);
34
+
35
+ useEffect(() => {
36
+ const channel = openPresentChannel(deckId);
37
+ channelRef.current = channel;
38
+ if (!channel) return;
39
+ channel.onmessage = (event: MessageEvent<PresentMessage>) => {
40
+ if (event.data?.type === "state") setIndex(event.data.index);
41
+ };
42
+ channel.postMessage({ type: "hello" } satisfies PresentMessage);
43
+ return () => {
44
+ channel.close();
45
+ channelRef.current = null;
46
+ };
47
+ }, [deckId]);
48
+
49
+ useEffect(() => {
50
+ const timer = window.setInterval(() => setElapsed((s) => s + 1), 1000);
51
+ return () => window.clearInterval(timer);
52
+ }, []);
53
+
54
+ const send = useCallback((message: PresentMessage) => {
55
+ channelRef.current?.postMessage(message);
56
+ }, []);
57
+
58
+ const goNext = useCallback(
59
+ () => send({ type: "command", command: "next" }),
60
+ [send],
61
+ );
62
+ const goPrev = useCallback(
63
+ () => send({ type: "command", command: "prev" }),
64
+ [send],
65
+ );
66
+
67
+ useEffect(() => {
68
+ const handleKey = (e: KeyboardEvent) => {
69
+ switch (e.key) {
70
+ case "ArrowRight":
71
+ case "ArrowDown":
72
+ case " ":
73
+ e.preventDefault();
74
+ goNext();
75
+ break;
76
+ case "ArrowLeft":
77
+ case "ArrowUp":
78
+ e.preventDefault();
79
+ goPrev();
80
+ break;
81
+ }
82
+ };
83
+ window.addEventListener("keydown", handleKey);
84
+ return () => window.removeEventListener("keydown", handleKey);
85
+ }, [goNext, goPrev]);
86
+
87
+ const safeSlides = useMemo(
88
+ () => (Array.isArray(slides) ? slides.filter(Boolean) : []),
89
+ [slides],
90
+ );
91
+ const current = safeSlides[index];
92
+ const next = safeSlides[index + 1];
93
+ const notes = current?.notes?.trim();
94
+
95
+ return (
96
+ <div className="fixed inset-0 flex flex-col bg-[hsl(240,6%,6%)] text-white">
97
+ <header className="flex items-center justify-between gap-4 border-b border-white/10 px-5 py-3">
98
+ <span className="font-mono text-sm text-white/50">
99
+ {index + 1} / {safeSlides.length}
100
+ </span>
101
+ <span className="font-mono text-2xl tabular-nums text-white/80">
102
+ {formatElapsed(elapsed)}
103
+ </span>
104
+ <div className="flex items-center gap-2">
105
+ <button
106
+ onClick={goPrev}
107
+ disabled={index === 0}
108
+ className="cursor-pointer rounded-lg bg-white/10 p-2 hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-30"
109
+ aria-label={t("presentation.previousSlide")}
110
+ >
111
+ <IconChevronLeft className="h-4 w-4" />
112
+ </button>
113
+ <button
114
+ onClick={goNext}
115
+ disabled={index >= safeSlides.length - 1}
116
+ className="cursor-pointer rounded-lg bg-white/10 p-2 hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-30"
117
+ aria-label={t("presentation.nextSlide")}
118
+ >
119
+ <IconChevronRight className="h-4 w-4" />
120
+ </button>
121
+ <button
122
+ onClick={() => window.close()}
123
+ className="cursor-pointer rounded-lg bg-white/10 p-2 hover:bg-white/20"
124
+ aria-label={t("presentation.closePresenterView")}
125
+ >
126
+ <IconX className="h-4 w-4" />
127
+ </button>
128
+ </div>
129
+ </header>
130
+
131
+ <div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
132
+ <div className="flex gap-4">
133
+ <div className="w-2/3 overflow-hidden rounded-lg bg-black">
134
+ {current && (
135
+ <SlideRenderer
136
+ slide={current}
137
+ thumbnail
138
+ aspectRatio={aspectRatio}
139
+ />
140
+ )}
141
+ </div>
142
+ <div className="flex w-1/3 flex-col gap-2">
143
+ <div className="font-mono text-[11px] uppercase tracking-widest text-white/40">
144
+ {t("presentation.upNext")}
145
+ </div>
146
+ {next ? (
147
+ <div className="overflow-hidden rounded-lg bg-black">
148
+ <SlideRenderer
149
+ slide={next}
150
+ thumbnail
151
+ aspectRatio={aspectRatio}
152
+ />
153
+ </div>
154
+ ) : (
155
+ <div className="rounded-lg bg-white/[0.04] p-4 text-sm text-white/40">
156
+ {t("presentation.endOfDeck")}
157
+ </div>
158
+ )}
159
+ </div>
160
+ </div>
161
+
162
+ <div className="min-h-0 flex-1 overflow-y-auto rounded-lg bg-white/[0.04] p-5">
163
+ <div className="mb-2 font-mono text-[11px] uppercase tracking-widest text-white/40">
164
+ {t("presentation.speakerNotes")}
165
+ </div>
166
+ {notes ? (
167
+ <p className="whitespace-pre-wrap text-lg leading-relaxed text-white/90">
168
+ {notes}
169
+ </p>
170
+ ) : (
171
+ <p className="text-sm text-white/40">
172
+ {t("presentation.noNotesForSlide")}
173
+ </p>
174
+ )}
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Cross-window channel between the fullscreen presentation and the presenter
3
+ * window. The presenter window never advances on its own — it sends commands
4
+ * and renders whatever state the presentation echoes back, so build steps
5
+ * (which move the step, not the slide) stay authoritative in one place.
6
+ */
7
+ export type PresentMessage =
8
+ | { type: "state"; index: number }
9
+ | { type: "command"; command: "next" | "prev" }
10
+ | { type: "hello" };
11
+
12
+ export function openPresentChannel(deckId: string): BroadcastChannel | null {
13
+ if (typeof BroadcastChannel === "undefined") return null;
14
+ return new BroadcastChannel(`slides-present:${deckId}`);
15
+ }
@@ -256,7 +256,10 @@ const messages = {
256
256
  downloadHtml: "Download as HTML",
257
257
  exportPdf: "Export as PDF",
258
258
  exportPptx: "Export as PPTX",
259
- downloadGoogleSlides: "Download for Google Slides",
259
+ openInGoogleSlides: "Open in Google Slides",
260
+ googleSlidesCreated: "Opened in Google Slides",
261
+ googleSlidesCreatedHint:
262
+ "A copy of this deck was created in your Google Drive.",
260
263
  duplicateDeck: "Duplicate deck",
261
264
  },
262
265
  share: {
@@ -467,6 +470,12 @@ const messages = {
467
470
  slides: "Slides",
468
471
  },
469
472
  presentation: {
473
+ presenterView: "Presenter view",
474
+ speakerNotes: "Speaker notes",
475
+ noNotesForSlide: "No notes for this slide",
476
+ upNext: "Up next",
477
+ endOfDeck: "End of deck",
478
+ closePresenterView: "Close presenter view",
470
479
  noSlides: "No slides to present",
471
480
  previousSlide: "Previous slide",
472
481
  nextSlide: "Next slide",
@@ -0,0 +1,67 @@
1
+ import { appBasePath } from "@agent-native/core/client/api-path";
2
+
3
+ import type { AspectRatio } from "./aspect-ratios";
4
+ import { buildDeckPptxBlob } from "./export-pptx-client";
5
+
6
+ interface GoogleSlidesExportSlide {
7
+ id: string;
8
+ notes?: string;
9
+ }
10
+
11
+ export type GoogleSlidesExportResult =
12
+ | { url: string }
13
+ /** Drive was unavailable, so the PPTX was downloaded for a manual import. */
14
+ | { url: null; downloaded: true; reason: string };
15
+
16
+ function triggerBlobDownload(blob: Blob, filename: string) {
17
+ const url = URL.createObjectURL(blob);
18
+ const a = document.createElement("a");
19
+ a.href = url;
20
+ a.download = filename;
21
+ a.rel = "noopener";
22
+ document.body.appendChild(a);
23
+ a.click();
24
+ a.remove();
25
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
26
+ }
27
+
28
+ /**
29
+ * Creates a native Google Slides deck in the user's Drive when their Google
30
+ * account is connected. Without a connection — or if Drive rejects the upload —
31
+ * the PPTX is downloaded instead so the user can import it by hand, and the
32
+ * reason is reported rather than swallowed.
33
+ */
34
+ export async function exportDeckToGoogleSlides(
35
+ deckTitle: string,
36
+ slides: GoogleSlidesExportSlide[],
37
+ aspectRatio?: AspectRatio,
38
+ ): Promise<GoogleSlidesExportResult> {
39
+ const { blob, filename } = await buildDeckPptxBlob(
40
+ deckTitle,
41
+ slides,
42
+ aspectRatio,
43
+ );
44
+
45
+ const form = new FormData();
46
+ form.append("file", blob, filename);
47
+ form.append("title", deckTitle);
48
+
49
+ const res = await fetch(`${appBasePath()}/api/exports/google-slides`, {
50
+ method: "POST",
51
+ body: form,
52
+ });
53
+
54
+ const payload = (await res.json().catch(() => null)) as {
55
+ url?: string;
56
+ error?: string;
57
+ } | null;
58
+
59
+ if (res.ok && payload?.url) return { url: payload.url };
60
+
61
+ triggerBlobDownload(blob, filename);
62
+ return {
63
+ url: null,
64
+ downloaded: true,
65
+ reason: payload?.error ?? `HTTP ${res.status}`,
66
+ };
67
+ }
@@ -313,11 +313,11 @@ function widenNoWrapTextElements(root: HTMLElement) {
313
313
  }
314
314
  }
315
315
 
316
- export async function exportDeckAsPptx(
316
+ export async function buildDeckPptxBlob(
317
317
  deckTitle: string,
318
318
  slides: PptxExportSlide[],
319
319
  aspectRatio?: AspectRatio,
320
- ): Promise<void> {
320
+ ): Promise<{ blob: Blob; filename: string }> {
321
321
  const { exportToPptx } = await importExportModule(
322
322
  () => import("dom-to-pptx"),
323
323
  );
@@ -359,10 +359,23 @@ export async function exportDeckAsPptx(
359
359
  );
360
360
 
361
361
  const blob = await addSpeakerNotesToPptxBlob(initialBlob, slides);
362
- triggerBlobDownload(blob, safePptxName(deckTitle));
362
+ return { blob, filename: safePptxName(deckTitle) };
363
363
  } finally {
364
364
  for (const clone of exportClones) {
365
365
  clone.cleanup();
366
366
  }
367
367
  }
368
368
  }
369
+
370
+ export async function exportDeckAsPptx(
371
+ deckTitle: string,
372
+ slides: PptxExportSlide[],
373
+ aspectRatio?: AspectRatio,
374
+ ): Promise<void> {
375
+ const { blob, filename } = await buildDeckPptxBlob(
376
+ deckTitle,
377
+ slides,
378
+ aspectRatio,
379
+ );
380
+ triggerBlobDownload(blob, filename);
381
+ }
@@ -62,6 +62,7 @@ import {
62
62
  } from "@/hooks/use-slide-comments";
63
63
  import type { AspectRatio } from "@/lib/aspect-ratios";
64
64
  import { getPreset } from "@/lib/design-systems";
65
+ import { exportDeckToGoogleSlides } from "@/lib/export-google-slides-client";
65
66
  import { exportDeckAsPdf } from "@/lib/export-pdf-client";
66
67
  import { exportDeckAsPptx } from "@/lib/export-pptx-client";
67
68
  import {
@@ -854,6 +855,16 @@ export default function DeckEditor() {
854
855
  }
855
856
  await exportDeckAsPptx(deck.title, slides, deck.aspectRatio);
856
857
  }}
858
+ onExportGoogleSlides={async () => {
859
+ const slides = deck.slides.map((s) => ({
860
+ id: s.id,
861
+ notes: s.notes,
862
+ }));
863
+ if (slides.length === 0) {
864
+ throw new Error(t("deckEditor.deckHasNoSlides"));
865
+ }
866
+ return exportDeckToGoogleSlides(deck.title, slides, deck.aspectRatio);
867
+ }}
857
868
  aspectRatio={deck.aspectRatio}
858
869
  designSystemTitle={designSystemTitle}
859
870
  onSetAspectRatio={(ratio: AspectRatio) => {
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { useParams, Navigate, useSearchParams } from "react-router";
4
4
 
5
5
  import PresentationView from "@/components/presentation/PresentationView";
6
+ import PresenterView from "@/components/presentation/PresenterView";
6
7
  import { useDecks } from "@/context/DeckContext";
7
8
  import type { Deck } from "@/context/DeckContext";
8
9
 
@@ -45,10 +46,14 @@ export default function Presentation() {
45
46
  };
46
47
  }, [contextDeck, id, loading]);
47
48
 
48
- if (loading || fallbackState === "loading") {
49
+ if (!id) return <Navigate to="/" replace />;
50
+ // "Not fetched yet" is not "not found": on a cold load of this URL the deck
51
+ // context is empty and the fallback fetch has not run, so redirecting on a
52
+ // falsy deck bounced every direct/presenter/share link back to the index.
53
+ if (!deck && fallbackState !== "missing") {
49
54
  return <div className="h-screen bg-black" />;
50
55
  }
51
- if (!deck || !id || fallbackState === "missing") {
56
+ if (!deck) {
52
57
  return <Navigate to="/" replace />;
53
58
  }
54
59
 
@@ -58,8 +63,11 @@ export default function Presentation() {
58
63
  ? Math.max(0, parsedSlide - 1)
59
64
  : 0;
60
65
 
66
+ const View =
67
+ searchParams.get("presenter") === "1" ? PresenterView : PresentationView;
68
+
61
69
  return (
62
- <PresentationView
70
+ <View
63
71
  slides={Array.isArray(deck.slides) ? deck.slides : []}
64
72
  deckId={id}
65
73
  startIndex={startSlide}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-28
4
+ ---
5
+
6
+ Export to Google Slides now creates the deck directly in your Google Drive when your Google account is connected, instead of only downloading a PPTX to import by hand.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-28
4
+ ---
5
+
6
+ Opening a presentation link directly no longer bounces back to the deck list.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-28
4
+ ---
5
+
6
+ Presenter view: open a second window while presenting to see speaker notes, the next slide, and a timer, with both windows kept in sync.