141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
import { Paperclip, Search, SendHorizontal } from "lucide-preact";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type MutableValueRef = {
|
|
current: string;
|
|
};
|
|
|
|
type Props = {
|
|
draftRef: MutableValueRef;
|
|
draftRevision: number;
|
|
error: string | null;
|
|
isSearchMode: boolean;
|
|
isSending: boolean;
|
|
pendingAttachmentCount: number;
|
|
attachmentButtonDisabled: boolean;
|
|
onOpenAttachmentPicker: () => void;
|
|
onPaste: (event: ClipboardEvent) => void;
|
|
onSend: (draft: string) => void | Promise<void>;
|
|
};
|
|
|
|
const MIRROR_SENTINEL = "\u200b";
|
|
const HAS_NON_WHITESPACE = /\S/;
|
|
|
|
export function ChatComposer({
|
|
draftRef,
|
|
draftRevision,
|
|
error,
|
|
isSearchMode,
|
|
isSending,
|
|
pendingAttachmentCount,
|
|
attachmentButtonDisabled,
|
|
onOpenAttachmentPicker,
|
|
onPaste,
|
|
onSend,
|
|
}: Props) {
|
|
// The draft stays in the DOM/ref so typing never schedules a render of the workspace transcript.
|
|
const textareaContainerRef = useRef<HTMLDivElement>(null);
|
|
const mirrorRef = useRef<HTMLDivElement>(null);
|
|
const [hasDraft, setHasDraft] = useState(() => HAS_NON_WHITESPACE.test(draftRef.current));
|
|
const hasDraftRef = useRef(hasDraft);
|
|
|
|
const getTextarea = () => textareaContainerRef.current?.querySelector("textarea") ?? null;
|
|
|
|
const updateMirror = (value: string) => {
|
|
// The overlapping mirror lets normal layout size the textarea without synchronous scrollHeight reads.
|
|
if (mirrorRef.current) mirrorRef.current.textContent = `${value}${MIRROR_SENTINEL}`;
|
|
};
|
|
|
|
const updateHasDraft = (value: string) => {
|
|
const nextHasDraft = HAS_NON_WHITESPACE.test(value);
|
|
if (nextHasDraft !== hasDraftRef.current) {
|
|
hasDraftRef.current = nextHasDraft;
|
|
setHasDraft(nextHasDraft);
|
|
}
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
const value = draftRef.current;
|
|
const textarea = getTextarea();
|
|
if (textarea && textarea.value !== value) {
|
|
textarea.value = value;
|
|
}
|
|
updateMirror(value);
|
|
updateHasDraft(value);
|
|
}, [draftRevision]);
|
|
|
|
const submit = () => {
|
|
const textarea = getTextarea();
|
|
const draft = textarea?.value ?? draftRef.current;
|
|
const canSend = HAS_NON_WHITESPACE.test(draft) || (!isSearchMode && pendingAttachmentCount > 0);
|
|
if (isSending || !canSend) return;
|
|
|
|
draftRef.current = "";
|
|
if (textarea) textarea.value = "";
|
|
updateMirror("");
|
|
updateHasDraft("");
|
|
void onSend(draft);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div ref={textareaContainerRef} className="grid max-h-40 min-h-0 overflow-hidden">
|
|
<div
|
|
ref={mirrorRef}
|
|
className="pointer-events-none invisible col-start-1 row-start-1 max-h-40 min-h-0 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-3 text-base"
|
|
aria-hidden="true"
|
|
/>
|
|
<Textarea
|
|
id="composer-input"
|
|
rows={1}
|
|
onInput={(event) => {
|
|
const value = event.currentTarget.value;
|
|
draftRef.current = value;
|
|
updateMirror(value);
|
|
updateHasDraft(value);
|
|
}}
|
|
onPaste={(event) => {
|
|
onPaste(event);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
|
event.preventDefault();
|
|
submit();
|
|
}
|
|
}}
|
|
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
|
|
className="col-start-1 row-start-1 h-full max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
|
disabled={isSending}
|
|
/>
|
|
</div>
|
|
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
|
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
|
{!isSearchMode ? (
|
|
<Button
|
|
className="h-10 w-10 rounded-lg"
|
|
onClick={onOpenAttachmentPicker}
|
|
size="icon"
|
|
variant="secondary"
|
|
disabled={attachmentButtonDisabled}
|
|
aria-label="Attach files"
|
|
>
|
|
<Paperclip className="h-4 w-4" />
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
className="h-10 w-10 rounded-lg"
|
|
onClick={submit}
|
|
size="icon"
|
|
disabled={isSending || (!hasDraft && (isSearchMode || pendingAttachmentCount === 0))}
|
|
aria-label={isSearchMode ? "Search" : "Send message"}
|
|
>
|
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|