← Back to all posts
AITutorial

Let's Build a Customer Support AI Copilot: An Event-Driven Agent with LangGraph, Go, pgvector & Redis Streams [Part 5]

Karan Kashyap

Karan Kashyap

June 29, 2026

Let's Build a Customer Support AI Copilot: An Event-Driven Agent with LangGraph, Go, pgvector & Redis Streams [Part 5]

Part 5 — The Next.js Console: Queue, Review, and Real-Time Updates


The console has one job: make the human-in-the-loop workflow as frictionless as possible. A customer message arrives, the agent processes it, and a draft appears in the queue — live, without a refresh. The reviewer reads it, checks the grounding sources, sees the guard results, and either approves, edits, rejects, or escalates. All of that is a GraphQL subscription wired to a real-time queue view.

text
text
1apps/web/
2├── app/
3│ ├── layout.tsx # root layout: fonts + Providers wrapper
4│ ├── page.tsx # "/" — NewMessage + QueueView
5│ ├── providers.tsx # urql Client + Toaster (client boundary)
6│ └── c/[id]/
7│ └── page.tsx # "/c/:id" — ConversationView
8├── components/
9│ ├── compose/new-message.tsx # submit a customer message via mutation
10│ ├── queue/
11│ │ ├── queue-view.tsx # subscription + paginated list
12│ │ └── draft-row.tsx # summary card; links to detail
13│ ├── draft/
14│ │ ├── draft-panel.tsx # full draft detail (presentational)
15│ │ ├── draft-actions.tsx # approve / reject / escalate mutations
16│ │ ├── confidence-meter.tsx # gauge with threshold tick
17│ │ ├── guard-badges.tsx # grounded / tone / policy chips
18│ │ └── status-badge.tsx # SUGGESTED / ESCALATED / SENT / REJECTED
19│ ├── dashboard/
20│ │ └── dashboard-view.tsx # throughput stats + eval history
21│ └── conversation/
22│ ├── conversation-view.tsx # two-column: thread + draft panel
23│ └── message-thread.tsx # message list
24└── lib/
25 ├── urql.ts # Client factory (HTTP + WS exchanges)
26 ├── config.ts # NEXT_PUBLIC_* env with localhost defaults
27 ├── status.ts # enum → semantic color token mappings
28 └── graphql/operations.ts # all GQL documents (codegen-typed)

Step 1: The GraphQL Client

The entire UI shares one urql Client. It connects to the Go API over HTTP for queries and mutations, and over WebSocket for subscriptions. The client is created once per browser session via useMemo inside a client-side Providers component.

apps/web/lib/urql.ts
typescript
1// apps/web/lib/urql.ts
2"use client";
3
4import { Client, cacheExchange, fetchExchange, subscriptionExchange } from "urql";
5import { createClient as createWSClient } from "graphql-ws";
6import { GRAPHQL_HTTP, GRAPHQL_WS } from "./config";
7
8const wsClient =
9 typeof window !== "undefined"
10 ? createWSClient({ url: GRAPHQL_WS })
11 : null;
12
13export function makeClient(): Client {
14 return new Client({
15 url: GRAPHQL_HTTP,
16 exchanges: [
17 cacheExchange,
18 fetchExchange,
19 subscriptionExchange({
20 forwardSubscription(request) {
21 const input = { ...request, query: request.query ?? "" };
22 return {
23 subscribe(sink) {
24 if (!wsClient) return { unsubscribe() {} };
25 const unsubscribe = wsClient.subscribe(input, sink);
26 return { unsubscribe };
27 },
28 };
29 },
30 }),
31 ],
32 });
33}

The typeof window !== "undefined" guard prevents graphql-ws from being constructed during Next.js server rendering — subscriptions are client-only. Both URLs default to localhost:8080 so the app works after docker compose up with no configuration:

apps/web/lib/config.ts
typescript
1// apps/web/lib/config.ts
2export const GRAPHQL_HTTP =
3 process.env.NEXT_PUBLIC_GRAPHQL_HTTP ?? "http://localhost:8080/query";
4
5export const GRAPHQL_WS =
6 process.env.NEXT_PUBLIC_GRAPHQL_WS ?? "ws://localhost:8080/query";

Step 2: Typed Operations via Codegen

Every GraphQL document lives in one file. graphql-codegen runs as a prebuild/predev hook and generates TypeScript types from the shared schema. Calling graphql(...) wraps each document in a TypedDocumentNode — urql's hook return types are fully inferred with no as casts.

apps/web/lib/graphql/operations.ts
typescript
1// apps/web/lib/graphql/operations.ts
2
3// Shared fragment — the single source of truth for what a Draft looks like.
4// Queue rows, the detail panel, and subscription payloads all use this type.
5export const DraftFields = graphql(`
6 fragment DraftFields on Draft {
7 id
8 messageId
9 conversationId
10 intent
11 category
12 sentiment
13 urgency
14 answer
15 suggestedAction
16 confidence
17 status
18 createdAt
19 citations { kbId title snippet }
20 guard { grounded tone policy reasons }
21 }
22`);
23
24export const QueueQuery = graphql(`
25 query Queue($status: DraftStatus, $limit: Int, $cursor: String) {
26 queue(status: $status, limit: $limit, cursor: $cursor) {
27 items { ...DraftFields }
28 nextCursor
29 }
30 }
31`);
32
33export const DraftUpdatesSubscription = graphql(`
34 subscription DraftUpdates($conversationId: ID) {
35 draftUpdates(conversationId: $conversationId) {
36 ...DraftFields
37 }
38 }
39`);
40
41export const IngestMessageMutation = graphql(`
42 mutation IngestMessage($input: IngestInput!) {
43 ingestMessage(input: $input) { id role body createdAt }
44 }
45`);
46
47export const ApproveReplyMutation = graphql(`
48 mutation ApproveReply($draftId: ID!, $edited: String) {
49 approveReply(draftId: $draftId, edited: $edited) { ...DraftFields }
50 }
51`);
52
53export const RejectReplyMutation = graphql(`
54 mutation RejectReply($draftId: ID!, $reason: String) {
55 rejectReply(draftId: $draftId, reason: $reason) { ...DraftFields }
56 }
57`);
58
59export const EscalateMutation = graphql(`
60 mutation Escalate($draftId: ID!) {
61 escalate(draftId: $draftId) { ...DraftFields }
62 }
63`);

DraftFieldsFragment — the TypeScript type inferred from the fragment — is imported by every component that touches a draft. There's one shape, shared everywhere, generated from the schema.


Step 3: Root Layout and Providers

Fonts are loaded via next/font/google and injected as CSS variables. The Providers component is a client boundary that owns the urql client and the toast system.

apps/web/app/layout.tsx
typescript
1// apps/web/app/layout.tsx
2import { Hanken_Grotesk, JetBrains_Mono, Bricolage_Grotesque } from "next/font/google";
3import { Providers } from "./providers";
4import { SiteHeader } from "@/components/site-header";
5
6const hanken = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-hanken", display: "swap" });
7const jetbrains = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap" });
8const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage", display: "swap" });
9
10export default function RootLayout({ children }: { children: React.ReactNode }) {
11 return (
12 <html lang="en" className="dark">
13 <body className={`${hanken.variable} ${jetbrains.variable} ${bricolage.variable} min-h-dvh`}>
14 <Providers>
15 <div className="flex min-h-dvh flex-col">
16 <SiteHeader />
17 <main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 sm:px-6">
18 {children}
19 </main>
20 </div>
21 </Providers>
22 </body>
23 </html>
24 );
25}
apps/web/app/providers.tsx
typescript
1// apps/web/app/providers.tsx
2"use client";
3
4import { useMemo } from "react";
5import { Provider } from "urql";
6import { makeClient } from "@/lib/urql";
7import { Toaster } from "@/components/ui/sonner";
8
9export function Providers({ children }: { children: React.ReactNode }) {
10 const client = useMemo(() => makeClient(), []);
11 return (
12 <Provider value={client}>
13 {children}
14 <Toaster theme="dark" position="bottom-right" />
15 </Provider>
16 );
17}

Step 4: The Home Page — Compose + Queue

The root route renders two components top-to-bottom: a compose box that submits a customer message, and the queue that shows all drafts.

apps/web/app/page.tsx
typescript
1// apps/web/app/page.tsx
2import { NewMessage } from "@/components/compose/new-message";
3import { QueueView } from "@/components/queue/queue-view";
4
5export default function HomePage() {
6 return (
7 <>
8 <NewMessage />
9 <QueueView />
10 </>
11 );
12}

NewMessage fires IngestMessage and shows a toast — the draft appears in the queue below on its own via the subscription, demonstrating the end-to-end flow:

apps/web/components/compose/new-message.tsx
typescript
1// apps/web/components/compose/new-message.tsx
2"use client";
3
4export function NewMessage() {
5 const [body, setBody] = useState("");
6 const [{ fetching }, ingest] = useMutation(IngestMessageMutation);
7
8 async function submit() {
9 const text = body.trim();
10 if (!text) return;
11 const res = await ingest({ input: { body: text } });
12 if (res.error) { toast.error(res.error.message); return; }
13 toast.success("Message ingested — draft incoming");
14 setBody("");
15 }
16
17 return (
18 <div className="mb-6 rounded-lg border border-border bg-card/40 p-3">
19 <label htmlFor="new-message" className="font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground">
20 Simulate a customer message
21 </label>
22 <Textarea
23 id="new-message"
24 value={body}
25 onChange={(e) => setBody(e.target.value)}
26 onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit(); }}
27 placeholder="e.g. How do I cancel an order I just placed?"
28 rows={2}
29 className="mt-2 resize-none text-sm font-display"
30 />
31 <div className="mt-2 flex items-center justify-between">
32 <span className="font-mono text-[10px] text-muted-foreground">/Ctrl + Enter</span>
33 <Button size="sm" disabled={fetching || !body.trim()} onClick={submit}>
34 <Send className="size-4" /> {fetching ? "Sending…" : "Send"}
35 </Button>
36 </div>
37 </div>
38 );
39}

Step 5: The Live Queue

QueueView is the most complex component. It does three things simultaneously: paginate drafts from the REST-style queue query, subscribe for live updates, and maintain local state that merges both sources.

QueueView Data Flow

apps/web/components/queue/queue-view.tsx
typescript
1// apps/web/components/queue/queue-view.tsx
2"use client";
3
4const PAGE = 25;
5
6export function QueueView() {
7 const client = useClient();
8 const [status, setStatus] = useState<DraftStatus | null>(null);
9 const [items, setItems] = useState<DraftFieldsFragment[]>([]);
10 const [cursor, setCursor] = useState<string | null>(null);
11 const [flashId, setFlashId] = useState<string | null>(null);
12
13 // One subscription for the whole queue; conversationId=null = all conversations.
14 const [stream] = useSubscription({ query: DraftUpdatesSubscription, variables: { conversationId: null } });
15 const live = !stream.error;
16
17 useEffect(() => {
18 const d = stream.data?.draftUpdates;
19 if (!d) return;
20 setItems((prev) => {
21 // Update in-place if already present, prepend if new.
22 if (prev.some((x) => x.id === d.id)) return prev.map((x) => (x.id === d.id ? d : x));
23 if (status && d.status !== status) return prev; // outside the active filter
24 return [d, ...prev];
25 });
26 setFlashId(d.id); // triggers the CSS animation on the new row
27 }, [stream.data, status]);
28
29 const fetchPage = useCallback(
30 async (next: string | null, append: boolean) => {
31 setLoading(true);
32 const res = await client.query(QueueQuery, { status, limit: PAGE, cursor: next }).toPromise();
33 if (res.data) {
34 const page = res.data.queue.items;
35 setItems((prev) => (append ? [...prev, ...page] : page));
36 setCursor(res.data.queue.nextCursor ?? null);
37 }
38 setLoading(false);
39 },
40 [client, status],
41 );
42
43 useEffect(() => { fetchPage(null, false); }, [fetchPage]);
44 // ...
45}

The live indicator — ● Live vs Offline — derives directly from stream.error. If the WebSocket drops, the dot goes grey; no manual ping needed.

When a subscription event arrives, the merge logic:

  1. If a row with that id already exists → replace it (status transition: PENDING → SUGGESTED).
  2. If it's new and matches the current filter → prepend + set flashId.
  3. If it's new but outside the active filter → ignore (don't blow up the filtered view).

Step 6: The Draft Row

Each row in the list shows everything a reviewer needs to decide whether to open the detail view:

apps/web/components/queue/draft-row.tsx
typescript
1// apps/web/components/queue/draft-row.tsx
2export function DraftRow({ draft, flash }: { draft: DraftFieldsFragment; flash?: boolean }) {
3 return (
4 <Link
5 href={`/c/${draft.conversationId}`}
6 className={cn(
7 "group block rounded-lg border border-border bg-card/60 p-4 transition-colors hover:border-signal/40",
8 flash && "animate-in fade-in slide-in-from-top-2 duration-500 border-signal/50",
9 )}
10 >
11 <div className="flex items-start justify-between gap-3">
12 <div className="flex flex-wrap items-center gap-2">
13 <span className="font-mono text-sm font-medium">{draft.intent}</span>
14 <Badge variant="outline" className={toneClass(sentimentTone(draft.sentiment))}>
15 {draft.sentiment}
16 </Badge>
17 <Badge variant="outline" className={toneClass(urgencyTone(draft.urgency))}>
18 {draft.urgency}
19 </Badge>
20 </div>
21 <div className="flex items-center gap-2">
22 <StatusBadge status={draft.status} />
23 <span className="font-mono text-[11px] text-muted-foreground">{formatRelative(draft.createdAt)}</span>
24 </div>
25 </div>
26
27 <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{draft.answer}</p>
28
29 <div className="mt-3 flex items-center gap-4">
30 <ConfidenceMeter value={draft.confidence} className="max-w-[180px] flex-1" />
31 <GuardBadges guard={draft.guard} />
32 <span className="ml-auto font-mono text-[11px] text-muted-foreground">
33 {draft.citations.length} cited
34 </span>
35 </div>
36 </Link>
37 );
38}

flash && "animate-in slide-in-from-top-2" — new rows slide in from the top for 500ms. After that, the flashId is cleared and the row looks identical to any other.


Step 7: The Semantic Color System

All status-to-color mappings live in one file, imported by every component. No hardcoded hex, no scattered conditional classes. The actual color values are CSS variables in globals.css; the TypeScript layer maps enums to semantic token names.

apps/web/lib/status.ts
typescript
1// apps/web/lib/status.ts
2
3// Mirrors the worker's escalation cutoff so the gauge threshold is accurate.
4export const CONFIDENCE_THRESHOLD = 0.6;
5
6type Tone = "ok" | "warn" | "danger" | "info" | "muted";
7
8const STATUS_TONE: Record<DraftStatus, Tone> = {
9 PENDING: "info",
10 SUGGESTED: "ok",
11 ESCALATED: "warn",
12 SENT: "ok",
13 REJECTED: "danger",
14};
15
16export function statusTone(s: DraftStatus): Tone { return STATUS_TONE[s] ?? "muted"; }
17
18export function confidenceTone(c: number): Tone {
19 if (c < 0.4) return "danger";
20 if (c < CONFIDENCE_THRESHOLD) return "warn";
21 return "ok";
22}
23
24export function formatRelative(iso: string): string {
25 const diff = Date.now() - new Date(iso).getTime();
26 const s = Math.round(diff / 1000);
27 if (s < 60) return "just now";
28 const m = Math.round(s / 60);
29 if (m < 60) return `${m}m ago`;
30 const h = Math.round(m / 60);
31 if (h < 24) return `${h}h ago`;
32 return `${Math.round(h / 24)}d ago`;
33}

CONFIDENCE_THRESHOLD = 0.6 deliberately mirrors the worker's CONFIDENCE_THRESHOLD environment variable default. If that value changes in the worker, the UI threshold should change too — keeping them in sync in a shared constant would require a monorepo cross-package import, but for a single-repo project the explicit comment is enough to catch it in review.


Step 8: The Confidence Meter

A horizontal gauge with a tick mark at the escalation threshold. At a glance, a reviewer sees whether a draft cleared the bar the worker uses to decide "suggest vs escalate."

apps/web/components/draft/confidence-meter.tsx
typescript
1// apps/web/components/draft/confidence-meter.tsx
2export function ConfidenceMeter({ value, className }: { value: number; className?: string }) {
3 const pct = Math.round(Math.min(1, Math.max(0, value)) * 100);
4 const tone = confidenceTone(value);
5 return (
6 <div className={cn("flex items-center gap-2", className)}>
7 <div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
8 <div className={cn("h-full rounded-full transition-all", FILL[tone])} style={{ width: `${pct}%` }} />
9 {/* Tick at the escalation threshold — not a visual decoration, tells the reviewer
10 whether this draft would have been suggested or escalated on confidence alone. */}
11 <div
12 className="absolute top-0 h-full w-px bg-foreground/40"
13 style={{ left: `${CONFIDENCE_THRESHOLD * 100}%` }}
14 aria-hidden
15 />
16 </div>
17 <span className="w-9 shrink-0 text-right font-mono text-xs tabular-nums text-muted-foreground">
18 {pct}%
19 </span>
20 </div>
21 );
22}

Step 9: The Guard Badges

Three deterministic checks from the worker — grounded, tone, policy — each rendered as a small chip. Green check = pass, red X = fail.

apps/web/components/draft/guard-badges.tsx
typescript
1// apps/web/components/draft/guard-badges.tsx
2function GuardChip({ label, ok }: { label: string; ok: boolean }) {
3 return (
4 <span className={cn(
5 "inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider",
6 ok ? "border-ok/30 bg-ok/10 text-ok" : "border-danger/30 bg-danger/10 text-danger",
7 )}>
8 {ok ? <Check className="size-3" /> : <X className="size-3" />}
9 {label}
10 </span>
11 );
12}
13
14export function GuardBadges({ guard }: { guard: DraftFieldsFragment["guard"] }) {
15 return (
16 <div className="flex flex-wrap items-center gap-1.5">
17 <GuardChip label="grounded" ok={guard.grounded} />
18 <GuardChip label="tone" ok={guard.tone} />
19 <GuardChip label="policy" ok={guard.policy} />
20 </div>
21 );
22}

Step 10: Conversation Detail — Thread + Draft

Clicking a row navigates to /c/:id. This page shows two columns: the message thread on the left and the active draft on the right.

apps/web/components/conversation/conversation-view.tsx
typescript
1// apps/web/components/conversation/conversation-view.tsx
2"use client";
3
4export function ConversationView({ id }: { id: string }) {
5 const [{ data, fetching, error }, reexecute] = useQuery({
6 query: ConversationQuery,
7 variables: { id },
8 requestPolicy: "cache-and-network",
9 });
10
11 // Narrow subscription: only events for this conversation.
12 const [stream] = useSubscription({
13 query: DraftUpdatesSubscription,
14 variables: { conversationId: id },
15 });
16
17 // When the worker emits a draft, refetch the full thread.
18 useEffect(() => {
19 if (stream.data?.draftUpdates) reexecute({ requestPolicy: "network-only" });
20 }, [stream.data, reexecute]);
21
22 const withDraft = [...(conv?.messages ?? [])].reverse().find((m) => m.draft);
23 const activeDraft = withDraft?.draft ?? null;
24
25 return (
26 <div className="grid gap-5 lg:grid-cols-[1fr_1.1fr]">
27 <section aria-label="Conversation">
28 <MessageThread messages={conv.messages} selectedId={withDraft?.id} />
29 </section>
30
31 <section aria-label="Draft under review">
32 {activeDraft ? (
33 <DraftPanel
34 draft={activeDraft}
35 actions={
36 <DraftActions
37 draft={activeDraft}
38 onDone={() => reexecute({ requestPolicy: "network-only" })}
39 />
40 }
41 />
42 ) : (
43 <p>No draft yet — the worker is still processing.</p>
44 )}
45 </section>
46 </div>
47 );
48}

The subscription here uses conversationId: id — it filters to only events for this conversation. The queue subscription uses conversationId: null to receive all events. Same subscription, different variable.


Step 11: The Draft Panel

DraftPanel is presentational — no mutations. It receives an actions render prop so the conversation view can inject DraftActions without the panel knowing about mutations.

apps/web/components/draft/draft-panel.tsx
typescript
1// apps/web/components/draft/draft-panel.tsx
2export function DraftPanel({ draft, actions }: { draft: DraftFieldsFragment; actions?: React.ReactNode }) {
3 return (
4 <div className="rounded-lg border border-border bg-card/60">
5 {/* Header: intent · category, sentiment/urgency/action badges, status */}
6 <div className="flex items-start justify-between gap-3 border-b border-border p-4">
7 <div>
8 <div className="flex items-center gap-2 font-mono text-sm">
9 <span className="font-medium">{draft.intent}</span>
10 <span className="text-muted-foreground">·</span>
11 <span className="text-muted-foreground">{draft.category}</span>
12 </div>
13 <div className="mt-2 flex flex-wrap items-center gap-1.5">
14 <Badge className={toneClass(sentimentTone(draft.sentiment))}>{draft.sentiment}</Badge>
15 <Badge className={toneClass(urgencyTone(draft.urgency))}>{draft.urgency}</Badge>
16 {draft.suggestedAction && (
17 <Badge className="border-info/30 bg-info/10 text-info">{draft.suggestedAction}</Badge>
18 )}
19 </div>
20 </div>
21 <StatusBadge status={draft.status} />
22 </div>
23
24 <div className="space-y-4 p-4">
25 {/* Confidence gauge */}
26 <div>
27 <Label>Confidence</Label>
28 <ConfidenceMeter value={draft.confidence} className="mt-1.5" />
29 </div>
30
31 {/* The draft text */}
32 <div>
33 <Label>Draft reply</Label>
34 <p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed">{draft.answer}</p>
35 </div>
36
37 {/* Citation list — each cites a real kb_document row */}
38 <div>
39 <Label>Grounding — {draft.citations.length} source{draft.citations.length !== 1 ? "s" : ""}</Label>
40 <ul className="mt-2 space-y-2">
41 {draft.citations.map((c, i) => (
42 <li key={`${c.kbId}-${i}`} className="rounded-md border border-border bg-background/50 p-2.5">
43 <div className="flex items-center gap-2">
44 <span className="font-mono text-[10px] text-muted-foreground">{c.kbId.slice(0, 8)}</span>
45 <span className="text-xs font-medium">{c.title}</span>
46 </div>
47 <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{c.snippet}</p>
48 </li>
49 ))}
50 {draft.citations.length === 0 && (
51 <li className="text-xs text-danger">No sources — ungrounded.</li>
52 )}
53 </ul>
54 </div>
55
56 {/* Guard results */}
57 <div>
58 <Label>Guard</Label>
59 <GuardBadges guard={draft.guard} />
60 {draft.guard.reasons.length > 0 && (
61 <ul className="mt-2 list-inside list-disc font-mono text-[11px] text-muted-foreground">
62 {draft.guard.reasons.map((r, i) => <li key={i}>{r}</li>)}
63 </ul>
64 )}
65 </div>
66 </div>
67
68 {/* Actions slot — filled by the parent, empty on read-only views */}
69 {actions && <><Separator /><div className="p-4">{actions}</div></>}
70 </div>
71 );
72}

Step 12: Draft Actions — Approve, Reject, Escalate

DraftActions is a small state machine with three modes: idle, edit, and reject. In idle mode the three buttons are shown. Clicking Approve & send switches to edit mode — the reviewer can modify the text before sending. Clicking Reject switches to reject mode — an optional reason textarea appears.

apps/web/components/draft/draft-actions.tsx
typescript
1// apps/web/components/draft/draft-actions.tsx
2"use client";
3
4const TERMINAL = new Set(["SENT", "REJECTED"]);
5type Mode = "idle" | "edit" | "reject";
6
7export function DraftActions({ draft, onDone }: { draft: DraftFieldsFragment; onDone?: () => void }) {
8 const [mode, setMode] = useState<Mode>("idle");
9 const [edited, setEdited] = useState(draft.answer);
10 const [reason, setReason] = useState("");
11
12 const [, approve] = useMutation(ApproveReplyMutation);
13 const [, reject] = useMutation(RejectReplyMutation);
14 const [, escalate] = useMutation(EscalateMutation);
15
16 // Terminal states are read-only — no further action possible.
17 if (TERMINAL.has(draft.status)) {
18 return (
19 <p className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
20 {draft.status === "SENT" ? "Reply sent — resolved." : "Draft rejected."}
21 </p>
22 );
23 }
24
25 // edit mode: shows the draft text in a textarea; sends edited or original
26 if (mode === "edit") {
27 const changed = edited.trim() !== draft.answer.trim();
28 return (
29 <div className="space-y-2">
30 <Textarea value={edited} onChange={(e) => setEdited(e.target.value)} rows={5} />
31 <div className="flex justify-end gap-2">
32 <Button variant="ghost" size="sm" onClick={() => setMode("idle")}>Cancel</Button>
33 <Button size="sm" onClick={() =>
34 approve({ draftId: draft.id, edited: changed ? edited : null })
35 .then(() => { toast.success(changed ? "Edited reply sent" : "Reply sent"); onDone?.(); })
36 }>
37 <Check className="size-4" /> Send reply
38 </Button>
39 </div>
40 </div>
41 );
42 }
43
44 // idle mode: the three primary action buttons
45 return (
46 <div className="flex flex-wrap gap-2">
47 <Button size="sm" onClick={() => setMode("edit")}>
48 <Check className="size-4" /> Approve & send
49 </Button>
50 <Button variant="outline" size="sm" onClick={() => setMode("reject")}>
51 <X className="size-4" /> Reject
52 </Button>
53 <Button
54 variant="outline" size="sm"
55 className="border-warn/40 text-warn hover:bg-warn/10"
56 onClick={() => escalate({ draftId: draft.id }).then(() => toast.success("Escalated to a human"))}
57 >
58 <ShieldAlert className="size-4" /> Escalate
59 </Button>
60 </div>
61 );
62}

The edited: changed ? edited : null pattern lets the API know whether the reviewer changed the text. If null, the original draft answer is used; if non-null, the edited version is persisted alongside the approval.


Step 13: The Dashboard

The /dashboard route shows throughput stats and an eval quality trend. Two queries, rendered into four stat cards, a status-mix bar, and a table of recent eval runs.

apps/web/components/dashboard/dashboard-view.tsx
typescript
1// apps/web/components/dashboard/dashboard-view.tsx
2"use client";
3
4const AUTO_DRAFT_TARGET = 0.6; // ≥60% of messages should get a confident draft
5
6export function DashboardView() {
7 const [stats] = useQuery({ query: DashboardStatsQuery, requestPolicy: "cache-and-network" });
8 const [evals] = useQuery({ query: EvalRunsQuery, variables: { limit: 10 }, requestPolicy: "cache-and-network" });
9
10 const s = stats.data?.dashboardStats;
11 const runs = evals.data?.evalRuns ?? [];
12
13 return (
14 <div>
15 {/* 4-column stat grid */}
16 <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
17 <Stat
18 label="Auto-draft rate"
19 value={pct(s.autoDraftRate)}
20 tone={s.autoDraftRate >= AUTO_DRAFT_TARGET ? "ok" : "warn"}
21 sub={`target ≥ ${pct(AUTO_DRAFT_TARGET)}`}
22 />
23 <Stat label="Escalation rate" value={pct(s.escalationRate)} tone="info" />
24 <Stat label="Avg cost" value={`${s.avgCostCents.toFixed(4)}¢`} tone="muted" />
25 <Stat label="p95 latency" value={fmtMs(s.p95LatencyMs)} tone="muted" />
26 </div>
27
28 {/* Status-mix bar: suggested / sent / escalated / rejected */}
29 <StatusBar suggested={s.suggested} escalated={s.escalated} sent={s.sent} rejected={s.rejected} />
30
31 {/* Eval trend: most recent N runs from eval_runs table */}
32 <ul className="mt-2 space-y-2">
33 {runs.map((r) => (
34 <li key={r.id} className="rounded-lg border border-border bg-card/60 p-3">
35 <div className="grid grid-cols-3 gap-3">
36 <Metric label="Groundedness" value={r.groundedness} target={0.9} />
37 <Metric label="Routing" value={r.routingAccuracy} target={0.85} />
38 <Metric label="Answer" value={r.answerScore} /> {/* tracked, not gated */}
39 </div>
40 </li>
41 ))}
42 </ul>
43 </div>
44 );
45}

Metric colors value green/red against target if a target is given, plain otherwise. answer_score has no target — it renders as a blue progress bar, not red/green. Same rule as the eval gate: no numeric answer-quality target means no alarm when it's low.


The End-to-End Flow

Complete UI Flow


Running It

bash
bash
1docker compose up -d
2# open http://localhost:3000

Type a customer message → hit Send → watch a draft slide into the queue below, within a few seconds. Click the row to open the conversation detail, read the citations, check the guard badges, then approve, edit, or escalate.

bash
bash
1# Verify codegen runs clean before build
2cd apps/web
3npm run codegen # regenerates lib/gql/* from the shared schema
4npx tsc --noEmit # catches type errors without building
5npm run build # full production build

What We Have

text
text
1apps/web/
2├── lib/urql.ts — single client, HTTP + WS exchanges, no-op on server
3├── lib/config.ts — NEXT_PUBLIC_* with localhost defaults
4├── lib/status.ts — enum → semantic tone, CONFIDENCE_THRESHOLD, formatRelative
5├── lib/graphql/operations.ts — all typed GQL documents from one file
6├── app/
7│ ├── layout.tsx — three fonts, Providers wrapper, max-w-6xl shell
8│ ├── providers.tsx — urql Provider + Toaster, useMemo client
9│ ├── page.tsx — NewMessage + QueueView
10│ └── c/[id]/page.tsx — async params → ConversationView
11└── components/
12 ├── compose/new-message.tsx — IngestMessage mutation + toast
13 ├── queue/queue-view.tsx — subscription + cursor pagination + merge
14 ├── queue/draft-row.tsx — summary card, flash animation, link
15 ├── draft/draft-panel.tsx — presentational detail, actions slot
16 ├── draft/draft-actions.tsx — idle/edit/reject state machine
17 ├── draft/confidence-meter.tsx — gauge with threshold tick
18 ├── draft/guard-badges.tsx — grounded / tone / policy chips
19 └── dashboard/dashboard-view.tsx — stats + eval history

The subscription is the spine of the entire UI: one WebSocket connection, shared via the urql client, delivers live draft updates to both the queue and the conversation detail simultaneously.

Blog series · 6 parts

Let's Build a Customer Support Co-Pilot

an Event-Driven AI Agent with LangGraph, Go, pgvector & Redis Streams

View on GitHub
GoPythonpgvectorRedisNext.jsDockerLangGraph

Ready to Build Something Extraordinary?

Let's discuss your idea. We'll show you how AI-powered development can compress your timeline and budget — without cutting corners.

We respond within 24 hours. No sales pitch — just a straight conversation about your project.

More from the Blog

Explore more engineering insights, case studies, and technical deep-dives.

View all posts →
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 6]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 6]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 4, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 5]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 5]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 3, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 4]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 4]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 3, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 3]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 3]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 2, 2026