Team Chat (built-in channels and direct messages) (part 4)
Part 4 of the Team Chat page: what the feature deliberately does not do and the shortcuts still to come, then the engineering — how messages, threads, pins and reactions are stored and kept honest, how search is built and why it is not switched on yet, and what an operator still has to do to take Team Chat live.
What it is
This is part 4 of the Team Chat (built-in channels and direct messages) page. It covers the edge of the feature and the machinery underneath it: the limits of the first version and the keyboard shortcuts that are planned but not built, and then, for a reader who works on the product rather than in it, how the chat data is laid out and kept identical across the desktop app and the phone, how message search is indexed and why it answers "not switched on yet", and the handful of deployment steps an operator still owes before the team can rely on it.
Where to find it
The same surface as the parent page: once it is switched on, Team Chat appears as a built-in virtual project in the projects sidebar, with the channel and direct-message list in the sidebar pane and the open conversation filling the main panel. It ships dark until Settings → Lab → Team Chat is turned on. The parent page covers finding and enabling it.
Everything described here is behind the same surface: the Team Chat virtual project in the sidebar, switched on from Settings → Lab → Team Chat. The limits and the planned shortcuts are about the chat window itself; the engineering sections that follow are not a screen at all — they are the design notes for someone changing the code, gathered at the end so a reader who only wants to use chat can stop before them.
How it behaves
Limits / not in v1
- Teammate pickers for a new DM are wired to the live member roster (
dmCandidatesFromMembers), so a DM can be started as soon as the workspace's members materialize; the deterministic-id open path is fully wired. The member directory (a read-only roster) is built. Inviting new people by email is now available from the chat surface for admins (it reuses the workspace bulk-invite path — see "Invite teammates by email" above); removing members still lives in the workspace-setup lane (Settings → User Management). - Built v1 surface: create public/private channels + DMs, send / receive / paged history, presence + typing, @mentions (including in public channels), real names/avatars, edit/delete-own, reactions, pins, threads, profile card, member directory, unread badges, the Cmd/Ctrl+K quick switcher, per-channel notification preferences, and right-click channel management (edit name/topic, leave, delete).
- Built in this wave (v2 — see "New in this wave" above): file uploads / attachments, saved items, custom status, and scheduled send — plus message-surface polish, composer power, wired-in search, and desktop notifications + an unread badge. Editing a message re-parses
mentions[]so an added @mention renders as a chip, but does not re-NOTIFY (the notification function fires only on message create — matching Slack). - Notifications — the push-to-your-phone fan-out is delivered by the notification Cloud Function (on an @mention or DM); you control all / mentions / none globally and per channel via the Notification preferences above. The message data model carries the parsed
mentions[]it needs.
Planned keyboard shortcut enhancements
Current keyboard shortcuts cover composing (Ctrl+B/I/E formatting, send-key modes), navigation (Ctrl+K quick switcher, Ctrl+Shift+D jump to date), and thread management (Ctrl/Cmd+W close, R to focus reply, up-arrow to edit last). Planned enhancements include shortcuts for common per-message actions (pin, react, reply, save, copy link) and channel-level actions (mark as read, mute/unmute, browse channels) to bring keyboard-driven workflow parity with Slack/Discord.
For agents
How it works (for agents / engineers)
- Transport: the renderer talks to Firestore directly via the Firebase web SDK — chat data does not cross Omniscio's IPC layer. Main mints a short-lived Firebase custom token for the signed-in Global Auth user (the
team-chat:get-web-authIPC channel returns the public web config + that custom token + the user'sorganizationId), and the renderer signs in withsignInWithCustomToken. Authorization is enforced entirely by Firestore Security Rules, never by hiding the config (the web API key is public by design). - Tenancy: company-chat collections nest under
organizations/{orgId}— channels, messages, presence, read state. The activeorgIdis the user's workspace, read from a verified Firebase claim returned on the auth bridge. A user who is signed in but not yet a member of a workspace sees a distinct "not in a workspace yet" state, never a stuck spinner. (Self-serve workspaces nest underchatWorkspaces/{wsId}instead, addressed by the same tenant-agnostic path layer — see Self-serve workspaces above.) - Cost: $0 of AI spend — it is a messaging feature, not an AI feature. No Claude tokens are used.
- Listeners (cost control): the sidebar subscribes only to channel docs (not message bodies). Because the channel read rule evaluates each doc's own
visibility/memberUids, both clients list channels with two rule-shaped queries —visibility == 'public'+memberUids array-contains you, merged by id — never one unfilteredcollection()query: Firestore "rules are not filters" denies an unfiltered list wholesale (even for a valid member), which is exactly what left the desktop sidebar showing "No channels yet". Locked by contractthe-channel-read-rule-is-list-compatible. The message listener attaches only to the currently-open channel's latest ~50 messages and detaches on switch; older history is a one-shot paged read with no live listener. Presence is a single workspace-wide subscription. The Browse channels directory attaches its OWN scopedwhere('visibility','==','public')listener ONLY while the dialog is open (rule-safe — every result passes the channel read rule) and detaches on close. - Presence publisher & manual overrides: the singleton presence publisher (
use-presence-publisher.ts) heartbeatslastActiveAtevery 2 minutes. When a manual availability override is active, the publisher writes ONLY{lastActiveAt}with merge:true (never touchingstateormanualAvailability), so the override persists. When no override is set, it writes the full{state, lastActiveAt}. Thebeforeunloadhandler clears a manual 'online' pin to null before writing offline, but preserves away/dnd/offline (they're intentional).effectivePresence()in@shared/team-chat/presence-dot.tsis the single source of truth for the rendered dot — it checksmanualAvailabilityfirst, then falls back to automatic detection fromstate+ staleness. - Join / leave a public channel: an
arrayUnion(self)/arrayRemove(self)on the channel'smemberUids. The existing public-channelupdaterule already permits a member to do this, so it needs no schema or rules change. The member count shown in the directory is the JOINED count. Details + invariants: team-chat-channel-browser-contract.md. - Offline & idempotent retries: Firestore offline persistence is on, so an optimistic write queues locally and syncs on reconnect. The message's Firestore doc id IS its
clientMsgId(likeopenOrCreateDm), so an at-least-once retry after a lost ack (the F079 Retry path re-runs the SAME id) lands on the same doc — exactly-once — instead of a second auto-id copy that the PWA (which dedupes by doc id) would render twice and that would double-fireonMessageCreate(notification + unread + search).sendMessageskips the write if the doc already exists; the field-locked message-update rule is the backstop. Locked by load-check.integration.test.ts + the chat-client / PWA write tests. - Connect resilience (quiet reconnect + one self-clearing status): the Firebase sign-in every listener depends on (
getChatDb→ensureConnectedinweb-app.ts) is single-flighted and retries a transient failure silently with backoff, so a brief blip on mobile (a slower path that must reach the desktop app to mint the token) recovers with no toast. Only a sustained outage surfaces ONE sticky, coalesced, cause-aware toast (team-chat:unavailable) — "your desktop app may be asleep" (auth/session) vs "offline" (network) — that self-clears the moment a server-confirmed snapshot (!metadata.fromCache, never a cache emission) arrives;surfaceChatErrorroutes connectivity failures to that single status instead of one toast per listener. The status lives in a Firebase-freechat-connect-status.tsso the SDK stays off the first-paint graph. The workspace (org) resolve rides that retried connect rather than racing it — the connect is awaited first, then the org is read from the web-auth it just fetched — so a lookup that fails is recorded as a failure with a retry screen that the reconnect ladder heals on its own, never as the false "not in a workspace yet" state (the 2026-09-14 blank-panel outage). Details + invariants: mobile-team-chat-reconnect-contract.md. - Connect BUDGET (why a failed connect gets quieter, not louder): signing in needs a custom token from the
teamChatTokenrelay, which caps each account at 60 mints an hour — enough for many devices and restarts, since the desktop caches its token for 45 minutes against a ~60-minute life (≈1 mint an hour per app instance). If that cap is ever hit the relay answers 429 with aRetry-After, and the desktop stops calling it entirely for that (clamped: 10-minute cap, 30-second floor) window — no retry, no per-listener stampede — then resumes on its own. Meanwhile the reconnect poll backs off exponentially (20s, doubling, capped at 5 minutes) whileonline/ tab-refocus nudges stay instant, and a failed web-auth is briefly memoized so one failed connect costs one request instead of one per attempt. During that pause the toast says "Team Chat reached its connection limit. It will reconnect on its own shortly." rather than "Reconnecting…", because nothing is being attempted. This whole budget exists because the earlier daily cap plus a retry-everything client produced a self-sustaining request storm that kept Team Chat offline for the rest of the UTC day: team-chat-mint-quota-lockout-postmortem.md. - Local cache is platform-gated (so a phone opens fast on a repeat visit):
web-app.tspicks the Firestore cache by platform. The Electron desktop stays in-memory (memoryLocalCache— on-disk IndexedDB was removed there because persisting every realtime update to the busy DATA_DIR disk stalled the renderer UI thread ~120-485ms/update), while mobile / web uses the on-diskpersistentLocalCache, so a phone (which reloads the page constantly) hydrates channels/members/last-channel messages instantly from disk instead of re-fetching everything cold over the network on every open (the "Team Chat slow on mobile" fix). It degrades to in-memory if persistence can't be enabled (private mode / a 2nd tab / an insecure http context), and is wiped on a cloud-identity swap (resetChatWebAppterminates +clearIndexedDbPersistence, browser only) so a shared device can't serve the prior user's cached chat. Build kill switch:VITE_DISABLE_MOBILE_CHAT_PERSISTENCE=1. Details + invariants: mobile-team-chat-reconnect-contract.md. - Recency + unread: sending a message ALSO denormalizes
lastMessageAt/lastMessagePreview/lastMessageAuthorUidonto the channel (atomically, in the same write batch) — this powers the sidebar's "newest first" ordering and the unread fallback, independent of the notification function (which owns the authoritative per-user counts). - Reactions: each person's reactions on a message are their OWN doc at
messages/{mid}/reactions/{uid}; the UI inverts the subcollection to render emoji→count pills. Add/remove is an atomicarrayUnion/arrayRemove, so two quick taps never clobber each other, and Firestore's own offline/latency-compensation shows your reaction instantly (and rolls it back if the write is rejected) — there is no hand-rolled optimistic layer. Listeners attach only to the messages currently in view (cost control), and there is no Cloud Function and no rules change — the schema-foundation rule already gates the own-uid write. You can't react to your OWN message — the add-reaction affordance (the desktop hover-toolbar picker AND the mobile quick-reaction row) is hidden on messages you authored, with a matching no-op guard at the write chokepoint (a stale self-reaction from before this rule can still be removed). Clicking a reaction pill opens a "who reacted" popover — the list of members who reacted with that emoji (avatar + name, you shown first as "You"), plus a toggle to add or remove your own reaction; it works on desktop AND phone (tap), so the reactor list is reachable without a hover. Hovering a pill still shows the same Discord-style quick tooltip with reactor names ("You, Alice and Bob reacted with [emoji]"), resolved from themembersByUidstore slice, truncated at 5 names. Details + invariants: team-chat-reactions-contract.md. - Custom emoji: all workspace members can upload custom images by default (PNG/GIF/WebP ≤256 KB, no SVG) via the emoji picker's "+" button (
BulkEmojiUploadDialog), or the sidebar management dialog (CustomEmojiDialog, which also opensBulkEmojiUploadDialogfor uploads — up to 50 files via drag-and-drop, auto-dedup viadeduplicateShortcodes, sequential upload with per-file progress); a toggleableadminOnlyflag oncanUploadCustomEmojirestricts to owners/admins. Members delete their own; admins delete anyone's (canDeleteCustomEmoji). Metadata lands in acustomEmojiFirestore collection, the image in Storage. A liveonSnapshotlistener feeds acustomEmojiByShortcodestore slice (keyed for O(1) lookup) and accumulates the current user's own uploads intopersonalEmojiByShortcode, tagged by source workspace. Rendering surfaces use the merged effective emoji map, with workspace emoji overriding personal emoji on shortcode collisions. The personal layer survives workspace switches, is persisted to localStorage by Firebase uid, and is cleared on identity reset. In message text,:shortcode:is rendered as an<img>by theteamChatEmojiPluginremark plugin inChatProseBlock(same pattern as @mentions — text node →amc-custom-emoji:link →CustomEmojiImgcomponent override; skips code/link nodes). In reactions and custom status thecustom:<shortcode>identifier occupies the same slot as a native emoji character, so existing code needs no separate branch. TheCustomEmojiImgcomponent renders at emoji scale (xs=16 sm=20 md=24 lg=32) with intrinsic dimensions for CLS prevention. When a custom emoji can't be resolved (workspace switch, permission denied, or deleted), all surfaces render anUnresolvedEmojiChip(a styled:shortcode:inline chip) instead of rawcustom:shortcodetext. Pure validation + permission helpers live in@shared/team-chat/custom-emoji(shortcode regex + file caps +canUploadCustomEmoji+canDeleteCustomEmoji+deriveShortcodeFromFilename+deduplicateShortcodes+CUSTOM_EMOJI_BULK_MAX). Details + invariants: team-chat-custom-emoji-contract.md. - Pins: a pinned message is a doc at
channels/{cid}/pins/{messageId}(the doc id IS the pinned message id), created withpinnedBystamped to you (the Security Rule forbids spoofing it) and deleted to unpin — any channel member may pin or unpin. ONE live listener per open channel mirrors the pins into the top-of-channel "Pinned" bar (newest first, resolving each pin to its loaded message), and Firestore's own latency-compensation shows your pin instantly (rolling it back if the write is rejected). Like reactions there is no Cloud Function and no rules change — the schema-foundation rule already gates the no-spoof create + any-member delete. Details + invariants: team-chat-pins-contract.md. - Threads: a reply is simply a normal message with
parentIdset, written into the samemessagescollection (the schema-foundation extension). The channel list filters to top-level messages (parentIdnull); a small per-parent listener (where parentId == thatMessage) mirrors each visible parent's replies, so the reply count is derived on the client — no Cloud Function, and sending a reply needs no rules change (the frozen author-only message rule means a reply's author can't bump a counter on someone else's message, so v1 derives it instead). The query is a single equality filter, so it needs no new index. The reply composer reuses the channel composer (so Ctrl/Cmd+Enter sends), and a reply reconciles by its client-minted id (no double-post). Sending a reply also best-effort denormalizeslastThreadReply*onto the channel doc (a separate, decoupled write — it never breaks sending and never bumpslastMessageAt), which feeds the targeted reply Inbox notice described under Desktop notifications above; that one denorm adds a single tightly-scoped channel rule (channelReplyRecencyDenormOnly) — the lane's only rules change. Details + invariants: team-chat-threads-contract.md. - Topics (named threads): a topic is a name for a thread — the messages inside it are ordinary messages joined by the same
parentIdthreads already use, so nothing about sending changes. A reply topic's id is its anchor message's id, which is exactly whatparentIdhas always meant, so every reply ever sent is already inside a topic and no data had to be migrated; a topic started from scratch gets atop_<uuid>id, and that prefix is what tells the pane not to expect a first message. The name lives in a newchannels/{cid}/topics/{topicId}collection holding only title/creator/timestamps — deliberately not on the channel doc, whose field list is strictly gated and once broke every desktop send for four days when a key was missed. Titles are derived on the client from the topic's first message (free, instant, works offline and on a phone), capped well under what the rule accepts so a boundary bug can't become a rejected write. Writing the topic is decoupled from sending and retried on every message, so it can never fail a send and a rejected create repairs itself. Renaming CREATES the record if it is not there yet — a topic's record is only written on the first send, so a plain update would have failed both for a topic you name before sending and for every thread that predates this feature. If the rules aren't deployed the listener gets a permission error and the whole topics UI hides itself, leaving the app exactly as it was. Details + invariants: team-chat-topic-threads-contract.md. - Quick switcher (desktop): a Cmd/Ctrl+K palette scoped to the Team Chat surface. A Team-Chat-scoped keydown dispatcher (
isTeamChatActiveincontext.ts+team-chat.ts) intercepts the combo in the one keydown router BEFORE the globalglobalSearchbinding resolves, so it never hijacks Ctrl+K elsewhere — and it stands down itself for the composer's message textarea, where Ctrl+K is INSERT LINK instead (the toolbar and the rest of the surface keep the switcher). The match/rank is a pure module (fuzzy-rank.ts— case-insensitive subsequence, ranks start-of-name / word-boundary / consecutive runs first); the overlay (TeamChatQuickSwitcher.tsx) is a thin shell over the sharedCommandPaletteShell, reads channels read-only, resolves DM labels via the samechannelDisplayNamethe sidebar uses, opens the pick via the store'sopenChannel, and auto-closes if Team Chat stops being the active surface. Wiring rules: keyboard-shortcut-dispatch-contract.md.
Message search (search infrastructure)
Firestore has no native full-text search, so Team Chat search is a Firestore-mirror index maintained server-side. A Cloud Function (onMessageWritten) mirrors each message's tokenized words into a hidden {workspaceRoot}/searchIndex/{mid} doc on create / edit / delete (idempotent, best-effort, kill switch TEAMCHAT_SEARCH_DISABLED=1). The workspace root is organizations/{orgId} for company workspaces or chatWorkspaces/{wsId} for self-serve workspaces, driven by the same WorkspaceRef that the rest of Team Chat uses. The index is admin-only in the Security Rules — no client can read it directly.
Searching goes through a single callable, teamChatSearch, which is the trust boundary: the caller passes a WorkspaceRef (workspaceKind + workspaceId), and the function confirms the caller is a member of that workspace (the server-side members/{uid} doc, NOT a token claim), resolves which channels they may read (public + member-of private/dm, mirroring the rules' channelAllows), finds matches, and drops any hit from a channel the caller can't read before returning — a private channel you're not in never leaks even on a word match. v1 is recency-ordered, whole-word + prefix matching (proj → project), Latin-ish (CJK is a follow-up). The v2 wave wired it in: the sidebar magnifier opens a TeamChatSearchDialog overlay that drives the debounced useMessageSearch hook (which takes the store's activeWorkspace ref); a result jumps to its channel, and a not-yet-deployed backend degrades to a humanized "search isn't switched on for this workspace yet." Like the rest of Team Chat the search backend is not deployed (authored + pure/emulator-tested; the emulator suites need JDK 21). Design + invariants + the deploy checklist (composite index, rules, backfill): team-chat-search-contract.md.
Search filters (client-side, wired)
The search dialog includes a toggleable filter bar with author and date range filters. Filter values are passed through SearchCallRequest.filters to the Cloud Function. The server-side index already carries channelId, authorUid, and createdAt fields, so applying filters requires only corresponding Firestore where clauses in the callable once deployed. Until then the filters are included in the request but have no server-side effect.
Planned: cross-workspace unified search
Search currently operates within the active workspace only. Users in multiple workspaces must switch between them to find messages. A future enhancement would fan out the query across all workspaces the user is a member of (iterating membership docs server-side), merge and de-duplicate results, and present them in a unified activity-feed-style result list. This requires server-side changes (the callable must resolve memberships across workspace roots) and a new client surface to display cross-workspace results with workspace badges. Documented as a follow-on in team-chat-search-contract.md.
- Composer: the message box (TeamChatComposer.tsx) produces only the message TEXT. Formatting + emoji insert at the caret (standard markdown — the message-row lane renders it). Rich / Google-Docs paste → Markdown routes through the shared
usePastedTextComposer({ trimToChip: false })engine (tier-2, convert-only): a structural-HTML paste is converted and spliced at the caret viaapplyChange; a plain-text or file-only paste returns{ consumed: false }, so native paste + the panel wrapper's file-paste handler still run. Gated bypasteRichTextAsMarkdown(Ctrl+Shift+V carries no HTML → plain). No chip (unlike the session composer's tier-1). See composer-paste-engine-contract.md. Send key is a user preference (teamChatSubmitKeyMode, default'enter'): the sharedisPlainEnterSubmithelper (same one the session composer uses) owns the plain-Enter rule — plain Enter sends in Enter-to-send mode, is a newline in Ctrl+Enter mode — and Ctrl/Cmd+Enter always sends. The send branch sits AFTER the @mention/emoji typeahead handlers, so an open popup still consumes Enter to pick. List auto-continuation reuses the sharedcomputeListContinuationhelper fromlib/list-continuation.ts(same as the session composer) — wired into theonKeyDownhandler with IME / key-repeat / Ctrl+Meta guards, firing on the newline half of the active submit mode (Shift+Enter in Enter-to-send, plain Enter in Ctrl+Enter). Outlined lists build on the same marker grammar (parseListLine, exported from that file so both halves read ONE definition of "a list line"): ordered lists renumber themselves from their own first number — the run the caret is in when the newline key or Tab/Shift+Tab changes the list's shape, and every run in the message on send — so a duplicated or skipped number (the reported4.twice) never reaches the reader, while the numbers a list was STARTED at, its content, its indentation, its./)style, bullets and fenced code are left byte-identical; and Tab / Shift+Tab nest and un-nest an item (list-outline.ts), carrying the item's sub-tree and sizing the step from the PARENT's own marker column (2 after-, 3 after1., 4 after10.) — which is the column markdown actually requires for the child to render as a nested list instead of decaying into a literal continuation line; an item dropped into a new level restarts at 1 (only1.may interrupt a paragraph). Tab is intercepted ONLY on a list line — elsewhere it keeps moving focus, an open typeahead has already claimed it, and mid-composition it belongs to the IME; inside a list it is always consumed, so Shift+Tab at the outermost level leaves the line alone rather than dropping focus out of the box. Links come from ONE builder (composer-link.ts) so the keyboard and the paste path cannot drift: Ctrl/Cmd+K (and Ctrl/Cmd+Shift+U, Slack's chord, for the same action) wraps the selection as[selection](with the caret parked where the URL goes, or inserts an empty[]()with the caret between the brackets when nothing is selected, and pasting a single bare http(s) URL over selected text replaces that text with[selection](url)— a pasted URL with no selection, a copied sentence, or rich HTML all still take the shared paste path untouched. Ctrl+K reaches the box through TWO stand-downs, and both are needed: the composer yields the global Search binding (the same one-row yield the notes editor and the Tasks outliner declare, so search keeps Ctrl+K everywhere else and gets it back the moment focus leaves) — and the quick switcher below yields the message box's textarea, because its scoped dispatcher runs AHEAD of the yield table and would otherwise swallow the chord before the composer ever saw it. Either one alone leaves the feature dead or doubles it up with search. The @mention typeahead shows display NAMES and inserts@<displayName>;resolveMentionsForSendswaps to@<uid>at send time — the only formparseMentions(text, channel.memberUids)(@shared/team-chat/mentions.ts) turns intomentions[]— so mentions need NO change toonSend. Member names come from the store's read-onlymembersByUidslice (the typeahead is simply empty until the identity/directory lane materializes it). Invariants + tests: team-chat-composer-contract.md. - Message body render: the row renders the body through
ChatProseBlock(AgentMarkdown.tsx) — the sharedremarkGfm+rehypeHighlightSubset+markdownComponentspipeline (same as operator messages), plus a small remark step that chips@<uid>tokens inmentions[](mention-in-code/link stays literal). It is lazy-loaded (the ~722 KB markdown-vendor chunk stays off the mobile entry graph) with thesplitMessageIntoSegmentsplain-text+chip render as the Suspense fallback, and is XSS-safe by default (norehype-raw;urlTransformstripsjavascript:/data:). URL autolinking: remark-gfm linkshttp(s)/www/email in the loaded state, and the Suspense fallback now runs its plain-text runs through the shared LinkifiedText so a URL is never briefly dead while the chunk loads; the standalone mobile PWA (plain text, no markdown engine) got its own small http(s)/www linkifier (linkify-message.tsx). Invariants: D9 in team-chat-desktop-contract.md. - Invite by email (in-chat, admin-only): the sidebar header Invite button + the New-DM-picker "Invite by email" link open
InviteTeammateDialog, which callsinviteTeammatesByEmail→ the existingGLOBAL_AUTH_ADMIN_BULK_INVITE(bulkInviteToOrg) with the chat store'sorgId— no new backend, IPC channel, or schema. Visibility is gated bycanInviteTeammates(role, orgId)(global role owner/admin + a workspace), mirroring how Settings → User Management is gated — a non-admin sees neither entry point; the cloud function re-enforces admin + tenant scope, so the renderer gate is UX-only. A rejected write is surfaced viasurfaceIpcError(never a false success). Because the invitee'smembers/{uid}mirror doc only materializes at first sign-in (Lane A), the success copy promises they appear "once they accept and sign in," not instantly. Invariant D11 + tests: team-chat-desktop-contract.md. - Dev-only "test teammate" seam (testing aid — never ships): under
npm run devONLY, an admin can type emails in the invite dialog to drop fake "(test)" teammates straight into the LOCAL New-DM picker — exercising the invite + picker flow solo with no second account and zero cloud writes. The dialog (given the host's dev-gatedonSeedTestTeammatescallback) seeds a SEPARATEtestMembersByUidstore slice and skips the backend entirely (noinvited:doc); opening a DM to atest:-prefixed uid is refused BEFORE any Firestore write. Hard-gated onisDevTestSeamEnabled()(import.meta.env.DEV) and tree-shaken from the packaged app — the load-bearing guarantee iscombineWithTestMembers(real, test, false)returning the real roster UNCHANGED, so a shipped build can never surface a fake. Invariant D12 + tests: team-chat-desktop-contract.md. - Identity layer (desktop): a members listener (
organizations/{orgId}/members) feeds amembersByUidstore slice that EVERY desktop lane reads (rows, DM names, pickers); message rows resolve real names/avatars and highlight@mentionsfrom it. The roster self-heals: both roster listeners (useMembersListener+ the always-onuseOrgMembersListener) route through the sharedsubscribeMembersWithRetry, which re-attaches on a bounded backoff when the members snapshot errors (exceptpermission-denied) or its first server snapshot stalls — so a failed/stalled roster load recovers WITHOUT an app restart, and a DM name shows a "Loading…"/"Unknown member" fallback (never a raw uid) until it resolves (the mobile "DM names show raw ids, a restart fixes it" fix; contract D10). Unread badges clear because three pieces now exist together — a reads listener that populates the read state, an optimistic local clear on open, and themarkReadwrite (gated so it can't write-storm); a stale-snapshot merge keeps the optimistic clear from flickering back. Invariants + tests: team-chat-desktop-contract.md.
Related
This is one of four pages describing Team Chat. The parent page describes the feature and what the first version can do. Its three companions are part 2 on the second wave of features and workspaces, part 3 on the phone client, notifications and the smaller surfaces, and part 4 on the limits and the engineering behind it.
Two pages outside this set are worth having open alongside it: cross-org-connections.md for connecting with someone in another organization, and session-starter-bot.md for the bot that starts a Claude session from a channel message.
Last verified 2026-10-01