# Message (Notchset): prompt.md (v2.0.0)

- id: `message` · version 2.0.0 · component · free
- category: AI interfaces
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/message.json`
- npm dependencies: lucide-react@^1
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json, @notchset/button, @notchset/icon-button, @notchset/menu, @notchset/spinner
- docs: https://notchset.dev/components/message
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A reply that streams with Stop at any length, keeps what it wrote when stopped (Continue, Try again), says Still working after 8 s and names what answered under it. Edits and retries are versions in place, none deleted. Screen readers hear start, working, done, stopped and failed..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: `lucide-react@^1`.
- Files: `components/ui/notchset/message.tsx`; shared code: `lib/notchset/ui-message.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `button`, `icon-button`, `menu`, `spinner`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `Message`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-bad-ink`, `--ns-control-sm`, `--ns-font-display`, `--ns-font-mono`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-line`, `--ns-line-soft`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-selected`, `--ns-signal`, `--ns-text-body`, `--ns-text-meta`, `--ns-text-small`, `--ns-text-strong`, `--ns-time-color`, `--ns-weight-strong`, `--ns-well`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { Message, type ReplyStatus } from "@/components/ui/notchset/message";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/message.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- button (`button`), installed with it: reuse it, don't rebuild it
- icon-button (`icon-button`), installed with it: reuse it, don't rebuild it
- menu (`menu`), installed with it: reuse it, don't rebuild it
- spinner (`spinner`), installed with it: reuse it, don't rebuild it
- Ui message (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import * as React from "react";
import { Message, type ReplyStatus } from "@/components/ui/notchset/message";
import type { UIMessage } from "@/lib/notchset/ui-message";

const ASK: UIMessage = { id: "u1", role: "user", parts: [{ type: "text", text: "Give me three subject lines for the launch email." }] };
const ANSWERS = [
  "Three options, from plain to playful:\n\n1. **Setup now takes 9 minutes**\n2. Bring your team in first, sort billing later\n3. The 12-step checklist is gone\n\nThe first one tested best with admins in the beta.",
  "Here are three more, shorter:\n\n1. **Setup, in 9 minutes**\n2. Invite first, bill later\n3. Goodbye, checklist",
];

export default function MessageDemo() {
  /* each answer is a version of the reply, kept; Try again adds one */
  const [versions, setVersions] = React.useState<{ words: number; status: ReplyStatus; ms?: number }[]>([{ words: 999, status: "done" }]);
  const [at, setAt] = React.useState(0);
  const [started, setStarted] = React.useState(() => Date.now());
  const v = versions[at]!;
  const all = ANSWERS[at % ANSWERS.length]!.match(/\s*\S+/g) ?? [];

  /* the sample streams a word every 60 ms; Stop (or Esc) ends it where it is */
  React.useEffect(() => {
    if (v.status !== "submitted" && v.status !== "streaming") return;
    const t = setTimeout(() => {
      setVersions((vs) => vs.map((x, i) => (i !== at ? x : x.words + 1 >= all.length ? { words: all.length, status: "done" } : { words: x.words + 1, status: "streaming" })));
    }, v.status === "submitted" ? 700 : 60);
    return () => clearTimeout(t);
  }, [v, at, all.length]);
  const stop = React.useCallback(() => {
    setVersions((vs) => vs.map((x, i) => (i === at && (x.status === "streaming" || x.status === "submitted") ? { ...x, status: "stopped", ms: Date.now() - started } : x)));
  }, [at, started]);
  React.useEffect(() => {
    const key = (e: KeyboardEvent) => e.key === "Escape" && !e.isComposing && stop();
    addEventListener("keydown", key);
    return () => removeEventListener("keydown", key);
  }, [stop]);
  const retry = () => {
    setVersions((vs) => [...vs, { words: 0, status: "submitted" }]);
    setAt(versions.length);
    setStarted(Date.now());
  };

  const reply: UIMessage = { id: `a${at}`, role: "assistant", parts: [{ type: "text", text: all.slice(0, v.words).join("") }] };
  return (
    <div className="flex max-w-[640px] flex-col gap-5">
      <Message message={ASK} />
      <Message
        message={reply}
        status={v.status}
        startedAt={started}
        stoppedAfterMs={v.ms}
        model={{ purpose: "Balanced", name: "Sample reply", thoughtForMs: at ? undefined : 2000 }}
        versions={{ index: at, count: versions.length, list: versions.map((_, i) => ({ id: String(i), label: i === 0 ? "Three options, from plain…" : "Here are three more…", time: i === 0 ? "4:02 pm" : "4:05 pm" })), onPick: setAt }}
        onStop={stop}
        onContinue={() => setVersions((vs) => vs.map((x, i) => (i === at ? { ...x, status: "streaming" } : x)))}
        onRetry={retry}
      />
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `message` | `UIMessage` |  | Required. |
| `status` | `ReplyStatus` | `"done"` | an assistant reply's life; a message of yours is always done |
| `model` | `ModelLine` |  | what answered: every assistant reply has one |
| `startedAt` | `number` |  | when the reply began (ms), for "Still working" and "Stopped after" |
| `stoppedAfterMs` | `number` |  | how long it ran before it was stopped |
| `error` | `string` |  | why it failed, in words; what you asked is kept |
| `versions` | `{ index: number; count: number; list?: Version[] \| undefined; onPick: (index: number) => void }` |  |  |
| `memory` | `{ count: number; onOpen?: (() => void) \| undefined }` |  | "Used 2 things it remembers", opening them |
| `receipt` | `React.ReactNode` |  | a receipt (blocks/receipt) in place of the model line and memory words: what stands behind the answer |
| `onStop` | `() => void` |  |  |
| `onContinue` | `() => void` |  |  |
| `onRetry` | `() => void` |  |  |
| `onEdit` | `() => void` |  |  |
| `onCopied` | `() => void` |  | called after the text was copied (the piece copies it) |
| `actions` | `React.ReactNode` |  | a finished reply's own verbs after Copy and Try again (thumbs, Branch to new chat) |
| `cite` | `(n: number) => React.ReactNode` |  | draws a citation marker "[n]" in the text (Sources' Citation); without it markers are left as written |
| `name` | `string` | `"Assistant"` | the assistant's name, read by screen readers ("Assistant") |

Full docs: https://notchset.dev/components/message

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/message.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/message`. Every components.json style gets the same files.

## Continue the workflow

- [Prompt input](https://notchset.dev/components/prompt-input)
- [Model selector](https://notchset.dev/components/model-selector)
- [Run steps](https://notchset.dev/blocks/tool)

## Source

### components/ui/notchset/message.tsx

```tsx
// Notchset message 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { Check, ChevronDown, ChevronLeft, ChevronRight, Copy, Pencil, RotateCcw, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/notchset/button";
import { IconButton } from "@/components/ui/notchset/icon-button";
import { Menu, MenuGroup, MenuItem } from "@/components/ui/notchset/menu";
import { Spinner } from "@/components/ui/notchset/spinner";
import { modelLineWords, seconds, textOf, type ModelLine, type ReasoningPart, type UIMessage } from "@/lib/notchset/ui-message";

/*
 * Message: one turn of a conversation, yours or the assistant's, with its whole life designed.
 *
 * Before the first word the reply holds a calm placeholder (the composer's orb says Thinking; nothing else moves).
 * Words arrive as they stream; Stop works from the first word to the last, and a stopped reply keeps what it had
 * and says so ("Stopped after 14 s") with Continue and Try again. After 8 s it says it is still working. Every reply
 * names what answered under it ("Balanced · thought for 9 s"), including a fallback. An edit or a retry never deletes:
 * versions sit in place ("2 of 3") with All versions beside them. When it finishes, focus moves to the answer if it
 * was on Stop. Screen readers hear it start, still working, done, stopped and failed, once each.
 *
 * Messages are AI SDK UIMessage-shaped (components/ui-message.ts). The host owns the conversation, the timer that
 * streams and the keys (Esc to stop is the page's); tool steps, approvals and sources go in as children.
 */
type ReplyStatus = "submitted" | "streaming" | "done" | "stopped" | "error";
type Version = { id: string; label: string; time: string };
type MessageProps = {
  message: UIMessage;
  /** an assistant reply's life; a message of yours is always done */
  status?: ReplyStatus | undefined;
  /** what answered: every assistant reply has one */
  model?: ModelLine | undefined;
  /** when the reply began (ms), for "Still working" and "Stopped after" */
  startedAt?: number | undefined;
  /** how long it ran before it was stopped */
  stoppedAfterMs?: number | undefined;
  /** why it failed, in words; what you asked is kept */
  error?: string | undefined;
  versions?: { index: number; count: number; list?: Version[] | undefined; onPick: (index: number) => void } | undefined;
  /** "Used 2 things it remembers", opening them */
  memory?: { count: number; onOpen?: (() => void) | undefined } | undefined;
  /** a receipt (blocks/receipt) in place of the model line and memory words: what stands behind the answer */
  receipt?: React.ReactNode;
  onStop?: (() => void) | undefined;
  onContinue?: (() => void) | undefined;
  onRetry?: (() => void) | undefined;
  onEdit?: (() => void) | undefined;
  /** called after the text was copied (the piece copies it) */
  onCopied?: (() => void) | undefined;
  /** a finished reply's own verbs after Copy and Try again (thumbs, Branch to new chat) */
  actions?: React.ReactNode;
  /** draws a citation marker "[n]" in the text (Sources' Citation); without it markers are left as written */
  cite?: ((n: number) => React.ReactNode) | undefined;
  /** the assistant's name, read by screen readers ("Assistant") */
  name?: string | undefined;
  children?: React.ReactNode;
  className?: string | undefined;
};

/* ---------- the reply's text: paragraphs, headings, lists, bold, code and code blocks ---------- */

function inline(text: string, cite: MessageProps["cite"], key: string): React.ReactNode[] {
  const out: React.ReactNode[] = [];
  const re = /(\*\*[^*]+\*\*|`[^`]+`|\[\d+\])/g;
  let last = 0, m: RegExpExecArray | null, i = 0;
  while ((m = re.exec(text))) {
    if (m.index > last) out.push(text.slice(last, m.index));
    const t = m[0];
    if (t.startsWith("**")) out.push(<strong key={`${key}b${i++}`} className="font-[number:var(--ns-weight-strong)]">{t.slice(2, -2)}</strong>);
    else if (t.startsWith("`")) out.push(<code key={`${key}c${i++}`} className="rounded-[4px] bg-[var(--ns-well)] px-1 py-px font-[family-name:var(--ns-font-mono)] text-[0.92em]">{t.slice(1, -1)}</code>);
    else out.push(cite ? <React.Fragment key={`${key}n${i++}`}>{cite(Number(t.slice(1, -1)))}</React.Fragment> : t);
    last = m.index + t.length;
  }
  if (last < text.length) out.push(text.slice(last));
  return out;
}

function CodeBlock({ code, lang }: { code: string; lang: string }) {
  const [copied, setCopied] = React.useState(false);
  return (
    <div data-slot="message-code" className="relative min-w-0 overflow-hidden rounded-[var(--ns-radius-lg)] bg-[var(--ns-well)] shadow-[inset_0_0_0_1px_var(--ns-line-soft)]">
      <div className="flex h-8 items-center justify-between pr-1 pl-3 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]">
        <span className="font-[family-name:var(--ns-font-mono)]">{lang || "code"}</span>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => {
            void navigator.clipboard?.writeText(code).then(() => {
              setCopied(true);
              setTimeout(() => setCopied(false), 1600);
            }, () => setCopied(false));
          }}
        >
          {copied ? <Check aria-hidden /> : <Copy aria-hidden />}
          {copied ? "Copied" : "Copy"}
        </Button>
      </div>
      {/* a long line scrolls inside the block, and the keyboard can scroll it too */}
      <pre tabIndex={0} aria-label={`${lang || "Code"} block`} className="m-0 overflow-x-auto px-3 pb-3 outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ns-signal)] font-[family-name:var(--ns-font-mono)] text-[length:var(--ns-text-small)] leading-[1.6]">
        <code>{code}</code>
      </pre>
    </div>
  );
}

function Response({ text, cite }: { text: string; cite: MessageProps["cite"] }) {
  const blocks: React.ReactNode[] = [];
  const lines = text.split("\n");
  let i = 0, k = 0;
  while (i < lines.length) {
    const line = lines[i]!;
    if (line.startsWith("```")) {
      const lang = line.slice(3).trim();
      const body: string[] = [];
      i++;
      while (i < lines.length && !lines[i]!.startsWith("```")) body.push(lines[i++]!);
      i++;
      blocks.push(<CodeBlock key={k++} lang={lang} code={body.join("\n")} />);
    } else if (/^#{1,3} /.test(line)) {
      blocks.push(<h3 key={k++} className="m-0 mt-1 font-[family-name:var(--ns-font-display)] text-[length:var(--ns-text-strong)] font-[number:var(--ns-weight-strong)]">{inline(line.replace(/^#+ /, ""), cite, `h${k}`)}</h3>);
      i++;
    } else if (/^(- |\d+\. )/.test(line)) {
      const ordered = /^\d+\. /.test(line);
      const items: string[] = [];
      while (i < lines.length && /^(- |\d+\. )/.test(lines[i]!)) items.push(lines[i++]!.replace(/^(- |\d+\. )/, ""));
      const L = ordered ? "ol" : "ul";
      blocks.push(<L key={k++} className={cn("m-0 flex flex-col gap-1 pl-5", ordered ? "list-decimal" : "list-disc")}>{items.map((t, j) => <li key={j}>{inline(t, cite, `l${k}-${j}`)}</li>)}</L>);
    } else if (line.trim() === "") i++;
    else {
      const para: string[] = [];
      while (i < lines.length && lines[i]!.trim() !== "" && !/^(```|#{1,3} |- |\d+\. )/.test(lines[i]!)) para.push(lines[i++]!);
      blocks.push(<p key={k++} className="m-0">{inline(para.join(" "), cite, `p${k}`)}</p>);
    }
  }
  return <div data-slot="message-response" className="flex min-w-0 flex-col gap-3 [overflow-wrap:anywhere]">{blocks}</div>;
}

function Reasoning({ part }: { part: ReasoningPart }) {
  const [open, setOpen] = React.useState(false);
  const id = React.useId();
  const thinking = part.state === "streaming";
  return (
    <div data-slot="message-reasoning" className="flex flex-col items-start gap-1.5">
      <button
        type="button"
        aria-expanded={open}
        aria-controls={id}
        onClick={() => setOpen(!open)}
        className="inline-flex cursor-pointer items-center gap-1.5 rounded-[var(--ns-radius-sm)] px-1 py-0.5 -ml-1 text-[length:var(--ns-text-small)] text-[var(--ns-ink-3)] outline-hidden hover:text-[var(--ns-ink-2)] focus-visible:outline-2 focus-visible:outline-[var(--ns-signal)]"
      >
        {thinking && <Spinner size={12} />}
        {thinking ? "Thinking…" : part.durationMs ? `Thought for ${seconds(part.durationMs)}` : "Thought it through"}
        <ChevronDown aria-hidden className={cn("size-3.5 [transition:rotate_var(--ns-time-color)]", open && "rotate-180")} />
      </button>
      {open && (
        <p id={id} className="m-0 border-l-2 border-[var(--ns-line)] pl-3 text-[length:var(--ns-text-small)] leading-[1.6] whitespace-pre-wrap text-[var(--ns-ink-2)]">
          {part.text}
        </p>
      )}
    </div>
  );
}

/** What a screen reader hears for each step of a reply's life, said once each. */
function announcement(status: ReplyStatus, slow: boolean, stoppedAfterMs: number | undefined, error: string | undefined, name: string) {
  if (status === "submitted") return `${name} is answering`;
  if (status === "streaming") return slow ? `${name} is still working` : `${name} is answering`;
  if (status === "done") return `${name} finished answering`;
  if (status === "stopped") return `Stopped${stoppedAfterMs ? ` after ${seconds(stoppedAfterMs)}` : ""}`;
  return `${name} couldn't finish${error ? `: ${error}` : ""}`;
}

function Message({
  message,
  status = "done",
  model,
  startedAt,
  stoppedAfterMs,
  error,
  versions,
  memory,
  receipt,
  onStop,
  onContinue,
  onRetry,
  onEdit,
  onCopied,
  actions,
  cite,
  name = "Assistant",
  children,
  className,
}: MessageProps) {
  const mine = message.role === "user";
  const text = textOf(message);
  const reasoning = message.parts.find((p): p is ReasoningPart => p.type === "reasoning");
  const running = !mine && (status === "submitted" || status === "streaming");
  const root = React.useRef<HTMLElement>(null);
  const [copied, setCopied] = React.useState(false);

  /* "Still working" after 8 s: a timer for this reply only, gone when it ends */
  const [slowFor, setSlowFor] = React.useState<number | null>(null);
  React.useEffect(() => {
    if (!running || startedAt === undefined) return;
    const t = setTimeout(() => setSlowFor(startedAt), Math.max(0, startedAt + 8000 - Date.now()));
    return () => clearTimeout(t);
  }, [running, startedAt]);
  const slow = running && startedAt !== undefined && slowFor === startedAt;

  /* the hand-off: when it finishes, focus that was on Stop moves to the answer; focus you moved elsewhere stays */
  const was = React.useRef(status);
  React.useEffect(() => {
    const prev = was.current;
    was.current = status;
    /* only when the reply ends: done, stopped or failed */
    if (mine || prev === status || (prev !== "streaming" && prev !== "submitted") || status === "streaming" || status === "submitted") return;
    const a = document.activeElement;
    if (root.current && (a === document.body || a === null || (a instanceof Node && root.current.contains(a) && !root.current.isSameNode(a)))) root.current.focus({ preventScroll: true });
  }, [status, mine]);

  /* a reply that arrived finished (from history) says nothing; one that lives here says each step once */
  const [first] = React.useState(status);
  const settled = first === status && status !== "submitted" && status !== "streaming";
  const said = mine || settled ? "" : announcement(status, slow, stoppedAfterMs, error, name);

  const copy = () =>
    void navigator.clipboard?.writeText(text).then(
      () => {
        setCopied(true);
        onCopied?.();
        setTimeout(() => setCopied(false), 1600);
      },
      () => setCopied(false),
    );

  const versionNav = versions && versions.count > 1 && (
    <span data-slot="message-versions" className="inline-flex items-center gap-0.5 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)] tabular-nums">
      <IconButton size="sm" variant="ghost" label="Previous version" disabled={versions.index <= 0} onClick={() => versions.onPick(versions.index - 1)}>
        <ChevronLeft aria-hidden />
      </IconButton>
      <span aria-live="polite">
        {versions.index + 1} of {versions.count}
      </span>
      <IconButton size="sm" variant="ghost" label="Next version" disabled={versions.index >= versions.count - 1} onClick={() => versions.onPick(versions.index + 1)}>
        <ChevronRight aria-hidden />
      </IconButton>
      {versions.list && (
        <Menu
          align="end"
          trigger={
            <Button size="sm" variant="ghost">
              All versions
            </Button>
          }
        >
          <MenuGroup label={`${versions.count} versions, none deleted`}>
            {versions.list.map((v, i) => (
              <MenuItem key={v.id} onClick={() => versions.onPick(i)} icon={i === versions.index ? <Check aria-hidden /> : <span className="size-3.5" />}>
                <span className="flex min-w-0 items-baseline gap-2">
                  <span className="truncate">{v.label}</span>
                  <span className="shrink-0 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]">{v.time}</span>
                </span>
              </MenuItem>
            ))}
          </MenuGroup>
        </Menu>
      )}
    </span>
  );

  if (mine)
    return (
      <article ref={root} tabIndex={-1} data-slot="message" data-role="user" aria-label="You" className={cn("group/msg flex flex-col items-end gap-1 outline-hidden", className)}>
        <div className="max-w-[min(85%,560px)] rounded-[var(--ns-radius-lg)] bg-[var(--ns-selected)] px-3.5 py-2.5 text-[length:var(--ns-text-body)] leading-[1.6] whitespace-pre-wrap text-[var(--ns-ink)] [overflow-wrap:anywhere]">
          {text}
          {children}
        </div>
        <div className="flex min-h-[var(--ns-control-sm)] items-center gap-0.5 opacity-0 [transition:opacity_var(--ns-time-color)] group-hover/msg:opacity-100 group-focus-within/msg:opacity-100 pointer-coarse:opacity-100 has-[[data-slot=message-versions]]:opacity-100">
          {versionNav}
          {onEdit && (
            <IconButton size="sm" variant="ghost" label="Edit" onClick={onEdit}>
              <Pencil aria-hidden />
            </IconButton>
          )}
          <IconButton size="sm" variant="ghost" label={copied ? "Copied" : "Copy"} onClick={copy}>
            {copied ? <Check aria-hidden /> : <Copy aria-hidden />}
          </IconButton>
        </div>
      </article>
    );

  return (
    <article
      ref={root}
      tabIndex={-1}
      data-slot="message"
      data-role="assistant"
      data-status={status}
      aria-label={`${name}'s answer`}
      aria-busy={running || undefined}
      className={cn("group/msg flex min-w-0 flex-col gap-3 text-[length:var(--ns-text-body)] leading-[1.65] text-[var(--ns-ink)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ns-signal)] rounded-[var(--ns-radius-sm)]", className)}
    >
      <span role="status" className="sr-only">
        {said}
      </span>
      {reasoning && <Reasoning part={reasoning} />}
      {status === "submitted" && !text ? (
        <div data-slot="message-placeholder" aria-hidden className="flex flex-col gap-2 py-1">
          <span className="block h-2.5 w-[72%] rounded-full bg-[var(--ns-well)]" />
          <span className="block h-2.5 w-[48%] rounded-full bg-[var(--ns-well)]" />
        </div>
      ) : (
        text && <Response text={text} cite={cite} />
      )}
      {children}
      {status === "stopped" && (
        <div data-slot="message-stopped" className="flex flex-wrap items-center gap-2">
          <span className="text-[length:var(--ns-text-small)] text-[var(--ns-ink-2)]">
            Stopped{stoppedAfterMs ? ` after ${seconds(stoppedAfterMs)}` : ""}
            {text ? ". What it wrote is kept." : "."}
          </span>
          {onContinue && text && (
            <Button size="sm" onClick={onContinue}>
              Continue
            </Button>
          )}
          {onRetry && (
            <Button size="sm" variant="ghost" onClick={onRetry}>
              <RotateCcw aria-hidden />
              Try again
            </Button>
          )}
        </div>
      )}
      {status === "error" && (
        <div data-slot="message-error" className="flex flex-wrap items-center gap-2">
          <span className="text-[length:var(--ns-text-small)] text-[var(--ns-bad-ink)]">Couldn&apos;t finish{error ? `: ${error}` : ""}. Your question is kept.</span>
          {onRetry && (
            <Button size="sm" onClick={onRetry}>
              <RotateCcw aria-hidden />
              Try again
            </Button>
          )}
        </div>
      )}
      {(running || model || text || (versions && versions.count > 1)) && <footer className="flex min-h-[var(--ns-control-sm)] flex-wrap items-center gap-x-1 gap-y-1 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]">
        {running ? (
          <>
            <Button size="sm" onClick={onStop} aria-keyshortcuts="Escape">
              <Square aria-hidden className="size-3 fill-current" />
              Stop
            </Button>
            {slow && <span className="pl-1.5">Still working</span>}
          </>
        ) : (
          <>
            {versionNav}
            {status !== "error" && text && (
              <IconButton size="sm" variant="ghost" label={copied ? "Copied" : "Copy"} onClick={copy}>
                {copied ? <Check aria-hidden /> : <Copy aria-hidden />}
              </IconButton>
            )}
            {onRetry && status === "done" && (
              <IconButton size="sm" variant="ghost" label="Try again" onClick={onRetry}>
                <RotateCcw aria-hidden />
              </IconButton>
            )}
            {status === "done" && actions}
            {receipt ? (
              <span className="pl-1.5">{receipt}</span>
            ) : (
              <>
            {model && (
              <span data-slot="message-model" className="pl-1.5">
                {modelLineWords(model)}
                <span className="max-[600px]:hidden"> · {model.name}</span>
              </span>
            )}
            {memory && memory.count > 0 && (
              <>
                <span aria-hidden className="px-0.5">
                  ·
                </span>
                {memory.onOpen ? (
                  <button type="button" onClick={memory.onOpen} className="inline-flex min-h-6 cursor-pointer items-center rounded-[4px] text-[var(--ns-ink-3)] underline decoration-[var(--ns-line)] underline-offset-2 outline-hidden hover:text-[var(--ns-ink)] focus-visible:outline-2 focus-visible:outline-[var(--ns-signal)]">
                    Used {memory.count === 1 ? "1 thing" : `${memory.count} things`} it remembers
                  </button>
                ) : (
                  <span>Used {memory.count === 1 ? "1 thing" : `${memory.count} things`} it remembers</span>
                )}
              </>
            )}
              </>
            )}
          </>
        )}
      </footer>}
    </article>
  );
}

export { Message, type MessageProps, type ReplyStatus };
```

### lib/notchset/ui-message.ts

```tsx
// Notchset message 2.0.0 · https://notchset.dev/changelog
/*
 * The assistant's shared words and shapes (installs to lib/notchset/ui-message.ts with any piece that reads it).
 *
 * Messages are shaped like the AI SDK's UIMessage (an id, a role and parts: text, reasoning, tool-*, dynamic-tool,
 * source-url, source-document, file, step-start, data-*), defined here so a piece needs no runtime dependency: a buyer on the AI SDK passes its messages
 * straight in. The figures every piece shows (what answered, how much of a window is used, when it resets) are
 * worked out here, once, so the usage meter, the composer's line and the plan card can never read two ways
 * ("One name, one calculation").
 */

/* ---------- messages ---------- */

export type TextPart = { type: "text"; text: string; state?: "streaming" | "done" | undefined };
export type ReasoningPart = { type: "reasoning"; text: string; state?: "streaming" | "done" | undefined; durationMs?: number | undefined };
/** A tool call, as the AI SDK names its states, approvals included. */
export type ToolState = "input-streaming" | "input-available" | "approval-requested" | "approval-responded" | "output-available" | "output-error" | "output-denied";
export type ToolPart = {
  type: `tool-${string}`;
  toolCallId: string;
  state: ToolState;
  input?: unknown;
  output?: unknown;
  errorText?: string | undefined;
  approval?: { id: string; approved?: boolean | undefined; reason?: string | undefined } | undefined;
};
export type SourceUrlPart = { type: "source-url"; sourceId: string; url: string; title?: string | undefined };
export type FilePart = { type: "file"; mediaType: string; url: string; filename?: string | undefined };
/** A tool the host registered at run time (the AI SDK's dynamic tool): named by `toolName`, not by its type. */
export type DynamicToolPart = {
  type: "dynamic-tool";
  toolName: string;
  toolCallId: string;
  state: ToolState;
  input?: unknown;
  output?: unknown;
  errorText?: string | undefined;
  title?: string | undefined;
  approval?: { id: string; approved?: boolean | undefined; reason?: string | undefined } | undefined;
};
export type SourceDocumentPart = { type: "source-document"; sourceId: string; mediaType: string; title: string; filename?: string | undefined };
/** Where one model step began in a multi-step reply: a boundary the SDK writes, nothing to draw. */
export type StepStartPart = { type: "step-start" };
/** Provider-specific content and files the model reasoned with: carried through, not drawn. */
export type CustomPart = { type: "custom"; kind: `${string}.${string}` };
export type ReasoningFilePart = { type: "reasoning-file"; mediaType: string; url: string };
export type DataPart = { type: `data-${string}`; id?: string | undefined; data: unknown };
export type UIMessagePart = TextPart | ReasoningPart | ToolPart | DynamicToolPart | SourceUrlPart | SourceDocumentPart | FilePart | ReasoningFilePart | CustomPart | StepStartPart | DataPart;

export type UIMessage<M = unknown> = { id: string; role: "system" | "user" | "assistant"; parts: UIMessagePart[]; metadata?: M | undefined };

export const isToolPart = (p: UIMessagePart): p is ToolPart => p.type.startsWith("tool-");
/** A tool call as one shape: a dynamic tool becomes `tool-<its name>`; anything else that is not a tool is null. */
export const toolOf = (p: UIMessagePart): ToolPart | null => (isToolPart(p) ? p : p.type === "dynamic-tool" ? { ...p, type: `tool-${p.toolName}` } : null);
export const textOf = (m: Pick<UIMessage, "parts">) =>
  m.parts
    .filter((p): p is TextPart => p.type === "text")
    .map((p) => p.text)
    .join("");

/* ---------- what answered (DECISIONS D2: every reply names it) ---------- */

/** The purpose-named choices a picker offers (never "Auto"); each maps to a model and an effort in the adapter. */
export type ModelPurpose = "fast" | "balanced" | "deep" | "long";
export type ModelLine = {
  /** the purpose the person reads: "Balanced" */
  purpose: string;
  /** the model's own name, a second line: "Claude Sonnet 5.5" */
  name: string;
  effort?: "low" | "medium" | "high" | undefined;
  /** how long it thought before answering, when it did */
  thoughtForMs?: number | undefined;
  /** the purpose that was asked for, when another one answered ("Deep thinking was busy") */
  asked?: string | undefined;
  /** why another answered */
  fallbackReason?: string | undefined;
};

/** "14 s", "1 min 5 s": a duration a person reads. */
export function seconds(ms: number) {
  const s = Math.max(1, Math.round(ms / 1000));
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ""}`;
}

/** The model line under every reply: "Balanced · thought for 9 s", or what answered in another's place. */
export function modelLineWords(m: ModelLine) {
  const lead = m.asked && m.asked !== m.purpose ? `Answered by ${m.purpose}; ${m.asked} ${m.fallbackReason ?? "wasn't available"}` : m.purpose;
  return m.thoughtForMs ? `${lead} · thought for ${seconds(m.thoughtForMs)}` : lead;
}

/* ---------- usage (DECISIONS D8: two windows, said in words) ---------- */

/** What is used of each window, as a share from 0 to 1, and when each resets. Null when nothing measures it. */
export type Usage = { window: number; week: number; windowResetsAt: string; weekResetsAt: string };

/** "4:10 pm" in the reader's own clock. */
export function clockTime(iso: string, locale?: string) {
  return new Date(iso).toLocaleTimeString(locale ?? "en-US", { hour: "numeric", minute: "2-digit" }).replace(/\s?([AP])M$/i, (_, a: string) => ` ${a.toLowerCase()}m`);
}

/** "today at 4:10 pm", "Monday", or a date further out. */
export function resetWords(iso: string, now: Date, locale?: string) {
  const at = new Date(iso);
  const days = Math.round((new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime() - new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()) / 864e5);
  if (days <= 0) return clockTime(iso, locale);
  if (days === 1) return `tomorrow ${clockTime(iso, locale)}`;
  if (days < 7) return at.toLocaleDateString(locale ?? "en-US", { weekday: "long" });
  return at.toLocaleDateString(locale ?? "en-US", { month: "short", day: "numeric" });
}

/** The reset in a sentence: "at 4:10 pm", "tomorrow at 4:10 pm", "on Monday". */
export function resetPhrase(iso: string, now: Date, locale?: string) {
  const w = resetWords(iso, now, locale);
  return /^\d/.test(w) ? `at ${w}` : w.startsWith("tomorrow ") ? `tomorrow at ${w.slice(9)}` : `on ${w}`;
}

/** Where a figure stands against its limit: the same edges the Meter uses (75% warns, 100% is full). */
export type UsageLevel = "ok" | "near" | "full";

/**
 * Every usage figure the pieces show, from one calculation: each window's share used (a whole percent, rounded
 * down so "100%" only ever means full), its words, its reset, whether to warn (under 20% of the window left) and
 * whether the window is spent. Whichever window runs out first is the one that limits.
 */
export function usageFigures(u: Usage, now: Date, locale?: string) {
  const pct = (x: number) => Math.max(0, Math.min(100, Math.floor(x * 100)));
  const window = pct(u.window), week = pct(u.week);
  const level = (p: number): UsageLevel => (p >= 100 ? "full" : p >= 75 ? "near" : "ok");
  const windowReset = resetWords(u.windowResetsAt, now, locale), weekReset = resetWords(u.weekResetsAt, now, locale);
  const limiting = week >= 100 || (week > window && week >= 80) ? "week" : "window";
  const left = 100 - (limiting === "week" ? week : window);
  const reset = limiting === "week" ? weekReset : windowReset;
  return {
    window,
    week,
    windowLevel: level(window),
    weekLevel: level(week),
    windowLine: `${window}% used · resets ${windowReset}`,
    weekLine: `${week}% of this week · resets ${weekReset}`,
    limiting,
    left,
    reset,
    /** the reset in a sentence ("at 4:10 pm", "on Monday") */
    resetPhrase: resetPhrase(limiting === "week" ? u.weekResetsAt : u.windowResetsAt, now, locale),
    resetsAt: limiting === "week" ? u.weekResetsAt : u.windowResetsAt,
    /** under 20% left: the composer shows its quiet line */
    low: left < 20 && left > 0,
    atLimit: left <= 0,
    /** the composer's line, only when low or spent */
    composerLine: left <= 0 ? `${limiting === "week" ? "You've reached this week's limit" : "You've reached your limit for now"} · resets ${reset}` : `${left}% left${limiting === "week" ? " this week" : ""} · resets ${reset}`,
  };
}

/** How full a chat is against what the model can read at once, in words with no token talk (DEPTH 93). */
export function contextWords(share: number) {
  const p = Math.max(0, Math.min(100, Math.floor(share * 100)));
  if (p >= 90) return { pct: p, line: "This chat is very long: early messages may be left out. Start a new chat to keep answers sharp." };
  if (p >= 60) return { pct: p, line: "This chat is getting long: answers may miss early details." };
  return { pct: p, line: "Room to keep going in this chat." };
}
```

The foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://notchset.dev/r/notchset-foundation.json.
