DocDoeAI / src /components /study-chat /StudyComposer.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
7.16 kB
"use client";
import { useState } from "react";
import {
ArrowUp,
LoaderCircle,
} from "lucide-react";
import type { ApiDocument } from "@/lib/api/types";
import { cn } from "@/lib/utils";
import { ComposerPlusMenu } from "./ComposerPlusMenu";
import { ComposerSourceDraft } from "./ComposerSourceDraft";
import { SourcePill } from "./SourceControls";
import type { SourceDraftMode } from "./types";
export function StudyComposer({
value,
onChange,
onSubmit,
placeholder,
disabled,
selectedDocuments,
onRemoveSource,
onMaterialFile,
onOpenMaterialPicker,
onOpenPaperPicker,
sourceDraftMode,
onSourceDraftModeChange,
draftTitle,
onDraftTitleChange,
draftText,
onDraftTextChange,
onSaveDraftSource,
isSavingDraft,
isUploading,
uploadNotice,
onRetryUpload,
plusOpen,
onPlusOpenChange,
compact = false,
variant = "default",
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder: string;
disabled: boolean;
selectedDocuments: ApiDocument[];
onRemoveSource: (id: string) => void;
onMaterialFile: (file: File) => void;
onOpenMaterialPicker: () => void;
onOpenPaperPicker: () => void;
sourceDraftMode: SourceDraftMode;
onSourceDraftModeChange: (mode: SourceDraftMode) => void;
draftTitle: string;
onDraftTitleChange: (value: string) => void;
draftText: string;
onDraftTextChange: (value: string) => void;
onSaveDraftSource: () => void;
isSavingDraft: boolean;
isUploading: boolean;
uploadNotice: string | null;
onRetryUpload?: () => void;
plusOpen: boolean;
onPlusOpenChange: (open: boolean) => void;
compact?: boolean;
variant?: "default" | "hero";
}) {
const [dragging, setDragging] = useState(false);
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files?.[0];
if (file) onMaterialFile(file);
}
return (
<div
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
"kb-composer relative rounded-[1.35rem] border border-white/[0.095] bg-[#080808]/92 p-3 shadow-[0_18px_70px_rgba(0,0,0,0.38),inset_0_1px_0_rgba(255,255,255,0.04)] transition duration-200 focus-within:border-white/[0.22] focus-within:bg-[#0b0b0c] sm:p-4",
variant === "hero" && !compact
? "kb-composer-hero border-white/[0.11] p-3.5 shadow-[0_22px_80px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.05)] sm:p-5"
: "",
compact ? "rounded-[1.15rem] p-2.5 sm:p-3" : "",
dragging
? "border-white/[0.28] bg-white/[0.07] shadow-[0_24px_90px_rgba(0,0,0,0.5)]"
: "",
)}
>
{dragging ? (
<div className="pointer-events-none absolute inset-2 z-10 grid place-items-center rounded-[1.05rem] border border-dashed border-white/[0.22] bg-black/72 text-sm font-semibold text-white/72">
Drop your study source
</div>
) : null}
{selectedDocuments.length > 0 ? (
<div className="mb-2 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<span className="text-[9px] self-center px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 border border-emerald-400/20">grounded to {selectedDocuments.length} source(s) • multi-turn</span>
{selectedDocuments.map((document) => (
<SourcePill
key={document.id}
document={document}
active
onToggle={() => onRemoveSource(document.id)}
onRemove={() => onRemoveSource(document.id)}
/>
))}
</div>
) : null}
{sourceDraftMode ? (
<ComposerSourceDraft
sourceDraftMode={sourceDraftMode}
draftTitle={draftTitle}
onDraftTitleChange={onDraftTitleChange}
draftText={draftText}
onDraftTextChange={onDraftTextChange}
onSaveDraftSource={onSaveDraftSource}
onClose={() => onSourceDraftModeChange(null)}
isSavingDraft={isSavingDraft}
/>
) : null}
<label className="sr-only" htmlFor={compact ? "docdoe-study-composer-sticky" : "docdoe-study-composer"}>
Ask DocDoe
</label>
<textarea
id={compact ? "docdoe-study-composer-sticky" : "docdoe-study-composer"}
aria-label="Ask DocDoe"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
rows={compact ? 1 : 2}
className={cn(
"w-full resize-none border-0 bg-transparent px-1 text-[16px] leading-7 text-white outline-none placeholder:text-white/34 focus:border-0 focus:shadow-none focus:ring-0",
compact ? "min-h-[3rem]" : variant === "hero" ? "min-h-[5.5rem] sm:min-h-[6.25rem]" : "min-h-[5.15rem] sm:min-h-[5.7rem]",
)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit();
}
}}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ComposerPlusMenu
plusOpen={plusOpen}
onPlusOpenChange={onPlusOpenChange}
onOpenMaterialPicker={onOpenMaterialPicker}
onOpenPaperPicker={onOpenPaperPicker}
onSourceDraftModeChange={onSourceDraftModeChange}
/>
{uploadNotice ? (
<span className="ml-2 hidden text-xs text-white/42 sm:inline">{uploadNotice}</span>
) : null}
</div>
<div className="flex items-center gap-2">
{onRetryUpload ? (
<button
type="button"
onClick={onRetryUpload}
className="inline-flex h-9 items-center rounded-full border border-white/[0.12] px-3 text-xs font-semibold text-white/64 transition hover:border-white/24 hover:text-white"
>
Retry
</button>
) : null}
<button
type="button"
disabled={disabled || isUploading || !value.trim()}
onClick={onSubmit}
aria-label="Send message"
aria-busy={disabled || isUploading}
className="inline-flex size-10 items-center justify-center rounded-full bg-white text-black shadow-[0_8px_24px_rgba(255,255,255,0.1)] transition hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-40"
>
{disabled || isUploading ? <LoaderCircle className="size-3.5 animate-spin" /> : <ArrowUp className="size-4" />}
</button>
</div>
</div>
{uploadNotice ? <p className="mt-1.5 text-xs leading-5 text-white/42 sm:hidden">{uploadNotice}</p> : null}
{!compact ? (
<p className="mt-2 hidden text-[11px] text-white/28 sm:block">Enter to send · Shift+Enter for a new line</p>
) : null}
</div>
);
}