@agent-native/core 0.91.0 → 0.91.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 (219) hide show
  1. package/blueprints/action/crud.md +1 -1
  2. package/blueprints/channel/discord.md +1 -1
  3. package/blueprints/provider/stripe.md +1 -1
  4. package/blueprints/sandbox/docker.md +1 -1
  5. package/corpus/README.md +1 -1
  6. package/corpus/core/CHANGELOG.md +26 -0
  7. package/corpus/core/blueprints/action/crud.md +1 -1
  8. package/corpus/core/blueprints/channel/discord.md +1 -1
  9. package/corpus/core/blueprints/provider/stripe.md +1 -1
  10. package/corpus/core/blueprints/sandbox/docker.md +1 -1
  11. package/corpus/core/package.json +6 -5
  12. package/corpus/core/src/agent/run-manager.ts +23 -2
  13. package/corpus/core/src/agent/run-store.ts +2 -2
  14. package/corpus/core/src/cli/add.ts +1 -1
  15. package/corpus/core/src/cli/create.ts +8 -3
  16. package/corpus/core/src/cli/design-connect.ts +53 -9
  17. package/corpus/core/src/cli/index.ts +5 -4
  18. package/corpus/core/src/client/extensions/ExtensionSlot.tsx +7 -0
  19. package/corpus/core/src/client/session-replay.ts +97 -3
  20. package/corpus/core/src/jobs/scheduler.ts +30 -1
  21. package/corpus/core/src/localization/default-messages.ts +21 -0
  22. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +21 -0
  23. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +21 -0
  24. package/corpus/core/src/templates/default/app/i18n/en-US.ts +21 -0
  25. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +21 -0
  26. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +21 -0
  27. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +20 -0
  28. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +21 -0
  29. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +20 -0
  30. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +21 -0
  31. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +20 -0
  32. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +19 -0
  33. package/corpus/core/src/templates/default/package.json +2 -2
  34. package/corpus/core/src/templates/headless/package.json +3 -3
  35. package/corpus/core/src/templates/workspace-core/package.json +6 -6
  36. package/corpus/core/src/templates/workspace-root/package.json +2 -2
  37. package/corpus/core/src/triggers/dispatcher.ts +30 -2
  38. package/corpus/core/src/vite/client.ts +96 -1
  39. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +21 -2
  40. package/corpus/templates/analytics/app/global.css +26 -10
  41. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  42. package/corpus/templates/analytics/app/i18n-data.ts +25 -0
  43. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +548 -237
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +285 -294
  45. package/corpus/templates/analytics/changelog/2026-07-08-console-details-expand-inline-under-each-row-and-the-event-t.md +6 -0
  46. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-clamps-ultra-wide-viewports-while-keeping-the.md +6 -0
  47. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-keeps-a-usable-stage-when-dev-tools-is-open-a.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-resumes-after-scrubbing-and-expands-console-r.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-08-session-replays-wait-for-full-load-keep-a-normal-viewport-an.md +6 -0
  50. package/corpus/templates/analytics/package.json +2 -2
  51. package/corpus/templates/assets/package.json +2 -2
  52. package/corpus/templates/brain/package.json +2 -2
  53. package/corpus/templates/calendar/AGENTS.md +10 -0
  54. package/corpus/templates/calendar/actions/create-event.ts +5 -1
  55. package/corpus/templates/calendar/actions/event-action-helpers.ts +41 -0
  56. package/corpus/templates/calendar/actions/get-attendee-timezones.ts +28 -0
  57. package/corpus/templates/calendar/actions/set-attendee-timezone.ts +68 -0
  58. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +150 -11
  59. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +36 -1
  60. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +32 -0
  61. package/corpus/templates/calendar/app/hooks/use-attendee-timezones.ts +24 -0
  62. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  63. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  64. package/corpus/templates/calendar/app/lib/attendee-local-time.ts +122 -0
  65. package/corpus/templates/calendar/changelog/2026-07-08-event-details-now-support-extension-widgets-and-you-can-see-.md +6 -0
  66. package/corpus/templates/calendar/changelog/2026-07-08-when-you-invite-guests-to-a-new-event-you-now-appear-in.md +6 -0
  67. package/corpus/templates/calendar/package.json +2 -2
  68. package/corpus/templates/calendar/server/handlers/events.ts +5 -1
  69. package/corpus/templates/calendar/shared/api.ts +6 -0
  70. package/corpus/templates/chat/package.json +2 -2
  71. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +6 -2
  72. package/corpus/templates/clips/actions/create-meeting.ts +8 -3
  73. package/corpus/templates/clips/actions/list-meetings.ts +31 -3
  74. package/corpus/templates/clips/changelog/2026-07-08-after-finishing-a-desktop-recording-reopening-clips-restor.md +6 -0
  75. package/corpus/templates/clips/changelog/2026-07-08-clips-now-pops-up-to-take-notes-when-you-join-an-adhoc-zoom-.md +6 -0
  76. package/corpus/templates/clips/changelog/2026-07-08-desktop-clip-recordings-are-louder-after-capture-mic-only.md +6 -0
  77. package/corpus/templates/clips/changelog/2026-07-08-meeting-reminders-now-appear-1-minute-before-start-stay-unti.md +6 -0
  78. package/corpus/templates/clips/changelog/2026-07-08-the-desktop-meetings-panel-has-room-for-its-drop-shadow-and-.md +6 -0
  79. package/corpus/templates/clips/chrome-extension/package.json +2 -2
  80. package/corpus/templates/clips/desktop/package.json +2 -2
  81. package/corpus/templates/clips/desktop/src/app.tsx +91 -34
  82. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +15 -5
  83. package/corpus/templates/clips/desktop/src/lib/meeting-notification-timing.ts +83 -0
  84. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +21 -0
  85. package/corpus/templates/clips/desktop/src/lib/recorder.ts +98 -20
  86. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +174 -50
  87. package/corpus/templates/clips/desktop/src/styles.css +128 -53
  88. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +364 -0
  89. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
  90. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +5 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +91 -11
  92. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +69 -19
  93. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +36 -1
  94. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +62 -10
  95. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +1 -24
  96. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +36 -1
  97. package/corpus/templates/clips/package.json +2 -2
  98. package/corpus/templates/content/actions/_content-database-personal-view.ts +80 -0
  99. package/corpus/templates/content/actions/get-content-database-personal-view.ts +27 -0
  100. package/corpus/templates/content/actions/update-content-database-personal-view.ts +31 -0
  101. package/corpus/templates/content/actions/update-content-database-view.ts +3 -0
  102. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
  103. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2584 -546
  104. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +220 -18
  105. package/corpus/templates/content/app/hooks/use-content-database.ts +32 -0
  106. package/corpus/templates/content/app/i18n/zh-TW.ts +16 -0
  107. package/corpus/templates/content/app/i18n-data.ts +154 -0
  108. package/corpus/templates/content/changelog/2026-07-08-database-filters-now-support-notion-style-reset-save-for-eve.md +6 -0
  109. package/corpus/templates/content/changelog/2026-07-08-date-filters-can-now-target-a-between-range.md +6 -0
  110. package/corpus/templates/content/package.json +2 -2
  111. package/corpus/templates/content/parity/matrix.md +1 -1
  112. package/corpus/templates/content/parity/matrix.ts +2 -0
  113. package/corpus/templates/content/shared/api.ts +24 -0
  114. package/corpus/templates/design/AGENTS.md +9 -0
  115. package/corpus/templates/design/actions/request-localhost-write-consent.ts +141 -0
  116. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +13 -19
  117. package/corpus/templates/design/package.json +2 -2
  118. package/corpus/templates/design/server/lib/verify-write-grant.ts +4 -2
  119. package/corpus/templates/dispatch/app/i18n/zh-TW.ts +34 -1
  120. package/corpus/templates/dispatch/app/i18n-data.ts +387 -10
  121. package/corpus/templates/dispatch/app/routes/automations.tsx +1 -0
  122. package/corpus/templates/dispatch/changelog/2026-07-08-added-an-automations-page-to-review-pause-and-create.md +6 -0
  123. package/corpus/templates/dispatch/changelog/2026-07-08-destinations-now-shows-outbound-delivery-queue-health.md +6 -0
  124. package/corpus/templates/dispatch/package.json +2 -2
  125. package/corpus/templates/forms/package.json +2 -2
  126. package/corpus/templates/macros/package.json +2 -2
  127. package/corpus/templates/mail/app/hooks/use-emails.ts +56 -44
  128. package/corpus/templates/mail/app/lib/gmail-mutation-queue.ts +325 -0
  129. package/corpus/templates/mail/changelog/2026-07-08-rapid-archive-and-mark-read-now-batch-gmail-updates-so-the-i.md +6 -0
  130. package/corpus/templates/mail/package.json +2 -2
  131. package/corpus/templates/plan/package.json +2 -2
  132. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +46 -29
  133. package/corpus/templates/slides/app/context/DeckContext.tsx +45 -18
  134. package/corpus/templates/slides/app/i18n/ar-SA.ts +4 -0
  135. package/corpus/templates/slides/app/i18n/de-DE.ts +4 -0
  136. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  137. package/corpus/templates/slides/app/i18n/es-ES.ts +4 -0
  138. package/corpus/templates/slides/app/i18n/fr-FR.ts +4 -0
  139. package/corpus/templates/slides/app/i18n/hi-IN.ts +4 -0
  140. package/corpus/templates/slides/app/i18n/ja-JP.ts +4 -0
  141. package/corpus/templates/slides/app/i18n/ko-KR.ts +4 -0
  142. package/corpus/templates/slides/app/i18n/pt-BR.ts +4 -0
  143. package/corpus/templates/slides/app/i18n/zh-CN.ts +4 -0
  144. package/corpus/templates/slides/app/i18n/zh-TW.ts +4 -0
  145. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +115 -0
  146. package/corpus/templates/slides/app/pages/DeckEditor.tsx +22 -13
  147. package/corpus/templates/slides/changelog/2026-07-08-dropping-an-image-onto-a-slide-and-sending-it-to-the-agent-w.md +6 -0
  148. package/corpus/templates/slides/changelog/2026-07-08-undo-works-after-the-agent-edits-a-slide-so-you-can-reverse.md +6 -0
  149. package/corpus/templates/slides/package.json +2 -2
  150. package/dist/agent/run-manager.d.ts.map +1 -1
  151. package/dist/agent/run-manager.js +23 -2
  152. package/dist/agent/run-manager.js.map +1 -1
  153. package/dist/agent/run-store.js +2 -2
  154. package/dist/agent/run-store.js.map +1 -1
  155. package/dist/catalog.json +2 -2
  156. package/dist/cli/add.js +1 -1
  157. package/dist/cli/add.js.map +1 -1
  158. package/dist/cli/create.d.ts.map +1 -1
  159. package/dist/cli/create.js +8 -3
  160. package/dist/cli/create.js.map +1 -1
  161. package/dist/cli/design-connect.d.ts.map +1 -1
  162. package/dist/cli/design-connect.js +40 -10
  163. package/dist/cli/design-connect.js.map +1 -1
  164. package/dist/cli/index.js +5 -4
  165. package/dist/cli/index.js.map +1 -1
  166. package/dist/client/extensions/ExtensionSlot.js +6 -0
  167. package/dist/client/extensions/ExtensionSlot.js.map +1 -1
  168. package/dist/client/session-replay.d.ts +8 -0
  169. package/dist/client/session-replay.d.ts.map +1 -1
  170. package/dist/client/session-replay.js +80 -3
  171. package/dist/client/session-replay.js.map +1 -1
  172. package/dist/collab/routes.d.ts +1 -1
  173. package/dist/jobs/scheduler.js +26 -1
  174. package/dist/jobs/scheduler.js.map +1 -1
  175. package/dist/localization/default-messages.d.ts +19 -0
  176. package/dist/localization/default-messages.d.ts.map +1 -1
  177. package/dist/localization/default-messages.js +19 -0
  178. package/dist/localization/default-messages.js.map +1 -1
  179. package/dist/notifications/routes.d.ts +1 -1
  180. package/dist/observability/routes.d.ts +1 -1
  181. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  182. package/dist/resources/handlers.d.ts +1 -1
  183. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  184. package/dist/templates/default/app/i18n/ar-SA.ts +21 -0
  185. package/dist/templates/default/app/i18n/de-DE.ts +21 -0
  186. package/dist/templates/default/app/i18n/en-US.ts +21 -0
  187. package/dist/templates/default/app/i18n/es-ES.ts +21 -0
  188. package/dist/templates/default/app/i18n/fr-FR.ts +21 -0
  189. package/dist/templates/default/app/i18n/hi-IN.ts +20 -0
  190. package/dist/templates/default/app/i18n/ja-JP.ts +21 -0
  191. package/dist/templates/default/app/i18n/ko-KR.ts +20 -0
  192. package/dist/templates/default/app/i18n/pt-BR.ts +21 -0
  193. package/dist/templates/default/app/i18n/zh-CN.ts +20 -0
  194. package/dist/templates/default/app/i18n/zh-TW.ts +19 -0
  195. package/dist/templates/default/package.json +2 -2
  196. package/dist/templates/headless/package.json +3 -3
  197. package/dist/templates/workspace-core/package.json +6 -6
  198. package/dist/templates/workspace-root/package.json +2 -2
  199. package/dist/triggers/dispatcher.js +27 -2
  200. package/dist/triggers/dispatcher.js.map +1 -1
  201. package/dist/vite/client.d.ts.map +1 -1
  202. package/dist/vite/client.js +70 -1
  203. package/dist/vite/client.js.map +1 -1
  204. package/package.json +7 -6
  205. package/src/templates/default/app/i18n/ar-SA.ts +21 -0
  206. package/src/templates/default/app/i18n/de-DE.ts +21 -0
  207. package/src/templates/default/app/i18n/en-US.ts +21 -0
  208. package/src/templates/default/app/i18n/es-ES.ts +21 -0
  209. package/src/templates/default/app/i18n/fr-FR.ts +21 -0
  210. package/src/templates/default/app/i18n/hi-IN.ts +20 -0
  211. package/src/templates/default/app/i18n/ja-JP.ts +21 -0
  212. package/src/templates/default/app/i18n/ko-KR.ts +20 -0
  213. package/src/templates/default/app/i18n/pt-BR.ts +21 -0
  214. package/src/templates/default/app/i18n/zh-CN.ts +20 -0
  215. package/src/templates/default/app/i18n/zh-TW.ts +19 -0
  216. package/src/templates/default/package.json +2 -2
  217. package/src/templates/headless/package.json +3 -3
  218. package/src/templates/workspace-core/package.json +6 -6
  219. package/src/templates/workspace-root/package.json +2 -2
@@ -13,10 +13,11 @@ import {
13
13
  useMutation,
14
14
  useQueryClient,
15
15
  } from "@tanstack/react-query";
16
- import { useMemo, useRef } from "react";
16
+ import { useMemo } from "react";
17
17
  import { toast } from "sonner";
18
18
 
19
19
  import { useAccountFilter } from "@/hooks/use-account-filter";
20
+ import { gmailMutationQueue } from "@/lib/gmail-mutation-queue";
20
21
  import { TAB_ID } from "@/lib/tab-id";
21
22
  import {
22
23
  useThreadCache,
@@ -636,14 +637,21 @@ export function useMarkRead() {
636
637
  id,
637
638
  isRead,
638
639
  accountEmail,
640
+ threadId,
639
641
  }: {
640
642
  id: string;
641
643
  isRead: boolean;
642
644
  accountEmail?: string;
645
+ threadId?: string;
643
646
  }) =>
644
- callAction("mark-read", { id, unread: !isRead, accountEmail }).then(
645
- assertActionSuccess,
646
- ),
647
+ // Buffer + batch via Gmail messages.batchModify so rapid open/mark-read
648
+ // (and e-e-e archive) stay snappy without burning quota per keypress.
649
+ gmailMutationQueue.enqueue("mark-read", {
650
+ id,
651
+ threadId,
652
+ accountEmail,
653
+ flag: isRead,
654
+ }),
647
655
  onMutate: async ({ id, isRead }) => {
648
656
  await qc.cancelQueries({ queryKey: ["emails"] });
649
657
  const previous = qc.getQueriesData<InfiniteEmails>({
@@ -667,22 +675,9 @@ export function useMarkRead() {
667
675
 
668
676
  export function useMarkThreadRead() {
669
677
  const qc = useQueryClient();
670
- // Per-thread pending entries — using a Map so concurrent mutations for different
671
- // threads don't overwrite each other's pending entries.
672
- const pendingByThread = useRef(
673
- new Map<string, { id: string; accountEmail?: string }[]>(),
674
- );
675
678
  return useMutation({
676
- mutationFn: async (threadId: string) => {
677
- const entries = pendingByThread.current.get(threadId) ?? [];
678
- pendingByThread.current.delete(threadId);
679
- if (entries.length > 0) {
680
- await callAction("mark-thread-read", {
681
- threadId,
682
- accountEmail: entries[0]?.accountEmail,
683
- }).then(assertActionSuccess);
684
- }
685
- },
679
+ mutationFn: (threadId: string) =>
680
+ callAction("mark-thread-read", { threadId }).then(assertActionSuccess),
686
681
  onMutate: async (threadId) => {
687
682
  await qc.cancelQueries({ queryKey: ["emails"] });
688
683
  const previous = qc.getQueriesData<InfiniteEmails>({
@@ -691,11 +686,9 @@ export function useMarkThreadRead() {
691
686
  // Capture unread entries BEFORE optimistic update
692
687
  const allEmails =
693
688
  previous.flatMap(([, data]) => flattenInfiniteEmails(data)) ?? [];
694
- const unreadEntries = allEmails
689
+ const unreadIds = allEmails
695
690
  .filter((e) => (e.threadId || e.id) === threadId && !e.isRead)
696
- .map((e) => ({ id: e.id, accountEmail: e.accountEmail }));
697
- pendingByThread.current.set(threadId, unreadEntries);
698
- const unreadIds = unreadEntries.map((e) => e.id);
691
+ .map((e) => e.id);
699
692
  const previousThread = getCachedThread(threadId);
700
693
  // Set overrides so refetches don't revert read state
701
694
  for (const id of unreadIds) {
@@ -737,17 +730,19 @@ export function useToggleStar() {
737
730
  id,
738
731
  isStarred,
739
732
  accountEmail,
733
+ threadId,
740
734
  }: {
741
735
  id: string;
742
736
  isStarred: boolean;
743
737
  accountEmail?: string;
744
738
  threadId?: string;
745
739
  }) =>
746
- callAction("star-email", {
740
+ gmailMutationQueue.enqueue("star", {
747
741
  id,
748
- unstar: !isStarred,
742
+ threadId,
749
743
  accountEmail,
750
- }).then(assertActionSuccess),
744
+ flag: isStarred,
745
+ }),
751
746
  onMutate: async ({ id, isStarred, threadId }) => {
752
747
  await qc.cancelQueries({ queryKey: ["emails"] });
753
748
  const previous = qc.getQueriesData<InfiniteEmails>({
@@ -802,12 +797,12 @@ export function useArchiveEmail() {
802
797
  removeLabel?: string;
803
798
  threadId?: string;
804
799
  }) =>
805
- callAction("archive-email", {
800
+ gmailMutationQueue.enqueue("archive", {
806
801
  id,
807
802
  accountEmail,
808
803
  removeLabel,
809
804
  threadId,
810
- }).then(assertActionSuccess),
805
+ }),
811
806
  onMutate: async ({
812
807
  id,
813
808
  threadId: hintedThreadId,
@@ -848,8 +843,13 @@ export function useArchiveEmail() {
848
843
  export function useUnarchiveEmail() {
849
844
  const qc = useQueryClient();
850
845
  return useMutation({
851
- mutationFn: (id: string) =>
852
- callAction("unarchive-email", { id }).then(assertActionSuccess),
846
+ mutationFn: (id: string) => {
847
+ // Undo often lands inside the debounce window — drop the pending
848
+ // archive so we never send a modify we immediately reverse.
849
+ const cancelled = gmailMutationQueue.cancel("archive", id);
850
+ if (cancelled) return Promise.resolve("cancelled-pending-archive");
851
+ return callAction("unarchive-email", { id }).then(assertActionSuccess);
852
+ },
853
853
  onSuccess: () => qc.invalidateQueries({ queryKey: ["emails"] }),
854
854
  });
855
855
  }
@@ -924,10 +924,14 @@ export function useBulkArchiveEmails() {
924
924
  const t = useT();
925
925
  return useMutation({
926
926
  mutationFn: ({ targets, removeLabel }: BulkArchiveVars) =>
927
- callAction("archive-email", {
928
- ...bulkActionArgs(targets),
929
- removeLabel,
930
- }).then(assertActionSuccess),
927
+ Promise.all(
928
+ targets.map((target) =>
929
+ gmailMutationQueue.enqueue("archive", {
930
+ ...target,
931
+ removeLabel,
932
+ }),
933
+ ),
934
+ ).then(() => `Queued archive for ${targets.length} email(s)`),
931
935
  onMutate: async ({ targets }: BulkArchiveVars) => {
932
936
  await qc.cancelQueries({ queryKey: ["emails"] });
933
937
  const previous = qc.getQueriesData<InfiniteEmails>({
@@ -1008,7 +1012,7 @@ export function useBulkTrashEmails() {
1008
1012
  });
1009
1013
  }
1010
1014
 
1011
- /** Bulk star/unstar: one action call, one optimistic override per message. */
1015
+ /** Bulk star/unstar: queued + batched Gmail modify, one optimistic override. */
1012
1016
  export function useBulkToggleStar() {
1013
1017
  const qc = useQueryClient();
1014
1018
  return useMutation({
@@ -1019,10 +1023,14 @@ export function useBulkToggleStar() {
1019
1023
  targets: BulkEmailTarget[];
1020
1024
  isStarred: boolean;
1021
1025
  }) =>
1022
- callAction("star-email", {
1023
- ...bulkActionArgs(targets),
1024
- unstar: !isStarred,
1025
- }).then(assertActionSuccess),
1026
+ Promise.all(
1027
+ targets.map((target) =>
1028
+ gmailMutationQueue.enqueue("star", {
1029
+ ...target,
1030
+ flag: isStarred,
1031
+ }),
1032
+ ),
1033
+ ).then(() => `Queued star for ${targets.length} email(s)`),
1026
1034
  onMutate: async ({ targets, isStarred }) => {
1027
1035
  await qc.cancelQueries({ queryKey: ["emails"] });
1028
1036
  const previous = qc.getQueriesData<InfiniteEmails>({
@@ -1045,7 +1053,7 @@ export function useBulkToggleStar() {
1045
1053
  });
1046
1054
  }
1047
1055
 
1048
- /** Bulk mark read/unread: one action call, one optimistic override per message. */
1056
+ /** Bulk mark read/unread: queued + batched Gmail modify, one optimistic override. */
1049
1057
  export function useBulkMarkRead() {
1050
1058
  const qc = useQueryClient();
1051
1059
  return useMutation({
@@ -1056,10 +1064,14 @@ export function useBulkMarkRead() {
1056
1064
  targets: BulkEmailTarget[];
1057
1065
  isRead: boolean;
1058
1066
  }) =>
1059
- callAction("mark-read", {
1060
- ...bulkActionArgs(targets),
1061
- unread: !isRead,
1062
- }).then(assertActionSuccess),
1067
+ Promise.all(
1068
+ targets.map((target) =>
1069
+ gmailMutationQueue.enqueue("mark-read", {
1070
+ ...target,
1071
+ flag: isRead,
1072
+ }),
1073
+ ),
1074
+ ).then(() => `Queued mark-read for ${targets.length} email(s)`),
1063
1075
  onMutate: async ({ targets, isRead }) => {
1064
1076
  await qc.cancelQueries({ queryKey: ["emails"] });
1065
1077
  const previous = qc.getQueriesData<InfiniteEmails>({
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Debounced client-side buffer for Gmail state mutations.
3
+ *
4
+ * Rapid archive / mark-read / star must feel instant (optimistic UI), but
5
+ * each keypress hitting Gmail as its own modify burns quota. We keep the
6
+ * React Query optimistic path, then coalesce outbound calls into the
7
+ * existing bulk actions (one messages.batchModify per account).
8
+ */
9
+
10
+ import { callAction } from "@agent-native/core/client";
11
+
12
+ export type GmailMutationKind = "archive" | "mark-read" | "star";
13
+
14
+ export interface GmailMutationTarget {
15
+ id: string;
16
+ threadId?: string;
17
+ accountEmail?: string;
18
+ /** Archive-from-label view: also remove this label. */
19
+ removeLabel?: string;
20
+ /** mark-read: true = read, false = unread. star: true = star. */
21
+ flag?: boolean;
22
+ }
23
+
24
+ interface QueuedMutation extends GmailMutationTarget {
25
+ kind: GmailMutationKind;
26
+ resolve: () => void;
27
+ reject: (error: unknown) => void;
28
+ }
29
+
30
+ const DEFAULT_DEBOUNCE_MS = 280;
31
+ const MAX_WAIT_MS = 1200;
32
+
33
+ type FlushListener = (info: {
34
+ kind: GmailMutationKind;
35
+ count: number;
36
+ error?: unknown;
37
+ }) => void;
38
+
39
+ function targetKey(
40
+ kind: GmailMutationKind,
41
+ target: GmailMutationTarget,
42
+ ): string {
43
+ // Coalesce by message id + kind + removeLabel so re-pressing `e` on the
44
+ // same thread replaces the pending op instead of stacking duplicates.
45
+ return `${kind}:${target.id}:${target.removeLabel ?? ""}:${target.flag ?? ""}`;
46
+ }
47
+
48
+ function bulkArgs(targets: GmailMutationTarget[]) {
49
+ return {
50
+ id: targets.map((t) => t.id).join(","),
51
+ threadIds: targets.map((t) => t.threadId ?? "").join(","),
52
+ accountEmails: targets.map((t) => t.accountEmail ?? "").join(","),
53
+ };
54
+ }
55
+
56
+ function assertActionSuccess<T>(result: T): T {
57
+ if (
58
+ typeof result === "string" &&
59
+ (result.startsWith("Error:") || /failed/i.test(result.slice(0, 40)))
60
+ ) {
61
+ throw new Error(result);
62
+ }
63
+ if (
64
+ result &&
65
+ typeof result === "object" &&
66
+ "ok" in result &&
67
+ (result as { ok?: boolean }).ok === false
68
+ ) {
69
+ const message =
70
+ "error" in result &&
71
+ typeof (result as { error?: unknown }).error === "string"
72
+ ? (result as { error: string }).error
73
+ : "Action failed";
74
+ throw new Error(message);
75
+ }
76
+ return result;
77
+ }
78
+
79
+ class GmailMutationQueue {
80
+ private pending = new Map<string, QueuedMutation>();
81
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
82
+ private maxWaitTimer: ReturnType<typeof setTimeout> | null = null;
83
+ private flushing: Promise<void> | null = null;
84
+ private firstEnqueueAt = 0;
85
+ private debounceMs = DEFAULT_DEBOUNCE_MS;
86
+ private listeners = new Set<FlushListener>();
87
+ private installedUnload = false;
88
+
89
+ /** Test-only: override debounce. */
90
+ setDebounceMs(ms: number) {
91
+ this.debounceMs = ms;
92
+ }
93
+
94
+ onFlush(listener: FlushListener): () => void {
95
+ this.listeners.add(listener);
96
+ return () => this.listeners.delete(listener);
97
+ }
98
+
99
+ /** Pending op count — useful in tests. */
100
+ size(): number {
101
+ return this.pending.size;
102
+ }
103
+
104
+ enqueue(kind: GmailMutationKind, target: GmailMutationTarget): Promise<void> {
105
+ this.ensureUnloadHook();
106
+ const key = targetKey(kind, target);
107
+ return new Promise<void>((resolve, reject) => {
108
+ const existing = this.pending.get(key);
109
+ if (existing) {
110
+ // Drop the superseded waiter as success — the newer op owns the flush.
111
+ existing.resolve();
112
+ }
113
+ this.pending.set(key, { kind, ...target, resolve, reject });
114
+ if (!this.firstEnqueueAt) this.firstEnqueueAt = Date.now();
115
+ this.scheduleFlush();
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Drop a pending op without sending it (e.g. user undid an archive before
121
+ * the debounce window closed). Resolves waiters so callers don't hang.
122
+ */
123
+ cancel(kind: GmailMutationKind, id: string, removeLabel?: string): boolean {
124
+ let cancelled = false;
125
+ for (const [key, op] of this.pending) {
126
+ if (
127
+ op.kind === kind &&
128
+ op.id === id &&
129
+ (removeLabel === undefined || op.removeLabel === removeLabel)
130
+ ) {
131
+ this.pending.delete(key);
132
+ op.resolve();
133
+ cancelled = true;
134
+ }
135
+ }
136
+ if (this.pending.size === 0) this.clearTimers();
137
+ return cancelled;
138
+ }
139
+
140
+ /** Force-send everything now. Safe to call while a flush is already running. */
141
+ async flush(): Promise<void> {
142
+ this.clearTimers();
143
+ if (this.flushing) {
144
+ await this.flushing;
145
+ if (this.pending.size > 0) await this.flush();
146
+ return;
147
+ }
148
+ if (this.pending.size === 0) return;
149
+
150
+ const batch = [...this.pending.values()];
151
+ this.pending.clear();
152
+ this.firstEnqueueAt = 0;
153
+
154
+ this.flushing = this.runFlush(batch).finally(() => {
155
+ this.flushing = null;
156
+ });
157
+ await this.flushing;
158
+ }
159
+
160
+ private scheduleFlush() {
161
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
162
+ this.debounceTimer = setTimeout(() => {
163
+ void this.flush();
164
+ }, this.debounceMs);
165
+
166
+ if (!this.maxWaitTimer && this.firstEnqueueAt) {
167
+ const remaining = Math.max(
168
+ 0,
169
+ MAX_WAIT_MS - (Date.now() - this.firstEnqueueAt),
170
+ );
171
+ this.maxWaitTimer = setTimeout(() => {
172
+ void this.flush();
173
+ }, remaining);
174
+ }
175
+ }
176
+
177
+ private clearTimers() {
178
+ if (this.debounceTimer) {
179
+ clearTimeout(this.debounceTimer);
180
+ this.debounceTimer = null;
181
+ }
182
+ if (this.maxWaitTimer) {
183
+ clearTimeout(this.maxWaitTimer);
184
+ this.maxWaitTimer = null;
185
+ }
186
+ }
187
+
188
+ private async runFlush(batch: QueuedMutation[]): Promise<void> {
189
+ const byKind = new Map<GmailMutationKind, QueuedMutation[]>();
190
+ for (const op of batch) {
191
+ const list = byKind.get(op.kind) ?? [];
192
+ list.push(op);
193
+ byKind.set(op.kind, list);
194
+ }
195
+
196
+ for (const [kind, ops] of byKind) {
197
+ // Group archives that share the same removeLabel so label-view archives
198
+ // stay correct without blocking the default bulk INBOX path.
199
+ if (kind === "archive") {
200
+ const byLabel = new Map<string, QueuedMutation[]>();
201
+ for (const op of ops) {
202
+ const labelKey = op.removeLabel ?? "";
203
+ const list = byLabel.get(labelKey) ?? [];
204
+ list.push(op);
205
+ byLabel.set(labelKey, list);
206
+ }
207
+ for (const [, group] of byLabel) {
208
+ await this.flushArchive(group);
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (kind === "mark-read") {
214
+ const byFlag = new Map<boolean, QueuedMutation[]>();
215
+ for (const op of ops) {
216
+ const flag = op.flag !== false;
217
+ const list = byFlag.get(flag) ?? [];
218
+ list.push(op);
219
+ byFlag.set(flag, list);
220
+ }
221
+ for (const [isRead, group] of byFlag) {
222
+ await this.flushMarkRead(group, isRead);
223
+ }
224
+ continue;
225
+ }
226
+
227
+ if (kind === "star") {
228
+ const byFlag = new Map<boolean, QueuedMutation[]>();
229
+ for (const op of ops) {
230
+ const flag = op.flag !== false;
231
+ const list = byFlag.get(flag) ?? [];
232
+ list.push(op);
233
+ byFlag.set(flag, list);
234
+ }
235
+ for (const [isStarred, group] of byFlag) {
236
+ await this.flushStar(group, isStarred);
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ private async flushArchive(ops: QueuedMutation[]): Promise<void> {
243
+ try {
244
+ await callAction("archive-email", {
245
+ ...bulkArgs(ops),
246
+ removeLabel: ops[0]?.removeLabel,
247
+ }).then(assertActionSuccess);
248
+ for (const op of ops) op.resolve();
249
+ this.emit({ kind: "archive", count: ops.length });
250
+ } catch (error) {
251
+ for (const op of ops) op.reject(error);
252
+ this.emit({ kind: "archive", count: ops.length, error });
253
+ }
254
+ }
255
+
256
+ private async flushMarkRead(
257
+ ops: QueuedMutation[],
258
+ isRead: boolean,
259
+ ): Promise<void> {
260
+ try {
261
+ await callAction("mark-read", {
262
+ ...bulkArgs(ops),
263
+ unread: !isRead,
264
+ }).then(assertActionSuccess);
265
+ for (const op of ops) op.resolve();
266
+ this.emit({ kind: "mark-read", count: ops.length });
267
+ } catch (error) {
268
+ for (const op of ops) op.reject(error);
269
+ this.emit({ kind: "mark-read", count: ops.length, error });
270
+ }
271
+ }
272
+
273
+ private async flushStar(
274
+ ops: QueuedMutation[],
275
+ isStarred: boolean,
276
+ ): Promise<void> {
277
+ try {
278
+ await callAction("star-email", {
279
+ ...bulkArgs(ops),
280
+ unstar: !isStarred,
281
+ }).then(assertActionSuccess);
282
+ for (const op of ops) op.resolve();
283
+ this.emit({ kind: "star", count: ops.length });
284
+ } catch (error) {
285
+ for (const op of ops) op.reject(error);
286
+ this.emit({ kind: "star", count: ops.length, error });
287
+ }
288
+ }
289
+
290
+ private emit(info: {
291
+ kind: GmailMutationKind;
292
+ count: number;
293
+ error?: unknown;
294
+ }) {
295
+ for (const listener of this.listeners) {
296
+ try {
297
+ listener(info);
298
+ } catch {
299
+ // ignore listener errors
300
+ }
301
+ }
302
+ }
303
+
304
+ private ensureUnloadHook() {
305
+ if (this.installedUnload || typeof window === "undefined") return;
306
+ this.installedUnload = true;
307
+ const flushSync = () => {
308
+ // Best-effort: kick flush; can't reliably await on unload.
309
+ void this.flush();
310
+ };
311
+ window.addEventListener("pagehide", flushSync);
312
+ window.addEventListener("beforeunload", flushSync);
313
+ }
314
+
315
+ /** Test helper: wipe pending state. */
316
+ resetForTests() {
317
+ this.clearTimers();
318
+ for (const op of this.pending.values()) op.resolve();
319
+ this.pending.clear();
320
+ this.firstEnqueueAt = 0;
321
+ this.flushing = null;
322
+ }
323
+ }
324
+
325
+ export const gmailMutationQueue = new GmailMutationQueue();
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-08
4
+ ---
5
+
6
+ Rapid archive and mark-read now batch Gmail updates so the inbox stays snappy under rate limits
@@ -80,7 +80,6 @@
80
80
  "@types/node": "^24.2.1",
81
81
  "@types/react": "catalog:",
82
82
  "@types/react-dom": "catalog:",
83
- "@typescript/native-preview": "catalog:",
84
83
  "@vitejs/plugin-react-swc": "^4.0.0",
85
84
  "class-variance-authority": "^0.7.1",
86
85
  "clsx": "^2.1.1",
@@ -103,7 +102,8 @@
103
102
  "tailwindcss": "catalog:",
104
103
  "tiptap-markdown": "^0.9.0",
105
104
  "tsx": "^4.20.3",
106
- "typescript": "^6.0.3",
105
+ "typescript": "catalog:",
106
+ "typescript-7": "catalog:",
107
107
  "vaul": "^1.1.2",
108
108
  "vite": "catalog:",
109
109
  "vitest": "catalog:"
@@ -81,7 +81,6 @@
81
81
  "@types/node": "^24.2.1",
82
82
  "@types/react": "catalog:",
83
83
  "@types/react-dom": "catalog:",
84
- "@typescript/native-preview": "catalog:",
85
84
  "@xterm/addon-fit": "^0.11.0",
86
85
  "@xterm/addon-web-links": "^0.12.0",
87
86
  "@xterm/xterm": "^6.0.0",
@@ -103,7 +102,8 @@
103
102
  "tailwind-merge": "^3.5.0",
104
103
  "tailwindcss": "catalog:",
105
104
  "tsx": "^4.20.3",
106
- "typescript": "^6.0.3",
105
+ "typescript": "catalog:",
106
+ "typescript-7": "catalog:",
107
107
  "vaul": "^1.1.2",
108
108
  "vite": "catalog:",
109
109
  "vitest": "catalog:"
@@ -6,6 +6,12 @@ import { createPortal } from "react-dom";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Textarea } from "@/components/ui/textarea";
8
8
  import { toast } from "@/hooks/use-toast";
9
+ import {
10
+ buildImageDropAgentPayload,
11
+ readFileAsDataUrl,
12
+ type HostedImageUploadResult,
13
+ } from "@/lib/image-drop-to-agent";
14
+ import { parseUploadResponse } from "@/lib/upload-response";
9
15
 
10
16
  const POPOVER_WIDTH = 360;
11
17
  const POPOVER_MARGIN = 12;
@@ -24,8 +30,12 @@ interface ImageDropPromptPopoverProps {
24
30
  * Popover shown after a user drops an image somewhere on the slides editor
25
31
  * that doesn't have a clear target (i.e. not on an image placeholder or
26
32
  * existing `<img>`). The popover previews the image, lets the user describe
27
- * what to do with it, and hands the task off to the agent chat with the image
28
- * uploaded to Builder.io as a reference URL.
33
+ * what to do with it, and hands the task off to the agent chat.
34
+ *
35
+ * Prefers a hosted CDN URL via `/api/assets/upload` when a file-upload
36
+ * provider (Builder.io / S3 / …) is configured. When nothing is configured,
37
+ * falls back to an inline data-URL attachment so the drop still reaches the
38
+ * agent instead of toasting a 503.
29
39
  *
30
40
  * Why this exists: dropping image files onto an unclear target previously did
31
41
  * one of two unhelpful things — opened the file in a new browser tab (when the
@@ -113,44 +123,51 @@ export default function ImageDropPromptPopover({
113
123
  try {
114
124
  const form = new FormData();
115
125
  form.append("file", file);
116
- // Use the slides assets endpoint (which routes through the framework's
117
- // uploadFile() provider chain first, then falls back to local disk in
118
- // dev). Goes via Builder.io when configured; works without it in dev.
126
+ // Prefer the hosted provider chain. When none is configured the route
127
+ // returns 503 fall back to an inline data URL so the agent still gets
128
+ // the image (chat already accepts `images` data URLs).
119
129
  const res = await fetch(`${appBasePath()}/api/assets/upload`, {
120
130
  method: "POST",
121
131
  body: form,
122
132
  });
123
- const data = (await res.json().catch(() => null)) as {
133
+ const data = await parseUploadResponse<{
124
134
  url?: string;
125
135
  error?: string;
126
- } | null;
127
- if (!res.ok || !data?.url) {
128
- throw new Error(
129
- data?.error ||
130
- "Image upload failed. Connect Builder.io from the agent composer model menu, or register a custom provider via registerFileUploadProvider().",
131
- );
132
- }
136
+ }>(res, t("raw.imageUploadGenericError"));
137
+ const upload: HostedImageUploadResult = {
138
+ ok: res.ok && !!data.url,
139
+ status: res.status,
140
+ url: data.url,
141
+ error: data.error,
142
+ };
133
143
 
134
- const userIntent = prompt.trim();
135
- const intentLine =
136
- userIntent.length > 0
137
- ? userIntent
138
- : "Use this image on the current slide.";
139
- const lines = [intentLine];
140
- if (contextHint && contextHint.trim().length > 0) {
141
- lines.push(contextHint.trim());
144
+ let dataUrl: string | undefined;
145
+ if (!upload.ok) {
146
+ dataUrl = await readFileAsDataUrl(file);
142
147
  }
143
- lines.push(
144
- `Image URL (already uploaded): ${data.url}`,
145
- `Filename: ${file.name}`,
146
- );
147
148
 
148
- sendToAgentChat({
149
- message: lines.join("\n\n"),
150
- submit: true,
151
- referenceImagePaths: [data.url],
149
+ const payload = buildImageDropAgentPayload({
150
+ intent: prompt,
151
+ contextHint,
152
+ filename: file.name,
153
+ upload,
154
+ dataUrl,
152
155
  });
153
156
 
157
+ if (payload.kind === "hosted") {
158
+ sendToAgentChat({
159
+ message: payload.message,
160
+ submit: true,
161
+ referenceImagePaths: payload.referenceImagePaths,
162
+ });
163
+ } else {
164
+ sendToAgentChat({
165
+ message: payload.message,
166
+ submit: true,
167
+ images: payload.images,
168
+ });
169
+ }
170
+
154
171
  onClose();
155
172
  toast({
156
173
  title: t("raw.sentToAgent"),