Timace

Message

Assistant

Yes. Cut the comparison table and keep the three plans.

Install
npm install react-aria-components
components/ui/message.tsx
"use client";

import type { ReactNode } from "react";
import { Button, type ButtonProps } from "react-aria-components";

const join = (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(" ");

const styles = {
  root: "group/message flex w-full gap-(--tui-message-gap) font-(family-name:--tui-font) text-(length:--tui-message-font-size) text-(--tui-message-fg)",
  avatar: "flex size-(--tui-message-avatar-size) shrink-0 items-center justify-center overflow-hidden rounded-(--tui-message-avatar-radius) bg-(--tui-message-avatar-bg) text-(--tui-message-avatar-fg)",
  header: "flex items-baseline gap-2 leading-none",
  name: "text-(length:--tui-message-font-size) font-(--tui-message-name-weight)",
  meta: "text-(length:--tui-message-meta-size) text-(--tui-message-muted)",
  // What a reply is made of: paragraphs, lists and code, spaced as prose.
  body: "min-w-0 leading-relaxed [&_code]:rounded-[4px] [&_code]:bg-(--tui-message-code-bg) [&_code]:px-[0.3em] [&_code]:py-[0.1em] [&_code]:text-[0.92em] [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:m-0 [&_p+p]:mt-3 [&_strong]:font-semibold [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
  caret: "after:ml-0.5 after:inline-block after:h-[1.05em] after:w-[0.5em] after:translate-y-[0.18em] after:rounded-[1px] after:bg-(--tui-message-caret) after:[animation:tui-blink_1s_steps(2,start)_infinite]",
  bubble: "rounded-(--tui-message-radius) bg-(--tui-message-user-bg) px-(--tui-message-padding-x) py-(--tui-message-padding-y) text-(--tui-message-user-fg)",
  error: "rounded-(--tui-message-radius) border-solid border-(length:--tui-message-error-border-width) border-(--tui-message-error) px-(--tui-message-padding-x) py-(--tui-message-padding-y)",
  actions: "flex items-center gap-0.5",
  quiet: "opacity-0 transition-opacity duration-(--tui-duration) group-focus-within/message:opacity-100 group-hover/message:opacity-100",
  action:
    "flex size-(--tui-message-action-size) cursor-default items-center justify-center rounded-(--tui-message-action-radius) border-none bg-transparent text-(--tui-message-muted) outline-none transition-colors duration-(--tui-duration) data-focus-visible:[outline:2px_solid_var(--tui-ring)] data-hovered:bg-(--tui-message-action-bg-hover) data-hovered:text-(--tui-message-fg) data-pressed:bg-(--tui-message-action-bg-hover) [&_svg]:size-[55%]",
};

/** The mark a reply carries when it is given no avatar of its own. */
function Spark() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-[55%]">
      <path d="M8 1.5c.3 2.9 1.6 4.2 4.5 4.5v1C9.6 7.3 8.3 8.6 8 11.500H7C6.700 8.600 5.400 7.300 2.500 7V6C5.400 5.700 6.700 4.400 7 1.500z" />
      <path d="M12.500 10.500c.150 1.300.700 1.850 2 2v.5c-1.300.150-1.850.700-2 2H12c-.150-1.300-.700-1.850-2-2v-.5c1.300-.150 1.850-.700 2-2z" />
    </svg>
  );
}

export interface MessageProps {
  role?: "user" | "assistant";
  /** Who is speaking. Shown for replies. */
  name?: ReactNode;
  /** A note beside the name: the model, the time. */
  meta?: ReactNode;
  /** An avatar. A reply without one gets a mark. */
  avatar?: ReactNode;
  /** Files sent with the message. Use Attachment. */
  attachments?: ReactNode;
  /** Controls under the message. Use MessageAction. */
  actions?: ReactNode;
  /** Keep the actions on show, not only on hover and focus. */
  showActions?: boolean;
  /** The reply is still arriving: a caret follows the last word. */
  isStreaming?: boolean;
  /** Something went wrong producing it. */
  isError?: boolean;
  children: ReactNode;
  className?: string;
}

/**
 * One turn in a conversation. A person's sits on the right in a bubble. A
 * reply runs the width on the left under who wrote it, as prose, with its
 * actions underneath.
 */
export function Message({ role = "assistant", name, meta, avatar, attachments, actions, showActions, isStreaming, isError, children, className }: MessageProps) {
  const bar = actions && <div className={join(styles.actions, !showActions && !isStreaming && styles.quiet)}>{actions}</div>;

  if (role === "user") {
    return (
      <div data-role="user" className={join(styles.root, "flex-col items-end gap-1.5!", className)}>
        {attachments && <div className="flex max-w-(--tui-message-max-width) flex-wrap justify-end gap-2">{attachments}</div>}
        <div data-slot="message" className={join("max-w-(--tui-message-max-width) leading-relaxed", styles.bubble)}>
          {children}
        </div>
        {bar}
      </div>
    );
  }

  return (
    <div data-role="assistant" aria-busy={isStreaming || undefined} className={join(styles.root, className)}>
      <div className={styles.avatar}>{avatar ?? <Spark />}</div>
      <div className="flex min-w-0 flex-1 flex-col gap-2">
        {(name || meta) && (
          <div className={styles.header}>
            {name && <span className={styles.name}>{name}</span>}
            {meta && <span className={styles.meta}>{meta}</span>}
          </div>
        )}
        {attachments && <div className="flex flex-wrap gap-2">{attachments}</div>}
        <div data-slot="message" role={isError ? "alert" : undefined} className={join(styles.body, isStreaming && styles.caret, isError && styles.error)}>
          {children}
        </div>
        {bar}
      </div>
    </div>
  );
}

/** A small button under a message: copy, retry, rate. It needs an aria-label. */
export function MessageAction({ className, ...props }: Omit<ButtonProps, "className"> & { className?: string }) {
  return <Button {...props} className={join(styles.action, className)} />;
}
styles/timace-ui.css
:root {
  color-scheme: light;
  --tui-bg: #ffffff;
  --tui-fg: #0a0a0a;
  --tui-muted: #f5f5f5;
  --tui-muted-fg: #737373;
  --tui-border: #e5e5e5;
  --tui-primary: #171717;
  --tui-primary-fg: #fafafa;
  --tui-ring: #a3a3a3;
  --tui-success: #16a34a;
  --tui-warning: #d97706;
  --tui-danger: #dc2626;
  --tui-radius: 8px;
  --tui-border-width: 1px;
  --tui-space: 4px;
  --tui-shadow: none;
  --tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --tui-text: 14px;
  --tui-duration: 150ms;

  /* Message */
  --tui-message-gap: calc(var(--tui-space) * 3);
  --tui-message-avatar-size: calc(var(--tui-space) * 7);
  --tui-message-action-size: calc(var(--tui-space) * 7);
  --tui-message-max-width: 85%;
  --tui-message-padding-x: calc(var(--tui-space) * 3.5);
  --tui-message-padding-y: calc(var(--tui-space) * 2.5);
  --tui-message-radius: calc(var(--tui-radius) * 2);
  --tui-message-avatar-radius: calc(var(--tui-radius) * 0.9);
  --tui-message-action-radius: calc(var(--tui-radius) * 0.75);
  --tui-message-font-size: var(--tui-text);
  --tui-message-meta-size: 12px;
  --tui-message-name-weight: 600;
  --tui-message-fg: var(--tui-fg);
  --tui-message-muted: var(--tui-muted-fg);
  --tui-message-avatar-bg: var(--tui-primary);
  --tui-message-avatar-fg: var(--tui-primary-fg);
  --tui-message-user-bg: var(--tui-muted);
  --tui-message-user-fg: var(--tui-fg);
  --tui-message-code-bg: var(--tui-muted);
  --tui-message-caret: var(--tui-fg);
  --tui-message-action-bg-hover: var(--tui-muted);
  --tui-message-error: color-mix(in oklab, var(--tui-danger) 45%, var(--tui-bg));
  --tui-message-error-border-width: var(--tui-border-width);
}

.dark,
[data-theme="dark"] {
  color-scheme: dark;
  --tui-bg: #0a0a0a;
  --tui-fg: #fafafa;
  --tui-muted: #1c1c1c;
  --tui-muted-fg: #a3a3a3;
  --tui-border: #2a2a2a;
  --tui-primary: #fafafa;
  --tui-primary-fg: #171717;
  --tui-ring: #737373;
  --tui-success: #22c55e;
  --tui-warning: #f59e0b;
  --tui-danger: #ef4444;
}

@keyframes tui-blink {
  to { visibility: hidden; }
}

Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.