hmc-rag / frontend /src /components /ChatInput.tsx
webmuppet
chat: bottom input matches hero — floating card, text-xl, Send label
ac3c645
Raw
History Blame Contribute Delete
7.74 kB
/**
* 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;