@agent-native/core 0.188.0 → 0.189.0

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 (73) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/CHANGELOG.md +6 -0
  3. package/corpus/templates/content/actions/list-document-versions.ts +102 -2
  4. package/corpus/templates/content/actions/restore-document-version.ts +50 -2
  5. package/corpus/templates/content/app/components/layout/Layout.tsx +6 -1
  6. package/corpus/templates/content/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
  7. package/corpus/templates/content/server/lib/document-history.ts +59 -8
  8. package/corpus/templates/content/server/lib/document-version-context.ts +5 -0
  9. package/corpus/templates/content/server/plugins/agent-chat.ts +164 -0
  10. package/corpus/templates/content/vite.config.ts +2 -38
  11. package/corpus/templates/design/CHANGELOG.md +4 -0
  12. package/corpus/templates/design/actions/list-design-versions.ts +3 -1
  13. package/corpus/templates/design/app/components/layout/Layout.tsx +17 -2
  14. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +10 -3
  15. package/corpus/templates/design/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
  16. package/corpus/templates/design/server/lib/design-versions.ts +136 -20
  17. package/corpus/templates/design/server/plugins/agent-chat.ts +18 -2
  18. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +36 -11
  19. package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +82 -0
  20. package/corpus/templates/mail/app/pages/InboxPage.tsx +18 -19
  21. package/corpus/templates/mail/changelog/2026-09-25-keep-inbox-tab-counts-consistent-when-switching-tabs.md +6 -0
  22. package/corpus/templates/slides/CHANGELOG.md +4 -0
  23. package/corpus/templates/slides/actions/list-deck-versions.ts +62 -10
  24. package/corpus/templates/slides/app/components/layout/Layout.tsx +10 -2
  25. package/corpus/templates/slides/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
  26. package/corpus/templates/slides/server/lib/deck-versions.ts +36 -1
  27. package/corpus/templates/slides/server/plugins/agent-chat.ts +26 -2
  28. package/dist/automation/index.d.ts +1 -1
  29. package/dist/cli/index.js +1 -2
  30. package/dist/client/AssistantChat.js +112 -35
  31. package/dist/client/chat/assistant-chat-history-version.d.ts +1 -0
  32. package/dist/client/chat/message-components.d.ts +8 -1
  33. package/dist/client/chat/message-components.js +52 -11
  34. package/dist/client/resources/mcp-integration-catalog.js +1 -1
  35. package/dist/client/resources/mcp-integration-logos.js +29 -30
  36. package/dist/collab/awareness.d.ts +2 -2
  37. package/dist/deploy/build.d.ts +1 -1
  38. package/dist/deploy/build.js +9 -348
  39. package/dist/deploy/workspace-deploy.d.ts +2 -2
  40. package/dist/deploy/workspace-deploy.js +9 -130
  41. package/dist/localization/core-messages/ar-SA.js +2 -0
  42. package/dist/localization/core-messages/de-DE.js +2 -0
  43. package/dist/localization/core-messages/en-US.d.ts +2 -0
  44. package/dist/localization/core-messages/en-US.js +2 -0
  45. package/dist/localization/core-messages/es-ES.js +2 -0
  46. package/dist/localization/core-messages/fr-FR.js +2 -0
  47. package/dist/localization/core-messages/hi-IN.js +2 -0
  48. package/dist/localization/core-messages/ja-JP.js +2 -0
  49. package/dist/localization/core-messages/ko-KR.js +2 -0
  50. package/dist/localization/core-messages/pt-BR.js +2 -0
  51. package/dist/localization/core-messages/zh-CN.js +2 -0
  52. package/dist/localization/core-messages/zh-TW.js +2 -0
  53. package/dist/localization/core-messages.d.ts +2 -0
  54. package/dist/observability/routes.d.ts +3 -3
  55. package/dist/progress/routes.d.ts +1 -1
  56. package/dist/secrets/routes.d.ts +9 -9
  57. package/dist/server/agent-chat/plugin-options.d.ts +2 -0
  58. package/dist/server/agent-chat-plugin.d.ts +1 -0
  59. package/dist/server/agent-chat-plugin.js +26 -0
  60. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  61. package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
  62. package/dist/server/onboarding-html.js +28 -5
  63. package/dist/vite/client.d.ts +1 -1
  64. package/docs/content/a2a-protocol.mdx +2 -2
  65. package/docs/content/cloudflare.mdx +2 -10
  66. package/docs/content/deploy-an-app.mdx +0 -1
  67. package/docs/content/deployment.mdx +14 -9
  68. package/docs/content/environment-variables.mdx +1 -1
  69. package/docs/content/multi-app-workspace.mdx +1 -1
  70. package/docs/content/netlify.mdx +1 -1
  71. package/docs/content/real-time-collaboration.mdx +6 -6
  72. package/docs/content/workspace-deployment.mdx +4 -15
  73. package/package.json +4 -4
@@ -129,15 +129,29 @@ export function Layout({ children }: LayoutProps) {
129
129
  if (!designId) return null;
130
130
  return { type: "design" as const, id: designId };
131
131
  }, [location.pathname]);
132
+ const flushDesignEditorSaves = useCallback(async () => {
133
+ const flushes: Promise<void>[] = [];
134
+ window.dispatchEvent(
135
+ new CustomEvent("agent-native:design-flush-pending-saves", {
136
+ detail: flushes,
137
+ }),
138
+ );
139
+ await Promise.all(flushes);
140
+ }, []);
132
141
  const designChatHistory = useMemo<
133
142
  AssistantChatHistoryConfig | undefined
134
143
  >(() => {
135
144
  if (!designScope) return undefined;
136
145
  const designId = designScope.id;
137
146
  return {
147
+ beforeStart: flushDesignEditorSaves,
138
148
  list: {
139
149
  action: "list-design-versions",
140
- args: { designId, limit: 100 },
150
+ args: (threadId) => ({
151
+ designId,
152
+ limit: 100,
153
+ ...(threadId ? { threadId } : {}),
154
+ }),
141
155
  getVersions: (result: unknown) => {
142
156
  const versions =
143
157
  result && typeof result === "object"
@@ -154,9 +168,10 @@ export function Layout({ children }: LayoutProps) {
154
168
  designId,
155
169
  versionId: version.id,
156
170
  }),
171
+ beforeRestore: flushDesignEditorSaves,
157
172
  },
158
173
  };
159
- }, [designScope]);
174
+ }, [designScope, flushDesignEditorSaves]);
160
175
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
161
176
  storageKey: DESIGN_CHAT_STORAGE_KEY,
162
177
  activePath: location.pathname,
@@ -741,9 +741,9 @@ export function flushFileContentSavesOnBackground(
741
741
  pendingByFileId: FileContentSaveRequestsById,
742
742
  latestUnacknowledgedByFileId: FileContentSaveRequestsById,
743
743
  timerIds: readonly number[],
744
- save: (pending: FileContentSaveRequest) => void,
744
+ save: (pending: FileContentSaveRequest) => unknown | Promise<unknown>,
745
745
  clearTimer: (timerId: number) => void,
746
- ): void {
746
+ ): Promise<void> {
747
747
  const newestByFileId = new Map<string, FileContentSaveRequest>();
748
748
  for (const pending of Object.values(latestUnacknowledgedByFileId)) {
749
749
  newestByFileId.set(pending.id, pending);
@@ -754,8 +754,15 @@ export function flushFileContentSavesOnBackground(
754
754
  newestByFileId.set(pending.id, pending);
755
755
  }
756
756
  }
757
- for (const pending of newestByFileId.values()) save(pending);
757
+ const saves = [...newestByFileId.values()].map((pending) => {
758
+ try {
759
+ return Promise.resolve(save(pending));
760
+ } catch (error) {
761
+ return Promise.reject(error);
762
+ }
763
+ });
758
764
  for (const timerId of timerIds) clearTimer(timerId);
765
+ return Promise.all(saves).then(() => undefined);
759
766
  }
760
767
 
761
768
  /**
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-09-25
4
+ ---
5
+
6
+ Chat messages can be reverted to their autosaved version, including a shortcut to restore the start of a chat.
@@ -18,7 +18,7 @@ import {
18
18
  import { captureError } from "@agent-native/core/server";
19
19
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
20
20
  import { accessFilter, assertAccess } from "@agent-native/core/sharing";
21
- import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
21
+ import { and, asc, desc, eq, isNull, like, sql } from "drizzle-orm";
22
22
  import { nanoid } from "nanoid";
23
23
 
24
24
  import {
@@ -49,6 +49,7 @@ export interface DesignVersionChatContext {
49
49
  runId?: string;
50
50
  turnId?: string;
51
51
  actionName?: string;
52
+ phase?: "start" | "end";
52
53
  surface?: "editor";
53
54
  /** Which editor-surface caller wrote this checkpoint. Only set alongside
54
55
  * `surface: "editor"` — see Throttle 1 below for why it matters. */
@@ -172,6 +173,15 @@ function stableStringify(value: unknown): string {
172
173
  .join(",")}}`;
173
174
  }
174
175
 
176
+ function designVersionIdForIdempotencyKey(
177
+ designId: string,
178
+ idempotencyKey: string,
179
+ ) {
180
+ return `design-version-${createHash("sha256")
181
+ .update(stableStringify({ designId, idempotencyKey }))
182
+ .digest("hex")}`;
183
+ }
184
+
175
185
  function nextRevisionTimestamp(previous: string | null | undefined): string {
176
186
  const previousMs = previous ? Date.parse(previous) : Number.NaN;
177
187
  return new Date(
@@ -204,6 +214,9 @@ function parseChatContext(
204
214
  context[key] = candidate;
205
215
  }
206
216
  }
217
+ if (value.phase === "start" || value.phase === "end") {
218
+ context.phase = value.phase;
219
+ }
207
220
  if (value.surface === "editor") context.surface = "editor";
208
221
  if (value.caller === "frontend" || value.caller === "webmcp") {
209
222
  context.caller = value.caller;
@@ -414,7 +427,7 @@ function chatContextKey(
414
427
  if (!context || context.surface === "editor") return null;
415
428
  const scope = context.threadId ?? "";
416
429
  const turn = context.turnId ?? context.runId ?? "";
417
- return turn ? `${scope}:${turn}` : null;
430
+ return turn ? `${scope}:${turn}:${context.phase ?? ""}` : null;
418
431
  }
419
432
 
420
433
  function actionChatContext(
@@ -621,6 +634,7 @@ async function captureDesignVersion(
621
634
  chatContext?: DesignVersionChatContext;
622
635
  deletionGeometry?: ComponentDeletionGeometry;
623
636
  preferStoredFileContent?: boolean;
637
+ idempotencyKey?: string;
624
638
  },
625
639
  access: DesignAccess,
626
640
  database?: DesignDatabase,
@@ -735,16 +749,18 @@ async function captureDesignVersion(
735
749
  };
736
750
  }
737
751
  }
738
- const id = `design-version-${createHash("sha256")
739
- .update(
740
- stableStringify({
741
- designId,
742
- previousVersionId: latest?.id ?? "initial",
743
- chatContextKey: chatContextKey(options.chatContext),
744
- stateHash,
745
- }),
746
- )
747
- .digest("hex")}`;
752
+ const id = options.idempotencyKey
753
+ ? designVersionIdForIdempotencyKey(designId, options.idempotencyKey)
754
+ : `design-version-${createHash("sha256")
755
+ .update(
756
+ stableStringify({
757
+ designId,
758
+ previousVersionId: latest?.id ?? "initial",
759
+ chatContextKey: chatContextKey(options.chatContext),
760
+ stateHash,
761
+ }),
762
+ )
763
+ .digest("hex")}`;
748
764
  const snapshot = JSON.stringify({
749
765
  schemaVersion: 1,
750
766
  snapshotKind: "design-history",
@@ -877,6 +893,41 @@ export async function createDesignVersionSnapshot(
877
893
  });
878
894
  }
879
895
 
896
+ export async function createDesignChatBeginningSnapshot(
897
+ designId: string,
898
+ run: { threadId: string; runId: string },
899
+ ) {
900
+ return withDesignVersionLock(designId, async () => {
901
+ const access = await assertAccess("design", designId, "editor");
902
+ const rows = await getDb()
903
+ .select({ id: schema.designVersions.id })
904
+ .from(schema.designVersions)
905
+ .where(
906
+ and(
907
+ eq(schema.designVersions.designId, designId),
908
+ eq(
909
+ schema.designVersions.id,
910
+ designVersionIdForIdempotencyKey(
911
+ designId,
912
+ `chat-start:${run.threadId}`,
913
+ ),
914
+ ),
915
+ ),
916
+ )
917
+ .limit(1);
918
+ if (rows.length) return null;
919
+ return captureDesignVersion(
920
+ designId,
921
+ {
922
+ label: "Before chat",
923
+ chatContext: { ...run, phase: "start" },
924
+ idempotencyKey: `chat-start:${run.threadId}`,
925
+ },
926
+ access,
927
+ );
928
+ });
929
+ }
930
+
880
931
  function checkpointSkipReason(
881
932
  error: unknown,
882
933
  ): DesignVersionCheckpointSkipReason {
@@ -1140,6 +1191,7 @@ export async function snapshotDesignBeforeAgentEditInVersionLock(
1140
1191
  export async function listDesignVersions(
1141
1192
  designId: string,
1142
1193
  limit: number,
1194
+ threadId?: string,
1143
1195
  ): Promise<{
1144
1196
  designId: string;
1145
1197
  count: number;
@@ -1147,7 +1199,8 @@ export async function listDesignVersions(
1147
1199
  versions: DesignVersionListEntry[];
1148
1200
  }> {
1149
1201
  await assertAccess("design", designId, "viewer");
1150
- const rows = await getDb()
1202
+ const db = getDb();
1203
+ const rows = await db
1151
1204
  .select({
1152
1205
  id: schema.designVersions.id,
1153
1206
  label: schema.designVersions.label,
@@ -1163,11 +1216,46 @@ export async function listDesignVersions(
1163
1216
  desc(schema.designVersions.id),
1164
1217
  )
1165
1218
  .limit(limit);
1219
+ const beginningRows = await db
1220
+ .select({
1221
+ id: schema.designVersions.id,
1222
+ label: schema.designVersions.label,
1223
+ createdAt: schema.designVersions.createdAt,
1224
+ chatContext: schema.designVersions.chatContext,
1225
+ fileCount: schema.designVersions.fileCount,
1226
+ })
1227
+ .from(schema.designVersions)
1228
+ .where(
1229
+ threadId
1230
+ ? and(
1231
+ eq(schema.designVersions.designId, designId),
1232
+ eq(
1233
+ schema.designVersions.id,
1234
+ designVersionIdForIdempotencyKey(
1235
+ designId,
1236
+ `chat-start:${threadId}`,
1237
+ ),
1238
+ ),
1239
+ )
1240
+ : and(
1241
+ eq(schema.designVersions.designId, designId),
1242
+ like(schema.designVersions.chatContext, '%"phase":"start"%'),
1243
+ ),
1244
+ )
1245
+ .orderBy(
1246
+ asc(isNull(schema.designVersions.createdAt)),
1247
+ asc(schema.designVersions.createdAt),
1248
+ )
1249
+ .limit(threadId ? 1 : limit);
1250
+ const rowsById = new Map(
1251
+ [...rows, ...beginningRows].map((row) => [row.id, row]),
1252
+ );
1166
1253
 
1167
1254
  const regular: DesignVersionListEntry[] = [];
1168
1255
  const chat = new Map<string, DesignVersionListEntry>();
1256
+ const activeStartEntries: DesignVersionListEntry[] = [];
1169
1257
  let invalidCount = 0;
1170
- for (const row of rows) {
1258
+ for (const row of rowsById.values()) {
1171
1259
  let chatContext: DesignVersionChatContext | undefined;
1172
1260
  try {
1173
1261
  chatContext = parseStoredChatContext(row.chatContext);
@@ -1195,19 +1283,47 @@ export async function listDesignVersions(
1195
1283
  regular.push(entry);
1196
1284
  continue;
1197
1285
  }
1198
- const previous = chat.get(key);
1199
1286
  if (
1200
- !previous ||
1201
- versionTime(entry.createdAt) < versionTime(previous.createdAt)
1287
+ threadId &&
1288
+ chatContext?.threadId === threadId &&
1289
+ chatContext.phase === "start"
1202
1290
  ) {
1203
- chat.set(key, entry);
1291
+ activeStartEntries.push(entry);
1292
+ continue;
1204
1293
  }
1294
+ const previous = chat.get(key);
1295
+ const replacePrevious =
1296
+ !previous ||
1297
+ (chatContext?.phase === "end"
1298
+ ? versionTime(entry.createdAt) > versionTime(previous.createdAt)
1299
+ : versionTime(entry.createdAt) < versionTime(previous.createdAt));
1300
+ if (replacePrevious) chat.set(key, entry);
1205
1301
  }
1206
1302
 
1207
- const versions = [...regular, ...chat.values()].sort(
1303
+ const versions = [...regular, ...chat.values(), ...activeStartEntries].sort(
1208
1304
  (left, right) => versionTime(right.createdAt) - versionTime(left.createdAt),
1209
1305
  );
1210
- return { designId, count: versions.length, invalidCount, versions };
1306
+ const limitedVersions = versions.slice(0, limit);
1307
+ const activeStart = threadId
1308
+ ? versions.find(
1309
+ (version) =>
1310
+ version.chatContext?.threadId === threadId &&
1311
+ version.chatContext.phase === "start",
1312
+ )
1313
+ : undefined;
1314
+ if (activeStart && !limitedVersions.includes(activeStart)) {
1315
+ limitedVersions[limitedVersions.length - 1] = activeStart;
1316
+ limitedVersions.sort(
1317
+ (left, right) =>
1318
+ versionTime(right.createdAt) - versionTime(left.createdAt),
1319
+ );
1320
+ }
1321
+ return {
1322
+ designId,
1323
+ count: limitedVersions.length,
1324
+ invalidCount,
1325
+ versions: limitedVersions,
1326
+ };
1211
1327
  }
1212
1328
 
1213
1329
  interface RestoreFile {
@@ -232,21 +232,37 @@ async function autosaveDesignAfterAgentTurn(
232
232
  },
233
233
  ): Promise<void> {
234
234
  if (scope.type !== "design" || !(await hasDesignEdit(run, scope.id))) return;
235
+ if (!run.threadId || !run.runId) return;
235
236
 
236
237
  const { createDesignVersionSnapshot } =
237
238
  await import("../lib/design-versions.js");
238
239
  await createDesignVersionSnapshot(scope.id, {
239
240
  label: "Chat autosave",
240
241
  chatContext: {
241
- ...(run.threadId ? { threadId: run.threadId } : {}),
242
- ...(run.runId ? { runId: run.runId } : {}),
242
+ threadId: run.threadId,
243
+ runId: run.runId,
243
244
  ...(run.turnId ? { turnId: run.turnId } : {}),
245
+ phase: "end",
244
246
  },
245
247
  });
246
248
  }
247
249
 
250
+ async function autosaveDesignBeforeAgentTurn(
251
+ scope: { type: string; id: string },
252
+ run: { threadId?: string; runId?: string },
253
+ ): Promise<void> {
254
+ if (scope.type !== "design" || !run.threadId || !run.runId) return;
255
+ const { createDesignChatBeginningSnapshot } =
256
+ await import("../lib/design-versions.js");
257
+ await createDesignChatBeginningSnapshot(scope.id, {
258
+ threadId: run.threadId,
259
+ runId: run.runId,
260
+ });
261
+ }
262
+
248
263
  export default createAgentChatPlugin({
249
264
  appId: "design",
265
+ onAgentTurnStart: autosaveDesignBeforeAgentTurn,
250
266
  onAgentTurnComplete: autosaveDesignAfterAgentTurn,
251
267
  actions: guardRepromptActionRegistry(
252
268
  loadActionsFromStaticRegistry(actionsRegistry),
@@ -101,7 +101,9 @@ import {
101
101
  import {
102
102
  INBOX_PAGE_SIZE,
103
103
  invalidateInboxThreads,
104
+ mergeOptimisticInboxTabCounts,
104
105
  resolveInboxTabId,
106
+ useInboxOverview,
105
107
  useInboxThreads,
106
108
  } from "@/hooks/use-inbox-threads";
107
109
  import {
@@ -563,18 +565,43 @@ function AppLayoutInner({ children }: AppLayoutProps) {
563
565
  const resolvedInboxTab = resolveInboxTabId(searchParams);
564
566
  const inboxAccountEmails =
565
567
  activeAccounts.size > 0 ? [...activeAccounts] : undefined;
566
- const inboxThreads = useInboxThreads({
568
+ const inboxThreadInput = {
567
569
  tab: resolvedInboxTab,
568
570
  accountEmails: inboxAccountEmails,
569
571
  limit: INBOX_PAGE_SIZE,
570
572
  offset: 0,
571
- });
573
+ };
574
+ const inboxThreads = useInboxThreads(inboxThreadInput);
575
+ const inboxRawPage = queryClient.getQueryData<
576
+ NonNullable<typeof inboxThreads.data>
577
+ >(["action", "list-inbox-threads", inboxThreadInput]);
578
+ const inboxOverview = useInboxOverview(inboxAccountEmails);
579
+ const inboxMetadata =
580
+ inboxOverview.data ??
581
+ (inboxThreads.isPlaceholderData ? undefined : inboxThreads.data);
582
+ const inboxTabs = useMemo(() => {
583
+ const tabs = inboxMetadata?.tabs ?? [];
584
+ if (!inboxOverview.data || inboxThreads.isPlaceholderData) {
585
+ return tabs;
586
+ }
587
+ return mergeOptimisticInboxTabCounts(
588
+ inboxOverview.data,
589
+ inboxRawPage,
590
+ inboxThreads.data,
591
+ );
592
+ }, [
593
+ inboxMetadata?.tabs,
594
+ inboxOverview.data,
595
+ inboxThreads.data,
596
+ inboxThreads.isPlaceholderData,
597
+ inboxRawPage,
598
+ ]);
572
599
  const activeInboxTabId = inboxThreads.isPlaceholderData
573
600
  ? (resolvedInboxTab ?? inboxThreads.data?.tabs[0]?.id)
574
601
  : (inboxThreads.data?.activeTabId ?? resolvedInboxTab);
575
602
  const inboxIsFetching = inboxThreads.isFetching;
576
- const inboxSyncing = inboxThreads.data?.syncing === true;
577
- const needsReauthAccount = inboxThreads.data?.accounts.find(
603
+ const inboxSyncing = inboxMetadata?.syncing === true;
604
+ const needsReauthAccount = inboxMetadata?.accounts.find(
578
605
  (account) => account.state === "needs_reauth",
579
606
  );
580
607
 
@@ -721,12 +748,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
721
748
  }, [combineInbox, pinnedLabels, view, t]);
722
749
 
723
750
  // The inbox split (Important / pinned labels / saved filters / Other) with
724
- // its counts comes straight from the server — see the useInboxThreads call
725
- // above. A tab's badge can never disagree with what it lists because both
726
- // are read off the same row.
751
+ // its counts comes from one account-scoped snapshot, shared across each
752
+ // tab's separately cached row page.
727
753
  const dataTabs = useMemo<RenderedTab[]>(() => {
728
- const tabs = inboxThreads.data?.tabs ?? [];
729
- return tabs.map((tab) => {
754
+ return inboxTabs.map((tab) => {
730
755
  const label = labels.find((l) => l.id === tab.id);
731
756
  return {
732
757
  id: tab.id,
@@ -743,7 +768,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
743
768
  isSystemView: false,
744
769
  };
745
770
  });
746
- }, [inboxThreads.data?.tabs, activeInboxTabId, labels, t, view]);
771
+ }, [inboxTabs, activeInboxTabId, labels, t, view]);
747
772
 
748
773
  const topBarTabs = useMemo<RenderedTab[]>(
749
774
  () => [...systemViewTabs, ...dataTabs],
@@ -1265,7 +1290,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
1265
1290
  // unread count from the same sync-cache label list the tab bar uses —
1266
1291
  // summing the (possibly overlapping) split tabs would double-count threads
1267
1292
  // that match more than one label/filter tab.
1268
- const inboxSidebarUnreadCount = inboxThreads.data?.labels.find(
1293
+ const inboxSidebarUnreadCount = inboxMetadata?.labels.find(
1269
1294
  (label) => label.id === "inbox",
1270
1295
  )?.unreadCount;
1271
1296
  const railNavItems = [
@@ -7,6 +7,7 @@ import type {
7
7
  } from "@shared/inbox-threads";
8
8
  import {
9
9
  keepPreviousData,
10
+ skipToken,
10
11
  useQuery,
11
12
  useQueries,
12
13
  useQueryClient,
@@ -22,6 +23,79 @@ export const INBOX_THREADS_QUERY_KEY = ["action", "list-inbox-threads"];
22
23
 
23
24
  const SYNCING_POLL_MS = 3_000;
24
25
  const IDLE_POLL_MS = 20_000;
26
+ const INBOX_THREADS_STALE_TIME_MS = IDLE_POLL_MS;
27
+
28
+ export type InboxOverview = Pick<
29
+ ListInboxThreadsResult,
30
+ "tabs" | "syncing" | "accounts" | "labels"
31
+ > & { clientSnapshotId: number };
32
+
33
+ type InboxPageCountSnapshot = Pick<
34
+ ListInboxThreadsResult,
35
+ "activeTabId" | "tabs"
36
+ > & { clientSnapshotId: number };
37
+
38
+ export function mergeOptimisticInboxTabCounts(
39
+ overview: Pick<InboxOverview, "tabs" | "clientSnapshotId">,
40
+ base: InboxPageCountSnapshot | undefined,
41
+ projected: InboxPageCountSnapshot | undefined,
42
+ ) {
43
+ if (
44
+ !base ||
45
+ !projected ||
46
+ base.clientSnapshotId !== overview.clientSnapshotId ||
47
+ projected.clientSnapshotId !== overview.clientSnapshotId ||
48
+ base.activeTabId !== projected.activeTabId
49
+ ) {
50
+ return overview.tabs;
51
+ }
52
+ const baseTab = base.tabs.find((tab) => tab.id === base.activeTabId);
53
+ const projectedTab = projected.tabs.find(
54
+ (tab) => tab.id === projected.activeTabId,
55
+ );
56
+ if (!baseTab || !projectedTab) return overview.tabs;
57
+
58
+ const totalDelta = projectedTab.total - baseTab.total;
59
+ const unreadDelta = projectedTab.unread - baseTab.unread;
60
+ if (totalDelta === 0 && unreadDelta === 0) return overview.tabs;
61
+
62
+ return overview.tabs.map((tab) =>
63
+ tab.id === projectedTab.id
64
+ ? {
65
+ ...tab,
66
+ total: Math.max(0, tab.total + totalDelta),
67
+ unread: Math.max(0, tab.unread + unreadDelta),
68
+ }
69
+ : tab,
70
+ );
71
+ }
72
+
73
+ export function inboxOverviewQueryKey(accountEmails?: readonly string[]) {
74
+ const accounts = accountEmails
75
+ ? [...accountEmails].map((email) => email.toLowerCase()).sort()
76
+ : undefined;
77
+ return ["mail-inbox-overview", accounts] as const;
78
+ }
79
+
80
+ export function publishInboxOverview(
81
+ qc: QueryClient,
82
+ accountEmails: readonly string[] | undefined,
83
+ incoming: InboxOverview,
84
+ ) {
85
+ const queryKey = inboxOverviewQueryKey(accountEmails);
86
+ const current = qc.getQueryData<InboxOverview>(queryKey);
87
+ if (current && current.clientSnapshotId >= incoming.clientSnapshotId) return;
88
+ qc.setQueryData(queryKey, incoming);
89
+ }
90
+
91
+ /** Shared metadata is populated by the active tab query and never fetches separately. */
92
+ export function useInboxOverview(accountEmails?: readonly string[]) {
93
+ return useQuery<InboxOverview>({
94
+ queryKey: inboxOverviewQueryKey(accountEmails),
95
+ queryFn: skipToken,
96
+ staleTime: Infinity,
97
+ });
98
+ }
25
99
 
26
100
  // Not yet re-exported for template use from
27
101
  // packages/core/src/client/create-query-client.ts's isTerminalAuthFailure.
@@ -124,6 +198,13 @@ function fetchInboxThreads(
124
198
  { method: "GET", signal },
125
199
  ).then((data) => {
126
200
  const incoming = { ...data, clientSnapshotId };
201
+ publishInboxOverview(qc, input.accountEmails, {
202
+ tabs: incoming.tabs,
203
+ syncing: incoming.syncing,
204
+ accounts: incoming.accounts,
205
+ labels: incoming.labels,
206
+ clientSnapshotId,
207
+ });
127
208
  const current = qc.getQueryData<InboxQueryResult>(queryKey);
128
209
  return keepLatestInboxSnapshot(current, incoming);
129
210
  });
@@ -151,6 +232,7 @@ export function useInboxThreads(
151
232
  // window-focus refetch fans out across every mounted instance (bar +
152
233
  // list) and isn't worth the added request-storm risk.
153
234
  refetchInterval: inboxThreadsRefetchInterval,
235
+ staleTime: INBOX_THREADS_STALE_TIME_MS,
154
236
  // Tab switches must never blank the list while the new tab's page loads.
155
237
  placeholderData: keepPreviousData,
156
238
  select: (data) => applyInboxMutationOverlay(qc, data) as InboxQueryResult,
@@ -35,6 +35,7 @@ import {
35
35
  inboxThreadsHasNextPage,
36
36
  mergeInboxThreadPages,
37
37
  resolveInboxTabId,
38
+ useInboxOverview,
38
39
  useInboxThreads,
39
40
  useInboxThreadsPages,
40
41
  } from "@/hooks/use-inbox-threads";
@@ -450,13 +451,11 @@ export function InboxPage() {
450
451
  const searchQuery =
451
452
  activeSavedFilter?.query ?? searchParams.get("q") ?? undefined;
452
453
 
453
- // The inbox view is split server-side (tabs, counts, and the rendered
454
- // list all come from one `list-inbox-threads` call keyed by the resolved
455
- // tab id) — see shared/inbox-threads.ts. Every other view still fetches
456
- // through `useEmails` below, unchanged. A `q` search on /inbox is not a
457
- // tab partition the store computes, so it falls through to the same
458
- // `useEmails` search path non-inbox views use — the store path is only
459
- // for a plain /inbox with no `q`.
454
+ // Inbox rows are tab-scoped; each response publishes its account-scoped
455
+ // counts and sync metadata to the shared overview cache. Other views still
456
+ // fetch through `useEmails` below, unchanged. A `q` search on /inbox is not
457
+ // a tab partition the store computes, so it uses the same `useEmails`
458
+ // search path as non-inbox views.
460
459
  const isInboxView = view === "inbox" && !searchParams.get("q");
461
460
  useEffect(() => {
462
461
  if (!jevAvailability.isSuccess) return;
@@ -473,10 +472,10 @@ export function InboxPage() {
473
472
  const resolvedInboxTab = resolveInboxTabId(searchParams);
474
473
  const inboxAccountEmails =
475
474
  activeAccounts.size > 0 ? [...activeAccounts] : undefined;
476
- // Page 0 drives tabs/counts/syncing/accounts/labels and is the only page
477
- // that polls. "Load more" grows `inboxExtraPageCount`, fetching one more
478
- // unpolled page per step (see useInboxThreadsPages's doc for why this
479
- // isn't one useInfiniteQuery).
475
+ // Page 0 drives rows and pagination totals and is the only page that polls.
476
+ // Its account-scoped metadata snapshot is shared across tabs. "Load more"
477
+ // grows `inboxExtraPageCount`, fetching one unpolled page per step (see
478
+ // useInboxThreadsPages's doc for why this isn't one useInfiniteQuery).
480
479
  const inboxThreads = useInboxThreads(
481
480
  {
482
481
  tab: resolvedInboxTab,
@@ -488,6 +487,10 @@ export function InboxPage() {
488
487
  // it stays keyed on the route alone, not `isInboxView`.
489
488
  { enabled: view === "inbox" },
490
489
  );
490
+ const inboxOverview = useInboxOverview(inboxAccountEmails);
491
+ const inboxMetadata =
492
+ inboxOverview.data ??
493
+ (inboxThreads.isPlaceholderData ? undefined : inboxThreads.data);
491
494
  const [inboxExtraPageCount, setInboxExtraPageCount] = useState(0);
492
495
  useEffect(() => {
493
496
  const priorityExtraPages = Math.max(
@@ -559,7 +562,7 @@ export function InboxPage() {
559
562
  // coverage the same as a sync error (the reconnect-specific banner in
560
563
  // AppLayout is unaffected — this only feeds the generic notice + the
561
564
  // Inbox Zero suppression below).
562
- const errored = inboxThreads.data?.accounts.filter(
565
+ const errored = inboxMetadata?.accounts.filter(
563
566
  (account) =>
564
567
  account.state === "error" || account.state === "needs_reauth",
565
568
  );
@@ -579,7 +582,7 @@ export function InboxPage() {
579
582
  const combined = [...inboxErrors, ...labelErrors];
580
583
  return combined.length ? combined : undefined;
581
584
  }, [
582
- inboxThreads.data?.accounts,
585
+ inboxMetadata?.accounts,
583
586
  inboxThreads.isPlaceholderData,
584
587
  labelAccountErrors,
585
588
  ]);
@@ -687,9 +690,7 @@ export function InboxPage() {
687
690
  // produced any rows yet, so the list shows skeleton rows (not Inbox Zero)
688
691
  // until there is something real to show either way.
689
692
  const inboxStillSyncingEmpty =
690
- isInboxView &&
691
- inboxThreads.data?.syncing === true &&
692
- inboxItems.length === 0;
693
+ isInboxView && inboxMetadata?.syncing === true && inboxItems.length === 0;
693
694
  const isLoading = isInboxView
694
695
  ? inboxThreads.isLoading ||
695
696
  inboxThreads.isPlaceholderData ||
@@ -1152,9 +1153,7 @@ export function InboxPage() {
1152
1153
  emailsError={emailsError}
1153
1154
  accountErrors={accountErrors}
1154
1155
  labels={
1155
- isInboxView
1156
- ? (inboxThreads.data?.labels ?? EMPTY_LABELS)
1157
- : undefined
1156
+ isInboxView ? (inboxMetadata?.labels ?? EMPTY_LABELS) : undefined
1158
1157
  }
1159
1158
  refetchEmails={refetchEmails}
1160
1159
  hasNextPage={hasNextPage}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-25
4
+ ---
5
+
6
+ Keep inbox tab counts consistent when switching tabs
@@ -5,6 +5,10 @@ time from the command menu (Cmd+K → "What's new").
5
5
 
6
6
  ## 2026-09-25
7
7
 
8
+ ### Added
9
+
10
+ - Chat messages can be reverted to their autosaved version, including a shortcut to restore the start of a chat.
11
+
8
12
  ### Fixed
9
13
 
10
14
  - Word document imports now complete successfully.