Skip to content
Index / $15

What Does a Trustworthy AI Analyst UI Actually Look Like?

Lessons from shipping a client-ready React 19/TypeScript front end for an AI analyst: SSE streaming with click-to-source citations, constrained tool-calling with stable chart ids, and human accept/reject gates in front of every autofill and export.

Date
Jul 10, 2026
Runtime
13 min · 2,883 words
Tags
genai, react, typescript
Slot
$15
Contents
tip // Evidence Strip

Goal: Turn a working AI-analyst prototype into a front end a professional can defend in front of a client — every claim traceable, every side-effect visible, every output editable.

Constraints: No model SDKs or secrets in the browser; all model work stays behind a versioned API gateway. No invented numbers in the UI, ever — if the model can't cite it, the interface has to say so.

Approach: A React 19 / TypeScript / Vite thin client (Tailwind CSS + shadcn/ui, Zustand for state) speaking SSE to the gateway. Streaming chat with inline charts and click-to-source citation markers; constrained tool-calling where the agent edits charts by stable id instead of regenerating; web-search-grounded autofill routed through a human accept/reject review with confidence and source attached; multi-format export (native editable PowerPoint charts, filled Excel workbooks, self-contained interactive HTML) behind approval gates and async jobs.

Result: The prototype UI was replaced by a client-ready production web application. The trust features — citations, review queues, approval gates — turned out to be the product, not the chrome around it.

Open work: Better failure UX for long-running exports, richer citation previews, and honest handling of the "the model is confident and wrong" case, which no confidence score fully solves.

I spent part of this year building the front end for an AI analyst — the kind of tool where an analyst asks a question in chat, the system runs data queries, draws charts, researches facts on the web, and eventually produces documents that go in front of a client. This post is about what I learned turning the prototype into something people could actually trust, because the gap between "impressive demo" and "I would put my name on this output" is almost entirely a front-end problem.

The models were fine. The pipeline was fine. What was missing was an interface that made the system's honesty inspectable.

The shape of the thing
React 19
TypeScript + Vite thin client
Tailwind + shadcn/ui, Zustand
SSE
streaming for every agent response
typed events, not text soup
stable ids
on every chart the agent creates
edit beats regenerate
3
export formats behind one approval gate
PPTX, XLSX, interactive HTML
0
model SDKs in the browser
everything behind a versioned gateway

Why AI UIs fail on trust

Most AI product front ends fail the same three ways, and none of them are model failures.

Streaming without provenance. Tokens flow in, prose forms, numbers appear — and nothing in the interface distinguishes "this figure came from your uploaded data" from "this figure was hallucinated mid-sentence." A wall of confident streaming text is worse than a slow answer, because fluency reads as authority. If your UI renders every token identically, you have built a machine for laundering uncertainty.

Silent tool side-effects. The agent ran a query. Which query? Against what? It generated a chart. From which columns, filtered how? If the interface swallows tool activity into a spinner, the user can't audit anything — and an analyst who can't audit a number won't use it, or worse, will use it and get burned once, after which the tool is dead to them and everyone they warn.

Un-editable outputs. The classic demo failure: the AI produces a chart or a slide as a flattened image. It's 90% right. The user needs to change one label. Their only option is to re-prompt and pray the regeneration keeps the 90% while fixing the 10% — it usually doesn't, because regeneration re-rolls everything. Output you can't touch up is output you can't ship.

Every architectural decision below is a response to one of these three failures.

The thin-client discipline

The first decision was the least glamorous and the most important: the browser knows nothing about models. No LLM SDKs, no provider keys, no prompt templates, no retry logic against a model API. The front end speaks to exactly one thing — a versioned API gateway — and everything model-shaped lives behind it.

This sounds like standard hygiene (it is), but on AI products the temptation to cheat is unusually strong. Provider SDKs ship browser builds. Demos get shipped with a key in an env var "temporarily." Streaming from the browser straight to a model API is genuinely less code. And every one of those shortcuts couples your UI to a specific provider, leaks capability and cost surface to the client, and makes the security review a bloodbath.

The thin-client rule bought us three things:

  1. The UI's contract is events, not models. The front end consumes a typed SSE stream. Whether the other side is one model, three models, or a rules engine is invisible — which meant the backend team swapped and versioned model behavior without a single front-end release.
  2. Versioning is real. The gateway is versioned; the UI pins a version. "The model got weirder last Tuesday" becomes a backend changelog entry, not a front-end mystery.
  3. The browser can be dumb about safety. Validation of what tools the agent may call, what queries are legal, what data is reachable — all server-side. The front end's job is to render what happened truthfully, not to enforce what's allowed.

Streaming is a state machine, not a text append

The naive streaming UI is text += chunk. It works until the stream contains anything other than prose — and an agent stream contains a lot more: tool-call starts, tool results, chart payloads, citation markers, errors mid-generation. Model these as typed events and the UI becomes a reducer over a discriminated union; model them as text and you're regex-parsing your own product.

Here's the shape of ours, simplified but representative:

type StreamEvent =
  | { type: "message_start"; messageId: string }
  | { type: "text_delta"; messageId: string; delta: string }
  | { type: "citation"; messageId: string; citationId: string;
      sourceRef: SourceRef; anchorOffset: number }
  | { type: "tool_call_start"; toolCallId: string; toolName: string;
      argsSummary: string }
  | { type: "tool_call_result"; toolCallId: string;
      status: "ok" | "error"; resultSummary: string }
  | { type: "chart_upsert"; chartId: string; spec: ChartSpec }
  | { type: "message_end"; messageId: string; stopReason: StopReason }
  | { type: "stream_error"; recoverable: boolean; detail: string };
 
function chatReducer(state: ChatState, event: StreamEvent): ChatState {
  switch (event.type) {
    case "text_delta":
      return appendText(state, event.messageId, event.delta);
    case "citation":
      // Citations are first-class nodes in the message tree,
      // anchored to an offset — never inlined into the text blob.
      return insertCitation(state, event);
    case "tool_call_start":
      // Render immediately: the user sees WHAT is running before
      // any result exists. No silent side-effects.
      return pushToolActivity(state, event);
    case "chart_upsert":
      // Same chartId → in-place update. New chartId → new chart.
      // This one line is why "edit the chart" works at all.
      return upsertChart(state, event.chartId, event.spec);
    case "stream_error":
      return event.recoverable
        ? markDegraded(state, event.detail)
        : failMessage(state, event.detail);
    // ...
  }
}

Two details carried disproportionate weight.

First, tool calls render at start, not at result. The moment the agent decides to run a query, the UI shows a card: tool name, human-readable argument summary, spinner. The user watches the system work before knowing whether it succeeded. This is the direct antidote to silent side-effects — and it changed how people related to the tool. Watching "running validated query: revenue by region, last 8 quarters" appear before the chart does more for trust than any accuracy number.

Second, the stream can degrade without dying. A recoverable error mid-stream (a tool timed out, a retry is in flight) marks the message degraded and keeps going; the UI shows a subtle banner rather than eating the whole response. Unrecoverable errors fail the message loudly, preserving everything already rendered. Users forgive visible partial failure; they do not forgive a blank bubble that discards thirty seconds of streamed work.

Here's the full round trip:

Loading diagram...

Click-to-source citations are a UI contract

Citations in most AI products are decoration — a superscript that links to nothing, or to a URL the model may have invented. We treated the citation marker as a contract with the user: if a marker exists, clicking it opens the actual source — the document passage, the query result, the web page — that grounds the adjacent claim. And the contrapositive is enforced by the interface: a claim without a marker is visibly ungrounded. Unsourced prose renders as ordinary text; sourced prose carries a marker. The absence is information.

The component is not complicated. That's the point — the discipline is in the data contract, not the pixels:

type SourceRef =
  | { kind: "document"; docId: string; passage: string; page?: number }
  | { kind: "query"; toolCallId: string; querySummary: string }
  | { kind: "web"; url: string; title: string; retrievedAt: string };
 
function CitationMarker({ citation }: { citation: Citation }) {
  const open = useSourcePanel();
  return (
    <button
      className="citation-marker"
      aria-label={`Source: ${sourceLabel(citation.sourceRef)}`}
      onClick={() => open(citation.sourceRef)}
    >
      <sup>[{citation.ordinal}]</sup>
    </button>
  );
}

The load-bearing rule sits upstream: citation events come from the backend with the source payload attached, or they don't come at all. The front end never fabricates a marker, never renders one whose source failed to resolve, and never lets prose claim grounding the stream didn't deliver. If the pipeline can't produce the source, the UI shows unsourced text — uncomfortable for the demo, essential for the product. The one time a user clicks a citation and finds nothing behind it, every other marker in the app becomes noise.

Constrained tools and stable chart ids: editing beats regenerating

The agent's tool surface is deliberately narrow: it can run validated data queries — parameterized, schema-checked server-side, not free-form SQL — and it can create or edit charts. That's the productive surface area, and constraining it is what makes the activity feed legible: every tool card renders a human-readable summary because the tool space is small enough to summarize.

The chart design has one rule that outworked everything else: every chart the agent creates gets a stable id, and the edit tool takes that id. When a user says "make the second chart a stacked bar and drop 2019," the agent doesn't regenerate — it calls the edit tool against c_31 with a delta. The reducer's chart_upsert matches the existing id and updates in place.

Compare the alternative, which every first-draft AI product ships: the agent regenerates the whole chart from scratch. New object, new colors, maybe new — subtly different — underlying query. The user's mental model of "my chart" shatters; three edits in, they're diffing chart versions by eye to check nothing else silently changed. Regeneration makes every small edit a full re-review.

Stable ids make edits composable and auditable. The chart is an object with an identity and a history; the conversation is a sequence of operations on it; the activity feed shows each operation. It's the difference between a collaborator who amends the document and one who retypes it from memory each time you ask for a comma.

Human-in-the-loop is a flow, not a checkbox

The feature that most sharpened my thinking was web-search-grounded autofill. The user has a structured field to fill — a market figure, a company fact — and asks the system to research it. The agent searches the web, extracts a candidate value, and attaches two things: a confidence score and the source it came from.

Then — and this is the entire feature — nothing happens until a human decides.

Loading diagram...

The review card shows the candidate value, the confidence, the source (clickable, same contract as chat citations), and two buttons. Accept commits the value with its provenance attached — the field remembers where it came from. Reject leaves the field untouched and logs the rejected candidate.

What made this work as product rather than compliance theater:

  • The pending state is honest. A field awaiting review is visually distinct from an empty field and from a committed one. Nobody mistakes a candidate for a fact.
  • Reject is cheap and blameless. One click, no justification dialog, no friction. If rejecting costs more than accepting, you've built a rubber stamp with extra steps.
  • Provenance survives acceptance. Weeks later, anyone can ask "where did this number come from?" and the field answers. Accept doesn't launder the value into unattributed truth.

The same gate fronts export. The system produces three formats — native PowerPoint files with editable charts (real chart objects, not screenshots — the un-editable-outputs failure again), filled Excel workbooks, and self-contained interactive HTML. Exports run as async jobs with visible status, and no job starts until a human has reviewed and approved what's going out. The approval screen is a preview, not a summary: you approve the thing itself.

The asymmetry that justifies all of it: in chat, a wrong answer wastes a minute. In an exported deck, a wrong number can walk into a client meeting under a professional's name. Gate severity should scale with blast radius — free-flowing conversation, gated commits, hard-gated exports.

Testing an AI UI

You cannot snapshot-test a model, but almost nothing above is a model — it's deterministic UI responding to typed events, and that's very testable.

The reducer is pure gold. Every nasty stream ordering became a fixture: citation arriving before its anchor text finished streaming, chart_upsert racing tool_call_result, recoverable errors mid-message, out-of-order tool results, a message_end with no message_start. Feed the event array, assert the state. No network, no model, no flakes — dozens of cases that run in milliseconds and catch real regressions.

Components get contract tests. The citation marker with each SourceRef kind, the review card in pending/accepted/rejected states, tool cards through their lifecycle. Standard component testing; the only AI-specific discipline is generating fixtures from the gateway's event schema so UI tests break when the contract drifts.

E2E runs against a scripted fake gateway — a stub that replays canned SSE sequences with realistic timing. The E2E suite verifies the trust journeys end to end: ask → watch tools run → click a citation → see the source; request autofill → reject → confirm the field stayed empty; request export → approve → watch the job complete. Deterministic, fast, and pointed at exactly the flows where a regression would cost user trust rather than pixels.

The mental shift: you're not testing intelligence, you're testing honesty — that the interface faithfully renders whatever the stream claims happened, including failure, degradation, and absence.

What's hard, and what's next

Honest ledger of what this design doesn't solve.

Confidence scores are the weakest link. A calibrated-looking number next to a wrong value is more dangerous than no number — it borrows the credibility of every correct suggestion before it. The accept/reject flow contains the damage but doesn't detect it; a plausible wrong value with a plausible source will get accepted sometimes. I don't think a UI can fully fix this. The most promising direction I see is showing disagreement — multiple sources with conflicting values — rather than a single scalar pretending to summarize certainty.

Long-running export jobs have rough failure UX. Async jobs with progress are fine when they succeed. When one dies at 90%, the current experience is a status flip and a retry button — no partial recovery, no clear diagnosis of whether the input or the pipeline was at fault. Resumable jobs and legible failure reasons are on the list.

Review fatigue is real and unmeasured. Accept/reject works when candidates arrive in ones and twos. If usage grows to dozens per session, humans start pattern-matching on the confidence number and clicking through — the rubber stamp I was trying not to build, emerging through volume instead of design. Batch review that stays honest, without becoming "select all → accept," is a genuinely open design problem.

Citation previews are shallower than I'd like. Clicking through to a source is the contract, but the panel currently shows the passage or the query — not why the model believes this passage supports that claim. Closing the gap between "here's the source" and "here's the reasoning" is the next trust frontier, and I suspect it's mostly a UI problem too.

That's been the theme throughout, actually. The models will keep improving on someone else's schedule. Whether people can trust what shows up on their screen — that's built in the front end, one honest event at a time.

EOF · $15 · 2,883 words · Daniel Plas Rivera
Share[X][LinkedIn]