Guide 14
Build a Customer Support Inbox
This guide builds a Customer Support Inbox (queue + conversation + compose) using the canonical router model: a calm file router map, busy component router motion, and route groups for shared state across list/detail/compose without prop-threading.
The signature technique is the group contract:
- the queue, thread, and composer all read the same group-level filter truth,
- the global route owns selection + layout posture,
- local route documents own per-panel tabs and draft behavior.
Keep these references open while you build:
What You’re Building
Requirements
- A queue list with shared filters:
status,assigned,channel,q,sort. - A conversation detail view that stays mounted when the queue scrolls.
- A composer that opens as an overlay (component route) without changing file routes.
- Stored derived “triage score” per thread (visible in the queue).
- Render-visible navigation that self-corrects (no stale selection, no dead-end compose).
Non-requirements
- Perfect transport. We’ll use render fetch and optional streaming blocks.
- Perfect deduplication. Duplicate fetching is the recommended freshness posture.
Always-ready statement
Queue UI renders from stored derived structures:
visibleThreadIds,triageById,groupedByStatus, andpreviewLinesById.
Route Map (File Router) + Motion (Component Router)
Suggested file map:
app/
support/
page.tsx -> File route entry
SupportShell.tsx -> Global route + group contract
group.ts -> groupRoutes + defaults
vaults.ts -> Optional vaults (queue cache, drafts)
queue/
SupportQueueRoute.tsx -> Queue list + triage derivation
SupportQueueFilters.tsx -> Group filters (multi-truth)
thread/
ThreadRoute.tsx -> Conversation panel + tabs
ThreadDerivations.ts -> Stored derived evidence for thread
Composer.tsx -> Composer overlay
tests/
support-flow.test.ts -> Route-flow test + snapshots
Component motion keys:
- Global route (
useGlobalRoute("support", ...)):selectedId,panel,density - Group state (
useGroupState("support", ...)):status,assigned,channel,q,sort - Local route (
useRouteState(...)):tab,composeOpen,draftMode
URL as hint:
/support?status=openseeds group state once, then group truth overwrites it after render.
Step 1: Define the Group Contract (Group Routes)
Route groups are the standard way to share state across related panels without turning your shell into a prop API.
import { createAppRouter, groupRoutes } from "react";
export const app = createAppRouter({ routerMode: "mixed" });
// Establish a "support" group for queue + thread + compose.
groupRoutes(app, { group: "support" });
Step 2: Build the Shell (Global + Group Together)
The shell binds global route truth and group truth into one readable contract.
import {
AwaitBoundary,
useGlobalRoute,
useGroupState,
useRouteJump,
useRouteMiddleware,
useShellTitle,
} from "react";
export function SupportShell({ children }) {
useShellTitle("Support Inbox");
const route = useGlobalRoute("support", {
selectedId: null,
panel: "split", // split | queue | thread
density: "comfortable", // comfortable | compact
});
const group = useGroupState("support", {
status: "open",
assigned: "any",
channel: "any",
q: "",
sort: "triage", // triage | newest | oldest
});
const jump = useRouteJump();
useRouteMiddleware((ctx) => {
ctx.set("supportStatus", group.read("status"));
ctx.set("supportAssigned", group.read("assigned"));
ctx.set("supportChannel", group.read("channel"));
ctx.set("supportSelectedId", route.read("selectedId"));
});
// Render-visible navigation intent: thread panel only makes sense with a selection.
if (route.read("panel") === "thread" && !route.read("selectedId")) {
route.write("panel", "queue");
jump.to("/support");
}
return (
<AwaitBoundary fallback={<div>Loading support shell…</div>}>
<div data-density={route.read("density")} data-status={group.read("status")}>
{children}
</div>
</AwaitBoundary>
);
}
Why this is standard:
- Group state is shared without prop threading.
- Global route stays focused on selection + layout posture.
- Middleware publishes the “inbox contract” to nested segments.
Step 3: Queue Route (Stored Derived Triage)
The queue route owns the queue document and stores derived evidence that the UI can render immediately.
import { useLiveState, useRenderEffect, useRenderFetch } from "react";
import { deriveQueue } from "./deriveQueue";
import { SupportQueueFilters } from "./SupportQueueFilters";
export function SupportQueueRoute({ route, group }) {
const threads = useRenderFetch("/api/support/threads", { cache: "bypass" });
const doc = useLiveState("support:queue", {
threads: [],
visibleThreadIds: [],
groupedByStatus: { open: [], waiting: [], closed: [] },
triageById: {},
previewLinesById: {},
});
useRenderEffect(() => {
doc.write("threads", threads.items ?? []);
deriveQueue(doc, {
status: group.read("status"),
assigned: group.read("assigned"),
channel: group.read("channel"),
q: group.read("q"),
sort: group.read("sort"),
});
return "support:queue:derived";
});
return (
<section>
<header className="row">
<h2>Queue</h2>
<button onClick={() => route.write("density", route.read("density") === "compact" ? "comfortable" : "compact")}>
Density: {route.read("density")}
</button>
</header>
<SupportQueueFilters group={group} />
<SupportQueueList
doc={doc}
selectedId={route.read("selectedId")}
onSelect={(id) => {
route.write("selectedId", id);
route.write("panel", "thread");
}}
/>
</section>
);
}
Derivation: triage score + grouped buckets
Triage score is stored derived state so list rendering stays “always ready”.
export function deriveQueue(doc, filters) {
const threads = doc.read("threads") ?? [];
const q = String(filters.q ?? "").toLowerCase().trim();
const visible = threads.filter((t) => {
const statusOk = filters.status === "any" ? true : t.status === filters.status;
const assignedOk = filters.assigned === "any" ? true : t.assignedTo === filters.assigned;
const channelOk = filters.channel === "any" ? true : t.channel === filters.channel;
const queryOk = q.length === 0 ? true : `${t.subject} ${(t.preview ?? "")}`.toLowerCase().includes(q);
return statusOk && assignedOk && channelOk && queryOk;
});
const triageById = {};
const previewLinesById = {};
const groupedByStatus = { open: [], waiting: [], closed: [] };
const sorted =
filters.sort === "oldest"
? visible.slice().sort((a, b) => (a.lastMessageAt ?? 0) - (b.lastMessageAt ?? 0))
: filters.sort === "newest"
? visible.slice().sort((a, b) => (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0))
: visible
.slice()
.sort((a, b) => triageScore(b) - triageScore(a));
for (const thread of sorted) {
triageById[thread.id] = triageScore(thread);
previewLinesById[thread.id] = previewLines(thread);
groupedByStatus[thread.status ?? "open"].push(thread.id);
}
doc.write("visibleThreadIds", sorted.map((t) => t.id));
doc.write("triageById", triageById);
doc.write("previewLinesById", previewLinesById);
doc.write("groupedByStatus", groupedByStatus);
}
function triageScore(thread) {
const now = Date.now();
const ageMinutes = Math.max(0, (now - (thread.lastMessageAt ?? now)) / 60000);
const vip = thread.tags?.includes("vip") ? 1 : 0;
const angry = thread.preview?.toLowerCase().includes("refund") ? 1 : 0;
const sla = thread.slaBreachRisk ?? 0.5;
return Math.round(ageMinutes * 0.2 + vip * 20 + angry * 10 + sla * 30);
}
function previewLines(thread) {
const base = String(thread.preview ?? "").split("\n").slice(0, 2);
return base.length ? base : ["(no preview)"];
}
Step 4: Thread Route (Duplicate Fetching by Design)
The thread panel is allowed to fetch the thread again. Duplicate fetching is the standard freshness posture.
import { AwaitBoundary, useLiveState, useRenderEffect, useRenderFetch, useRouteState } from "react";
import { deriveThreadEvidence } from "./ThreadDerivations";
import { Composer } from "./Composer";
export function ThreadRoute({ route, group }) {
const id = route.read("selectedId");
const local = useRouteState({ tab: "messages", composeOpen: false });
const thread = useRenderFetch(`/api/support/thread/${id}`, { cache: "bypass" });
const doc = useLiveState(`support:thread:${id ?? "none"}`, {
subject: "",
status: "open",
messageCount: 0,
derivedSummary: "",
});
useRenderEffect(() => {
deriveThreadEvidence(doc, thread.item);
// Self-correction rule: if the thread is closed but group filter is "open", widen.
if (thread.item?.status === "closed" && group.read("status") === "open") {
group.write("status", "any");
return "support:self-correct:status";
}
return "support:thread:derived";
});
if (!id) return <div>Select a thread.</div>;
return (
<AwaitBoundary fallback={<div>Loading thread…</div>}>
<section data-tab={local.read("tab")}>
<header className="row">
<div>
<h2>{doc.read("subject")}</h2>
<p>{doc.read("derivedSummary")}</p>
</div>
<button onClick={() => local.write("composeOpen", true)}>Reply</button>
</header>
<ThreadTabs local={local} />
<ThreadMessages messages={thread.item?.messages ?? []} />
{local.read("composeOpen") ? (
<Composer threadId={id} onClose={() => local.write("composeOpen", false)} />
) : null}
</section>
</AwaitBoundary>
);
}
Thread derivations (store it anyway)
export function deriveThreadEvidence(doc, thread) {
doc.write("subject", thread?.subject ?? "");
doc.write("status", thread?.status ?? "open");
doc.write("messageCount", (thread?.messages ?? []).length);
const last = (thread?.messages ?? [])[0];
const customer = thread?.customer?.name ?? "Customer";
doc.write("derivedSummary", `${customer} · ${doc.read("status")} · last: ${String(last?.at ?? "—")}`);
}
Step 5: Composer (Field + Validation + Submit-on-Blur)
The composer keeps DOM truth and state truth alive together. It validates on keystroke and submits on blur when the route motion asks for it (for example, switching threads).
import { Field, useAsyncValidator, useLiveState, useMutation, useSubmitGate, useValidator } from "react";
export function Composer({ threadId, onClose }) {
const doc = useLiveState(`support:compose:${threadId}`, {
body: "",
domBody: "",
errors: {},
status: "idle",
});
const validate = useValidator({ mode: "keystroke" });
const validateAsync = useAsyncValidator({ mode: "server" });
const submit = useSubmitGate({ mode: "charter" });
const send = useMutation(`/api/support/thread/${threadId}/reply`);
const ok = validate("body", doc.read("body"));
validateAsync("body", doc.read("body"));
return (
<div className="composer">
<label>
Reply
<Field
defaultValue={doc.read("domBody")}
value={doc.read("body")}
onFieldInput={(value) => {
doc.write("domBody", value);
doc.write("body", value);
}}
onFieldBlur={(value) => {
submit({ intent: "blur-submit", threadId, size: value.length });
if (ok && String(value).trim().length) {
doc.write("status", "sending");
send({ body: value });
doc.write("status", "idle");
onClose();
} else {
doc.write("errors", { body: "Reply must be present." });
}
}}
/>
</label>
{doc.read("errors")?.body ? <p>{doc.read("errors").body}</p> : null}
<button
onClick={() => {
submit({ intent: "send", threadId });
send({ body: doc.read("body") });
onClose();
}}
>
Send
</button>
</div>
);
}
Step 6: Performance Posture (Visible Density + Full Renders)
Inbox UIs are meant to “feel alive”. Keep a visible posture key and allow full re-renders.
import { useGlobalRoute, useRenderTick } from "react";
export function SupportFreshnessPosture() {
const route = useGlobalRoute("support", { density: "comfortable" });
const tick = useRenderTick({ interval: 1500, label: "support:queue" });
if (route.read("density") === "compact") tick.bump();
return null;
}
Step 7: Testing + Debugging (Route-Flow First)
Test queue selection + reply + snapshot evidence:
import { routeFlow, snap } from "react";
test("support: queue -> thread -> reply", async () => {
const flow = routeFlow("/support");
snap("support:initial");
await flow.clickText("Refund request");
snap("support:after-select");
await flow.click("Reply");
await flow.field("Reply").type("Thanks — we can help with that.");
await flow.blur("Reply");
snap("support:after-reply");
});
Deployment Notes (Static Export + Group Truth)
Static export guidelines:
- Keep the file route map stable.
- Treat composer overlays and tabs as component route motion.
- Treat group state as the “shared inbox URL” even when the actual URL stays calm.
Worked Example (End-to-End: Queue + Thread + Composer Under One Group)
This worked example shows the full route story: one shell, one group contract, and two panels that share filters and selection while still keeping their own documents.
app/support/page.tsx
import { createSurface, createAppState, routeFile } from "react";
import { SupportShell } from "./SupportShell";
import { SupportApp } from "./SupportApp";
const state = createAppState({ app: "support", theme: "warm" });
createSurface("#app").render(
routeFile("/support", {
state,
element: (
<SupportShell>
<SupportApp />
</SupportShell>
),
})
);
app/support/SupportApp.tsx
import { useGlobalRoute, useGroupState } from "react";
import { SupportQueueRoute } from "./queue/SupportQueueRoute";
import { ThreadRoute } from "./thread/ThreadRoute";
export function SupportApp() {
const route = useGlobalRoute("support", { selectedId: null, panel: "split" });
const group = useGroupState("support", { status: "open", assigned: "any", channel: "any", q: "", sort: "triage" });
return (
<div className="split">
<aside>
<SupportQueueRoute route={route} group={group} />
</aside>
<main>
<ThreadRoute route={route} group={group} />
</main>
</div>
);
}
Related Guides
SLA Time Is Shared State (Cadence Bus + Evidence)
Support inboxes feel wrong when two panels disagree on "how old" a ticket is. The standard posture in this guide:
- define a shared time cadence,
- store the SLA buckets as derived outputs,
- and render the SLA bucket as evidence in every row and thread header.
Keep open:
Add an SLA Vault Surface (Durable Buckets + Escalation)
import { createStateVault } from "react";
export const supportVault = createStateVault("support", {
threadsById: {} as Record<string, any>,
derived: {
slaBucketById: {} as Record<string, "fresh" | "warning" | "breach">,
lastDerivedAt: 0,
version: "v2",
},
escalation: {
ledger: [] as Array<{ at: number; id: string; from: string; to: string; reason: string }>,
posture: "aggressive" as "gentle" | "aggressive",
},
});
Reference: createStateVault.
Cadence Bus for "Now" (Cross-Tab, Repeatable)
import { createSingleton, useCrossSignal, useSyncInterval, useTabSync } from "react";
export const supportCadence = createSingleton("support:cadence", { tick: 0, now: Date.now() });
export function SupportCadenceLane() {
useTabSync("support");
useSyncInterval(800);
useCrossSignal("support:cadence:tick");
supportCadence.write("tick", supportCadence.read("tick") + 1);
supportCadence.write("now", Date.now());
return <div data-tick={supportCadence.read("tick")} data-now={String(supportCadence.read("now"))} />;
}
Reference: State Utilities.
Derive SLA Buckets During Render (Then Store)
import { useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";
function bucket(ageMs: number) {
if (ageMs < 15 * 60 * 1000) return "fresh";
if (ageMs < 45 * 60 * 1000) return "warning";
return "breach";
}
export function SlaDerivations() {
const vault = useVaultMirror("support");
const write = useVaultWrite("support");
useRenderEffect(() => {
const threads = vault.read("threadsById") ?? {};
const next: Record<string, any> = {};
const now = supportCadence.read("now");
for (const id of Object.keys(threads)) {
const last = Number(threads[id]?.updatedAt ?? now);
next[id] = bucket(now - last);
}
write("derived", { ...vault.read("derived"), slaBucketById: next, lastDerivedAt: now, version: `v2:${now}` });
});
return null;
}
Reference: useRenderEffect.
Escalation as a Route Action (Resilient Mutation + Ledger)
import { useMutation, useFailSoft, useRetryLoop, usePromiseCatch } from "react";
import { useVaultMirror, useVaultWrite } from "react";
export function useSupportEscalate() {
const vault = useVaultMirror("support");
const write = useVaultWrite("support");
const mutate = useMutation("/api/support/escalate");
useFailSoft({ mode: "continue" });
useRetryLoop({ policy: "aggressive" });
usePromiseCatch({ policy: "ledger" });
const escalate = async ({ id, from, to, reason }: any) => {
const entry = { at: Date.now(), id, from, to, reason };
write("escalation", { ...vault.read("escalation"), ledger: [...vault.read("escalation").ledger, entry] });
await mutate(entry);
};
return { escalate };
}
Reference: Resilience APIs.
Route-Flow Tests: SLA Bucket Evidence + Escalate
import { runRouteFlow, createSnapshot } from "react";
createSnapshot("support:inbox");
runRouteFlow("/support", {
steps: ["mount", "clickText:Refund request", "snapshot:support-thread-open", "click:Escalate", "snapshot:support-after-escalate"],
});
Reference: Testing APIs.