Skip to content
Star on GitHub
ConversationLive answer
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Live answer

Answers that stream in with numbered sources and next steps. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add live-answer
Where are people dropping off in onboarding?
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add live-answer

React

$npx designme add live-answer --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Live answer shows a question and the agent's reply as it streams in word by word, with numbered citations that point to a source list. Each turn moves through waiting, thinking, answering and done, and once it is done the person can copy, rate, retry or pick a follow-up question that adds a new turn below.

  • When your agent answers questions from data, documents or the web and people need to check where each claim came from.
  • When answers arrive token by token and you want the text to build up in place instead of appearing all at once.
  • When you want to show that the agent thought first, with a short list of what it did, without cluttering the answer.
  • When a conversation should continue through suggested follow-up questions.
03

Usage

Import it and drive it from your own state.

Import

import { LiveAnswer } from '@/components/designme/live-answer/LiveAnswer';

Example

The demo above as a React component, 57 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useCallback, useEffect, useState } from 'react';
import { useWait } from '../lib/useScript';
import { LiveAnswer, type AnswerSource, type AnswerTurn } from './LiveAnswer';

/* ---------- Live answer: it thinks, streams a cited answer, then offers follow-ups that add turns ---------- */
const FIRST = 'Where are people dropping off in onboarding?';
const ANSWERS: Record<string, string> = {
  [FIRST]: 'Most people leave at the workspace step [1]. It asks for a team name before anyone has made anything, and five of eight people in interviews paused there [2]. Moving it after the first project, and filling the name from the email domain, should help [3].',
  'Show the drop-off by device': 'On mobile the workspace step loses 41% [1], on desktop 23% [1]. Most of the mobile drop comes from the keyboard covering the Continue button [3].',
  'Draft a fix for the workspace step': 'Ask for the team name after the first project, and fill it from the email domain so most people never type it [2]. On mobile, keep Continue above the keyboard [3], then compare the step again after a week [1].'
};
const THOUGHTS = ['Read the funnel for the last 30 days', 'Matched the drop to the interview notes', 'Checked step 2 in Onboarding.fig'];
const SOURCES: AnswerSource[] = [
  { n: 1, mark: 'F', color: '#7a5af8', title: 'Funnel data', detail: 'Amplitude, last 30 days', tip: 'Funnel data, last 30 days' },
  { n: 2, mark: 'N', color: '#0ea5a4', title: 'Session notes', detail: 'Notion, 8 interviews', tip: 'Session notes from 8 interviews' },
  { n: 3, mark: 'S', color: '#f97316', title: 'Onboarding.fig', detail: 'Figma, step 2', tip: 'Onboarding.fig, step 2' }
];

export default function LiveAnswerDemo() {
  const [turns, setTurns] = useState<AnswerTurn[]>([{ key: 0, question: FIRST, state: 'waiting' }]);
  const [asked, setAsked] = useState(false);
  const wait = useWait();

  // streams the answer to question q into turn i, one word at a time
  const answer = useCallback(async (i: number, q: string) => {
    const set = (patch: Partial<AnswerTurn>) => setTurns(ts => ts.map((t, k) => k === i ? { ...t, ...patch } : t));
    set({ state: 'thinking', text: '' });
    await wait(1300);
    set({ state: 'answering', thoughts: THOUGHTS, thoughtFor: 2 });
    let text = '';
    for (const tok of ANSWERS[q].split(' ')) {
      text += (text ? ' ' : '') + tok; set({ text });
      await wait(55 + Math.random() * 45);
    }
    set({ state: 'done', sources: SOURCES });
  }, [wait]);

  useEffect(() => { answer(0, FIRST); }, [answer]);

  const ask = async (q: string) => {
    const i = turns.length;
    setAsked(true);
    setTurns(ts => [...ts, { key: i, question: q, state: 'waiting' }]);
    await wait(450); await answer(i, q);
  };
  const retry = (i: number) => { if (turns[i].state === 'done') answer(i, turns[i].question ?? ''); };

  return (
    // keeps the room the first answer fills with its sources and follow-ups, so nothing climbs when they arrive
    <div style={{ width: '100%', minHeight: 376 }}>
      <LiveAnswer turns={turns} followUps={asked ? undefined : ['Show the drop-off by device', 'Draft a fix for the workspace step']} onFollowUp={ask} onRetry={retry} />
    </div>
  );
}
04

Props

Everything LiveAnswer takes.

PropTypeWhat it does
turnsrequiredAnswerTurn[]
followUpsstring[]Suggested next questions, shown under the last answer once it is done
onFollowUp(question: string) => void
onRetry(index: number) => voidAdds a Try again button under each answer
onRate(index: number, rating: AnswerRating) => void
onCopy(index: number, text: string) => voidCalled after an answer is copied, with its text without the citations
onSourceClick(source: AnswerSource) => voidCalled when a source in the list is clicked, besides pinning it
classNamestring
05

Code

The full source, as the CLI installs it.

LiveAnswer.tsxReact · 215 lines
'use client';
import { Fragment, useState } from 'react';
import { cx } from '../lib/cx';
import { useCopied, useSeconds } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Think, Tip } from '../lib/motion';
import '../designme.css';
import './live-answer.css';

export interface AnswerSource {
  /** The number its citations use: [1] in the text cites source 1 */
  n: number;
  /** Like "Funnel data" */
  title: string;
  /** Where it came from, shown in the list, like "Amplitude, last 30 days" */
  detail?: string;
  /** Shown when you hover a citation. Defaults to the title and detail */
  tip?: string;
  /** The letter on its badge. Defaults to the title's first letter */
  mark?: string;
  /** Badge colour, any CSS colour */
  color?: string;
}

export type AnswerState = 'waiting' | 'thinking' | 'answering' | 'done';

export interface AnswerTurn {
  /** Keeps each turn in place when turns are added. Defaults to its index */
  key?: string | number;
  /** What the person asked, shown as a bubble above the answer. Leave it out when the bubble is shown elsewhere, like in a chat */
  question?: string;
  /** waiting shows nothing yet, thinking shows the Thinking line, answering streams the text, done adds sources and actions */
  state: AnswerState;
  /** The answer so far. Each new word fades in as it arrives. [1] after a word cites source 1, a blank line starts a new paragraph */
  text?: string;
  /** What the agent did before it answered, listed under a toggle that counts them, like "3 steps · 2s" */
  thoughts?: string[];
  /** Seconds it thought for. Leave it out to count while thinking */
  thoughtFor?: number;
  sources?: AnswerSource[];
}

export type AnswerRating = 'good' | 'bad' | null;

export interface LiveAnswerProps {
  turns: AnswerTurn[];
  /** Suggested next questions, shown under the last answer once it is done */
  followUps?: string[];
  onFollowUp?: (question: string) => void;
  /** Adds a Try again button under each answer */
  onRetry?: (index: number) => void;
  onRate?: (index: number, rating: AnswerRating) => void;
  /** Called after an answer is copied, with its text without the citations */
  onCopy?: (index: number, text: string) => void;
  /** Called when a source in the list is clicked, besides pinning it */
  onSourceClick?: (source: AnswerSource) => void;
  className?: string;
}

type Word = { text: string; cites: { n: number; rest: string }[] };

/** Splits a paragraph into words, hanging each [n] citation on the word before it */
function words(par: string, streaming: boolean): Word[] {
  const toks = par.split(' ').filter(Boolean);
  // a citation still arriving, like "[" or "[2", waits until it is complete
  if (streaming && /^\[\d*$/.test(toks[toks.length - 1] ?? '')) toks.pop();
  const out: Word[] = [];
  for (const tok of toks) {
    const m = tok.match(/^\[(\d+)\](.*)$/);
    if (m && out.length) out[out.length - 1].cites.push({ n: +m[1], rest: m[2] });
    else out.push({ text: tok, cites: [] });
  }
  return out;
}
const plain = (text: string) => text.replace(/\s*\[\d+\]/g, '').replace(/\s+/g, ' ').trim();

interface TurnProps {
  turn: AnswerTurn;
  followUps?: string[];
  onFollowUp?: (q: string) => void;
  onRetry?: () => void;
  onRate?: (r: AnswerRating) => void;
  onCopy?: (text: string) => void;
  onSourceClick?: (s: AnswerSource) => void;
}

function Turn({ turn, followUps, onFollowUp, onRetry, onRate, onCopy, onSourceClick }: TurnProps) {
  const { state, text = '', thoughts, sources = [] } = turn;
  const done = state === 'done';
  const counted = useSeconds(state === 'thinking' && turn.thoughtFor === undefined);
  const [thoughtsOpen, setThoughtsOpen] = useState(false);
  const [listOpen, setListOpen] = useState(false);
  // hovering a citation or a source previews it, a click pins it until the next click
  const [hover, setHover] = useState<number | null>(null);
  const [pin, setPin] = useState<number | null>(null);
  const [rating, setRating] = useState<AnswerRating>(null);
  const [copied, copyText] = useCopied();
  const active = hover ?? pin;
  const pick = (n: number) => { const next = pin === n ? null : n; setPin(next); if (next !== null) setListOpen(true); };
  const rate = (r: 'good' | 'bad') => { const next = rating === r ? null : r; setRating(next); onRate?.(next); };
  const copy = () => {
    const out = plain(text);
    copyText(out); onCopy?.(out);
  };
  const tipOf = (n: number) => { const s = sources.find(x => x.n === n); return s ? s.tip ?? [s.title, s.detail].filter(Boolean).join(', ') : `Source ${n}`; };

  return (
    <div className="dm-lva-turn">
      {state === 'thinking' && (
        <div className="dm-lva-th dm-enter">
          <span style={{ color: 'var(--dm-fg2)', display: 'grid' }}><Think /></span><span className="dm-shim">Thinking</span>
        </div>
      )}
      {(state === 'answering' || done) && thoughts?.length ? (
        <>
          <button type="button" className="dm-lva-th dm-lva-tb" aria-expanded={thoughtsOpen} onClick={() => setThoughtsOpen(!thoughtsOpen)}>
            <span>{thoughts.length} {thoughts.length === 1 ? 'step' : 'steps'} · {turn.thoughtFor ?? Math.max(1, counted)}s</span><Icon name="chev-d" size="xs" />
          </button>
          <Fold open={thoughtsOpen}><ul className="dm-lva-tlist">{thoughts.map((t, i) => <li key={i}>{t}</li>)}</ul></Fold>
        </>
      ) : null}
      {(state === 'answering' || done) && text.split(/\n{2,}/).map((par, p) => (
        <p key={p} className="dm-lva-p">
          {words(par, !done).map((w, i) => (
            <span key={i} className="dm-w" style={w.cites.length ? { whiteSpace: 'nowrap' } : undefined}>
              {w.text}
              {w.cites.map((c, k) => (
                <Fragment key={k}>
                  <Tip label={tipOf(c.n)}>
                    <span className={cx('dm-cite', active === c.n && 'dm-on')} style={done ? { cursor: 'pointer' } : undefined}
                      onMouseEnter={done ? () => setHover(c.n) : undefined} onMouseLeave={done ? () => setHover(null) : undefined} onClick={done ? () => pick(c.n) : undefined}>{c.n}</span>
                  </Tip>
                  {c.rest}
                </Fragment>
              ))}
              {' '}
            </span>
          ))}
        </p>
      ))}
      {done && (
        <>
          <div className="dm-lva-src dm-enter">
            <span className="dm-lva-acts">
              <Tip label={copied ? 'Copied to clipboard' : 'Copy'} open={copied || undefined}>
                <button type="button" className={cx('dm-ib', copied && 'dm-copied')} aria-label="Copy" onClick={copy}><Icon name={copied ? 'check' : 'copy'} /></button>
              </Tip>
              <Tip label="Good answer">
                <button type="button" className={cx('dm-ib', rating === 'good' && 'dm-on')} aria-label="Good answer" aria-pressed={rating === 'good'} onClick={() => rate('good')}><Icon name="up-thumb" /></button>
              </Tip>
              <Tip label="Bad answer">
                <button type="button" className={cx('dm-ib', rating === 'bad' && 'dm-on')} aria-label="Bad answer" aria-pressed={rating === 'bad'} onClick={() => rate('bad')}><Icon name="down-thumb" /></button>
              </Tip>
              {onRetry && <Tip label="Try again"><button type="button" className="dm-ib" aria-label="Try again" onClick={onRetry}><Icon name="refresh" /></button></Tip>}
            </span>
            {sources.length > 0 && (
              <button type="button" className="dm-lva-sb" aria-expanded={listOpen} onClick={() => setListOpen(!listOpen)}>
                <span className="dm-lva-fav">{sources.map(s => <span key={s.n} style={{ backgroundColor: s.color ?? 'var(--dm-c1)' }}>{s.mark ?? s.title.charAt(0)}</span>)}</span>
                {sources.length} {sources.length === 1 ? 'source' : 'sources'}<Icon name="chev-d" size="xs" />
              </button>
            )}
          </div>
          {sources.length > 0 && (
            <Fold open={listOpen}>
              <div className="dm-lva-ll">
                {sources.map(s => (
                  <button type="button" key={s.n} className={cx('dm-lva-s', active === s.n && 'dm-on')}
                    onMouseEnter={() => setHover(s.n)} onMouseLeave={() => setHover(null)} onClick={() => { pick(s.n); onSourceClick?.(s); }}>
                    <span className="dm-n">{s.n}</span><span>{s.title}</span><span className="dm-d">{s.detail}</span><Icon name="ext" size="xs" />
                  </button>
                ))}
              </div>
            </Fold>
          )}
          {followUps?.length ? (
            <div className="dm-lva-fu">
              {followUps.map((q, i) => <button type="button" key={q} {...enter(i * 90)} onClick={() => onFollowUp?.(q)}><Icon name="chev-r" size="xs" />{q}</button>)}
            </div>
          ) : null}
        </>
      )}
    </div>
  );
}

/** Starts a turn afresh (closed lists, no pin or rating) each time it goes back from done to answering again, like Try again */
function TurnSlot(props: TurnProps) {
  const [seen, setSeen] = useState({ state: props.turn.state, round: 0 });
  let round = seen.round;
  if (seen.state !== props.turn.state) {
    round += seen.state === 'done' ? 1 : 0;
    setSeen({ state: props.turn.state, round });
  }
  return <Turn key={round} {...props} />;
}

/** A question and an answer that streams in word by word, with citations, sources and follow-ups, then more turns below it. */
export function LiveAnswer({ turns, followUps, onFollowUp, onRetry, onRate, onCopy, onSourceClick, className }: LiveAnswerProps) {
  const last = turns.length - 1;
  return (
    <div data-dm="" className={cx('dm-lva', className)}>
      {turns.length > 0 && turns[0].question && <div className="dm-lva-q"><div className="dm-bub">{turns[0].question}</div></div>}
      <div className="dm-lva-a">
        {turns.map((t, i) => (
          <Fragment key={t.key ?? i}>
            {i > 0 && t.question && <div className="dm-lva-q dm-enter"><div className="dm-bub">{t.question}</div></div>}
            <TurnSlot turn={t} followUps={i === last ? followUps : undefined} onFollowUp={onFollowUp}
              onRetry={onRetry && (() => onRetry(i))} onRate={onRate && (r => onRate(i, r))} onCopy={onCopy && (x => onCopy(i, x))} onSourceClick={onSourceClick} />
          </Fragment>
        ))}
      </div>
    </div>
  );
}
live-answer.cssReact · 47 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Live answer ---------- */
.dm-lva{display:flex;flex-direction:column;gap:14px}
.dm-lva-q{display:flex;justify-content:flex-end}
.dm-lva-a{display:flex;flex-direction:column;gap:14px;min-height:230px}
/* on a card the first answer keeps the room its sources and follow-ups fill, so nothing climbs when they arrive */
.dm-stage-in>.dm-lva .dm-lva-a{min-height:324px}
.dm-lva-turn{display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-lva-th{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13.5px;color:var(--dm-muted)}
.dm-lva-tb{display:inline-flex;gap:6px;align-self:flex-start;padding:2px var(--dm-s-2);margin-left:-8px;border-radius:var(--dm-r-pill);transition:color var(--dm-t-fast),background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-lva-tb:hover{color:var(--dm-fg2);background:var(--dm-row)}}
.dm-lva-tb>.dm-i{transition:transform .2s var(--dm-ease)}
.dm-lva-tb[aria-expanded="true"]>.dm-i{transform:rotate(180deg)}
.dm-lva-tlist{list-style:none;margin:0;padding:2px 0 2px var(--dm-s-3);border-left:1px solid color-mix(in srgb,var(--dm-rail) 50%,transparent);display:flex;flex-direction:column;gap:var(--dm-s-1);font-size:13px;color:var(--dm-muted)}
.dm-lva-p{margin:0;font-size:14px;line-height:1.65}
.dm-lva-p .dm-w{display:inline;animation:dm-lvw .5s var(--dm-ease) both}
@keyframes dm-lvw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-cite{display:inline-grid;place-items:center;min-width:17px;height:17px;padding:0 var(--dm-s-1);margin:0 2px;border-radius:5px;background:var(--dm-hover);font-size:10.5px;font-weight:500;color:var(--dm-fg2);vertical-align:2px;cursor:default;transition:background .2s,color .2s}
.dm-cite.dm-on{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.dm-cite:hover{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}}
.dm-lva-p .dm-cite{animation:dm-lvw .5s var(--dm-ease) both}
.dm-lva-src{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--dm-muted)}
.dm-lva-sb{display:inline-flex;align-items:center;gap:var(--dm-s-2);height:28px;padding:0 9px 0 5px;border-radius:var(--dm-r-pill);color:var(--dm-fg2);transition:background .2s,color .2s}
.dm-lva-sb[aria-expanded="true"]{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-lva-sb:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-lva-sb>.dm-i{color:var(--dm-muted);transition:transform .35s var(--dm-ease)}
.dm-lva-sb[aria-expanded="true"]>.dm-i{transform:rotate(180deg)}
.dm-lva-fav{display:flex}
.dm-lva-fav span{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:9.5px;font-weight:500;color:#fff;background-image:linear-gradient(rgba(0,0,0,.3),rgba(0,0,0,.3));margin-left:-4px;box-shadow:0 0 0 1.5px var(--dm-surface);transition:margin .3s var(--dm-ease)}
.dm-lva-fav span:first-child{margin-left:0}
@media (hover:hover) and (pointer:fine){.dm-lva-sb:hover .dm-lva-fav span:not(:first-child){margin-left:2px}}
.dm-lva-acts{display:flex;gap:2px;margin-left:-6px}
.dm-lva-acts .dm-ib{width:28px;height:28px}
.dm-lva-ll{display:flex;flex-direction:column;gap:2px}
.dm-lva-s{display:flex;align-items:center;gap:10px;width:100%;padding:7px var(--dm-s-2);border-radius:10px;font-size:13px;text-align:left;color:var(--dm-fg);transition:background .2s}
.dm-lva-s.dm-on{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-lva-s:hover{background:var(--dm-row)}}
.dm-lva-s .dm-n{width:17px;height:17px;border-radius:5px;background:var(--dm-hover);display:grid;place-items:center;font-size:10.5px;font-weight:500;color:var(--dm-fg2);flex:none;transition:background .2s,color .2s}
.dm-lva-s.dm-on .dm-n{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.dm-lva-s:hover .dm-n{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}}
.dm-lva-s .dm-d{color:var(--dm-muted);font-size:12px;margin-right:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-lva-s>.dm-i{color:var(--dm-muted);opacity:0;transition:opacity .2s}
@media (hover:hover) and (pointer:fine){.dm-lva-s:hover>.dm-i{opacity:1}}
.dm-lva-fu{display:flex;flex-direction:column;align-items:flex-start;gap:6px}
.dm-lva-fu button{display:inline-flex;align-items:center;gap:var(--dm-s-2);padding:6px var(--dm-s-3) 6px 10px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2);transition:color .2s,box-shadow .2s,transform .2s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-lva-fu button:hover{color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}}
live-answer.htmlHTML · 9 lines
<div class="card bare" data-c="live-answer">
  <div class="stage-in">
    <div class="lva">
      <div class="lva-q"><div class="bub">Where are people dropping off in onboarding?</div></div>
      <div class="lva-a"></div>
    </div>
  </div>
</div>
live-answer.cssHTML · 46 lines
/* ---------- Live answer ---------- */
.lva{display:flex;flex-direction:column;gap:14px}
.lva-q{display:flex;justify-content:flex-end}
.lva-a{display:flex;flex-direction:column;gap:14px;min-height:230px}
/* on a card the first answer keeps the room its sources and follow-ups fill, so nothing climbs when they arrive */
.stage-in>.lva .lva-a{min-height:324px}
.lva-turn{display:flex;flex-direction:column;gap:var(--s-3)}
.lva-th{display:flex;align-items:center;gap:var(--s-2);font-size:13.5px;color:var(--muted)}
.lva-tb{display:inline-flex;gap:6px;align-self:flex-start;padding:2px var(--s-2);margin-left:-8px;border-radius:var(--r-pill);transition:color var(--t-fast),background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.lva-tb:hover{color:var(--fg2);background:var(--row)}}
.lva-tb>.i{transition:transform .2s var(--ease)}
.lva-tb[aria-expanded="true"]>.i{transform:rotate(180deg)}
.lva-tlist{list-style:none;margin:0;padding:2px 0 2px var(--s-3);border-left:1px solid color-mix(in srgb,var(--rail) 50%,transparent);display:flex;flex-direction:column;gap:var(--s-1);font-size:13px;color:var(--muted)}
.lva-p{margin:0;font-size:14px;line-height:1.65}
.lva-p .w{display:inline;animation:lvw .5s var(--ease) both}
@keyframes lvw{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.cite{display:inline-grid;place-items:center;min-width:17px;height:17px;padding:0 var(--s-1);margin:0 2px;border-radius:5px;background:var(--hover);font-size:10.5px;font-weight:500;color:var(--fg2);vertical-align:2px;cursor:default;transition:background .2s,color .2s}
.cite.on{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
@media (hover:hover) and (pointer:fine){.cite:hover{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}}
.lva-p .cite{animation:lvw .5s var(--ease) both}
.lva-src{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted)}
.lva-sb{display:inline-flex;align-items:center;gap:var(--s-2);height:28px;padding:0 9px 0 5px;border-radius:var(--r-pill);color:var(--fg2);transition:background .2s,color .2s}
.lva-sb[aria-expanded="true"]{background:var(--row);color:var(--fg)}
@media (hover:hover) and (pointer:fine){.lva-sb:hover{background:var(--row);color:var(--fg)}}
.lva-sb>.i{color:var(--muted);transition:transform .35s var(--ease)}
.lva-sb[aria-expanded="true"]>.i{transform:rotate(180deg)}
.lva-fav{display:flex}
.lva-fav span{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:9.5px;font-weight:500;color:#fff;background-image:linear-gradient(rgba(0,0,0,.3),rgba(0,0,0,.3));margin-left:-4px;box-shadow:0 0 0 1.5px var(--surface);transition:margin .3s var(--ease)}
.lva-fav span:first-child{margin-left:0}
@media (hover:hover) and (pointer:fine){.lva-sb:hover .lva-fav span:not(:first-child){margin-left:2px}}
.lva-acts{display:flex;gap:2px;margin-left:-6px}
.lva-acts .ib{width:28px;height:28px}
.lva-ll{display:flex;flex-direction:column;gap:2px}
.lva-s{display:flex;align-items:center;gap:10px;width:100%;padding:7px var(--s-2);border-radius:10px;font-size:13px;text-align:left;color:var(--fg);transition:background .2s}
.lva-s.on{background:var(--row)}
@media (hover:hover) and (pointer:fine){.lva-s:hover{background:var(--row)}}
.lva-s .n{width:17px;height:17px;border-radius:5px;background:var(--hover);display:grid;place-items:center;font-size:10.5px;font-weight:500;color:var(--fg2);flex:none;transition:background .2s,color .2s}
.lva-s.on .n{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
@media (hover:hover) and (pointer:fine){.lva-s:hover .n{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}}
.lva-s .d{color:var(--muted);font-size:12px;margin-right:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lva-s>.i{color:var(--muted);opacity:0;transition:opacity .2s}
@media (hover:hover) and (pointer:fine){.lva-s:hover>.i{opacity:1}}
.lva-fu{display:flex;flex-direction:column;align-items:flex-start;gap:6px}
.lva-fu button{display:inline-flex;align-items:center;gap:var(--s-2);padding:6px var(--s-3) 6px 10px;border-radius:var(--r-pill);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:13px;color:var(--fg2);transition:color .2s,box-shadow .2s,transform .2s var(--ease)}
@media (hover:hover) and (pointer:fine){.lva-fu button:hover{color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}}
live-answer.jsHTML · 59 lines
DM.def('live-answer', (r, c) => {
  const A = $('.lva-a', r);
  const srcs = { 1:'Funnel data, last 30 days', 2:'Session notes from 8 interviews', 3:'Onboarding.fig, step 2' };
  const S = [[1,'F','#7a5af8','Funnel data','Amplitude, last 30 days'],[2,'N','#0ea5a4','Session notes','Notion, 8 interviews'],[3,'S','#f97316','Onboarding.fig','Figma, step 2']];
  const T = {
    '': 'Most people leave at the workspace step [1]. It asks for a team name before anyone has made anything, and five of eight people in interviews paused there [2]. Moving it after the first project, and filling the name from the email domain, should help [3].',
    'Show the drop-off by device': 'On mobile the workspace step loses 41% [1], on desktop 23% [1]. Most of the mobile drop comes from the keyboard covering the Continue button [3].',
    'Draft a fix for the workspace step': 'Ask for the team name after the first project, and fill it from the email domain so most people never type it [2]. On mobile, keep Continue above the keyboard [3], then compare the step again after a week [1].'
  };
  const steps = ['Read the funnel for the last 30 days','Matched the drop to the interview notes','Checked step 2 in Onboarding.fig'];
  /* each answer streams into its own turn, so follow-ups add to the thread instead of replacing it */
  async function answer(q, turn){
    if (turn._busy) return; turn._busy = true; turn.innerHTML = '';
    const th = html(`<div class="lva-th"><span class="think on" style="color:var(--fg2)"><i></i><i></i><i></i><i></i></span><span class="shim">Thinking</span></div>`);
    turn.appendChild(th); enter(th);
    await c.wait(1300);
    const tb = html(`<button class="lva-th lva-tb" aria-expanded="false"><span>${steps.length} steps · 2s</span>${I('chev-d','xs')}</button>`);
    const tl = html(`<div class="fold shut lva-tl"><div><ul class="lva-tlist">${steps.map(x => `<li>${x}</li>`).join('')}</ul></div></div>`);
    th.replaceWith(tb); tb.after(tl);
    tb.onclick = () => { const s = tl.classList.toggle('shut'); tb.setAttribute('aria-expanded', !s); };
    const p = html('<p class="lva-p"></p>'); turn.appendChild(p);
    for (const tok of T[q].split(' ')){
      const m = tok.match(/^\[(\d)\](.*)$/);
      const prev = p.lastElementChild;
      if (m && prev){ prev.textContent = prev.textContent.replace(/ $/, ''); prev.style.whiteSpace = 'nowrap'; prev.insertAdjacentHTML('beforeend', `<span class="cite" data-tip="${srcs[m[1]]}">${m[1]}</span>${m[2]} `); }
      else p.insertAdjacentHTML('beforeend', `<span class="w">${tok} </span>`);
      await c.wait(55 + Math.random() * 45);
    }
    const src = html(`<div class="lva-src"><span class="lva-acts"><button class="ib" aria-label="Copy" data-tip="Copy">${I('copy')}</button><button class="ib" aria-label="Good answer" data-tip="Good answer">${I('up-thumb')}</button><button class="ib" aria-label="Bad answer" data-tip="Bad answer">${I('down-thumb')}</button><button class="ib" aria-label="Try again" data-tip="Try again">${I('refresh')}</button></span><button class="lva-sb" aria-expanded="false"><span class="lva-fav">${S.map(x => `<span style="background-color:${x[2]}">${x[1]}</span>`).join('')}</span>3 sources${I('chev-d','xs')}</button></div>`);
    const ls = html(`<div class="fold shut lva-ls"><div><div class="lva-ll">${S.map(x => `<button class="lva-s" data-n="${x[0]}"><span class="n">${x[0]}</span><span class="t">${x[3]}</span><span class="d">${x[4]}</span>${I('ext','xs')}</button>`).join('')}</div></div></div>`);
    turn.appendChild(src); turn.appendChild(ls); enter(src);
    const sb = $('.lva-sb', src), open = o => { ls.classList.toggle('shut', !o); sb.setAttribute('aria-expanded', o); };
    sb.onclick = () => open(ls.classList.contains('shut'));
    /* hover previews a source, a click pins it until the next click */
    let pin = '';
    const hl = n => { $$('.cite', p).forEach(x => x.classList.toggle('on', x.textContent === n)); $$('.lva-s', ls).forEach(x => x.classList.toggle('on', x.dataset.n === n)); };
    const pick = n => { pin = pin === n ? '' : n; if (pin) open(true); hl(pin); };
    $$('.lva-s', ls).forEach(x => { x.onmouseenter = () => hl(x.dataset.n); x.onmouseleave = () => hl(pin); x.onclick = () => pick(x.dataset.n); });
    $$('.cite', p).forEach(x => { x.style.cursor = 'pointer'; x.onmouseenter = () => hl(x.textContent); x.onmouseleave = () => hl(pin); x.onclick = () => pick(x.textContent); });
    const acts = $$('.lva-acts .ib', src);
    acts[0].onclick = () => { const cl = p.cloneNode(true); $$('.cite', cl).forEach(x => x.remove()); copied(acts[0], cl.textContent.replace(/\s+/g, ' ').trim()); };
    [1, 2].forEach(i => acts[i].onclick = () => { const on = !acts[i].classList.contains('on'); acts[1].classList.remove('on'); acts[2].classList.remove('on'); acts[i].classList.toggle('on', on); });
    acts[3].onclick = () => c.run(() => answer(q, turn));
    turn._busy = false;
    if (!q && !A.querySelector('.lva-q.fu')){
      const fu = html(`<div class="lva-fu"><button>${I('chev-r','xs')}Show the drop-off by device</button><button>${I('chev-r','xs')}Draft a fix for the workspace step</button></div>`);
      turn.appendChild(fu); stagger($$('button', fu), 90);
      $$('button', fu).forEach(b => b.onclick = () => {
        const t = b.textContent; fu.remove();
        const bub = html(`<div class="lva-q fu"><div class="bub">${t}</div></div>`), next = html('<div class="lva-turn"></div>');
        A.append(bub, next); enter(bub);
        c.run(async () => { await c.wait(450); await answer(t, next); });
      });
    }
  }
  const first = html('<div class="lva-turn"></div>'); A.appendChild(first);
  c.run(() => answer('', first));
});
06

Questions

What people ask about it.

How do I stream the answer in?
Set the turn's state to 'answering' and keep updating its text as tokens arrive. Each new word fades in, and when the stream ends you set state to 'done' and pass sources.
How do citations link to sources?
Put [n] right after a word in text, and give a source in sources the same n. Its tooltip shows the source's tip, or its title and detail when tip is left out.
What happens to ratings and pinned sources when the person clicks Try again?
When a turn goes from done back to answering, it starts afresh with closed lists, no pinned source and no rating. Your onRetry handler decides how to regenerate the text.
Can I show the question bubble somewhere else, like in my own chat?
Yes. Leave out question on a turn and no bubble is drawn for it, so only the answer appears.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
Answer action barCopy, regenerate, rate and listen to an answer from one row under it.
All 15 in Conversation →