Datalumina

Frontend and refinement

The trust UI

Stream live pipeline status and clickable citations into the frontend so analysts can verify every claim in one click.

The backend can now produce grounded, validated answers. This stage wires that pipeline into the frontend. The answer streams into the chat, a live status line shows what the agent is doing while the user waits, and every citation is a clickable chip that opens the exact source passage in a slide-over panel. This is Phase 7 in docs/todos.md, and its goal line says it all. Analysts can verify every claim in one click, and that is what makes the product usable.

The stage also carries two of the most transferable lessons of the whole build. One is what to do when a fast model gets stuck in a broken debugging loop. The other is how to prompt a design pass that does not look templated.

daveebbelaar/document-copilot2:19:00

Stream AI SDK parts from FastAPI

The frontend uses Vercel's AI SDK, which expects a specific server-sent events protocol made up of a start event, text-start/text-delta/text-end for the answer, optional typed data parts, and a finish event. The backend speaks that protocol directly from backend/app/chat/streaming.py, so no adapter layer is needed on the client.

async def _text_events(text: str, *, message_id: str) -> AsyncIterator[str]:
    yield _sse_event({"type": "text-start", "id": message_id})

    for word in text.split(" "):
        yield _sse_event({"type": "text-delta", "id": message_id, "delta": f"{word} "})

    yield _sse_event({"type": "text-end", "id": message_id})

async def stream_grounded_answer(
    answer: GroundedAnswer,
    registry: TurnRegistry,
    *,
    message_id: str,
) -> AsyncIterator[str]:
    yield _sse_event({"type": "start", "messageId": message_id})

    async for event in _text_events(answer.answer, message_id=message_id):
        yield event

    assistant_message = build_assistant_message(answer, registry, message_id=uuid.UUID(message_id))
    citation_parts = [
        part for part in assistant_message.parts if isinstance(part, CitationPart)
    ]
    async for event in _citation_events(citation_parts):
        yield event

    yield _sse_event({"type": "finish"})

Two custom part types ride along on the same stream. data-citation parts carry the citation payload (chunk id, excerpt, ticker, form, filing date, page) after the text finishes. data-status parts are emitted throughout the turn by the orchestrator via stream_status, so the client knows which pipeline stage is running:

async def stream_status(stage: str, message: str) -> AsyncIterator[str]:
    part = StatusPart(data=StatusPayload(stage=stage, message=message))
    yield _sse_event(part.model_dump(by_alias=True, mode="json"))

Note the persistence detail in stream_grounded_turn_and_persist. The turn is appended to the database in a finally block, so the conversation is saved even if the client disconnects mid-stream. And if the grounding validator rejected the answer, the stream sends a single error event with an honest failure message instead of an unverified answer.

The tee() trick for live status

The AI SDK's DefaultChatTransport consumes the SSE stream to build the message, but it does not surface custom data-status parts while streaming. Rather than writing a custom transport, frontend/src/hooks/useChatTransport.ts intercepts the response with a custom fetch and splits the body with tee():

fetch: async (input, init) => {
  const response = await fetch(input, init)

  if (!response.ok || !response.body || !onStatus) {
    return response
  }

  const [clientStream, statusStream] = response.body.tee()
  void consumeStatusStream(statusStream, onStatus)

  return new Response(clientStream, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  })
},

One copy of the stream goes back to the AI SDK untouched. The other is read line by line in consumeStatusStream, which parses each data: line, checks it with the isStatusPart type guard from frontend/src/lib/citations.ts, and calls onStatus for every pipeline update (analyzing, searching, reading, verifying, streaming).

That callback drives frontend/src/components/chat/PipelineStatus.tsx, a single line of text with an animated gray gradient wipe. Dave initially got a UI that showed the whole stage cycle at once; the fix (dictated from an annotated screenshot) was to show one message at a time and simply replace it as the stage changes. Because the agent runs a real tool loop, a query can take a while, and this line is what makes the wait feel like progress instead of a hang.

Citations you can click

frontend/src/components/chat/AssistantMessage.tsx assembles the message from its parts. textFromMessage joins the text parts, and citationsFromMessage collects the data-citation parts sorted by index. The markdown renderer turns inline [1] style references into CitationMarker buttons, and a row of CitationChip buttons under the answer lists every source:

<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-foreground text-[0.6rem] font-semibold text-background tabular-nums">
  {citation.citationIndex}
</span>
<span className="truncate font-medium">{citationLabel(citation)}</span>

citationLabel renders ticker · form · filingDate · p.X, so an analyst sees at a glance which filing and page a claim comes from. There is also a defensive state. If a finished answer has text but zero citations, the UI shows "No filing evidence was found to support this answer" instead of presenting it as grounded.

The source passage sheet

Clicking a marker or chip opens frontend/src/components/chat/SourcePassageSheet.tsx, a slide-over panel from the right. It fetches the citation context endpoint in backend/app/api/chat.py:

@router.get("/citations/{chunk_id}/context")
async def get_citation_context(
    chunk_id: uuid.UUID,
    radius: int = Query(default=1, ge=0, le=3),
    _user: CurrentUser = Depends(get_current_user),
) -> CitationContextResponse:
    context = await run_in_threadpool(load_citation_context, chunk_id, radius)
    if context is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Citation chunk not found",
        )
    return context

The response tags each chunk with a role (previous, anchor, next), so the sheet renders the cited passage highlighted, with its neighboring chunks above and below. Dave asked for this explicitly in the video. Showing the bare chunk "feels a little bit out of nowhere", so the panel shows what came before and after for continuity. If the context fetch fails, the sheet falls back to the excerpt saved with the citation, so the user always sees something.

When the fast model gets stuck, zoom out

Wiring this up did not go smoothly. Creating a new conversation returned a 422 Unprocessable Entity, and the fast model Dave had been using for speed kept trying fixes in the same chat thread without landing one. His response is the debugging lesson of this stage. Do not keep patching inside a broken thread.

He opened a fresh session, switched to a heavier reasoning model, and instead of asking it to fix the error, asked it to audit the wiring end to end, checking whether the API communicates correctly with the frontend, how a new conversation is created, and whether everything is set up correctly down to the database. The audit found a mismatch in the chat data model and fixed it in one pass.

Zoom out, clear context, bigger model

When a chat thread goes off the rails, the accumulated context is part of the problem. Start fresh, describe the symptom, and ask for an audit of the whole path rather than a patch for the last error message. Small models are fine early on; once the project has many moving parts, wiring problems need a model that can hold the whole system in view.

The design pass

With the plumbing working, Dave runs a dedicated design pass using Anthropic's frontend-design skill with Claude Opus. His observation from experience is that Anthropic models are generally better at frontend work, so he layers a Claude pass on top even when another model did the wiring.

The prompt is long and dictated, but the ingredients that matter are short:

  • Do research online first. Find best practices for chat applications and perform a gap analysis against the current code before writing anything.
  • Frame it through a UX/UI lens, covering what the collapsible sidebar does, where the user logs out, and what happens while the user waits for an answer.
  • Constrain the palette. Monochrome, black and white, gray is okay. No decoration for its own sake.
  • Name the component sources, shadcn/ui plus the prompt-kit chat components, which pair naturally with the AI SDK.
  • Ask for a design system, meaning reusable components instead of isolated one-off pieces, so later changes stay consistent.

Refine from annotated screenshots

The first design output is close but not right, and Dave fixes it visually rather than verbally. Using CleanShot he annotates a screenshot with numbered markers (a stray divider, a default AI sparkle icon, the cycling status text, unexplained loading dots) and prompts against the numbers. Number one is a glitch, get rid of it; number three should become a single loading line with a gray gradient wipe.

This loop (screenshot, annotate, paste, describe each number) is far more precise than describing visual problems in prose, and it is how the shimmer status line, the logo padding, and the corner radii all got fixed.

While iterating on the UI, Dave also launches a parallel agent on Phase 8, a readiness audit that reviews the codebase against docs/client-brief.md, runs tests, and reports anything missed. Design work and review work do not share files, so they can safely run at the same time.

Checkpoint

Before moving on, run a full query in the browser and confirm:

  • The status line updates live through analyzing, searching, reading, and verifying while the agent works.
  • The answer streams in word by word, with markdown rendered.
  • Citation chips appear under the answer with ticker, form, filing date, and page.
  • Clicking a citation opens the slide-over sheet with the cited passage highlighted and its neighboring chunks around it.
  • A question the corpus cannot answer produces the honest grounding failure message, not a made-up answer.

Next: Better table extraction

Find the weakest link (messy financial tables) and fix it with a dedicated SEC table extractor.

On this page