Skip to content
Star on GitHub
ConversationAnswer action bar
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

Answer action bar

Copy, regenerate, rate and listen to an answer from one row under it. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add answer-action-bar
How much do people save on the yearly Pro plan?

What was off?

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add answer-action-bar

React

$npx designme add answer-action-bar --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.

Answer action bar is the row of actions under an agent's answer: copy, regenerate with a pager through the versions, thumbs up or down, and read aloud. A thumbs down asks why with a few reasons, a new version highlights what changed, and reading lights each word as it goes.

  • Under every answer in a chat, when people need to copy, retry, rate or listen to it without leaving the thread.
  • When a regenerated answer should keep the earlier versions a click away, with what changed easy to spot.
  • When a thumbs down should capture a reason the agent can act on, not just a count.
  • When people listen to answers, and you want them to see where the voice is and pause on a word.
03

Usage

Import it and drive it from your own state.

Import

import { AnswerActionBar } from '@/components/designme/answer-action-bar/AnswerActionBar';

Example

The demo above as a React component, 68 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 { useRef, useState } from 'react';
import { AnswerActionBar, type AnswerVersion } from './AnswerActionBar';
import { useScript, useStory, useWait } from '../lib/useScript';

/* ---------- Answer action bar: listen, hear a wrong number, rate it down, say why, ride out a dropped connection, copy the fix ---------- */
// the versions a regenerate writes, in turn; $24 to $19 a month saves 21%, so the first one is wrong
const POOL: AnswerVersion[] = [
  { text: 'Pro is $24 a month, or $19 a month when billed yearly. That saves 15%, and yearly is the option the new pricing page shows first.', meta: 'Sonnet 5.5 · 6.2s' },
  { text: 'Pro is $24 a month, or $19 a month billed yearly, which saves 21%, or $60 a year. The new pricing page shows the yearly price first.', changed: '21%, or $60 a year.', meta: 'Sonnet 5.5 · 4.8s' },
  { text: 'Billed yearly, Pro drops from $24 to $19 a month: $60 back each year, about 21%. That is why the pricing page leads with yearly.', changed: '$60 back each year,', meta: 'Sonnet 5.5 · 5.1s' }
];

export default function AnswerActionBarDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [versions, setVersions] = useState<AnswerVersion[]>([{ ...POOL[0], text: '' }]);
  const [version, setVersion] = useState(0);
  const [streaming, setStreaming] = useState(true);
  const [status, setStatus] = useState('');
  const [tone, setTone] = useState<'warn' | 'ok'>();
  const failed = useRef(false);

  // a version streams in word by word
  const stream = async (i: number, v: AnswerVersion) => {
    setStreaming(true);
    const ws = v.text.split(' ');
    for (let k = 1; k <= ws.length; k++) { setVersions(vs => vs.map((x, j) => j === i ? { ...v, text: ws.slice(0, k).join(' ') } : x)); await wait(38); }
    setStreaming(false);
  };
  useScript(async ({ wait: w }) => { await w(300); await stream(0, POOL[0]); });

  // regenerate writes the next version; the first time after a thumbs down the connection drops once and is retried
  const regenerate = async (note?: string) => {
    if (streaming) return;
    const label = 'Writing a new version' + (note ? ' with your note' : '');
    setTone(undefined); setStatus(label); setStreaming(true);
    if (note && !failed.current) {
      failed.current = true; await wait(1200);
      for (let k = 2; k > 0; k--) { setTone('warn'); setStatus(`Couldn't reach Sonnet 5.5. Retrying in ${k}s`); await wait(1000); }
      setTone(undefined); setStatus(label);
    }
    const i = versions.length, v = POOL[Math.min(i, POOL.length - 1)];
    setVersions(vs => [...vs, { ...v, text: '' }]); setVersion(i);
    await stream(i, v);
    setTone('ok'); setStatus(i === 1 ? 'Version 2 fixes the saving: 21%, not 15%' : `Version ${i + 1} of ${i + 1}`);
  };

  // the preview's story: listen until the wrong number, pause, rate it down, pick why, then copy the fix
  useStory(ref, async ({ wait, press }) => {
    await wait(2900); press('.dm-aab-rd');
    await wait(4100); press('.dm-aab-rd');
    await wait(500); press('.dm-aab-dn');
    await wait(1500); press('.dm-aab-ch .dm-chip');
    await wait(400 + 1200 + 2000 + 1200 + 1500); press('.dm-aab-cp');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <AnswerActionBar question="How much do people save on the yearly Pro plan?" versions={versions} version={version} onVersionChange={setVersion}
        streaming={streaming} status={status} statusTone={tone} onRegenerate={() => void regenerate()}
        onReason={(_, reason) => { setTimeout(() => void regenerate(reason), 400); }}
        onCopy={() => { setTone('ok'); setStatus('Copied without the highlights'); }} />
    </div>
  );
}
04

Props

Everything AnswerActionBar takes.

PropTypeWhat it does
questionReactNodeThe person's message the answer replies to, shown as a bubble above it
versionsrequiredAnswerVersion[]Every version of the answer so far, oldest first
versionnumberWhich version is showing. Leave it out to let the arrows page through them
defaultVersionnumber
onVersionChange(index: number) => void
streamingbooleanThe newest version is still being written: new words fade in as they arrive, and paging and reading wait
onRegenerate() => voidCalled by the regenerate button. Add a version and show it to answer it
onCopy(text: string) => voidCalled with the plain text after it is copied
onRate(index: number, rating: AnswerBarRating) => voidCalled when a thumb is pressed or pressed again to clear it
reasonsstring[]The reasons a thumbs down offers. Defaults to Wrong numbers, Too long, Not our tone and Missed the brief
onReason(index: number, reason: string) => voidCalled with the reason picked after a thumbs down
statusstringThe quiet line under the bar, like "Writing a new version". It shimmers while streaming
statusTone'warn' | 'ok'warn turns the status amber; ok gives it a green check
readingReadingStateRead aloud: playing lights each word in turn, paused keeps the place. Leave it out to let the button play and pause
defaultReadingReadingState
onReadingChange(state: ReadingState) => void
secondsPerWordnumberHow long the built-in reading clock gives each word. Defaults to 0.28s
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

AnswerActionBar.tsxReact · 154 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useCopied, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './answer-action-bar.css';

export type AnswerBarRating = 'up' | 'down' | null;
export type ReadingState = 'idle' | 'playing' | 'paused';

/** One version of the answer */
export interface AnswerVersion {
  text: string;
  /** A phrase in the text that changed from the version before, like "21%, or $60 a year". It's highlighted */
  changed?: string;
  /** Quiet text after the bar, like "Sonnet 5.5 · 6.2s" */
  meta?: string;
  /** Its rating. Leave it out to let the thumbs hold their own, one per version */
  rating?: AnswerBarRating;
}

export interface AnswerActionBarProps {
  /** The person's message the answer replies to, shown as a bubble above it */
  question?: ReactNode;
  /** Every version of the answer so far, oldest first */
  versions: AnswerVersion[];
  /** Which version is showing. Leave it out to let the arrows page through them */
  version?: number;
  defaultVersion?: number;
  onVersionChange?: (index: number) => void;
  /** The newest version is still being written: new words fade in as they arrive, and paging and reading wait */
  streaming?: boolean;
  /** Called by the regenerate button. Add a version and show it to answer it */
  onRegenerate?: () => void;
  /** Called with the plain text after it is copied */
  onCopy?: (text: string) => void;
  /** Called when a thumb is pressed or pressed again to clear it */
  onRate?: (index: number, rating: AnswerBarRating) => void;
  /** The reasons a thumbs down offers. Defaults to Wrong numbers, Too long, Not our tone and Missed the brief */
  reasons?: string[];
  /** Called with the reason picked after a thumbs down */
  onReason?: (index: number, reason: string) => void;
  /** The quiet line under the bar, like "Writing a new version". It shimmers while streaming */
  status?: string;
  /** warn turns the status amber; ok gives it a green check */
  statusTone?: 'warn' | 'ok';
  /** Read aloud: playing lights each word in turn, paused keeps the place. Leave it out to let the button play and pause */
  reading?: ReadingState;
  defaultReading?: ReadingState;
  onReadingChange?: (state: ReadingState) => void;
  /** How long the built-in reading clock gives each word. Defaults to 0.28s */
  secondsPerWord?: number;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

const REASONS = ['Wrong numbers', 'Too long', 'Not our tone', 'Missed the brief'];
const clock = (s: number) => Math.floor(s / 60) + ':' + String(Math.floor(s) % 60).padStart(2, '0');

/** Every action on an answer in one row under it: copy, regenerate with versions, thumbs with a reason, and read aloud. */
export function AnswerActionBar({
  question, versions, version, defaultVersion = 0, onVersionChange, streaming = false, onRegenerate, onCopy, onRate, reasons = REASONS, onReason,
  status = '', statusTone, reading, defaultReading = 'idle', onReadingChange, secondsPerWord = .28, bare = true, className
}: AnswerActionBarProps) {
  const [cur, setCur] = useControllable(version, defaultVersion, onVersionChange);
  const [read, setRead] = useControllable(reading, defaultReading, onReadingChange);
  const reduce = useReducedMotion();
  const last = Math.max(0, versions.length - 1), at = Math.min(Math.max(cur, 0), last), v = versions[at];
  const [own, setOwn] = useState<Record<number, AnswerBarRating>>({});
  const rating = v?.rating !== undefined ? v.rating : own[at] ?? null;
  const [asked, setAsked] = useState<Record<number, string>>({});
  const [copied, copyText] = useCopied();
  const [spin, setSpin] = useState(false);

  // the words, with the changed phrase marked
  const text = v?.text ?? '';
  const from = v?.changed ? text.indexOf(v.changed) : -1, to = from < 0 ? -1 : from + (v?.changed?.length ?? 0);
  let p = 0;
  const words = text.split(' ').filter(Boolean).map(w => { const a = text.indexOf(w, p); p = a + w.length; return { w, nw: from >= 0 && a < to && a + w.length > from }; });

  // reading: a place in words, lit one word at a time
  const [pos, setPos] = useState(0);
  const timer = useRef<ReturnType<typeof setInterval>>(undefined);
  useEffect(() => { setPos(0); if (read !== 'idle') setRead('idle'); }, [at]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (read !== 'playing') return;
    timer.current = setInterval(() => setPos(x => x + .1 / secondsPerWord), 100);
    return () => clearInterval(timer.current);
  }, [read, secondsPerWord]);
  useEffect(() => { if (read === 'playing' && pos >= words.length) { setRead('idle'); setPos(0); } }, [pos, read, words.length]); // eslint-disable-line react-hooks/exhaustive-deps
  const lit = read === 'idle' ? -1 : Math.min(words.length - 1, Math.floor(pos));

  const rate = (r: AnswerBarRating) => { const next = rating === r ? null : r; setOwn(o => ({ ...o, [at]: next })); onRate?.(at, next); if (next !== 'down') setAsked(a => ({ ...a, [at]: '' })); };
  const regenerate = () => { if (streaming) return; setRead('idle'); setPos(0); setSpin(true); setTimeout(() => setSpin(false), 700); onRegenerate?.(); };
  const copy = () => { copyText(text); onCopy?.(text); };
  const why = rating === 'down' && !asked[at];

  return (
    <div data-dm="" className={cx('dm-aab', !bare && 'dm-panel', className)} style={bare ? undefined : { padding: 16 }}>
      {question && <div className="dm-bub">{question}</div>}
      <p className="dm-msg dm-aab-t" aria-live="polite">
        {words.map(({ w, nw }, k) => (
          // inside the changed phrase the space is highlighted too, so the phrase reads as one
          <span key={at + ':' + k}>{k ? <span className={nw && words[k - 1].nw ? 'dm-nw' : undefined}> </span> : null}<span className={cx('dm-w dm-in', nw && 'dm-nw', k === lit && 'dm-rd')} style={!streaming && !reduce ? { animationDelay: k * 10 + 'ms' } : undefined}>{w}</span></span>
        ))}
      </p>
      <div className="dm-aab-row">
        <div className="dm-aab-bar" role="toolbar" aria-label="Answer actions" onKeyDown={e => {
          // Left and Right move between the toolbar's buttons, skipping any that are switched off
          const k = ({ ArrowRight: 1, ArrowLeft: -1 } as Record<string, number>)[e.key]; if (!k) return;
          const bs = [...e.currentTarget.querySelectorAll('button')].filter(b => !b.disabled && !b.closest('[inert]')), i = bs.indexOf(document.activeElement as HTMLButtonElement);
          if (i < 0) return; e.preventDefault(); bs[(i + k + bs.length) % bs.length].focus();
        }}>
          <Tip label={copied ? 'Copied to clipboard' : 'Copy'} open={copied || undefined}><button type="button" className={cx('dm-ib dm-aab-cp', copied && 'dm-copied')} aria-label="Copy" onClick={copy}><Icon name={copied ? 'check' : 'copy'} /></button></Tip>
          {onRegenerate && <Tip label="Regenerate"><button type="button" className={cx('dm-ib dm-aab-re', spin && 'dm-go')} aria-label="Regenerate" disabled={streaming} onClick={regenerate}><Icon name="refresh" /></button></Tip>}
          <span className={cx('dm-aab-pg', versions.length > 1 && 'dm-on')} inert={versions.length < 2}>
            <Tip label="Previous version"><button type="button" className="dm-ib" aria-label="Previous version" disabled={at === 0 || streaming} onClick={() => setCur(at - 1)}><Icon name="chev-l" size="xs" /></button></Tip>
            <span className="dm-aab-n dm-num" aria-live="polite">{at + 1} / {Math.max(versions.length, 1)}</span>
            <Tip label="Next version"><button type="button" className="dm-ib" aria-label="Next version" disabled={at === last || streaming} onClick={() => setCur(at + 1)}><Icon name="chev-r" size="xs" /></button></Tip>
          </span>
          <span className="dm-aab-sep" aria-hidden="true" />
          <Tip label="Good answer"><button type="button" className="dm-ib dm-aab-up" aria-label="Good answer" aria-pressed={rating === 'up'} onClick={() => rate('up')}><Icon name="up-thumb" /></button></Tip>
          <Tip label="Bad answer"><button type="button" className="dm-ib dm-aab-dn" aria-label="Bad answer" aria-pressed={rating === 'down'} onClick={() => rate('down')}><Icon name="down-thumb" /></button></Tip>
          <span className="dm-aab-sep" aria-hidden="true" />
          <Tip label={read === 'playing' ? 'Pause' : read === 'paused' ? 'Resume' : 'Read aloud'}>
            <button type="button" className={cx('dm-aab-rd', read !== 'idle' && 'dm-on', read === 'paused' && 'dm-paused')} aria-pressed={read === 'playing'}
              aria-label={read === 'playing' ? 'Pause reading' : read === 'paused' ? 'Resume reading' : 'Read aloud'} disabled={streaming || !words.length}
              onClick={() => setRead(read === 'playing' ? 'paused' : 'playing')}>
              <span className="dm-ic"><Icon name={read === 'playing' ? 'pause' : read === 'paused' ? 'play' : 'vol'} size="xs" /></span>
              <span className="dm-aab-pr" aria-hidden="true"><i style={{ width: Math.min(100, pos / Math.max(words.length, 1) * 100) + '%' }} /></span>
              <span className="dm-aab-tm dm-num">{clock(pos * secondsPerWord)} / {clock(words.length * secondsPerWord)}</span>
            </button>
          </Tip>
        </div>
        <Swap className="dm-aab-meta dm-muted dm-num">{v?.meta ?? ''}</Swap>
      </div>
      <Fold open={why} className="dm-aab-why">
        <div className="dm-aab-wy">
          <span className="dm-aab-wq">What was off?</span>
          <div className="dm-aab-ch">
            {reasons.map(r => <button key={r} type="button" className="dm-chip dm-sm" onClick={() => { setAsked(a => ({ ...a, [at]: r })); onReason?.(at, r); }}>{r}</button>)}
          </div>
        </div>
      </Fold>
      <p className={cx('dm-aab-st dm-muted', statusTone && 'dm-' + statusTone)} aria-live="polite">
        {statusTone === 'ok' && status ? <><Icon name="ok" size="xs" /><span key={status} {...enter()}>{status}</span></> : <Swap className={streaming && status ? 'dm-shim' : undefined}>{status}</Swap>}
      </p>
    </div>
  );
}
answer-action-bar.cssReact · 48 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Answer action bar ---------- */
/* every action on an answer in one raised rail under it: copy, regenerate with its versions, thumbs, and read aloud, which
   widens into a progress bar and a clock and lights each word as it reads it */
.dm-aab{display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-aab-t{min-height:78px}
.dm-aab-t .dm-w{transition:background .16s,color .16s;border-radius:var(--dm-r-xs)}
.dm-aab-t .dm-w.dm-in{animation:dm-aabw .42s var(--dm-ease) both}
@keyframes dm-aabw{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:none}}
.dm-aab-t.dm-out{animation:dm-aabo .32s var(--dm-ease) both}
@keyframes dm-aabo{to{opacity:0;filter:blur(4px);transform:translateY(-2px)}}
.dm-aab-t .dm-nw{background:var(--dm-hl-ok);border-radius:0}
.dm-aab-t .dm-w.dm-rd{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-aab-row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--dm-s-2) var(--dm-s-3)}
.dm-aab-bar{display:inline-flex;align-items:center;gap:1px;padding:3px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:var(--dm-shadow)}
.dm-aab-bar .dm-ib{width:28px;height:28px}
.dm-aab-bar .dm-ib[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-aab-bar .dm-ib svg{transition:transform .42s var(--dm-ease)}
.dm-aab-re.dm-go svg{transform:rotate(360deg)}
.dm-aab-up[aria-pressed="true"]{color:var(--dm-ok);background:var(--dm-ok-bg)}
.dm-aab-dn[aria-pressed="true"]{color:var(--dm-bad);background:var(--dm-bad-bg)}
.dm-aab-up[aria-pressed="true"] svg,.dm-aab-dn[aria-pressed="true"] svg{transform:scale(1.1) rotate(-8deg)}
.dm-aab-sep{width:1px;height:16px;background:var(--dm-line);margin:0 var(--dm-s-1)}
/* the version pager only takes room once there is a second version */
.dm-aab-pg{display:inline-flex;align-items:center;max-width:0;overflow:hidden;opacity:0;transition:max-width .42s var(--dm-ease),opacity .32s}
.dm-aab-pg.dm-on{max-width:96px;opacity:1}
.dm-aab-pg .dm-ib{width:24px;height:24px}
.dm-aab-n{font-size:12px;color:var(--dm-muted);min-width:32px;text-align:center}
.dm-aab-rd{display:inline-flex;align-items:center;height:28px;padding:0 7px;border-radius:var(--dm-r-pill);color:var(--dm-fg2);transition:background .16s,color .16s}
@media (hover:hover) and (pointer:fine){.dm-aab-rd:hover{background:var(--dm-hover)}}
.dm-aab-rd .dm-ic{display:grid;place-items:center;width:14px}
.dm-aab-rd[aria-pressed="true"]{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-aab-rd.dm-paused{background:var(--dm-chip);color:var(--dm-fg2)}
.dm-aab-pr{width:0;height:3px;border-radius:2px;background:color-mix(in srgb,currentColor 22%,transparent);overflow:hidden;margin-left:0;transition:width .42s var(--dm-ease),margin .42s var(--dm-ease)}
.dm-aab-pr i{display:block;height:100%;width:0;background:currentColor;border-radius:inherit}
.dm-aab-tm{font-size:11.5px;max-width:0;overflow:hidden;white-space:nowrap;transition:max-width .42s var(--dm-ease),margin .42s var(--dm-ease)}
.dm-aab-rd.dm-on .dm-aab-pr{width:56px;margin-left:var(--dm-s-2)}
.dm-aab-rd.dm-on .dm-aab-tm{max-width:80px;margin-left:var(--dm-s-2)}
.dm-aab-meta{font-size:12px}
.dm-aab-wy{display:flex;flex-direction:column;gap:var(--dm-s-2);padding:2px 0 var(--dm-s-1)}
.dm-aab-wq{font-size:13px;font-weight:500}
.dm-aab-ch{display:flex;flex-wrap:wrap;gap:6px}
.dm-aab-ch .dm-chip.dm-on{background:var(--dm-sel);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
.dm-aab-st{margin:0;min-height:20px;font-size:12.5px;display:flex;align-items:center;gap:6px}
.dm-aab-st.dm-warn{color:var(--dm-warn)}
.dm-aab-st.dm-ok svg{color:var(--dm-ok);flex:none}
@media (max-width:480px){.dm-aab-rd.dm-on .dm-aab-tm{display:none}.dm-aab-rd.dm-on .dm-aab-pr{width:40px}}
answer-action-bar.htmlHTML · 27 lines
<div class="card bare" data-c="answer-action-bar">
  <div class="stage-in">
    <div class="aab">
      <div class="bub">How much do people save on the yearly Pro plan?</div>
      <p class="msg aab-t" aria-live="polite"></p>
      <div class="aab-row">
        <div class="aab-bar" role="toolbar" aria-label="Answer actions">
          <button class="ib aab-cp" aria-label="Copy" data-tip="Copy"><svg class="i s"><use href="#i-copy"/></svg></button>
          <button class="ib aab-re" aria-label="Regenerate" data-tip="Regenerate"><svg class="i s"><use href="#i-refresh"/></svg></button>
          <span class="aab-pg"><button class="ib aab-prev" aria-label="Previous version" data-tip="Previous version"><svg class="i xs"><use href="#i-chev-l"/></svg></button><span class="aab-n num" aria-live="polite">1 / 1</span><button class="ib aab-next" aria-label="Next version" data-tip="Next version"><svg class="i xs"><use href="#i-chev-r"/></svg></button></span>
          <span class="aab-sep" aria-hidden="true"></span>
          <button class="ib aab-up" aria-label="Good answer" aria-pressed="false" data-tip="Good answer"><svg class="i s"><use href="#i-up-thumb"/></svg></button>
          <button class="ib aab-dn" aria-label="Bad answer" aria-pressed="false" data-tip="Bad answer"><svg class="i s"><use href="#i-down-thumb"/></svg></button>
          <span class="aab-sep" aria-hidden="true"></span>
          <button class="aab-rd" aria-label="Read aloud" aria-pressed="false" data-tip="Read aloud"><span class="ic"><svg class="i xs"><use href="#i-vol"/></svg></span><span class="aab-pr" aria-hidden="true"><i></i></span><span class="aab-tm num"></span></button>
        </div>
        <span class="aab-meta muted num"></span>
      </div>
      <div class="fold shut aab-why"><div><div class="aab-wy">
        <span class="aab-wq">What was off?</span>
        <div class="aab-ch"><button class="chip sm">Wrong numbers</button><button class="chip sm">Too long</button><button class="chip sm">Not our tone</button><button class="chip sm">Missed the brief</button></div>
      </div></div></div>
      <p class="aab-st muted" aria-live="polite"></p>
    </div>
  </div>
</div>
answer-action-bar.cssHTML · 47 lines
/* ---------- Answer action bar ---------- */
/* every action on an answer in one raised rail under it: copy, regenerate with its versions, thumbs, and read aloud, which
   widens into a progress bar and a clock and lights each word as it reads it */
.aab{display:flex;flex-direction:column;gap:var(--s-3)}
.aab-t{min-height:78px}
.aab-t .w{transition:background .16s,color .16s;border-radius:var(--r-xs)}
.aab-t .w.in{animation:aabw .42s var(--ease) both}
@keyframes aabw{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:none}}
.aab-t.out{animation:aabo .32s var(--ease) both}
@keyframes aabo{to{opacity:0;filter:blur(4px);transform:translateY(-2px)}}
.aab-t .nw{background:var(--hl-ok);border-radius:0}
.aab-t .w.rd{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
.aab-row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--s-2) var(--s-3)}
.aab-bar{display:inline-flex;align-items:center;gap:1px;padding:3px;border-radius:var(--r-pill);background:var(--surface);box-shadow:var(--shadow)}
.aab-bar .ib{width:28px;height:28px}
.aab-bar .ib[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.aab-bar .ib svg{transition:transform .42s var(--ease)}
.aab-re.go svg{transform:rotate(360deg)}
.aab-up[aria-pressed="true"]{color:var(--ok);background:var(--ok-bg)}
.aab-dn[aria-pressed="true"]{color:var(--bad);background:var(--bad-bg)}
.aab-up[aria-pressed="true"] svg,.aab-dn[aria-pressed="true"] svg{transform:scale(1.1) rotate(-8deg)}
.aab-sep{width:1px;height:16px;background:var(--line);margin:0 var(--s-1)}
/* the version pager only takes room once there is a second version */
.aab-pg{display:inline-flex;align-items:center;max-width:0;overflow:hidden;opacity:0;transition:max-width .42s var(--ease),opacity .32s}
.aab-pg.on{max-width:96px;opacity:1}
.aab-pg .ib{width:24px;height:24px}
.aab-n{font-size:12px;color:var(--muted);min-width:32px;text-align:center}
.aab-rd{display:inline-flex;align-items:center;height:28px;padding:0 7px;border-radius:var(--r-pill);color:var(--fg2);transition:background .16s,color .16s}
@media (hover:hover) and (pointer:fine){.aab-rd:hover{background:var(--hover)}}
.aab-rd .ic{display:grid;place-items:center;width:14px}
.aab-rd[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
.aab-rd.paused{background:var(--chip);color:var(--fg2)}
.aab-pr{width:0;height:3px;border-radius:2px;background:color-mix(in srgb,currentColor 22%,transparent);overflow:hidden;margin-left:0;transition:width .42s var(--ease),margin .42s var(--ease)}
.aab-pr i{display:block;height:100%;width:0;background:currentColor;border-radius:inherit}
.aab-tm{font-size:11.5px;max-width:0;overflow:hidden;white-space:nowrap;transition:max-width .42s var(--ease),margin .42s var(--ease)}
.aab-rd.on .aab-pr{width:56px;margin-left:var(--s-2)}
.aab-rd.on .aab-tm{max-width:80px;margin-left:var(--s-2)}
.aab-meta{font-size:12px}
.aab-wy{display:flex;flex-direction:column;gap:var(--s-2);padding:2px 0 var(--s-1)}
.aab-wq{font-size:13px;font-weight:500}
.aab-ch{display:flex;flex-wrap:wrap;gap:6px}
.aab-ch .chip.on{background:var(--sel);color:var(--accent-fg,var(--accent));box-shadow:none}
.aab-st{margin:0;min-height:20px;font-size:12.5px;display:flex;align-items:center;gap:6px}
.aab-st.warn{color:var(--warn)}
.aab-st.ok svg{color:var(--ok);flex:none}
@media (max-width:480px){.aab-rd.on .aab-tm{display:none}.aab-rd.on .aab-pr{width:40px}}
answer-action-bar.jsHTML · 93 lines
DM.def('answer-action-bar', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{copy}', I('copy')).replace('{re}', I('refresh')).replace('{l}', I('chev-l','xs')).replace('{rr}', I('chev-r','xs'))
    .replace('{up}', I('up-thumb')).replace('{dn}', I('down-thumb')).replace('{vol}', I('vol','xs'));
  // the versions a regenerate writes, in turn. [[…]] marks what changed from the version before; $24 to $19 a month saves 21%
  const POOL = [
    { t:'Pro is $24 a month, or $19 a month when billed yearly. That saves 15%, and yearly is the option the new pricing page shows first.', meta:'Sonnet 5.5 · 6.2s' },
    { t:'Pro is $24 a month, or $19 a month billed yearly, which saves [[21%, or $60 a year]]. The new pricing page shows the yearly price first.', meta:'Sonnet 5.5 · 4.8s' },
    { t:'Billed yearly, Pro drops from $24 to $19 a month: [[$60 back each year]], about 21%. That is why the pricing page leads with yearly.', meta:'Sonnet 5.5 · 5.1s' }
  ];
  const t = $('.aab-t', r), pg = $('.aab-pg', r), n = $('.aab-n', r), pv = $('.aab-prev', r), nx = $('.aab-next', r), re = $('.aab-re', r), cp = $('.aab-cp', r);
  const up = $('.aab-up', r), dn = $('.aab-dn', r), rd = $('.aab-rd', r), bar = $('.aab-pr i', r), tm = $('.aab-tm', r), meta = $('.aab-meta', r), why = $('.aab-why', r), st = $('.aab-st', r), chips = $$('.aab-ch .chip', r);
  // every version keeps its own rating
  const vers = [{ ...POOL[0], rate:'' }]; let cur = 0, busy = false, failOnce = false;
  const plain = v => v.t.replace(/\[\[|\]\]/g, '');
  // each word's HTML, with the space before it; inside the changed phrase the space is highlighted too, so the phrase reads as one
  const words = v => { let on = false; return v.t.split(' ').map((w, i) => { const a = w.startsWith('[['), b = w.includes(']]'), inner = on && !a; if (a) on = true;
    const s = (i ? `<span${inner ? ' class="nw"' : ''}> </span>` : '') + `<span class="w${on ? ' nw' : ''}">${w.replace(/\[\[|\]\]/g, '')}</span>`; if (b) on = false; return s; }); };
  const status = (text, tone) => { st.className = 'aab-st muted' + (tone ? ' ' + tone : ''); if (tone === 'ok') { st.innerHTML = `${I('ok','xs')}<span>${text}</span>`; enter(st); } else swap(st, text); };
  function nav(){
    n.textContent = (cur + 1) + ' / ' + vers.length; pg.classList.toggle('on', vers.length > 1); pg.inert = vers.length < 2;
    pv.disabled = cur === 0 || busy; nx.disabled = cur === vers.length - 1 || busy; re.disabled = busy;
    up.setAttribute('aria-pressed', vers[cur].rate === 'up'); dn.setAttribute('aria-pressed', vers[cur].rate === 'down');
    swap(meta, vers[cur].meta);
  }
  // a version streams in word by word; paging between versions shows one at once
  async function stream(v){
    t.classList.add('out'); await c.wait(260); t.classList.remove('out'); t.innerHTML = '';
    const ws = words(v);
    for (let i = 0; i < ws.length; i++){ t.insertAdjacentHTML('beforeend', ws[i]); t.lastElementChild.classList.add('in'); await c.wait(38); }
  }
  async function show(i){ stop(); cur = i; nav(); t.classList.add('out'); await c.wait(240); t.classList.remove('out'); t.innerHTML = words(vers[i]).join(''); $$('.w', t).forEach((w, k) => { w.classList.add('in'); w.style.animationDelay = k * 10 + 'ms'; }); }
  pv.onclick = () => c.run(() => show(cur - 1));
  nx.onclick = () => c.run(() => show(cur + 1));
  // regenerate writes the next version; a dropped connection is retried once with a countdown
  function regenerate(note){
    if (busy) return; stop(); busy = true; re.classList.add('go'); c.later(700, () => re.classList.remove('go')); nav();
    c.run(async () => {
      if (failOnce){ failOnce = false;
        status('Writing a new version' + (note ? ' with your note' : '')); st.classList.add('shim'); await c.wait(1200); st.classList.remove('shim');
        for (let k = 2; k > 0; k--){ status(`Couldn't reach Sonnet 5.5. Retrying in ${k}s`, 'warn'); await c.wait(1000); }
      }
      status('Writing a new version' + (note ? ' with your note' : '')); st.classList.add('shim');
      const v = { ...POOL[Math.min(vers.length, POOL.length - 1)], rate:'' }; vers.push(v); cur = vers.length - 1; nav();
      await stream(v); busy = false; nav(); st.classList.remove('shim');
      status(cur === 1 ? 'Version 2 fixes the saving: 21%, not 15%' : `Version ${cur + 1} of ${vers.length}`, 'ok');
    });
  }
  re.onclick = () => regenerate();
  cp.onclick = () => { copied(cp, plain(vers[cur])); status('Copied without the highlights', 'ok'); };
  // thumbs: up is a quiet thanks; down asks why, and a reason writes a version that answers it
  up.onclick = () => { const v = vers[cur]; v.rate = v.rate === 'up' ? '' : 'up'; why.classList.add('shut'); nav(); status(v.rate ? 'Thanks, noted for this project' : ''); };
  dn.onclick = () => { const v = vers[cur]; v.rate = v.rate === 'down' ? '' : 'down'; why.classList.toggle('shut', v.rate !== 'down'); why.inert = v.rate !== 'down'; nav(); status(''); chips.forEach(x => x.classList.remove('on')); };
  chips.forEach(ch => ch.onclick = () => { chips.forEach(x => x.classList.toggle('on', x === ch)); c.later(400, () => { why.classList.add('shut'); why.inert = true; failOnce = vers.length === 1; regenerate(ch.textContent); }); });
  why.inert = true;
  // read aloud: about 0.28s a word, each word lit as it's read, with a progress bar and a clock
  let reading = 0, pos = 0;
  const clock = s => Math.floor(s / 60) + ':' + String(Math.floor(s) % 60).padStart(2, '0');
  const icon = k => { $('.ic', rd).innerHTML = I(k, 'xs'); };
  // stop drops the place (a new version, another page); pause keeps it, so the bar, the clock and the lit word stay
  function stop(){ if (reading) clearInterval(reading); reading = 0; pos = 0; rd.classList.remove('on', 'paused'); rd.setAttribute('aria-pressed', 'false'); rd.setAttribute('aria-label', 'Read aloud'); rd.dataset.tip = 'Read aloud'; icon('vol'); bar.style.width = '0%'; $$('.w.rd', t).forEach(w => w.classList.remove('rd')); }
  function pause(){ clearInterval(reading); reading = 0; rd.classList.add('paused'); rd.setAttribute('aria-pressed', 'false'); rd.setAttribute('aria-label', 'Resume reading'); rd.dataset.tip = 'Resume'; icon('play'); }
  rd.onclick = () => {
    if (reading) return pause();
    const ws = $$('.w', t); if (!ws.length || busy) return;
    const total = ws.length * .28;
    rd.classList.add('on'); rd.classList.remove('paused'); rd.setAttribute('aria-pressed', 'true'); rd.setAttribute('aria-label', 'Pause reading'); rd.dataset.tip = 'Pause'; icon('pause');
    reading = c.every(100, () => {
      pos += 100 / 280; const i = Math.min(ws.length - 1, Math.floor(pos));
      ws.forEach((w, k) => w.classList.toggle('rd', k === i));
      bar.style.width = Math.min(100, pos / ws.length * 100) + '%'; tm.textContent = clock(pos * .28) + ' / ' + clock(total);
      if (pos >= ws.length) stop();
    });
  };
  // Left and Right move between the toolbar's buttons, skipping any that are switched off
  c.on($('.aab-bar', r), 'keydown', e => {
    const k = { ArrowRight:1, ArrowLeft:-1 }[e.key]; if (!k) return;
    const bs = $$('.aab-bar button', r).filter(b => !b.disabled && !b.closest('[inert]')), i = bs.indexOf(document.activeElement);
    if (i < 0) return; e.preventDefault(); bs[(i + k + bs.length) % bs.length].focus({ preventScroll:true });
  });
  nav();
  c.run(async () => { busy = true; nav(); await c.wait(300); await stream(vers[0]); busy = false; nav(); });
  // the preview's story: listen, hear a wrong number, rate it down, say why, ride out a dropped connection, copy the fix
  c.story(async s => {
    await s.until(() => !busy); await s.beat(2);
    await s.tap(rd); s.away(); await s.beat(7);
    await s.tap(rd); await s.beat(1);
    await s.tap(dn); s.away(); await s.beat(3);
    await s.tap(chips[0]); s.away();
    await s.until(() => vers.length > 1 && !busy); await s.beat(3);
    await s.tap(cp); s.away(); await s.beat(1);
  });
});
06

Questions

What people ask about it.

Does read aloud speak the answer?
No. It runs a clock that lights each word in turn and shows the progress. Drive reading from your own text to speech, and set secondsPerWord to match its pace.
How do I show what a new version changed?
Pass the changed phrase as the version's changed. It must appear in the text exactly as written, and the words it covers are highlighted.
Where do the ratings go?
onRate is called with the version's index and 'up', 'down' or null when a thumb is pressed again. Pass rating on each version to control them, or leave it out and the bar keeps one rating per version itself.
Can I hide regenerate?
Yes. Leave out onRegenerate and the button isn't shown. The pager still appears whenever there are two or more versions.
07

More in Conversation

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

Live answerAnswers that stream in with numbered sources and next steps.
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.
All 15 in Conversation →