ConversationLive answer
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callLive 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add live-answerReact
npx designme add live-answer --reactThe 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.
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.
- Each turn has a
state: waiting shows nothing, thinking shows a Thinking line, answering streamstext, and done adds sources and actions. - Write [1] after a word in
textto cite source 1. A half-typed citation like "[2" is held back until it is complete, and a blank line starts a new paragraph. - Hovering a citation or a source highlights the pair, and a click pins it and opens the source list until the next click.
- A toggle like "3 steps · 2s" lists the agent's
thoughts. Leave outthoughtForand it counts the seconds while the turn is thinking. - Copy, Good answer and Bad answer buttons sit under every finished answer, and
onRetryadds a Try again button. Copying strips the citation numbers before callingonCopy. followUpsappear under the last answer once it is done, andonFollowUpreceives the chosen question so you can add the next turn.
- The steps toggle and the sources button report their open state with
aria-expanded. - The Good answer and Bad answer buttons use
aria-pressed, and every icon-only button has anaria-label. - Collapsed thought and source lists are marked
inertandaria-hidden, so they drop out of the tab order. - Under
prefers-reduced-motion: reduce, the word fade-ins and other animations are cut to near zero.
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>
);
}
Props
Everything LiveAnswer takes.
| Prop | Type | What it does |
|---|---|---|
turnsrequired | AnswerTurn[] | |
followUps | string[] | Suggested next questions, shown under the last answer once it is done |
onFollowUp | (question: string) => void | |
onRetry | (index: number) => void | Adds a Try again button under each answer |
onRate | (index: number, rating: AnswerRating) => void | |
onCopy | (index: number, text: string) => void | Called after an answer is copied, with its text without the citations |
onSourceClick | (source: AnswerSource) => void | Called when a source in the list is clicked, besides pinning it |
className | string |
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));
});
Questions
What people ask about it.
- How do I stream the answer in?
- Set the turn's
stateto 'answering' and keep updating itstextas tokens arrive. Each new word fades in, and when the stream ends you setstateto 'done' and passsources. - How do citations link to sources?
- Put [n] right after a word in
text, and give a source insourcesthe samen. Its tooltip shows the source'stip, or its title and detail whentipis 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
onRetryhandler decides how to regenerate the text. - Can I show the question bubble somewhere else, like in my own chat?
- Yes. Leave out
questionon a turn and no bubble is drawn for it, so only the answer appears.
More in Conversation
Everything for asking, answering and replying, by text or by voice.