-

Chat Composer

PreviousNext

A ZGI chat input panel with model selection, attachment area, submit, and stop controls.

Installation

pnpm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/chat-composer.json
npm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/chat-composer.json
yarn
yarn dlx shadcn@latest add https://ui.zgi.ai/r/chat-composer.json

Component

components/ui/chat-composer.tsx
"use client"

import * as React from "react"
import { PaperclipIcon, SendIcon, SquareIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"

export interface ChatComposerSubmitData {
  message: string
}

export interface ChatComposerProps
  extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  onSubmit?: (data: ChatComposerSubmitData) => void | Promise<void>
  onStop?: () => void
  placeholder?: string
  disabled?: boolean
  loading?: boolean
  attachments?: React.ReactNode
  leadingActions?: React.ReactNode
  trailingActions?: React.ReactNode
  modelSelector?: React.ReactNode
  submitLabel?: string
  stopLabel?: string
  helperText?: React.ReactNode
}

export function ChatComposer({
  value,
  defaultValue = "",
  onValueChange,
  onSubmit,
  onStop,
  placeholder = "Ask ZGI to review, transform, or run a workflow...",
  disabled = false,
  loading = false,
  attachments,
  leadingActions,
  trailingActions,
  modelSelector,
  submitLabel = "Send",
  stopLabel = "Stop",
  helperText,
  className,
  ...props
}: ChatComposerProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const message = value ?? internalValue
  const canSubmit = message.trim().length > 0 && !disabled && !loading

  function setMessage(nextValue: string) {
    onValueChange?.(nextValue)
    if (value === undefined) setInternalValue(nextValue)
  }

  async function submit() {
    if (!canSubmit) return
    await onSubmit?.({ message: message.trim() })
    if (value === undefined) setInternalValue("")
  }

  return (
    <div
      className={cn(
        "border-border bg-card text-card-foreground rounded-lg border p-3 shadow-sm",
        className
      )}
      {...props}
    >
      {attachments ? <div className="mb-3">{attachments}</div> : null}
      <Textarea
        value={message}
        onChange={(event) => setMessage(event.target.value)}
        onKeyDown={(event) => {
          if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault()
            submit()
          }
        }}
        placeholder={placeholder}
        disabled={disabled}
        className="min-h-24 resize-none border-0 bg-transparent p-0 shadow-none focus-visible:ring-0"
      />
      <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
        <div className="flex min-w-0 flex-wrap items-center gap-2">
          {modelSelector}
          {leadingActions}
          {helperText ? (
            <Badge variant="secondary" className="rounded-sm font-normal">
              {helperText}
            </Badge>
          ) : null}
        </div>
        <div className="flex shrink-0 items-center justify-end gap-2">
          {trailingActions}
          <Button
            type="button"
            variant="outline"
            size="icon"
            className="size-9 rounded-md"
            disabled={disabled || loading}
            aria-label="Attach file"
          >
            <PaperclipIcon className="size-4" />
          </Button>
          {loading ? (
            <Button
              type="button"
              variant="outline"
              className="h-9 rounded-md"
              onClick={onStop}
            >
              <SquareIcon className="size-4 fill-current" />
              {stopLabel}
            </Button>
          ) : (
            <Button
              type="button"
              className="h-9 rounded-md"
              disabled={!canSubmit}
              onClick={submit}
            >
              <SendIcon className="size-4" />
              {submitLabel}
            </Button>
          )}
        </div>
      </div>
    </div>
  )
}

Usage

import { ChatComposer } from "@/components/ui/chat-composer"
 
export function Example() {
  return (
    <ChatComposer
      onSubmit={({ message }) => {
        console.log(message)
      }}
    />
  )
}

Props

PropTypeDefault
valuestring-
defaultValuestring""
onValueChange(value: string) => void-
onSubmit({ message }) => void | Promise<void>-
onStop() => void-
loadingbooleanfalse
attachmentsReactNode-
modelSelectorReactNode-
helperTextReactNode-