Parent
#328.
What to build
The typing bounce on a face has no keepalive and no expiry. If the stop signal is lost, the bounce stays. It clears only when another presence event rewrites that person, and nothing bounds that time. The emoji and voice activity chips already solve this with a resend and an expiry. Typing should follow the same rule, so a stale bounce clears within seconds.
Acceptance criteria
Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone.
Category: bug
Current behavior:
- The sender sends
startTyping once per burst, guarded by hasStartedTyping (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/handleTypingIndicator.ts:50-58).
- It sends
stopTyping 1 s after the last key (:45-48), or at once on send (:61-64).
sendTypingIndicator ignores the send result (:35-42). It also sends nothing when broadcaster or profile is missing (:25-27). A lost stop is never resent.
- The peer sets
TYPING on startTyping and ONLINE on stopTyping, with no timer (apps/webapp/src/hooks/useBroadcastListener.ts:35-38).
- Activity chips differ. The sender resends every 3 s (
handleTypingIndicator.ts:15, :87-89). The peer expires the chip 8 s after the last start (useBroadcastListener.ts:6, :39-50).
- Presence events rewrite the status. A presence
sync sets every tracked face to ONLINE (apps/webapp/src/services/workspacePresenceSync.ts:78-89, :255-257). A join sets ONLINE (:258-266) and a leave sets OFFLINE (:267-271).
AvatarStack shows the bounce when status === 'TYPING' (apps/webapp/src/components/ui/AvatarStack.tsx:74).
- Evidence: code-traced on 2026-09-28. Not reproduced in a browser.
Desired behavior:
- Sender: in the
StartTyping branch, when hasStartedTyping is true, resend startTyping if 3 s have passed since the last start. Keep the time of the last start in module state. Reset it wherever hasStartedTyping goes back to false. No interval is needed: typing ends 1 s after the last key.
- Peer: keep a separate expiry map for typing. Do not reuse
activityExpiries. On startTyping, set TYPING and arm an 8 s timer. On stopTyping, clear the timer. When the timer fires, read the face from useStore.getState().usersPresence. Set ONLINE only if its status is still TYPING. Clear the map in the effect cleanup.
- A presence
sync still resets the face to ONLINE. The next resend brings the bounce back within 3 s.
- A client from before this change never resends. Its bounce drops after 8 s of steady typing, then returns on its next burst. Accept this.
- Use the same 3 s and 8 s values as the activity chips. 8 s stays above two resends, so one lost resend keeps the bounce.
Where to start:
handleTypingIndicator.ts: handleTypingIndicator, debouncedStopTypingIndicator, startComposerActivity.
useBroadcastListener.ts: the typingIndicator handler and the effect cleanup.
handleTypingIndicator.test.ts beside the sender: it already uses fake timers and a fresh module per test.
Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
apps/webapp/src/components/chatroom/CLAUDE.md §MessageComposer, the "Activity chips" bullet.
- AGENTS.md §Test Policy, case (c): this is a timing rule that is hard to check by hand. Add one case to the existing sender test file. Add no test for the peer.
- AGENTS.md §Documentation And Comments: comments are 4 lines at most, and say why.
- The
CLAUDE.md edit is prose. Write it in Simplified English: .cursor/skills/tech-writer/SKILL.md §Simplified English (house standard).
Verify:
bun run --filter @docs.plus/webapp test handleTypingIndicator passes.
- Prove it by sabotage. Remove the resend, and the new case must fail. Put the resend back.
bun run --filter @docs.plus/webapp typecheck passes.
bun run lint and bun run check:agent-docs pass.
- Open one pad in two browsers, signed in as two members, with the same heading chat open. Check light and dark, desktop and phone.
- Type for 10 s without a pause in browser A. Browser B keeps the bounce the whole time, in the chat participants stack and on the TOC row.
- Stop typing in A. B clears the bounce about 1 s later.
- Open DevTools in A. In its console, run
setTimeout(() => { debugger }, 2000). Type without a break until the pause hits. Keep A paused. The 1 s stop is now frozen.
- B drops the bounce about 8 s later. Before this change, B keeps it well past 8 s, until A's socket times out and B gets a leave (unverified).
Out of scope
- Any change to the activity chips, their timings, or
Profile.activity.
- Presence tracking, the heartbeat, and the
status column in Postgres.
- The look of the typing bounce.
Parent
#328.
What to build
The typing bounce on a face has no keepalive and no expiry. If the stop signal is lost, the bounce stays. It clears only when another presence event rewrites that person, and nothing bounds that time. The emoji and voice activity chips already solve this with a resend and an expiry. Typing should follow the same rule, so a stale bounce clears within seconds.
Acceptance criteria
CLAUDE.mddescribes the typing resend and expiry next to the "Activity chips" bullet.Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone.
Category: bug
Current behavior:
startTypingonce per burst, guarded byhasStartedTyping(apps/webapp/src/components/chatroom/components/MessageComposer/helpers/handleTypingIndicator.ts:50-58).stopTyping1 s after the last key (:45-48), or at once on send (:61-64).sendTypingIndicatorignores the send result (:35-42). It also sends nothing whenbroadcasterorprofileis missing (:25-27). A lost stop is never resent.TYPINGonstartTypingandONLINEonstopTyping, with no timer (apps/webapp/src/hooks/useBroadcastListener.ts:35-38).handleTypingIndicator.ts:15,:87-89). The peer expires the chip 8 s after the last start (useBroadcastListener.ts:6,:39-50).syncsets every tracked face toONLINE(apps/webapp/src/services/workspacePresenceSync.ts:78-89,:255-257). AjoinsetsONLINE(:258-266) and aleavesetsOFFLINE(:267-271).AvatarStackshows the bounce whenstatus === 'TYPING'(apps/webapp/src/components/ui/AvatarStack.tsx:74).Desired behavior:
StartTypingbranch, whenhasStartedTypingis true, resendstartTypingif 3 s have passed since the last start. Keep the time of the last start in module state. Reset it whereverhasStartedTypinggoes back to false. No interval is needed: typing ends 1 s after the last key.activityExpiries. OnstartTyping, setTYPINGand arm an 8 s timer. OnstopTyping, clear the timer. When the timer fires, read the face fromuseStore.getState().usersPresence. SetONLINEonly if itsstatusis stillTYPING. Clear the map in the effect cleanup.syncstill resets the face toONLINE. The next resend brings the bounce back within 3 s.Where to start:
handleTypingIndicator.ts:handleTypingIndicator,debouncedStopTypingIndicator,startComposerActivity.useBroadcastListener.ts: thetypingIndicatorhandler and the effect cleanup.handleTypingIndicator.test.tsbeside the sender: it already uses fake timers and a fresh module per test.Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
apps/webapp/src/components/chatroom/CLAUDE.md§MessageComposer, the "Activity chips" bullet.CLAUDE.mdedit is prose. Write it in Simplified English:.cursor/skills/tech-writer/SKILL.md§Simplified English (house standard).Verify:
bun run --filter @docs.plus/webapp test handleTypingIndicatorpasses.bun run --filter @docs.plus/webapp typecheckpasses.bun run lintandbun run check:agent-docspass.setTimeout(() => { debugger }, 2000). Type without a break until the pause hits. Keep A paused. The 1 s stop is now frozen.Out of scope
Profile.activity.statuscolumn in Postgres.