Repository navigation
Use one client presence rule so the @ picker agrees with the avatars #493
Description
Summary
The webapp has two rules for "is this face present". The @ mention picker uses status === 'ONLINE'. The pad header avatars, the TOC row trail and the chat participants stack use "not OFFLINE". So while a peer types, three surfaces show them as present, and the picker shows them as offline.
This issue adds one exported predicate, isFacePresent, as the only client rule. All three readers call it. The only visible change: a typing peer keeps the online dot in the @ picker.
This is an architecture finding from the presence review in #496. It does not fix the stuck-Online bug; #489 and #490 do that.
Scope: webapp TypeScript and two doc files. No SQL, no migration, no generated file, no prod paste.
Problem
Profile.status holds 'TYPING' while a peer types. useBroadcastListener.ts:35-38 writes 'TYPING' on startTyping and 'ONLINE' on stopTyping. The sender, handleTypingIndicator.ts:45-60, sends stopTyping after 1000 ms with no keystroke. So a peer is TYPING only while they type with no pause over 1 s.
The readers disagree on 'TYPING':
| Reader | Code | Rule |
|---|---|---|
workspacePresenceSync.ts:40-46 selectPresenceOthers, used by PresentUsers.tsx:13 and usePresentUsers.ts:39 |
u.status !== 'OFFLINE' |
not OFFLINE |
usePresentUsers.ts:15-22 getByChannel, used by TocRowTrail.tsx:37 and ParticipantsList.tsx:13 |
if (user.status === 'OFFLINE') continue |
not OFFLINE (a copy) |
MentionList.tsx:51-57 onlineByUserId |
presence.status === 'ONLINE' |
ONLINE only |
The picker turns that map into a dot at MentionSuggestions.tsx:123, then MentionSuggestionRow.tsx:54. Only TYPING reaches the map from this app's own writers. AWAY, BUSY and INVISIBLE could only come from a modified peer's broadcast.
These status reads are not presence questions, and they stay as they are:
AvatarStack.tsx:74(user.status === 'TYPING') asks "is this face typing".workspacePresenceSync.ts:67(status !== 'OFFLINE'insideprofileFromPresence) tests the value being written, to tell a leave apart.useHandleUserStatus.ts:21dedupes the user's own DB write. Fix the Status writer's tab race, reload gap and silent errors #492 owns it.
#434 does not change this. No DB status reaches usersPresence: getUserById does not select it (USER_PROFILE_COLUMNS), and every presence path sets its own value (workspacePresenceSync.ts:85, :237, :263, :269).
The docs are stale too. The apps/webapp/src/components/chatroom/CLAUDE.md bullet that starts "AvatarStack consumers filter the current user out" gives the old shape Array.from(usersPresence.values()).filter(u => u.id !== profile?.id). CONTEXT.md §Presence awareness calls usersPresence a "Map of online Profiles", but a leave keeps the face as OFFLINE (workspacePresenceSync.ts:267-271).
Decisions
Maintainer rulings are posted as a comment.
- Which rule is the one rule? Ruled 2026-10-15:
status !== 'OFFLINE'. Three of four readers already use it, so only the picker changes. The other option is an allowlist (ONLINEorTYPING). It would also hide spoofedAWAYorBUSYfaces, but it changes avatars and the TOC for a case nobody sees today.
Depends on
Nothing must ship first. This issue can start now.
- Expire a peer's typing state when its stop signal never arrives #342 edits the "Activity chips" bullet in
apps/webapp/src/components/chatroom/CLAUDE.md. This issue edits a different bullet in that file, so either order works. No TypeScript file overlaps. Both rely on one contract:TYPINGcounts as present. So Expire a peer's typing state when its stop signal never arrives #342's expiry (TYPINGtoONLINE) changes no reader's answer. - Fix the Status writer's tab race, reload gap and silent errors #492 keeps the
ONLINEpin atapplySignedInProfile.ts:32(its Out of scope). This issue holds without the pin too, because join, sync and the broadcast fallback set a value. - Measure workspace presence join fan-out before changing it #495 Step A, if it ships, also edits
workspacePresenceSync.tsand the chatroomCLAUDE.mdanon-viewers bullet. Land this issue first, because it is smaller. - Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489, Make the chat bell use the online lease instead of the raw status flag #490 and Make presence heartbeats HOT updates and drop the duplicate username index #491 are SQL only.
Fix plan
No Step 0: no prod read is needed. Line numbers are hints as of db481158b; search by symbol.
-
Add the predicate. In
apps/webapp/src/services/workspacePresenceSync.ts, add this export just aboveselectPresenceOthers, and makeselectPresenceOtherscall it:/** The one client rule for "is present". A typing face is present; only a leave sets OFFLINE. */ export const isFacePresent = (u: Pick<Profile, 'status'>): boolean => u.status !== 'OFFLINE' export function selectPresenceOthers( users: Iterable<Profile> | null | undefined, selfId: string | undefined | null ): Profile[] { if (!users) return [] return Array.from(users).filter((u) => u.id !== selfId && isFacePresent(u)) }
The name is
isFacePresent, notisPresent.ComposerLinkModalShell.tsx:27already usesisPresentfor framer-motionuseIsPresent(). -
usePresentUsers. Inapps/webapp/src/hooks/usePresentUsers.ts, extend the import toimport { isFacePresent, selectPresenceOthers } from '@services/workspacePresenceSync'. Change line 18 toif (!isFacePresent(user)) continue. Keep this line: the anon path (:38) returns the list without callingselectPresenceOthers. -
MentionList. Inapps/webapp/src/components/chatroom/components/MessageComposer/helpers/MentionList.tsx, addimport { isFacePresent } from '@services/workspacePresenceSync'. Put it after theimport { searchWorkspaceUsers } from '@api'line and before theimport { useAuthStore, useStore } from '@stores'line. Root lint runssimple-import-sortwith--max-warnings=0, so any other spot fails Verify. Change line 54 tomap.set(id, isFacePresent(presence)). Keep theMap<string, boolean>shape, soMentionSuggestionsandMentionSuggestionRowdo not change. -
Chatroom doc. In
apps/webapp/src/components/chatroom/CLAUDE.md, find the bullet that starts "AvatarStackconsumers filter the current user out". Replace only this sentence. Old text:The right shape is `Array.from(usersPresence.values()).filter(u => u.id !== profile?.id)` then render when the remainder is non-empty — works for both auth (drops self) and anon (no-op).
New text:
The right shape is `selectPresenceOthers(usersPresence.values(), profile?.id)`, then render when the remainder is non-empty. It drops self and every face that fails `isFacePresent`, so it works for both auth and anon. Do not write a second presence rule.
Do not write the literal shape
status !== 'OFFLINE'in this file. The grep gate in Verify scans it.In the same file, fix a stale reader in the bullet that starts "Anon viewers subscribe to the same".
UserReadStatusdoes not readusersPresence(grep finds no hit). Old text:TOC header, TOC items, pad header `PresentUsers`, chatroom `ParticipantsList`, and message `UserReadStatus`.
New text:
TOC header, TOC items, pad header `PresentUsers`, and chatroom `ParticipantsList`.
-
Glossary. In
CONTEXT.md§Presence awareness, replace two lines. Old text:- **usersPresence** — Map of online Profiles keyed by user id (`useStore.usersPresence`); TOC heading stacks filter by `channelId`.
- **selectPresenceOthers** — filter self out of presence stacks (`PresentUsers`, `usePresentUsers`); pass `map.values()` or a channel list.
New text. The first line replaces the
usersPresenceline. The next two replace theselectPresenceOthersline:- **usersPresence** — Map of faces (presence Profiles) keyed by user id (`useStore.usersPresence`). A leave marks the face `OFFLINE`. It stays in the map until that user joins again or the next resubscribe. TOC heading stacks filter by `channelId`.
- **isFacePresent** — the one client rule for "is this face present" (`workspacePresenceSync.ts`). A typing face is present. Only a leave makes a face not present. - **selectPresenceOthers** — drops self and every face that fails `isFacePresent` (`PresentUsers`, `usePresentUsers`); pass `map.values()` or a channel list.
-
Ship. One commit with the 3 TS files and the 2 doc files. Then the normal webapp deploy. Local apply: none. Prod paste: none.
Rollback. Revert the commit and redeploy the webapp. There is no data or schema state.
Tests. Add none (AGENTS.md §Test Policy). The change is one comparison, not dense logic. The grep gate and the browser check below cover it.
Verify
-
Baseline first. Run these four commands from the repo root on HEAD, before any edit. Note any failure that exists already. Run them again after the change. Each must pass, or fail only with the same baseline failure:
bun run --filter @docs.plus/webapp typecheck bun run lint bun run format bun run check:agent-docs
-
Grep gate:
rg -n "status (===|!==) '(ONLINE|OFFLINE)'" apps/webapp/src- Before the fix it prints exactly 4 hits:
workspacePresenceSync.ts:45,workspacePresenceSync.ts:67,usePresentUsers.ts:18,MentionList.tsx:54. - After the fix it prints exactly 2 hits, both in
workspacePresenceSync.ts: theisFacePresentbody and the leave test inprofileFromPresence. A third hit means a second presence rule, or the shape leaked intochatroom/CLAUDE.md.
- Before the fix it prints exactly 4 hits:
-
Browser check. Setup:
- Start the local stack with
make dev-local. Open the webapp athttp://localhost:3000. - Use two browser profiles, A and B. Sign each in with its own email link. Read the links in the local inbox at
http://127.0.0.1:54324. - Both open the same pad and the same heading chat. B has no emoji or voice chip active, because an active chip blocks
startTyping. - Put windows A and B side by side, both visible. A covered or hidden window slows timers to about 1 s, which is the debounce edge.
One person cannot type in B while working in A: focusing A blurs B's composer, and the blur sends
stopTyping1 s later. So a script types for B.Steps:
-
In B, open DevTools and run:
const t = setInterval(() => document.querySelector('#chatroom-editor .ProseMirror').editor.commands.insertContent('a'), 300)
Each insert resets the 1 s stop timer, so B stays
TYPINGwhile you work in A. -
In A, type
@and part of B's name. B's row shows the online dot. Before the fix it shows offline. -
While B types, the pad header avatars, B's TOC row trail and the participants stack still show B.
-
In B's console run
clearInterval(t). Wait 2 s. The dot stays online. -
B closes the tab. After the leave arrives, B's dot goes offline, and B drops from the TOC and participant stacks.
- Start the local stack with
Acceptance criteria
-
isFacePresentis exported fromapps/webapp/src/services/workspacePresenceSync.ts, andselectPresenceOtherscalls it. -
usePresentUsers.tsandMentionList.tsxcallisFacePresent, and they have no status test of their own. - The grep gate prints exactly 2 hits, both in
workspacePresenceSync.ts. - A typing peer shows the online dot in the @ picker.
- A typing peer still shows in the pad header avatars, the TOC row trail and the participants stack.
- A peer that left shows offline in the picker and drops from the stacks.
- The chatroom
CLAUDE.mdbullet andCONTEXT.md§Presence awareness nameisFacePresentas the one rule. - The chatroom
CLAUDE.mdanon-viewers bullet no longer listsUserReadStatus. - Typecheck, lint, format and
check:agent-docspass, or fail only with a failure noted on the HEAD baseline.
Out of scope
- The typing writer (resend and expiry). Expire a peer's typing state when its stop signal never arrives #342 owns it. This issue does not touch
useBroadcastListener.tsorusersPresence.ts. - The revive guard. Read from code, not reproduced. A late
typingIndicator(usersPresence.ts:31) orpresencebroadcast (workspacePresenceSync.ts:228-240) can revive a face that left. Avatars, the TOC and participants have this gap today. A guard needs both writers. File it alone after a browser check reproduces it. - Moving
TYPINGout ofstatusintoactivity. The "Activity chips" rule says one activity per face, and Expire a peer's typing state when its stop signal never arrives #342 assumes the current shape. - Two tabs, one closed. Read from code, not reproduced: a leave clears
channelId, and the next sync re-seeds the face without one (workspacePresenceSync.ts:60-89). File it alone after a browser check. - Chip loss on a join or leave. Read from code, not reproduced:
profileFromPresencerebuilds each face withoutactivity. Raise it in Expire a peer's typing state when its stop signal never arrives #342 after a browser check. - Scale. One comparison in an O(N) memo that runs only while the picker is open. It makes no limit worse (join fan-out is Measure workspace presence join fan-out before changing it #495).
Generated by Claude Code
Activity
[-]Use one client rule for whether a face is present[/-][+]Use one client presence rule so the @ picker agrees with the avatars[/+]HMarzban commentedon Oct 15, 2026
Maintainer ruling, 2026-10-15
Decision 1: the one client rule is
status !== 'OFFLINE'. Only the @ picker changes.All decisions in this issue are now ruled.
refactor(webapp): make the presence room the only presence writer
HMarzban commentedon Oct 19, 2026
Browser checks, 2026-10-19 (local stack, two signed-in profiles)
isFacePresentis exported and used byselectPresenceOthers,usePresentUsers.tsandMentionList.tsx, which have no status test of their own: pass.isFacePresent: pass.Grep gate (maintainer ruling, 2026-10-19): the gate now prints 1 hit, not 2. Commit
8527f1540rewrote the leave test asisFacePresent({ status }), so there is still one presence rule. 1 hit is the accepted target.