← Orion

registry:block

Chat

Tabbed chat panel with reasoning replies and a composer.

Rendered example

Compare mint chip to last summer

Install

orion-themesegmented-controlshimmer
Open registry JSON

registry/ui/chat-composer.tsx

"use client"

import * as React from "react"

import { SegmentedControl } from "@/registry/ui/segmented-control"
import { Shimmer } from "@/registry/ui/shimmer"

/**
 * CHAT — interactive panel with tabs, replies, and composer. The reply
 * sequence begins only after the user sends.
 */

type Phase = "idle" | "sent" | "typing" | "reply1" | "reply2" | "done"

const TAB_DATA = {
  Flavors: {
    submitted: "Compare mint chip to last summer",
    repl1: {
      label: "Sales History",
      sub: "Flavor Data",
      time: "4s",
      body: "Pulled 3 summers of mint chip sales for comparison. Sales are up 12% YoY with strongest weekends in July.",
    },
    repl2: {
      label: "Seasonal Trend",
      sub: "Forecast",
      time: "3s",
      body: 'Mint chip peaks in late spring. Consider a "Mint Chip Summer" promo tied to the June heatwave forecast.',
    },
  },
  Suppliers: {
    submitted: "Who supplies our mint?",
    repl1: {
      label: "Vendor Lookup",
      sub: "Supply Chain",
      time: "3s",
      body: "Mint extract comes from Alpine Flavors Inc (contract #AF-2024-112). Current lead time is 14 days.",
    },
    repl2: {
      label: "Price Alert",
      sub: "Procurement",
      time: "2s",
      body: "Alpine Flavors flagged a 7% price increase starting next quarter. Lock in current rates before Nov 1.",
    },
  },
} as const

type TabKey = keyof typeof TAB_DATA

function Section({
  label,
  sub,
  time,
  body,
  resolving,
}: {
  label: string
  sub: string
  time: string
  body: string
  resolving?: boolean
}) {
  return (
    <div
      className="flex w-full flex-col gap-1.5 transition-[opacity,filter,transform] duration-400"
      style={{
        opacity: resolving ? 0.55 : 1,
        filter: resolving ? "blur(0.5px)" : "blur(0)",
        transform: resolving ? "scale(0.985)" : "scale(1)",
        transformOrigin: "top left",
        transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
        animation: "fade-up 400ms cubic-bezier(0.23,1,0.32,1) both",
      }}
    >
      <div className="flex items-center gap-1 text-[12px] leading-[1.3]">
        <span className="font-medium text-ink">{label}</span>
        <span className="text-ink-2">{sub}</span>
        <span className="text-ink">for {time}</span>
      </div>
      <p className="text-[13px] leading-normal text-ink">{body}</p>
    </div>
  )
}

export function ChatComposer() {
  const [phase, setPhase] = React.useState<Phase>("idle")
  const [draft, setDraft] = React.useState("")
  const [tab, setTab] = React.useState<TabKey>("Flavors")
  const inputRef = React.useRef<HTMLInputElement>(null)
  const data = TAB_DATA[tab]

  // auto-trigger on mount for docs demo
  React.useEffect(() => {
    const t = setTimeout(() => setPhase("sent"), 300)
    return () => clearTimeout(t)
  }, [])

  React.useEffect(() => {
    let t: ReturnType<typeof setTimeout>
    if (phase === "sent") t = setTimeout(() => setPhase("typing"), 400)
    else if (phase === "typing") t = setTimeout(() => setPhase("reply1"), 800)
    else if (phase === "reply1") t = setTimeout(() => setPhase("reply2"), 1600)
    else if (phase === "reply2") t = setTimeout(() => setPhase("done"), 1200)
    else return
    return () => clearTimeout(t)
  }, [phase])

  const sent = phase !== "idle"
  const canSend = draft.trim().length > 0

  const send = () => {
    if (!canSend) return
    setDraft("")
    setPhase("sent")
  }

  const reset = () => {
    setPhase("idle")
  }

  const switchTab = (v: TabKey) => {
    setTab(v)
    setPhase("sent")
  }

  return (
    <div className="flex h-[312px] w-full max-w-95 flex-col self-start overflow-hidden rounded-card bg-surface shadow-card">
      {/* header bar */}
      <div className="flex shrink-0 items-center justify-between border-b border-line p-1.5">
        <div className="w-40">
          <SegmentedControl
            segments={[
              { value: "Flavors", label: "Flavors" },
              { value: "Suppliers", label: "Suppliers" },
            ]}
            value={tab}
            onChangeAction={switchTab}
          />
        </div>
        <div className="flex items-center gap-1">
          <button
            type="button"
            aria-label="New chat"
            onClick={reset}
            className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink-2"
          >
            <svg
              width="15"
              height="15"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
            >
              <path d="M12 5v14M5 12h14" />
            </svg>
          </button>
        </div>
      </div>

      {/* message area */}
      <div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-3 pt-2.5 pb-1">
        {/* user message */}
        <div className="flex justify-end pl-14">
          <div
            className="rounded-xl bg-field px-3 py-1.5 text-[13px] leading-[1.4] text-ink transition-[opacity,transform] duration-300"
            style={{
              opacity: sent ? 1 : 0,
              transform: sent ? "translateY(0)" : "translateY(10px)",
              transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
            }}
          >
            {data.submitted}
          </div>
        </div>

        {/* typing indicator */}
        {(phase === "typing" || phase === "reply1" || phase === "reply2" || phase === "done") && (
          <div
            className="flex items-center gap-2 px-1"
            style={{
              animation: "fade-in 300ms ease-out both",
              opacity: phase === "typing" ? 1 : 0,
              transition: "opacity 300ms ease-out",
            }}
          >
            {phase === "typing" ? (
              <>
                <span className="flex size-5 items-center justify-center rounded-full bg-inset">
                  <svg
                    width="10"
                    height="10"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="var(--ink-3)"
                    strokeWidth="2.5"
                    strokeLinecap="round"
                  >
                    <circle cx="12" cy="12" r="10" />
                    <path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
                  </svg>
                </span>
                <Shimmer className="text-[12px]">Researching</Shimmer>
              </>
            ) : (
              <span className="flex size-5 items-center justify-center rounded-full bg-accent-tint">
                <svg width="10" height="10" viewBox="0 0 24 24" fill="var(--accent)">
                  <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
                </svg>
              </span>
            )}
          </div>
        )}

        {/* reply sections */}
        {(phase === "reply1" || phase === "reply2" || phase === "done") && (
          <Section label={data.repl1.label} sub={data.repl1.sub} time={data.repl1.time} body={data.repl1.body} />
        )}
        {(phase === "reply2" || phase === "done") && (
          <Section
            label={data.repl2.label}
            sub={data.repl2.sub}
            time={data.repl2.time}
            body={data.repl2.body}
            resolving={phase === "reply2"}
          />
        )}

        {/* done indicator */}
        {phase === "done" && (
          <div className="flex items-center gap-1.5 px-1" style={{ animation: "fade-in 300ms ease-out both" }}>
            <svg
              width="12"
              height="12"
              viewBox="0 0 24 24"
              fill="none"
              stroke="var(--green)"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d="M20 6L9 17l-5-5" />
            </svg>
            <span className="text-[11.5px] text-green font-medium">Complete</span>
          </div>
        )}
      </div>

      {/* composer */}
      <div className="mt-auto shrink-0 p-1.5">
        <div
          role="button"
          tabIndex={-1}
          onClick={() => inputRef.current?.focus()}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === " ") inputRef.current?.focus()
          }}
          className="flex cursor-text flex-col gap-2 rounded-control border border-line bg-field p-2.5 shadow-hairline transition-[border-color,box-shadow] duration-150 focus-within:border-line-strong focus-within:shadow-btn"
        >
          <input
            ref={inputRef}
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter" && !event.shiftKey) {
                event.preventDefault()
                send()
              }
            }}
            placeholder="Type a message…"
            aria-label="Chat prompt"
            className="min-h-4.5 bg-transparent text-[13px] leading-[1.4] text-ink outline-none placeholder:text-ink-3"
          />
          <div className="flex items-center justify-end">
            <button
              type="button"
              aria-label="Send"
              disabled={!canSend}
              onClick={send}
              className="flex size-7 items-center justify-center rounded-control transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.96]"
              style={{
                background: canSend ? "var(--ink)" : "var(--line-strong)",
                color: canSend ? "var(--surface)" : "var(--ink-2)",
              }}
            >
              <svg
                width="16"
                height="16"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2.4"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M12 19V5M5 12l7-7 7 7" />
              </svg>
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}