35faeb0fb7
- pendingImage becomes pendingAttachment (image or PDF/TXT/MD/CSV), uploaded through POST /api/conversation/upload and sent as image_key or file_key. - MessageInput shows an icon/name/size chip for files, an inline error for unsupported or oversized files, and a paperclip picker. - DropOverlay covers the chat area with "Drop to attach" while files are dragged over the window; drags without files are ignored. - QuestionBubble renders a saved trailing "📎 name" as a chip. - test-fixtures/cowork/mock-server.mjs: dependency-free mock API (static dist/, conversations, contract-shaped SSE, uploads, artifacts) for Playwright runs without Docker or OIDC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
9.3 KiB
TypeScript
276 lines
9.3 KiB
TypeScript
import React, { useEffect, useMemo, useRef } from "react";
|
|
import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import {
|
|
ATTACHMENT_ACCEPT,
|
|
attachmentKind,
|
|
fileExtension,
|
|
formatBytes,
|
|
} from "../lib/attachments";
|
|
import { Textarea } from "./ui/textarea";
|
|
|
|
type MessageInputProps = {
|
|
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
handleKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
handleQuestionSubmit: () => void;
|
|
simbaMode: boolean;
|
|
onToggleSimbaMode: () => void;
|
|
query: string;
|
|
isLoading: boolean;
|
|
onStop: () => void;
|
|
pendingAttachment: File | null;
|
|
onAttach: (file: File) => void;
|
|
onClearAttachment: () => void;
|
|
attachmentError: string | null;
|
|
onDismissAttachmentError: () => void;
|
|
autoFocus?: boolean;
|
|
};
|
|
|
|
export const MessageInput = React.memo(
|
|
({
|
|
query,
|
|
handleKeyDown,
|
|
handleQueryChange,
|
|
handleQuestionSubmit,
|
|
simbaMode,
|
|
onToggleSimbaMode,
|
|
isLoading,
|
|
onStop,
|
|
pendingAttachment,
|
|
onAttach,
|
|
onClearAttachment,
|
|
attachmentError,
|
|
onDismissAttachmentError,
|
|
autoFocus,
|
|
}: MessageInputProps) => {
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const pendingImage =
|
|
pendingAttachment && attachmentKind(pendingAttachment) === "image"
|
|
? pendingAttachment
|
|
: null;
|
|
const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null;
|
|
|
|
// Create blob URL once per image, revoke on cleanup
|
|
const previewUrl = useMemo(
|
|
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
|
[pendingImage],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
};
|
|
}, [previewUrl]);
|
|
|
|
// Auto-grow the textarea to fit its content (capped by max-height in CSS).
|
|
useEffect(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${el.scrollHeight}px`;
|
|
}, [query]);
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
onAttach(file);
|
|
}
|
|
// Reset so the same file can be re-selected
|
|
e.target.value = "";
|
|
};
|
|
|
|
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
if (isLoading) return;
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
for (const item of Array.from(items)) {
|
|
if (item.type.startsWith("image/")) {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
e.preventDefault();
|
|
onAttach(file);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const canSend = !isLoading && Boolean(query.trim() || pendingAttachment);
|
|
const FileIcon =
|
|
pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
|
|
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
|
|
"focus-within:border-amber-soft/60",
|
|
)}
|
|
>
|
|
{/* Image preview */}
|
|
{pendingImage && (
|
|
<div className="px-3 pt-3">
|
|
<div className="relative inline-block">
|
|
<img
|
|
src={previewUrl!}
|
|
alt="Pending upload"
|
|
className="h-20 rounded-lg object-cover border border-sand"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onClearAttachment}
|
|
aria-label="Remove attachment"
|
|
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File chip */}
|
|
{pendingFile && (
|
|
<div className="px-3 pt-3">
|
|
<div
|
|
className="relative inline-flex items-center gap-2.5 max-w-full rounded-xl border border-sand bg-cream pl-2 pr-7 py-2 message-enter"
|
|
data-testid="attachment-chip"
|
|
>
|
|
<div className="w-8 h-8 shrink-0 rounded-lg bg-amber-pale text-amber-dark flex items-center justify-center">
|
|
<FileIcon size={16} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium text-charcoal truncate max-w-[14rem] sm:max-w-xs">
|
|
{pendingFile.name}
|
|
</div>
|
|
<div className="text-[11px] text-warm-gray">
|
|
{fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "}
|
|
{formatBytes(pendingFile.size)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClearAttachment}
|
|
aria-label="Remove attachment"
|
|
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full text-warm-gray flex items-center justify-center hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{attachmentError && (
|
|
<div className="px-3 pt-3">
|
|
<div
|
|
role="alert"
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600 message-enter"
|
|
>
|
|
<span>{attachmentError}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onDismissAttachmentError}
|
|
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
|
|
aria-label="Dismiss error"
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Textarea */}
|
|
<Textarea
|
|
ref={textareaRef}
|
|
onChange={handleQueryChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
value={query}
|
|
rows={2}
|
|
placeholder="Ask Simba anything..."
|
|
className="min-h-[60px] max-h-40 overflow-y-auto"
|
|
autoFocus={autoFocus}
|
|
/>
|
|
|
|
{/* Hidden file input */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={ATTACHMENT_ACCEPT}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Bottom toolbar */}
|
|
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
|
|
<div className="flex items-center gap-3">
|
|
{/* Simba mode toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={onToggleSimbaMode}
|
|
className="flex items-center gap-2 group cursor-pointer select-none"
|
|
>
|
|
<div className={cn("toggle-track", simbaMode && "checked")}>
|
|
<div className="toggle-thumb" />
|
|
</div>
|
|
<span className="text-xs text-warm-gray group-hover:text-charcoal transition-colors">
|
|
simba mode
|
|
</span>
|
|
</button>
|
|
|
|
{/* Attach button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isLoading}
|
|
aria-label="Attach a file"
|
|
title="Attach an image, PDF, TXT, MD or CSV"
|
|
className={cn(
|
|
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
|
|
isLoading
|
|
? "text-warm-gray/40 cursor-not-allowed"
|
|
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
|
)}
|
|
>
|
|
<Paperclip size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Send / Stop button */}
|
|
{isLoading ? (
|
|
<button
|
|
type="button"
|
|
onClick={onStop}
|
|
aria-label="Stop generating"
|
|
className={cn(
|
|
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm",
|
|
"bg-charcoal text-white transition-all duration-200 cursor-pointer",
|
|
"hover:bg-charcoal/85 active:scale-95",
|
|
)}
|
|
>
|
|
<Square size={13} strokeWidth={2.5} fill="currentColor" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
onClick={handleQuestionSubmit}
|
|
disabled={!canSend}
|
|
aria-label="Send message"
|
|
className={cn(
|
|
"w-8 h-8 rounded-full flex items-center justify-center",
|
|
"transition-all duration-200 cursor-pointer shadow-sm",
|
|
!canSend
|
|
? "bg-sand text-warm-gray/50 cursor-not-allowed shadow-none"
|
|
: "bg-amber-glow text-white hover:bg-amber-dark hover:shadow-md hover:shadow-amber-glow/30 active:scale-95",
|
|
)}
|
|
>
|
|
<ArrowUp size={15} strokeWidth={2.5} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|