Skip to content

Use one client presence rule so the @ picker agrees with the avatars #493

Closed

Description

@HMarzban

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' inside profileFromPresence) tests the value being written, to tell a leave apart.
  • useHandleUserStatus.ts:21 dedupes 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.

  1. 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 (ONLINE or TYPING). It would also hide spoofed AWAY or BUSY faces, but it changes avatars and the TOC for a case nobody sees today.

Depends on

Nothing must ship first. This issue can start now.

Fix plan

No Step 0: no prod read is needed. Line numbers are hints as of db481158b; search by symbol.

  1. Add the predicate. In apps/webapp/src/services/workspacePresenceSync.ts, add this export just above selectPresenceOthers, and make selectPresenceOthers call 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, not isPresent. ComposerLinkModalShell.tsx:27 already uses isPresent for framer-motion useIsPresent().

  2. usePresentUsers. In apps/webapp/src/hooks/usePresentUsers.ts, extend the import to import { isFacePresent, selectPresenceOthers } from '@services/workspacePresenceSync'. Change line 18 to if (!isFacePresent(user)) continue. Keep this line: the anon path (:38) returns the list without calling selectPresenceOthers.

  3. MentionList. In apps/webapp/src/components/chatroom/components/MessageComposer/helpers/MentionList.tsx, add import { isFacePresent } from '@services/workspacePresenceSync'. Put it after the import { searchWorkspaceUsers } from '@api' line and before the import { useAuthStore, useStore } from '@stores' line. Root lint runs simple-import-sort with --max-warnings=0, so any other spot fails Verify. Change line 54 to map.set(id, isFacePresent(presence)). Keep the Map<string, boolean> shape, so MentionSuggestions and MentionSuggestionRow do not change.

  4. Chatroom doc. In apps/webapp/src/components/chatroom/CLAUDE.md, find the bullet that starts "AvatarStack consumers 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". UserReadStatus does not read usersPresence (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`.
  5. 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 usersPresence line. The next two replace the selectPresenceOthers line:

    - **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.
  6. 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

  1. 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
  2. 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: the isFacePresent body and the leave test in profileFromPresence. A third hit means a second presence rule, or the shape leaked into chatroom/CLAUDE.md.
  3. Browser check. Setup:

    • Start the local stack with make dev-local. Open the webapp at http://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 stopTyping 1 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 TYPING while 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.

Acceptance criteria

  • isFacePresent is exported from apps/webapp/src/services/workspacePresenceSync.ts, and selectPresenceOthers calls it.
  • usePresentUsers.ts and MentionList.tsx call isFacePresent, 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.md bullet and CONTEXT.md §Presence awareness name isFacePresent as the one rule.
  • The chatroom CLAUDE.md anon-viewers bullet no longer lists UserReadStatus.
  • Typecheck, lint, format and check:agent-docs pass, or fail only with a failure noted on the HEAD baseline.

Out of scope


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    ChatRelated to chat features
    on Oct 14, 2026
  2. added and removed
    bugSomething isn't working
    on Oct 15, 2026
  3. changed the title [-]Use one client rule for whether a face is present[/-] [+]Use one client presence rule so the @ picker agrees with the avatars[/+] on Oct 15, 2026
  4. HMarzban commented on Oct 15, 2026

    @HMarzban
    CollaboratorAuthor

    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.

  5. added this to the v2.2.0 milestone on Oct 18, 2026
  6. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    Browser checks, 2026-10-19 (local stack, two signed-in profiles)

    • isFacePresent is exported and used by selectPresenceOthers, usePresentUsers.ts and MentionList.tsx, which have no status test of their own: pass.
    • A typing peer shows the online dot in the @ picker, the pad header avatars, the TOC row trail and the participants stack: pass.
    • Two seconds after the peer stops typing, it stays online, and the typing mark clears: pass.
    • A peer that left shows offline in the picker and drops from all three stacks within about 0.5 s: pass.
    • The doc items name isFacePresent: pass.

    Grep gate (maintainer ruling, 2026-10-19): the gate now prints 1 hit, not 2. Commit 8527f1540 rewrote the leave test as isFacePresent({ status }), so there is still one presence rule. 1 hit is the accepted target.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChatRelated to chat featuresRefactor

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions