@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.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- 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 →
|
|
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.
|
|
45
|
-
*
|
|
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
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
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
|
-
//
|
|
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
|
-
|
|
363
|
-
|
|
456
|
+
const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
|
|
457
|
+
|
|
458
|
+
interface CollabSession {
|
|
364
459
|
slug: string;
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
412
|
-
color
|
|
413
|
-
cursor: null,
|
|
414
|
-
selection: null,
|
|
415
|
-
annotationSelection: [],
|
|
416
|
-
viewport: { x: 0, y: 0, zoom: 1 },
|
|
417
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
+
writeSyncStep1(encoder, doc);
|
|
448
632
|
sendFrame(ws, encoding.toUint8Array(encoder));
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
};
|
|
551
|
-
}, [slug, doc, awareness]);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
552
677
|
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
710
|
+
ws.close();
|
|
563
711
|
} catch {
|
|
564
712
|
/* ignore */
|
|
565
713
|
}
|
|
566
|
-
}
|
|
567
|
-
|
|
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 ??
|
|
586
|
-
color: current.color ??
|
|
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,
|
|
828
|
+
[awareness, session, myConnId]
|
|
597
829
|
);
|
|
598
830
|
|
|
599
831
|
// ── Cleanup throttle timer on unmount ───────────────────────────────────
|