Skip to content

Feature: Presence Port - #97

Merged
jhweir merged 11 commits into
devfrom
feat/presence-port
Jul 31, 2026
Merged

Feature: Presence Port#97
jhweir merged 11 commits into
devfrom
feat/presence-port

Conversation

@jhweir

@jhweir jhweir commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

PR: The Ephemeral Port and Live Presence

Summary

Adds a fifth backend port to WE — Ephemeral, for transient agent-to-agent state — and its first
consumer, Presence. The visible result is "N online now" plus an avatar row in the space header,
showing who else is in the space right now.

The port is the load-bearing part. Presence proves it works; the WebRTC call module, live cursors,
typing indicators, and DM typing all ride the same seam afterwards.

Branch: feat/presence-port · 11 commits · 58 new tests · verified across two agents

Design rationale in full: notes/we/August-2026/presence-port.md.


Why a new port rather than QueryIR

QueryIR assumes durable entities declared in a model manifest, each with a globally-stable id.
Ephemeral state has none of those — no manifest entity to name, no id surviving a restart, nothing
persisted to query. Routing presence through $query would either corrupt the IR's guarantees or
require inventing a phantom entity type.

The rule this port exists to enforce, to be applied every time something is proposed for it:

If it must still be there after a refresh, it is not ephemeral.

Durable messaging (chat, DMs) therefore stays with DataSource. See "Non-consumer: user-to-user
messaging" in the design note — a two-person neighbourhood gives confidentiality by membrane, which
an ephemeral transport inside a shared space cannot.


What's in it

@we/schema-shared — the neutral core (DOM-free, fully unit-tested)

ephemeral.ts — the port:

ephemeral(dataset).channel(tag, { coalesce? })  { publish(payload, to?), onMessage(cb) }

Dataset-scoped because a call happens in a space and DM typing in a DM neighbourhood.
Tag-namespaced so a feature module gets a namespace it cannot collide with. Payloads are opaque, so
presence, RTC, and cursors share one pipe without the transport knowing about any of them. The tag
maps onto a Socket.io event name, a Supabase channel topic, a Matrix to-device type, or a gossipsub
topic.

Plus EphemeralCapabilities and planEphemeral, mirroring AdapterCapabilities / planQuery.

presence.tsFocus, Activity, PresenceState, Peer, derivePeers, applyFocusDepth,
peersMatching, callRosters, peerTone, sortByPresence, and createHeartbeatPresence.

@we/app-framework — the bindings

file role
shared/ad4mEphemeralAdapter.ts AD4M implementation, ~140 lines, sibling to ad4mAdapter.ts
shared/tabCoordinator.ts focus-follows-leader election; only one tab publishes
frameworks/solid/stores/PresenceStore.tsx lifecycle, focus publishing, profile join
schemas/…/HeaderLayout/SpaceHeader.ts the visible feature

@we/components — one design-system addition

AvatarInfo.tone — a per-avatar ring colour as a semantic token (success / warning / danger /
primary / neutral), overriding the stack-level ring. Generic rather than presence-specific: any
stack may want to distinguish members. The design system owns the colour, so neither templates nor
stores ever name one.


Design decisions worth reviewing

unicast is a tri-state, not a boolean

unicast: "native" | "emulated" | "none";

'emulated' means addressed-but-broadcast: every peer receives the payload and is trusted to discard
it. Fine for SDP/ICE, fatal for anything confidential. As a boolean, someone eventually builds a
private feature on unicast: true and it silently is not private — which is the state Flux is in
today
, where every "directed" WebRTC signal and private emoji reaction is broadcast community-wide
and filtered client-side (webrtcStore.ts:88-92).

AD4M is 'emulated': it exposes real directed send (sendSignal / sendSignalU) but that is
known-broken, so the adapter addresses over broadcast. When it is fixed, flip one value and delete
the filter — grep unicast:emulated. No consumer changes. Same doctrine as the drill-down predicate
workaround already in ad4mAdapter.ts: a backend defect degrades to an adapter workaround, not a WE
outage.

Corollary, stated so it does not have to be rediscovered: confidentiality comes from membrane or
encryption, never from addressing.

authenticatedSender is declared, not assumed

AD4M supplies link.author from the executor, so a peer cannot write into another agent's presence
slot — which is what will make a work-claim lease trustworthy. A naive relay carries the sender in
the payload, where it is forgeable. Without the flag, a host could implement this port entirely
correctly and still ship spoofable presence.

focus is one value; activities is a list

A call is not a place you are instead of the Kanban board — it is something you are in while also
being somewhere. Routes are exclusive; participation is not.

Flux found this and modelled it flatly: currentRoute + callRoute + inCall + mediaSettings.
As a list, concurrent calls in one space fall out for free (Flux structurally cannot express
them — callRoute is one value, so one call per agent), and a feature module adds an activity type
without touching the schema.

availability (declared) vs liveness (measured)

Flux fuses them into one union and carries a standing // TODO: better distinguish between manually set agent status and signalling health (useSignallingService.ts:236). They are orthogonal: an
agent can be busy and online, or available and stale.

Only liveness is shown today. peerTone colours the avatar ring green / amber / red across
online / idle / stale. Nothing can set availability yet — no idle detector, no manual control
— so every peer reports available and rendering it would be dead weight decided on a guess. A test
pins the current behaviour so that when that UI lands, choosing how the two axes combine is a
deliberate change rather than something that falls out of code written today.

Departure is announced, not inferred

Leaving a space was indistinguishable from a crashed laptop: the agent simply stopped heartbeating
and ran the full decay ladder, so it lingered for a full offlineAfter (60s) in a space it left
instantly. Arrival was already one round trip via hello; bye makes departure symmetric, and peers
drop the agent outright rather than watching it fade.

An optimisation, not a guarantee — the transport is lossy and fire-and-forget, so a lost bye
just falls back to decay. Fast path explicit, slow path derived, exactly as hello relates to the
heartbeat.

One case that is easy to get wrong: an invisible agent sends no bye. It published nothing, so
peers hold no state to retract, and sending one would disclose the departure and therefore the
presence.

Ring colour, not opacity

The first attempt encoded two axes at once — ring colour for declared availability, opacity for
measured liveness — so both could be read together. It was reverted after testing, for a reason
that is structural rather than a matter of tuning:

Avatars in a stack overlap. Lowering opacity lets the avatar behind show through the one in
front, and the opaque separator ring is precisely what prevents that. Opacity and overlap cannot
coexist — no value would have worked.

It also simply read as more confusing; two axes is more than a glanceable indicator should carry. So
emphasis was removed from AvatarStack rather than left in the design system unused and quietly
broken in the component's own primary layout.

focus is hierarchical, and that is the privacy dial

{ datasetUri, path?, nodeId? }. Publish the deepest you know; consumers slice. Space presence is a
selector, not a different data model.

depth peers learn
off "James is online"
space "…is in this space"
route "…is looking at the budget page"
precise "…is reading your post"

availability: 'invisible' is the separate, stronger control that stops publishing entirely.
Flux keeps broadcasting full state including route when invisible and merely hides it on receipt, so
any modified client sees invisible agents and where they are.

datasetUri, never perspective.uuid

AD4M perspective uuids are local per-agent — the same neighbourhood has a different one on every
peer, so a broadcast uuid is meaningless to whoever receives it. Same local-id vs global-uri split
already baked into DatasetHandle. This fails silently across peers while looking correct
locally
, which is why it is in the type rather than in a comment.

Presence never fetches a profile

It carries agentId only; profiles come from adamStore.agents(), the cache $identities and the
$agent block already use. Flux's presence map is its profile cache, so it re-hydrates every peer
profile on every heartbeat — an N-peer Promise.all every five seconds.

Note the deliberate divergence from spaceStore.members, which filters out agents with no cached
profile. Presence does not: a peer with no profile still counts. So when profile fetching is failing,
"N online now" still shows a count while the members row goes empty. That is presence being
correctly more robust, not a bug.

App-lifetime, not view-lifetime; retain nothing, subscribe deliberately

Flux creates a signalling service per community view and stops it on unmount, so leaving the view
loses every peer with no path back except remounting. It also defines deleteCommunityService and
never calls it, so services accumulate holding frozen agent maps — retention without
subscription, which keeps state and lets it rot.

PresenceStore lives in StoreProvider and re-scopes on space change, tearing the old source down.
Retention buys nothing: presence is TTL'd, so a retained map is already stale, and the join handshake
repopulates in one round trip.

Subscription scope is current-space-only. Presence for every joined space would let the sidebar
show occupancy everywhere, but inbound traffic is (spaces × members ÷ heartbeat) signals per
second — 20 spaces × 30 members ÷ 5s ≈ 120/s, each crossing the executor's GraphQL boundary into
reactive state on the main thread. Widening this is a deliberate later decision and realistically
wants a backend that reports presence server-side.


Lifted from Flux, and what was fixed

Kept, because load-bearing:

  • Adaptive scheduling — a change publishes immediately and pushes the next tick out a full
    interval, so navigating quickly does not double-send.
  • Join handshake — a joiner sets hello, peers re-announce at once. Without it a joiner waits a
    whole interval against a stateless, lossy transport.
  • Tab-leader election — leadership follows window focus; followers stay subscribed so their UI
    stays live.

Fixed:

Flux here
agent map only relabels to offline, grows forever TTL eviction
no departure signal — leaving decays like a crash bye drops the peer at once
invisible still broadcasts route, filtered on receipt invisible stops publishing
profiles re-fetched every heartbeat (watch {deep:true}) profiles never touched by presence
"directed" signals broadcast to everyone, filtered client-side declared as unicast: 'emulated' so nothing builds privacy on it
full state JSON every 5s small heartbeat; full state on change/handshake

Backpressure (coalesce)

channel(tag, { coalesce: true }) drops a publish while a previous one is still in flight.

Correct only for idempotent last-write-wins traffic, where the next beat carries the same state.
Explicitly wrong for a handshake: an RTC offer dropped because the previous send is slow is simply
lost. Default off; presence opts in.

Earned from a real failure. On an unhealthy executor sendBroadcast hangs until a 30s RPC timeout
while presence heartbeats every 5s, so six stuck calls accumulate at steady state — each adding load
to the backend that is already the problem — and a warning every five seconds buries the actual
fault. Failure logging now backs off geometrically (1st, 2nd, 4th, 8th…) and logs once on recovery
with the count.


Testing

Verified: 576 tests in @we/schema-shared (38 new) and 82 in @we/app-framework (20 new);
@we/schema-shared, @we/app-framework and @we/components typecheck; lint clean; all 13 template
schemas validate. Confirmed working live across two agents on separate machines.

The neutral core is tested against a fake channel and an injected clock, so liveness derivation, the
handshake, adaptive scheduling, eviction, activity replacement, and the invisible-stops-publishing
guarantee are all covered without a backend. The tab coordinator takes its channel, focus source, and
tab id as dependencies for the same reason, and is driven by an in-memory bus.

Three real defects were found by writing tests, all silent in production:

  1. peersAtPath(peers, '/kanban') unioned peers across different spaces sharing a route path —
    the same class of mistake as broadcasting a local uuid, invisible in single-space testing.
    Replaced with peersMatching(peers, partialFocus), so a path cannot be expressed without its
    dataset.
  2. Symmetric leadership conflict resolution never terminated. "Hear another leader → step down" makes
    both step down; nobody publishes until the timeout, then both take over again. Now the lower tab
    id survives — deterministic and total.
  3. Pinning was a local exemption, which is not a total order: a pinned leader skipped the comparison
    for itself but never told the other leader to yield, so when the pinned tab held the higher id
    both published forever. Pinning is now asserted, with a fallback to the id comparison when both
    tabs are pinned (two calls in two tabs).

None of the three throws or logs. Each would have presented as an AD4M problem.

Live verification

Exercised across two agents on separate machines: peers appear, the ring tracks the decay ladder, and
departures drop immediately. This also confirms the adapter's two assumptions about AD4M's wire
behaviour, neither of which unit tests can reach — that link.author arrives populated (so a peer
cannot write into another agent's slot) and that sendBroadcastU does not loop back to the sender.

Getting there needed a fresh pair of neighbourhoods. An earlier attempt failed entirely, and the
diagnosis is worth keeping because it is not obvious and will recur:

  • neighbourhood.sendBroadcast timed out on the 30s RPC deadline, repeatedly.
  • agentByDID returned 500 — centralized-agent-language could not reach socket.ad4m.dev
    (Connection refused, os error 111), so no profile resolved.
  • Space member data did not load either, and that comes from client.neighbourhood.otherAgents(),
    which touches none of this code.

socket.ad4m.dev backs both centralized-agent-language and centralized-p-diff-sync, and the
latter serves link sync and telepresence. So one unreachable host can explain every symptom at
once, with none of it being Holochain or presence. The check is
await client.runtime.knownLinkLanguageTemplates() — new spaces take [0].

AD4M's telepresence is not an out-of-band channel. p-diff-sync's sendBroadcast is a Holochain
zome call (bootstrap-languages/p-diff-sync/index.ts:346), so presence shares the conductor with data
sync. It does not depend on the perspective's links having synced — presence lights up before space
data loads — but a wedged conductor hangs it. On a websocket or Matrix host the two are genuinely
independent; EphemeralCapabilities does not currently express that difference.

Still worth re-checking on any future backend, in priority order:

  1. The failure that looks fine with one agent — the broadcast source must carry a datasetUri
    starting neighbourhood://, not a perspective uuid. If wrong, online populates but onlineHere
    never does.
  2. Join handshake — B appears for A within ~1s, not after a 5s wait.
  3. Decay — kill the tab (don't navigate, that sends bye); ring goes green → amber at 15s → red at
    30s → gone at 60s.
  4. Departure — navigate away; B disappears for A at once rather than decaying.
  5. Personal space — the block is absent, no console errors.
  6. Two tabs on one machine — A sees one B, following whichever tab B is focused on.

Deliberately deferred

  • $presence renderer block + tier validation. The header reads presenceStore through
    $store, so this PR needs no renderer or @we/schema-shared render-path changes. A block is sugar
    and touches schema-solid — separate, riskier, and it shouldn't ride along.
  • Availability UI, and showing it. setAvailability exists on the store but nothing calls it, so
    every peer reports available and only liveness reaches the ring. Wants an input-idle detector
    (no mouse/keyboard for N minutes → away), a manual control, and AgentSettings persistence in
    we-root — the pattern is already there alongside currentTemplateId / perspectiveOrder. Note
    that auto-derived away should not persist, while a manual "do not disturb" should.
  • Privacy-dial UI. focusDepth exists on the store; nothing surfaces it yet. Also wants
    AgentSettings persistence.
  • planEphemeral has no caller. Presence needs only fan-out, so it would always pass. The
    mechanism is there for the call module, which needs unicast.
  • calls / activitiesOfType are unused until the call module.
  • Multi-space subscription (sidebar occupancy dots). See the arithmetic above.
  • Amber may flicker. idle means "missed three beats", so a wobbly connection dips the ring to
    amber and back. Deliberate for now — it makes the mechanism visible — but worth softening once it
    has been watched in real use.

Follows on

notes/we/August-2026/feature-modules.md — the call module is a feature module, and needs two
host capabilities that do not exist yet: an open shell-slot mechanism (shellRegistry is a closed
three-key object, and a call bar must persist across navigation) and store registration
(TemplateProvider builds its stores bag as an object literal). That work is the next PR; the call
module is the one after.

jhweir and others added 11 commits July 31, 2026 19:10
Sibling seam to dataSource.ts, for state that is lossy, last-write-wins,
and gone on reload: live presence, WebRTC signalling, cursors, work claims.

Deliberately not part of QueryIR. The IR assumes durable entities in a model
manifest, each with a globally-stable id; ephemeral state has none of those.
The rule the port exists to enforce: if it must still be there after a
refresh, it is not ephemeral — so durable messaging stays with DataSource.

Shape is ephemeral(dataset).channel(tag). Dataset scoping because a call
happens *in* a space; tag namespacing so a feature module gets a namespace it
cannot collide with. Payloads are opaque, so presence, RTC, and cursors share
one pipe without the transport knowing about any of them. The tag maps onto a
Socket.io event, a Supabase channel topic, a Matrix to-device type, or a
gossipsub topic.

unicast is a tri-state rather than a boolean because 'emulated'
(addressed-but-broadcast, every peer receives it) and 'native' differ in a way
that matters for security, not efficiency. As a boolean, someone eventually
builds a private feature on `unicast: true` and it silently is not private.
authenticatedSender is declared for the same reason: AD4M supplies
link.author, but a naive relay carries the sender in the payload where it is
forgeable, and a work-claim lease must be able to tell the difference.

planEphemeral mirrors planQuery — fail loudly at registration rather than
mounting a consumer that cannot work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Liveness derivation, the heartbeat driver, and the selectors — all
backend-agnostic and DOM-free, so they unit-test against a fake channel with
no executor. Flux welds the equivalent into one AD4M-coupled composable that
also owns profile hydration and an unrelated WebRTC protocol; splitting them
is the point.

Two shape decisions carry the design:

focus is one value, activities is a list. A call is not a place you are
instead of the Kanban board — it is something you are in while also being
somewhere. Routes are exclusive, participation is not. Flux found this and
modelled it flatly (currentRoute + callRoute + inCall + mediaSettings); as a
list, concurrent calls in one space fall out for free, and a feature module
adds an activity type without touching the schema.

availability (declared) and liveness (measured) are separate. Flux fuses them
into one union and carries a standing TODO about it. They are orthogonal: an
agent can be busy and online, or available and stale.

Also lifted from Flux, because they are load-bearing: adaptive scheduling (a
change publishes immediately and pushes the next tick out a full interval, so
navigating quickly does not double-send) and the join handshake (a joiner sets
hello, peers re-announce at once, otherwise a joiner waits out a whole
interval against a stateless lossy transport). Added: TTL eviction, which Flux
lacks — its agent map only ever relabels to offline and grows forever.

invisible stops publishing entirely rather than filtering on receipt. Flux
keeps broadcasting full state including route when invisible and hides it
client-side, so any modified client sees invisible agents and where they are.

peersMatching takes a partial focus rather than offering peersAtPath: a route
path is only meaningful within a dataset, and two spaces routinely share one.
The tests caught exactly that unioning peers across spaces — a bug invisible
in single-space testing, the same class as broadcasting a local uuid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling to ad4mAdapter.ts, and deliberately the same size and shape: a
capability profile plus the minimum translation, with no timers, no liveness
derivation, and no profile fetching. If it grows past ~120 lines something
backend-agnostic has leaked in.

sendBroadcastU is an unsigned broadcast never written to the perspective —
exactly right for state that must not persist. The sender arrives as
link.author from the executor rather than from the payload, hence
authenticatedSender: true; that is what stops a peer writing into another
agent's presence slot, and what will make a work-claim lease trustworthy.

unicast is 'emulated': AD4M exposes real directed send (sendSignal /
sendSignalU) but it is known-broken, so this addresses over broadcast with the
recipient DID in the link target and a receive-side filter. That is
addressing, not privacy — every peer still receives the payload — which is why
planEphemeral refuses confidential consumers rather than silently exposing
them. Mirrors the drill-down predicate workaround already in ad4mAdapter.ts: a
backend defect degrades to an adapter workaround, not a WE outage. When
sendSignalU is fixed, flip the capability and delete the filter; grep
unicast:emulated. No consumer changes.

One signal handler per scope, fanned out to channels by predicate suffix —
registering per-channel would mean N executor subscriptions for one stream.
Returns null for a personal space (no neighbourhood, nobody to signal) so
consumers degrade deliberately instead of publishing into a void.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without this every open tab heartbeats independently: N times the broadcast
traffic, and peers see one agent flapping between whatever each tab happens to
be showing. Leadership follows window focus, so the tab the user is actually
looking at is the one whose location gets published, and a tab holding
something uninterruptible can pin leadership and refuse to yield.

Followers stay fully subscribed — only publishing is restricted, so every
tab's UI stays live.

Lives in app-framework/shared rather than schema-shared because
BroadcastChannel is a DOM API: schema-shared is DOM-free and is consumed by
the we-validate-schemas CLI under Node. This is host wiring, like $onError and
$useQueryIR. Degrades to a permanent sole leader under electron/tauri and
anywhere BroadcastChannel is unavailable, which is the correct behaviour for a
single window.

Adapted from Flux's useTabCoordinator, minus the Vue coupling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Solid binding over the neutral core. It owns the three things the core
deliberately does not: when to start and stop (following the current
perspective), what to publish as focus (following the route), and the join
from a bare agentId to a displayable profile.

App-lifetime, not view-lifetime — mounted in StoreProvider rather than inside
a view. Flux creates a signalling service per community view and tears it down
on unmount, so leaving the view loses every peer with no path back except
remounting.

Publishing and subscribing are asymmetric and easy to conflate. This agent has
one location, so it publishes once per heartbeat to the space it is in, and
subscribes only to that same space. Our own dot needs no transport at all —
it is routeStore.currentPath, read locally. Presence for every joined space
would let the sidebar show occupancy everywhere, but inbound traffic is
(spaces x members / heartbeat) signals per second, each crossing the
executor's GraphQL boundary into reactive state on the main thread; widening
this is a deliberate later decision.

On space change the source is torn down rather than retained. Retention
without subscription only preserves state already past its TTL, and the join
handshake repopulates in one round trip anyway. Flux defines
deleteCommunityService and never calls it, so services accumulate holding
frozen agent maps.

Focus publishes datasetUri from currentPerspectiveSharedUrl, never
perspective.uuid: AD4M uuids are local per-agent, so a broadcast uuid is
meaningless to whoever receives it. This fails silently across peers while
looking correct locally.

Tab-leader gating wraps channel.publish at the store rather than living in the
driver, keeping the neutral core unaware that browser tabs exist. Profiles
come from adamStore.agents(), the cache $identities and $agent already use —
presence never fetches one, so it cannot repeat Flux's N-peer Promise.all
every five seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"N online now" plus an avatar row, to the right of the route buttons — the nav
row already has ax: 'between', so a second child lands there. Reuses
AvatarStack exactly as the members row below the title does.

Hidden when nobody else is around rather than rendering "0 online now", and
absent entirely in a personal space, where there is no neighbourhood and so no
presence.

Reads presenceStore through $store, so this needs no renderer or
schema-shared changes. A $presence block would be sugar on top and touches
schema-solid; keeping it out means the first consumer of the port is proved by
an ordinary template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On an unhealthy executor `sendBroadcast` hangs until a 30s RPC timeout while
presence heartbeats every 5s, so six stuck calls accumulate at steady state —
each one adding load to the backend that is already the problem — and the
console fills with a warning every five seconds, burying whatever the real
fault is.

Adds an opt-in `coalesce` channel option: drop a publish while a previous one
is still in flight. Correct only for idempotent last-write-wins traffic, where
the next beat carries the same state; explicitly wrong for a handshake, where
a dropped RTC offer is simply lost. Presence opts in; the default is off so
the call module's rtc channel is unaffected.

Failure logging now backs off geometrically (1st, 2nd, 4th, 8th…) and logs
once on recovery with the count, so an unreachable neighbourhood reports
itself without drowning the console.

Neither of these makes presence work against a neighbourhood that is not
syncing — they stop presence making it worse, and stop it hiding the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…est it

Refactors the coordinator to take its channel, focus source, and tab id as
dependencies — the pattern createHeartbeatPresence already uses for now/timers
— so leader election is testable without a DOM. Adds 20 tests. The public
interface is unchanged; PresenceStore is untouched.

Writing them surfaced two real defects, both of the silent kind: no exception,
no log, just doubled heartbeats or none, which would present as an AD4M
problem.

Symmetric conflict resolution never terminates. "Hear another leader, step
down" makes *both* step down, so nobody publishes until the timeout, then both
take over again — a stable oscillation. Now the lower tab id survives.
Deterministic and total. Focus still expresses preference; the id comparison
only resolves conflict.

Pinning was a local exemption, which is not a total order. A pinned leader
skipped the comparison for itself but never told the other leader to yield, so
when the pinned tab held the higher id neither stepped down and both published
forever. Pinning is now asserted with a `pinned` message, and a `pinned`
received while also pinned falls back to the id comparison — otherwise two
tabs each holding a call would both defer and leave nobody publishing.

Also fixes a genuine crash-recovery hole in the test harness rather than the
code: a killed tab must stop sending as well as receiving, or its heartbeats
keep holding peers off after it is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes that together make decay legible and stop it firing when it
shouldn't.

**bye on departure.** Leaving a space was indistinguishable from a crashed
laptop: the agent simply stopped heartbeating and ran the full ladder, so it
lingered for a full offlineAfter (60s) in a space it left instantly. Arrival
was already one round trip via hello; departure is now symmetric. Best-effort
by design — the transport is lossy, so TTL decay remains the backstop and this
is only the fast path. An invisible agent stays silent: peers hold no state to
retract, and a bye would disclose the departure and therefore the presence.

**Two visual channels, because there are two independent facts.** tone (ring
colour) carries declared availability — available/away/busy → success/warning/
danger. emphasis (opacity) carries measured liveness — online/idle/stale →
full/muted/faded. Folding both into ring colour would force a false choice
between showing that someone is on Do Not Disturb and showing that we are
losing contact with them, and it would spend amber on a connection state when
every user reads amber as "away".

**Stable sort.** Most-present first, tiebroken on agentId. Not cosmetic: peers
come out of a Map, so equal-liveness peers would otherwise order by insertion
and the avatar row would reshuffle on every heartbeat, making a settled group
look like it was churning.

AvatarStack gains per-avatar tone and emphasis. Both are generic — any stack
may want to distinguish or de-emphasise members — so no presence semantics
leak into the design system, and tone maps to DS colour tokens rather than
letting templates or stores name colours. The ring is never removed, only
recoloured: it is what separates overlapping avatars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the two-channel encoding added in the previous commit. Ring colour now
tracks liveness directly: green active, amber idle, red stale.

The opacity channel was wrong for this component, for a reason that is
structural rather than a matter of tuning: **avatars in a stack overlap**, so
lowering opacity lets the avatar behind show through the one in front. The
opaque separator ring is precisely what prevents that, so opacity and overlap
cannot coexist. It also read as more confusing than it was worth — two axes
encoded at once is more than a glanceable indicator should carry.

Removes `emphasis` from AvatarStack rather than leaving it in the design system
unused and quietly broken in the component's primary layout. `tone` stays; it
is sound and generic.

`availability` is no longer mapped to colour. Nothing can set it today — no
idle detector, no manual control — so every peer reports `available` and the
channel would be dead weight. A test records the current behaviour so that when
that UI lands, deciding how the two axes combine is a deliberate change rather
than an accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit fbbf850
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a6cf5aa9303130008eba341
😎 Deploy Preview https://deploy-preview-97--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit 1e16830 into dev Jul 31, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant