---
title: Team Chat (built-in channels and direct messages) (part 4)
---

# Team Chat (built-in channels and direct messages) (part 4)

## What it is

This is part 4 of the [Team Chat (built-in channels and direct messages)](team-chat.md) 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](team-chat.md) 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-auth` IPC channel returns the public web config + that custom token + the user's `organizationId`), and the renderer signs in with `signInWithCustomToken`. 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 active `orgId` is 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 under `chatWorkspaces/{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 unfiltered `collection()` 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 contract `the-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 scoped `where('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`) heartbeats `lastActiveAt` every 2 minutes. When a manual availability override is active, the publisher writes ONLY `{lastActiveAt}` with merge:true (never touching `state` or `manualAvailability`), so the override persists. When no override is set, it writes the full `{state, lastActiveAt}`. The `beforeunload` handler 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.ts` is the single source of truth for the rendered dot — it checks `manualAvailability` first, then falls back to automatic detection from `state` + staleness.
- **Join / leave a public channel:** an `arrayUnion(self)` / `arrayRemove(self)` on the channel's `memberUids`. The existing public-channel `update` rule 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](../../.claude/memory/contracts/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`** (like `openOrCreateDm`), 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-fire `onMessageCreate` (notification + unread + search). `sendMessage` skips the write if the doc already exists; the field-locked message-update rule is the backstop. Locked by [load-check.integration.test.ts](../../firebase/functions/src/team-chat/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` → `ensureConnected` in `web-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; `surfaceChatError` routes connectivity failures to that single status instead of one toast per listener. The status lives in a **Firebase-free** `chat-connect-status.ts` so 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](../../.claude/memory/contracts/mobile-team-chat-reconnect-contract.md).
- **Connect BUDGET (why a failed connect gets quieter, not louder):** signing in needs a custom token from the `teamChatToken` relay, 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 a `Retry-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) while `online` / 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](../../.claude/memory/postmortems/team-chat-mint-quota-lockout-postmortem.md).
- **Local cache is platform-gated (so a phone opens fast on a repeat visit):** `web-app.ts` picks 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-disk** `persistentLocalCache`, 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 (`resetChatWebApp` terminates + `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](../../.claude/memory/contracts/mobile-team-chat-reconnect-contract.md).
- **Recency + unread:** sending a message ALSO denormalizes `lastMessageAt`/`lastMessagePreview`/`lastMessageAuthorUid` onto 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 atomic `arrayUnion`/`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 the `membersByUid` store slice, truncated at 5 names. Details + invariants: [team-chat-reactions-contract.md](../../.claude/memory/contracts/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 opens `BulkEmojiUploadDialog` for uploads — up to 50 files via drag-and-drop, auto-dedup via `deduplicateShortcodes`, sequential upload with per-file progress); a toggleable `adminOnly` flag on `canUploadCustomEmoji` restricts to owners/admins. Members delete their own; admins delete anyone's (`canDeleteCustomEmoji`). Metadata lands in a `customEmoji` Firestore collection, the image in Storage. A live `onSnapshot` listener feeds a `customEmojiByShortcode` store slice (keyed for O(1) lookup) and accumulates the current user's own uploads into `personalEmojiByShortcode`, 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 the `teamChatEmojiPlugin` remark plugin in `ChatProseBlock` (same pattern as @mentions — text node → `amc-custom-emoji:` link → `CustomEmojiImg` component override; skips code/link nodes). In reactions and custom status the `custom:<shortcode>` identifier occupies the same slot as a native emoji character, so existing code needs no separate branch. The `CustomEmojiImg` component 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 an `UnresolvedEmojiChip` (a styled `:shortcode:` inline chip) instead of raw `custom:shortcode` text. 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](../../.claude/memory/contracts/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 with `pinnedBy` stamped 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](../../.claude/memory/contracts/team-chat-pins-contract.md).
- **Threads:** a reply is simply a **normal message with `parentId` set**, written into the same `messages` collection (the schema-foundation extension). The channel list filters to top-level messages (`parentId` null); 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 denormalizes `lastThreadReply*` onto the channel doc** (a separate, decoupled write — it never breaks sending and never bumps `lastMessageAt`), 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](../../.claude/memory/contracts/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 `parentId`** threads already use, so nothing about sending changes. A reply topic's id **is** its anchor message's id, which is exactly what `parentId` has 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 a `top_<uuid>` id, and that prefix is what tells the pane not to expect a first message. The name lives in a new `channels/{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](../../.claude/memory/contracts/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 (`isTeamChatActive` in [`context.ts`](../../src/renderer/src/hooks/keyboard-shortcuts/context.ts) + [`team-chat.ts`](../../src/renderer/src/hooks/keyboard-shortcuts/team-chat.ts)) intercepts the combo in the one keydown router BEFORE the global `globalSearch` binding resolves, so it never hijacks Ctrl+K elsewhere. The match/rank is a **pure** module ([`fuzzy-rank.ts`](../../src/shared/fuzzy-rank.ts) — case-insensitive subsequence, ranks start-of-name / word-boundary / consecutive runs first); the overlay ([`TeamChatQuickSwitcher.tsx`](../../src/renderer/src/features/team-chat/quick-switcher/TeamChatQuickSwitcher.tsx)) is a thin shell over the shared `CommandPaletteShell`, reads channels read-only, resolves DM labels via the same `channelDisplayName` the sidebar uses, opens the pick via the store's `openChannel`, and auto-closes if Team Chat stops being the active surface. Wiring rules: [keyboard-shortcut-dispatch-contract.md](../../.claude/memory/contracts/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](../../.claude/memory/contracts/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](../../.claude/memory/contracts/team-chat-search-contract.md).

- **Composer:** the message box ([TeamChatComposer.tsx](../../src/renderer/src/features/team-chat/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 via `applyChange`; a plain-text or file-only paste returns `{ consumed: false }`, so native paste + the panel wrapper's file-paste handler still run. Gated by `pasteRichTextAsMarkdown` (Ctrl+Shift+V carries no HTML → plain). No chip (unlike the session composer's tier-1). See [composer-paste-engine-contract.md](../../.claude/memory/contracts/composer-paste-engine-contract.md). **Send key is a user preference** (`teamChatSubmitKeyMode`, default `'enter'`): the shared `isPlainEnterSubmit` helper (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 shared `computeListContinuation` helper from `lib/list-continuation.ts` (same as the session composer) — wired into the `onKeyDown` handler 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). The @mention typeahead shows display NAMES and inserts `@<displayName>`; `resolveMentionsForSend` swaps to `@<uid>` at send time — the only form `parseMentions(text, channel.memberUids)` (`@shared/team-chat/mentions.ts`) turns into `mentions[]` — so mentions need NO change to `onSend`. Member names come from the store's read-only `membersByUid` slice (the typeahead is simply empty until the identity/directory lane materializes it). Invariants + tests: [team-chat-composer-contract.md](../../.claude/memory/contracts/team-chat-composer-contract.md).
- **Message body render:** the row renders the body through `ChatProseBlock` ([AgentMarkdown.tsx](../../src/renderer/src/components/ui/AgentMarkdown.tsx)) — the shared `remarkGfm` + `rehypeHighlightSubset` + `markdownComponents` pipeline (same as operator messages), plus a small remark step that chips `@<uid>` tokens in `mentions[]` (mention-in-code/link stays literal). It is **lazy-loaded** (the ~722 KB markdown-vendor chunk stays off the mobile entry graph) with the `splitMessageIntoSegments` plain-text+chip render as the Suspense fallback, and is **XSS-safe by default** (no `rehype-raw`; `urlTransform` strips `javascript:`/`data:`). **URL autolinking:** remark-gfm links `http(s)`/`www`/email in the loaded state, and the Suspense fallback now runs its plain-text runs through the shared [LinkifiedText](../../src/renderer/src/components/ui/LinkifiedText.tsx) 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](../../firebase/team-chat-pwa/src/ui/linkify-message.tsx)). Invariants: D9 in [team-chat-desktop-contract.md](../../.claude/memory/contracts/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 calls `inviteTeammatesByEmail` → the existing `GLOBAL_AUTH_ADMIN_BULK_INVITE` (`bulkInviteToOrg`) with the chat store's `orgId` — **no new backend, IPC channel, or schema**. Visibility is gated by `canInviteTeammates(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 via `surfaceIpcError` (never a false success). Because the invitee's `members/{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](../../.claude/memory/contracts/team-chat-desktop-contract.md).
- **Dev-only "test teammate" seam (testing aid — never ships):** under `npm run dev` ONLY, 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-gated `onSeedTestTeammates` callback) seeds a SEPARATE `testMembersByUid` store slice and **skips the backend entirely** (no `invited:` doc); opening a DM to a `test:`-prefixed uid is refused BEFORE any Firestore write. Hard-gated on `isDevTestSeamEnabled()` (`import.meta.env.DEV`) and tree-shaken from the packaged app — the load-bearing guarantee is `combineWithTestMembers(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](../../.claude/memory/contracts/team-chat-desktop-contract.md).
- **Identity layer (desktop):** a members listener (`organizations/{orgId}/members`) feeds a `membersByUid` store slice that EVERY desktop lane reads (rows, DM names, pickers); message rows resolve real names/avatars and highlight `@mentions` from it. **The roster self-heals:** both roster listeners (`useMembersListener` + the always-on `useOrgMembersListener`) route through the shared `subscribeMembersWithRetry`, which re-attaches on a bounded backoff when the members snapshot errors (except `permission-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 the `markRead` write (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](../../.claude/memory/contracts/team-chat-desktop-contract.md).

## Related

This is one of four pages describing Team Chat. The [parent page](team-chat.md) describes the feature and what the first version can do. Its three companions are [part 2](team-chat-part-2.md) on the second wave of features and workspaces, [part 3](team-chat-part-3.md) on the phone client, notifications and the smaller surfaces, and [part 4](team-chat-part-4.md) on the limits and the engineering behind it.

Two pages outside this set are worth having open alongside it: [cross-org-connections.md](cross-org-connections.md) for connecting with someone in another organization, and [session-starter-bot.md](session-starter-bot.md) for the bot that starts a Claude session from a channel message.
