@agent-native/core 0.188.0 → 0.189.0-nightly-20260925200527

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 (53) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/list-document-versions.ts +102 -2
  3. package/corpus/templates/content/actions/restore-document-version.ts +50 -2
  4. package/corpus/templates/content/app/components/layout/Layout.tsx +6 -1
  5. package/corpus/templates/content/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
  6. package/corpus/templates/content/server/lib/document-history.ts +59 -8
  7. package/corpus/templates/content/server/lib/document-version-context.ts +5 -0
  8. package/corpus/templates/content/server/plugins/agent-chat.ts +164 -0
  9. package/corpus/templates/design/actions/list-design-versions.ts +3 -1
  10. package/corpus/templates/design/app/components/layout/Layout.tsx +17 -2
  11. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +10 -3
  12. package/corpus/templates/design/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
  13. package/corpus/templates/design/server/lib/design-versions.ts +136 -20
  14. package/corpus/templates/design/server/plugins/agent-chat.ts +18 -2
  15. package/corpus/templates/slides/actions/list-deck-versions.ts +62 -10
  16. package/corpus/templates/slides/app/components/layout/Layout.tsx +10 -2
  17. package/corpus/templates/slides/changelog/2026-09-25-chat-messages-can-be-reverted-to-their-autosaved-version-inc.md +6 -0
  18. package/corpus/templates/slides/server/lib/deck-versions.ts +36 -1
  19. package/corpus/templates/slides/server/plugins/agent-chat.ts +26 -2
  20. package/dist/automation/index.d.ts +1 -1
  21. package/dist/client/AssistantChat.js +112 -35
  22. package/dist/client/chat/assistant-chat-history-version.d.ts +1 -0
  23. package/dist/client/chat/message-components.d.ts +8 -1
  24. package/dist/client/chat/message-components.js +52 -11
  25. package/dist/client/resources/mcp-integration-catalog.js +1 -1
  26. package/dist/client/resources/mcp-integration-logos.js +29 -30
  27. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  28. package/dist/localization/core-messages/ar-SA.js +2 -0
  29. package/dist/localization/core-messages/de-DE.js +2 -0
  30. package/dist/localization/core-messages/en-US.d.ts +2 -0
  31. package/dist/localization/core-messages/en-US.js +2 -0
  32. package/dist/localization/core-messages/es-ES.js +2 -0
  33. package/dist/localization/core-messages/fr-FR.js +2 -0
  34. package/dist/localization/core-messages/hi-IN.js +2 -0
  35. package/dist/localization/core-messages/ja-JP.js +2 -0
  36. package/dist/localization/core-messages/ko-KR.js +2 -0
  37. package/dist/localization/core-messages/pt-BR.js +2 -0
  38. package/dist/localization/core-messages/zh-CN.js +2 -0
  39. package/dist/localization/core-messages/zh-TW.js +2 -0
  40. package/dist/localization/core-messages.d.ts +2 -0
  41. package/dist/notifications/routes.d.ts +3 -3
  42. package/dist/observability/routes.d.ts +4 -4
  43. package/dist/org/handlers.d.ts +1 -1
  44. package/dist/resources/handlers.d.ts +1 -1
  45. package/dist/secrets/routes.d.ts +6 -6
  46. package/dist/server/agent-chat/plugin-options.d.ts +2 -0
  47. package/dist/server/agent-chat-plugin.d.ts +1 -0
  48. package/dist/server/agent-chat-plugin.js +26 -0
  49. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  50. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  51. package/dist/server/onboarding-html.js +28 -5
  52. package/docs/content/deployment.mdx +12 -1
  53. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 9959
34
+ - template files: 9962
@@ -1,9 +1,10 @@
1
1
  import { defineAction } from "@agent-native/core/action";
2
2
  import { assertAccess } from "@agent-native/core/sharing";
3
- import { and, desc, eq } from "drizzle-orm";
3
+ import { and, asc, desc, eq, like } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { documentChatStartVersionId } from "../server/lib/document-history.js";
7
8
  import { parseDocumentVersionChatContext } from "../server/lib/document-version-context.js";
8
9
 
9
10
  export default defineAction({
@@ -16,6 +17,7 @@ export default defineAction({
16
17
  .default(true)
17
18
  .describe("Include full version content in the response"),
18
19
  limit: z.coerce.number().int().min(1).max(100).default(100),
20
+ threadId: z.string().min(1).optional(),
19
21
  }),
20
22
  http: { method: "GET" },
21
23
  readOnly: true,
@@ -48,9 +50,107 @@ export default defineAction({
48
50
  .where(where)
49
51
  .orderBy(desc(schema.documentVersions.createdAt))
50
52
  .limit(args.limit);
53
+ const beginningVersions = args.threadId
54
+ ? args.includeContent
55
+ ? await db
56
+ .select()
57
+ .from(schema.documentVersions)
58
+ .where(
59
+ and(
60
+ where,
61
+ eq(
62
+ schema.documentVersions.id,
63
+ documentChatStartVersionId(
64
+ ownerEmail,
65
+ args.documentId,
66
+ args.threadId,
67
+ ),
68
+ ),
69
+ ),
70
+ )
71
+ .orderBy(asc(schema.documentVersions.createdAt))
72
+ .limit(1)
73
+ : await db
74
+ .select({
75
+ id: schema.documentVersions.id,
76
+ documentId: schema.documentVersions.documentId,
77
+ title: schema.documentVersions.title,
78
+ createdAt: schema.documentVersions.createdAt,
79
+ chatContext: schema.documentVersions.chatContext,
80
+ })
81
+ .from(schema.documentVersions)
82
+ .where(
83
+ and(
84
+ where,
85
+ eq(
86
+ schema.documentVersions.id,
87
+ documentChatStartVersionId(
88
+ ownerEmail,
89
+ args.documentId,
90
+ args.threadId,
91
+ ),
92
+ ),
93
+ ),
94
+ )
95
+ .orderBy(asc(schema.documentVersions.createdAt))
96
+ .limit(1)
97
+ : args.includeContent
98
+ ? await db
99
+ .select()
100
+ .from(schema.documentVersions)
101
+ .where(
102
+ and(
103
+ where,
104
+ like(schema.documentVersions.chatContext, '%"phase":"start"%'),
105
+ ),
106
+ )
107
+ .orderBy(desc(schema.documentVersions.createdAt))
108
+ .limit(args.limit)
109
+ : await db
110
+ .select({
111
+ id: schema.documentVersions.id,
112
+ documentId: schema.documentVersions.documentId,
113
+ title: schema.documentVersions.title,
114
+ createdAt: schema.documentVersions.createdAt,
115
+ chatContext: schema.documentVersions.chatContext,
116
+ })
117
+ .from(schema.documentVersions)
118
+ .where(
119
+ and(
120
+ where,
121
+ like(schema.documentVersions.chatContext, '%"phase":"start"%'),
122
+ ),
123
+ )
124
+ .orderBy(desc(schema.documentVersions.createdAt))
125
+ .limit(args.limit);
126
+ const versionsById = new Map(
127
+ [...beginningVersions, ...versions].map((version) => [
128
+ version.id,
129
+ version,
130
+ ]),
131
+ );
132
+
133
+ const mergedVersions = [...versionsById.values()];
134
+ const sortedVersions = mergedVersions.sort(
135
+ (left, right) =>
136
+ new Date(right.createdAt).getTime() -
137
+ new Date(left.createdAt).getTime(),
138
+ );
139
+ const limitedVersions = sortedVersions.slice(0, args.limit);
140
+ const activeStart = args.threadId
141
+ ? sortedVersions.find((version) => beginningVersions.includes(version))
142
+ : undefined;
143
+ if (activeStart && !limitedVersions.includes(activeStart)) {
144
+ limitedVersions[limitedVersions.length - 1] = activeStart;
145
+ limitedVersions.sort(
146
+ (left, right) =>
147
+ new Date(right.createdAt).getTime() -
148
+ new Date(left.createdAt).getTime(),
149
+ );
150
+ }
51
151
 
52
152
  return {
53
- versions: versions.map((version) => {
153
+ versions: limitedVersions.map((version) => {
54
154
  let chatContext;
55
155
  try {
56
156
  chatContext = parseDocumentVersionChatContext(version.chatContext);
@@ -23,6 +23,7 @@ import {
23
23
  documentContentHash,
24
24
  documentRevisionToken,
25
25
  } from "./_document-edit-mutation.js";
26
+ import { flushOpenDocumentEditorToSql } from "./_document-flush.js";
26
27
 
27
28
  function isLinkedLocalSource(
28
29
  documentId: string,
@@ -78,6 +79,53 @@ export default defineAction({
78
79
  if (isLinkedLocalSource(documentId, source))
79
80
  linkedLocalRestoreUnsupported();
80
81
  const db = getDb();
82
+ const [beforeFlush] = await db
83
+ .select({ updatedAt: schema.documents.updatedAt })
84
+ .from(schema.documents)
85
+ .where(
86
+ and(
87
+ eq(schema.documents.id, documentId),
88
+ eq(schema.documents.ownerEmail, ownerEmail),
89
+ ),
90
+ )
91
+ .limit(1);
92
+ if (!beforeFlush) {
93
+ throw new ActionContractError("Document not found.", {
94
+ errorCode: "DOCUMENT_NOT_FOUND",
95
+ statusCode: 404,
96
+ });
97
+ }
98
+ if (beforeFlush.updatedAt !== args.expectedUpdatedAt) {
99
+ throw new ActionContractError(
100
+ "The document changed after this restore was prepared.",
101
+ {
102
+ errorCode: "DOCUMENT_RESTORE_CONFLICT",
103
+ statusCode: 409,
104
+ details: {
105
+ expectedUpdatedAt: args.expectedUpdatedAt,
106
+ currentUpdatedAt: beforeFlush.updatedAt,
107
+ },
108
+ },
109
+ );
110
+ }
111
+ await flushOpenDocumentEditorToSql({ documentId, ownerEmail });
112
+ const [afterFlush] = await db
113
+ .select({ updatedAt: schema.documents.updatedAt })
114
+ .from(schema.documents)
115
+ .where(
116
+ and(
117
+ eq(schema.documents.id, documentId),
118
+ eq(schema.documents.ownerEmail, ownerEmail),
119
+ ),
120
+ )
121
+ .limit(1);
122
+ if (!afterFlush) {
123
+ throw new ActionContractError("Document not found.", {
124
+ errorCode: "DOCUMENT_NOT_FOUND",
125
+ statusCode: 404,
126
+ });
127
+ }
128
+ const expectedUpdatedAt = afterFlush.updatedAt;
81
129
  let softDeletedDatabaseIds: string[] = [];
82
130
  const updated = await db.transaction(async (rawTx) => {
83
131
  const tx = rawTx as any;
@@ -110,14 +158,14 @@ export default defineAction({
110
158
  if (isLinkedLocalSource(documentId, current)) {
111
159
  linkedLocalRestoreUnsupported();
112
160
  }
113
- if (current.updatedAt !== args.expectedUpdatedAt) {
161
+ if (current.updatedAt !== expectedUpdatedAt) {
114
162
  throw new ActionContractError(
115
163
  "The document changed after this restore was prepared.",
116
164
  {
117
165
  errorCode: "DOCUMENT_RESTORE_CONFLICT",
118
166
  statusCode: 409,
119
167
  details: {
120
- expectedUpdatedAt: args.expectedUpdatedAt,
168
+ expectedUpdatedAt,
121
169
  currentUpdatedAt: current.updatedAt,
122
170
  },
123
171
  },
@@ -126,7 +126,12 @@ export function Layout({ children }: LayoutProps) {
126
126
  return {
127
127
  list: {
128
128
  action: "list-document-versions",
129
- args: { documentId, includeContent: false, limit: 100 },
129
+ args: (threadId) => ({
130
+ documentId,
131
+ includeContent: false,
132
+ limit: 100,
133
+ ...(threadId ? { threadId } : {}),
134
+ }),
130
135
  getVersions: (result: unknown) => {
131
136
  const versions =
132
137
  result && typeof result === "object"
@@ -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.
@@ -8,6 +8,7 @@ import type {
8
8
  import { getDb, schema } from "../db/index.js";
9
9
  import {
10
10
  documentVersionChatContextFromAction,
11
+ type DocumentVersionChatContext,
11
12
  serializeDocumentVersionChatContext,
12
13
  } from "./document-version-context.js";
13
14
 
@@ -26,6 +27,8 @@ export interface DocumentHistoryCause {
26
27
  actorEmail?: string | null;
27
28
  actorKind?: Exclude<DocumentHistoryActorKind, "unknown">;
28
29
  origin?: string;
30
+ chatContext?: DocumentVersionChatContext;
31
+ skipBeforeCheckpoint?: boolean;
29
32
  operation: string;
30
33
  }
31
34
 
@@ -42,6 +45,14 @@ function newGroupId(prefix: string) {
42
45
  return `${prefix}:${crypto.randomUUID()}`;
43
46
  }
44
47
 
48
+ export function documentChatStartVersionId(
49
+ ownerEmail: string,
50
+ documentId: string,
51
+ chatStartKey: string,
52
+ ) {
53
+ return `agent-chat-start:${encodeURIComponent(ownerEmail)}:${encodeURIComponent(documentId)}:${encodeURIComponent(chatStartKey)}`;
54
+ }
55
+
45
56
  export function resolveDocumentHistoryCause(
46
57
  cause: DocumentHistoryCause,
47
58
  ): ResolvedDocumentHistoryCause {
@@ -152,9 +163,10 @@ export async function recordDocumentHistoryTransition(args: {
152
163
 
153
164
  let beforeCheckpointId: string | undefined;
154
165
  if (
155
- !latest ||
156
- latest.title !== args.before.title ||
157
- latest.content !== args.before.content
166
+ !args.cause.skipBeforeCheckpoint &&
167
+ (!latest ||
168
+ latest.title !== args.before.title ||
169
+ latest.content !== args.before.content)
158
170
  ) {
159
171
  beforeCheckpointId = crypto.randomUUID();
160
172
  await args.db.insert(schema.documentVersions).values({
@@ -164,7 +176,8 @@ export async function recordDocumentHistoryTransition(args: {
164
176
  title: args.before.title,
165
177
  content: args.before.content,
166
178
  chatContext: serializeDocumentVersionChatContext(
167
- documentVersionChatContextFromAction(args.cause.ctx),
179
+ args.cause.chatContext ??
180
+ documentVersionChatContextFromAction(args.cause.ctx),
168
181
  ),
169
182
  ...cause,
170
183
  checkpointKind: "before",
@@ -176,20 +189,58 @@ export async function recordDocumentHistoryTransition(args: {
176
189
  const afterCreatedAt = beforeCheckpointId
177
190
  ? new Date(new Date(checkpointCreatedAt).getTime() + 1).toISOString()
178
191
  : checkpointCreatedAt;
179
- const afterCheckpointId = crypto.randomUUID();
180
- await args.db.insert(schema.documentVersions).values({
192
+ const isChatStart =
193
+ cause.groupKind === "agent_run" && cause.operation === "chat start";
194
+ const chatStartKey = args.cause.chatContext?.threadId ?? cause.groupId;
195
+ const afterCheckpointId = isChatStart
196
+ ? documentChatStartVersionId(args.ownerEmail, args.documentId, chatStartKey)
197
+ : crypto.randomUUID();
198
+ const afterValues = {
181
199
  id: afterCheckpointId,
182
200
  ownerEmail: args.ownerEmail,
183
201
  documentId: args.documentId,
184
202
  title: args.after.title,
185
203
  content: args.after.content,
186
204
  chatContext: serializeDocumentVersionChatContext(
187
- documentVersionChatContextFromAction(args.cause.ctx),
205
+ args.cause.chatContext ??
206
+ documentVersionChatContextFromAction(args.cause.ctx),
188
207
  ),
189
208
  ...cause,
190
209
  checkpointKind: "after",
191
210
  createdAt: afterCreatedAt,
192
211
  updatedAt: afterCreatedAt,
193
- });
212
+ };
213
+ if (isChatStart) {
214
+ const [inserted] = await args.db
215
+ .insert(schema.documentVersions)
216
+ .values(afterValues)
217
+ .onConflictDoNothing()
218
+ .returning({ id: schema.documentVersions.id });
219
+ if (!inserted) {
220
+ const [existing] = await args.db
221
+ .select({ id: schema.documentVersions.id })
222
+ .from(schema.documentVersions)
223
+ .where(
224
+ and(
225
+ eq(schema.documentVersions.ownerEmail, args.ownerEmail),
226
+ eq(schema.documentVersions.documentId, args.documentId),
227
+ eq(schema.documentVersions.id, afterCheckpointId),
228
+ ),
229
+ )
230
+ .limit(1);
231
+ if (!existing) {
232
+ throw new Error(
233
+ "Chat-start history checkpoint conflict had no matching checkpoint.",
234
+ );
235
+ }
236
+ return {
237
+ groupId: cause.groupId,
238
+ beforeCheckpointId,
239
+ afterCheckpointId: existing.id,
240
+ };
241
+ }
242
+ } else {
243
+ await args.db.insert(schema.documentVersions).values(afterValues);
244
+ }
194
245
  return { groupId: cause.groupId, beforeCheckpointId, afterCheckpointId };
195
246
  }
@@ -4,12 +4,14 @@ export interface DocumentVersionChatContext {
4
4
  threadId?: string;
5
5
  runId?: string;
6
6
  turnId?: string;
7
+ phase?: "start" | "end";
7
8
  }
8
9
 
9
10
  function contextFromFields(value: {
10
11
  threadId?: unknown;
11
12
  runId?: unknown;
12
13
  turnId?: unknown;
14
+ phase?: unknown;
13
15
  }): DocumentVersionChatContext | undefined {
14
16
  const context: DocumentVersionChatContext = {};
15
17
  for (const key of ["threadId", "runId", "turnId"] as const) {
@@ -17,6 +19,9 @@ function contextFromFields(value: {
17
19
  context[key] = value[key];
18
20
  }
19
21
  }
22
+ if (value.phase === "start" || value.phase === "end") {
23
+ context.phase = value.phase;
24
+ }
20
25
  return Object.keys(context).length > 0 ? context : undefined;
21
26
  }
22
27
 
@@ -3,9 +3,17 @@ import {
3
3
  createAgentChatPlugin,
4
4
  loadActionsFromStaticRegistry,
5
5
  } from "@agent-native/core/server";
6
+ import { assertAccess } from "@agent-native/core/sharing";
7
+ import { and, eq } from "drizzle-orm";
6
8
 
7
9
  import actionsRegistry from "../../.generated/actions-registry.js";
10
+ import { flushOpenDocumentEditorToSql } from "../../actions/_document-flush.js";
11
+ import { getDb, schema } from "../db/index.js";
8
12
  import { resolveCommentAiActionSurface } from "../lib/comment-ai.js";
13
+ import {
14
+ documentChatStartVersionId,
15
+ recordDocumentHistoryTransition,
16
+ } from "../lib/document-history.js";
9
17
  import {
10
18
  publicDocumentExtraContext,
11
19
  resolvePublicViewerOwner,
@@ -21,8 +29,164 @@ const INJECTED_INITIAL_TOOL_NAMES = [
21
29
  "query-staged-dataset",
22
30
  ];
23
31
 
32
+ const DOCUMENT_EDIT_TOOLS = new Set([
33
+ "edit-document",
34
+ "restore-document-version",
35
+ "update-document",
36
+ ]);
37
+
38
+ function eventRecord(entry: unknown): Record<string, unknown> | undefined {
39
+ if (!entry || typeof entry !== "object") return undefined;
40
+ const event = (entry as { event?: unknown }).event;
41
+ return event && typeof event === "object"
42
+ ? (event as Record<string, unknown>)
43
+ : undefined;
44
+ }
45
+
46
+ function inputForCompletedTool(
47
+ events: readonly unknown[],
48
+ index: number,
49
+ completed: Record<string, unknown>,
50
+ ): Record<string, unknown> | undefined {
51
+ if (completed.input && typeof completed.input === "object") {
52
+ return completed.input as Record<string, unknown>;
53
+ }
54
+ const id = typeof completed.id === "string" ? completed.id : undefined;
55
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
56
+ const candidate = eventRecord(events[cursor]);
57
+ if (
58
+ candidate?.type !== "tool_start" ||
59
+ candidate.tool !== completed.tool ||
60
+ (id && candidate.id !== id)
61
+ ) {
62
+ continue;
63
+ }
64
+ return candidate.input && typeof candidate.input === "object"
65
+ ? (candidate.input as Record<string, unknown>)
66
+ : undefined;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function hasDocumentEdit(
72
+ run: { events: readonly unknown[] },
73
+ documentId: string,
74
+ ): boolean {
75
+ return run.events.some((entry, index) => {
76
+ const record = eventRecord(entry);
77
+ if (
78
+ record?.type !== "tool_done" ||
79
+ record.completedSideEffect !== true ||
80
+ record.isError === true ||
81
+ typeof record.tool !== "string" ||
82
+ !DOCUMENT_EDIT_TOOLS.has(record.tool)
83
+ ) {
84
+ return false;
85
+ }
86
+ const input = inputForCompletedTool(run.events, index, record);
87
+ return (input?.documentId ?? input?.id) === documentId;
88
+ });
89
+ }
90
+
91
+ async function autosaveDocumentAtChatBoundary(
92
+ scope: { type: string; id: string },
93
+ run: { events?: readonly unknown[]; threadId?: string; runId?: string },
94
+ phase: "start" | "end",
95
+ ): Promise<void> {
96
+ const hasEdit = run.events
97
+ ? hasDocumentEdit({ events: run.events }, scope.id)
98
+ : false;
99
+ if (
100
+ scope.type !== "document" ||
101
+ !run.threadId ||
102
+ !run.runId ||
103
+ (phase === "end" && !hasEdit)
104
+ ) {
105
+ return;
106
+ }
107
+
108
+ let access = await assertAccess("document", scope.id, "editor");
109
+ let document = access.resource as {
110
+ ownerEmail: string;
111
+ title: string;
112
+ content: string;
113
+ };
114
+ if (phase === "start") {
115
+ await flushOpenDocumentEditorToSql({
116
+ documentId: scope.id,
117
+ ownerEmail: document.ownerEmail,
118
+ });
119
+ access = await assertAccess("document", scope.id, "editor");
120
+ document = access.resource as typeof document;
121
+ }
122
+ const db = getDb();
123
+ const chatContext = { threadId: run.threadId, runId: run.runId, phase };
124
+
125
+ if (phase === "start") {
126
+ const existing = await db
127
+ .select({ id: schema.documentVersions.id })
128
+ .from(schema.documentVersions)
129
+ .where(
130
+ and(
131
+ eq(schema.documentVersions.documentId, scope.id),
132
+ eq(schema.documentVersions.ownerEmail, document.ownerEmail),
133
+ eq(
134
+ schema.documentVersions.id,
135
+ documentChatStartVersionId(
136
+ document.ownerEmail,
137
+ scope.id,
138
+ run.threadId,
139
+ ),
140
+ ),
141
+ ),
142
+ )
143
+ .limit(1);
144
+ if (existing.length) return;
145
+ }
146
+
147
+ const state = { title: document.title, content: document.content };
148
+ await recordDocumentHistoryTransition({
149
+ db,
150
+ ownerEmail: document.ownerEmail,
151
+ documentId: scope.id,
152
+ before: state,
153
+ after: state,
154
+ cause: {
155
+ groupId: `agent:${document.ownerEmail}:${run.runId}`,
156
+ groupKind: "agent_run",
157
+ actorEmail: document.ownerEmail,
158
+ actorKind: "agent",
159
+ origin: "agent-chat",
160
+ operation: phase === "start" ? "chat start" : "chat autosave",
161
+ chatContext,
162
+ ...(phase === "start" ? { skipBeforeCheckpoint: true } : {}),
163
+ },
164
+ now: new Date().toISOString(),
165
+ });
166
+ }
167
+
168
+ async function autosaveDocumentBeforeAgentTurn(
169
+ scope: { type: string; id: string },
170
+ run: { threadId?: string; runId?: string },
171
+ ): Promise<void> {
172
+ await autosaveDocumentAtChatBoundary(scope, run, "start");
173
+ }
174
+
175
+ async function autosaveDocumentAfterAgentTurn(
176
+ scope: { type: string; id: string },
177
+ run: {
178
+ events: readonly unknown[];
179
+ threadId?: string;
180
+ runId?: string;
181
+ },
182
+ ): Promise<void> {
183
+ await autosaveDocumentAtChatBoundary(scope, run, "end");
184
+ }
185
+
24
186
  export default createAgentChatPlugin({
25
187
  appId: "content",
188
+ onAgentTurnStart: autosaveDocumentBeforeAgentTurn,
189
+ onAgentTurnComplete: autosaveDocumentAfterAgentTurn,
26
190
  nativeActionsInDev: true,
27
191
  resolveActionSurface: resolveCommentAiActionSurface,
28
192
  durableBackgroundRuns: true,
@@ -9,6 +9,7 @@ export default defineAction({
9
9
  "metadata only, not the stored file contents.",
10
10
  schema: z.object({
11
11
  designId: z.string().describe("Design project ID"),
12
+ threadId: z.string().min(1).optional(),
12
13
  limit: z.coerce
13
14
  .number()
14
15
  .int()
@@ -19,5 +20,6 @@ export default defineAction({
19
20
  }),
20
21
  http: { method: "GET" },
21
22
  readOnly: true,
22
- run: async ({ designId, limit }) => listDesignVersions(designId, limit),
23
+ run: async ({ designId, limit, threadId }) =>
24
+ listDesignVersions(designId, limit, threadId),
23
25
  });
@@ -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.