Lunia DS

AI output

Streaming answer

An AI answer that streams in through the shared streaming text with inline citation chips, then offers copy, regenerate, feedback, a sources count and follow-up questions, where the actions rise in once the text has settled and small swaps and pops confirm each action.

Loading preview

An answer about a new app build (the Figma copy) streams in uneven bursts over two paragraphs with two citation chips while the sources count rolls up to 3; when it is done the actions rise in and two follow-up questions show. A second answer stops with an error, keeps its actions and offers Try again. Replay streams both again.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add streaming-answer

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import type { StreamingContent, StreamingTextStatus } from '../streaming-text/streaming-text';
import { StreamingAnswer, type StreamingAnswerSource } from './streaming-answer';

type Step =
  | { at: number; add: StreamingContent[number] }
  | { at: number; status: StreamingTextStatus }
  | { at: number; source: StreamingAnswerSource };

const SOURCES: StreamingAnswerSource[] = [
  { name: 'Build log', href: 'https://example.com/builds/218' },
  { name: 'Bundle report', href: 'https://example.com/bundle' },
  { name: 'Release notes', href: 'https://example.com/release' },
];

const BUILD: Step[] = [
  { at: 300, add: 'The new build is ready to test. ' },
  { at: 560, add: { type: 'citation', label: 'build-log', href: 'https://example.com/builds/218' } },
  { at: 600, source: SOURCES[0] as StreamingAnswerSource },
  { at: 700, add: ' The first screen now loads in 1.2 seconds, about 30 percent faster' },
  { at: 1700, add: ' than last week, and the main bundle is 18 KB smaller.' },
  { at: 1750, source: SOURCES[1] as StreamingAnswerSource },
  { at: 2100, add: '\n\nTo try it, open the preview link on your phone ' },
  { at: 2350, add: { type: 'citation', label: 'release-notes', href: 'https://example.com/release' } },
  { at: 2400, source: SOURCES[2] as StreamingAnswerSource },
  { at: 2500, add: ' and sign in with your test account.' },
  { at: 2900, status: 'done' },
];

const CUT: Step[] = [
  { at: 400, add: 'The new build is ready to test. ' },
  { at: 700, add: { type: 'citation', label: 'build-log' } },
  { at: 720, source: SOURCES[0] as StreamingAnswerSource },
  { at: 900, add: ' The first screen now loads in 1.2 seconds, about 30 percent faster' },
  { at: 1700, status: 'error' },
];

const FOLLOW_UPS = ['How do I test it on my phone?', 'What made the bundle smaller?'];

function finalState(steps: Step[]) {
  const state = {
    content: [] as StreamingContent,
    status: 'streaming' as StreamingTextStatus,
    sources: [] as StreamingAnswerSource[],
  };
  for (const step of steps) apply(state, step);
  return state;
}

function apply(state: ReturnType<typeof finalState>, step: Step) {
  if ('add' in step) state.content = [...state.content, step.add];
  else if ('status' in step) state.status = step.status;
  else state.sources = [...state.sources, step.source];
}

function useAnswer(steps: Step[]) {
  const [state, setState] = React.useState(() => finalState(steps));
  const timers = React.useRef<number[]>([]);
  const play = React.useCallback(() => {
    for (const id of timers.current) window.clearTimeout(id);
    setState({ content: [], status: 'streaming', sources: [] });
    timers.current = steps.map((step) =>
      window.setTimeout(
        () =>
          setState((s) => {
            const next = { ...s };
            apply(next, step);
            return next;
          }),
        step.at,
      ),
    );
  }, [steps]);
  React.useEffect(
    () => () => {
      for (const id of timers.current) window.clearTimeout(id);
    },
    [],
  );
  return { state, play };
}

const plainText = (content: StreamingContent) =>
  content.map((item) => (typeof item === 'string' ? item : '')).join('');

export default function StreamingAnswerDemo({ replay = 0 }: { replay?: number }) {
  const build = useAnswer(BUILD);
  const cut = useAnswer(CUT);
  const plays = React.useRef({ build: build.play, cut: cut.play });
  plays.current = { build: build.play, cut: cut.play };

  React.useEffect(() => {
    if (replay === 0) return;
    plays.current.build();
    plays.current.cut();
  }, [replay]);

  return (
    <div className="flex w-full max-w-[480px] flex-col gap-12">
      <StreamingAnswer
        content={build.state.content}
        status={build.state.status}
        sources={build.state.sources}
        copyText={plainText(build.state.content)}
        followUps={FOLLOW_UPS}
        actionsDisabled={build.state.status === 'streaming'}
        onRegenerate={build.play}
      />
      <StreamingAnswer
        content={cut.state.content}
        status={cut.state.status}
        sources={cut.state.sources}
        copyText={plainText(cut.state.content)}
        actionsDisabled={cut.state.status === 'streaming'}
        onRetry={cut.play}
        onRegenerate={cut.play}
      />
    </div>
  );
}

API

Parts: root (three parts: text, actions, follow-ups), text (streaming-text component: paragraphs, citation chips, caret, stopped and error lines), actions row, copy button (copy icon and drawn check, same slot), regenerate button, feedback buttons (thumb up, thumb down), sources button (up to 3 dots or icons, count number roll, word), status region (visually hidden), follow-ups (divider, heading, list of buttons).

PropTypeDefaultDescription
contentArray<string | { type: "citation"; label: string; href?: string }>[]The answer so far, with inline citations. Append to it as tokens arrive; a blank line starts a new paragraph.
status"streaming" | "done" | "stopped" | "error""done"Where the stream is. The caret shows only while streaming.
copyTextstringundefinedText the copy button puts on the clipboard; the button hides when not set.
feedback"up" | "down" | nullundefinedControlled feedback, for example loaded from your database.
defaultFeedback"up" | "down" | nullnullFeedback at first when not controlled.
sourcesArray<{ name: string; href?: string; icon?: Node; color?: string }>undefinedSources used; the first 3 show as dots or icons next to the count.
actionsDisabledbooleanfalseTurns off the action buttons, for example while the answer still streams.
followUpsstring[][]Suggested next questions.
followUpsLabelstring"Follow-up questions"Heading above the follow-ups.
EventDescription
retryThe person pressed Try again after an error. The Try again button shows only when this has a listener.
regenerateThe person asked for a new answer. The button shows only when this has a listener.
feedbackChangeThe person rated the answer, or pressed the same thumb again to clear it. Payload: "up", "down" or null.
copyThe answer text was put on the clipboard. Payload: the text.
sourcesClickThe person pressed the sources count. Without a listener the count is plain text.
followUpSelectThe person chose a follow-up question. Payload: the question.
SlotDescription
defaultYour own renderer (for example Markdown) for the answer text instead of content.
sourceIconScoped slot for a source icon, for example a site icon.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
SAN1Text arrives, pauses, stops or endstext (streaming-text component)reveal, citation chip, caret, stopped and error lines: streaming-text rows STX1 to STX8see streaming-text → see streaming-textduration-md (260)ease-outShows at once
SAN14streaming-text settle eventactions row, follow-upsopacity, translate y0, distance-rise → 1, 0duration-lg (320)ease-settledelay duration-2xs (120)Shows at once
SAN15streaming-text settle eventfollow-up buttonopacity, translate y0, distance-rise → 1, 0duration-lg (320)ease-settledelay duration-2xs + stagger-step x rowShows at once
SAN5Copy pressed, and back after the waitcopy icon and checkopacity, blur0, blur-swap → 1, 0 (the leaving icon runs back)duration-md (260)ease-outSwaps at once
SAN6Copy pressed, and back after the waitcopy icon and checkscalescale-swap → 1 (the leaving icon runs back)duration-xl (380)ease-outSwaps at once
SAN7Copy pressedcheckstroke drawn0% → 100%duration-xl (380)ease-outdelay 100msShows at once
SAN8Copied state endscopy buttonswap back to the copy icon (SAN5, SAN6)check → copy iconduration-md (260)ease-outdelay duration-loop (1600)Swaps at once
SAN9A thumb is chosenthumb iconscale, rotation0.8, -8deg → 1, 0degduration-2xl (420)ease-throwStays still
SAN10A thumb is chosen or clearedthumb iconfilltransparent → currentColorduration-sm (200)ease-outSwaps at once
SAN11Hover, or pressed state changeaction buttonbackground color, icon colortransparent, fg-quaternary → bg-primary_hover, fg-secondaryduration-xs (160)ease-outSwaps at once
SAN12Number of sources changessources countnumber rollold count → new countduration-5xl (640)ease-outSwaps at once
SAN13Hoversources button, follow-up buttonbackground color, text colortransparent, text-tertiary → bg-primary_hover, text-primaryduration-xs (160)ease-outSwaps at once

Accessibility

  • The text follows streaming-text: aria-busy="true" while streaming, not a live region, and one polite line when the answer starts and one when it ends.
  • The source dots and the follow-up arrow are aria-hidden.
  • Action buttons are icon buttons with names ("Copy answer", "Regenerate", "Helpful", "Not helpful"); the thumbs use aria-pressed; disabled buttons are disabled, not only dimmed.
  • A visually hidden role="status" region outside the buttons says "Copied", so the button keeps its own name.
  • Citation chips, the caret and the stopped and error lines follow streaming-text.
  • The actions row stays out of the tab order and inert until the settle event, so keyboard users do not reach buttons that are still invisible.
  • Buttons are 28px; every control has a visible focus ring.
  • Under reduced motion the text follows streaming-text, the actions and follow-ups appear at once, the thumb fills at once without the pop and the copy check shows at once.