@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/content/CHANGELOG.md +6 -0
- package/corpus/templates/content/actions/list-document-versions.ts +102 -2
- package/corpus/templates/content/actions/restore-document-version.ts +50 -2
- package/corpus/templates/content/app/components/layout/Layout.tsx +6 -1
- package/corpus/templates/content/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
- package/corpus/templates/content/server/lib/document-history.ts +59 -8
- package/corpus/templates/content/server/lib/document-version-context.ts +5 -0
- package/corpus/templates/content/server/plugins/agent-chat.ts +164 -0
- package/corpus/templates/content/vite.config.ts +2 -38
- package/corpus/templates/design/CHANGELOG.md +4 -0
- package/corpus/templates/design/actions/list-design-versions.ts +3 -1
- package/corpus/templates/design/app/components/layout/Layout.tsx +17 -2
- package/corpus/templates/design/app/pages/design-editor/editor-state.ts +10 -3
- package/corpus/templates/design/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
- package/corpus/templates/design/server/lib/design-versions.ts +136 -20
- package/corpus/templates/design/server/plugins/agent-chat.ts +18 -2
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +36 -11
- package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +82 -0
- package/corpus/templates/mail/app/pages/InboxPage.tsx +18 -19
- package/corpus/templates/mail/changelog/2026-09-25-keep-inbox-tab-counts-consistent-when-switching-tabs.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +4 -0
- package/corpus/templates/slides/actions/list-deck-versions.ts +62 -10
- package/corpus/templates/slides/app/components/layout/Layout.tsx +10 -2
- package/corpus/templates/slides/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
- package/corpus/templates/slides/server/lib/deck-versions.ts +36 -1
- package/corpus/templates/slides/server/plugins/agent-chat.ts +26 -2
- package/dist/automation/index.d.ts +1 -1
- package/dist/cli/index.js +1 -2
- package/dist/client/AssistantChat.js +112 -35
- package/dist/client/chat/assistant-chat-history-version.d.ts +1 -0
- package/dist/client/chat/message-components.d.ts +8 -1
- package/dist/client/chat/message-components.js +52 -11
- package/dist/client/resources/mcp-integration-catalog.js +1 -1
- package/dist/client/resources/mcp-integration-logos.js +29 -30
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/deploy/build.d.ts +1 -1
- package/dist/deploy/build.js +9 -348
- package/dist/deploy/workspace-deploy.d.ts +2 -2
- package/dist/deploy/workspace-deploy.js +9 -130
- package/dist/localization/core-messages/ar-SA.js +2 -0
- package/dist/localization/core-messages/de-DE.js +2 -0
- package/dist/localization/core-messages/en-US.d.ts +2 -0
- package/dist/localization/core-messages/en-US.js +2 -0
- package/dist/localization/core-messages/es-ES.js +2 -0
- package/dist/localization/core-messages/fr-FR.js +2 -0
- package/dist/localization/core-messages/hi-IN.js +2 -0
- package/dist/localization/core-messages/ja-JP.js +2 -0
- package/dist/localization/core-messages/ko-KR.js +2 -0
- package/dist/localization/core-messages/pt-BR.js +2 -0
- package/dist/localization/core-messages/zh-CN.js +2 -0
- package/dist/localization/core-messages/zh-TW.js +2 -0
- package/dist/localization/core-messages.d.ts +2 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-chat/plugin-options.d.ts +2 -0
- package/dist/server/agent-chat-plugin.d.ts +1 -0
- package/dist/server/agent-chat-plugin.js +26 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
- package/dist/server/onboarding-html.js +28 -5
- package/dist/vite/client.d.ts +1 -1
- package/docs/content/a2a-protocol.mdx +2 -2
- package/docs/content/cloudflare.mdx +2 -10
- package/docs/content/deploy-an-app.mdx +0 -1
- package/docs/content/deployment.mdx +14 -9
- package/docs/content/environment-variables.mdx +1 -1
- package/docs/content/multi-app-workspace.mdx +1 -1
- package/docs/content/netlify.mdx +1 -1
- package/docs/content/real-time-collaboration.mdx +6 -6
- package/docs/content/workspace-deployment.mdx +4 -15
- 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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -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 =
|
|
739
|
-
.
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1201
|
-
|
|
1287
|
+
threadId &&
|
|
1288
|
+
chatContext?.threadId === threadId &&
|
|
1289
|
+
chatContext.phase === "start"
|
|
1202
1290
|
) {
|
|
1203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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 =
|
|
577
|
-
const needsReauthAccount =
|
|
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
|
|
725
|
-
//
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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 =
|
|
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
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
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
|
|
477
|
-
//
|
|
478
|
-
// unpolled page per step (see
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
@@ -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.
|