Inbox

Layout, setup, languages and strings, motion tokens and server-action auth for the team inbox.

@sendo/inbox is the team inbox. The files are copied into your app, so you own and edit them. The operator work it supports (step in, pause, notes, drafts, assignment, tags, roles) is described in Team inbox; this page covers the UI item.

Layout#

Four columns on wide screens:

  1. Icon rail: Inbox, then Numbers, Rules, Metrics and Links and QR when those items are installed (inboxNav in inbox-config.ts), and the signed-in user.
  2. Conversation list: number selector (“All active numbers” by default; “Show disconnected” or picking a disconnected number brings back its conversations, marked “· disconnected” and read-only; search always includes them), search (name, app reference or phone), status tabs with counts (Open, Assigned to me, AI agent, People, Closed) and tag filter chips (any-match). Every filter runs on the server. Each row shows the name, the app reference (or “Not linked”), the last message, “Draft” and “Paused” flags, tags, time, unread count, the assignee’s avatar, the channel badge and the 24-hour window.
  3. Thread: status badge, the Pause agent switch (supervisors and admins), Return to agent, Take conversation and Close. Banners above the messages show why the conversation was handed to a person (only while it is with a person), that the agent is paused (with Resume), and that the number was disconnected. Messages are drawn by kind: the contact (with an “Origin” chip for [#code] links, code removed), the agent (and who approved its draft), people on the team, replies sent from the WhatsApp Business app (“From the phone”), imported history, internal notes and system lines, with day separators. A pending draft appears as a card with Discard, Edit and Approve and send.
  4. Context panel (300 px): contact and app link, assignment and queue, tags, channel, number, origin, window and, for admins, the agent’s cost.

Below 1180 px the context panel opens from the thread header as a side panel; on phones it is a bottom sheet and the list and the thread take turns.

What gets installed#

PathPurpose
components/sendo/*.tsxinbox, inbox-rail, conversation-list, thread, thread-banners, message-bubble, note-bubble, system-line, draft-card, context-panel, assignee-avatar, channel-badge, tag-chip, window-indicator, composer, template-picker, status-ticks, media-preview, motion-sheet
components/sendo/strings.ts, types.ts, utils.tsEnglish and Spanish strings, view types and the actions contract, locale-aware formatters
components/sendo/strings-context.tsxThe strings context ({ strings, locale, formatLocale }) and its hooks
lib/sendo-ui.tssendoLocale: the language of every Sendo screen (Languages); the same file ships with every UI item
components/sendo/motion.cssMotion tokens (below)
app/(protected)/inbox/page.tsxThe inbox page: resolves the actor, its permissions, the numbers, the tag catalog and the first page
app/(protected)/inbox/actions.tsServer actions calling sendo.inbox.* and sendo.tags.list with the actor from requireUser() and sendo.actor()
app/(protected)/inbox/inbox-config.tsYours to edit (below)
hooks/use-sendo-realtime.tsSupabase Realtime subscription; polls instead with a null client or a failed channel
lib/sendo-realtime.tsBrowser Supabase client factory for live updates

shadcn dependencies (button, input, textarea, avatar, dialog, select, switch, tooltip, sonner) and the npm packages date-fns, motion and radix-ui are installed with it. It needs schema v2 (@sendo/schema). Styling uses your theme tokens, light and dark.

Setup#

inbox-config.ts is the only file you have to edit:

ExportWhat to do
requireUser()Return the signed-in user’s id and tenant from your auth, or redirect to your login. It throws until you implement it.
inboxRealtimeEnabled()Live updates through Supabase Realtime (on when the public Supabase env vars are set).
inboxNavPaths of the other Sendo screens you installed (/inbox/numbers, /inbox/rules, /inbox/metrics, /inbox/links); null hides the rail entry.
resolveUserNames(ids)Display names for note authors, approvers and the signed-in user. Use the same source as resolveUsers in lib/sendo.ts.
contactAppHref(contact)Link for “View … in the app” from the contact’s appRef; null shows the reference as text.
inboxAgentNamesDisplay names for your agents, e.g. { recepcion: "Recepción IA" }.

Also configure resolveRole (and resolveUsers, resolveAssignees) in lib/sendo.ts: a user whose role resolves to null sees “You don’t have access to the inbox”. Set the language in lib/sendo-ui.ts (Languages).

Server-action auth#

Every server action calls your auth check first, and the template’s requireUser() throws until you implement it (fail closed). It then resolves the user as a Sendo actor through your resolveRole; every sendo.inbox.* call takes it and enforces the role matrix (Team inbox):

app/(protected)/inbox/actions.ts
interface AppUser {
  id: string;
  tenantId: string | null;
}

// Fail closed: throw until you wire your app's session check.
async function requireUser(): Promise<AppUser> {
  throw new Error("TODO: requireUser() — return the signed-in staff user or throw.");
}

// The acting user: role and queues come from `resolveRole` in lib/sendo.ts.
// No `resolveRole`, or no role in the tenant, throws ForbiddenError (FORBIDDEN).
async function requireActor() {
  const user = await requireUser();
  return sendo.actor(user.id, user.tenantId);
}
app/(protected)/inbox/actions.ts
export async function listConversations(cursor?: string) {
  const actor = await requireActor();
  // Agents only get their own conversations and unassigned ones in their queues.
  return sendo.inbox.list({ actor, status: ["human", "agent"], cursor });
}

export async function openThread(conversationId: string, cursor?: string) {
  const actor = await requireActor();
  const page = await sendo.inbox.thread(conversationId, { actor, cursor });
  await sendo.inbox.markSeen(conversationId, { actor });
  return { ...page, items: page.items.toReversed() }; // newest first → chronological
}

export async function reply(conversationId: string, payload: InboxReplyPayload) {
  // Step-in: sends as the user; the status and the agent don't change.
  return sendo.inbox.reply(conversationId, payload, { actor: await requireActor() });
}

export async function take(conversationId: string) {
  return sendo.inbox.take(conversationId, { actor: await requireActor() });
}
  • reply stores the message as sender human with senderRef = your user id, without changing the status. Outside the window, free-form replies throw WindowClosedError before calling Meta, and the composer switches to the template picker.
  • A conversation of another tenant is RECORD_NOT_FOUND; one the user may not see or act on is FORBIDDEN.
  • The page also computes what the role allows (can(actor, …)): the pause switch, reassigning and the agent cost are hidden from roles without them. The package checks again on every call.
  • Media URLs in thread are signed for 300 seconds; request the thread again rather than storing them.

The actions contract#

<Inbox actions={...} /> receives the server actions as one object. The page passes the ones in actions.ts; you can pass your own implementation (another backend, a test double). Every action resolves to a result instead of throwing, so the inbox can show the right message:

components/sendo/types.ts
export type InboxActionResult<T> =
  | { ok: true; data: T }
  | {
      ok: false;
      code:
        | "WINDOW_CLOSED"
        | "NOT_FOUND"
        | "INVALID"
        | "FORBIDDEN"
        | "ACCOUNT_INACTIVE" // the number was disconnected: "Número desconectado"
        | "CONFLICT" // duplicate snippet shortcut or tag name
        | "UNKNOWN";
      message: string;
    };

export type InboxTab = "open" | "mine" | "agent" | "human" | "closed";

export type InboxReplyInput =
  | { kind: "text"; body: string }
  | { kind: "media"; file: File; caption?: string }
  | { kind: "template"; name: string; language: string; variables: string[] };

type Result<T> = Promise<InboxActionResult<T>>;

export interface InboxActions {
  list(input: {
    tab: InboxTab;
    accountId?: string | null;
    tagIds?: string[]; // any-match
    search?: string; // name, app reference or phone
    cursor?: string | null;
  }): Result<InboxPage<InboxConversationView> & { counts: Record<InboxTab, number> | null }>;
  /** Newest first, notes, system lines and drafts included, plus display names. */
  thread(
    conversationId: string,
    cursor?: string | null,
  ): Result<InboxPage<InboxMessageView> & { names: Record<string, string> }>;
  reply(conversationId: string, input: InboxReplyInput): Result<InboxMessageView>;
  markSeen(conversationId: string): Result<null>;
  take(conversationId: string): Result<InboxConversationView>;
  assign(conversationId: string, userId: string | null): Result<InboxConversationView>;
  returnToAgent(conversationId: string): Result<InboxConversationView>;
  close(conversationId: string): Result<InboxConversationView>;
  pauseAgent(conversationId: string, paused: boolean): Result<InboxConversationView>;
  approveDraft(messageId: string, text?: string): Result<InboxMessageView>;
  discardDraft(messageId: string): Result<null>;
  addTag(conversationId: string, tagId: string): Result<InboxTag[]>;
  removeTag(conversationId: string, tagId: string): Result<InboxTag[]>;
  listAssignees(conversationId: string): Result<InboxAssignee[]>;
  listTags(): Result<InboxTag[]>;
  listTemplates(conversationId: string): Result<InboxTemplateView[]>;
  // Optional: the UI hides note mode, the snippet picker and the admin sheets without them.
  note?(conversationId: string, text: string): Result<InboxMessageView>;
  listSnippets?(conversationId: string | null): Result<InboxSnippetView[]>;
  renderSnippet?(
    snippetId: string,
    conversationId: string,
  ): Result<{ text: string; unresolved: string[] }>;
}
  • WINDOW_CLOSED from reply switches the composer to the template picker and keeps what was typed; from approveDraft the draft card shows “The window closed; use a template”.
  • ACCOUNT_INACTIVE marks the number as disconnected: the banner appears and the composer is disabled. Reads keep working.
  • FORBIDDEN from list shows “You don’t have access to the inbox”. On a single conversation (for example one reassigned away from an agent) it removes the conversation from the list and closes it.
  • Failures show a sonner toast; successful actions don’t (the system line and the badge change are the feedback).

Realtime and RLS#

The migration adds sendo_conversations and sendo_messages to the supabase_realtime publication. Browser clients use your anon key and the user’s session, so they only receive rows for which sendo_can_access_tenant(tenant_id) is true. With the default (false) the inbox gets no live updates: implement it as in Quickstart step 4.

Without Supabase Realtime (no public Supabase env vars, inboxRealtimeEnabled() returning false, a Drizzle or Prisma adapter, or a channel that errors or times out) the inbox polls: the list every 10 s and the open thread every 3 s, paused while the browser tab is hidden. New inbound messages in the open thread are marked seen, as with realtime.

A change patches the open list and thread right away, then the list is fetched again (previews, tags, assignee names, and visibility: a conversation reassigned away from an agent leaves their list). Tag changes made by someone else (sendo_conversation_tags isn’t in the publication) show up with that refetch.

Strings#

The inbox renders in English or Spanish: the page passes locale={sendoLocale} from lib/sendo-ui.ts (default "en"), and an optional formatLocale (for example "es-GT") for dates, times, numbers and costs. Every user-facing string comes from inboxStrings[locale] in components/sendo/strings.ts and can be overridden through the strings prop, on top of the chosen language. Times use the America/Guatemala timezone by default (timeZone prop). Languages covers choosing the language, regional formats and adding a third language.

GroupKeys (examples)
Rail and accessnavNumbers, navRules, navMetrics, navLinks, accessDeniedTitle
Conversation listtabOpen, tabMine, tabAgent, tabHuman, tabClosed, searchPlaceholder, numberAll, notLinked, flagDraft, flagPaused
Thread header and bannerspauseAgent, take, returnToAgent, close, handoffBannerTitle, pausedBannerBy(name), resume, disconnectedTitle
MessagesfromPhone, importedMessage, approvedBy(name), origin(code), noteLabel(name), dayToday, actorAgent(name)
Draft carddraftTitle(agent), draftAwaiting, draftBeforeHandoff, draftApprove, draftSendEdited, draftWindowClosed
Context panelsectionAssignment, queueLabel(queue), sectionTags, fieldOrigin, originDirect, sectionCost, costTurns(n)
WindowwindowRemaining(hours, minutes), windowClosed, windowNeverOpened
Composer and templatescomposerModeReply, composerModeNote, composerHintStepIn, snippetUnresolved(names), templateExplanation

The full list is the InboxStrings type, with one object per language in inboxStrings (components/sendo/strings.ts); resolveInboxStrings(locale, overrides) merges them the way the inbox does. Some keys are functions (plurals, names); the ones that show a number or a list get format, which formats in formatLocale:

Overriding strings
// <Inbox locale={sendoLocale} strings={{ ... }} />: override any key; the rest keep that language's strings.
export const inboxStringOverrides = {
  tabHuman: "With a person",
  take: "Assign to me",
  templateExplanation: "The 24-hour window closed. Send an approved template.",
  unreadCount: (count: number, format: { number: (value: number) => string }) =>
    count === 1 ? "1 new" : `${format.number(count)} new`,
};

Motion tokens#

Inbox components animate only transform and opacity, with durations and curves from these custom properties:

components/sendo/motion.css
/* sendo inbox motion tokens. Override any of them in your own CSS. See MOTION.md. */
:root {
  --sendo-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --sendo-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --sendo-dur-press: 120ms; /* press feedback 100–160 ms */
  --sendo-dur-fast: 160ms; /* tooltips, small popovers 125–200 ms */
  --sendo-dur-menu: 200ms; /* dropdowns, selects 150–250 ms */
  --sendo-dur-sheet: 320ms; /* dialogs, sheets 200–500 ms */
}

/*
 * Reduced motion keeps the durations, so opacity and colour still fade (they help to follow a
 * state change), and removes movement: every element that moves carries `sendo-motion-transform`,
 * whose transform (rise on enter, press scale) is neutralised here. The mobile sheets are driven
 * by `motion` and switch to an opacity-only fade through `useReducedMotion()`.
 */
@media (prefers-reduced-motion: reduce) {
  .sendo-motion-transform {
    transform: none !important;
  }
}
TokenUsed for
--sendo-dur-pressPress feedback on the rail, the thread actions, the draft buttons and send (scale 0.97 on pointer-down)
--sendo-dur-fastStatus badge and status tick cross-fades, tab and tag chip colours
--sendo-dur-menuRows, bubbles, notes and draft cards that arrive while you look; a banner that appears; the draft card leaving after approval
--sendo-dur-sheetThe context side panel (768–1179 px). The phone sheets use a critically damped motion spring with drag-to-dismiss instead
--sendo-ease-out / --sendo-ease-in-outEntering elements / on-screen movement. Never ease-in.

Override them after importing motion.css. Under prefers-reduced-motion: reduce the durations stay, so opacity and colour still fade (they help to follow a state change), and movement is removed: every element that moves carries .sendo-motion-transform, whose transform is neutralised. The phone sheets read useReducedMotion() and only fade. The recipes and the reasons behind each one (and what was left out) are in the item’s MOTION.md.

app/globals.css
/* app/globals.css — after importing components/sendo/motion.css */
:root {
  --sendo-dur-menu: 160ms; /* snappier entrances */
  --sendo-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}

/* Turn every inbox motion off (opacity and colour changes become instant too). */
.inbox-static {
  --sendo-dur-press: 0ms;
  --sendo-dur-fast: 0ms;
  --sendo-dur-menu: 0ms;
  --sendo-dur-sheet: 0ms;
}