@1agh/maude 0.43.0 → 0.44.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 (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -158,6 +158,7 @@ import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.t
158
158
  import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
159
159
  import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
160
160
  import { crossedDragThreshold, type Tool } from './input-router.tsx';
161
+ import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
161
162
  import { mountCaret, placeCaretAt } from './text-caret.ts';
162
163
  import {
163
164
  AnnotationResizeOverlay,
@@ -170,6 +171,7 @@ import {
170
171
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
171
172
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
172
173
  import {
174
+ BATCH_DROP_CASCADE_PX,
173
175
  isHttpUrl,
174
176
  linkDomain,
175
177
  prettifyUrl,
@@ -396,6 +398,123 @@ function resolveAssetHref(href: string): string {
396
398
  return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
397
399
  }
398
400
 
401
+ /**
402
+ * An optimistic ImageStroke's `blob:`/`data:` href must never be PUT to the
403
+ * server. `sanitizeAnnotationSvg` (api.ts) strips an href it doesn't
404
+ * recognize — keeping the `<image>` element, dropping only the attribute —
405
+ * so the server's STORED + broadcast SVG silently diverges from whatever the
406
+ * client just sent. That divergence defeats the collab-echo self-suppression
407
+ * guard (the `recentSelfSvgsRef` history, near `putStrokes` below): the
408
+ * echo's content no longer matches anything we recorded as "already
409
+ * applied", so a real `setStrokesState` fires from the (href-stripped)
410
+ * server copy — which can wipe out a SIBLING stroke's still-in-flight
411
+ * optimistic insert that was never itself part of that PUT. This is the
412
+ * concrete failure a 3-file concurrent drop hit: one
413
+ * image ended up href-stripped (a blank frame) and another was dropped
414
+ * entirely once an unrelated commit's echo round-tripped while both were
415
+ * still uploading. See media-commit-chain.ts for the sibling accumulator fix
416
+ * — this closes the other half, at the persistence layer.
417
+ */
418
+ function isEphemeralHref(s: Stroke): boolean {
419
+ return s.tool === 'image' && /^(?:blob|data):/i.test(s.href);
420
+ }
421
+
422
+ /**
423
+ * Fold a local mutation's `next` against the live-rendered `prev`, restoring
424
+ * only strokes that are genuinely concurrent additions from ANOTHER
425
+ * in-flight commit — never reverting THIS mutation's own deletions.
426
+ * Disambiguated via `opBefore`, the baseline this mutation's `next` was
427
+ * itself computed from: an id present in `prev` but absent from BOTH
428
+ * `opBefore` and `next` was added by someone else after `opBefore` was
429
+ * captured (fold it in — this is the sibling-insert race this helper was
430
+ * originally written for). An id present in `opBefore` but absent from
431
+ * `next` was deliberately removed BY THIS MUTATION and must stay removed
432
+ * even though `prev` (React's rendered state) hasn't caught up to that
433
+ * removal yet.
434
+ *
435
+ * The prior version of this helper compared only `prev` against `next` —
436
+ * exactly the shape of every delete (an id in `prev`, absent from `next`) —
437
+ * so it silently folded every erased stroke straight back in locally, while
438
+ * the smaller, correct set still went out over PUT. Backspace looked like a
439
+ * no-op until a reload picked up the server's already-correct copy.
440
+ */
441
+ export function reconcileCommit(
442
+ prev: readonly Stroke[],
443
+ opBefore: readonly Stroke[],
444
+ next: readonly Stroke[]
445
+ ): Stroke[] {
446
+ const beforeIds = new Set(opBefore.map((s) => s.id));
447
+ const nextIds = new Set(next.map((s) => s.id));
448
+ const extra = prev.filter((s) => !beforeIds.has(s.id) && !nextIds.has(s.id));
449
+ return extra.length ? [...next, ...extra] : (next as Stroke[]);
450
+ }
451
+
452
+ /**
453
+ * Fold a FOREIGN collab-echo snapshot against local state. A foreign
454
+ * broadcast legitimately omits an id that was deleted (by us or a peer) —
455
+ * reviving it just because local `prev` hasn't caught up would make deletes
456
+ * unsyncable across tabs/peers. The only strokes worth resurrecting here are
457
+ * ones that are still purely local, not-yet-synced optimistic previews (an
458
+ * ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
459
+ * never have known about in the first place.
460
+ */
461
+ export function reconcileForeignEcho(
462
+ prev: readonly Stroke[],
463
+ incoming: readonly Stroke[]
464
+ ): Stroke[] {
465
+ const incomingIds = new Set(incoming.map((s) => s.id));
466
+ const extra = prev.filter((s) => !incomingIds.has(s.id) && isEphemeralHref(s));
467
+ return extra.length ? [...incoming, ...extra] : (incoming as Stroke[]);
468
+ }
469
+
470
+ /**
471
+ * Decide what `createImageFromFile`'s post-upload swap should do with a
472
+ * given `before` chain link. Extracted as a pure function (no React) so the
473
+ * delete-during-upload race is unit-testable: `wasDeleted` reflects whether
474
+ * `deleteStrokes` recorded this id (via `deletedStrokeIdsRef`) before the
475
+ * upload resolved — checking that FIRST, rather than inferring intent from
476
+ * `before`'s membership alone, is what stops a Backspace-then-still-
477
+ * uploading image from resurrecting once the upload lands (and syncing the
478
+ * resurrection to every collab peer). When not deleted, an id absent from
479
+ * `before` is assumed to be render lag (this same file's optimistic insert
480
+ * hasn't rendered yet) rather than a delete, and gets folded back in.
481
+ */
482
+ export function resolveImageUploadSwap(
483
+ before: readonly Stroke[],
484
+ id: string,
485
+ optimistic: Stroke,
486
+ realHref: string,
487
+ wasDeleted: boolean
488
+ ): MediaCommitResult<Stroke> | null {
489
+ if (wasDeleted) {
490
+ if (!before.some((s) => s.id === id)) return null;
491
+ return { after: before.filter((s) => s.id !== id), label: 'add image' };
492
+ }
493
+ const base = before.some((s) => s.id === id) ? before : [...before, optimistic];
494
+ const after = base.map((s) => (s.id === id ? ({ ...s, href: realHref } as Stroke) : s));
495
+ // The optimistic blob: entry was never itself committed, so the undo
496
+ // "before" excludes it — undo should remove the image outright, not
497
+ // restore a revoked blob:.
498
+ const commitBefore = base.filter((s) => s.id !== id);
499
+ return { after, commitBefore, label: 'add image' };
500
+ }
501
+
502
+ // feature-bulk-media-insert Task 11 — classify an already-uploaded
503
+ // `assets/<sha8>.<ext>` path client-side, mirroring the same extension sets
504
+ // `listAssets` uses server-side (api.ts). The picker only ever surfaces paths
505
+ // it already listed via /_api/assets, so every path resolves to one of these;
506
+ // `null` (unrecognized) is treated as "skip" by the caller.
507
+ const ANNOTATION_IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']);
508
+ const ANNOTATION_VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogg']);
509
+ const ANNOTATION_AUDIO_EXT = new Set(['mp3', 'wav', 'm4a', 'aac', 'flac', 'oga', 'opus']);
510
+ function classifyAssetPathKind(path: string): 'image' | 'video' | 'audio' | null {
511
+ const ext = (path.split('.').pop() || '').toLowerCase();
512
+ if (ANNOTATION_IMAGE_EXT.has(ext)) return 'image';
513
+ if (ANNOTATION_VIDEO_EXT.has(ext)) return 'video';
514
+ if (ANNOTATION_AUDIO_EXT.has(ext)) return 'audio';
515
+ return null;
516
+ }
517
+
399
518
  /**
400
519
  * DDR-150 dogfood #8 — true when a pointer/click event targets the inline
401
520
  * media player inside a mediaref chip. Every document-capture annotation
@@ -935,6 +1054,43 @@ export function AnnotationsLayer() {
935
1054
  */
936
1055
  const strokesRef = useRef<Stroke[]>(strokes);
937
1056
  strokesRef.current = strokes;
1057
+ // Phase 23 batch-drop fix — see media-commit-chain.ts. Every async media
1058
+ // completion (image upload swap, video/audio upload commit) that can be
1059
+ // triggered concurrently (N Finder files dropped at once) enqueues onto
1060
+ // this instead of calling commitStrokes directly off a `strokesRef.current`
1061
+ // read, so two completions landing before a render commit don't clobber
1062
+ // each other.
1063
+ const mediaCommitChainRef = useRef(
1064
+ createMediaCommitChain<Stroke>(
1065
+ () => strokesRef.current,
1066
+ (s) => s.id
1067
+ )
1068
+ );
1069
+ // Live-security-review finding (feature-bulk-media-insert follow-up) —
1070
+ // `createImageFromFile`'s upload-completion swap folds its optimistic
1071
+ // stroke back in whenever the chain's `before` doesn't have it yet,
1072
+ // reasoning "render lag, not a delete" (see the comment there). That
1073
+ // reasoning has no way to tell a genuine delete apart from render lag by
1074
+ // array membership alone — if the user drops an image then hits
1075
+ // Backspace before the upload resolves, the id is legitimately gone, and
1076
+ // the old code resurrected it anyway once the upload landed (and synced
1077
+ // the resurrection to every collab peer). Tracked explicitly here instead
1078
+ // of inferred: `deleteStrokes` records every id it removes; the swap
1079
+ // checks (and consumes) this before assuming absence means lag. Bounded
1080
+ // like `recentSelfSvgsRef` below — only ever holds ids a delete has
1081
+ // touched, which is small in practice, but capped for safety.
1082
+ const DELETED_STROKE_IDS_CAP = 128;
1083
+ const deletedStrokeIdsRef = useRef<Set<string>>(new Set());
1084
+ const rememberDeletedIds = useCallback((ids: readonly string[]) => {
1085
+ const set = deletedStrokeIdsRef.current;
1086
+ for (const id of ids) {
1087
+ set.add(id);
1088
+ if (set.size > DELETED_STROKE_IDS_CAP) {
1089
+ const oldest = set.values().next().value;
1090
+ if (oldest !== undefined) set.delete(oldest);
1091
+ }
1092
+ }
1093
+ }, []);
938
1094
 
939
1095
  const isDraw =
940
1096
  tool === 'pen' ||
@@ -961,10 +1117,28 @@ export function AnnotationsLayer() {
961
1117
  }, [ghostCapable, visible]);
962
1118
 
963
1119
  // Load existing annotations on mount.
964
- // Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
965
- // collab connects shortly after this fetch lands) doesn't re-apply the
966
- // same content we just hydrated from REST.
967
- const lastAppliedSvgRef = useRef<string>('');
1120
+ // Self-echo suppression (Phase 8 Task 5, hardened feature-bulk-media-
1121
+ // insert follow-up). A single "last applied" string only catches the MOST
1122
+ // RECENT self-write: when the chain fires several rapid commits (a batch
1123
+ // drop), the server's broadcast of an EARLIER commit can arrive after a
1124
+ // LATER local commit has already moved "last applied" on — so the earlier
1125
+ // echo no longer matches, gets misread as a foreign change, and rolls
1126
+ // local state BACK to that stale snapshot. This is the confirmed cause of
1127
+ // images silently vanishing after a multi-file drop (live-tested: 12
1128
+ // mismatched/misapplied echoes correlated exactly with lost strokes across
1129
+ // a 30-batch stress run). Track a bounded HISTORY of our own recent writes
1130
+ // instead of just the latest one, so an out-of-order echo of any recent
1131
+ // self-write is still recognized and suppressed.
1132
+ const RECENT_SELF_SVG_CAP = 64;
1133
+ const recentSelfSvgsRef = useRef<Set<string>>(new Set());
1134
+ const rememberSelfSvg = useCallback((svg: string) => {
1135
+ const set = recentSelfSvgsRef.current;
1136
+ set.add(svg);
1137
+ if (set.size > RECENT_SELF_SVG_CAP) {
1138
+ const oldest = set.values().next().value;
1139
+ if (oldest !== undefined) set.delete(oldest);
1140
+ }
1141
+ }, []);
968
1142
  useEffect(() => {
969
1143
  const file = deriveFile();
970
1144
  fileRef.current = file;
@@ -979,7 +1153,7 @@ export function AnnotationsLayer() {
979
1153
  const loaded = svgToStrokes(text);
980
1154
  if (loaded.length) {
981
1155
  setStrokesState(loaded);
982
- lastAppliedSvgRef.current = text;
1156
+ rememberSelfSvg(text);
983
1157
  }
984
1158
  })
985
1159
  .catch(() => {
@@ -988,14 +1162,15 @@ export function AnnotationsLayer() {
988
1162
  return () => {
989
1163
  cancelled = true;
990
1164
  };
991
- }, []);
1165
+ }, [rememberSelfSvg]);
992
1166
 
993
1167
  // Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
994
- // other tabs. Bail when the incoming SVG STRING is identical to the one
995
- // we last applied (covers the local echo round-trip without missing real
996
- // foreign changes). The prior length+first/last-id check was wrong: a
997
- // resize / move keeps the same id list, so all three predicates matched
998
- // even though geometry changed — foreign edits silently disappeared.
1168
+ // other tabs. Bail when the incoming SVG STRING matches any RECENT
1169
+ // self-write (covers the local echo round-trip, including an
1170
+ // out-of-order one, without missing real foreign changes). The prior
1171
+ // length+first/last-id check was wrong: a resize / move keeps the same id
1172
+ // list, so all three predicates matched even though geometry changed —
1173
+ // foreign edits silently disappeared.
999
1174
  const collab = useCollab();
1000
1175
  useEffect(() => {
1001
1176
  if (!collab) return;
@@ -1003,9 +1178,10 @@ export function AnnotationsLayer() {
1003
1178
  const apply = () => {
1004
1179
  const svg = map.get('svg');
1005
1180
  if (typeof svg !== 'string' || !svg) return;
1006
- if (svg === lastAppliedSvgRef.current) return;
1007
- lastAppliedSvgRef.current = svg;
1008
- setStrokesState(svgToStrokes(svg));
1181
+ if (recentSelfSvgsRef.current.has(svg)) return;
1182
+ rememberSelfSvg(svg);
1183
+ const incoming = svgToStrokes(svg);
1184
+ setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
1009
1185
  };
1010
1186
  apply();
1011
1187
  map.observe(apply);
@@ -1016,13 +1192,29 @@ export function AnnotationsLayer() {
1016
1192
  /* doc destroyed before unmount */
1017
1193
  }
1018
1194
  };
1019
- }, [collab]);
1195
+ }, [collab, rememberSelfSvg]);
1020
1196
 
1021
1197
  const undoStack = useUndoStackOptional();
1022
1198
  const undoSinks = useUndoSinks();
1023
1199
  const undoStackRef = useRef(undoStack);
1024
1200
  undoStackRef.current = undoStack;
1025
1201
 
1202
+ // feature-bulk-media-insert follow-up — dedicated PUT dispatch queue.
1203
+ // The undo-stack's own `inFlightRef` serializes its PUSH TASKS (each
1204
+ // awaits `cmd.do()` before the next task starts), but live network
1205
+ // capture during a rapid-fire batch drop showed the actual PUT REQUESTS
1206
+ // still overlapping (request N+1 starting before request N's response
1207
+ // arrived) — some other async hop between "task starts" and "fetch
1208
+ // fires" lets them interleave. Since every PUT body here is a strictly
1209
+ // growing superset (the media-commit-chain guarantees each commit is at
1210
+ // least as complete as the last), overlap is dangerous: if an EARLIER
1211
+ // (smaller) request's write lands on disk AFTER a LATER (larger) one's,
1212
+ // the smaller snapshot wins and silently erases the larger one's extra
1213
+ // strokes. A dedicated chain — mirroring `editApplyChainRef` in
1214
+ // client/app.jsx — makes the fetch DISPATCH itself wait for the
1215
+ // previous one's response, independent of whatever the undo-stack does.
1216
+ const putChainRef = useRef<Promise<void>>(Promise.resolve());
1217
+
1026
1218
  /**
1027
1219
  * Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
1028
1220
  * PUT to the server. Used as the `putFn` injected into the
@@ -1036,25 +1228,40 @@ export function AnnotationsLayer() {
1036
1228
  * we push a command, so the server only sees one PUT per edit instead
1037
1229
  * of two-step racing.
1038
1230
  */
1039
- const putStrokes = useCallback((next: readonly Stroke[]) => {
1040
- setStrokesState(next as Stroke[]);
1041
- const file = fileRef.current;
1042
- if (!file) return Promise.resolve();
1043
- const svg = strokesToSvg(next);
1044
- // Phase 8 Task 5 record the SVG we just authored locally so the
1045
- // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
1046
- // Y.Map.observe) doesn't trigger a redundant setStrokesState.
1047
- lastAppliedSvgRef.current = svg;
1048
- return fetch('/_api/annotations', {
1049
- method: 'PUT',
1050
- headers: { 'Content-Type': 'application/json' },
1051
- body: JSON.stringify({ file, svg }),
1052
- })
1053
- .then(() => undefined)
1054
- .catch(() => {
1055
- /* swallow user sees uncommitted state until the next stroke */
1056
- });
1057
- }, []);
1231
+ const putStrokes = useCallback(
1232
+ (next: readonly Stroke[], before: readonly Stroke[]) => {
1233
+ // See reconcileCommit — a direct setStrokesState(next) here can
1234
+ // clobber a sibling file's concurrent optimistic insert; folding
1235
+ // blindly against `prev` (no baseline) can just as easily revert this
1236
+ // very mutation's own delete. `before` (this command's own baseline)
1237
+ // disambiguates the two.
1238
+ setStrokesState((prev) => reconcileCommit(prev, before, next));
1239
+ const file = fileRef.current;
1240
+ if (!file) return Promise.resolve();
1241
+ const persistable = next.some(isEphemeralHref)
1242
+ ? next.filter((s) => !isEphemeralHref(s))
1243
+ : next;
1244
+ const svg = strokesToSvg(persistable);
1245
+ // Phase 8 Task 5 — record the SVG we just authored locally so the
1246
+ // server-broadcast echo (PUT onAnnotationsChanged → syncRoom* →
1247
+ // Y.Map.observe) doesn't trigger a redundant setStrokesState.
1248
+ rememberSelfSvg(svg);
1249
+ const dispatch = () =>
1250
+ fetch('/_api/annotations', {
1251
+ method: 'PUT',
1252
+ headers: { 'Content-Type': 'application/json' },
1253
+ body: JSON.stringify({ file, svg }),
1254
+ })
1255
+ .then(() => undefined)
1256
+ .catch(() => {
1257
+ /* swallow — user sees uncommitted state until the next stroke */
1258
+ });
1259
+ const chained = putChainRef.current.then(dispatch, dispatch);
1260
+ putChainRef.current = chained;
1261
+ return chained;
1262
+ },
1263
+ [rememberSelfSvg]
1264
+ );
1058
1265
 
1059
1266
  // Register the strokes put sink with the undo provider so the rebuilt
1060
1267
  // AnnotationStrokesCommand (after a canvas switch + return) routes through
@@ -1113,6 +1320,12 @@ export function AnnotationsLayer() {
1113
1320
  (s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
1114
1321
  );
1115
1322
  if (filtered.length === prev.length) return;
1323
+ // Record every id actually removed (not just the requested `ids` —
1324
+ // bound text anchors get swept too) so an in-flight image upload's
1325
+ // swap (see deletedStrokeIdsRef above) knows this id is a genuine
1326
+ // delete, not render lag, if it resolves after this point.
1327
+ const filteredIds = new Set(filtered.map((s) => s.id));
1328
+ rememberDeletedIds(prev.filter((s) => !filteredIds.has(s.id)).map((s) => s.id));
1116
1329
  // FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
1117
1330
  // arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
1118
1331
  commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
@@ -1220,7 +1433,100 @@ export function AnnotationsLayer() {
1220
1433
  previewStroke,
1221
1434
  commitGesture,
1222
1435
  };
1223
- }, [strokes, setStrokes, commitStrokes]);
1436
+ }, [strokes, setStrokes, commitStrokes, rememberDeletedIds]);
1437
+
1438
+ // feature-bulk-media-insert Task 11 — the picker's "Add as annotation"
1439
+ // confirm posts N already-uploaded asset paths in ONE message; unlike
1440
+ // createImageFromFile/createMediaReference (which each race an independent
1441
+ // upload), every path here is already on disk, so this resolves every
1442
+ // image's natural size up front (Promise.all) and performs exactly ONE
1443
+ // commitStrokes for the whole batch — correct by construction, no chain
1444
+ // needed since there's no per-item async race once all N are resolved.
1445
+ const createMediaFromAssetPaths = useCallback(
1446
+ (paths: readonly string[], world: [number, number]) => {
1447
+ if (typeof window === 'undefined' || !paths.length) return;
1448
+ const classified = paths
1449
+ .map((path) => ({ path, kind: classifyAssetPathKind(path) }))
1450
+ .filter((it): it is { path: string; kind: 'image' | 'video' | 'audio' } => it.kind != null);
1451
+ if (!classified.length) return;
1452
+
1453
+ type Resolved = { path: string; kind: 'image' | 'video' | 'audio'; w: number; h: number };
1454
+ const probeOne = (path: string, kind: 'image' | 'video' | 'audio'): Promise<Resolved> => {
1455
+ if (kind !== 'image') {
1456
+ const h = kind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
1457
+ return Promise.resolve({ path, kind, w: MEDIAREF_DEFAULT_W, h });
1458
+ }
1459
+ return new Promise<Resolved>((resolve) => {
1460
+ const probe = new Image();
1461
+ probe.onload = () => {
1462
+ const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
1463
+ const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
1464
+ const longest = Math.max(natW, natH) || 1;
1465
+ const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
1466
+ resolve({
1467
+ path,
1468
+ kind: 'image',
1469
+ w: Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale)),
1470
+ h: Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale)),
1471
+ });
1472
+ };
1473
+ // Couldn't decode — fall back to a small square rather than
1474
+ // dropping this item from the batch (the file is already on disk).
1475
+ probe.onerror = () => resolve({ path, kind: 'image', w: 64, h: 64 });
1476
+ probe.src = resolveAssetHref(path);
1477
+ });
1478
+ };
1479
+
1480
+ void Promise.all(classified.map(({ path, kind }) => probeOne(path, kind))).then(
1481
+ (resolved) => {
1482
+ // Ids generated up front (stable regardless of chain timing) so
1483
+ // they're available for the selection call below without waiting
1484
+ // on the chain to settle.
1485
+ const withIds = resolved.map((item, i) => ({ ...item, id: rid(), i }));
1486
+ const label = `add ${withIds.length} item${withIds.length === 1 ? '' : 's'}`;
1487
+ // Batch-drop fix (see media-commit-chain.ts) — every path here is
1488
+ // already resolved, so THIS call alone is race-free by
1489
+ // construction, but a SECOND overlapping bulk-insert (or a
1490
+ // concurrent drag-drop via createImageFromFile/createMediaReference)
1491
+ // can still land while this one's Promise.all was in flight (large
1492
+ // photos take real, non-trivial decode time) — both read/write the
1493
+ // same `strokes` state, so this must enqueue onto the shared chain
1494
+ // too, not read `strokesRef.current` + commit directly.
1495
+ void mediaCommitChainRef.current.enqueue((before) => {
1496
+ const added: Stroke[] = withIds.map((item) => {
1497
+ const cx = world[0] + item.i * BATCH_DROP_CASCADE_PX;
1498
+ const cy = world[1] + item.i * BATCH_DROP_CASCADE_PX;
1499
+ if (item.kind === 'image') {
1500
+ return {
1501
+ id: item.id,
1502
+ tool: 'image',
1503
+ x: cx - item.w / 2,
1504
+ y: cy - item.h / 2,
1505
+ w: item.w,
1506
+ h: item.h,
1507
+ href: item.path,
1508
+ } as ImageStroke;
1509
+ }
1510
+ return {
1511
+ id: item.id,
1512
+ tool: 'mediaref',
1513
+ x: cx - item.w / 2,
1514
+ y: cy - item.h / 2,
1515
+ w: item.w,
1516
+ h: item.h,
1517
+ src: item.path,
1518
+ mediaKind: item.kind,
1519
+ title: item.path.split('/').pop() || item.path,
1520
+ } as MediaRefStroke;
1521
+ });
1522
+ return { after: [...before, ...added], label };
1523
+ }, commitStrokes);
1524
+ annotSel?.replace(withIds.map((item) => item.id));
1525
+ }
1526
+ );
1527
+ },
1528
+ [commitStrokes, annotSel]
1529
+ );
1224
1530
 
1225
1531
  // Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
1226
1532
  // dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
@@ -1297,10 +1603,29 @@ export function AnnotationsLayer() {
1297
1603
  commitStrokes(before, [...before, stroke], 'add sticker');
1298
1604
  annotSel?.replace([id]);
1299
1605
  }
1606
+ // feature-bulk-media-insert Task 11 — the shell's AssetPicker (main-
1607
+ // origin) multi-select "Add as annotation" confirm. Every path is
1608
+ // already an uploaded assets/… path (no upload step here); same
1609
+ // viewport-center placement as insert-sticker above (no cross-origin
1610
+ // cursor position to reuse).
1611
+ if (m.dgn === 'insert-annotation-media' && Array.isArray((m as { paths?: unknown }).paths)) {
1612
+ const paths = (m as { paths: unknown[] }).paths.filter(
1613
+ (p): p is string => typeof p === 'string'
1614
+ );
1615
+ if (paths.length) {
1616
+ const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
1617
+ const z = v.zoom || 1;
1618
+ const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
1619
+ const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
1620
+ const wx = (cx - v.x) / z;
1621
+ const wy = (cy - v.y) / z;
1622
+ createMediaFromAssetPaths(paths, [wx, wy]);
1623
+ }
1624
+ }
1300
1625
  };
1301
1626
  window.addEventListener('message', onMessage);
1302
1627
  return () => window.removeEventListener('message', onMessage);
1303
- }, [annotSel, strokes, setVisible, commitStrokes]);
1628
+ }, [annotSel, strokes, setVisible, commitStrokes, createMediaFromAssetPaths]);
1304
1629
 
1305
1630
  // Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
1306
1631
  // owned by the dev-server menubar (Help button); we no longer ship an
@@ -1355,17 +1680,35 @@ export function AnnotationsLayer() {
1355
1680
  };
1356
1681
  // Local-only insert — the blob: href must NOT reach the server (it's
1357
1682
  // ephemeral + would be stripped by the sanitizer); we commit only the
1358
- // assets/… form once the upload lands.
1359
- setStrokesState([...strokesRef.current, optimistic]);
1683
+ // assets/… form once the upload lands. Functional updater — a batch
1684
+ // drop fires this for every file independently, so two optimistic
1685
+ // inserts landing before a render commit must compose against each
1686
+ // other rather than each overwriting the other's array snapshot.
1687
+ setStrokesState((prev) => [...prev, optimistic]);
1360
1688
  void uploadAsset(file).then((res) => {
1361
- const cur = strokesRef.current;
1362
1689
  if ('path' in res) {
1363
- const after = cur.map((s) => (s.id === id ? ({ ...s, href: res.path } as Stroke) : s));
1364
- const beforeForUndo = cur.filter((s) => s.id !== id);
1365
- commitStrokes(beforeForUndo, after, 'add image');
1366
- annotSel?.replace([id]);
1690
+ // Batch-drop fix (see media-commit-chain.ts) N concurrent
1691
+ // uploads resolve at unpredictable relative speed; enqueue the
1692
+ // swap instead of reading strokesRef.current directly, so this
1693
+ // completion always builds on the latest accumulated state
1694
+ // rather than a stale pre-render snapshot another completion
1695
+ // already moved past.
1696
+ void mediaCommitChainRef.current
1697
+ .enqueue((before) => {
1698
+ const wasDeleted = deletedStrokeIdsRef.current.has(id);
1699
+ if (wasDeleted) deletedStrokeIdsRef.current.delete(id);
1700
+ return resolveImageUploadSwap(before, id, optimistic, res.path, wasDeleted);
1701
+ }, commitStrokes)
1702
+ .then((after) => {
1703
+ if (after.some((s) => s.id === id)) annotSel?.replace([id]);
1704
+ });
1367
1705
  } else {
1368
- setStrokesState(cur.filter((s) => s.id !== id));
1706
+ // Failure cleanup — no undo record (the optimistic stroke was
1707
+ // never committed), so a plain functional updater (not the
1708
+ // chain) is enough: it still composes correctly against any
1709
+ // chain-driven setStrokesState queued in the same batch.
1710
+ setStrokesState((prev) => prev.filter((s) => s.id !== id));
1711
+ deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
1369
1712
  showCanvasToast(`Image upload failed — ${res.error}`);
1370
1713
  }
1371
1714
  URL.revokeObjectURL(blobUrl);
@@ -1433,8 +1776,15 @@ export function AnnotationsLayer() {
1433
1776
  mediaKind,
1434
1777
  title: (file.name || res.path).slice(0, 300),
1435
1778
  };
1436
- const before = strokesRef.current;
1437
- commitStrokes(before, [...before, ref], `add ${mediaKind} reference`);
1779
+ // Batch-drop fix (see media-commit-chain.ts) — same accumulator
1780
+ // chain as createImageFromFile's swap, so a video/audio upload
1781
+ // resolving interleaved with concurrent image uploads (or other
1782
+ // media uploads) in the same batch never commits against a stale
1783
+ // pre-render snapshot.
1784
+ void mediaCommitChainRef.current.enqueue(
1785
+ (before) => ({ after: [...before, ref], label: `add ${mediaKind} reference` }),
1786
+ commitStrokes
1787
+ );
1438
1788
  annotSel?.replace([id]);
1439
1789
  const sizeMb = file.size / (1024 * 1024);
1440
1790
  showCanvasToast(