AI

AI Answer

A search-answer section — the question, the sources it stands on, prose whose every sentence carries a numbered chip back to a source, follow-ups, and a streaming pass where sources land before the text.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowUpRight, BookOpen, Globe, RefreshCcw, SearchX, Sparkles } from "lucide-react"

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type {
  AiAnswerFollowUp,
  AiAnswerSegment,
  AiAnswerSource,
  AiAnswerStatus,
} from "./ai-answer.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/ai-answer.json

Prompt

Build a React + TypeScript + Tailwind "AiAnswer" block with zod and
lucide-react, reusing a shadcn/Radix Tooltip for the citation reveal.

Contract
- One zod schema in a sibling contract file is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; query: string;
    segments: { id, kind?: "text" | "bullet" | "heading", text,
                citationRefs: number[], startsParagraph? }[];
    sources: { id, n, title, url, domain, snippet?, faviconUrl? }[];
    followUps: { id, text }[];
    streaming?: boolean }.
- A SEGMENT is a sentence, not a paragraph — a sentence is the unit a citation
  attaches to, and a streaming generator emits them one at a time.
- `source.n` is the citation NUMBER assigned upstream by whatever produced the
  answer; `segment.citationRefs` holds those same numbers. Neither is an array
  index: filtering or re-ordering the sources for display must never renumber
  the prose.
- `streaming` is ORTHOGONAL to status. `loading` means nothing arrived yet;
  `ready` + `streaming` means retrieval finished and the text is still being
  written. Model it as data, never as an internal timer — a state you can only
  reach by waiting is a state nobody can screenshot or review.
- `faviconUrl` is supplied by the caller. Never guess a third-party favicon
  endpoint; that hands every source your readers open to someone else's server.
  With no URL, draw a monogram tile from the domain.
- Props = the schema fields plus: onFollowUp?(followUp); onCitationSelect?
  (source); onSourceSelect?(source, event); onRetry?; target? ("_blank");
  headingLevel? ("h2"); preview? (true); delayDuration? (250);
  loadingLines? (4, clamped >= 1); emptyState?; errorMessage?; className and
  the rest of the section's native props, with the ref forwarded to <section>.

Behavior
- FOLD SENTENCES INTO PROSE. Rendering one block per segment produces a stack
  of one-line paragraphs that reads like a receipt. Group consecutive `text`
  segments into one <p> (joined by a space), consecutive `bullet` segments into
  one <ul>, and let `heading` open a new section. `startsParagraph: true`
  forces a break inside a run of text. The grouping is a pure function of the
  array — no state, safe to recompute on every render, and stable while
  streaming appends to the end.
- CHIPS. After each segment's text, render its `citationRefs` — de-duplicated,
  authored order preserved ("…[2][2]" is a rendering bug, not data). A resolved
  ref is a <button> carrying `source.n`, sized so it cannot change the
  paragraph's line height (fixed small box, leading-none, aligned middle,
  nudged up a pixel). An UNRESOLVED ref — the generator cited something the
  retriever never returned — renders as an inert dashed <span> with an sr-only
  "(source unavailable)": the number stays visible because the model claimed
  it, but an affordance that reveals nothing is worse than a footnote.
- CROSS-HIGHLIGHT, BOTH DIRECTIONS. Hovering or focusing a chip rings the
  matching source card; hovering or focusing a source card tints every sentence
  whose `citationRefs` contain that number. One piece of state — the "active
  number" — drives both, so the two directions can never disagree.
- PIN AND SCROLL. Clicking a chip pins that number (sticky highlight that
  survives the pointer leaving), scrolls the matching card into view with
  `block: "nearest", inline: "nearest"` so the page does not jump, and calls
  `onCitationSelect`. Clicking the same chip again unpins — a highlight you
  cannot switch off eventually lies about where you are. Effective highlight =
  hovered ?? pinned.
- NEW ANSWER, CLEAN SLATE. Derive an identity token from the query plus the
  source ids; when it changes, reset hover and pin DURING RENDER (not in an
  effect), or the first frame of the new answer highlights the old one's source.
- STATES. `loading`: the query is already known — echo the heading, then ghost
  a four-card sources grid and `loadingLines` prose lines. `error`: an alert
  with `role="alert"`, the message, and "Try again" only when `onRetry` exists.
  `empty` — or `ready` with zero segments and not streaming — says the
  retrieved material did not answer the question, and still renders the sources
  and the follow-ups, because a narrower question is the way out of a dead end.
  `ready`: header, sources, prose, follow-ups.
- STREAMING. Sources render in full, prose renders as far as it got, a blinking
  caret trails the last segment, and two ghost lines hold the space below so the
  card stops jumping every time a sentence lands. Set `aria-busy` on the section
  and announce "the answer is still being written" once in an sr-only
  `role="status"` — never re-announce the growing text. Follow-ups stay hidden
  until the stream ends: a suggestion is a claim that the answer is finished.
- FOLLOW-UPS ARE REAL OR ABSENT. Render the block only when `onFollowUp` is
  wired. A chip that looks clickable and does nothing is the most expensive
  thing in this component.
- SOURCE CARDS ARE REAL LINKS (<a href> + target/rel), so middle-click and
  ⌘-click keep working. `onSourceSelect` intercepts only a plain left click
  (bail out on defaultPrevented, meta/ctrl/shift/alt, or a non-zero button) for
  hosts that open sources in a reader panel.
- FAVICONS MAY FAIL. Store the src that broke, not a boolean, so a card
  recycled onto a different source retries the new image instead of inheriting
  the old failure — and no effect is needed to reset it.

Rendering & styling
- Semantic tokens only: bg-card section, bg-background source cards, bg-muted
  ghosts and inactive pills, text-muted-foreground for domains and meta,
  bg-primary / text-primary-foreground for the active chip and the caret,
  bg-primary/10 for highlighted sentences, border-destructive/40 and
  bg-destructive/5 for the error panel. No hex, no rgb, no oklch.
- Sources sit in a wrapping grid — repeat(auto-fit, minmax(min(11rem,100%),
  1fr)) — not a horizontal scroller, so every retrieved source stays visible at
  every width instead of hiding past the right edge.
- Prose is `text-sm leading-7` so a 1rem chip never opens the line box; titles
  clamp to two lines; long domains truncate; the heading and long queries use
  text-pretty and min-w-0 to survive a 200-character question.
- cn() merges className everywhere. Every animation is decorative and carries
  `motion-reduce:animate-none` / `motion-reduce:transition-none`; the one
  behaviour a class cannot reach — scrollIntoView's `behavior` — reads
  `matchMedia("(prefers-reduced-motion: reduce)")` through a hook that removes
  its listener on unmount.
- a11y: the section is labelled by the query heading via aria-labelledby;
  `headingLevel` keeps the document outline correct and a `heading` segment
  renders exactly one level below it (h2 -> h3), so the answer's own sections
  never read as siblings of the question; chips carry
  `aria-label="Source n: title, domain"` and `aria-pressed` for the pin; every
  interactive element has a focus-visible ring, and the whole cross-highlight is
  reachable by Tab because focus drives it exactly like hover does.

Customization levers
- Density: drop `gap-5` to `gap-3` and the card padding to `p-4` for a chat
  bubble; raise `leading-7` to `leading-8` for a reading surface.
- Sub-blocks: the header, sources row, prose and follow-ups are four
  independent siblings — delete any one (or reorder sources below the prose,
  the "answer first" variant) without touching the citation logic.
- Sources shape: swap the wrapping grid for `flex gap-2 overflow-x-auto` plus a
  fixed card width to get the strip that scrolls; or cap the list and add a
  "+N more" tile.
- Chip style: superscript numbers, bracketed `[n]`, or a favicon-in-chip — only
  the box metrics matter, keep the height at or below the line height.
- Emphasis: `bg-primary/10` on cited sentences is the loudest choice; a bottom
  border or a left rule reads quieter in dense documents.
- Streaming intensity: replace the caret with a shimmer on the last segment,
  and tune the two ghost lines to match your average answer length.
- Reveal: set `preview={false}` for a dense mobile layout where the chip only
  pins and scrolls, or raise `delayDuration` if hovering the prose feels twitchy.

Concepts

  • Sentence-level segments, paragraph-level rendering — the generator emits one sentence at a time because that is what a citation attaches to; the component folds consecutive sentences back into flowing paragraphs, so streaming granularity never leaks into the typography.
  • Numbering authored upstream — chips and cards both print source.n, never the array index, so re-ordering or filtering the sources for display cannot silently renumber the prose the reader is quoting.
  • Bidirectional cross-highlight — a single "active number" drives both directions: point at a chip to find its source, point at a source to see every sentence it backs. Focus drives it identically, so the whole relationship is reachable by keyboard.
  • Pin versus hover — hover is a transient preview, a click pins the highlight and scrolls the card into view; the second click unpins, because a sticky highlight that cannot be cleared starts describing where you were, not where you are.
  • Unresolved citation stays inert — when the model cites a source retrieval never returned, the number is still shown but is not a button: hiding it would edit the model's claim, and making it clickable would promise a reveal that does not exist.
  • Streaming is data, not a timerready + streaming is a value in the contract, so the mid-stream frame (sources landed, prose half-written, caret trailing, ghost lines below) can be rendered, screenshotted and reviewed like any other state.

On This Page