feat(collab): presence P3 — eeschema port (collab-presence 0003)

- eeschema_embind.cpp: presence section (0002 pattern, zero fork changes) —
  wx canvas triggers + SCHEMATIC_LISTENER piggyback → post-settle
  SCH_SELECTION_TOOL emit; throttled cursor; remote VIEW_OVERLAY render
  (SCHEMATIC::ResolveItem, name tags, screen-constant via GAL matrix);
  schCollab{PresenceStart,SetRemote,GetViewport,GetSelection,TestSelectFirst,
  TestClearSelection}; kicad_editor_embind dispatches by active frame.
- sheet-manager: parked rooms carry SKELETON awareness states
  ({user,tool,sheetPath=bound sheet}) via publishSkeletons on switch + late
  warm-up — the bound room's awareness then holds every project peer, so no
  multi-room aggregation; presence.ts publishSkeleton helper.
- PresenceRoster: sheet-aware — peers on another sheet render dimmed with
  'on <sheet>' tooltip; WasmTool un-gates the kicad presence bridge for
  eeschema and threads activeSheetPath.
- tests: presence-eeschema.spec.ts (5 e2e, mirrors pcbnew incl. the px/IU
  band at eeschema's 1e4/mm IU); presence.test.ts +2 (skeleton visibility,
  no ghost cursor after rebind). eeschema-collab/subschema stay green.

Verified live: two tabs on demo.kicad_sch — peer cursor cross + label,
selection box + name tag, roster avatar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
Gergő Törcsvári 2026-07-06 17:05:15 +02:00
commit 6bc54de92b
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
9 changed files with 982 additions and 29 deletions

View file

@ -1,18 +1,43 @@
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.
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
* the room's OTHER users, one colored initial-avatar per person, fed by the
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
* they're on in the tooltip. 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[] }) {
function sheetLabel(sheetPath?: string): string {
if (!sheetPath) return "";
const base = sheetPath.split("/").pop() ?? sheetPath;
return base.replace(/\.kicad_sch$/, "");
}
export function PresenceRoster({
peers,
activeSheetPath,
}: {
peers: PresencePeer[];
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
activeSheetPath?: string;
}) {
if (!peers.length) return null;
const names = peers.map((p) => p.user.name).join(", ");
const shown = peers.slice(0, MAX_AVATARS);
const sameSheet = (p: PresencePeer) =>
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
const names = ordered
.map((p) =>
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
)
.join(", ");
const shown = ordered.slice(0, MAX_AVATARS);
return (
<span
data-testid="presence-roster"
@ -23,9 +48,16 @@ export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
<span
key={p.user.id}
data-presence-user={p.user.id}
title={p.user.name}
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
title={
sameSheet(p)
? p.user.name
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
}
style={{ backgroundColor: p.user.color }}
className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0"
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
sameSheet(p) ? "" : "opacity-40"
}`}
>
{p.user.name.charAt(0).toUpperCase()}
</span>

View file

@ -444,6 +444,9 @@ async function startSheetCollab(
provider: yjsProviderConfig(),
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
onActiveChange: opts.onActiveChange,
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
// any sheet's roster shows the whole schematic's crew (0003).
presenceUser: presenceUser(),
log: opts.log,
initial:
opts.session && opts.targetPath
@ -627,6 +630,9 @@ export function WasmTool({
// 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<PresencePeer[]>([]);
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
// skeleton state says they're on a different sheet (collab-presence 0003).
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
const append = React.useCallback(
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
@ -772,10 +778,11 @@ export function WasmTool({
presenceRef.current = presence;
presence.subscribe(setPeers);
setPeers(presence.peers());
// Canvas presence (0002): cursor + selection emit and the remote
// VIEW_OVERLAY render. pcbnew only until the eeschema port (0003); the
// bridge gate also skips wasm builds predating the presence exports.
if (tool === "pcbnew" && hasPresenceBridge(win.Module)) {
setActiveSheetPath(sheetPath);
// Canvas presence (0002 pcbnew / 0003 eeschema): cursor + selection emit
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
// the exports and wasm builds predating them.
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
presenceBridgeRef.current = bindKicadPresence({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
@ -1099,7 +1106,9 @@ export function WasmTool({
where this project lives / whether Save persists. */}
{ready && (peers.length > 0 || sourceDescriptor) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && <PresenceRoster peers={peers} />}
{peers.length > 0 && (
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
)}
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
</div>
)}

View file

@ -3,7 +3,7 @@ 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";
import { createPresence, publishSkeleton, type PresenceHandle } from "./presence";
/**
* Presence unit tests (collab-presence 0001): two Awareness instances relayed
@ -107,6 +107,86 @@ describe("presence over the BroadcastChannel awareness relay", () => {
expect(a.presence.peers()).toEqual([]);
});
it("skeleton states mark parked-room users with their real sheet (0003)", async () => {
// Two per-sheet rooms; alice is BOUND to root and PARKED in sub, bob is
// bound to sub. Bob's roster (sub room) must show alice as being on root,
// with no cursor/selection.
const rootCh = `presence-test-${channelSeq++}`;
const subCh = `presence-test-${channelSeq++}`;
const aliceRoot = client(rootCh);
const aliceSub = client(subCh);
const bobSub = client(subCh);
aliceRoot.presence = createPresence({
awareness: aliceRoot.awareness,
user: user("alice"),
tool: "eeschema",
sheetPath: "root.kicad_sch",
});
publishSkeleton(aliceSub.awareness, user("alice"), "eeschema", "root.kicad_sch");
bobSub.presence = createPresence({
awareness: bobSub.awareness,
user: user("bob"),
tool: "eeschema",
sheetPath: "sub/child.kicad_sch",
});
await settle();
const aliceSeenByBob = bobSub.presence.peers();
expect(aliceSeenByBob.map((p) => p.user.id)).toEqual(["alice"]);
expect(aliceSeenByBob[0]!.sheetPath).toBe("root.kicad_sch");
expect(aliceSeenByBob[0]!.cursor).toBeNull();
expect(aliceSeenByBob[0]!.selection).toEqual([]);
// Alice navigates into sub: full presence rebinds there (overwriting the
// skeleton) — bob now sees her on HIS sheet, cursor live again.
aliceSub.presence = createPresence({
awareness: aliceSub.awareness,
user: user("alice"),
tool: "eeschema",
sheetPath: "sub/child.kicad_sch",
});
aliceSub.presence.setCursor({ x: 1, y: 2 });
await settle();
const after = bobSub.presence.peers();
expect(after[0]!.sheetPath).toBe("sub/child.kicad_sch");
expect(after[0]!.cursor).toEqual({ x: 1, y: 2 });
});
it("rebind away leaves no ghost cursor in the old room", async () => {
// Alice bound in room1 with a live cursor; she navigates away: presence
// destroy + skeleton must leave sheetPath pointing elsewhere, cursor null.
const ch = `presence-test-${channelSeq++}`;
const alice = client(ch);
const bob = client(ch);
alice.presence = createPresence({
awareness: alice.awareness,
user: user("alice"),
tool: "eeschema",
sheetPath: "root.kicad_sch",
});
alice.presence.setCursor({ x: 9, y: 9 });
bob.presence = createPresence({
awareness: bob.awareness,
user: user("bob"),
tool: "eeschema",
sheetPath: "root.kicad_sch",
});
await settle();
expect(bob.presence.peers()[0]!.cursor).toEqual({ x: 9, y: 9 });
alice.presence.destroy();
alice.presence = undefined;
publishSkeleton(alice.awareness, user("alice"), "eeschema", "sub/child.kicad_sch");
await settle();
const ghost = bob.presence.peers();
expect(ghost.map((p) => p.user.id)).toEqual(["alice"]);
expect(ghost[0]!.cursor).toBeNull();
expect(ghost[0]!.sheetPath).toBe("sub/child.kicad_sch");
});
it("subscribe fires on change and setters keep sibling fields intact", async () => {
const channel = `presence-test-${channelSeq++}`;
const a = client(channel);

View file

@ -40,6 +40,30 @@ export interface PresenceHandle {
destroy(): void;
}
/**
* Publish a SKELETON presence state into a room the user is connected to but
* not looking at (eeschema warm pool, collab-presence 0003): identity + which
* sheet they are actually on, no cursor/selection. Any sheet's roster can then
* answer "who is in this schematic, and where". The bound room's full state is
* owned by `createPresence` (which overwrites the skeleton on rebind).
*/
export function publishSkeleton(
awareness: Awareness,
user: PresenceUser,
tool: string,
sheetPath: string,
): void {
const state: PresenceState = {
user,
tool,
sheetPath,
cursor: null,
selection: [],
updatedAt: Date.now(),
};
awareness.setLocalState(state);
}
export function createPresence(opts: {
awareness: Awareness;
user: PresenceUser;

View file

@ -1,6 +1,13 @@
import type * as Y from "yjs";
import { collabRoomId, fileToDoc, syncLayoutToY, type KicadDoc } from "@pcbjam/shared";
import {
collabRoomId,
fileToDoc,
syncLayoutToY,
type KicadDoc,
type PresenceUser,
} from "@pcbjam/shared";
import { connectKicadDoc, type KicadDocSession } from "./index";
import { publishSkeleton } from "./presence";
import {
bindKicadCollab,
moduleItemsBridge,
@ -79,6 +86,15 @@ export interface SheetManagerOptions {
* (re)start drift detection on the now-active doc.
*/
onActiveChange?: (active: ActiveSheet | null) => void;
/**
* Presence identity (collab-presence 0003). When set, every PARKED room in the
* warm pool carries a skeleton awareness state ({user, tool, sheetPath: the
* sheet the user is ACTUALLY on}) so any sheet's roster can answer "who is in
* this schematic, and where". The BOUND room's full presence (cursor/selection)
* is owned by the host via onActiveChange → createPresence, which overwrites
* the skeleton on rebind.
*/
presenceUser?: PresenceUser;
log: (m: string) => void;
/**
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
@ -132,6 +148,19 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
});
}
// Skeleton presence for every PARKED room (0003): mark this user as "in this
// schematic, on `activePath`". The bound room is skipped — its full state is
// published by the host's presence handle (rebound via onActiveChange).
function publishSkeletons(): void {
const user = opts.presenceUser;
if (!user || !activePath) return;
for (const [path, room] of rooms) {
if (path === activePath) continue;
const awareness = room.session.provider.awareness;
if (awareness) publishSkeleton(awareness, user, "eeschema", activePath);
}
}
async function ensureRoom(sheetPath: string): Promise<Room> {
const existing = rooms.get(sheetPath);
if (existing) return existing;
@ -152,6 +181,14 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
};
rooms.set(sheetPath, room);
log(`[sheet] warm room connected: ${sheetPath}`);
// A room warmed after the first bind starts parked — give it a skeleton
// right away so its roster shows this user without waiting for a switch.
if (activePath && sheetPath !== activePath && opts.presenceUser) {
const awareness = session.provider.awareness;
if (awareness) {
publishSkeleton(awareness, opts.presenceUser, "eeschema", activePath);
}
}
return room;
})();
@ -219,6 +256,10 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
activePath = sheetPath;
opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider });
// AFTER the host rebound its full presence to the new room: refresh every
// parked room's skeleton to point at the new sheet (incl. the old active
// room, whose full state the host just cleared).
publishSkeletons();
}
function switchTo(sheetPath: string): Promise<void> {