Suggestions

A recipe, not a package namespace. Suggested prompts above the Composer are Buttons in a labelled list, and the "2 of 3" switcher between versions of a reply is two Buttons and a count in the reply's Toolbar. Which prompts to suggest and how replies branch are up to your product. There is no Suggestions.* export.

On this page

Pick a suggestion to put it in the field, then send it. Regenerate a reply to add a version, and step between versions with the arrows.

How do I name an icon-only button?

Put the name in aria-label, or in a visually hidden label inside it. The icon itself stays hidden from screen readers.

Suggested prompts

Show starter prompts in an empty conversation and follow-ups after a reply, in the same list; the example shows follow-ups. A suggestion puts its text in the field and moves focus there, so the person can change it before sending, and Enter sends it. Hide the list while a reply runs and while the field has text other than spaces, so a suggestion never replaces a draft. Cap each item at the row's width, so a suggestion that doesn't fit is cut off and shows its full text as a tooltip, as any Button whose label is cut off does.

import { Button, Composer, Field, Stack } from '@tounsoo/todae';

const pick = (text: string) => {
  setValue(text);
  field.current?.focus();
};

{!busy && value.trim() === '' && suggestions.length > 0 && (
  <Stack.Root as="ul" direction="row" gap="condensed" wrap aria-label="Suggestions">
    {suggestions.map((text) => (
      <li key={text} style={{ maxInlineSize: '100%' }}>
        <Button.Root variant="secondary" onClick={() => pick(text)}>
          {text}
        </Button.Root>
      </li>
    ))}
  </Stack.Root>
)}
<Composer.Root busy={busy} onSubmit={send}>
  <Field.Root aria-label="Message">
    <Field.Textarea ref={field} value={value} onChange={(event) => setValue(event.target.value)} autoGrow />
    <Field.Row>
      <Composer.Submit disabled={value.trim() === ''} onStop={stop} />
    </Field.Row>
  </Field.Root>
</Composer.Root>

To send a suggestion at once instead, pass its text to the function that sends a message, not through the field's state, which the submit handler would still read as empty. The list goes away as the reply starts, so move focus to the field first.

Versions of a reply

Keep each version of a reply as a sibling under the message it answers, and remember which one shows. The turns after a reply belong to that version, so switching versions switches them too. Regenerating adds a version and shows it. Editing a message the person sent works the same way, with the switcher on their message.

The switcher goes at the start of the reply's toolbar, with a separator after it. At either end its button takes aria-disabled, not disabled, as a Pagination step does: it is drawn disabled and keeps focus, where disabled would drop focus to the page. Button.Root ignores its clicks. Both buttons are described by the count, so moving to one reads the position. Announce the new count after a switch, since the change happens away from the button.

import { Button, Text, Toolbar, useAnnouncer } from '@tounsoo/todae';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useId } from 'react';

export function VersionSwitcher({
  index,
  count,
  locked,
  onShow,
}: {
  index: number;
  count: number;
  // True while a reply runs, so the turn it belongs to stays in view.
  locked: boolean;
  onShow: (index: number) => void;
}) {
  const announce = useAnnouncer();
  const position = useId();

  const held = (to: number) => locked || to < 0 || to >= count;
  const show = (to: number) => {
    onShow(to);
    announce(`Reply ${to + 1} of ${count}`);
  };

  return (
    <>
      <Button.Root
        variant="ghost"
        aria-label="Previous reply"
        aria-describedby={position}
        aria-disabled={held(index - 1) || undefined}
        onClick={() => show(index - 1)}
      >
        <Button.Icon>
          <ChevronLeft />
        </Button.Icon>
      </Button.Root>
      <Text.Root as="span" id={position} tone="muted">
        {index + 1} of {count}
      </Text.Root>
      <Button.Root
        variant="ghost"
        aria-label="Next reply"
        aria-describedby={position}
        aria-disabled={held(index + 1) || undefined}
        onClick={() => show(index + 1)}
      >
        <Button.Icon>
          <ChevronRight />
        </Button.Icon>
      </Button.Root>
      <Toolbar.Separator />
    </>
  );
}

Render the switcher only when there are two versions or more. Key the reply's Message.Root by the message it answers, not by the version, so switching keeps the same message and the button that has focus.

Hold Regenerate on every other reply while one runs, with aria-disabled as on the switcher. In Message actions, Regenerate cancels the reply in flight; once replies branch, regenerating an earlier reply would also leave the newer question without a reply, in a version no longer shown. Regenerate on the reply that is running stays live and replaces it.

// pendingReply is the id of the one reply in flight, or null.
const regenerateHeld = (reply: { id: string }) => pendingReply !== null && pendingReply !== reply.id;

<Button.Root
  variant="ghost"
  aria-label="Regenerate"
  aria-disabled={regenerateHeld(reply) || undefined}
  onClick={() => regenerate(reply)}
>
  <Button.Icon>
    <RotateCcw />
  </Button.Icon>
</Button.Root>

Accessible composition

Todae handles: Toolbar.Root gives the reply's buttons one tab stop and arrow keys. A Button.Root with aria-disabled is drawn disabled, keeps focus, and ignores clicks. An icon-only Button.Root shows its aria-label as a tooltip, and a Button whose label is cut off shows its full text. Composer.Root sends on Enter and leaves an IME composition alone. With the messages in a Conversation, it announces when a reply starts and ends.

You supply: A name for the list of suggestions and for each icon-only button. Focus moved to the field when a suggestion fills it. aria-disabled at the switcher's ends and on Regenerate for other replies while one runs. The count as the description of the switcher's buttons, and the announcement of the new version. All the names and the count in your product's language.

Watch for: A suggestion that replaces a draft the person wrote. Suggestions that move focus or are announced as they appear: they come with every reply, so let the person reach them with Tab. Removing the list while one of its buttons has focus, as sending a suggestion at once does. A switcher that changes only the reply but leaves the later turns of the other version. Switching away from a version that is still streaming, which hides the turn the reply belongs to: hold the switcher, and Regenerate on other replies, while a reply runs, as the example does. Stopping the reply to a new question, which leaves it with no reply and nothing to regenerate: put it back in the field to send again, or, if the person has started another draft, keep the reply as stopped so Regenerate can ask again.

When to use

When: Prompts a person can start from, follow-ups the assistant offers after a reply, and moving between regenerated or edited versions of a turn.

When not: Completing what the person is typing → mentions and slash commands. Actions on one message, such as copy or rate → Message actions.