@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -25,6 +25,7 @@ import {
25
25
  useContext,
26
26
  useEffect,
27
27
  useMemo,
28
+ useReducer,
28
29
  useRef,
29
30
  useState,
30
31
  } from 'react';
@@ -39,26 +40,31 @@ const MESSAGE_AWARENESS = 1;
39
40
  // Color hash — stable per peer identity.
40
41
 
41
42
  /**
42
- * djb2 string hash → 0xRRGGBB color in a curated palette. Determinism per
43
+ * djb2 string hash → OKLCH color in a curated palette. Determinism per
43
44
  * input name is the load-bearing property: every peer hashing "Alice" must
44
- * land on the SAME color. 12 hues spread evenly around the wheel; saturation
45
- * + lightness fixed so all colors stay readable on light + dark surfaces.
45
+ * land on the SAME color.
46
+ *
47
+ * DS contract (colors-presence specimen): the AI agent rides
48
+ * `--presence-agent` (violet-magenta, hue 322) — "a hue no human state uses,
49
+ * so attribution on a shared canvas is unambiguous". Human hues therefore
50
+ * EXCLUDE the agent band (~292–352) AND the accent indigo band (~245–290,
51
+ * reserved for selection/active). L/C match the DS presence tokens
52
+ * (oklch ≈0.74 0.16) so every cursor reads at the same weight on both themes.
46
53
  */
47
54
  const COLOR_PALETTE = [
48
- '#e91e63', // pink
49
- '#f44336', // red
50
- '#ff9800', // orange
51
- '#ffc107', // amber
52
- '#cddc39', // lime
53
- '#4caf50', // green
54
- '#009688', // teal
55
- '#00bcd4', // cyan
56
- '#03a9f4', // light blue
57
- '#3f51b5', // indigo
58
- '#673ab7', // deep purple
59
- '#9c27b0', // purple
55
+ 'oklch(0.70 0.17 12)', // rose
56
+ 'oklch(0.72 0.16 40)', // coral
57
+ 'oklch(0.78 0.15 78)', // amber (presence-away hue)
58
+ 'oklch(0.76 0.16 108)', // lime
59
+ 'oklch(0.74 0.16 145)', // green (presence-online hue)
60
+ 'oklch(0.75 0.14 172)', // teal
61
+ 'oklch(0.73 0.13 200)', // cyan
62
+ 'oklch(0.72 0.13 238)', // blue (status-info hue)
60
63
  ] as const;
61
64
 
65
+ /** The AI agent's exclusive cursor/avatar hue — `--presence-agent`. */
66
+ export const AGENT_COLOR = 'oklch(0.700 0.190 322)';
67
+
62
68
  export function colorForName(name: string): string {
63
69
  // COLOR_PALETTE is a non-empty const tuple; the explicit `?? '#000'`
64
70
  // fallback is unreachable but satisfies `noUncheckedIndexedAccess`.
@@ -98,6 +104,17 @@ export interface CollabAwarenessState {
98
104
  */
99
105
  annotationSelection: string[];
100
106
  viewport: { x: number; y: number; zoom: number };
107
+ /**
108
+ * Soft editing-presence (Phase 30). Set while THIS peer (a human editing via
109
+ * the CSS-inspector / a `/design:edit`-driven write, or a bridged agent) is
110
+ * actively editing the canvas body; `since` is the epoch-ms the edit session
111
+ * began. Null/absent = not editing. This is a SOFT, attributed heads-up that
112
+ * rides the same hub-bridged awareness channel as cursors — it is NOT a lock
113
+ * (no lease, no takeover, never blocks another peer). Cleared on idle + on
114
+ * disconnect (awareness GC). The visual conflict picker (DDR-116) remains the
115
+ * safety net for divergent saves.
116
+ */
117
+ editing?: { since: number } | null;
101
118
  /**
102
119
  * Server-side `disconnect` matches awareness states to outgoing peers by
103
120
  * this token (must equal the ws.data.id the server assigns at upgrade).
@@ -233,6 +250,18 @@ function sanitizeAnnotationSelection(raw: unknown): string[] {
233
250
  return out;
234
251
  }
235
252
 
253
+ // Soft editing-presence (Phase 30). `since` must be a finite POSITIVE epoch-ms
254
+ // that is not in the future (allow ±5 s clock skew). A future / NaN / Infinity /
255
+ // non-positive value is rejected → `null` (treated as not-editing), so a hostile
256
+ // hub peer can't pin a permanent "editing" badge with a far-future timestamp or
257
+ // poison a `Date.now() - since` age computation with a NaN.
258
+ function sanitizeEditingState(raw: unknown): { since: number } | null {
259
+ if (!raw || typeof raw !== 'object') return null;
260
+ const e = raw as { since?: unknown };
261
+ if (!isFiniteNum(e.since) || e.since <= 0 || e.since > Date.now() + 5000) return null;
262
+ return { since: e.since };
263
+ }
264
+
236
265
  /**
237
266
  * Validate + normalize one foreign awareness state at the trust boundary.
238
267
  * Returns null for states that can't be a peer (no usable name). `color` is
@@ -253,6 +282,7 @@ export function sanitizeForeignState(clientID: number, state: unknown): ForeignA
253
282
  selection: sanitizeSelection(s.selection),
254
283
  annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
255
284
  viewport: sanitizeViewport(s.viewport),
285
+ editing: sanitizeEditingState(s.editing),
256
286
  };
257
287
  }
258
288
 
@@ -328,6 +358,57 @@ export function useForeignAwareness(): ForeignAwareness[] {
328
358
  return peers;
329
359
  }
330
360
 
361
+ // ─────────────────────────────────────────────────────────────────────────────
362
+ // Hook: soft editing-presence (Phase 30).
363
+ //
364
+ // A SOFT, attributed "I'm editing this canvas" heads-up — NOT a lock. It rides
365
+ // the same per-canvas awareness channel as cursors (so it crosses the hub for
366
+ // free via the awareness bridge) and is surfaced by the peer overlay so two
367
+ // people (or a person + an agent) don't unknowingly edit the same canvas at the
368
+ // same moment. It never blocks anyone; the visual conflict picker (DDR-116)
369
+ // remains the safety net for divergent saves.
370
+
371
+ const EDITING_IDLE_MS = 5000;
372
+
373
+ /**
374
+ * Returns `setEditing()` / `clearEditing()`. Call `setEditing()` on each edit
375
+ * the local user makes (CSS-inspector tweak, `/design:edit`-driven write); it
376
+ * publishes `editing: { since }` once and auto-extends, then auto-clears after
377
+ * `EDITING_IDLE_MS` of no calls (and on unmount). A no-op outside a
378
+ * `CollabProvider` (returns callbacks that do nothing).
379
+ */
380
+ export function useEditingPresence(): { setEditing: () => void; clearEditing: () => void } {
381
+ const collab = useCollab();
382
+ const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
383
+ const sinceRef = useRef<number | null>(null);
384
+
385
+ const clearEditing = useCallback(() => {
386
+ if (idleTimerRef.current) {
387
+ clearTimeout(idleTimerRef.current);
388
+ idleTimerRef.current = null;
389
+ }
390
+ if (sinceRef.current !== null) {
391
+ sinceRef.current = null;
392
+ collab?.publishAwareness({ editing: null });
393
+ }
394
+ }, [collab]);
395
+
396
+ const setEditing = useCallback(() => {
397
+ if (!collab) return;
398
+ if (sinceRef.current === null) {
399
+ sinceRef.current = Date.now();
400
+ collab.publishAwareness({ editing: { since: sinceRef.current } });
401
+ }
402
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
403
+ idleTimerRef.current = setTimeout(clearEditing, EDITING_IDLE_MS);
404
+ }, [collab, clearEditing]);
405
+
406
+ // Clear local editing-presence on unmount.
407
+ useEffect(() => () => clearEditing(), [clearEditing]);
408
+
409
+ return { setEditing, clearEditing };
410
+ }
411
+
331
412
  // ─────────────────────────────────────────────────────────────────────────────
332
413
  // Slug derivation — must match `api.fileSlug` server-side.
333
414
 
@@ -357,215 +438,365 @@ export function canvasSlugFromPath(canvasRel: string | null | undefined): string
357
438
  }
358
439
 
359
440
  // ─────────────────────────────────────────────────────────────────────────────
360
- // Provider opens WS, owns Y.Doc + Awareness lifecycle.
441
+ // Module-level, refcounted collab session (F4 presence survives a hot-swap).
442
+ //
443
+ // A cross-peer synced edit (or an agent edit) hot-swaps the canvas module in
444
+ // place, which REMOUNTS the whole canvas subtree — including <CollabProvider>.
445
+ // If the Y.Doc + Awareness + WebSocket were owned by the component (useMemo /
446
+ // useEffect), that remount would CLOSE the awareness socket and re-handshake, so
447
+ // every peer's cursor + avatar blinks out and back on every synced change (the
448
+ // F4 bug). Instead the live session lives HERE, keyed by slug + refcounted: a
449
+ // same-slug remount re-acquires the SAME doc/awareness/socket within a short
450
+ // grace window, so the awareness connection never drops and presence is stable.
451
+ //
452
+ // One canvas iframe is one realm and only ever holds one slug (switching
453
+ // canvases navigates the iframe → fresh realm → fresh module state), so the map
454
+ // holds at most one live entry plus, briefly, one draining one.
361
455
 
362
- interface CollabProviderProps {
363
- /** Canvas slug — must match server-side `parseCollabSlug`. */
456
+ const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
457
+
458
+ interface CollabSession {
364
459
  slug: string;
365
- children: ReactNode;
460
+ doc: Y.Doc;
461
+ awareness: Awareness;
462
+ connId: string;
463
+ name: string;
464
+ color: string;
465
+ connected: boolean;
466
+ refCount: number;
467
+ destroyTimer: ReturnType<typeof setTimeout> | null;
468
+ /** React consumers subscribe so name/color/connected changes re-render. */
469
+ listeners: Set<() => void>;
470
+ /** Tear down the socket + listeners + destroy doc/awareness. */
471
+ stop: () => void;
366
472
  }
367
473
 
368
- const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
474
+ // The session registry MUST live on `window`, not in module scope, and be
475
+ // resolved LAZILY (per access, not once at module load). A hot-swap (F4)
476
+ // re-imports the canvas bundle with a cache-busting `?v=` query, and use-collab
477
+ // is INLINED into that per-canvas bundle — so each hot-swap re-evaluates a FRESH
478
+ // module with a fresh module-level binding. A plain `const SESSIONS = new Map()`
479
+ // would therefore be empty on every hot-swap and we'd spin a new Y.Doc +
480
+ // Awareness + socket (new clientID) each time, leaving the prior clientID's
481
+ // awareness to linger on the hub → phantom "self" avatars pile up until the
482
+ // awareness timeout. Anchoring the map on the iframe's `window` (which survives
483
+ // module re-evaluation) is what makes the session — and thus presence — survive
484
+ // the swap. Lazy resolution also tolerates a `window` that becomes available
485
+ // after this module first evaluates (test harness: imports are hoisted above
486
+ // happy-dom registration).
487
+ //
488
+ // SECURITY (DDR-054): the canvas iframe is untrusted and shares this realm, so
489
+ // the registry holds live network handles in reach of canvas script. We key it
490
+ // by a NON-ENUMERABLE global Symbol (not an enumerable string property) so it
491
+ // can't be harvested by an opportunistic `for…in` / `Object.keys(window)` sweep
492
+ // — defense in depth, NOT a trust boundary: same-realm canvas code can already
493
+ // reach collab state through `useCollab()`, and `Symbol.for` is recoverable by a
494
+ // determined attacker. Closing the underlying "untrusted canvas can mutate the
495
+ // shared doc" surface is a separate, pre-existing concern (tracked as a
496
+ // follow-up); this keeps the hot-swap fix from WIDENING discovery. A global
497
+ // Symbol (shared registry) is required so the re-imported module resolves the
498
+ // SAME key — a per-module `Symbol()` would defeat the cross-re-import survival.
499
+ const SESSIONS_KEY = Symbol.for('maude.collab.sessions.v1');
500
+ let moduleFallbackSessions: Map<string, CollabSession> | null = null;
501
+ function getSessions(): Map<string, CollabSession> {
502
+ if (typeof window === 'undefined') {
503
+ if (!moduleFallbackSessions) moduleFallbackSessions = new Map<string, CollabSession>();
504
+ return moduleFallbackSessions;
505
+ }
506
+ const w = window as unknown as Record<symbol, Map<string, CollabSession> | undefined>;
507
+ let map = w[SESSIONS_KEY];
508
+ if (!map) {
509
+ map = new Map<string, CollabSession>();
510
+ Object.defineProperty(w, SESSIONS_KEY, {
511
+ value: map,
512
+ enumerable: false,
513
+ writable: false,
514
+ configurable: false,
515
+ });
516
+ }
517
+ return map;
518
+ }
369
519
 
370
- export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
371
- // Y.Doc + Awareness are recreated whenever the slug changes (switching
372
- // canvases tears down the prior session cleanly). The useMemo factory
373
- // bodies don't read `slug` slug IS the cache key, intentionally.
374
- // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
375
- const doc = useMemo(() => new Y.Doc(), [slug]);
376
- const awareness = useMemo(() => new Awareness(doc), [doc]);
377
- // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
378
- const myConnId = useMemo(() => crypto.randomUUID(), [slug]);
379
-
380
- const [myName, setMyName] = useState('anonymous');
381
- const [myColor, setMyColor] = useState(colorForName('anonymous'));
382
- const [connected, setConnected] = useState(false);
383
-
384
- // Fetch git user.name once per slug; falls back to anonymous-<short id>.
385
- useEffect(() => {
386
- let cancelled = false;
387
- fetch('/_api/git-user')
388
- .then((r) => r.json())
389
- .then((j) => {
390
- if (cancelled) return;
391
- const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
392
- const finalName = n ?? `anonymous-${myConnId.slice(0, 6)}`;
393
- setMyName(finalName);
394
- setMyColor(colorForName(finalName));
395
- })
396
- .catch(() => {
397
- if (cancelled) return;
398
- const fallback = `anonymous-${myConnId.slice(0, 6)}`;
399
- setMyName(fallback);
400
- setMyColor(colorForName(fallback));
401
- });
402
- return () => {
403
- cancelled = true;
404
- };
405
- }, [myConnId]);
520
+ // Keep a refcount-0 session alive briefly so a hot-swap remount (which unmounts
521
+ // then immediately remounts the provider in the same commit) reuses the live
522
+ // socket instead of reconnecting. A genuine close (no re-acquire within the
523
+ // window) tears down so the peer leaves the room cleanly.
524
+ const SESSION_GRACE_MS = 4000;
406
525
 
407
- // Seed local awareness state immediately so foreign peers see our name even
408
- // before the cursor moves. Update when myName/myColor settles from the fetch.
409
- useEffect(() => {
526
+ function notifySession(s: CollabSession): void {
527
+ for (const l of s.listeners) l();
528
+ }
529
+
530
+ function createSession(slug: string): CollabSession {
531
+ const doc = new Y.Doc();
532
+ const awareness = new Awareness(doc);
533
+ const connId = crypto.randomUUID();
534
+
535
+ const session: CollabSession = {
536
+ slug,
537
+ doc,
538
+ awareness,
539
+ connId,
540
+ name: 'anonymous',
541
+ color: colorForName('anonymous'),
542
+ connected: false,
543
+ refCount: 0,
544
+ destroyTimer: null,
545
+ listeners: new Set(),
546
+ stop: () => {},
547
+ };
548
+
549
+ // Seed local awareness immediately so foreign peers see our name even before
550
+ // the first cursor move; preserves any cursor/selection already published.
551
+ const seedLocalAwareness = (name: string, color: string) => {
552
+ const cur = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
410
553
  awareness.setLocalState({
411
- name: myName,
412
- color: myColor,
413
- cursor: null,
414
- selection: null,
415
- annotationSelection: [],
416
- viewport: { x: 0, y: 0, zoom: 1 },
417
- __connId: myConnId,
554
+ name,
555
+ color,
556
+ cursor: cur.cursor ?? null,
557
+ selection: cur.selection ?? null,
558
+ annotationSelection: cur.annotationSelection ?? [],
559
+ viewport: cur.viewport ?? { x: 0, y: 0, zoom: 1 },
560
+ editing: cur.editing ?? null,
561
+ __connId: connId,
418
562
  } satisfies CollabAwarenessState);
419
- }, [awareness, myName, myColor, myConnId]);
563
+ };
564
+ seedLocalAwareness(session.name, session.color);
565
+
566
+ // Resolve identity from git user.name once per SESSION (not per mount) so a
567
+ // hot-swap remount doesn't re-fetch + re-publish (which would churn awareness).
568
+ let identityCancelled = false;
569
+ fetch('/_api/git-user')
570
+ .then((r) => r.json())
571
+ .then((j) => {
572
+ if (identityCancelled) return;
573
+ const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
574
+ const finalName = n ?? `anonymous-${connId.slice(0, 6)}`;
575
+ session.name = finalName;
576
+ session.color = colorForName(finalName);
577
+ seedLocalAwareness(finalName, session.color);
578
+ notifySession(session);
579
+ })
580
+ .catch(() => {
581
+ if (identityCancelled) return;
582
+ const fallback = `anonymous-${connId.slice(0, 6)}`;
583
+ session.name = fallback;
584
+ session.color = colorForName(fallback);
585
+ seedLocalAwareness(fallback, session.color);
586
+ notifySession(session);
587
+ });
420
588
 
421
589
  // ── WebSocket lifecycle ──────────────────────────────────────────────────
422
- const wsRef = useRef<WebSocket | null>(null);
423
-
424
- useEffect(() => {
425
- let cancelled = false;
426
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
427
-
428
- function sendFrame(ws: WebSocket, payload: Uint8Array) {
429
- try {
430
- ws.send(payload);
431
- } catch {
432
- /* dead socket — close handler will reconnect */
433
- }
590
+ let cancelled = false;
591
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
592
+ let wsRef: WebSocket | null = null;
593
+
594
+ function sendFrame(ws: WebSocket, payload: Uint8Array) {
595
+ try {
596
+ ws.send(payload);
597
+ } catch {
598
+ /* dead socket — close handler will reconnect */
434
599
  }
600
+ }
435
601
 
436
- function broadcastAwareness(ws: WebSocket, changed: number[]) {
437
- if (changed.length === 0) return;
438
- const encoder = encoding.createEncoder();
439
- encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
440
- encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
441
- sendFrame(ws, encoding.toUint8Array(encoder));
442
- }
602
+ function broadcastAwareness(ws: WebSocket, changed: number[]) {
603
+ if (changed.length === 0) return;
604
+ const encoder = encoding.createEncoder();
605
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
606
+ encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
607
+ sendFrame(ws, encoding.toUint8Array(encoder));
608
+ }
443
609
 
444
- function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
610
+ function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
611
+ const encoder = encoding.createEncoder();
612
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
613
+ writeUpdate(encoder, update);
614
+ sendFrame(ws, encoding.toUint8Array(encoder));
615
+ }
616
+
617
+ function connect() {
618
+ if (cancelled) return;
619
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
620
+ const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
621
+ ws.binaryType = 'arraybuffer';
622
+ wsRef = ws;
623
+
624
+ ws.addEventListener('open', () => {
625
+ session.connected = true;
626
+ notifySession(session);
627
+ // Sync step 1 — announce our state vector so the server can send the
628
+ // missing pieces (matches the encodeHandshake server path).
445
629
  const encoder = encoding.createEncoder();
446
630
  encoding.writeVarUint(encoder, MESSAGE_SYNC);
447
- writeUpdate(encoder, update);
631
+ writeSyncStep1(encoder, doc);
448
632
  sendFrame(ws, encoding.toUint8Array(encoder));
449
- }
450
-
451
- function connect() {
633
+ // Awareness initial state — fire our local state to the room.
634
+ broadcastAwareness(ws, [awareness.clientID]);
635
+ });
636
+
637
+ ws.addEventListener('close', () => {
638
+ session.connected = false;
639
+ notifySession(session);
640
+ wsRef = null;
452
641
  if (cancelled) return;
453
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
454
- const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
455
- ws.binaryType = 'arraybuffer';
456
- wsRef.current = ws;
457
-
458
- ws.addEventListener('open', () => {
459
- setConnected(true);
460
- // Sync step 1 — announce our state vector so the server can send the
461
- // missing pieces (matches the encodeHandshake server path).
462
- const encoder = encoding.createEncoder();
463
- encoding.writeVarUint(encoder, MESSAGE_SYNC);
464
- writeSyncStep1(encoder, doc);
465
- sendFrame(ws, encoding.toUint8Array(encoder));
466
- // Awareness initial state — fire our local state to the room.
467
- broadcastAwareness(ws, [awareness.clientID]);
468
- });
469
-
470
- ws.addEventListener('close', () => {
471
- setConnected(false);
472
- wsRef.current = null;
473
- if (cancelled) return;
474
- reconnectTimer = setTimeout(connect, 1000);
475
- });
476
-
477
- ws.addEventListener('error', () => {
478
- // Let close handler do the reconnect; error events without a close
479
- // would just retry-spam.
480
- });
481
-
482
- ws.addEventListener('message', (evt) => {
483
- const payload =
484
- evt.data instanceof ArrayBuffer
485
- ? new Uint8Array(evt.data)
486
- : evt.data instanceof Uint8Array
487
- ? evt.data
488
- : null;
489
- if (!payload) return;
490
- const decoder = decoding.createDecoder(payload);
491
- const messageType = decoding.readVarUint(decoder);
492
- switch (messageType) {
493
- case MESSAGE_SYNC: {
494
- const encoder = encoding.createEncoder();
495
- encoding.writeVarUint(encoder, MESSAGE_SYNC);
496
- readSyncMessage(decoder, encoder, doc, ws);
497
- if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
498
- break;
499
- }
500
- case MESSAGE_AWARENESS: {
501
- applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
502
- break;
503
- }
504
- default:
505
- break;
642
+ reconnectTimer = setTimeout(connect, 1000);
643
+ });
644
+
645
+ ws.addEventListener('error', () => {
646
+ // Let close handler do the reconnect; error events without a close
647
+ // would just retry-spam.
648
+ });
649
+
650
+ ws.addEventListener('message', (evt) => {
651
+ const payload =
652
+ evt.data instanceof ArrayBuffer
653
+ ? new Uint8Array(evt.data)
654
+ : evt.data instanceof Uint8Array
655
+ ? evt.data
656
+ : null;
657
+ if (!payload) return;
658
+ const decoder = decoding.createDecoder(payload);
659
+ const messageType = decoding.readVarUint(decoder);
660
+ switch (messageType) {
661
+ case MESSAGE_SYNC: {
662
+ const encoder = encoding.createEncoder();
663
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
664
+ readSyncMessage(decoder, encoder, doc, ws);
665
+ if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
666
+ break;
506
667
  }
507
- });
508
- }
509
-
510
- // Wire doc updates → broadcast to server. Origin tagged with the ws ref so
511
- // server-side updates we receive don't echo back.
512
- const onDocUpdate = (update: Uint8Array, origin: unknown) => {
513
- const ws = wsRef.current;
514
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
515
- if (origin === ws) return; // came from server, don't echo
516
- broadcastSyncUpdate(ws, update);
517
- };
518
- doc.on('update', onDocUpdate);
519
-
520
- // Wire awareness changes → broadcast. Same origin guard.
521
- const onAwarenessUpdate = (
522
- { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
523
- origin: unknown
524
- ) => {
525
- const ws = wsRef.current;
526
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
527
- if (origin === ws) return;
528
- const changed = added.concat(updated, removed);
529
- broadcastAwareness(ws, changed);
530
- };
531
- awareness.on('update', onAwarenessUpdate);
532
-
533
- connect();
534
-
535
- return () => {
536
- cancelled = true;
537
- if (reconnectTimer) clearTimeout(reconnectTimer);
538
- doc.off('update', onDocUpdate);
539
- awareness.off('update', onAwarenessUpdate);
540
- const ws = wsRef.current;
541
- if (ws && ws.readyState === WebSocket.OPEN) {
542
- try {
543
- ws.close();
544
- } catch {
545
- /* ignore */
668
+ case MESSAGE_AWARENESS: {
669
+ applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
670
+ break;
546
671
  }
672
+ default:
673
+ break;
547
674
  }
548
- // Don't destroy doc/awareness here — the useMemo-tied lifetime handles
549
- // that when the slug changes.
550
- };
551
- }, [slug, doc, awareness]);
675
+ });
676
+ }
552
677
 
553
- // Per-slug cleanup of doc/awareness when slug changes (or provider unmounts).
554
- useEffect(
555
- () => () => {
556
- try {
557
- awareness.destroy();
558
- } catch {
559
- /* ignore */
560
- }
678
+ // Wire doc updates broadcast to server. Origin tagged with the ws so
679
+ // server-side updates we receive don't echo back.
680
+ const onDocUpdate = (update: Uint8Array, origin: unknown) => {
681
+ const ws = wsRef;
682
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
683
+ if (origin === ws) return; // came from server, don't echo
684
+ broadcastSyncUpdate(ws, update);
685
+ };
686
+ doc.on('update', onDocUpdate);
687
+
688
+ // Wire awareness changes → broadcast. Same origin guard.
689
+ const onAwarenessUpdate = (
690
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
691
+ origin: unknown
692
+ ) => {
693
+ const ws = wsRef;
694
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
695
+ if (origin === ws) return;
696
+ const changed = added.concat(updated, removed);
697
+ broadcastAwareness(ws, changed);
698
+ };
699
+ awareness.on('update', onAwarenessUpdate);
700
+
701
+ session.stop = () => {
702
+ identityCancelled = true;
703
+ cancelled = true;
704
+ if (reconnectTimer) clearTimeout(reconnectTimer);
705
+ doc.off('update', onDocUpdate);
706
+ awareness.off('update', onAwarenessUpdate);
707
+ const ws = wsRef;
708
+ if (ws && ws.readyState === WebSocket.OPEN) {
561
709
  try {
562
- doc.destroy();
710
+ ws.close();
563
711
  } catch {
564
712
  /* ignore */
565
713
  }
566
- },
567
- [doc, awareness]
568
- );
714
+ }
715
+ try {
716
+ awareness.destroy();
717
+ } catch {
718
+ /* ignore */
719
+ }
720
+ try {
721
+ doc.destroy();
722
+ } catch {
723
+ /* ignore */
724
+ }
725
+ };
726
+
727
+ connect();
728
+ return session;
729
+ }
730
+
731
+ function scheduleSessionDestroy(s: CollabSession): void {
732
+ if (s.destroyTimer) return;
733
+ s.destroyTimer = setTimeout(() => {
734
+ getSessions().delete(s.slug);
735
+ s.stop();
736
+ }, SESSION_GRACE_MS);
737
+ }
738
+
739
+ /** Idempotent get-or-create (called in render so children get a live doc on
740
+ * first render). A freshly created session self-destructs after the grace
741
+ * window unless a mount effect retains it — so a thrown-away render can't leak
742
+ * a socket. */
743
+ function peekOrCreateSession(slug: string): CollabSession {
744
+ const sessions = getSessions();
745
+ let s = sessions.get(slug);
746
+ if (!s) {
747
+ s = createSession(slug);
748
+ sessions.set(slug, s);
749
+ scheduleSessionDestroy(s);
750
+ }
751
+ return s;
752
+ }
753
+
754
+ function retainSession(slug: string): CollabSession {
755
+ const s = peekOrCreateSession(slug);
756
+ if (s.destroyTimer) {
757
+ clearTimeout(s.destroyTimer);
758
+ s.destroyTimer = null;
759
+ }
760
+ s.refCount++;
761
+ return s;
762
+ }
763
+
764
+ function releaseSession(slug: string): void {
765
+ const s = getSessions().get(slug);
766
+ if (!s) return;
767
+ s.refCount = Math.max(0, s.refCount - 1);
768
+ if (s.refCount === 0) scheduleSessionDestroy(s);
769
+ }
770
+
771
+ // ─────────────────────────────────────────────────────────────────────────────
772
+ // Provider — thin consumer of the shared session (lifetime owned above).
773
+
774
+ interface CollabProviderProps {
775
+ /** Canvas slug — must match server-side `parseCollabSlug`. */
776
+ slug: string;
777
+ children: ReactNode;
778
+ }
779
+
780
+ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
781
+ // Acquire (get-or-create) for render so children see a live doc immediately;
782
+ // the refcount + teardown lifetime is managed in the effect below so a
783
+ // hot-swap remount reuses the SAME socket (no presence blink — F4).
784
+ const session = peekOrCreateSession(slug);
785
+ const [, forceRender] = useReducer((c: number) => c + 1, 0);
786
+
787
+ useEffect(() => {
788
+ const s = retainSession(slug);
789
+ const listener = () => forceRender();
790
+ s.listeners.add(listener);
791
+ // Identity / connection may have resolved between render and this effect.
792
+ forceRender();
793
+ return () => {
794
+ s.listeners.delete(listener);
795
+ releaseSession(slug);
796
+ };
797
+ }, [slug]);
798
+
799
+ const { doc, awareness, connId: myConnId, name: myName, color: myColor, connected } = session;
569
800
 
570
801
  // ── Throttled awareness publish ─────────────────────────────────────────
571
802
  const pendingRef = useRef<Partial<Omit<CollabAwarenessState, '__connId'>> | null>(null);
@@ -582,18 +813,19 @@ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Ele
582
813
  if (!next) return;
583
814
  const current = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
584
815
  awareness.setLocalState({
585
- name: current.name ?? myName,
586
- color: current.color ?? myColor,
816
+ name: current.name ?? session.name,
817
+ color: current.color ?? session.color,
587
818
  cursor: current.cursor ?? null,
588
819
  selection: current.selection ?? null,
589
820
  annotationSelection: current.annotationSelection ?? [],
590
821
  viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
822
+ editing: current.editing ?? null,
591
823
  __connId: myConnId,
592
824
  ...next,
593
825
  } satisfies CollabAwarenessState);
594
826
  }, AWARENESS_THROTTLE_MS);
595
827
  },
596
- [awareness, myName, myColor, myConnId]
828
+ [awareness, session, myConnId]
597
829
  );
598
830
 
599
831
  // ── Cleanup throttle timer on unmount ───────────────────────────────────