Spaces:
Runtime error
Runtime error
| /** | |
| * ChatInput — fixed-bottom input bar for submitting chat queries. | |
| * | |
| * - Positioned at the bottom of the main content area | |
| * - Input field + submit button, both disabled while streaming | |
| * - "Download conversation" button visible when messages exist | |
| * - On submit: fires onSubmit(query) callback and clears the input | |
| * | |
| * Requirements: 4.1, 4.6, 11.1 | |
| */ | |
| import { useState, type FormEvent, type KeyboardEvent } from "react"; | |
| export interface ChatInputProps { | |
| onSubmit: (query: string) => void; | |
| isStreaming: boolean; | |
| onStop: () => void; | |
| onExportConversation: () => void; | |
| hasMessages: boolean; | |
| /** | |
| * "hero" — floating centred card on the empty-state screen (no sticky) | |
| * "bottom" — sticky footer bar once a conversation is in progress (default) | |
| */ | |
| variant?: "hero" | "bottom"; | |
| } | |
| /** | |
| * Renders the fixed-bottom chat input bar. | |
| * | |
| * The input and submit button are disabled while `isStreaming` is true | |
| * (Requirement 4.6). The "Download conversation" button is only visible | |
| * when `hasMessages` is true (Requirement 11.1). On form submission, | |
| * the component calls `onSubmit` with the current query text and clears | |
| * the input field. | |
| */ | |
| export function ChatInput({ | |
| onSubmit, | |
| isStreaming, | |
| onStop, | |
| onExportConversation, | |
| hasMessages, | |
| variant = "bottom", | |
| }: ChatInputProps) { | |
| const [query, setQuery] = useState(""); | |
| const canSubmit = query.trim().length > 0 && !isStreaming; | |
| function handleSubmit(e: FormEvent) { | |
| e.preventDefault(); | |
| if (!canSubmit) return; | |
| onSubmit(query.trim()); | |
| setQuery(""); | |
| } | |
| function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { | |
| // Submit on Enter without Shift (Shift+Enter inserts a newline) | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (canSubmit) { | |
| onSubmit(query.trim()); | |
| setQuery(""); | |
| } | |
| } | |
| } | |
| if (variant === "hero") { | |
| // Floating card — centred on the empty-state screen, no sticky positioning. | |
| // Larger textarea (rows + font size) matches the Claude.ai input prominence. | |
| return ( | |
| <div | |
| className="w-full rounded-2xl border border-white/10" | |
| style={{ backgroundColor: "rgba(255,255,255,0.08)" }} | |
| > | |
| <form onSubmit={handleSubmit} className="flex flex-col gap-0 px-5 pt-5 pb-4"> | |
| {/* Large textarea — the focal point of the empty screen */} | |
| <textarea | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| disabled={isStreaming} | |
| placeholder="Ask a compliance question..." | |
| rows={4} | |
| className="w-full resize-none border-0 bg-transparent text-xl text-white placeholder-white/40 outline-none focus:ring-0 disabled:opacity-50 disabled:cursor-not-allowed leading-relaxed" | |
| aria-label="Chat input" | |
| /> | |
| {/* Submit button row — right-aligned below the textarea */} | |
| <div className="flex justify-end pt-2"> | |
| <button | |
| type="submit" | |
| disabled={!canSubmit} | |
| className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-white/90 hover:text-white bg-white/10 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" | |
| title="Send message" | |
| aria-label="Send message" | |
| > | |
| <SendIcon /> | |
| <span>Send</span> | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| ); | |
| } | |
| // "bottom" variant — floating card anchored to bottom, matching hero style | |
| return ( | |
| <div className="sticky bottom-0 w-full px-4 pb-4 pt-2" style={{ background: "linear-gradient(to top, #0F0027 60%, transparent)" }}> | |
| <div | |
| className="mx-auto max-w-3xl rounded-2xl border border-white/10" | |
| style={{ backgroundColor: "rgba(255,255,255,0.08)" }} | |
| > | |
| <form onSubmit={handleSubmit} className="flex flex-col gap-0 px-5 pt-4 pb-3"> | |
| {/* Large textarea — same scale as hero input */} | |
| <textarea | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| disabled={isStreaming} | |
| placeholder={isStreaming ? "Waiting for response..." : "Ask a compliance question..."} | |
| rows={2} | |
| className="w-full resize-none border-0 bg-transparent text-xl text-white placeholder-white/40 outline-none focus:ring-0 disabled:opacity-50 disabled:cursor-not-allowed leading-relaxed" | |
| aria-label="Chat input" | |
| /> | |
| {/* Action row */} | |
| <div className="flex items-center justify-between pt-2"> | |
| {/* Download conversation button */} | |
| {hasMessages && ( | |
| <button | |
| type="button" | |
| onClick={onExportConversation} | |
| className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" | |
| title="Download conversation" | |
| aria-label="Download conversation" | |
| > | |
| <DownloadIcon /> | |
| </button> | |
| )} | |
| {!hasMessages && <div />} | |
| {/* Stop / Send */} | |
| {isStreaming ? ( | |
| <button | |
| type="button" | |
| onClick={onStop} | |
| className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-white/90 hover:text-white bg-white/10 hover:bg-white/20 transition-colors" | |
| title="Stop generating" | |
| aria-label="Stop generating" | |
| > | |
| <StopIcon /> | |
| <span>Stop</span> | |
| </button> | |
| ) : ( | |
| <button | |
| type="submit" | |
| disabled={!canSubmit} | |
| className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-white/90 hover:text-white bg-white/10 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" | |
| title="Send message" | |
| aria-label="Send message" | |
| > | |
| <SendIcon /> | |
| <span>Send</span> | |
| </button> | |
| )} | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Icon components (inline SVG to avoid external icon library dependency) | |
| // --------------------------------------------------------------------------- | |
| function DownloadIcon() { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| strokeWidth={1.5} | |
| stroke="currentColor" | |
| className="w-5 h-5" | |
| aria-hidden="true" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| function StopIcon() { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| viewBox="0 0 24 24" | |
| fill="currentColor" | |
| className="w-5 h-5" | |
| aria-hidden="true" | |
| > | |
| <rect x="6" y="6" width="12" height="12" rx="2" /> | |
| </svg> | |
| ); | |
| } | |
| function SendIcon() { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| strokeWidth={1.5} | |
| stroke="currentColor" | |
| className="w-5 h-5" | |
| aria-hidden="true" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| export default ChatInput; | |