@1agh/maude 1.4.4 → 1.4.6

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 (47) hide show
  1. package/apps/studio/annotations-model.ts +33 -1
  2. package/apps/studio/api.ts +19 -0
  3. package/apps/studio/canvas-comment-mount.tsx +48 -3
  4. package/apps/studio/canvas-lib.tsx +5 -0
  5. package/apps/studio/canvas-shell.tsx +110 -14
  6. package/apps/studio/canvas-source-memo.ts +39 -0
  7. package/apps/studio/canvas-text-patch.ts +117 -0
  8. package/apps/studio/client/app.jsx +108 -60
  9. package/apps/studio/client/comments-overlay.css +10 -0
  10. package/apps/studio/client/file-tree.jsx +157 -0
  11. package/apps/studio/client/index.html +1 -1
  12. package/apps/studio/client/panels/DiffView.jsx +37 -8
  13. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  14. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  15. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  16. package/apps/studio/client/styles/4-components.css +4 -2
  17. package/apps/studio/collab/index.ts +9 -0
  18. package/apps/studio/collab/persistence.ts +49 -1
  19. package/apps/studio/comment-anchor.ts +116 -0
  20. package/apps/studio/comments-overlay.tsx +78 -35
  21. package/apps/studio/context.ts +1 -0
  22. package/apps/studio/cursors-overlay.tsx +181 -107
  23. package/apps/studio/dist/client.bundle.js +1086 -1086
  24. package/apps/studio/dist/comment-mount.js +2 -2
  25. package/apps/studio/dist/styles.css +1 -1
  26. package/apps/studio/hmr-broadcast.ts +59 -3
  27. package/apps/studio/http.ts +3 -0
  28. package/apps/studio/sync/accepted-cold-start.ts +95 -7
  29. package/apps/studio/sync/accepted-link.ts +165 -29
  30. package/apps/studio/sync/agent.ts +28 -2
  31. package/apps/studio/sync/comment-ledger.ts +229 -0
  32. package/apps/studio/sync/file-plane.ts +101 -2
  33. package/apps/studio/sync/index.ts +124 -17
  34. package/apps/studio/sync/journal-client.ts +9 -3
  35. package/apps/studio/sync/migrate-seed.ts +9 -2
  36. package/apps/studio/sync/projection.ts +114 -11
  37. package/apps/studio/sync/remote-docs.ts +3 -2
  38. package/apps/studio/sync/source-recovery.ts +67 -0
  39. package/apps/studio/sync/status.ts +11 -3
  40. package/apps/studio/sync/transaction-client.ts +49 -4
  41. package/apps/studio/use-collab.tsx +28 -1
  42. package/apps/studio/use-selection-set.tsx +13 -3
  43. package/apps/studio/use-undo-stack.tsx +22 -2
  44. package/apps/studio/whats-new.json +45 -0
  45. package/cli/lib/workspace-plan.mjs +10 -0
  46. package/package.json +8 -8
  47. package/plugins/design/templates/_shell.html +21 -0
@@ -58,6 +58,7 @@ import {
58
58
  unionCommentsById,
59
59
  } from './cold-start.ts';
60
60
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
61
+ import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
61
62
  import { hashBytes } from './echo-guard.ts';
62
63
  import type { SyncJournal } from './journal.ts';
63
64
  import { ORIGINS } from './origins.ts';
@@ -85,6 +86,8 @@ export interface MigrateSeedOptions {
85
86
  historyDir?: string;
86
87
  /** DDR-102 — per-machine journal; gates fast-forward vs conflict. */
87
88
  journal?: SyncJournal;
89
+ /** Issue #133 — comment ids synced from here before (sync/comment-ledger.ts). */
90
+ commentLedger?: CommentLedger;
88
91
  /** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
89
92
  snapshot?: (content: string, reason: ColdStartSnapshotReason) => Promise<string | null>;
90
93
  /**
@@ -414,9 +417,13 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
414
417
  // delete-then-insert codec — same-id entries keep the doc's version, so the
415
418
  // duplication trap stays closed; local-only comments survive.
416
419
  if (localComments) {
417
- const parsed = tryParseJsonArray(localComments);
420
+ const docList = doc.getArray(Y_TYPES.comments).toArray();
421
+ const read = tryParseJsonArray(localComments);
422
+ // Issue #133 — never union back a comment a peer deleted while we were away.
423
+ const parsed = read
424
+ ? withoutRemotelyDeleted(read, docList, opts.commentLedger?.get(slug))
425
+ : null;
418
426
  if (parsed && parsed.length > 0) {
419
- const docList = doc.getArray(Y_TYPES.comments).toArray();
420
427
  const merged = unionCommentsById(docList, parsed);
421
428
  if (merged.length !== docList.length) {
422
429
  doc.transact(() => {
@@ -54,7 +54,12 @@ import type { RevisionBarrier } from './revision-barrier.ts';
54
54
  import { repairSeedDuplication } from './seed-repair.ts';
55
55
  import { mergeSource } from './source-merge.ts';
56
56
  import type { SourceOp } from './source-ops.ts';
57
- import { saveRecoveryBody } from './source-recovery.ts';
57
+ import {
58
+ preserveRecoveryCandidate,
59
+ readRecoveryCandidate,
60
+ resolveRecoveryCandidate,
61
+ saveRecoveryBody,
62
+ } from './source-recovery.ts';
58
63
  import { sourceError } from './source-validation.ts';
59
64
  import { laneHash } from './transaction-client.ts';
60
65
 
@@ -222,6 +227,13 @@ export interface DocProjection {
222
227
  * write stays blocked with a visible conflict and a later save can merge.
223
228
  */
224
229
  adoptBase(body: string): void;
230
+ /**
231
+ * Accepted mode, cold start: `value` is this disk's own html proposal that a
232
+ * previous run left in the outbox and the drain just had accepted. It is
233
+ * what the disk was saved as — the base of whatever it holds next — even
234
+ * while this replica still shows the value before it.
235
+ */
236
+ adoptOwnAccepted(value: string): void;
225
237
  /**
226
238
  * Accepted-revisions mode: propose one lane value that did not come through
227
239
  * the watcher (a comment/annotation API write). Resolves with the outcome;
@@ -240,8 +252,13 @@ export interface DocProjection {
240
252
  * recovery slots. Returns false when nothing is held.
241
253
  */
242
254
  takeAccepted(): boolean;
243
- /** T28 — the two sides of a held source conflict (null when none). */
244
- conflictSides(): { mine: string | null; theirs: string } | null;
255
+ /** T28 — current sides plus the first candidate and proven base (null when unknown). */
256
+ conflictSides(): {
257
+ mine: string | null;
258
+ theirs: string;
259
+ original: string | null;
260
+ base: string | null;
261
+ } | null;
245
262
  /** Re-deliver file changes held while the document was not writable. */
246
263
  retryDeferred(): void;
247
264
  /**
@@ -290,6 +307,22 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
290
307
  let validationCache: { body: string; error: string | null } | null = null;
291
308
 
292
309
  function recovered(): void {
310
+ // An older accepted proposal is not resolution of a later pending/held one.
311
+ if (pending.has('html') || held.has('html')) return;
312
+ try {
313
+ if (opts.historyDir) resolveRecoveryCandidate(opts.historyDir, paths.html);
314
+ } catch {
315
+ // The accepted action is real even if updating our local recovery record
316
+ // fails. Keep the original pinned and the storage problem visible.
317
+ rejectedKey ??= 'recovery-failed';
318
+ opts.onConflict?.({
319
+ slug,
320
+ kind: 'body-rejected',
321
+ reason: 'history-failed',
322
+ snapshotFailed: true,
323
+ });
324
+ return;
325
+ }
293
326
  if (rejectedKey !== null) opts.onRecovered?.();
294
327
  rejectedKey = null;
295
328
  }
@@ -325,13 +358,16 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
325
358
  reason: BodyRejection['reason'],
326
359
  local: string | null,
327
360
  incoming: string,
328
- file = paths.html
361
+ file = paths.html,
362
+ base: string | null = lastHtml
329
363
  ): void {
330
364
  const key = `${file}:${reason}:${hashBytes(local ?? '')}:${hashBytes(incoming)}`;
331
365
  if (key === rejectedKey) return;
332
366
  rejectedKey = key;
333
367
  let snapshotFailed = false;
334
368
  try {
369
+ if (file === paths.html && opts.historyDir)
370
+ preserveRecoveryCandidate(opts.historyDir, file, local, base);
335
371
  if (file === paths.html) preserveLocal(local);
336
372
  if (opts.historyDir) {
337
373
  if (local !== null) saveRecoveryBody(opts.historyDir, file, 'local', local);
@@ -461,11 +497,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
461
497
  next = repeated.unit;
462
498
  }
463
499
  }
500
+ if (acceptedOwn !== null && next !== lastHtml) {
501
+ // The replica moved. When it moved to exactly our accepted value and the
502
+ // disk already holds a newer save, that save is ours on top of it: agree
503
+ // on the accepted value and leave the file for the watcher to propose.
504
+ const own = next === acceptedOwn;
505
+ acceptedOwn = null;
506
+ if (own) {
507
+ const local = readLocal(paths.html);
508
+ if (local !== null && local !== next && local !== observedBody) {
509
+ lastHtml = next;
510
+ rememberBase(next);
511
+ return true;
512
+ }
513
+ }
514
+ }
464
515
  if (next === lastHtml) return true;
465
516
  // Don't clobber a non-empty local body with an empty doc (cold-start before
466
517
  // the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
467
518
  if (next === '') {
468
- lastHtml = next;
519
+ // Accepted mode: an empty replica is one the project's publication has
520
+ // not reached yet, never a value this disk agreed on — a base already
521
+ // known (a canvas this disk just added, adopted on acceptance) stays.
522
+ if (lastHtml === null || !acceptedOn()) lastHtml = next;
469
523
  return true;
470
524
  }
471
525
  if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return false;
@@ -675,14 +729,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
675
729
  return lane === 'comments' ? paths.comments : paths.annotations;
676
730
  }
677
731
 
732
+ /**
733
+ * Our own html proposal the hub accepted, whose publication has not reached
734
+ * this replica yet. Until it does, it — not the older replica value — is
735
+ * what the next save on this disk was made on top of (F3 S14, 2026-09-23:
736
+ * a save in that window was proposed on the value before the first save and
737
+ * refused as a base conflict, and the echo itself read as "a local edit
738
+ * overlaps an incoming change").
739
+ */
740
+ let acceptedOwn: string | null = null;
741
+
678
742
  /** The value disk and the accepted replica last agreed on for `lane`. */
679
743
  function agreedValue(lane: ProposalLane): string {
680
- if (lane === 'html') return lastHtml ?? htmlFromDoc(doc);
744
+ if (lane === 'html') return acceptedOwn ?? lastHtml ?? htmlFromDoc(doc);
681
745
  if (lane === 'css') return lastCss ?? cssFromDoc(doc) ?? '';
682
746
  return readLaneFromDoc(doc, lane);
683
747
  }
684
748
 
685
- function onRejected(lane: ProposalLane, local: string, outcome: ProposalOutcome): void {
749
+ function onRejected(
750
+ lane: ProposalLane,
751
+ local: string,
752
+ outcome: ProposalOutcome,
753
+ base: string
754
+ ): void {
686
755
  held.add(lane);
687
756
  // The candidate stays on disk: the projection's local-edit guard protects
688
757
  // it (html), `held` blocks the lane writer (css/meta), and the recovery
@@ -704,7 +773,8 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
704
773
  REJECTION_REASON[outcome.code ?? ''] ?? 'local-edit',
705
774
  local,
706
775
  readLaneFromDoc(doc, lane),
707
- pathOfLane(lane)
776
+ pathOfLane(lane),
777
+ base
708
778
  );
709
779
  }
710
780
 
@@ -772,6 +842,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
772
842
  }
773
843
  if (outcome.status === 'accepted') {
774
844
  if (!pending.has(lane)) held.delete(lane);
845
+ if (lane === 'html' && !pending.has(lane)) {
846
+ // Our accepted value is the base of whatever this disk holds next —
847
+ // whichever arrives first, the answer or the publication.
848
+ // Persisted too: a cold start judges the disk against this base.
849
+ if (readLaneFromDoc(doc, 'html') === value) {
850
+ lastHtml = value;
851
+ rememberBase(value);
852
+ } else acceptedOwn = value;
853
+ }
775
854
  if (lane === 'html') recovered();
776
855
  if (outcome.actionId) {
777
856
  try {
@@ -793,7 +872,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
793
872
  lastMeta = null;
794
873
  }
795
874
  } else {
796
- onRejected(lane, local, outcome);
875
+ onRejected(lane, local, outcome, baseContent);
797
876
  }
798
877
  scheduleFlush();
799
878
  return outcome;
@@ -864,6 +943,14 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
864
943
  // 2026-09-15, L09 delete). Every edit to these lanes arrives through the
865
944
  // API with the base it was made from (`proposeLane`).
866
945
  if (lane === 'comments' || lane === 'annotations') return false;
946
+ // A STALE EVENT PROPOSES NOTHING. The reader took these bytes before a
947
+ // later write reached the file — typically this projection materializing a
948
+ // newer accepted value — and delivered them after. Proposing them would
949
+ // put an older body back over a teammate's accepted edit (F3 S15, cloud
950
+ // cell, 2026-09-24: v13 over v14). What the disk holds now arrives with its
951
+ // own event.
952
+ const onDisk = readLocal(evt.path);
953
+ if (onDisk !== null && onDisk !== str) return false;
867
954
  if (lane === 'html') {
868
955
  if (str === lastHtml && !held.has('html')) return false; // a redelivered projection
869
956
  if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
@@ -1127,6 +1214,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
1127
1214
  lastHtml = body;
1128
1215
  observedBody = body;
1129
1216
  },
1217
+ adoptOwnAccepted(value: string) {
1218
+ // Exactly what an answer in this process does (see `submit`): the
1219
+ // replica either holds it already, or its publication is still coming.
1220
+ const replica = htmlFromDoc(doc);
1221
+ if (replica === value) lastHtml = value;
1222
+ else {
1223
+ lastHtml = replica;
1224
+ acceptedOwn = value;
1225
+ }
1226
+ rememberBase(value);
1227
+ },
1130
1228
  hold(lane, base, local) {
1131
1229
  held.add(lane);
1132
1230
  if (lane === 'html') {
@@ -1146,12 +1244,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
1146
1244
  lastHtml = null;
1147
1245
  dirty = true;
1148
1246
  void flush();
1149
- recovered();
1150
1247
  return true;
1151
1248
  },
1152
1249
  conflictSides() {
1153
1250
  if (!held.has('html') && rejectedKey === null) return null;
1154
- return { mine: readLocal(paths.html), theirs: htmlFromDoc(doc) };
1251
+ const candidate = opts.historyDir ? readRecoveryCandidate(opts.historyDir, paths.html) : null;
1252
+ return {
1253
+ mine: readLocal(paths.html),
1254
+ theirs: htmlFromDoc(doc),
1255
+ original: candidate?.original ?? null,
1256
+ base: candidate?.base ?? null,
1257
+ };
1155
1258
  },
1156
1259
  proposeLane(lane, value, o) {
1157
1260
  if (!acceptedOn() || stopped) return null;
@@ -341,8 +341,8 @@ export function pullTargets(
341
341
  * readable. Both hops go through the same function, so "where does this canvas
342
342
  * go" cannot have two answers.
343
343
  *
344
- * Returns null when even the containment check refuses — the only case in which
345
- * a canvas is dropped rather than degraded.
344
+ * Returns null when a present path is refused or containment fails. Only a
345
+ * document with no path may use the legacy slug-derived fallback.
346
346
  */
347
347
  export function resolvePulledTarget(args: {
348
348
  slug: string;
@@ -376,6 +376,7 @@ export function resolvePulledTarget(args: {
376
376
  allowUndeclaredGroup: args.allowUndeclaredGroup,
377
377
  onRefused: args.onRefused,
378
378
  });
379
+ if (args.path !== undefined && args.path !== null && !fromPath) return null;
379
380
  const bodyAbs = args.join(args.designRoot, rel);
380
381
  // Belt and braces at a create. The validator already refuses everything that
381
382
  // could escape lexically; this catches whatever a platform's own `resolve`
@@ -12,6 +12,73 @@ import { sourceError } from './source-validation.ts';
12
12
  */
13
13
  export type RecoverySlot = 'last-valid' | 'local' | 'incoming' | 'base';
14
14
 
15
+ /** The first rejected draft and its proven base, not the mutable latest draft. */
16
+ export interface RecoveryCandidate {
17
+ version: 1;
18
+ original: string | null;
19
+ base: string | null;
20
+ resolved: boolean;
21
+ }
22
+
23
+ function candidatePath(historyDir: string, file: string): string {
24
+ return path.join(historyDir, 'sync-recovery', `candidate${path.extname(file)}.json`);
25
+ }
26
+
27
+ function boundedBody(body: unknown): body is string | null {
28
+ return (
29
+ body === null || (typeof body === 'string' && Buffer.byteLength(body, 'utf8') <= MAX_HTML_BYTES)
30
+ );
31
+ }
32
+
33
+ /** Corrupt/inaccessible records fail closed: never replace an unreadable draft. */
34
+ export function readRecoveryCandidate(historyDir: string, file: string): RecoveryCandidate | null {
35
+ const target = candidatePath(historyDir, file);
36
+ let size: number;
37
+ try {
38
+ size = statSync(target).size;
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
41
+ throw error;
42
+ }
43
+ // Two byte-capped strings, each potentially JSON-escaped as six bytes/char.
44
+ if (size > 12 * MAX_HTML_BYTES + 256) throw new Error('Recovery candidate exceeds size limit');
45
+ const value: unknown = JSON.parse(readFileSync(target, 'utf8'));
46
+ if (!value || typeof value !== 'object') throw new Error('Invalid recovery candidate');
47
+ const record = value as Record<string, unknown>;
48
+ if (
49
+ record.version !== 1 ||
50
+ typeof record.resolved !== 'boolean' ||
51
+ !boundedBody(record.original) ||
52
+ !boundedBody(record.base)
53
+ )
54
+ throw new Error('Invalid recovery candidate');
55
+ return { version: 1, original: record.original, base: record.base, resolved: record.resolved };
56
+ }
57
+
58
+ /** One bounded, atomic record per source file; retries/restarts cannot evict it. */
59
+ export function preserveRecoveryCandidate(
60
+ historyDir: string,
61
+ file: string,
62
+ original: string | null,
63
+ base: string | null
64
+ ): void {
65
+ if (!boundedBody(original) || !boundedBody(base))
66
+ throw new Error('Recovery source exceeds size limit');
67
+ const previous = readRecoveryCandidate(historyDir, file);
68
+ if (previous && !previous.resolved) return;
69
+ atomicWrite(
70
+ candidatePath(historyDir, file),
71
+ JSON.stringify({ version: 1, original, base, resolved: false })
72
+ );
73
+ }
74
+
75
+ /** Retain the bytes after resolution, allowing replacement only by a new episode. */
76
+ export function resolveRecoveryCandidate(historyDir: string, file: string): void {
77
+ const previous = readRecoveryCandidate(historyDir, file);
78
+ if (!previous || previous.resolved) return;
79
+ atomicWrite(candidatePath(historyDir, file), JSON.stringify({ ...previous, resolved: true }));
80
+ }
81
+
15
82
  export function saveRecoveryBody(
16
83
  historyDir: string,
17
84
  file: string,
@@ -447,9 +447,17 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
447
447
  clearSourceConflict(slug) {
448
448
  const id = `source-conflict-${slug}`;
449
449
  const index = notices.findIndex((n) => n.id === id);
450
- if (index < 0) return;
451
- notices.splice(index, 1);
452
- flush(true);
450
+ let changed = index >= 0;
451
+ if (index >= 0) notices.splice(index, 1);
452
+ // The presentation reads these facts, not the dismissible notice. Clear
453
+ // every rejection of this source while keeping other sources and notes.
454
+ for (let i = conflicts.length - 1; i >= 0; i--) {
455
+ if (conflicts[i].slug === slug && conflicts[i].kind === 'body-rejected') {
456
+ conflicts.splice(i, 1);
457
+ changed = true;
458
+ }
459
+ }
460
+ if (changed) flush(true);
453
461
  },
454
462
  get: payload,
455
463
  };
@@ -337,9 +337,33 @@ export function createTransactionClient(opts: TransactionClientOptions) {
337
337
  * process knew the project is bound first — it was never sent, so its bytes
338
338
  * may still change; once sent, they never do.
339
339
  */
340
+ /**
341
+ * Learn the project (id + epoch) before binding or rebasing an entry. A
342
+ * transport failure is WAITED OUT like an unknown delivery — a desktop that
343
+ * restarts offline drains its outbox before the hub is reachable, and a
344
+ * throw here was an unhandled rejection that ended the process (F3/S06).
345
+ * An answer — a legacy hub (`absent`), a refused sign-in — is still thrown.
346
+ */
347
+ async function bootstrapWhenReachable(): Promise<void> {
348
+ for (let attempt = 1; ; attempt++) {
349
+ if (stopped) throw new TransactionError('client stopped', 'stopped');
350
+ try {
351
+ await bootstrap();
352
+ return;
353
+ } catch (err) {
354
+ if (err instanceof TransactionError) throw err;
355
+ if (attempt === 1 || attempt % 10 === 0)
356
+ log.warn(
357
+ `[sync/tx] the project is not reachable yet (${(err as Error).message}); waiting`
358
+ );
359
+ await sleep(Math.min(retryMs * 2 ** Math.min(attempt - 1, 4), 30_000));
360
+ }
361
+ }
362
+ }
363
+
340
364
  async function settle(file: string, entry: OutboxEntry): Promise<ProposalResult> {
341
365
  if (entry.unbound || projectId === null) {
342
- if (projectId === null) await bootstrap();
366
+ if (projectId === null) await bootstrapWhenReachable();
343
367
  if (entry.unbound && entry.action) {
344
368
  entry = { ...entry, bytes: envelope(entry.action, entry.transactionId) };
345
369
  delete entry.unbound;
@@ -351,7 +375,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
351
375
  if (result.status === 'rejected' && result.code === 'epoch-stale' && entry.action) {
352
376
  // A rebase is a NEW transaction under the current epoch, never a mutated
353
377
  // retry — and anything that depended on the old id now depends on this.
354
- await bootstrap();
378
+ await bootstrapWhenReachable();
355
379
  const action = {
356
380
  ...entry.action,
357
381
  ...(entry.action.dependsOn
@@ -433,7 +457,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
433
457
  * Resend what a previous process left in the outbox — in creation order, the
434
458
  * same bytes, each resolved first in case its answer was simply lost.
435
459
  */
436
- function drainOutbox(): Promise<ProposalResult[]> {
460
+ function drainOutbox(
461
+ onEach?: (result: ProposalResult, operations: Operation[]) => void
462
+ ): Promise<ProposalResult[]> {
437
463
  return enqueue(async () => {
438
464
  const entries = readOutbox().filter(({ file }) => !owned.has(file));
439
465
  const results: ProposalResult[] = [];
@@ -441,7 +467,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
441
467
  waiting.set(entry.transactionId, entry.createdAt);
442
468
  setPending(1);
443
469
  try {
444
- results.push(await settle(file, entry));
470
+ const result = await settle(file, entry);
471
+ results.push(result);
472
+ onEach?.(result, entry.action?.operations ?? []);
445
473
  } finally {
446
474
  waiting.delete(entry.transactionId);
447
475
  setPending(-1);
@@ -451,6 +479,22 @@ export function createTransactionClient(opts: TransactionClientOptions) {
451
479
  });
452
480
  }
453
481
 
482
+ /**
483
+ * Is `content` a value the project store holds — i.e. one it accepted at
484
+ * some revision? `null` when it cannot tell (unreachable, older hub).
485
+ */
486
+ async function holdsValue(content: string): Promise<boolean | null> {
487
+ try {
488
+ if (projectId === null) await bootstrap();
489
+ const hash = createHash('sha256').update(content, 'utf8').digest('hex');
490
+ const { status, json } = await request('GET', `blobs/${hash}`);
491
+ if (status === 200) return (json as { body?: unknown } | null)?.body === content;
492
+ return status === 404 ? false : null;
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+
454
498
  /** A read route (`history`, `lane`, `revisions`) — no retry, bounded. */
455
499
  async function read(route: string, params: Record<string, string | number>): Promise<unknown> {
456
500
  if (projectId === null) await bootstrap();
@@ -468,6 +512,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
468
512
  read,
469
513
  newTransactionId,
470
514
  drainOutbox,
515
+ holdsValue,
471
516
  /** The epoch proposals are currently made under. */
472
517
  get epoch() {
473
518
  return epoch;
@@ -323,6 +323,27 @@ export function useCollab(): CollabValue | null {
323
323
  // ─────────────────────────────────────────────────────────────────────────────
324
324
  // Hook: foreign awareness peers (the cursor overlay subscribes to this).
325
325
 
326
+ export interface AwarenessChanges {
327
+ added: number[];
328
+ updated: number[];
329
+ removed: number[];
330
+ }
331
+
332
+ /**
333
+ * Could this awareness `change` alter the foreign peers we show? False only
334
+ * when every touched client is the local one (our own cursor publish — #131).
335
+ * Unknown shape → true, the safe direction.
336
+ */
337
+ export function touchesForeignClient(changes: AwarenessChanges | undefined, myId: number): boolean {
338
+ if (!changes) return true;
339
+ const touched = [
340
+ ...(changes.added ?? []),
341
+ ...(changes.updated ?? []),
342
+ ...(changes.removed ?? []),
343
+ ];
344
+ return touched.length === 0 || touched.some((id) => id !== myId);
345
+ }
346
+
326
347
  /**
327
348
  * Returns the current set of foreign peers (excludes the local client). The
328
349
  * returned array is stable-reference between awareness updates — useful for
@@ -351,7 +372,13 @@ export function useForeignAwareness(): ForeignAwareness[] {
351
372
  return out;
352
373
  }
353
374
  setPeers(compute());
354
- const onChange = () => setPeers(compute());
375
+ // Issue #131 — `change` fires for OUR OWN publishes too (the 30 Hz cursor),
376
+ // and each one rebuilt every peer object and re-rendered every consumer.
377
+ // Only a change that touches another client can change what we show.
378
+ const onChange = (changes?: AwarenessChanges) => {
379
+ if (!touchesForeignClient(changes, awareness.clientID)) return;
380
+ setPeers(compute());
381
+ };
355
382
  awareness.on('change', onChange);
356
383
  return () => {
357
384
  awareness.off('change', onChange);
@@ -67,6 +67,10 @@ export interface Selection {
67
67
  worldW?: number;
68
68
  worldH?: number;
69
69
  html?: string;
70
+ /** Comment drops only — the annotation the click landed on, and the click's
71
+ * world point (comment-anchor.ts). */
72
+ annotationId?: string;
73
+ world?: { x: number; y: number };
70
74
  /** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
71
75
  authored?: Record<string, string>;
72
76
  computed?: Record<string, string>;
@@ -152,12 +156,14 @@ export function SelectionSetProvider({
152
156
  }) {
153
157
  const [selected, setSelected] = useState<Selection[]>([]);
154
158
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
159
+ const pendingPostRef = useRef<(() => void) | null>(null);
155
160
 
156
161
  const post = useCallback(
157
162
  (next: Selection[]) => {
158
163
  if (timerRef.current) clearTimeout(timerRef.current);
159
- timerRef.current = setTimeout(() => {
164
+ pendingPostRef.current = () => {
160
165
  timerRef.current = null;
166
+ pendingPostRef.current = null;
161
167
  const target = postTarget ?? (typeof window !== 'undefined' ? window.parent : null);
162
168
  if (!target) return;
163
169
  // Wire shape: single object for N=1 (back-compat), array for N>1, null for empty.
@@ -168,15 +174,19 @@ export function SelectionSetProvider({
168
174
  } catch {
169
175
  /* iframe likely cross-origin or detached */
170
176
  }
171
- }, POST_DEBOUNCE_MS);
177
+ };
178
+ timerRef.current = setTimeout(() => pendingPostRef.current?.(), POST_DEBOUNCE_MS);
172
179
  },
173
180
  [postTarget]
174
181
  );
175
182
 
176
- // Cleanup the debounce timer on unmount.
183
+ // Soft HMR replaces this provider too. Deliver the last local selection
184
+ // before the replacement's loaded handshake, so the shell can reselect it.
185
+ // Cancelling silently loses a click made inside the 50 ms debounce window.
177
186
  useEffect(
178
187
  () => () => {
179
188
  if (timerRef.current) clearTimeout(timerRef.current);
189
+ pendingPostRef.current?.();
180
190
  },
181
191
  []
182
192
  );
@@ -99,6 +99,11 @@ const UndoSinksContext = createContext<UndoSinksValue | null>(null);
99
99
 
100
100
  const NOOP_SINKS: UndoSinksValue = { setSink: () => {} };
101
101
 
102
+ // The iframe survives a soft HMR swap, but its Provider does not. Keep pending
103
+ // operations serialized across that handoff; remove idle queues so this map
104
+ // retains neither closed canvases nor completed command closures.
105
+ const canvasFlights = new Map<string, Promise<void>>();
106
+
102
107
  // ─────────────────────────────────────────────────────────────────────────────
103
108
  // Provider
104
109
 
@@ -185,10 +190,25 @@ export function UndoStackProvider({
185
190
  }, []);
186
191
 
187
192
  const enqueue = useCallback((task: () => Promise<void>): Promise<void> => {
188
- const next = inFlightRef.current.then(task, task);
189
- inFlightRef.current = next.catch(() => {
193
+ const file = fileRef.current;
194
+ const previous = (file && canvasFlights.get(file)) || inFlightRef.current;
195
+ const run = async () => {
196
+ // The previous mount may have persisted its ACK after this mount read
197
+ // the initial state. Re-read only at the serialized operation boundary.
198
+ if (file) stateRef.current = loadStackState(file);
199
+ await task();
200
+ };
201
+ const next = previous.then(run, run);
202
+ const settled = next.catch(() => {
190
203
  /* swallow — per-op error already reported */
191
204
  });
205
+ inFlightRef.current = settled;
206
+ if (file) {
207
+ canvasFlights.set(file, settled);
208
+ void settled.then(() => {
209
+ if (canvasFlights.get(file) === settled) canvasFlights.delete(file);
210
+ });
211
+ }
192
212
  return next;
193
213
  }, []);
194
214
 
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "comments-stay-where-you-put-them",
6
+ "version": "1.4.6",
7
+ "date": "2026-09-30",
8
+ "kind": "fix",
9
+ "title": "Comments stay where you put them",
10
+ "summary": "A comment on a sticky, a drawing or empty canvas no longer disappears a few seconds after you save it. A comment on a sticky moves with it, and if the thing it points at is deleted the comment stays, marked as detached, until you remove it.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "comments-reach-every-peer",
15
+ "version": "1.4.6",
16
+ "date": "2026-09-30",
17
+ "kind": "fix",
18
+ "title": "Web comments reach the desktop app",
19
+ "summary": "Comments added on the web while your desktop app was closed now show up when you reopen it, and a comment deleted elsewhere no longer comes back.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "large-boards-pan-smoothly",
24
+ "version": "1.4.6",
25
+ "date": "2026-09-30",
26
+ "kind": "improvement",
27
+ "title": "Large boards pan smoothly again",
28
+ "summary": "Panning a board with many artboards stays smooth while something is selected or a teammate is on the canvas — on the desktop app it could drop to a frame a second.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "sync-no-phantom-waiting-files",
33
+ "version": "1.4.6",
34
+ "date": "2026-09-30",
35
+ "kind": "fix",
36
+ "title": "Sync stops counting files that are done",
37
+ "summary": "The Sync panel no longer shows files as waiting or stuck when there is nothing left to deliver, and Resync now brings down a file the project changed while you were uploading it.",
38
+ "surface": "design-ui"
39
+ },
40
+ {
41
+ "id": "team-projects-hold-up",
42
+ "version": "1.4.5",
43
+ "date": "2026-09-25",
44
+ "kind": "fix",
45
+ "title": "Team projects that hold up under failure",
46
+ "summary": "Edits made in the browser on a self-hosted workspace now reach the project, switching a project to saved revisions finishes on its own even if the connection drops for a moment, and removing a teammate ends their access at once. Cloud projects also keep work written just before a server crash.",
47
+ "surface": "design-ui"
48
+ },
4
49
  {
5
50
  "id": "steadier-team-projects",
6
51
  "version": "1.4.2",