diff --git a/tests/web/presence-roster.spec.ts b/tests/web/presence-roster.spec.ts new file mode 100644 index 0000000..65658de --- /dev/null +++ b/tests/web/presence-roster.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Presence roster e2e (collab-presence 0001): two tabs of the real React app on + * the SAME project file share one collab room over the default BroadcastChannel + * provider — each tab's PresenceRoster chip must show the OTHER user, and a tab + * leaving must drop off the peer's roster promptly (pagehide broadcast, not the + * 30s awareness timeout). + * + * Uses pl_editor (demo.kicad_wks): it is in COLLAB_TOOLS and is the smallest + * wasm, so the two boots stay cheap. Identity comes from the `?user=` slug + * (the e2e isolation hook config.ts already honors). + * + * Provider-agnostic: BC (default checkout) removes instantly via the pagehide + * broadcast; partykit removes when the server sees the WS close — measured + * ~5-8s under local wrangler dev (immediate close on real tab close; an + * about:blank navigation can BFCache the page and stretch this to ~15s, which + * is why the leave step CLOSES the tab). + */ + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_wks'; +const TITLE = /demo — Drawing Sheet Editor/i; + +async function bootAs(page: Page, user: string): Promise { + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { + message: `${user}: editor never reached the expected title`, + timeout: 120000, + intervals: [1000], + }) + .toMatch(TITLE); +} + +test('two tabs on one file see each other in the roster; leaving removes promptly', async ({ + page, + context, +}) => { + test.setTimeout(360000); // two full tool boots + + await bootAs(page, 'alice'); + + // Before anyone else joins, alice has no roster chip at all. + await expect(page.getByTestId('presence-roster')).toHaveCount(0); + + const pageB = await context.newPage(); + await bootAs(pageB, 'bob'); + + // Each side shows the other user (avatar keyed by slug), not itself. + await expect(page.locator('[data-presence-user="bob"]')).toBeVisible({ timeout: 30000 }); + await expect(pageB.locator('[data-presence-user="alice"]')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('[data-presence-user="alice"]')).toHaveCount(0); + await expect(pageB.locator('[data-presence-user="bob"]')).toHaveCount(0); + + // bob leaves (tab close → BC pagehide broadcast / partykit WS close): alice's + // roster must clear well inside the 30s awareness timeout. + await pageB.close(); + await expect(page.getByTestId('presence-roster')).toHaveCount(0, { timeout: 20000 }); +}); diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 2387cec..a99d1df 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 2387cecba80221e463fde02636d9ce4c3c24a044 +Subproject commit a99d1dfff92d1e8e7a77d39dc920c4db7e955208 diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 64bf70d..7b93149 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: y-partyserver: specifier: ^2.2.0 version: 2.2.0(@cloudflare/workers-types@4.20260610.1)(partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1))(yjs@13.6.31) + y-protocols: + specifier: ^1.0.6 + version: 1.0.7(yjs@13.6.31) yjs: specifier: ^13.6.18 version: 13.6.31 diff --git a/web/standalone/package.json b/web/standalone/package.json index 519365a..1b163d9 100644 --- a/web/standalone/package.json +++ b/web/standalone/package.json @@ -31,6 +31,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "y-partyserver": "^2.2.0", + "y-protocols": "^1.0.6", "yjs": "^13.6.18", "zod": "^3.24.1" }, diff --git a/web/standalone/src/components/PresenceRoster.tsx b/web/standalone/src/components/PresenceRoster.tsx new file mode 100644 index 0000000..899fcb1 --- /dev/null +++ b/web/standalone/src/components/PresenceRoster.tsx @@ -0,0 +1,40 @@ +import type { PresencePeer } from "@/wasm/collab/presence"; + +/** + * "Who else is in this file" (collab-presence 0001): a compact facepile of the + * room's OTHER users, one colored initial-avatar per person, fed by the collab + * session's awareness. Rendered in the editor's top-right overlay stack next to + * SourceChip; the parent hides it when there are no peers. Chip styling mirrors + * SourceChip (solid fill + inset ring) so it is legible on any backdrop. + */ +const MAX_AVATARS = 5; + +export function PresenceRoster({ peers }: { peers: PresencePeer[] }) { + if (!peers.length) return null; + const names = peers.map((p) => p.user.name).join(", "); + const shown = peers.slice(0, MAX_AVATARS); + return ( + + {shown.map((p) => ( + + {p.user.name.charAt(0).toUpperCase()} + + ))} + {peers.length > MAX_AVATARS && ( + + +{peers.length - MAX_AVATARS} + + )} + + ); +} diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index c84d1bd..05f1c08 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -19,6 +19,7 @@ import { currentScope, libsSourceConfig, modelsSourceConfig, + presenceUser, yjsProviderConfig, type DocSource, } from "@/lib/config"; @@ -44,11 +45,19 @@ import type { KicadCollabHandle, KicadDocSession, KicadItemsWindow, + YjsProvider, } from "@/wasm/collab"; +import { + createPresence, + type PresenceHandle, + type PresencePeer, +} from "@/wasm/collab/presence"; +import { PresenceRoster } from "@/components/PresenceRoster"; import { createSheetCollabManager, registerSheetChangedHook, registerSheetCreatedHook, + type ActiveSheet, type SheetChangedWindow, type SheetCollabManager, type SheetCreatedWindow, @@ -400,7 +409,7 @@ async function startSheetCollab( session?: KicadDocSession; /** The entry file was materialized from `session`'s doc (baseline-only first seed). */ editorMatchesDoc?: boolean; - onActiveChange: (active: { sheetPath: string; doc: Y.Doc } | null) => void; + onActiveChange: (active: ActiveSheet | null) => void; /** Upload sink (project-backed sessions) — used to register a just-created subsheet. */ saveBytes?: SaveBytes; log: (m: string) => void; @@ -570,6 +579,7 @@ export function WasmTool({ const containerRef = React.useRef(null); const startedRef = React.useRef(false); const driftRef = React.useRef<{ stop(): void } | null>(null); + const presenceRef = React.useRef(null); const sheetManagerRef = React.useRef(null); // The single-room collab doc (pcbnew/pl_editor), for the layout save-sync // (miss 08B); eeschema routes per sheet through the manager instead. @@ -607,6 +617,10 @@ export function WasmTool({ // Board 3D-model prefetch in flight (background; the viewer works without it — // anything still missing lazy-loads per model). Small badge, not an overlay. const [modelsSync, setModelsSync] = React.useState(null); + // The OTHER users in this document's collab room (awareness roster) — drives + // the PresenceRoster chip next to SourceChip. Empty when collab is off, the + // provider has no awareness (kind "none"), or nobody else is here. + const [peers, setPeers] = React.useState([]); const append = React.useCallback( (msg: string) => setLogs((prev) => [...prev.slice(-800), msg]), @@ -729,6 +743,29 @@ export function WasmTool({ const { proceed } = oom.start(); if (!proceed) return; + // (Re)bind presence to a collab room's awareness (collab-presence 0001): + // publish this user's identity and mirror the peers into the roster chip. + // pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the + // roster shows who is on the SAME sheet (room = sheet). + const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => { + presenceRef.current?.destroy(); + presenceRef.current = null; + const awareness = provider?.awareness; + if (!awareness) { + setPeers([]); + return; + } + const presence = createPresence({ + awareness, + user: presenceUser(), + tool, + sheetPath, + }); + presenceRef.current = presence; + presence.subscribe(setPeers); + setPeers(presence.peers()); + }; + // Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the // browser's "save page" dialog (observed in Firefox) — the keydown still // propagates to the wx canvas handler, which performs the actual save. @@ -854,10 +891,11 @@ export function WasmTool({ session, saveBytes, editorMatchesDoc: !!targetBytes, - // Re-point drift detection at whichever sheet is currently bound. + // Re-point drift detection + presence at whichever sheet is bound. onActiveChange: (activeRoom) => { driftRef.current?.stop(); driftRef.current = null; + startPresence(activeRoom?.provider, activeRoom?.sheetPath); if (activeRoom) { driftRef.current = startDriftDetection({ doc: activeRoom.doc, @@ -885,6 +923,7 @@ export function WasmTool({ onStatus: setStatus, }); collabDocRef.current = collabHandle?.doc ?? null; + startPresence(collabHandle?.provider); if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) { driftRef.current = startDriftDetection({ doc: collabHandle.doc, @@ -910,6 +949,8 @@ export function WasmTool({ return () => { win.removeEventListener("keydown", swallowBrowserSave, true); + presenceRef.current?.destroy(); + presenceRef.current = null; driftRef.current?.stop(); driftRef.current = null; // Tears down every warm room's provider/doc (the only place providers are @@ -1034,10 +1075,12 @@ export function WasmTool({ )} - {/* Where this project lives + whether Save persists (top-right). */} - {ready && sourceDescriptor && ( -
- + {/* Top-right overlay chips: who else is in this file (awareness roster) + + where this project lives / whether Save persists. */} + {ready && (peers.length > 0 || sourceDescriptor) && ( +
+ {peers.length > 0 && } + {sourceDescriptor && }
)} diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index c651d5f..26745eb 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -98,6 +98,7 @@ export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL || export const LOCAL_PROJECTS_ENABLED = import.meta.env.VITE_LOCAL_PROJECTS === "idb"; +import { colorForUser, type PresenceUser } from "@pcbjam/shared"; import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; import { cdnLibsSource } from "@/wasm/libs/cdn-source"; import { cdnModelsSource, type Model3dSource } from "@/wasm/libs/models-source"; @@ -169,6 +170,17 @@ export function userSlug(): string { ); } +/** + * The local user's presence identity (collab-presence 0001): the pre-auth slug + * doubles as id + display name, color is the deterministic palette hash — so + * every peer computes the same identity for this user with no coordination. + * Real auth/avatars later replace only how this object is built. + */ +export function presenceUser(): PresenceUser { + const slug = userSlug(); + return { id: slug, name: slug, color: colorForUser(slug) }; +} + /** * The active scope (first URL segment) for API calls. Mirrors how `userSlug()` * reads the URL, so the source layer scopes requests without threading scope diff --git a/web/standalone/src/wasm/collab/awareness-bc.ts b/web/standalone/src/wasm/collab/awareness-bc.ts new file mode 100644 index 0000000..8c15b2a --- /dev/null +++ b/web/standalone/src/wasm/collab/awareness-bc.ts @@ -0,0 +1,76 @@ +import { + applyAwarenessUpdate, + encodeAwarenessUpdate, + removeAwarenessStates, + type Awareness, +} from "y-protocols/awareness"; +import { clog } from "./debug"; + +/** + * BroadcastChannel relay for a Yjs Awareness instance (collab-presence 0001): + * the network providers (partykit/hocuspocus) multiplex awareness over their + * own websocket, but the BroadcastChannel doc transport carries doc updates + * only — this ferries the awareness sidecar between same-origin tabs so + * presence works in the default zero-backend setup too. + * + * Same shape as `broadcast-transport.ts`: relay local update events out, apply + * incoming ones with a marker origin so they don't echo back, and query peers + * on join so a late tab sees existing states immediately. The Awareness + * heartbeat (periodic local-clock bump) rides the same update event, so remote + * expiry (~30s of silence) works unchanged. + */ + +type Msg = { t: "aw"; u: Uint8Array } | { t: "aw-query" }; + +const BC_REMOTE_ORIGIN = "aw-bc-remote"; + +export function connectAwarenessBroadcast( + awareness: Awareness, + channelName: string, +): { destroy(): void } { + const bc = new BroadcastChannel(channelName); + clog("awareness BC open:", channelName); + + const onUpdate = ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => { + if (origin === BC_REMOTE_ORIGIN) return; // don't echo applied remote updates + const changed = added.concat(updated, removed); + if (!changed.length) return; + bc.postMessage({ t: "aw", u: encodeAwarenessUpdate(awareness, changed) } satisfies Msg); + }; + awareness.on("update", onUpdate); + + bc.onmessage = (e: MessageEvent) => { + const msg = e.data; + if (!msg) return; + switch (msg.t) { + case "aw": + applyAwarenessUpdate(awareness, new Uint8Array(msg.u), BC_REMOTE_ORIGIN); + break; + case "aw-query": + if (awareness.getLocalState() !== null) { + bc.postMessage({ + t: "aw", + u: encodeAwarenessUpdate(awareness, [awareness.clientID]), + } satisfies Msg); + } + break; + } + }; + + // Ask existing tabs for their states (they answer with their own entry only; + // every tab is on the channel, so the union covers the room). + bc.postMessage({ t: "aw-query" } satisfies Msg); + + return { + destroy: () => { + // Broadcast our removal BEFORE detaching, so peers drop us immediately + // instead of waiting out the awareness timeout. + removeAwarenessStates(awareness, [awareness.clientID], "destroy"); + awareness.off("update", onUpdate); + bc.close(); + }, + }; +} diff --git a/web/standalone/src/wasm/collab/presence.test.ts b/web/standalone/src/wasm/collab/presence.test.ts new file mode 100644 index 0000000..c8740e7 --- /dev/null +++ b/web/standalone/src/wasm/collab/presence.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness"; +import { colorForUser, type PresenceUser } from "@pcbjam/shared"; +import { connectAwarenessBroadcast } from "./awareness-bc"; +import { createPresence, type PresenceHandle } from "./presence"; + +/** + * Presence unit tests (collab-presence 0001): two Awareness instances relayed + * over a real BroadcastChannel (node ≥18 has it globally) stand in for two + * tabs. BC delivery is async — `settle()` lets a posted message round-trip. + */ + +const settle = () => new Promise((r) => setTimeout(r, 50)); + +function user(id: string): PresenceUser { + return { id, name: id, color: colorForUser(id) }; +} + +interface Client { + awareness: Awareness; + presence?: PresenceHandle; + destroy(): void; +} + +let clients: Client[] = []; +let channelSeq = 0; + +function client(channel: string): Client { + const doc = new Y.Doc(); + const awareness = new Awareness(doc); + const relay = connectAwarenessBroadcast(awareness, channel); + const c: Client = { + awareness, + destroy() { + this.presence?.destroy(); + relay.destroy(); + awareness.destroy(); + doc.destroy(); + }, + }; + clients.push(c); + return c; +} + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +describe("presence over the BroadcastChannel awareness relay", () => { + it("peers see each other's state; destroy removes it immediately", async () => { + const channel = `presence-test-${channelSeq++}`; + const a = client(channel); + const b = client(channel); + + a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" }); + b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" }); + await settle(); + + expect(a.presence.peers().map((p) => p.user.id)).toEqual(["bob"]); + expect(b.presence.peers().map((p) => p.user.id)).toEqual(["alice"]); + expect(a.presence.peers()[0]!.cursor).toBeNull(); + + // destroy() nulls the local state → relayed → bob leaves alice's roster + // without waiting for the awareness timeout. + b.presence.destroy(); + b.presence = undefined; + await settle(); + expect(a.presence.peers()).toEqual([]); + }); + + it("a late-joining tab sees existing peers (join query)", async () => { + const channel = `presence-test-${channelSeq++}`; + const a = client(channel); + a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" }); + await settle(); + + const b = client(channel); + b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" }); + await settle(); + expect(b.presence.peers().map((p) => p.user.id)).toEqual(["alice"]); + }); + + it("dedupes the same user across tabs and hides own slug", async () => { + const channel = `presence-test-${channelSeq++}`; + const a1 = client(channel); + const a2 = client(channel); + const b = client(channel); + a1.presence = createPresence({ awareness: a1.awareness, user: user("alice"), tool: "pcbnew" }); + a2.presence = createPresence({ awareness: a2.awareness, user: user("alice"), tool: "pcbnew" }); + b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" }); + await settle(); + + // bob sees ONE alice (two tabs, one person); alice's tabs don't see each other. + expect(b.presence.peers().map((p) => p.user.id)).toEqual(["alice"]); + expect(a1.presence.peers().map((p) => p.user.id)).toEqual(["bob"]); + }); + + it("drops malformed peer states instead of crashing the roster", async () => { + const channel = `presence-test-${channelSeq++}`; + const a = client(channel); + const b = client(channel); + a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" }); + b.awareness.setLocalState({ some: "legacy-shape" }); + await settle(); + expect(a.presence.peers()).toEqual([]); + }); + + it("subscribe fires on change and setters keep sibling fields intact", async () => { + const channel = `presence-test-${channelSeq++}`; + const a = client(channel); + const b = client(channel); + a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" }); + b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" }); + await settle(); + + let latest: string[] | null = null; + const unsub = a.presence.subscribe((peers) => { + latest = peers.map((p) => `${p.user.id}:${p.selection.join(",")}`); + }); + + b.presence.setSelection(["uuid-1"]); + b.presence.setCursor({ x: 5, y: 7 }); + await settle(); + + expect(latest).toEqual(["bob:uuid-1"]); + const bob = a.presence.peers()[0]!; + // setCursor must not have clobbered the selection set just before it. + expect(bob.selection).toEqual(["uuid-1"]); + expect(bob.cursor).toEqual({ x: 5, y: 7 }); + expect(bob.user.color).toBe(colorForUser("bob")); + unsub(); + }); +}); diff --git a/web/standalone/src/wasm/collab/presence.ts b/web/standalone/src/wasm/collab/presence.ts new file mode 100644 index 0000000..5f24e01 --- /dev/null +++ b/web/standalone/src/wasm/collab/presence.ts @@ -0,0 +1,122 @@ +import type { Awareness } from "y-protocols/awareness"; +import { + presenceStateSchema, + type PresenceState, + type PresenceUser, +} from "@pcbjam/shared"; +import { clog } from "./debug"; + +/** + * Room presence over Yjs awareness (collab-presence 0001): publish this + * client's `PresenceState` into the room and expose the peers' states as a + * subscribable roster. Ephemeral by design — the provider relays awareness, + * nothing is persisted, and peers expire a silent client automatically. + * + * One handle per bound room. eeschema rebinds on sheet navigation (the sheet + * manager tears this down and creates one on the new room), so a peer's + * roster always reflects the room they'd collide with. + */ + +/** A peer's validated presence state plus its awareness clientID. */ +export interface PresencePeer extends PresenceState { + clientId: number; +} + +export interface PresenceHandle { + /** + * The OTHER users in the room: own client excluded, entries that fail the + * wire schema dropped (older builds), and one entry per `user.id` (the same + * user in two tabs is one person — keep the freshest state). + */ + peers(): PresencePeer[]; + /** Fires with the new roster on every awareness change. Returns unsubscribe. */ + subscribe(cb: (peers: PresencePeer[]) => void): () => void; + /** 0002: publish the local pointer's world position (null = off canvas). */ + setCursor(pos: { x: number; y: number } | null): void; + /** 0002: publish the local selection's KIID strings. */ + setSelection(uuids: string[]): void; + /** Clear the local state and detach. The awareness instance itself belongs + * to the provider and stays alive (rebind re-uses it). */ + destroy(): void; +} + +export function createPresence(opts: { + awareness: Awareness; + user: PresenceUser; + tool: string; + sheetPath?: string; +}): PresenceHandle { + const { awareness, user } = opts; + + const patch = (fields: Partial) => { + const current = (awareness.getLocalState() ?? {}) as Partial; + awareness.setLocalState({ ...current, ...fields, updatedAt: Date.now() }); + }; + + patch({ + user, + tool: opts.tool, + ...(opts.sheetPath !== undefined ? { sheetPath: opts.sheetPath } : {}), + cursor: null, + selection: [], + }); + clog("presence: published local state for", user.id, "tool", opts.tool); + + function peers(): PresencePeer[] { + const byUser = new Map(); + for (const [clientId, raw] of awareness.getStates()) { + if (clientId === awareness.clientID) continue; + const parsed = presenceStateSchema.safeParse(raw); + if (!parsed.success) continue; + const peer: PresencePeer = { ...parsed.data, clientId }; + // Another tab of the SAME user isn't a peer — the roster shows other people. + if (peer.user.id === user.id) continue; + const existing = byUser.get(peer.user.id); + if (!existing || peer.updatedAt > existing.updatedAt) { + byUser.set(peer.user.id, peer); + } + } + return [...byUser.values()].sort((a, b) => a.user.id.localeCompare(b.user.id)); + } + + const subscribers = new Set<(peers: PresencePeer[]) => void>(); + const onChange = () => { + if (!subscribers.size) return; + const snapshot = peers(); + for (const cb of subscribers) cb(snapshot); + }; + awareness.on("change", onChange); + + // Best-effort fast removal on tab close/navigation: broadcasting the null + // state lets peers drop us immediately instead of waiting out the awareness + // timeout. Guarded — unit tests run in node. + const onPageHide = () => awareness.setLocalState(null); + if (typeof window !== "undefined") { + window.addEventListener("pagehide", onPageHide); + } + + let destroyed = false; + return { + peers, + subscribe(cb) { + subscribers.add(cb); + return () => subscribers.delete(cb); + }, + setCursor(pos) { + patch({ cursor: pos }); + }, + setSelection(uuids) { + patch({ selection: uuids }); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (typeof window !== "undefined") { + window.removeEventListener("pagehide", onPageHide); + } + awareness.off("change", onChange); + subscribers.clear(); + awareness.setLocalState(null); + }, + }; +} diff --git a/web/standalone/src/wasm/collab/provider.ts b/web/standalone/src/wasm/collab/provider.ts index b07cf4f..42b6f45 100644 --- a/web/standalone/src/wasm/collab/provider.ts +++ b/web/standalone/src/wasm/collab/provider.ts @@ -1,5 +1,7 @@ import type * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness"; import { connectBroadcastChannel } from "./broadcast-transport"; +import { connectAwarenessBroadcast } from "./awareness-bc"; /** * A Y.Doc transport, normalized across backends. The reconciler/seed logic only @@ -18,6 +20,13 @@ export interface YjsProvider { */ whenSynced(): Promise; destroy(): void; + /** + * The room's Yjs awareness sidecar (presence: roster/cursor/selection — + * collab-presence 0001). Network providers surface their own instance (the + * server relays it); the BroadcastChannel provider ferries one over a + * sibling channel. Absent for `none` — presence UI then renders nothing. + */ + awareness?: Awareness; } export type ProviderKind = @@ -59,11 +68,19 @@ function broadcastChannelProvider( settleMs: number, ): YjsProvider { const transport = connectBroadcastChannel(doc, room); + // Presence sidecar over its own channel — the doc transport stays untouched. + const awareness = new Awareness(doc); + const awarenessRelay = connectAwarenessBroadcast(awareness, `${room}:awareness`); return { // No server to sync with — keep the original seed-once heuristic: give a // peer tab a moment to answer the state query before deciding seed/adopt. whenSynced: () => new Promise((resolve) => setTimeout(resolve, settleMs)), - destroy: () => transport.destroy(), + destroy: () => { + awarenessRelay.destroy(); + awareness.destroy(); + transport.destroy(); + }, + awareness, }; } @@ -92,6 +109,9 @@ async function partyKitProvider( provider.on("sync", onSync); }), destroy: () => provider.destroy(), + // y-partyserver's provider carries its own awareness; the BoardRoom DO + // (YServer) relays awareness messages as part of the protocol. + awareness: provider.awareness, }; } @@ -119,6 +139,7 @@ async function hocuspocusProvider( provider.on("synced", onSync); }), destroy: () => provider.destroy(), + awareness: provider.awareness ?? undefined, }; } diff --git a/web/standalone/src/wasm/collab/sheet-manager.ts b/web/standalone/src/wasm/collab/sheet-manager.ts index 0d45411..1b73d15 100644 --- a/web/standalone/src/wasm/collab/sheet-manager.ts +++ b/web/standalone/src/wasm/collab/sheet-manager.ts @@ -8,7 +8,7 @@ import { type KicadItemsModule, type KicadItemsWindow, } from "./kicad-binding"; -import type { ProviderConfig } from "./provider"; +import type { ProviderConfig, YjsProvider } from "./provider"; import { clog, cwarn } from "./debug"; /** @@ -46,12 +46,20 @@ export interface SheetCollabManager { * otherwise only carries them from seed time. No-op for unknown sheets. */ syncLayoutFromSave(sheetPath: string, fileText: string): void; - /** The currently-bound sheet, for drift-detection wiring (null before first switch). */ - active(): { sheetPath: string; doc: Y.Doc } | null; + /** The currently-bound sheet, for drift-detection + presence wiring (null + * before first switch). `provider` carries the room's awareness. */ + active(): ActiveSheet | null; /** Tear down ALL bindings + providers + docs (session end / unmount). */ destroy(): void; } +/** The bound sheet's room: its doc (drift detection) + provider (presence). */ +export interface ActiveSheet { + sheetPath: string; + doc: Y.Doc; + provider: YjsProvider; +} + export interface SheetManagerOptions { /** The Emscripten Module exposing the v2 items bridge exports. */ mod: KicadItemsModule; @@ -70,7 +78,7 @@ export interface SheetManagerOptions { * Called whenever the active sheet changes (or clears on destroy) so the host can * (re)start drift detection on the now-active doc. */ - onActiveChange?: (active: { sheetPath: string; doc: Y.Doc } | null) => void; + onActiveChange?: (active: ActiveSheet | null) => void; log: (m: string) => void; /** * `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly @@ -210,7 +218,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab room.dirty = false; room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed activePath = sheetPath; - opts.onActiveChange?.({ sheetPath, doc: room.doc }); + opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider }); } function switchTo(sheetPath: string): Promise { @@ -282,10 +290,11 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab ); } - function active(): { sheetPath: string; doc: Y.Doc } | null { + function active(): ActiveSheet | null { if (!activePath) return null; const room = rooms.get(activePath); - return room ? { sheetPath: activePath, doc: room.doc } : null; + if (!room) return null; + return { sheetPath: activePath, doc: room.doc, provider: room.session.provider }; } function destroy(): void {