feat(ui): adopt vercel-style chat composer for playground

Replace the compact single-line input with a PromptInput-style composer:
taller auto-growing textarea, rounded card shell, footer tools, and
stop button while a request is in flight
This commit is contained in:
mubashir1osmani 2026-08-06 14:42:46 -07:00
parent d635f44683
commit 74b400d5be
2 changed files with 219 additions and 105 deletions

View file

@ -0,0 +1,184 @@
import React, { useEffect, useRef } from "react";
import { ArrowUp, Code2, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
interface ChatComposerProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel?: () => void;
placeholder: string;
disabled?: boolean;
isLoading?: boolean;
submitDisabled?: boolean;
tools?: React.ReactNode;
body?: React.ReactNode;
suggestions?: string[];
showSuggestions?: boolean;
onSuggestionSelect?: (suggestion: string) => void;
className?: string;
}
export function ChatComposer({
value,
onChange,
onSubmit,
onCancel,
placeholder,
disabled = false,
isLoading = false,
submitDisabled = false,
tools,
body,
suggestions = [],
showSuggestions = false,
onSuggestionSelect,
className,
}: ChatComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = textareaRef.current;
if (!el || body) {
return;
}
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, 192)}px`;
}, [value, body]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
if (!submitDisabled && !isLoading) {
onSubmit();
}
}
};
return (
<div className={cn("relative flex w-full flex-col gap-3", className)}>
{showSuggestions && suggestions.length > 0 && (
<div
className="flex w-full gap-2 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
data-testid="chat-suggested-actions"
>
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="min-w-[200px] shrink-0 rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground sm:min-w-0 sm:whitespace-normal sm:p-4 sm:text-[13px]"
onClick={() => onSuggestionSelect?.(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
if (!submitDisabled && !isLoading) {
onSubmit();
}
}}
>
<InputGroup
className={cn(
"h-auto min-h-[7.5rem] flex-col overflow-hidden rounded-2xl border border-border/40 bg-card shadow-sm",
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30",
)}
>
{body ? (
<div className="max-h-48 min-h-24 w-full overflow-y-auto px-3 pt-3">{body}</div>
) : (
<InputGroupTextarea
ref={textareaRef}
data-testid="chat-composer-input"
value={value}
disabled={disabled}
placeholder={placeholder}
rows={1}
className="min-h-24 max-h-48 resize-none border-0 bg-transparent px-4 pt-3.5 pb-1.5 text-[13px] leading-relaxed shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0"
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
/>
)}
<InputGroupAddon align="block-end" className="justify-between gap-2 px-3 pb-3 pt-1">
<div className="flex min-w-0 items-center gap-1">{tools}</div>
{isLoading && onCancel ? (
<InputGroupButton
type="button"
size="icon-sm"
aria-label="Stop request"
data-testid="chat-stop-button"
className="size-8 rounded-xl bg-foreground text-background hover:bg-foreground/90"
onClick={onCancel}
>
<Square className="size-3.5 fill-current" />
</InputGroupButton>
) : (
<InputGroupButton
type="submit"
size="icon-sm"
aria-label="Send message"
data-testid="chat-send-button"
disabled={submitDisabled || isLoading}
className={cn(
"size-8 rounded-xl transition-all duration-200",
!submitDisabled && !isLoading
? "bg-foreground text-background hover:opacity-90 active:scale-95"
: "cursor-not-allowed bg-muted text-muted-foreground/40",
)}
>
<ArrowUp className="size-4" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</form>
</div>
);
}
interface CodeInterpreterToggleProps {
enabled: boolean;
onToggle: () => void;
}
export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
"size-8 rounded-lg border border-border/40",
enabled
? "border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100"
: "text-muted-foreground hover:text-foreground",
)}
aria-label={enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
onClick={onToggle}
/>
}
>
<Code2 className="size-4" />
</TooltipTrigger>
<TooltipContent>
{enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
</TooltipContent>
</Tooltip>
);
}
export default ChatComposer;

View file

@ -1,7 +1,6 @@
"use client";
import {
ArrowUp,
Bot,
Code2,
Database,
@ -47,6 +46,7 @@ import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api
import { makeInteractionsRequest } from "../../llm_calls/interactions_api";
import AdditionalModelSettings from "./AdditionalModelSettings";
import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants";
import ChatComposer, { CodeInterpreterToggle } from "./ChatComposer";
import ChatImageUpload from "./ChatImageUpload";
import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils";
import CodeInterpreterTool from "./CodeInterpreterTool";
@ -72,7 +72,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import {
@ -506,14 +505,6 @@ const ChatUI: React.FC<ChatUIProps> = ({
}
}, [chatHistory]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); // Prevent default to avoid newline
handleSendMessage();
}
// If Shift+Enter is pressed, the default behavior (inserting a newline) will occur
};
const handleCancelRequest = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@ -1968,27 +1959,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
</div>
)}
{chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP && (
<div className="mb-3 flex items-center gap-2 overflow-x-auto">
{(endpointType === EndpointType.A2A_AGENTS
<ChatComposer
value={inputMessage}
onChange={setInputMessage}
onSubmit={handleSendMessage}
onCancel={handleCancelRequest}
placeholder={inputPlaceholder}
disabled={isLoading}
isLoading={isLoading}
submitDisabled={sendDisabled}
showSuggestions={chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP}
suggestions={
endpointType === EndpointType.A2A_AGENTS
? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"]
: ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"]
).map((prompt) => (
<button
key={prompt}
type="button"
className="shrink-0 cursor-pointer rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setInputMessage(prompt)} // lgtm[js/xss-through-dom]
>
{prompt}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<div className="flex min-h-[44px] flex-1 items-center rounded-xl border border-gray-300 bg-white px-3 py-1">
<div className="mr-2 flex shrink-0 items-center gap-1">
}
onSuggestionSelect={setInputMessage}
tools={
<>
{endpointType === EndpointType.RESPONSES && !responsesUploadedImage && (
<ResponsesImageUpload
responsesUploadedImage={responsesUploadedImage}
@ -2006,47 +1994,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
/>
)}
{endpointType === EndpointType.RESPONSES && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${
codeInterpreter.enabled
? "bg-blue-100 text-blue-600"
: "text-gray-400 hover:bg-gray-100 hover:text-gray-600"
}`}
aria-label={
codeInterpreter.enabled
? "Code Interpreter enabled (click to disable)"
: "Enable Code Interpreter"
}
onClick={() => {
codeInterpreter.toggle();
if (!codeInterpreter.enabled) {
NotificationsManager.success("Code Interpreter enabled!");
}
}}
/>
<CodeInterpreterToggle
enabled={codeInterpreter.enabled}
onToggle={() => {
codeInterpreter.toggle();
if (!codeInterpreter.enabled) {
NotificationsManager.success("Code Interpreter enabled!");
}
>
<Code2 className="size-4" />
</TooltipTrigger>
<TooltipContent>
{codeInterpreter.enabled
? "Code Interpreter enabled (click to disable)"
: "Enable Code Interpreter"}
</TooltipContent>
</Tooltip>
}}
/>
)}
</div>
{endpointType === EndpointType.MCP &&
</>
}
body={
endpointType === EndpointType.MCP &&
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" &&
selectedMCPDirectTool ? (
<div className="max-h-48 min-h-[44px] flex-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50/50 p-2">
{(() => {
selectedMCPDirectTool
? (() => {
const rawSel = selectedMCPServers[0];
let toolPool: { name: string }[] = [];
if (rawSel.startsWith("toolset:")) {
@ -2069,45 +2034,10 @@ const ChatUI: React.FC<ChatUIProps> = ({
Loading tool schema...
</div>
);
})()}
</div>
) : (
<Textarea
value={inputMessage}
onChange={(event) => setInputMessage(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={inputPlaceholder}
disabled={isLoading}
rows={1}
className="min-h-0 flex-1 resize-none border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0"
/>
)}
<Button
type="button"
size="icon-sm"
onClick={handleSendMessage}
disabled={sendDisabled}
className="ml-2 size-8 shrink-0 rounded-full bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 disabled:text-gray-500"
aria-label="Send message"
>
<ArrowUp className="size-3.5" />
</Button>
</div>
{isLoading && (
<Button
type="button"
variant="outline"
size="sm"
className="border-red-200 bg-red-50 text-red-600 hover:bg-red-100"
onClick={handleCancelRequest}
>
<Trash2 className="size-3.5" />
Cancel
</Button>
)}
</div>
})()
: undefined
}
/>
</div>
</>
)}