collab-presence 0009 C: seed live color claims from comment authors
createPresence accepts a lazy seedColors map (the doc's comment authors): claims prefer your own comment slot, lowestFreeColor counts authors' slots as used, colorOf answers offline authors from the seed before the hash. Comments' own fallback chain gains the seed for presence-less binds. WasmTool wires the bound doc into both presence paths (pcbnew + eeschema per-sheet rebinds). Bumps pcbjam-shared (commentAuthorColors). +4 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM
This commit is contained in:
parent
6675c1f0fc
commit
250dc3dbae
5 changed files with 130 additions and 11 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 8bcb8d6ac19b2199f31477a7313c968a3c048762
|
||||
Subproject commit 7b7527ba5d77845144422b5216e531513e92a648
|
||||
|
|
@ -2,6 +2,7 @@ import * as React from "react";
|
|||
import {
|
||||
collabRoomId,
|
||||
docToFile,
|
||||
commentAuthorColors,
|
||||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
fileToDoc,
|
||||
|
|
@ -1076,7 +1077,11 @@ export function WasmTool({
|
|||
// 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) => {
|
||||
const startPresence = (
|
||||
provider: YjsProvider | undefined,
|
||||
sheetPath?: string,
|
||||
doc?: import("yjs").Doc,
|
||||
) => {
|
||||
// Invisible observer (read-only-viewer): never bind presence — no roster,
|
||||
// no cursor/selection emit, no awareness state (peers stays empty).
|
||||
if (readOnly) return;
|
||||
|
|
@ -1097,6 +1102,9 @@ export function WasmTool({
|
|||
user: presenceUser(),
|
||||
tool,
|
||||
sheetPath,
|
||||
// Round-robin colors seeded by the doc's comment authors (0009 C):
|
||||
// claims avoid their slots, an author rejoining adopts their own.
|
||||
...(doc ? { seedColors: () => commentAuthorColors(doc) } : {}),
|
||||
});
|
||||
presenceRef.current = presence;
|
||||
presence.subscribe(setPeers);
|
||||
|
|
@ -1381,7 +1389,7 @@ export function WasmTool({
|
|||
onActiveChange: (activeRoom) => {
|
||||
driftRef.current?.stop();
|
||||
driftRef.current = null;
|
||||
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
|
||||
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
|
||||
startComments(activeRoom?.doc);
|
||||
if (activeRoom && !readOnly) {
|
||||
driftRef.current = startDriftDetection({
|
||||
|
|
@ -1412,7 +1420,7 @@ export function WasmTool({
|
|||
onStatus: setStatus,
|
||||
});
|
||||
collabDocRef.current = collabHandle?.doc ?? null;
|
||||
startPresence(collabHandle?.provider);
|
||||
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
||||
startComments(collabHandle?.doc);
|
||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
|
||||
driftRef.current = startDriftDetection({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
addMessage,
|
||||
args,
|
||||
colorForUser,
|
||||
commentAuthorColors,
|
||||
createThread,
|
||||
deleteThread,
|
||||
editMessage,
|
||||
|
|
@ -122,7 +123,12 @@ export function createComments(opts: {
|
|||
}): CommentsController {
|
||||
const { doc, mod, user } = opts;
|
||||
const iuPerMm = IU_PER_MM[opts.tool] ?? 1e6;
|
||||
const colorFor = (userId: string) => opts.colorFor?.(userId) ?? colorForUser(userId);
|
||||
// Fallback chain: live presence (nth-in-room) → the doc's comment-author
|
||||
// slot (0009 C — presence-less binds still color deterministically) → hash.
|
||||
const colorFor = (userId: string) =>
|
||||
opts.colorFor?.(userId) ??
|
||||
commentAuthorColors(doc).get(userId) ??
|
||||
colorForUser(userId);
|
||||
|
||||
let cache: ResolvedThread[] = [];
|
||||
let visible = true;
|
||||
|
|
|
|||
|
|
@ -289,3 +289,80 @@ describe("presence over the BroadcastChannel awareness relay", () => {
|
|||
expect(fromBob[0]!.user.color).toBe(PRESENCE_COLORS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 0009 C — comment-author seeded colors: the doc's existing comment authors
|
||||
* hold palette slots (deterministic first-appearance order), live claims
|
||||
* avoid them, an author rejoining adopts their own slot, and colorOf answers
|
||||
* offline authors from the seed instead of the hash.
|
||||
*/
|
||||
describe("comment-author color seeds (0009 C)", () => {
|
||||
const seedsOf = (entries: Array<[string, string]>) => () => new Map(entries);
|
||||
|
||||
it("a joining user claims around the seeded authors' slots", async () => {
|
||||
const channel = `presence-test-${channelSeq++}`;
|
||||
const a = client(channel);
|
||||
a.presence = createPresence({
|
||||
awareness: a.awareness,
|
||||
user: user("alice"),
|
||||
tool: "pcbnew",
|
||||
seedColors: seedsOf([["old-author", PRESENCE_COLORS[0]!]]),
|
||||
});
|
||||
await settle();
|
||||
// Slot 0 belongs to old-author's pins → alice takes slot 1.
|
||||
expect(a.presence.colorOf("alice")).toBe(PRESENCE_COLORS[1]);
|
||||
});
|
||||
|
||||
it("an author rejoining adopts their own comment slot", async () => {
|
||||
const channel = `presence-test-${channelSeq++}`;
|
||||
const a = client(channel);
|
||||
a.presence = createPresence({
|
||||
awareness: a.awareness,
|
||||
user: user("alice"),
|
||||
tool: "pcbnew",
|
||||
seedColors: seedsOf([
|
||||
["bob", PRESENCE_COLORS[0]!],
|
||||
["alice", PRESENCE_COLORS[1]!],
|
||||
]),
|
||||
});
|
||||
await settle();
|
||||
expect(a.presence.colorOf("alice")).toBe(PRESENCE_COLORS[1]);
|
||||
});
|
||||
|
||||
it("colorOf answers offline authors from the seed, not the hash", async () => {
|
||||
const channel = `presence-test-${channelSeq++}`;
|
||||
const a = client(channel);
|
||||
a.presence = createPresence({
|
||||
awareness: a.awareness,
|
||||
user: user("alice"),
|
||||
tool: "pcbnew",
|
||||
seedColors: seedsOf([["offline-bob", PRESENCE_COLORS[3]!]]),
|
||||
});
|
||||
await settle();
|
||||
expect(a.presence.colorOf("offline-bob")).toBe(PRESENCE_COLORS[3]);
|
||||
// A user with neither presence nor comments still hash-falls-back.
|
||||
expect(a.presence.colorOf("stranger")).toBe(colorForUser("stranger"));
|
||||
});
|
||||
|
||||
it("a live peer's published color beats the seed", 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);
|
||||
await settle();
|
||||
b.presence = createPresence({
|
||||
awareness: b.awareness,
|
||||
user: user("bob"),
|
||||
tool: "pcbnew",
|
||||
// Stale seed claims alice sits on slot 5 — her live claim (slot 0) wins.
|
||||
seedColors: seedsOf([["alice", PRESENCE_COLORS[5]!]]),
|
||||
});
|
||||
await settle();
|
||||
expect(b.presence.colorOf("alice")).toBe(PRESENCE_COLORS[0]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,12 +85,14 @@ export function claimedPresenceColor(userId: string): string | undefined {
|
|||
return g_claims.get(userId);
|
||||
}
|
||||
|
||||
/** Least-used palette color among the OTHER users in `states` — the lowest
|
||||
/** Least-used palette color among the OTHER users in `states` PLUS the doc's
|
||||
* comment authors (0009 C — their pins hold palette slots too) — the lowest
|
||||
* free slot while the room is smaller than the palette, fair reuse after. */
|
||||
function lowestFreeColor(
|
||||
states: Map<number, unknown>,
|
||||
ownClientId: number,
|
||||
ownUserId: string,
|
||||
seedColors?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const usage = new Array(PRESENCE_COLORS.length).fill(0);
|
||||
|
||||
|
|
@ -101,6 +103,13 @@ function lowestFreeColor(
|
|||
const idx = (PRESENCE_COLORS as readonly string[]).indexOf(parsed.data.user.color);
|
||||
if (idx >= 0) usage[idx]++;
|
||||
}
|
||||
if (seedColors) {
|
||||
for (const [author, color] of seedColors) {
|
||||
if (author === ownUserId) continue;
|
||||
const idx = (PRESENCE_COLORS as readonly string[]).indexOf(color);
|
||||
if (idx >= 0) usage[idx]++;
|
||||
}
|
||||
}
|
||||
|
||||
const min = Math.min(...usage);
|
||||
return PRESENCE_COLORS[usage.indexOf(min)]!;
|
||||
|
|
@ -137,12 +146,18 @@ export function createPresence(opts: {
|
|||
user: PresenceUser;
|
||||
tool: string;
|
||||
sheetPath?: string;
|
||||
/** The doc's comment-author palette assignments (0009 C, lazily read):
|
||||
* claimed around, adopted for our own past comments, and the offline-
|
||||
* author fallback in colorOf. Undefined ⇒ pre-0009 behavior. */
|
||||
seedColors?: () => ReadonlyMap<string, string>;
|
||||
}): PresenceHandle {
|
||||
const { awareness } = opts;
|
||||
const seeds = () => opts.seedColors?.();
|
||||
|
||||
// Nth-in-room color: adopt a same-user tab's color, else keep an earlier
|
||||
// claim (sticky across sheet rebinds), else claim the lowest free palette
|
||||
// slot given the peers already in the room.
|
||||
// claim (sticky across sheet rebinds), else our own comment-author slot
|
||||
// (the doc's pins already show us in it — cursor should match), else claim
|
||||
// the lowest palette slot free among live peers AND comment authors.
|
||||
let claimed = g_claims.get(opts.user.id) ?? null;
|
||||
if (!claimed) {
|
||||
for (const [clientId, raw] of awareness.getStates()) {
|
||||
|
|
@ -154,8 +169,14 @@ export function createPresence(opts: {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (!claimed) claimed = seeds()?.get(opts.user.id) ?? null;
|
||||
if (!claimed) {
|
||||
claimed = lowestFreeColor(awareness.getStates(), awareness.clientID, opts.user.id);
|
||||
claimed = lowestFreeColor(
|
||||
awareness.getStates(),
|
||||
awareness.clientID,
|
||||
opts.user.id,
|
||||
seeds(),
|
||||
);
|
||||
}
|
||||
g_claims.set(opts.user.id, claimed);
|
||||
|
||||
|
|
@ -214,7 +235,12 @@ export function createPresence(opts: {
|
|||
}
|
||||
|
||||
if (parsed.data.user.color === user.color && clientId < awareness.clientID) {
|
||||
user.color = lowestFreeColor(awareness.getStates(), awareness.clientID, user.id);
|
||||
user.color = lowestFreeColor(
|
||||
awareness.getStates(),
|
||||
awareness.clientID,
|
||||
user.id,
|
||||
seeds(),
|
||||
);
|
||||
g_claims.set(user.id, user.color);
|
||||
patch({ user: { ...user } });
|
||||
clog("presence: color collision — re-claimed", user.color);
|
||||
|
|
@ -293,7 +319,9 @@ export function createPresence(opts: {
|
|||
return parsed.data.user.color;
|
||||
}
|
||||
}
|
||||
return colorForUser(userId);
|
||||
// Offline users: their comment-author slot (0009 C) beats the hash —
|
||||
// deterministic, and lowestFreeColor keeps live claims off it.
|
||||
return seeds()?.get(userId) ?? colorForUser(userId);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue