Vela UI Docsvenizia.ai

Chat

Every chat composer the catalog holds, composed from Input Group: the builder's stop-to-cancel send and the form editor's steerable turn with a separate abort.

Examples

  • Chat input
  • Chat input abort
  • Chat messages
  • Chat input vietnamese
  • Chat input vietnamese error
  • Chat input vietnamese disabled
  • Chat vietnamese

View code

Chat · Chat input

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input.tsx
import { useEffect, useId, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ClockBackward } from "@/components/icons/clock-backward";
import { Close02 } from "@/components/icons/close-02";
import { Loader01 } from "@/components/icons/loader-01";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Send01 } from "@/components/icons/send-01";

/**
 * The run lifecycle behind the composer, mirroring the builder's job states:
 * the send request is in flight, the run is generating, the stop request is in
 * flight, and the run was stopped — "Cancelled", the way BuildPage's
 * `POST /builder/jobs/:id/cancel` settles a stop (#425).
 */
type RunState = "idle" | "sending" | "running" | "stopping" | "cancelled";

export default function ChatInputExample() {
  const id = useId();
  const [value, setValue] = useState("");
  const [run, setRun] = useState<RunState>("idle");
  const timers = useRef<number[]>([]);

  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const active = run === "sending" || run === "running" || run === "stopping";

  const send = () => {
    // Enter sends; Shift+Enter inserts a newline. A running or stopping run
    // takes no new prompt, the way the rail's `blocked` gate works.
    if (!value.trim() || active) return;
    setValue("");
    setRun("sending");
    timers.current.push(window.setTimeout(() => setRun("running"), 1000));
  };

  // The builder's stop (#425): the × is disabled while the cancel request is
  // in flight (`pending === "stop"`, `disabled={busy}`), then the run flips to
  // cancelled — the conversation records it and the composer comes back for
  // the next message.
  const stop = () => {
    if (run !== "running") return;
    setRun("stopping");
    timers.current.push(window.setTimeout(() => setRun("cancelled"), 600));
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input</Label>
      <TooltipProvider>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={3}
            placeholder="Ask anything…"
            className="max-h-60 overflow-y-auto"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="border-t">
            <Tooltip>
              <TooltipTrigger asChild>
                <InputGroupButton
                  variant="outline"
                  size="icon-sm"
                  aria-label="Version history"
                >
                  <ClockBackward />
                </InputGroupButton>
              </TooltipTrigger>
              <TooltipContent>Version history</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger asChild>
                <InputGroupButton
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Attach files"
                  className="text-icon-note hover:text-icon-strong"
                >
                  <Paperclip02 />
                </InputGroupButton>
              </TooltipTrigger>
              <TooltipContent>Attach files</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger asChild>
                <InputGroupButton
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Connectors"
                  className="text-icon-note hover:text-icon-strong"
                >
                  <Plug01 />
                </InputGroupButton>
              </TooltipTrigger>
              <TooltipContent>Connectors</TooltipContent>
            </Tooltip>
            {run === "running" || run === "stopping" ? (
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    size="icon-sm"
                    aria-label="Stop build"
                    className="ml-auto"
                    onClick={stop}
                    disabled={run === "stopping"}
                  >
                    <Close02 className="size-4" strokeWidth={1.75} />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>Stop build</TooltipContent>
              </Tooltip>
            ) : (
              <InputGroupButton
                variant="primary"
                size="icon-sm"
                aria-label="Send"
                className="ml-auto"
                onClick={send}
                disabled={active || !value.trim()}
              >
                {run === "sending" ? (
                  <Loader01 className="animate-spin" />
                ) : (
                  <Send01 />
                )}
              </InputGroupButton>
            )}
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {/* The conversation's account of the run, the way the rail reads a job:
       *  building, then the cancelled notice, then nothing. */}
      {run !== "idle" && (
        <p role="status" className="text-xs text-text-muted">
          {run === "sending" && "Sending…"}
          {run === "running" && "Building…"}
          {run === "stopping" && "Stopping…"}
          {run === "cancelled" && "Cancelled"}
        </p>
      )}
    </div>
  );
}
import { useEffect, useId, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ClockBackward } from "@/components/icons/clock-backward";
import { Close02 } from "@/components/icons/close-02";
import { Loader01 } from "@/components/icons/loader-01";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Send01 } from "@/components/icons/send-01";

/**
 * The run lifecycle behind the composer, mirroring the builder's job states:
 * the send request is in flight, the run is generating, the stop request is in
 * flight, and the run was stopped — "Cancelled", the way BuildPage's
 * `POST /builder/jobs/:id/cancel` settles a stop (#425).
 */
type RunState = "idle" | "sending" | "running" | "stopping" | "cancelled";

export default function ChatInputExample() {
  const id = useId();
  const [value, setValue] = useState("");
  const [run, setRun] = useState<RunState>("idle");
  const timers = useRef<number[]>([]);

  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const active = run === "sending" || run === "running" || run === "stopping";

  const send = () => {
    // Enter sends; Shift+Enter inserts a newline. A running or stopping run
    // takes no new prompt, the way the rail's `blocked` gate works.
    if (!value.trim() || active) return;
    setValue("");
    setRun("sending");
    timers.current.push(window.setTimeout(() => setRun("running"), 1000));
  };

  // The builder's stop (#425): the × is disabled while the cancel request is
  // in flight (`pending === "stop"`, `disabled={busy}`), then the run flips to
  // cancelled — the conversation records it and the composer comes back for
  // the next message.
  const stop = () => {
    if (run !== "running") return;
    setRun("stopping");
    timers.current.push(window.setTimeout(() => setRun("cancelled"), 600));
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input</Label>
      <TooltipProvider>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={3}
            placeholder="Ask anything…"
            className="max-h-60 overflow-y-auto"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="border-t">
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    variant="outline"
                    size="icon-sm"
                    aria-label="Version history"
                  />
                }
              >
                <ClockBackward />
              </TooltipTrigger>
              <TooltipContent>Version history</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Attach files"
                    className="text-icon-note hover:text-icon-strong"
                  />
                }
              >
                <Paperclip02 />
              </TooltipTrigger>
              <TooltipContent>Attach files</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Connectors"
                    className="text-icon-note hover:text-icon-strong"
                  />
                }
              >
                <Plug01 />
              </TooltipTrigger>
              <TooltipContent>Connectors</TooltipContent>
            </Tooltip>
            {run === "running" || run === "stopping" ? (
              <Tooltip>
                <TooltipTrigger
                  render={
                    <Button
                      size="icon-sm"
                      aria-label="Stop build"
                      className="ml-auto"
                      onClick={stop}
                      disabled={run === "stopping"}
                    />
                  }
                >
                  <Close02 className="size-4" strokeWidth={1.75} />
                </TooltipTrigger>
                <TooltipContent>Stop build</TooltipContent>
              </Tooltip>
            ) : (
              <InputGroupButton
                variant="primary"
                size="icon-sm"
                aria-label="Send"
                className="ml-auto"
                onClick={send}
                disabled={active || !value.trim()}
              >
                {run === "sending" ? (
                  <Loader01 className="animate-spin" />
                ) : (
                  <Send01 />
                )}
              </InputGroupButton>
            )}
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {/* The conversation's account of the run, the way the rail reads a job:
       *  building, then the cancelled notice, then nothing. */}
      {run !== "idle" && (
        <p role="status" className="text-xs text-text-muted">
          {run === "sending" && "Sending…"}
          {run === "running" && "Building…"}
          {run === "stopping" && "Stopping…"}
          {run === "cancelled" && "Cancelled"}
        </p>
      )}
    </div>
  );
}

View code

Chat · Chat input abort

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-abort.tsx
import { useId, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ArrowUp } from "@/components/icons/arrow-up";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Stop01 } from "@/components/icons/stop-01";

/**
 * The form-editor chat composer from veni-os (`chat-panel.tsx`): the stop
 * button never replaces send. While a turn streams, a round outline Stop
 * (`Stop01`, `onAbort`) appears beside a still-enabled send — a new prompt
 * steers the running turn — and stopping never blocks or resets the composer,
 * unlike veni-builder's composer, where × swaps send out and cancels the run.
 */
export default function ChatInputAbortExample() {
  const id = useId();
  const [draft, setDraft] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [stopped, setStopped] = useState(false);

  // veni-os `submit()`: every prompt sends, streaming or not — while a turn
  // runs, the composer is the steering wheel, not a blocked gate.
  const send = () => {
    if (!draft.trim()) return;
    setDraft("");
    setStopped(false);
    setStreaming(true);
  };

  // veni-os `onAbort()`: end the running turn; the composer never left.
  const abort = () => {
    setStreaming(false);
    setStopped(true);
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input with abort</Label>
      <TooltipProvider delayDuration={200}>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={2}
            placeholder={streaming ? "Steer the running turn…" : "Ask your assistant…"}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              // Enter sends, Shift+Enter breaks the line; an IME composition
              // (Vietnamese Telex) commits on Enter and must not send.
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="justify-between border-t">
            <div className="flex items-center gap-1">
              <Tooltip>
                <TooltipTrigger asChild>
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Attach images"
                    className="text-icon-note hover:text-icon-strong"
                  >
                    <Paperclip02 />
                  </InputGroupButton>
                </TooltipTrigger>
                <TooltipContent>Attach up to 4 images, 5 MB each</TooltipContent>
              </Tooltip>
              <Tooltip>
                <TooltipTrigger asChild>
                  {/* aria-disabled, not disabled: a natively disabled button
                   * swallows the hover that explains why. */}
                  <InputGroupButton
                    variant="ghost"
                    size="icon-sm"
                    aria-label="MCP connections"
                    aria-disabled
                    className="text-icon-note opacity-50 hover:text-icon-strong"
                  >
                    <Plug01 />
                  </InputGroupButton>
                </TooltipTrigger>
                <TooltipContent>MCP connections — coming soon</TooltipContent>
              </Tooltip>
            </div>
            <div className="flex items-center gap-2">
              {streaming && (
                <Button
                  variant="outline"
                  size="icon-sm"
                  className="rounded-full"
                  aria-label="Stop the running turn"
                  onClick={abort}
                >
                  <Stop01 className="size-4" strokeWidth={1.75} />
                </Button>
              )}
              <Button
                variant="primary"
                size="icon-sm"
                className="rounded-full"
                disabled={!draft.trim()}
                aria-label="Send"
                onClick={send}
              >
                <ArrowUp className="size-4" strokeWidth={1.75} />
              </Button>
            </div>
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {(streaming || stopped) && (
        <p role="status" className="text-xs text-text-muted">
          {streaming ? "Working…" : "Stopped"}
        </p>
      )}
    </div>
  );
}
import { useId, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Label } from "@/components/ui/label";
import { ArrowUp } from "@/components/icons/arrow-up";
import { Paperclip02 } from "@/components/icons/paperclip-02";
import { Plug01 } from "@/components/icons/plug-01";
import { Stop01 } from "@/components/icons/stop-01";

/**
 * The form-editor chat composer from veni-os (`chat-panel.tsx`): the stop
 * button never replaces send. While a turn streams, a round outline Stop
 * (`Stop01`, `onAbort`) appears beside a still-enabled send — a new prompt
 * steers the running turn — and stopping never blocks or resets the composer,
 * unlike veni-builder's composer, where × swaps send out and cancels the run.
 */
export default function ChatInputAbortExample() {
  const id = useId();
  const [draft, setDraft] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [stopped, setStopped] = useState(false);

  // veni-os `submit()`: every prompt sends, streaming or not — while a turn
  // runs, the composer is the steering wheel, not a blocked gate.
  const send = () => {
    if (!draft.trim()) return;
    setDraft("");
    setStopped(false);
    setStreaming(true);
  };

  // veni-os `onAbort()`: end the running turn; the composer never left.
  const abort = () => {
    setStreaming(false);
    setStopped(true);
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Label htmlFor={id}>Chat input with abort</Label>
      <TooltipProvider>
        <InputGroup>
          <InputGroupTextarea
            id={id}
            rows={2}
            placeholder={streaming ? "Steer the running turn…" : "Ask your assistant…"}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              // Enter sends, Shift+Enter breaks the line; an IME composition
              // (Vietnamese Telex) commits on Enter and must not send.
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="justify-between border-t">
            <div className="flex items-center gap-1">
              <Tooltip>
                <TooltipTrigger
                  render={
                    <InputGroupButton
                      variant="ghost"
                      size="icon-sm"
                      aria-label="Attach images"
                      className="text-icon-note hover:text-icon-strong"
                    />
                  }
                >
                  <Paperclip02 />
                </TooltipTrigger>
                <TooltipContent>Attach up to 4 images, 5 MB each</TooltipContent>
              </Tooltip>
              <Tooltip>
                <TooltipTrigger
                  render={
                    <InputGroupButton
                      variant="ghost"
                      size="icon-sm"
                      aria-label="MCP connections"
                      aria-disabled
                      className="text-icon-note opacity-50 hover:text-icon-strong"
                    />
                  }
                >
                  <Plug01 />
                </TooltipTrigger>
                <TooltipContent>MCP connections — coming soon</TooltipContent>
              </Tooltip>
            </div>
            <div className="flex items-center gap-2">
              {streaming && (
                <Button
                  variant="outline"
                  size="icon-sm"
                  className="rounded-full"
                  aria-label="Stop the running turn"
                  onClick={abort}
                >
                  <Stop01 className="size-4" strokeWidth={1.75} />
                </Button>
              )}
              <Button
                variant="primary"
                size="icon-sm"
                className="rounded-full"
                disabled={!draft.trim()}
                aria-label="Send"
                onClick={send}
              >
                <ArrowUp className="size-4" strokeWidth={1.75} />
              </Button>
            </div>
          </InputGroupAddon>
        </InputGroup>
      </TooltipProvider>
      {(streaming || stopped) && (
        <p role="status" className="text-xs text-text-muted">
          {streaming ? "Working…" : "Stopped"}
        </p>
      )}
    </div>
  );
}

View code

Chat · Chat messages

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-messages.tsx
import { useEffect, useRef, useState } from "react";

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";
import { ArrowUp } from "@/components/icons/arrow-up";
import { Check01 } from "@/components/icons/check-01";
import { ChevronRight } from "@/components/icons/chevron-right";
import { Loader01 } from "@/components/icons/loader-01";

/**
 * The build rail's conversation from veni-builder (`ChatRail.tsx`) as a live
 * chat area, demonstrating every kind of row the rail shows. What the person
 * sends is a right-aligned bubble hugging its text, primary-tinted with a
 * squared top-right corner and their name above (#661); what the agent says
 * is plain prose; the plan's questions are a bordered card (#300). The
 * replies differ by how they were sent:
 *
 *  - answers — the composer while the plan asks its questions, one line per
 *    answer (`/answer`, the `answers` array joined);
 *  - a decisions pick — "Apply choices" on the plan's decisions, sent as a
 *    formatted change (`/revise`, `choicesMessage`, #1361);
 *  - a change asked of the plan — the composer while the plan waits for
 *    approval, free text (`/revise`, the `revision`);
 *  - a narrowing — "Narrow it" on a large plan (`/narrow`), which the rail
 *    shows as a status row, not a person bubble (`{ narrowed }`).
 *
 * The scratch work folds away the same way: a build-log row ("Build log · N
 * steps", `DetailsRow`) that expands into its tool calls — a check per done
 * step, the one error line worth reading — with the agent's narration
 * interleaved (#784). Typing below runs the flow: the send lands as a person
 * turn, Appia answers after a thinking beat. The area resizes horizontally
 * from its right edge, by drag or arrow keys.
 */
interface ChatMessage {
  role: "person" | "agent" | "system";
  text?: string;
  author?: string;
  /** The plan's questions (#300): an agent turn that is a card, and the one
   *  the area's next reply answers. */
  questions?: string[];
  /** The folded build log (`DetailsRow`): tool calls with the agent's
   *  narration interleaved, collapsed until asked for. */
  log?: LogStep[];
}

interface LogStep {
  /** The command's first line, the way the rail truncates it. */
  command?: string;
  /** A step that finished with output; the text shows only on error. */
  result?: string;
  error?: boolean;
  /** What the agent said between calls (#784). */
  note?: string;
}

const QUESTIONS = [
  "Who approves a leave request — the requester's manager or HR?",
  "Should balances roll over at the year's end?",
];

const SEEDED: ChatMessage[] = [
  { role: "person", author: "Hiếu Võ", text: "Build leave request management with an approval flow." },
  { role: "agent", text: "I've planned the module — two things before I build." },
  { role: "agent", questions: QUESTIONS },
  // Answers: one line per answer, the way `answers` arrives from /answer.
  { role: "person", author: "Hiếu Võ", text: "The requester's manager approves.\nBalances don't roll over at the year's end." },
  { role: "agent", text: "Planned. One default to check: leave books by full days." },
  // A decisions pick: the formatted change /revise carries (#1361).
  { role: "person", author: "Hiếu Võ", text: "I am changing these choices in the plan:\n- Leave unit: half days (instead of full days only)" },
  { role: "agent", text: "Replanned with half days. It's a large first version — you can build part of it now." },
  // A change asked of the plan: the free-text /revise while it waits.
  { role: "person", author: "Hiếu Võ", text: "Cut the audit log for now — approvals only." },
  // A narrowing: /narrow reads as a status row, not a person bubble.
  { role: "system", text: "Narrowed the plan — building only Leave requests and Approvals; the rest in a later change." },
  { role: "agent", text: "Building those now." },
  {
    role: "agent",
    text: "The stack matches the plan — running the checks before hand-off.",
    log: [
      { command: "Read src/approvals/LeaveRequest.ts" },
      { command: "Glob src/approvals/**" },
      { command: "Bash npm run lint", result: "2 errors in src/balances/BalanceView.tsx", error: true },
      { note: "Lint flags unused props in the balance view — fixing those, then lint again." },
      { command: "Bash npm run lint", result: "No problems" },
      { command: "Bash npm run build", result: "built in 41s" },
    ],
  },
  { role: "agent", text: "Checks green — the approval flow is ready for review." },
];

/** What Appia answers with, one per turn the area runs. */
const REPLIES = [
  "Noted — routing approvals to the requester's manager and resetting balances each January.",
  "Adding the balance view next: accrued, taken and remaining per person, refreshed on approval.",
  "The approval queue is live for managers. Anything else before I hand this over?",
];

const MIN_WIDTH = 320;
const MAX_WIDTH = 640;
const THINKING_MS = 1100;

/** The folded build log (`DetailsRow`): collapsed until asked for, the
 *  chevron turning with it. A done step carries a check; its output text is
 *  only worth reading when the step failed. */
function BuildLog({ steps }: { steps: LogStep[] }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="px-1">
      <button
        type="button"
        className="flex items-center gap-1 text-xs text-text-muted hover:text-text-strong"
        aria-expanded={open}
        onClick={() => setOpen(o => !o)}
      >
        <ChevronRight className={`size-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
        {`Build log · ${steps.length} steps`}
      </button>
      {open && (
        <ol className="mt-1 space-y-1 border-l border-border-default pl-2 text-xs text-text-muted">
          {steps.map((step, index) =>
            step.note ? (
              <li key={index} className="whitespace-pre-wrap">
                {step.note}
              </li>
            ) : (
              <li key={index} className="flex items-start gap-2 py-0.5">
                {step.error ? (
                  <Check01 className="mt-0.5 size-3.5 shrink-0 text-negative-middle" strokeWidth={2.5} />
                ) : (
                  <Check01 className="mt-0.5 size-3.5 shrink-0 text-success-strong" strokeWidth={2.5} />
                )}
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-xs font-medium">{step.command}</span>
                  {step.error && step.result && (
                    <span className="block truncate text-xs text-negative-middle">{step.result}</span>
                  )}
                </span>
              </li>
            ),
          )}
        </ol>
      )}
    </div>
  );
}

export default function ChatMessagesExample() {
  const [messages, setMessages] = useState<ChatMessage[]>(SEEDED);
  const [draft, setDraft] = useState("");
  const [thinking, setThinking] = useState(false);
  const [replyIndex, setReplyIndex] = useState(0);
  const [width, setWidth] = useState(440);
  const [dragging, setDragging] = useState(false);
  const dragStart = useRef<{ x: number; width: number } | null>(null);
  const logRef = useRef<HTMLDivElement>(null);

  // The thinking beat is the flow: the send lands, the agent is seen working,
  // then its turn arrives. The timer cancels if the example unmounts mid-beat.
  useEffect(() => {
    if (!thinking) return;
    const timer = setTimeout(() => {
      setThinking(false);
      setMessages(m => [...m, { role: "agent", text: REPLIES[replyIndex % REPLIES.length] }]);
      setReplyIndex(i => i + 1);
    }, THINKING_MS);
    return () => clearTimeout(timer);
  }, [thinking, replyIndex]);

  // New turns pin to the bottom, like the rail the example is drawn from.
  useEffect(() => {
    const log = logRef.current;
    if (log) log.scrollTop = log.scrollHeight;
  }, [messages, thinking]);

  useEffect(() => {
    if (!dragging) return;
    const move = (e: PointerEvent) => {
      if (!dragStart.current) return;
      // The handle is the right edge: dragging right widens the area.
      const next = dragStart.current.width + (e.clientX - dragStart.current.x);
      setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, next)));
    };
    const up = () => {
      dragStart.current = null;
      setDragging(false);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
    };
  }, [dragging]);

  const send = () => {
    const text = draft.trim();
    if (!text || thinking) return;
    setMessages(m => [...m, { role: "person", author: "You", text }]);
    setDraft("");
    setThinking(true);
  };

  const beginResize = (e: React.PointerEvent) => {
    dragStart.current = { x: e.clientX, width };
    setDragging(true);
  };

  const resizeByKeyboard = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowRight") {
      setWidth(w => Math.min(MAX_WIDTH, w + 16));
    } else if (e.key === "ArrowLeft") {
      setWidth(w => Math.max(MIN_WIDTH, w - 16));
    } else {
      return;
    }
    e.preventDefault();
  };

  return (
    <div
      className="relative flex h-[440px] flex-col overflow-hidden rounded-xl border border-border-default bg-bg-main"
      style={{ width: `min(100%, ${width}px)` }}
    >
      <header className="flex items-baseline justify-between border-b border-border-default px-4 py-3">
        <p className="text-sm font-medium text-text-strong">Build chat</p>
        <p className="text-xs text-text-muted" role="status">
          {thinking ? "Appia is answering…" : "Appia · idle"}
        </p>
      </header>

      <div ref={logRef} role="log" aria-label="Conversation" aria-busy={thinking} className="flex-1 overflow-y-auto px-4 py-4">
        <ul className="flex flex-col gap-3">
          {messages.map((message, index) =>
            message.questions ? (
              /* The plan's questions (#300): a turn of the conversation, and
               * the composer below is where the answer goes. */
              <li key={index} className="rounded-lg border border-border-default p-3">
                <p className="text-sm font-semibold text-text-strong">
                  Before building, {message.questions.length} things:
                </p>
                <ul className="mt-1.5 list-disc space-y-1 pl-4 text-sm text-text-muted">
                  {message.questions.map(question => (
                    <li key={question}>{question}</li>
                  ))}
                </ul>
                <p className="mt-1.5 text-xs text-text-muted">Answer below — or say to decide for you.</p>
              </li>
            ) : message.role === "system" ? (
              <li
                key={index}
                className="rounded-lg bg-bg-surface-1 px-3 py-2 text-xs text-text-muted"
              >
                {message.text}
              </li>
            ) : message.role === "person" ? (
              <li key={index} className="flex flex-col items-end gap-1">
                {message.author && <span className="px-1 text-xs text-text-muted">{message.author}</span>}
                <span className="w-fit rounded-lg rounded-tr-sm bg-primary-default/10 px-3 py-2 text-sm whitespace-pre-wrap">
                  {message.text}
                </span>
              </li>
            ) : message.log ? (
              <li key={index}>
                <p className="mb-1 px-1 text-sm leading-relaxed text-text-strong">{message.text}</p>
                <BuildLog steps={message.log} />
              </li>
            ) : (
              <li key={index} className="px-1 text-sm leading-relaxed text-text-strong">
                {message.text}
              </li>
            ),
          )}

          {thinking && (
            <li className="flex items-center gap-2 px-1 text-xs text-text-muted">
              <Loader01 className="size-3.5 shrink-0 animate-spin" strokeWidth={1.75} />
              Appia is answering…
            </li>
          )}
        </ul>
      </div>

      <div className="border-t border-border-default p-2.5">
        <InputGroup>
          <InputGroupTextarea
            rows={1}
            placeholder="Reply, or say to decide for you."
            aria-label="Message Appia"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
            }}
          />
          <InputGroupAddon align="block-end" className="justify-end">
            <InputGroupButton
              variant="primary"
              size="icon-sm"
              className="rounded-full"
              aria-label="Send"
              disabled={!draft.trim() || thinking}
              onClick={send}
            >
              <ArrowUp className="size-4" strokeWidth={1.75} />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </div>

      {/* The right edge is the resize affordance: a hairline grip that appears
       *  on hover, a full-height hot zone, and a separator for the keyboard. */}
      <div
        role="separator"
        aria-orientation="vertical"
        aria-label="Resize the chat area"
        aria-valuemin={MIN_WIDTH}
        aria-valuemax={MAX_WIDTH}
        aria-valuenow={width}
        tabIndex={0}
        onPointerDown={beginResize}
        onKeyDown={resizeByKeyboard}
        className={`group absolute inset-y-0 right-0 z-10 flex w-3 cursor-ew-resize touch-none items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-neutral-outline ${
          dragging ? "before:absolute before:inset-y-0 before:right-0 before:w-0.5 before:bg-primary-default" : ""
        }`}
      >
        <span
          aria-hidden="true"
          className={`h-10 w-1 rounded-full bg-border-active/50 transition-opacity ${
            dragging ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
          }`}
        />
      </div>
    </div>
  );
}

View code

Chat · Chat input vietnamese

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-vietnamese.tsx
import { useEffect, useId, useState } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";
import {
  useVietnameseInput,
  VietnameseMethods,
} from "@/hooks/use-vietnamese-input";

// Demo phrasebook: the example has no translation service behind it.
const PHRASEBOOK: Record<string, string> = {
  "gửi phiếu lương tháng 9 cho toàn bộ nhân viên":
    "Send the September payslips to all employees",
  "xin chào": "Hello",
  "cảm ơn bạn": "Thank you",
};

type Status = "empty" | "typing" | "translated" | "unknown";

/**
 * The one-box translate input from Appia's "Translate input · 1 ô" study:
 * type Vietnamese with Telex and, after a short pause, the box swaps the text
 * for its English translation. The hint line under the box follows the state
 * — Empty ("Tự động dịch sang tiếng Anh khi bạn gõ"), Typing ("Đang dịch…",
 * focused ring) and Translated ("Đã dịch từ tiếng Việt · Hoàn tác") — and
 * Hoàn tác puts the Vietnamese back. Try "Xin chào" or "Cảm ơn bạn".
 */
export default function ChatInputVietnameseExample() {
  const id = useId();
  const [value, setValue] = useState("");
  const [original, setOriginal] = useState<string | null>(null);
  const [status, setStatus] = useState<Status>("empty");
  const { ref } = useVietnameseInput({ method: VietnameseMethods.TELEX });

  useEffect(() => {
    if (status !== "typing") return;
    const timer = window.setTimeout(() => {
      const english = PHRASEBOOK[value.trim().toLowerCase()];
      if (!english) {
        setStatus("unknown");
        return;
      }
      setOriginal(value);
      setValue(english);
      setStatus("translated");
    }, 900);
    return () => window.clearTimeout(timer);
  }, [status, value]);

  const undo = () => {
    if (original === null) return;
    setValue(original);
    setOriginal(null);
    setStatus("typing");
  };

  return (
    <div className="grid w-full max-w-sm gap-1.5">
      <Label htmlFor={id} className="font-bold text-text-muted">
        Nội dung (tiếng Anh)
      </Label>
      <Input
        id={id}
        ref={ref}
        size="md"
        placeholder="Gõ tiếng Việt, tự chuyển sang tiếng Anh"
        value={value}
        aria-describedby={`${id}-hint`}
        onChange={(event) => {
          setValue(event.target.value);
          setOriginal(null);
          setStatus(event.target.value.trim() ? "typing" : "empty");
        }}
      />
      <p
        id={`${id}-hint`}
        role="status"
        className="flex items-center gap-1 text-xs leading-[1.3] text-text-note"
      >
        <InformationCircleContained size={16} aria-hidden className="shrink-0" />
        {status === "empty" && "Tự động dịch sang tiếng Anh khi bạn gõ"}
        {status === "typing" && "Đang dịch sang tiếng Anh…"}
        {status === "unknown" && "Chưa có bản dịch mẫu cho câu này"}
        {status === "translated" && (
          <span>
            Đã dịch từ tiếng Việt ·{" "}
            <button
              type="button"
              onClick={undo}
              className="font-semibold text-text-strong underline"
            >
              Hoàn tác
            </button>
          </span>
        )}
      </p>
    </div>
  );
}

View code

Chat · Chat input vietnamese error

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-vietnamese-error.tsx
import { useId, useState } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";
import {
  useVietnameseInput,
  VietnameseMethods,
} from "@/hooks/use-vietnamese-input";

/**
 * The error state of the one-box translate input: the translation failed, so
 * the box keeps the Vietnamese the user typed, draws its negative border
 * through aria-invalid and the hint line turns into the failure message with
 * a Thử lại action. Telex stays on, and editing the text clears the error.
 */
export default function ChatInputVietnameseErrorExample() {
  const id = useId();
  const [value, setValue] = useState("Gửi phiếu lương tháng 9 cho toàn bộ nhân viên");
  const [failed, setFailed] = useState(true);
  const { ref } = useVietnameseInput({ method: VietnameseMethods.TELEX });

  const retry = () => {
    setValue("Send the September payslips to all employees");
    setFailed(false);
  };

  return (
    <div className="grid w-full max-w-sm gap-1.5">
      <Label htmlFor={id} className="font-bold text-text-muted">
        Nội dung (tiếng Anh)
      </Label>
      <Input
        id={id}
        ref={ref}
        size="md"
        placeholder="Gõ tiếng Việt, tự chuyển sang tiếng Anh"
        value={value}
        aria-invalid={failed}
        aria-describedby={`${id}-hint`}
        onChange={(event) => {
          setValue(event.target.value);
          setFailed(false);
        }}
      />
      <p
        id={`${id}-hint`}
        role={failed ? "alert" : "status"}
        className={
          failed
            ? "flex items-center gap-1 text-xs leading-[1.3] text-negative-strong"
            : "flex items-center gap-1 text-xs leading-[1.3] text-text-note"
        }
      >
        <InformationCircleContained size={16} aria-hidden className="shrink-0" />
        {failed ? (
          <span>
            Không dịch được lúc này ·{" "}
            <button type="button" onClick={retry} className="font-semibold underline">
              Thử lại
            </button>
          </span>
        ) : (
          "Đã dịch từ tiếng Việt"
        )}
      </p>
    </div>
  );
}

View code

Chat · Chat input vietnamese disabled

Installation

pnpm dlx velaui add @velaui/input-group
pnpm dlx velaui add @velaui/input-group

Code

input-group-chat-input-vietnamese-disabled.tsx
import { useId } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InformationCircleContained } from "@/components/icons/information-circle-contained";

/**
 * The disabled state of the one-box translate input: the box reads
 * border-disabled/bg-input-disabled and the hint line drops to text-disabled.
 * Used while a conversation is locked; no input method is attached because
 * nothing can be typed.
 */
export default function ChatInputVietnameseDisabledExample() {
  const id = useId();

  return (
    <div className="grid w-full max-w-sm gap-1.5">
      <Label htmlFor={id} className="font-bold text-text-muted">
        Nội dung (tiếng Anh)
      </Label>
      <Input
        id={id}
        size="md"
        disabled
        placeholder="Gõ tiếng Việt, tự chuyển sang tiếng Anh"
        aria-describedby={`${id}-hint`}
      />
      <p
        id={`${id}-hint`}
        className="flex items-center gap-1 text-xs leading-[1.3] text-text-disabled"
      >
        <InformationCircleContained size={16} aria-hidden className="shrink-0" />
        Tự động dịch sang tiếng Anh khi bạn gõ
      </p>
    </div>
  );
}

View code

Chat · Chat vietnamese

Installation

pnpm dlx velaui add @velaui/vietnamese-input
pnpm dlx velaui add @velaui/vietnamese-input

Code

vietnamese-input-chat-vietnamese.tsx
import { useEffect, useId, useState } from "react";

import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ArrowSwitchHorizontal } from "@/components/icons/arrow-switch-horizontal";
import { Close01 } from "@/components/icons/close-01";
import { CopyRight } from "@/components/icons/copy-right";
import { Microphone02 } from "@/components/icons/microphone-02";
import {
  useVietnameseInput,
  VietnameseMethods,
} from "@/hooks/use-vietnamese-input";

const LIMIT = 5000;

// Demo phrasebook: the example has no translation service behind it.
const PAIRS: Array<[vi: string, en: string]> = [
  [
    "Gửi phiếu lương tháng 9 cho toàn bộ nhân viên trước ngày 05/10.",
    "Send the September payslips to all employees before October 5.",
  ],
  ["Xin chào", "Hello"],
  ["Cảm ơn bạn", "Thank you"],
];

type Language = "vi" | "en";

const LANGUAGE_NAMES: Record<Language, string> = { vi: "Tiếng Việt", en: "Tiếng Anh" };

const translate = (text: string, from: Language) => {
  const key = text.trim().toLowerCase();
  const pair = PAIRS.find(([vi, en]) => (from === "vi" ? vi : en).toLowerCase() === key);
  return pair ? pair[from === "vi" ? 1 : 0] : null;
};

/**
 * The translate box from Appia's "Translate box" study: a language bar with a
 * swap button, a source field (Telex on while the source is Vietnamese, clear
 * and voice buttons, a character counter) and a muted translation panel under
 * it. The panel walks Empty → Translating ("Đang dịch…") → Translated, where
 * the copy button appears. Try "Xin chào" or "Cảm ơn bạn".
 */
export default function Example() {
  const id = useId();
  const [source, setSource] = useState<Language>("vi");
  const [value, setValue] = useState("");
  const [output, setOutput] = useState<string | null>(null);
  const [copied, setCopied] = useState(false);
  const target: Language = source === "vi" ? "en" : "vi";
  const translating = value.trim() !== "" && output === null;
  const { ref } = useVietnameseInput({
    method: VietnameseMethods.TELEX,
    enabled: source === "vi",
  });

  useEffect(() => {
    if (!value.trim()) return;
    const timer = window.setTimeout(
      () => setOutput(translate(value, source) ?? "Chưa có bản dịch mẫu cho câu này"),
      700,
    );
    return () => window.clearTimeout(timer);
  }, [value, source]);

  const edit = (next: string) => {
    setValue(next.slice(0, LIMIT));
    setOutput(null);
    setCopied(false);
  };

  const swap = () => {
    setSource(target);
    edit(output && translate(value, source) ? output : "");
  };

  const copy = () => {
    void navigator.clipboard?.writeText(output ?? "");
    setCopied(true);
  };

  return (
    <div className="w-full max-w-lg overflow-clip rounded-2xl border border-border-default bg-bg-main">
      <div className="flex items-center justify-between border-b border-border-default p-2">
        <span className="px-3 text-sm leading-[1.4] font-semibold text-text-strong">
          {LANGUAGE_NAMES[source]}
        </span>
        <Button variant="ghost" size="icon-sm" aria-label="Đảo ngôn ngữ" onClick={swap}>
          <ArrowSwitchHorizontal />
        </Button>
        <span className="px-3 text-sm leading-[1.4] font-semibold text-text-strong">
          {LANGUAGE_NAMES[target]}
        </span>
      </div>

      <div className="flex flex-col gap-4 pt-4 pr-3 pb-3 pl-5">
        <div className="flex min-h-14 items-start gap-2">
          <Textarea
            id={id}
            ref={ref}
            variant="underline"
            aria-label={`Văn bản ${LANGUAGE_NAMES[source].toLowerCase()}`}
            placeholder="Nhập văn bản để dịch…"
            value={value}
            onChange={(event) => edit(event.target.value)}
            className="min-h-14 flex-1 resize-none border-0 bg-transparent p-0 text-base focus-visible:ring-0 hover:bg-transparent focus-visible:bg-transparent"
          />
          {value && (
            <Button variant="ghost" size="icon-sm" aria-label="Xoá" onClick={() => edit("")}>
              <Close01 />
            </Button>
          )}
        </div>
        <div className="flex items-center justify-between">
          <Button variant="ghost" size="icon-sm" aria-label="Nói">
            <Microphone02 />
          </Button>
          <span className="text-[10px] leading-[1.3] font-medium text-text-note">
            {value.length.toLocaleString("vi-VN")} / {LIMIT.toLocaleString("vi-VN")}
          </span>
        </div>
      </div>

      <div className="flex flex-col gap-2 border-t border-border-default bg-secondary-disabled pt-3.5 pr-3 pb-3 pl-5">
        <p
          className="flex items-center gap-1.5 text-[10px] leading-[1.3] font-semibold text-text-note uppercase"
        >
          {translating && (
            <span
              aria-hidden
              className="size-3 shrink-0 animate-spin rounded-full border-2 border-border-strong border-t-transparent"
            />
          )}
          {LANGUAGE_NAMES[target]} · tự động dịch
        </p>
        <p
          role="status"
          className={`min-h-14 text-base leading-[1.4] ${output ? "text-text-strong" : "text-text-note"}`}
        >
          {output ?? (translating ? "Đang dịch…" : "Bản dịch sẽ hiện ở đây")}
        </p>
        <div className="flex h-8 justify-end">
          {output && (
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={copied ? "Đã sao chép" : "Sao chép"}
              onClick={copy}
            >
              <CopyRight />
            </Button>
          )}
        </div>
      </div>
    </div>
  );
}