Check e.nativeEvent.isComposing and keyCode 229 in addition to React state, fixing a race condition where compositionend fires before keydown on some browsers (Chrome/macOS). Fixes #20
435 lines
18 KiB
TypeScript
435 lines
18 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff, Reply } from 'lucide-react';
|
|
import { useT } from '../hooks/useLocale';
|
|
import { useSendShortcut } from '../hooks/useSendShortcut';
|
|
import { SlashCommandMenu } from './SlashCommands';
|
|
import { shouldShowSlashMenu } from '../lib/slashUtils';
|
|
import { LazyMarkdown } from './LazyMarkdown';
|
|
|
|
interface FileAttachment {
|
|
id: string;
|
|
file: File;
|
|
base64: string; // raw base64 (no data: prefix)
|
|
mimeType: string;
|
|
preview?: string; // data url thumbnail for images
|
|
}
|
|
|
|
export interface ReplyContext {
|
|
preview: string;
|
|
}
|
|
|
|
interface Props {
|
|
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
|
onNewSession?: () => Promise<void>;
|
|
onAbort: () => void;
|
|
isGenerating: boolean;
|
|
disabled: boolean;
|
|
sessionKey?: string;
|
|
replyTo?: ReplyContext | null;
|
|
onCancelReply?: () => void;
|
|
}
|
|
|
|
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
|
const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize
|
|
|
|
function fileToBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
const base64 = dataUrl.split(',')[1] || '';
|
|
resolve(base64);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
let { width, height } = img;
|
|
// Downscale if needed
|
|
if (width > MAX_IMAGE_PIXELS || height > MAX_IMAGE_PIXELS) {
|
|
const ratio = Math.min(MAX_IMAGE_PIXELS / width, MAX_IMAGE_PIXELS / height);
|
|
width = Math.round(width * ratio);
|
|
height = Math.round(height * ratio);
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d')!;
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Try JPEG at decreasing quality until base64 length is under limit
|
|
for (let q = 0.85; q >= 0.2; q -= 0.05) {
|
|
const dataUrl = canvas.toDataURL('image/jpeg', q);
|
|
const b64 = dataUrl.split(',')[1] || '';
|
|
if (b64.length <= maxBase64Chars) {
|
|
return resolve({ base64: b64, mimeType: 'image/jpeg' });
|
|
}
|
|
}
|
|
// Last resort: further downscale
|
|
const scale = 0.5;
|
|
canvas.width = Math.round(width * scale);
|
|
canvas.height = Math.round(height * scale);
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.3);
|
|
resolve({ base64: dataUrl.split(',')[1] || '', mimeType: 'image/jpeg' });
|
|
};
|
|
img.onerror = reject;
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
}
|
|
|
|
export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) {
|
|
const t = useT();
|
|
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
|
const [text, setText] = useState('');
|
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1');
|
|
const [isComposing, setIsComposing] = useState(false);
|
|
const [showSlash, setShowSlash] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Per-session draft storage
|
|
const draftsRef = useRef<Map<string, string>>(new Map());
|
|
const prevSessionRef = useRef<string | undefined>(sessionKey);
|
|
|
|
// Save draft to previous session and restore draft for new session
|
|
useEffect(() => {
|
|
const prev = prevSessionRef.current;
|
|
// Save current text as draft for the previous session
|
|
if (prev && prev !== sessionKey) {
|
|
const currentText = textareaRef.current?.value ?? text;
|
|
if (currentText.trim()) {
|
|
draftsRef.current.set(prev, currentText);
|
|
} else {
|
|
draftsRef.current.delete(prev);
|
|
}
|
|
}
|
|
// Restore draft for the new session
|
|
const draft = sessionKey ? draftsRef.current.get(sessionKey) ?? '' : '';
|
|
setText(draft); // eslint-disable-line react-hooks/set-state-in-effect -- intentional: restore draft on session switch
|
|
prevSessionRef.current = sessionKey;
|
|
}, [sessionKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px';
|
|
}
|
|
}, [text]);
|
|
|
|
// Auto-focus textarea when session changes or connection becomes active
|
|
useEffect(() => {
|
|
if (!disabled && textareaRef.current) {
|
|
// Small delay to let the DOM settle after session switch
|
|
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [sessionKey, disabled]);
|
|
|
|
const addFiles = useCallback(async (fileList: FileList | File[]) => {
|
|
const newFiles: FileAttachment[] = [];
|
|
for (const file of Array.from(fileList)) {
|
|
if (file.size > 20 * 1024 * 1024) continue; // 20MB max
|
|
const isImage = file.type.startsWith('image/');
|
|
let base64: string;
|
|
let mimeType: string;
|
|
if (isImage) {
|
|
// Compress images to fit WS payload limit
|
|
const compressed = await compressImage(file, MAX_BASE64_CHARS);
|
|
base64 = compressed.base64;
|
|
mimeType = compressed.mimeType;
|
|
} else {
|
|
base64 = await fileToBase64(file);
|
|
mimeType = file.type || 'application/octet-stream';
|
|
}
|
|
newFiles.push({
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
file,
|
|
base64,
|
|
mimeType,
|
|
preview: isImage ? `data:${mimeType};base64,${base64}` : undefined,
|
|
});
|
|
}
|
|
setFiles(prev => [...prev, ...newFiles]);
|
|
}, []);
|
|
|
|
const removeFile = useCallback((id: string) => {
|
|
setFiles(prev => prev.filter(f => f.id !== id));
|
|
}, []);
|
|
|
|
const handleSubmit = () => {
|
|
const trimmed = text.trim();
|
|
if ((!trimmed && files.length === 0) || disabled) return;
|
|
|
|
if ((trimmed === '/new' || trimmed.startsWith('/new ')) && onNewSession) {
|
|
void onNewSession();
|
|
setText('');
|
|
setFiles([]);
|
|
setShowSlash(false);
|
|
onCancelReply?.();
|
|
if (sessionKey) draftsRef.current.delete(sessionKey);
|
|
return;
|
|
}
|
|
|
|
const attachments = files.length > 0 ? files.map(f => ({
|
|
mimeType: f.mimeType,
|
|
fileName: f.file.name,
|
|
content: f.base64,
|
|
})) : undefined;
|
|
// Prepend quote if replying
|
|
let finalText = trimmed || ' ';
|
|
if (replyTo?.preview) {
|
|
const quoteLine = replyTo.preview.split('\n')[0].slice(0, 80);
|
|
finalText = `> ${quoteLine}\n\n${finalText}`;
|
|
}
|
|
onSend(finalText, attachments);
|
|
setText('');
|
|
setFiles([]);
|
|
setShowSlash(false);
|
|
onCancelReply?.();
|
|
// Clear draft for this session after sending
|
|
if (sessionKey) draftsRef.current.delete(sessionKey);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
// Prevent sending when IME is composing (e.g., Chinese/Japanese input)
|
|
// Check both React state and native event property — on some browsers
|
|
// compositionend fires before keydown, making isComposing stale
|
|
if (isComposing || e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
if (sendOnEnter) {
|
|
// Enter sends, Shift+Enter for newline
|
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
} else {
|
|
// Ctrl+Enter (or Cmd+Enter on Mac) sends, Enter for newline
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
const pastedFiles: File[] = [];
|
|
for (const item of Array.from(items)) {
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile();
|
|
if (file) pastedFiles.push(file);
|
|
}
|
|
}
|
|
if (pastedFiles.length > 0) {
|
|
e.preventDefault();
|
|
addFiles(pastedFiles);
|
|
}
|
|
}, [addFiles]);
|
|
|
|
// Drag & drop handlers on the wrapper
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(true);
|
|
}, []);
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
}, []);
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
if (e.dataTransfer.files.length > 0) {
|
|
addFiles(e.dataTransfer.files);
|
|
}
|
|
}, [addFiles]);
|
|
|
|
return (
|
|
<div
|
|
className="border-t border-pc-border bg-[var(--pc-bg-input)]/60 backdrop-blur-xl p-4 print-hide"
|
|
role="form"
|
|
aria-label={t('chat.inputLabel')}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className={`relative rounded-3xl border bg-[var(--pc-bg-surface)]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-glow)]' : 'border-pc-border'}`}>
|
|
<SlashCommandMenu
|
|
query={text}
|
|
visible={showSlash}
|
|
onSelect={(cmd) => {
|
|
// Commands without args (no trailing space) should submit immediately
|
|
if (!cmd.endsWith(' ')) {
|
|
setText(cmd);
|
|
setShowSlash(false);
|
|
// Submit directly — need to call onSend/onNewSession inline since setState is async
|
|
if ((cmd === '/new') && onNewSession) {
|
|
void onNewSession();
|
|
setText('');
|
|
setFiles([]);
|
|
onCancelReply?.();
|
|
if (sessionKey) draftsRef.current.delete(sessionKey);
|
|
} else {
|
|
// For other no-arg commands (e.g. /status, /help), send as text
|
|
const finalText = replyTo?.preview
|
|
? `> ${replyTo.preview.split('\n')[0].slice(0, 80)}\n\n${cmd}`
|
|
: cmd;
|
|
onSend(finalText);
|
|
setText('');
|
|
setFiles([]);
|
|
onCancelReply?.();
|
|
if (sessionKey) draftsRef.current.delete(sessionKey);
|
|
}
|
|
} else {
|
|
setText(cmd);
|
|
setShowSlash(shouldShowSlashMenu(cmd));
|
|
textareaRef.current?.focus();
|
|
}
|
|
}}
|
|
onClose={() => setShowSlash(false)}
|
|
/>
|
|
{/* Reply context banner */}
|
|
{replyTo && (
|
|
<div className="flex items-center gap-2 mb-2 px-1 py-2 rounded-xl border-l-2 border-[var(--pc-accent)] bg-[rgba(var(--pc-accent-rgb),0.06)]">
|
|
<Reply size={14} className="shrink-0 text-pc-accent-light ml-2" />
|
|
<span className="text-xs text-pc-text-secondary truncate flex-1">{replyTo.preview || '…'}</span>
|
|
<button
|
|
onClick={onCancelReply}
|
|
className="shrink-0 h-5 w-5 rounded-md flex items-center justify-center text-pc-text-muted hover:text-pc-text-secondary transition-colors mr-1"
|
|
aria-label="Cancel reply"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* File previews */}
|
|
{files.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-3 px-1">
|
|
{files.map(f => (
|
|
<div key={f.id} className="group relative flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/50 px-3 py-2 text-xs text-pc-text-secondary">
|
|
{f.preview ? (
|
|
<img src={f.preview} alt="" className="h-8 w-8 rounded-lg object-cover" />
|
|
) : (
|
|
<FileText size={16} className="text-pc-text-muted shrink-0" />
|
|
)}
|
|
<div className="min-w-0 max-w-[120px]">
|
|
<div className="truncate text-pc-text">{f.file.name}</div>
|
|
<div className="text-[10px] text-pc-text-muted">{formatSize(f.file.size)}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => removeFile(f.id)}
|
|
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-pc-elevated border border-pc-border-strong flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80"
|
|
aria-label={`Remove ${f.file.name}`}
|
|
>
|
|
<X size={10} className="text-pc-text" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Markdown preview */}
|
|
{showPreview && text.trim() && (
|
|
<div className="mb-3 px-1 max-h-[200px] overflow-y-auto rounded-2xl border border-pc-border bg-pc-elevated/30 p-3 text-sm text-pc-text prose prose-invert prose-sm max-w-none [&_pre]:bg-pc-elevated [&_pre]:rounded-lg [&_pre]:p-2 [&_code]:text-cyan-300 [&_a]:text-cyan-400">
|
|
<LazyMarkdown>{text}</LazyMarkdown>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-3">
|
|
{/* File picker button */}
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={disabled}
|
|
className="shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)] transition-colors disabled:opacity-30"
|
|
title={t('chat.attachFile')}
|
|
aria-label={t('chat.attachFile')}
|
|
>
|
|
<Paperclip size={18} />
|
|
</button>
|
|
{/* Markdown preview toggle — hidden on mobile */}
|
|
<button
|
|
onClick={() => setShowPreview(v => { const next = !v; localStorage.setItem('pinchchat-md-preview', next ? '1' : '0'); return next; })}
|
|
className={`hidden sm:flex shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 items-center justify-center transition-colors ${showPreview ? 'text-pc-accent-light bg-[var(--pc-accent-glow)]' : 'text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)]'}`}
|
|
title={showPreview ? t('chat.hidePreview') : t('chat.showPreview')}
|
|
aria-label={showPreview ? t('chat.hidePreview') : t('chat.showPreview')}
|
|
>
|
|
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
|
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
|
|
/>
|
|
|
|
<textarea
|
|
id="chat-input"
|
|
ref={textareaRef}
|
|
value={text}
|
|
onChange={(e) => { setText(e.target.value); setShowSlash(shouldShowSlashMenu(e.target.value)); }}
|
|
onKeyDown={handleKeyDown}
|
|
onCompositionStart={() => setIsComposing(true)}
|
|
onCompositionEnd={() => setIsComposing(false)}
|
|
onPaste={handlePaste}
|
|
placeholder={t('chat.inputPlaceholder')}
|
|
aria-label={t('chat.inputLabel')}
|
|
disabled={disabled}
|
|
rows={1}
|
|
className="flex-1 bg-transparent resize-none rounded-2xl border border-pc-border bg-pc-input/35 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-muted outline-none transition-all max-h-[200px]"
|
|
/>
|
|
{/* Send shortcut toggle */}
|
|
<button
|
|
onClick={toggleSendShortcut}
|
|
className="hidden sm:flex shrink-0 h-7 rounded-xl border border-pc-border bg-pc-elevated/30 px-2 items-center gap-1 text-[10px] text-pc-text-muted hover:text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
|
|
title={`${t('settings.sendShortcut')}: ${sendOnEnter ? t('settings.sendEnter') : t('settings.sendCtrlEnter')}`}
|
|
aria-label={`${t('settings.sendShortcut')}: ${sendOnEnter ? t('settings.sendEnter') : t('settings.sendCtrlEnter')}`}
|
|
>
|
|
<span>{sendOnEnter ? '↵' : (navigator.userAgent.includes('Mac') ? '⌘↵' : 'Ctrl↵')}</span>
|
|
</button>
|
|
{isGenerating ? (
|
|
<button
|
|
onClick={onAbort}
|
|
className="shrink-0 h-11 px-4 rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors flex items-center gap-2"
|
|
aria-label={t('chat.stop')}
|
|
>
|
|
<Square size={16} />
|
|
<span className="text-sm hidden sm:inline">{t('chat.stop')}</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={(!text.trim() && files.length === 0) || disabled}
|
|
aria-label={t('chat.send')}
|
|
className="shrink-0 h-11 px-5 rounded-2xl bg-[var(--pc-accent)] text-white font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(var(--pc-accent-rgb),0.15)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
|
|
>
|
|
<Send size={16} />
|
|
<span className="hidden sm:inline">{t('chat.send')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|