# AmroUI conversational

Installable AmroUI product component source.

## Installation

```bash
npx shadcn@latest add https://amro-ui.vercel.app/r/amro-ui-conversational.json
```

[Registry JSON](https://amro-ui.vercel.app/r/amro-ui-conversational.json)

## Source

### source/components/conversational.tsx

```tsx
import {
  Bot,
  Check,
  CircleAlert,
  Copy,
  Mic,
  MoreHorizontal,
  Paperclip,
  PhoneForwarded,
  Send,
  Square,
  X
} from "lucide-react";
import {
  forwardRef,
  useId,
  useMemo,
  useState,
  type ButtonHTMLAttributes,
  type HTMLAttributes,
  type ReactNode,
  type TextareaHTMLAttributes
} from "react";
import { cn } from "../lib/cn.js";
import { Avatar } from "./avatar.js";
import { Badge } from "./badge.js";
import { Button } from "./button.js";
import {
  ActionRow,
  EmptyNotice,
  FeaturePanel,
  StatusIndicator,
  type ActionItem
} from "./feature-primitives.js";
import { Textarea } from "./input.js";

export type AssistantPresenceStatus = "idle" | "listening" | "thinking" | "speaking" | "error";

export interface AssistantPresenceOrbProps extends HTMLAttributes<HTMLDivElement> {
  status: AssistantPresenceStatus;
  label?: ReactNode;
  size?: "sm" | "md" | "lg";
  children?: ReactNode;
}

export const AssistantPresenceOrb = forwardRef<HTMLDivElement, AssistantPresenceOrbProps>(
  ({ children, className, label, size = "md", status, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        "amro-presence-orb",
        `amro-presence-orb--${status}`,
        `amro-presence-orb--${size}`,
        className
      )}
      data-status={status}
      role="status"
      {...props}
    >
      <span className="amro-presence-orb__ring" aria-hidden="true" />
      <span className="amro-presence-orb__core">{children ?? <Bot aria-hidden="true" />}</span>
      <span className="amro-visually-hidden">{label ?? `Assistant is ${status}`}</span>
    </div>
  )
);
AssistantPresenceOrb.displayName = "AssistantPresenceOrb";

export type AskAmroLauncherState = "online" | "unread" | "minimized" | "attention";

export interface AskAmroLauncherProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  state?: AskAmroLauncherState;
  assistantName?: string;
  unreadCount?: number;
  expanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
}

export const AskAmroLauncher = forwardRef<HTMLButtonElement, AskAmroLauncherProps>(
  (
    {
      assistantName = "Ask Amro",
      className,
      expanded,
      onExpandedChange,
      state = "online",
      unreadCount = 0,
      ...props
    },
    ref
  ) => {
    const [internalExpanded, setInternalExpanded] = useState(false);
    const isExpanded = expanded ?? internalExpanded;
    const toggle = () => {
      const next = !isExpanded;
      setInternalExpanded(next);
      onExpandedChange?.(next);
    };

    return (
      <button
        ref={ref}
        aria-expanded={isExpanded}
        className={cn("amro-launcher", `amro-launcher--${state}`, className)}
        onClick={toggle}
        type="button"
        {...props}
      >
        <AssistantPresenceOrb size="sm" status={state === "attention" ? "speaking" : "idle"} />
        {state !== "minimized" && <span className="amro-launcher__label">{assistantName}</span>}
        {(state === "unread" || unreadCount > 0) && (
          <span
            className="amro-launcher__unread"
            aria-label={`${unreadCount || 1} unread messages`}
          >
            {unreadCount || 1}
          </span>
        )}
      </button>
    );
  }
);
AskAmroLauncher.displayName = "AskAmroLauncher";

export interface AssistantHeaderProps extends HTMLAttributes<HTMLElement> {
  name: string;
  subtitle?: ReactNode;
  avatarUrl?: string;
  availability?: "online" | "offline" | "warning";
  onClose?: () => void;
  onOptions?: () => void;
}

export const AssistantHeader = forwardRef<HTMLElement, AssistantHeaderProps>(
  (
    { availability = "online", avatarUrl, className, name, onClose, onOptions, subtitle, ...props },
    ref
  ) => (
    <header ref={ref} className={cn("amro-assistant-header", className)} {...props}>
      <Avatar name={name} {...(avatarUrl === undefined ? {} : { src: avatarUrl })} />
      <div className="amro-assistant-header__identity">
        <strong>{name}</strong>
        <span>{subtitle ?? availability}</span>
      </div>
      <StatusIndicator status={availability} label={availability} />
      {onOptions !== undefined && (
        <button
          aria-label="Assistant options"
          className="amro-icon-button"
          onClick={onOptions}
          type="button"
        >
          <MoreHorizontal aria-hidden="true" />
        </button>
      )}
      {onClose !== undefined && (
        <button
          aria-label="Close assistant"
          className="amro-icon-button"
          onClick={onClose}
          type="button"
        >
          <X aria-hidden="true" />
        </button>
      )}
    </header>
  )
);
AssistantHeader.displayName = "AssistantHeader";

export interface ConversationMode {
  id: string;
  label: ReactNode;
  icon?: ReactNode;
  disabled?: boolean;
}

export interface ConversationModeBarProps extends HTMLAttributes<HTMLDivElement> {
  modes: readonly ConversationMode[];
  value: string;
  onValueChange: (value: string) => void;
  ariaLabel?: string;
}

export const ConversationModeBar = forwardRef<HTMLDivElement, ConversationModeBarProps>(
  ({ ariaLabel = "Conversation mode", className, modes, onValueChange, value, ...props }, ref) => (
    <div
      ref={ref}
      aria-label={ariaLabel}
      className={cn("amro-mode-bar", className)}
      role="tablist"
      {...props}
    >
      {modes.map((mode) => (
        <button
          aria-selected={value === mode.id}
          className="amro-mode-bar__option"
          disabled={mode.disabled}
          key={mode.id}
          onClick={() => onValueChange(mode.id)}
          role="tab"
          tabIndex={value === mode.id ? 0 : -1}
          type="button"
        >
          {mode.icon}
          <span>{mode.label}</span>
        </button>
      ))}
    </div>
  )
);
ConversationModeBar.displayName = "ConversationModeBar";

export interface OmniPromptComposerProps extends Omit<
  TextareaHTMLAttributes<HTMLTextAreaElement>,
  "onSubmit"
> {
  onSubmit: (value: string) => void;
  onAttach?: () => void;
  onVoice?: () => void;
  onCommand?: () => void;
  sendLabel?: string;
  value?: string;
  defaultValue?: string;
}

export const OmniPromptComposer = forwardRef<HTMLTextAreaElement, OmniPromptComposerProps>(
  (
    {
      className,
      defaultValue = "",
      disabled,
      onAttach,
      onCommand,
      onSubmit,
      onVoice,
      placeholder = "Ask anything…",
      sendLabel = "Send message",
      value,
      ...props
    },
    ref
  ) => {
    const [internalValue, setInternalValue] = useState(String(defaultValue));
    const currentValue = value ?? internalValue;
    const submit = () => {
      const trimmed = currentValue.trim();
      if (trimmed.length === 0 || disabled) return;
      onSubmit(trimmed);
      if (value === undefined) setInternalValue("");
    };

    return (
      <div className={cn("amro-composer", className)}>
        <Textarea
          ref={ref}
          className="amro-composer__input"
          disabled={disabled}
          onChange={(event) => setInternalValue(event.currentTarget.value)}
          onKeyDown={(event) => {
            if (event.key === "Enter" && !event.shiftKey) {
              event.preventDefault();
              submit();
            }
          }}
          placeholder={placeholder}
          rows={2}
          value={currentValue}
          {...props}
        />
        <div className="amro-composer__toolbar">
          <div className="amro-composer__tools">
            {onAttach !== undefined && (
              <button
                aria-label="Attach file"
                className="amro-icon-button"
                onClick={onAttach}
                type="button"
              >
                <Paperclip aria-hidden="true" />
              </button>
            )}
            {onVoice !== undefined && (
              <button
                aria-label="Start voice input"
                className="amro-icon-button"
                onClick={onVoice}
                type="button"
              >
                <Mic aria-hidden="true" />
              </button>
            )}
            {onCommand !== undefined && (
              <button className="amro-command-trigger" onClick={onCommand} type="button">
                /
              </button>
            )}
          </div>
          <Button
            aria-label={sendLabel}
            disabled={disabled || currentValue.trim().length === 0}
            onClick={submit}
            size="sm"
          >
            <Send aria-hidden="true" size={16} />
            <span className="amro-visually-hidden">{sendLabel}</span>
          </Button>
        </div>
      </div>
    );
  }
);
OmniPromptComposer.displayName = "OmniPromptComposer";

interface MessageBubbleBaseProps extends HTMLAttributes<HTMLDivElement> {
  author?: ReactNode;
  timestamp?: ReactNode;
  deliveryState?: ReactNode;
  actions?: ReactNode;
}

export interface AssistantMessageBubbleProps extends MessageBubbleBaseProps {
  assistantName?: string;
  avatarUrl?: string;
  streaming?: boolean;
}

export const AssistantMessageBubble = forwardRef<HTMLDivElement, AssistantMessageBubbleProps>(
  (
    {
      actions,
      assistantName = "Assistant",
      avatarUrl,
      children,
      className,
      streaming = false,
      timestamp,
      ...props
    },
    ref
  ) => (
    <article
      ref={ref}
      className={cn("amro-message", "amro-message--assistant", className)}
      {...props}
    >
      <Avatar name={assistantName} {...(avatarUrl === undefined ? {} : { src: avatarUrl })} />
      <div className="amro-message__content">
        <div className="amro-message__meta">
          <strong>{assistantName}</strong>
          {timestamp !== undefined && <time>{timestamp}</time>}
          {streaming && <StatusIndicator status="loading" label="Streaming" />}
        </div>
        <div className="amro-message__bubble">{children}</div>
        {actions !== undefined && <div className="amro-message__actions">{actions}</div>}
      </div>
    </article>
  )
);
AssistantMessageBubble.displayName = "AssistantMessageBubble";

export interface HumanMessageBubbleProps extends MessageBubbleBaseProps {
  name?: string;
}

export const HumanMessageBubble = forwardRef<HTMLDivElement, HumanMessageBubbleProps>(
  ({ children, className, deliveryState, name = "You", timestamp, ...props }, ref) => (
    <article ref={ref} className={cn("amro-message", "amro-message--human", className)} {...props}>
      <div className="amro-message__content">
        <div className="amro-message__meta">
          <strong>{name}</strong>
          {timestamp !== undefined && <time>{timestamp}</time>}
        </div>
        <div className="amro-message__bubble">{children}</div>
        {deliveryState !== undefined && (
          <small className="amro-message__delivery">{deliveryState}</small>
        )}
      </div>
    </article>
  )
);
HumanMessageBubble.displayName = "HumanMessageBubble";

export interface StreamingAnswerProps extends Omit<HTMLAttributes<HTMLDivElement>, "content"> {
  content: ReactNode;
  status?: "streaming" | "complete" | "cancelled" | "error";
  elapsedSeconds?: number;
  onCancel?: () => void;
  onCopy?: () => void;
}

export const StreamingAnswer = forwardRef<HTMLDivElement, StreamingAnswerProps>(
  (
    { className, content, elapsedSeconds, onCancel, onCopy, status = "streaming", ...props },
    ref
  ) => (
    <div ref={ref} aria-live="polite" className={cn("amro-streaming-answer", className)} {...props}>
      <div className="amro-streaming-answer__content">{content}</div>
      <div className="amro-streaming-answer__footer">
        <StatusIndicator
          status={
            status === "complete"
              ? "success"
              : status === "error"
                ? "error"
                : status === "streaming"
                  ? "loading"
                  : "idle"
          }
          label={status}
        />
        {elapsedSeconds !== undefined && <span>{elapsedSeconds}s</span>}
        {status === "streaming" && onCancel !== undefined && (
          <button
            className="amro-icon-button"
            aria-label="Cancel generation"
            onClick={onCancel}
            type="button"
          >
            <Square aria-hidden="true" />
          </button>
        )}
        {onCopy !== undefined && (
          <button
            className="amro-icon-button"
            aria-label="Copy answer"
            onClick={onCopy}
            type="button"
          >
            <Copy aria-hidden="true" />
          </button>
        )}
      </div>
    </div>
  )
);
StreamingAnswer.displayName = "StreamingAnswer";

export interface SuggestedPrompt {
  id: string;
  label: ReactNode;
  prompt: string;
  description?: ReactNode;
}

export interface SuggestedPromptDeckProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
  prompts: readonly SuggestedPrompt[];
  onSelect: (prompt: SuggestedPrompt) => void;
  emptyLabel?: ReactNode;
}

export const SuggestedPromptDeck = forwardRef<HTMLDivElement, SuggestedPromptDeckProps>(
  ({ className, emptyLabel = "No suggestions available", onSelect, prompts, ...props }, ref) => (
    <div ref={ref} className={cn("amro-prompt-deck", className)} {...props}>
      {prompts.length === 0 ? (
        <EmptyNotice title={emptyLabel} />
      ) : (
        prompts.map((prompt) => (
          <button
            className="amro-prompt-card"
            key={prompt.id}
            onClick={() => onSelect(prompt)}
            type="button"
          >
            <strong>{prompt.label}</strong>
            {prompt.description !== undefined && <span>{prompt.description}</span>}
          </button>
        ))
      )}
    </div>
  )
);
SuggestedPromptDeck.displayName = "SuggestedPromptDeck";

export interface SourceCitation {
  id: string;
  title: ReactNode;
  href?: string;
  excerpt?: ReactNode;
  confidence?: number;
  approved?: boolean;
}

export interface SourceCitationTrayProps extends HTMLAttributes<HTMLDetailsElement> {
  sources: readonly SourceCitation[];
  label?: ReactNode;
}

export const SourceCitationTray = forwardRef<HTMLDetailsElement, SourceCitationTrayProps>(
  ({ className, label, sources, ...props }, ref) => (
    <details ref={ref} className={cn("amro-citation-tray", className)} {...props}>
      <summary>{label ?? `${sources.length} source${sources.length === 1 ? "" : "s"}`}</summary>
      <div className="amro-citation-tray__list">
        {sources.length === 0 ? (
          <EmptyNotice title="No sources available" />
        ) : (
          sources.map((source) => (
            <article className="amro-citation" key={source.id}>
              <div>
                {source.href === undefined ? (
                  <strong>{source.title}</strong>
                ) : (
                  <a href={source.href}>{source.title}</a>
                )}
                {source.excerpt !== undefined && <p>{source.excerpt}</p>}
              </div>
              <div className="amro-citation__meta">
                {source.approved && <Badge tone="success">Approved</Badge>}
                {source.confidence !== undefined && (
                  <span>{Math.round(source.confidence * 100)}%</span>
                )}
              </div>
            </article>
          ))
        )}
      </div>
    </details>
  )
);
SourceCitationTray.displayName = "SourceCitationTray";

export type KnowledgeBoundary = "company" | "web" | "integration" | "mixed";

export interface KnowledgeBoundaryBadgeProps extends HTMLAttributes<HTMLSpanElement> {
  boundary: KnowledgeBoundary;
  label?: ReactNode;
}

export const KnowledgeBoundaryBadge = forwardRef<HTMLSpanElement, KnowledgeBoundaryBadgeProps>(
  ({ boundary, className, label, ...props }, ref) => (
    <Badge
      ref={ref}
      className={cn("amro-knowledge-boundary", className)}
      tone={boundary === "company" ? "success" : "neutral"}
      {...props}
    >
      {label ?? (boundary === "company" ? "Company knowledge" : boundary)}
    </Badge>
  )
);
KnowledgeBoundaryBadge.displayName = "KnowledgeBoundaryBadge";

export interface TypingAgent {
  id: string;
  name: string;
  avatarUrl?: string;
}

export interface AgentTypingStackProps extends HTMLAttributes<HTMLDivElement> {
  agents: readonly TypingAgent[];
  label?: ReactNode;
}

export const AgentTypingStack = forwardRef<HTMLDivElement, AgentTypingStackProps>(
  ({ agents, className, label, ...props }, ref) => (
    <div ref={ref} className={cn("amro-typing-stack", className)} role="status" {...props}>
      <div className="amro-typing-stack__avatars" aria-hidden="true">
        {agents.slice(0, 4).map((agent) => (
          <Avatar
            key={agent.id}
            name={agent.name}
            {...(agent.avatarUrl === undefined ? {} : { src: agent.avatarUrl })}
          />
        ))}
      </div>
      <span>
        {label ??
          (agents.length === 0
            ? "Waiting for an agent"
            : `${agents.map((agent) => agent.name).join(", ")} working`)}
      </span>
      {agents.length > 0 && (
        <span className="amro-typing-dots" aria-hidden="true">
          •••
        </span>
      )}
    </div>
  )
);
AgentTypingStack.displayName = "AgentTypingStack";

export interface ConversationHandoffCardProps extends Omit<
  HTMLAttributes<HTMLDivElement>,
  "title"
> {
  title?: ReactNode;
  description?: ReactNode;
  channels: readonly ActionItem[];
  status?: "available" | "queued" | "unavailable" | "error";
}

export const ConversationHandoffCard = forwardRef<HTMLDivElement, ConversationHandoffCardProps>(
  (
    {
      channels,
      className,
      description,
      status = "available",
      title = "Continue with a person",
      ...props
    },
    ref
  ) => (
    <FeaturePanel
      ref={ref}
      className={cn("amro-handoff-card", className)}
      description={description}
      status={
        <StatusIndicator
          status={
            status === "available"
              ? "online"
              : status === "error"
                ? "error"
                : status === "unavailable"
                  ? "offline"
                  : "loading"
          }
          label={status}
        />
      }
      title={title}
      {...props}
    >
      <ActionRow actions={channels} />
    </FeaturePanel>
  )
);
ConversationHandoffCard.displayName = "ConversationHandoffCard";

export interface EscalationReasonCardProps extends HTMLAttributes<HTMLDivElement> {
  reason: ReactNode;
  severity?: "info" | "warning" | "urgent";
  recommendedAction?: ReactNode;
  onEscalate?: () => void;
}

export const EscalationReasonCard = forwardRef<HTMLDivElement, EscalationReasonCardProps>(
  ({ className, onEscalate, reason, recommendedAction, severity = "warning", ...props }, ref) => (
    <div
      ref={ref}
      className={cn("amro-escalation-card", `amro-escalation-card--${severity}`, className)}
      {...props}
    >
      <CircleAlert aria-hidden="true" />
      <div>
        <strong>Human review recommended</strong>
        <p>{reason}</p>
        {recommendedAction !== undefined && <small>{recommendedAction}</small>}
      </div>
      {onEscalate !== undefined && (
        <Button onClick={onEscalate} size="sm" variant="secondary">
          Escalate
        </Button>
      )}
    </div>
  )
);
EscalationReasonCard.displayName = "EscalationReasonCard";

export interface ChatActionReceiptProps extends HTMLAttributes<HTMLDivElement> {
  action: ReactNode;
  detail?: ReactNode;
  status?: "pending" | "complete" | "failed";
  timestamp?: ReactNode;
}

export const ChatActionReceipt = forwardRef<HTMLDivElement, ChatActionReceiptProps>(
  ({ action, className, detail, status = "complete", timestamp, ...props }, ref) => (
    <div ref={ref} className={cn("amro-action-receipt", className)} role="status" {...props}>
      {status === "complete" ? (
        <Check aria-hidden="true" />
      ) : status === "failed" ? (
        <CircleAlert aria-hidden="true" />
      ) : (
        <span className="amro-spinner" aria-hidden="true" />
      )}
      <div>
        <strong>{action}</strong>
        {detail !== undefined && <span>{detail}</span>}
      </div>
      {timestamp !== undefined && <time>{timestamp}</time>}
    </div>
  )
);
ChatActionReceipt.displayName = "ChatActionReceipt";

export interface InlineApprovalCardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
  title: ReactNode;
  description?: ReactNode;
  status?: "pending" | "approved" | "rejected" | "error";
  onApprove?: () => void;
  onEdit?: () => void;
  onReject?: () => void;
  children?: ReactNode;
}

export const InlineApprovalCard = forwardRef<HTMLDivElement, InlineApprovalCardProps>(
  (
    {
      children,
      className,
      description,
      onApprove,
      onEdit,
      onReject,
      status = "pending",
      title,
      ...props
    },
    ref
  ) => {
    const actions = useMemo<ActionItem[]>(
      () => [
        ...(onApprove === undefined
          ? []
          : [{ id: "approve", label: "Approve", onSelect: onApprove, tone: "primary" as const }]),
        ...(onEdit === undefined ? [] : [{ id: "edit", label: "Edit", onSelect: onEdit }]),
        ...(onReject === undefined
          ? []
          : [{ id: "reject", label: "Reject", onSelect: onReject, tone: "danger" as const }])
      ],
      [onApprove, onEdit, onReject]
    );

    return (
      <FeaturePanel
        ref={ref}
        className={cn("amro-inline-approval", className)}
        description={description}
        footer={status === "pending" ? <ActionRow actions={actions} /> : undefined}
        status={
          <StatusIndicator
            status={
              status === "approved"
                ? "success"
                : status === "rejected"
                  ? "blocked"
                  : status === "error"
                    ? "error"
                    : "pending"
            }
            label={status}
          />
        }
        title={title}
        {...props}
      >
        {children}
      </FeaturePanel>
    );
  }
);
InlineApprovalCard.displayName = "InlineApprovalCard";

export interface VoiceConversationDockProps extends HTMLAttributes<HTMLDivElement> {
  status: AssistantPresenceStatus;
  muted?: boolean;
  transcriptVisible?: boolean;
  elapsedLabel?: ReactNode;
  levels?: readonly number[];
  onMuteChange?: (muted: boolean) => void;
  onInterrupt?: () => void;
  onTranscriptToggle?: () => void;
  onEnd?: () => void;
}

export const VoiceConversationDock = forwardRef<HTMLDivElement, VoiceConversationDockProps>(
  (
    {
      className,
      elapsedLabel,
      levels = [0.3, 0.7, 0.45, 0.9, 0.55, 0.35, 0.76],
      muted = false,
      onEnd,
      onInterrupt,
      onMuteChange,
      onTranscriptToggle,
      status,
      transcriptVisible = false,
      ...props
    },
    ref
  ) => (
    <div ref={ref} className={cn("amro-voice-dock", className)} {...props}>
      <AssistantPresenceOrb status={status} />
      <div className="amro-waveform" aria-label="Voice activity" role="img">
        {levels.map((level, index) => (
          <span
            key={`${index}-${level}`}
            style={{ "--amro-wave-level": level } as React.CSSProperties}
          />
        ))}
      </div>
      {elapsedLabel !== undefined && (
        <span className="amro-voice-dock__elapsed">{elapsedLabel}</span>
      )}
      <button
        aria-label={muted ? "Unmute" : "Mute"}
        aria-pressed={muted}
        className="amro-icon-button"
        onClick={() => onMuteChange?.(!muted)}
        type="button"
      >
        <Mic aria-hidden="true" />
      </button>
      {onInterrupt !== undefined && (
        <Button onClick={onInterrupt} size="sm" variant="secondary">
          Interrupt
        </Button>
      )}
      {onTranscriptToggle !== undefined && (
        <button
          aria-pressed={transcriptVisible}
          className="amro-inline-action"
          onClick={onTranscriptToggle}
          type="button"
        >
          Transcript
        </button>
      )}
      {onEnd !== undefined && (
        <button
          aria-label="End voice session"
          className="amro-icon-button amro-icon-button--danger"
          onClick={onEnd}
          type="button"
        >
          <PhoneForwarded aria-hidden="true" />
        </button>
      )}
    </div>
  )
);
VoiceConversationDock.displayName = "VoiceConversationDock";

export type ConversationTimelineItemType = "message" | "action" | "tool" | "handoff" | "error";

export interface ConversationTimelineItem {
  id: string;
  type: ConversationTimelineItemType;
  title: ReactNode;
  content?: ReactNode;
  timestamp?: ReactNode;
  status?: ReactNode;
}

export interface ConversationTimelineProps extends HTMLAttributes<HTMLOListElement> {
  items: readonly ConversationTimelineItem[];
  emptyLabel?: ReactNode;
}

export const ConversationTimeline = forwardRef<HTMLOListElement, ConversationTimelineProps>(
  ({ className, emptyLabel = "No conversation activity", items, ...props }, ref) => (
    <ol ref={ref} className={cn("amro-timeline", className)} {...props}>
      {items.length === 0 ? (
        <li>
          <EmptyNotice title={emptyLabel} />
        </li>
      ) : (
        items.map((item) => (
          <li
            className={cn("amro-timeline__item", `amro-timeline__item--${item.type}`)}
            key={item.id}
          >
            <span className="amro-timeline__marker" aria-hidden="true" />
            <div>
              <header>
                <strong>{item.title}</strong>
                {item.timestamp !== undefined && <time>{item.timestamp}</time>}
              </header>
              {item.content !== undefined && <div>{item.content}</div>}
              {item.status !== undefined && <small>{item.status}</small>}
            </div>
          </li>
        ))
      )}
    </ol>
  )
);
ConversationTimeline.displayName = "ConversationTimeline";

export interface ChatOption {
  id: string;
  label: ReactNode;
  description?: ReactNode;
  onSelect?: () => void;
  disabled?: boolean;
}

export interface ChatOptionsPopoverProps extends HTMLAttributes<HTMLDetailsElement> {
  options: readonly ChatOption[];
  triggerLabel?: string;
}

export const ChatOptionsPopover = forwardRef<HTMLDetailsElement, ChatOptionsPopoverProps>(
  ({ className, options, triggerLabel = "Chat options", ...props }, ref) => {
    const menuId = useId();
    return (
      <details ref={ref} className={cn("amro-options-popover", className)} {...props}>
        <summary aria-controls={menuId} aria-label={triggerLabel}>
          <MoreHorizontal aria-hidden="true" />
        </summary>
        <div className="amro-options-popover__menu" id={menuId} role="menu">
          {options.map((option) => (
            <button
              disabled={option.disabled}
              key={option.id}
              onClick={option.onSelect}
              role="menuitem"
              type="button"
            >
              <strong>{option.label}</strong>
              {option.description !== undefined && <span>{option.description}</span>}
            </button>
          ))}
        </div>
      </details>
    );
  }
);
ChatOptionsPopover.displayName = "ChatOptionsPopover";
```



## Usage

Shared product source installed automatically by AmroUI component entries.

