@agent-native/core 0.181.0 → 0.181.1-nightly-20260916144018

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 (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/create-account-dialog.tsx +2 -1
  3. package/corpus/templates/clips/changelog/2026-09-15-google-sign-up-now-opens-reliably-in-embedded-browsers.md +6 -0
  4. package/corpus/templates/content/actions/resolve-preview-document-draft.ts +320 -60
  5. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +3 -1
  6. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +349 -104
  7. package/corpus/templates/content/app/components/editor/DocumentReconcileRecovery.tsx +109 -0
  8. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +31 -153
  9. package/corpus/templates/content/app/components/editor/RecoveryComparison.tsx +230 -0
  10. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +46 -19
  11. package/corpus/templates/content/app/components/editor/editor-draft-save.ts +13 -0
  12. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +248 -0
  13. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +2 -1
  14. package/corpus/templates/content/app/i18n-data.ts +13 -0
  15. package/corpus/templates/content/changelog/2026-09-15-oauth-connections-now-open-reliably-in-embedded-browsers.md +6 -0
  16. package/corpus/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md +84 -2
  17. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +27 -7
  18. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +45 -15
  19. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +35 -8
  20. package/corpus/templates/design/app/components/design/design-canvas/element-payload.ts +14 -0
  21. package/corpus/templates/design/app/pages/DesignEditor.tsx +18 -5
  22. package/corpus/templates/design/app/pages/design-editor/commands/record-pending-live-structure-edit.ts +9 -4
  23. package/corpus/templates/design/app/pages/design-editor/commands/screen-visual-structure-change.ts +3 -0
  24. package/corpus/templates/design/app/pages/design-editor/commands/start-sidebar-resize.ts +7 -2
  25. package/corpus/templates/design/app/pages/design-editor/commands/visual-structure-change.ts +2 -0
  26. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +4 -0
  27. package/corpus/templates/design/app/pages/design-editor/pending-structure-verification.ts +197 -147
  28. package/corpus/templates/design/changelog/2026-09-16-design-editor-agent-chat-uses-shared-conversation-tabs-and-s.md +6 -0
  29. package/corpus/templates/design/changelog/2026-09-16-visual-edits-stay-applied-after-replacements.md +5 -0
  30. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +2 -1
  31. package/corpus/templates/mail/changelog/2026-09-15-google-connections-now-open-reliably-in-embedded-browsers.md +6 -0
  32. package/dist/client/AgentPanel.d.ts +1 -1
  33. package/dist/client/AgentPanel.js +4 -4
  34. package/dist/client/auth/AuthPage.js +6 -1
  35. package/dist/client/oauth-popup.d.ts +7 -0
  36. package/dist/client/oauth-popup.js +14 -0
  37. package/dist/client/ocean/OceanBackground.d.ts +3 -1
  38. package/dist/client/ocean/OceanBackground.js +4 -3
  39. package/dist/client/resources/mcp-integration-catalog.js +7 -3
  40. package/dist/client/settings/useBuilderStatus.js +4 -3
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/collab/struct-routes.d.ts +1 -1
  43. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  44. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  45. package/dist/notifications/routes.d.ts +3 -3
  46. package/dist/observability/routes.d.ts +3 -3
  47. package/dist/org/app-roles.d.ts +8 -0
  48. package/dist/org/app-roles.js +7 -0
  49. package/dist/org/index.d.ts +1 -1
  50. package/dist/org/index.js +1 -1
  51. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  52. package/dist/secrets/routes.d.ts +6 -6
  53. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  54. package/dist/server/auth.js +3 -0
  55. package/dist/server/core-routes-plugin.d.ts +3 -0
  56. package/dist/server/core-routes-plugin.js +17 -0
  57. package/dist/server/realtime-token.d.ts +1 -1
  58. package/package.json +5 -4
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: 9430
34
+ - template files: 9439
@@ -1,6 +1,7 @@
1
1
  import { trackEvent } from "@agent-native/core/client/analytics";
2
2
  import { appPath } from "@agent-native/core/client/api-path";
3
3
  import { useT } from "@agent-native/core/client/i18n";
4
+ import { openOAuthPopup } from "@agent-native/core/client/oauth-popup";
4
5
  import { buildSignInReturnHref } from "@agent-native/core/client/ui";
5
6
  import { isQaTestEmail } from "@agent-native/core/shared";
6
7
  import { resolveNativeAuthCopy } from "@agent-native/core/shared/auth-copy";
@@ -177,7 +178,7 @@ export function AccountGateDialog({
177
178
 
178
179
  const startGoogleSignup = async () => {
179
180
  if (googleBusy || submitting) return;
180
- const popup = window.open("", "_blank", "width=640,height=760");
181
+ const popup = openOAuthPopup({ features: "width=640,height=760" });
181
182
  if (!popup) {
182
183
  setErrorMessage(copy.failedToConnect);
183
184
  return;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-15
4
+ ---
5
+
6
+ Google sign-up now opens reliably in embedded browsers
@@ -23,7 +23,10 @@ const exactDraft = {
23
23
 
24
24
  const durableClaimPayload = z.object({
25
25
  choice: z.enum(["keep_mine", "use_saved", "save_separately"]),
26
- status: z.enum(["claimed", "resolved"]),
26
+ status: z.enum(["claimed", "processing", "resolved"]),
27
+ expectedDocumentUpdatedAt: z.string().optional(),
28
+ processingToken: z.string().optional(),
29
+ processingStartedAt: z.string().optional(),
27
30
  draftId: z.string().min(1),
28
31
  baseDocumentUpdatedAt: z.string().nullable(),
29
32
  loadedContentWasEmpty: z.number().int(),
@@ -78,8 +81,16 @@ export default defineAction({
78
81
  ...exactDraft,
79
82
  expectedDocumentUpdatedAt: z.string().min(1),
80
83
  }),
81
- z.object({ choice: z.literal("use_saved"), ...exactDraft }),
82
- z.object({ choice: z.literal("save_separately"), ...exactDraft }),
84
+ z.object({
85
+ choice: z.literal("use_saved"),
86
+ ...exactDraft,
87
+ expectedDocumentUpdatedAt: z.string().min(1),
88
+ }),
89
+ z.object({
90
+ choice: z.literal("save_separately"),
91
+ ...exactDraft,
92
+ expectedDocumentUpdatedAt: z.string().min(1),
93
+ }),
83
94
  ]),
84
95
  run: async (args, ctx) => {
85
96
  const userEmail = getRequestUserEmail();
@@ -101,6 +112,7 @@ export default defineAction({
101
112
  const claimId = `draft-claim-${recoveryId.slice("recovery-".length)}`;
102
113
  const claimDocumentId =
103
114
  ownerEmail === userEmail ? args.documentId : claimId;
115
+ const processingToken = crypto.randomUUID();
104
116
  const draftFilter = and(
105
117
  eq(schema.documentPreviewDrafts.ownerEmail, userEmail),
106
118
  eq(schema.documentPreviewDrafts.orgId, orgId),
@@ -143,23 +155,68 @@ export default defineAction({
143
155
  if (payload.choice !== args.choice) {
144
156
  conflict("This draft was already resolved with another choice.");
145
157
  }
158
+ if (payload.status === "resolved") {
159
+ return { status: "resolved" as const };
160
+ }
161
+ const [currentDocument] = await tx
162
+ .select()
163
+ .from(schema.documents)
164
+ .where(eq(schema.documents.id, args.documentId))
165
+ .for("update")
166
+ .limit(1);
167
+ const expectedUpdatedAt =
168
+ payload.expectedDocumentUpdatedAt ?? args.expectedDocumentUpdatedAt;
169
+ if (
170
+ args.expectedDocumentUpdatedAt !== expectedUpdatedAt ||
171
+ !currentDocument ||
172
+ (currentDocument.updatedAt !== expectedUpdatedAt &&
173
+ !(
174
+ args.choice === "keep_mine" &&
175
+ currentDocument.title === claim.title &&
176
+ currentDocument.content === claim.content
177
+ ))
178
+ ) {
179
+ return {
180
+ status: "document_conflict" as const,
181
+ document: currentDocument ?? null,
182
+ };
183
+ }
146
184
  return {
147
- id: payload.draftId,
148
- ownerEmail: userEmail,
149
- orgId,
150
- documentId: args.documentId,
151
- title: claim.title,
152
- content: claim.content,
153
- baseDocumentUpdatedAt: payload.baseDocumentUpdatedAt,
154
- loadedContentWasEmpty: payload.loadedContentWasEmpty,
155
- deferredReason: payload.deferredReason,
156
- version: args.expectedDraftVersion,
157
- createdAt: payload.createdAt,
158
- updatedAt: payload.updatedAt,
185
+ status: "claimed" as const,
186
+ draft: {
187
+ id: payload.draftId,
188
+ ownerEmail: userEmail,
189
+ orgId,
190
+ documentId: args.documentId,
191
+ title: claim.title,
192
+ content: claim.content,
193
+ baseDocumentUpdatedAt: payload.baseDocumentUpdatedAt,
194
+ loadedContentWasEmpty: payload.loadedContentWasEmpty,
195
+ deferredReason: payload.deferredReason,
196
+ version: args.expectedDraftVersion,
197
+ createdAt: payload.createdAt,
198
+ updatedAt: payload.updatedAt,
199
+ },
200
+ acquired: false,
201
+ };
202
+ }
203
+ const [currentDocument] = await tx
204
+ .select()
205
+ .from(schema.documents)
206
+ .where(eq(schema.documents.id, args.documentId))
207
+ .for("update")
208
+ .limit(1);
209
+ if (
210
+ !currentDocument ||
211
+ currentDocument.updatedAt !== args.expectedDocumentUpdatedAt
212
+ ) {
213
+ return {
214
+ status: "document_conflict" as const,
215
+ document: currentDocument ?? null,
159
216
  };
160
217
  }
161
218
  const now = new Date().toISOString();
162
- await tx
219
+ const inserted = await tx
163
220
  .insert(schema.documentVersions)
164
221
  .values({
165
222
  id: claimId,
@@ -169,7 +226,10 @@ export default defineAction({
169
226
  content: draft.content,
170
227
  chatContext: JSON.stringify({
171
228
  choice: args.choice,
172
- status: "claimed",
229
+ status: "processing",
230
+ expectedDocumentUpdatedAt: args.expectedDocumentUpdatedAt,
231
+ processingToken,
232
+ processingStartedAt: now,
173
233
  draftId: draft.id,
174
234
  baseDocumentUpdatedAt: draft.baseDocumentUpdatedAt,
175
235
  loadedContentWasEmpty: draft.loadedContentWasEmpty,
@@ -190,64 +250,235 @@ export default defineAction({
190
250
  createdAt: now,
191
251
  updatedAt: now,
192
252
  })
193
- .onConflictDoNothing();
253
+ .onConflictDoNothing()
254
+ .returning({ id: schema.documentVersions.id });
255
+ if (inserted.length === 0) {
256
+ const [claim] = await tx
257
+ .select()
258
+ .from(schema.documentVersions)
259
+ .where(
260
+ and(
261
+ eq(schema.documentVersions.id, claimId),
262
+ eq(schema.documentVersions.ownerEmail, userEmail),
263
+ eq(schema.documentVersions.documentId, claimDocumentId),
264
+ ),
265
+ )
266
+ .for("update")
267
+ .limit(1);
268
+ if (!claim?.chatContext) conflict("The recovery claim was lost.");
269
+ const payload = durableClaimPayload.parse(
270
+ JSON.parse(claim.chatContext),
271
+ );
272
+ if (payload.choice !== args.choice)
273
+ conflict("This draft was already resolved with another choice.");
274
+ if (payload.status === "resolved") {
275
+ const deleted = await tx
276
+ .delete(schema.documentPreviewDrafts)
277
+ .where(draftFilter)
278
+ .returning({ id: schema.documentPreviewDrafts.id });
279
+ if (deleted.length !== 1)
280
+ conflict("The saved draft changed during recovery.");
281
+ return { status: "resolved" as const };
282
+ }
283
+ if (
284
+ payload.status === "processing" &&
285
+ payload.processingStartedAt &&
286
+ Date.now() - Date.parse(payload.processingStartedAt) < 30_000
287
+ )
288
+ conflict("This recovery choice is already being applied.");
289
+ await tx
290
+ .update(schema.documentVersions)
291
+ .set({
292
+ chatContext: JSON.stringify({
293
+ ...payload,
294
+ status: "processing",
295
+ processingToken,
296
+ processingStartedAt: now,
297
+ }),
298
+ updatedAt: now,
299
+ })
300
+ .where(eq(schema.documentVersions.id, claimId));
301
+ }
194
302
  const deleted = await tx
195
303
  .delete(schema.documentPreviewDrafts)
196
304
  .where(draftFilter)
197
305
  .returning({ id: schema.documentPreviewDrafts.id });
198
306
  if (deleted.length !== 1)
199
307
  conflict("The saved draft changed during recovery.");
200
- return draft;
308
+ return { status: "claimed" as const, draft, acquired: true };
201
309
  });
202
310
  };
311
+ const acquireClaim = async () =>
312
+ db.transaction(async (tx) => {
313
+ const [claim] = await tx
314
+ .select()
315
+ .from(schema.documentVersions)
316
+ .where(
317
+ and(
318
+ eq(schema.documentVersions.id, claimId),
319
+ eq(schema.documentVersions.ownerEmail, userEmail),
320
+ eq(schema.documentVersions.documentId, claimDocumentId),
321
+ ),
322
+ )
323
+ .for("update")
324
+ .limit(1);
325
+ if (!claim?.chatContext) conflict("The recovery claim was lost.");
326
+ const payload = durableClaimPayload.parse(
327
+ JSON.parse(claim.chatContext),
328
+ );
329
+ if (payload.choice !== args.choice)
330
+ conflict("This draft was already resolved with another choice.");
331
+ if (payload.status === "resolved") return false;
332
+ const [currentDocument] = await tx
333
+ .select({
334
+ updatedAt: schema.documents.updatedAt,
335
+ title: schema.documents.title,
336
+ content: schema.documents.content,
337
+ })
338
+ .from(schema.documents)
339
+ .where(eq(schema.documents.id, args.documentId))
340
+ .for("update")
341
+ .limit(1);
342
+ if (
343
+ !currentDocument ||
344
+ (currentDocument.updatedAt !== args.expectedDocumentUpdatedAt &&
345
+ !(
346
+ args.choice === "keep_mine" &&
347
+ currentDocument.title === claim.title &&
348
+ currentDocument.content === claim.content
349
+ ))
350
+ ) {
351
+ conflict("The page changed after this recovery choice was reviewed.");
352
+ }
353
+ if (
354
+ payload.status === "processing" &&
355
+ payload.processingStartedAt &&
356
+ Date.now() - Date.parse(payload.processingStartedAt) < 30_000
357
+ ) {
358
+ conflict("This recovery choice is already being applied.");
359
+ }
360
+ const now = new Date().toISOString();
361
+ await tx
362
+ .update(schema.documentVersions)
363
+ .set({
364
+ chatContext: JSON.stringify({
365
+ ...payload,
366
+ status: "processing",
367
+ processingToken,
368
+ processingStartedAt: now,
369
+ }),
370
+ updatedAt: now,
371
+ })
372
+ .where(eq(schema.documentVersions.id, claimId));
373
+ return true;
374
+ });
203
375
  const markClaimResolved = async () => {
204
- const [claim] = await db
205
- .select({ chatContext: schema.documentVersions.chatContext })
206
- .from(schema.documentVersions)
207
- .where(
208
- and(
209
- eq(schema.documentVersions.id, claimId),
210
- eq(schema.documentVersions.ownerEmail, userEmail),
211
- eq(schema.documentVersions.documentId, claimDocumentId),
212
- ),
213
- )
214
- .limit(1);
215
- if (!claim?.chatContext) conflict("The recovery claim was lost.");
216
- const payload = durableClaimPayload.parse(JSON.parse(claim.chatContext));
217
- if (payload.choice !== args.choice) {
218
- conflict("This draft was already resolved with another choice.");
219
- }
220
- await db
221
- .update(schema.documentVersions)
222
- .set({
223
- chatContext: JSON.stringify({ ...payload, status: "resolved" }),
224
- updatedAt: new Date().toISOString(),
225
- })
226
- .where(
227
- and(
228
- eq(schema.documentVersions.id, claimId),
229
- eq(schema.documentVersions.ownerEmail, userEmail),
230
- eq(schema.documentVersions.documentId, claimDocumentId),
231
- ),
376
+ await db.transaction(async (tx) => {
377
+ const [claim] = await tx
378
+ .select({ chatContext: schema.documentVersions.chatContext })
379
+ .from(schema.documentVersions)
380
+ .where(
381
+ and(
382
+ eq(schema.documentVersions.id, claimId),
383
+ eq(schema.documentVersions.ownerEmail, userEmail),
384
+ eq(schema.documentVersions.documentId, claimDocumentId),
385
+ ),
386
+ )
387
+ .limit(1);
388
+ if (!claim?.chatContext) conflict("The recovery claim was lost.");
389
+ const payload = durableClaimPayload.parse(
390
+ JSON.parse(claim.chatContext),
232
391
  );
392
+ if (payload.choice !== args.choice) {
393
+ conflict("This draft was already resolved with another choice.");
394
+ }
395
+ if (payload.status === "resolved") return;
396
+ if (payload.processingToken !== processingToken)
397
+ conflict("This recovery choice is already being applied.");
398
+ await tx
399
+ .update(schema.documentVersions)
400
+ .set({
401
+ chatContext: JSON.stringify({ ...payload, status: "resolved" }),
402
+ updatedAt: new Date().toISOString(),
403
+ })
404
+ .where(
405
+ and(
406
+ eq(schema.documentVersions.id, claimId),
407
+ eq(schema.documentVersions.ownerEmail, userEmail),
408
+ eq(schema.documentVersions.documentId, claimDocumentId),
409
+ ),
410
+ );
411
+ });
233
412
  };
234
413
  const restoreClaimedDraft = async (
235
414
  draft: typeof schema.documentPreviewDrafts.$inferSelect,
236
415
  ) => {
237
- const restored = await db
238
- .insert(schema.documentPreviewDrafts)
239
- .values(draft)
240
- .onConflictDoNothing()
241
- .returning({ id: schema.documentPreviewDrafts.id });
242
- if (restored.length === 1) return;
416
+ await db.transaction(async (tx) => {
417
+ const [claim] = await tx
418
+ .select({ chatContext: schema.documentVersions.chatContext })
419
+ .from(schema.documentVersions)
420
+ .where(eq(schema.documentVersions.id, claimId))
421
+ .for("update")
422
+ .limit(1);
423
+ if (!claim?.chatContext) conflict("The recovery claim was lost.");
424
+ const payload = durableClaimPayload.parse(
425
+ JSON.parse(claim.chatContext),
426
+ );
427
+ if (
428
+ payload.status === "resolved" ||
429
+ payload.processingToken !== processingToken
430
+ )
431
+ return;
432
+ const restored = await tx
433
+ .insert(schema.documentPreviewDrafts)
434
+ .values(draft)
435
+ .onConflictDoNothing()
436
+ .returning({ id: schema.documentPreviewDrafts.id });
437
+ if (restored.length === 1) {
438
+ await tx
439
+ .update(schema.documentVersions)
440
+ .set({
441
+ chatContext: JSON.stringify({ ...payload, status: "claimed" }),
442
+ updatedAt: new Date().toISOString(),
443
+ })
444
+ .where(eq(schema.documentVersions.id, claimId));
445
+ return;
446
+ }
243
447
 
244
- conflict(
245
- "A newer draft replaced this recovery draft. The claimed version was preserved in Version History.",
246
- { recoveryVersionId: claimId },
247
- );
448
+ conflict(
449
+ "A newer draft replaced this recovery draft. The claimed version was preserved in Version History.",
450
+ { recoveryVersionId: claimId },
451
+ );
452
+ });
248
453
  };
249
454
  if (args.choice === "keep_mine") {
250
- const draft = await claimExactDraft();
455
+ const claimed = await claimExactDraft();
456
+ if (claimed.status === "document_conflict") return claimed;
457
+ if (claimed.status === "resolved") {
458
+ const [current] = await db
459
+ .select()
460
+ .from(schema.documents)
461
+ .where(eq(schema.documents.id, args.documentId))
462
+ .limit(1);
463
+ return {
464
+ status: "resolved" as const,
465
+ choice: args.choice,
466
+ document: current,
467
+ };
468
+ }
469
+ if (!claimed.acquired && !(await acquireClaim())) {
470
+ const [current] = await db
471
+ .select()
472
+ .from(schema.documents)
473
+ .where(eq(schema.documents.id, args.documentId))
474
+ .limit(1);
475
+ return {
476
+ status: "resolved" as const,
477
+ choice: args.choice,
478
+ document: current,
479
+ };
480
+ }
481
+ const { draft } = claimed;
251
482
  try {
252
483
  const [current] = await db
253
484
  .select()
@@ -299,7 +530,12 @@ export default defineAction({
299
530
  }
300
531
 
301
532
  if (args.choice === "use_saved") {
302
- await claimExactDraft();
533
+ const claimed = await claimExactDraft();
534
+ if (claimed.status === "document_conflict") return claimed;
535
+ if (claimed.status === "resolved")
536
+ return { status: "resolved" as const, choice: args.choice };
537
+ if (!claimed.acquired && !(await acquireClaim()))
538
+ return { status: "resolved" as const, choice: args.choice };
303
539
  await markClaimResolved();
304
540
  return { status: "resolved" as const, choice: args.choice };
305
541
  }
@@ -318,7 +554,31 @@ export default defineAction({
318
554
  ? existing
319
555
  : null;
320
556
  };
321
- const draft = await claimExactDraft();
557
+ const claimed = await claimExactDraft();
558
+ if (claimed.status === "document_conflict") return claimed;
559
+ if (claimed.status === "resolved") {
560
+ const existing = await findExistingRecovery();
561
+ if (!existing) conflict("The recovered copy could not be found.");
562
+ return {
563
+ status: "resolved" as const,
564
+ choice: args.choice,
565
+ document: existing,
566
+ createdDocumentId: existing.id,
567
+ urlPath: `/page/${existing.id}`,
568
+ };
569
+ }
570
+ if (!claimed.acquired && !(await acquireClaim())) {
571
+ const existing = await findExistingRecovery();
572
+ if (!existing) conflict("The recovered copy could not be found.");
573
+ return {
574
+ status: "resolved" as const,
575
+ choice: args.choice,
576
+ document: existing,
577
+ createdDocumentId: existing.id,
578
+ urlPath: `/page/${existing.id}`,
579
+ };
580
+ }
581
+ const { draft } = claimed;
322
582
  const existingRecovery = await findExistingRecovery();
323
583
  if (existingRecovery) {
324
584
  await markClaimResolved();
@@ -985,7 +985,9 @@ function AdditionalBlockEditor({
985
985
  documentId={documentId}
986
986
  content={content}
987
987
  onChange={onChange}
988
- onSaveContent={onSaveContent}
988
+ onSaveContent={async (markdown) =>
989
+ (await onSaveContent(markdown)) ? "persisted" : "failed"
990
+ }
989
991
  editable={canEdit}
990
992
  localFileMode
991
993
  />