presence: fix stale peer selections after delete, select-all lag, ghost-peer removal (findings group Y)

- wasm core: CORE::onDocChanged() from both collab listeners (local commit AND
  remote apply) repaints peers' shapes from the live document + re-checks the
  local selection post-settle; PresenceStart registers the bridge listener
- wasm core: cursors on their own overlay trio; shapes repaint only when the
  non-cursor snapshot changes; new kicadCollabSetRemoteCursors (cursor-only
  update, PEER.id) in both TUs + merged editor + JSPI mutator allowlist
- presence.ts: size-aware trailing throttle for cursor/viewport publishes
  (128 KB/s budget) + parsed-peers memo; presence-kicad.ts: cursor-only push
  when the shape signature is unchanged
- gateway.ts: honor the `gone` control (removeAwarenessStates)
- specs: kicad stale-after-delete gate, web ghost-peer timing (+diag), unit
  select-all budget (pcbjam-shared → 4573a7c)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AScTR39aqyrY5i3ZFHmnMn
This commit is contained in:
Gergő Törcsvári 2026-08-28 16:14:44 +02:00
commit 7a9aeb5181
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
13 changed files with 935 additions and 26 deletions

View file

@ -46,6 +46,7 @@ namespace pcbjam_presence {
struct PEER
{
std::string id; // awareness identity (cursor-only updates key on it)
std::string name;
KIGFX::COLOR4D color;
bool hasCursor = false;
@ -135,9 +136,19 @@ struct CORE
// chips — see the depth-layering note in collab_presence_style.h.
std::shared_ptr<KIGFX::VIEW_OVERLAY> chipOverlay;
std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay;
// Cursors live on their own overlay trio (findings Y-4): a peer's 20 Hz
// cursor tick repaints these only; the selection/xsel/pin shapes above
// are repainted only when a selection, lock, pin or zoom changes.
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorOverlay;
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorChipOverlay;
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorTextOverlay;
bool started = false;
bool redrawScheduled = false;
bool shapesDirty = false;
bool cursorsDirty = false;
bool docChangeScheduled = false;
std::string lastShapeSig; // setRemote dedupe: shapes vs cursor-only change
bool selCheckScheduled = false;
std::string lastSelectionJson; // dedupe: emit only when the payload changed
long long lastCursorEmitMs = 0;
@ -328,9 +339,11 @@ struct CORE
// ── remote render ─────────────────────────────────────────────────────
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE via the
// Repaint the remote-peers overlays. Runs in CallAfter + COROUTINE via the
// apply queue — serialized with the applies, same constraint as every
// other view mutation from JS.
// other view mutation from JS. Two groups, each repainted only when
// dirty: SHAPES (selection boxes, cross-app ghosts, comment pins — the
// expensive part: per-item resolve + outline geometry) and CURSORS.
void redrawOverlay()
{
redrawScheduled = false;
@ -359,9 +372,20 @@ struct CORE
if( !textOverlay )
textOverlay = makePresenceTextOverlay( view );
overlay->Clear();
chipOverlay->Clear();
textOverlay->Clear();
if( !cursorOverlay )
{
cursorOverlay = view->MakeOverlay();
cursorOverlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET );
}
if( !cursorChipOverlay )
{
cursorChipOverlay = view->MakeOverlay();
cursorChipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET );
}
if( !cursorTextOverlay )
cursorTextOverlay = makePresenceTextOverlay( view );
// Screen-constant sizing: px → world units, so cursors/outline widths
// don't scale with zoom. MUST go through the GAL matrix
@ -369,36 +393,72 @@ struct CORE
// px-per-IU, and under-sizes the drawing by ~7 orders of magnitude.
double px = view->ToWorld( 1.0 );
for( const PEER& peer : peers )
bool shapes = shapesDirty;
bool cursors = cursorsDirty;
shapesDirty = false;
cursorsDirty = false;
if( shapes )
{
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
overlay->Clear();
chipOverlay->Clear();
textOverlay->Clear();
// Selection boxes + cross-app ghosts: editor-specific resolution.
drawPeerShapes( *this, fr, peer, color, px );
for( const PEER& peer : peers )
{
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
if( peer.hasCursor )
drawCursor( overlay.get(), chipOverlay.get(), textOverlay.get(), peer.cursor,
peer.name, color, px, style );
// Selection boxes + cross-app ghosts: editor-specific resolution.
drawPeerShapes( *this, fr, peer, color, px );
}
// Comment pin dots (0005) — on the CHIPS layer so selection fills
// can't reject their fragments (see drawPin).
for( const PIN& pin : pins )
{
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style );
}
view->Update( overlay.get() );
view->Update( chipOverlay.get() );
view->Update( textOverlay.get() );
}
// Comment pin dots (0005) — on the CHIPS layer so selection fills
// can't reject their fragments (see drawPin).
for( const PIN& pin : pins )
if( cursors )
{
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style );
cursorOverlay->Clear();
cursorChipOverlay->Clear();
cursorTextOverlay->Clear();
for( const PEER& peer : peers )
{
if( !peer.hasCursor )
continue;
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
drawCursor( cursorOverlay.get(), cursorChipOverlay.get(), cursorTextOverlay.get(),
peer.cursor, peer.name, color, px, style );
}
view->Update( cursorOverlay.get() );
view->Update( cursorChipOverlay.get() );
view->Update( cursorTextOverlay.get() );
}
view->Update( overlay.get() );
view->Update( chipOverlay.get() );
view->Update( textOverlay.get() );
if( !shapes && !cursors )
return;
// The canvas repaints on its own only with focus/input — force it,
// exactly as the cross-probe flash does.
fr->GetCanvas()->ForceRefresh();
}
void scheduleRedraw()
void scheduleRedraw( bool aShapes = true, bool aCursors = true )
{
shapesDirty = shapesDirty || aShapes;
cursorsDirty = cursorsDirty || aCursors;
if( redrawScheduled )
return;
@ -411,6 +471,39 @@ struct CORE
pcbjam_collab::runOnCoroutine( fr, [this]() { redrawOverlay(); } );
}
/** The document changed (local commit OR remote apply — findings Y-1/Y-3):
* peers' selection boxes may now sit on deleted/moved items, and the
* local selection may have lost items with no closing canvas event.
* Queued on the apply coroutine so it runs AFTER the commit/apply body
* that raised it: repaint the shapes from the live document and re-check
* the local selection. Coalesced per settle. */
void onDocChanged()
{
if( docChangeScheduled )
return;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
docChangeScheduled = true;
pcbjam_collab::runOnCoroutine( fr, [this]()
{
docChangeScheduled = false;
if( !peers.empty() || !pins.empty() )
{
shapesDirty = true;
cursorsDirty = true;
redrawOverlay();
}
checkSelection();
} );
}
// ── JS entry-point bodies ─────────────────────────────────────────────
/** kicadCollabPresenceStart: install the input hooks on the GAL canvas
@ -491,6 +584,7 @@ struct CORE
for( const json& p : j.value( "peers", json::array() ) )
{
PEER peer;
peer.id = p.value( "id", "" );
peer.name = p.value( "name", "" );
peer.color = parsePeerColor( p.value( "color", "" ) );
@ -528,10 +622,75 @@ struct CORE
parsedLocks[ KIID( wxString::FromUTF8( uuid.c_str() ) ) ] = l.value( "name", "" );
}
// Shapes signature (everything but cursors): an unchanged one means
// this push is a cursor tick — repaint cursors only.
std::string sig;
for( const PEER& peer : parsed )
{
sig += peer.id + '\x1f' + peer.name + '\x1f' + peer.color.ToCSSString().ToStdString() + '\x1e';
for( const KIID& k : peer.selection )
sig += pcbjam_collab::toUtf8( k.AsString() ) + ',';
sig += '\x1e';
for( const KIID& k : peer.xsel )
sig += pcbjam_collab::toUtf8( k.AsString() ) + ',';
sig += '\x1d';
}
for( const auto& [id, name] : parsedLocks )
sig += pcbjam_collab::toUtf8( id.AsString() ) + '=' + name + ';';
bool shapesChanged = sig != lastShapeSig;
lastShapeSig = std::move( sig );
peers = std::move( parsed );
locks = std::move( parsedLocks );
start();
scheduleRedraw();
scheduleRedraw( shapesChanged, true );
}
/** kicadCollabSetRemoteCursors (findings Y-4): `{cursors:[{id,cursor:{x,y}|null}]}`
* for peers of the last full snapshot — updates their cursors and repaints
* the cursor overlays only. Unknown ids are ignored (the next full
* snapshot introduces them). */
void setRemoteCursors( const std::string& aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
bool changed = false;
for( const json& c : j.value( "cursors", json::array() ) )
{
std::string id = c.is_object() ? c.value( "id", "" ) : "";
for( PEER& peer : peers )
{
if( peer.id != id )
continue;
if( c.contains( "cursor" ) && c["cursor"].is_object() )
{
peer.hasCursor = true;
peer.cursor = VECTOR2D( c["cursor"].value( "x", 0.0 ), c["cursor"].value( "y", 0.0 ) );
}
else
{
peer.hasCursor = false;
}
changed = true;
}
}
if( changed )
scheduleRedraw( false, true );
}
/** kicadCollabSetPins (0005): comment pins — `{pins:[{id,name,x,y,

View file

@ -650,6 +650,12 @@ void scheduleSheetSave( SCH_SHEET* aSheet )
// too (delete, paste) with no closing canvas event — the trigger below also
// piggybacks a selection re-check. Defined in the presence section further down.
void schedulePresenceSelCheck();
// Findings Y-1/Y-3: ANY document change (local commit or remote apply) repaints
// the peers' overlay from the live document and re-checks the local selection,
// post-settle on the apply coroutine. Runs BEFORE the applying-remote early
// return below — the remote apply is exactly the case that left peers' boxes on
// deleted items.
void schedulePresenceDocChanged();
class COLLAB_LISTENER : public SCHEMATIC_LISTENER
{
@ -684,6 +690,8 @@ private:
// symbol — noteDirty), then coalesce into one post-settle flush.
void trigger( const std::vector<SCH_ITEM*>& aItems )
{
schedulePresenceDocChanged();
if( s_applyingRemote )
return;
@ -829,6 +837,11 @@ void schedulePresenceSelCheck()
presenceCore().scheduleSelCheck();
}
void schedulePresenceDocChanged()
{
presenceCore().onDocChanged();
}
} // namespace
@ -1839,6 +1852,11 @@ extern "C" void kicadCollabOnSave( const char* aPath )
// sheet navigation, so one install serves the whole session.
void schCollabPresenceStart()
{
// Presence needs the document listener too (findings Y-1/Y-3: doc changes
// repaint peers' shapes + re-check the local selection) — collab normally
// registers it first via snapshot/apply, but presence must not depend on
// that ordering (test harnesses, presence-only sessions).
ensureBridge();
presenceCore().start();
}
@ -1850,6 +1868,12 @@ void schCollabSetRemote( std::string aJson )
presenceCore().setRemote( aJson );
}
// JS → C++: cursor-only update for the peers of the last snapshot (findings Y-4).
void schCollabSetRemoteCursors( std::string aJson )
{
presenceCore().setRemoteCursors( aJson );
}
// JS → C++ (collab-presence 0005): comment pin dots (same wire as pcbnew).
void schCollabSetPins( std::string aJson )
{
@ -2241,6 +2265,7 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
function("kicadCollabPresenceStart", &schCollabPresenceStart);
function("kicadCollabSetRemote", &schCollabSetRemote);
function("kicadCollabSetRemoteCursors", &schCollabSetRemoteCursors);
function("kicadCollabSetPins", &schCollabSetPins);
function("kicadCollabSetViewport", &schCollabSetViewport);
// Follow-user (collab-presence 0008).

View file

@ -73,6 +73,7 @@ int pcbCollabTestUndoDepth();
// Presence (collab-presence 0002) + comment pins/panning (0005).
void pcbCollabPresenceStart();
void pcbCollabSetRemote( std::string aJson );
void pcbCollabSetRemoteCursors( std::string aJson );
void pcbCollabSetPins( std::string aJson );
void pcbCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
@ -120,6 +121,7 @@ int schCollabTestUndoDepth();
// Presence (collab-presence 0003 — eeschema counterparts) + pins (0005).
void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson );
void schCollabSetRemoteCursors( std::string aJson );
void schCollabSetPins( std::string aJson );
void schCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
@ -491,6 +493,11 @@ static void collabSetRemote( std::string aJson )
pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson );
}
static void collabSetRemoteCursors( std::string aJson )
{
pcbEditorActive() ? pcbCollabSetRemoteCursors( aJson ) : schCollabSetRemoteCursors( aJson );
}
static void collabSetPins( std::string aJson )
{
pcbEditorActive() ? pcbCollabSetPins( aJson ) : schCollabSetPins( aJson );
@ -658,6 +665,7 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
// Presence (collab-presence 0002/0003) + comment pins/panning (0005).
function("kicadCollabPresenceStart", &collabPresenceStart);
function("kicadCollabSetRemote", &collabSetRemote);
function("kicadCollabSetRemoteCursors", &collabSetRemoteCursors);
function("kicadCollabSetPins", &collabSetPins);
function("kicadCollabSetViewport", &collabSetViewport);
// Live color-theme switch (comments-ux 0002 F4).

View file

@ -1006,6 +1006,10 @@ void scheduleFlush()
// (delete, paste) with no closing canvas event — piggyback a selection re-check
// on the collab listener trigger. Defined in the presence section below.
void schedulePresenceSelCheck();
// Findings Y-1/Y-3: ANY document change (local commit or remote apply) repaints
// the peers' overlay from the live board and re-checks the local selection —
// runs BEFORE the applying-remote early return in trigger().
void schedulePresenceDocChanged();
// ChangeSource: the native BOARD_LISTENER is just a trigger — the actual change set comes from
// the post-settle snapshot diff above. Skipped while applying a remote delta (no echo); doApply
@ -1034,6 +1038,8 @@ private:
// may be freed before the flush runs), then coalesce into one flush.
void trigger( const std::vector<BOARD_ITEM*>& aItems )
{
schedulePresenceDocChanged();
if( s_applyingRemote )
return;
@ -1454,6 +1460,11 @@ void schedulePresenceSelCheck()
presenceCore().scheduleSelCheck();
}
void schedulePresenceDocChanged()
{
presenceCore().onDocChanged();
}
} // namespace
@ -1685,6 +1696,8 @@ std::string pcbCollabGetPos( std::string aId )
// run POST-event via CallAfter (the selection tool acts on the same event after us).
void pcbCollabPresenceStart()
{
// Presence needs the board listener too (findings Y-1/Y-3) — see eeschema.
ensureBridge();
presenceCore().start();
}
@ -1696,6 +1709,12 @@ void pcbCollabSetRemote( std::string aJson )
presenceCore().setRemote( aJson );
}
// JS → C++: cursor-only update for the peers of the last snapshot (findings Y-4).
void pcbCollabSetRemoteCursors( std::string aJson )
{
presenceCore().setRemoteCursors( aJson );
}
// JS → C++ (collab-presence 0005): comment pin dots — `{pins:[{id,x,y,color,
// resolved}]}`, world IU coords resolved by the TS side from the ydoc anchors.
// Snapshot semantics like SetRemote: cleared + fully redrawn each push.
@ -2897,6 +2916,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// with 0003 (the merged image dispatches pcb-only until then).
function("kicadCollabPresenceStart", &pcbCollabPresenceStart);
function("kicadCollabSetRemote", &pcbCollabSetRemote);
function("kicadCollabSetRemoteCursors", &pcbCollabSetRemoteCursors);
function("kicadCollabSetPins", &pcbCollabSetPins);
function("kicadCollabSetViewport", &pcbCollabSetViewport);
// Follow-user (collab-presence 0008).