Queue
Product guide
The inbox is the team command center for WhatsApp conversations: triage the queue, own the next action, reply with context, and keep every customer thread moving.
Queue
Thread
Context
Main concepts
The working thread between a WhatsApp channel and a customer or group.
The teammate who owns the next action and keeps responsibility clear.
The operational state of the thread, such as open, pending, or closed.
Lightweight labels for organization, follow-up, and broadcast segmentation.
Internal context that stays inside ReplyBox and is never sent to WhatsApp.
A timeline of important state changes, ownership moves, and operational context.
Use status, assignee, channel, tags, pins, stars, unread state, and search to find the right thread.
Assign the conversation before replying so teammates know who is driving the next step.
Check the contact rail, CRM details, notes, and activity before sending a customer-facing message.
Resolve completed conversations and leave active work open for the next pass.
Preferences and AI
Incognito read-receipt preference is stored server-side in InboxPrefs, so it follows the agent across devices and remains knowable by support. The message AI panel can show audio transcripts, image or sticker captions, and tenant-scoped translations. Automatic transcription stays narrow to PTT or voice_note audio for cost control, while agents can request on-demand transcripts for non-PTT audio when the UI offers the action.
Realtime behavior
The web client receives Socket.IO events for message.created and conversation.updated, then folds them into React Query caches. Do not implement realtime by triggering refetches on every event.
List row data
last_message_statusA nullable conversation column mirroring the delivery status of the conversation's last message (sent, delivered, read), used by the inbox list to render WhatsApp-style ticks for OUTBOUND previews. It was backfilled once on migration, and rollup writers plus receipt-sync logic keep it current. Clients must treat NULL as “no tick” rather than guessing a state.
first_message_atA denormalized TIMESTAMPTZ recording the earliest INBOUND message, that is, when the customer first wrote. Backfilled once on migration. Only INBOUND messages may move it, so it can move backwards during history backfills. NULL means the customer has never written; never substitute created_at.
List rows and contact drawers render without extra per-row aggregates. Clients reading list rows find both fields present where applicable, and the UI should prefer rendering nothing when a value is NULL.
Web presentation
Typing shows WhatsApp-desktop style three dots in place and replaces the preview line with an italic “typing…” text. The chat content slides up and down on a single measured curve (288ms, ease-out cubic) while the typing bubble scales from its tail corner.
The element stays mounted while it animates out, so conversation content shifts smoothly instead of jumping.
Keyboard focus is a teal inset ring, distinct from the hover fill, and the open chat row highlight is a translucent teal “glass” slide-in rather than a flat grey fill.
Clicking the chat header name opens the WhatsApp-style profile sheet, the same sheet the avatar opens, for one-to-one and group chats. The control exposes “Open profile” or “Open group info”, and the visible title text remains the accessible label. The inline rename pencil stays next to the name at all widths and is visible on touchscreens, with no hover required. Header geometry, typography, and the note and action affordances are unchanged.
Filter chips
Unread, Not replied, Individuals, and Groups are authoritative workspace totals from the API, which returns four required fields: unread, not_replied, individuals, and groups. Chips in the chip row, the filter rail, and the views menu all prefer them.
Against an older API (fields absent), or while the API is unavailable, chips show a browser-derived count marked partial, for example “12+” or “14+”, so numbers never contradict after loading more pages. The client-side resolver lives in lib/inbox/bucketCounts.ts, which formats totals (the “99+” cap is preserved) and marks partial counts. The Individuals bucket matches the list filter semantics (kind === "direct").
The Unread chip can lag a server refresh by up to ~25s (15s realtime coalescing plus a 10s Redis TTL), so the client invalidates inboxKeys.counts when a conversation is marked read and the chip refreshes as soon as the server confirms.
The API cache key is versioned as counts:v2:, so new code does not read old four-key entries during the TTL overlap. Members restricted to no channels see explicit zeros for every badge. Cross-check the exact SQL with apps/api/scripts/print_counts_sql.py. Deploy API and schema changes before shipping UI that consumes the new fields.
Notes and Not replied
has_note, notes_count, and what counts as unansweredInbox LIST rows carry has_note (bool) and notes_count (int), so the UI can show a note marker and a count without extra calls. has_note is a correlated EXISTS subquery and notes_count a correlated scalar SELECT count(*), which preserves ordering and keyset pagination and avoids an N+1 query per row.
The conversations LIST endpoint accepts ?has_note=1|0; omitting it returns all conversations. The client sends has_note=1 and the server narrows the results. The old client-side “pretend” narrowing is gone: toggling the chip refetches instead of re-slicing a cached page, and if an older API ignores the parameter the list stays unfiltered rather than empty.
has_note is not computedSingle-conversation reads, socket fan-out, and search responses default has_note to false and notes_count to 0. Clients must treat false from those surfaces as “not computed here”, not “definitively no notes”.
The “Chats Not Replied” bucket and saved rules exclude group chats: a group is a room rather than a customer waiting on a reply, so it no longer inflates the count. The derived client-side fallback skips groups to match, and UI counts are authoritative only when the API exposes the matching fields.