roiui
itemai-chatsource: ok · hit
AI Composer
An AI chat input component with mode selector and action buttons.
npx shadcn add "https://roiui.com/r/ai-chat.json"free
Where it goes
chat
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
https://roiui.com/r/button.jsonhttps://roiui.com/r/card.jsonhttps://roiui.com/r/field.jsonhttps://roiui.com/r/form.jsonhttps://roiui.com/r/select.json
Files2 · source cached 10/9/2026, 5:31:57 AM
registry/brook/blocks/ai-chat/components/ai-chat.tsx
"use client";
import { ArrowUp, Paperclip } from "lucide-react";
import { useState } from "react";
import { Button } from "../../ui/button/button";
import { Card, CardContent, CardFooter } from "../../ui/card/card";
import { Field, FieldControl } from "../../ui/field/field";
import { Form } from "../../ui/form/form";
import {
Select,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectList,
SelectPopup,
SelectPortal,
SelectPositioner,
SelectSpacer,
SelectTrigger,
SelectValue,
} from "../../ui/select/select";
import styles from "./ai-chat.module.css";
const aiModes = [
{ value: "creative", label: "Creative" },
{ value: "fast", label: "Fast" },
{ value: "reasoning", label: "Reason" },
{ value: "teach", label: "Teach" },
];
export type AiChatProps = {
onSubmit?: (submission: { message: string; mode: string }) => void | Promise<void>;
onAttach?: () => void;
};
export function AiChat({ onSubmit, onAttach }: AiChatProps = {}) {
const [message, setMessage] = useState("");
const [isPending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasContent = message.trim().length > 0;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!hasContent || isPending) {
return;
}
const mode = String(new FormData(event.currentTarget).get("mode") ?? aiModes[0].value);
setPending(true);
setError(null);
try {
if (onSubmit) {
await onSubmit({ message: message.trim(), mode });
}
setMessage("");
} catch {
setError("Message could not be sent. Please try again.");
} finally {
setPending(false);
}
};
return (
<Form className={styles.form} onSubmit={handleSubmit}>
<Card className={styles.aiChatCard}>
<CardContent>
<Field className={styles.field}>
<FieldControl
placeholder="How can I help…"
render={
<textarea
aria-label="Message"
className={styles.textarea}
disabled={isPending}
name="message"
onChange={(e) => setMessage(e.target.value)}
value={message}
/>
}
/>
</Field>
</CardContent>
<CardFooter className={styles.footer}>
{onAttach ? (
<Button
aria-label="Attach file"
className={styles.attachButton}
disabled={isPending}
onClick={onAttach}
size="icon"
type="button"
variant="ghost"
>
<Paperclip size={14} />
</Button>
) : null}
<div className={styles.footerActions}>
<Select defaultValue={aiModes[0].value} disabled={isPending} items={aiModes} name="mode">
<SelectTrigger
className={styles.selectTrigger}
render={<Button className={styles.selectButton} size="sm" variant="ghost" />}
>
<SelectValue>
{(value) => {
const selectedMode = aiModes.find((mode) => mode.value === value);
return <span className={styles.selectValue}>{selectedMode?.label}</span>;
}}
</SelectValue>
<SelectIcon className={styles.selectIcon} />
</SelectTrigger>
<SelectPortal>
<SelectPositioner align="start" alignItemWithTrigger={false} side="top" sideOffset={8}>
<SelectPopup className={styles.popup} data-slot="select-popup">
<SelectSpacer />
<SelectList>
{aiModes.map(({ label, value }) => (
<SelectItem data-slot="select-item" key={value} value={value}>
<SelectItemText>{label}</SelectItemText>
<SelectItemIndicator className={styles.selectIndicator} />
</SelectItem>
))}
</SelectList>
<SelectSpacer />
</SelectPopup>
</SelectPositioner>
</SelectPortal>
</Select>
<Button
aria-label="Send message"
className={styles.submitButton}
disabled={isPending || !hasContent}
size="icon"
type="submit"
variant="ghost"
>
<ArrowUp className={styles.submitIcon} size={16} />
</Button>
</div>
</CardFooter>
{error ? <p role="alert">{error}</p> : null}
</Card>
</Form>
);
}
registry/brook/blocks/ai-chat/components/ai-chat.module.css
.aiChatCard[data-slot="card"] {
padding: 0.75rem;
gap: 0.75rem;
width: 100%;
height: auto;
max-width: 600px;
min-width: 0;
margin: 0 auto;
border-radius: var(--radius-lg);
border-color: oklch(from var(--border) l c h / 0.25);
background-color: var(--mix-card-50-bg);
transition: border-color 0.2s ease;
&:hover,
&:focus-within {
border-color: oklch(from var(--border) l c h / 0.5);
}
}
.form {
width: 100%;
}
.textarea {
background-color: transparent;
border: none;
padding: 0.25rem;
resize: none;
min-height: 2.5rem;
height: auto;
line-height: 1.5;
&:focus {
outline: none;
}
}
.attachButton[data-slot="button"] {
width: 32px;
height: 32px;
padding: 8px;
border-radius: 50%;
flex-shrink: 0;
& svg {
color: var(--muted-foreground);
flex-shrink: 0;
transform: rotate(-45deg);
}
}
.footerActions {
margin-left: auto;
display: flex;
gap: 0.5rem;
align-items: center;
}
.selectButton[data-slot="button"] {
border-radius: var(--radius);
}
.selectValue {
color: var(--muted-foreground);
}
.selectIcon {
margin-left: 0.25rem;
}
.selectIndicator {
color: var(--muted-foreground);
}
.submitButton[data-slot="button"] {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--primary);
flex-shrink: 0;
}
.submitButton[data-slot="button"]:hover:not([data-disabled]) {
background-color: oklch(from var(--primary) calc(l * 0.8) c h);
}
.submitIcon {
color: var(--primary-foreground);
}
.footer[data-slot="card-footer"] {
gap: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.selectButton:hover,
.selectButton[data-popup-open] {
background-color: var(--accent) !important;
}
.popup[data-slot="select-popup"] {
box-sizing: border-box;
min-width: 140px;
}
@media (max-width: 768px) {
.popup[data-slot="select-popup"] {
width: 120px !important;
min-width: 120px !important;
max-width: 120px !important;
}
.selectValue {
font-size: 0.875rem;
}
.selectIcon {
display: none;
}
}
@media (max-width: 1280px) {
.textarea {
min-height: 4rem;
padding: 0.5rem;
}
}
@media (max-width: 640px) {
.aiChatCard[data-slot="card"] {
min-width: 230px;
}
.form {
display: flex;
justify-content: center;
align-items: center;
}
}