- ChatInput: e.nativeEvent.isComposing early-return added (CJK IME safety — first Enter of a composition no longer submits). Bare-Enter send path gated by !isMobile so mobile inserts a newline; send is button-only. Cmd/Ctrl+Enter and Shift+Cmd/Ctrl+Enter retained as desktop secondary bindings. Placeholder is now viewport-aware. Outer wrapper gets paddingBottom: env(safe-area-inset-bottom) so iOS home indicator doesn't overlap. - MessageBubble: ActionRow buttons (Copy / Regenerate / Fork / Trash) bumped to max-md min-h/min-w 44px; opacity-100 on mobile so actions don't hide behind a hover-to-reveal pattern. User bubble and assistant content wrapper gain break-words + min-w-0 so long unbreakable strings (URLs / paths) wrap rather than blowing out the column on narrow viewports. - ChatPane: queued-message dropdown + close X + Stop-generating button hit max-md 44px sizing. - ChatTabBar: per-tab X, +/History/Close-pane action buttons hit max-md 44px. Tab close X is force-visible on mobile (no hover-to-reveal). - M8: CodeBlock / Markdown tables / ToolCallCard already wrap overflow-x-auto pre-existing — no source change needed there; the break-words + min-w-0 additions above are the new defensive layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
9.1 KiB
TypeScript
286 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||
import { Send } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Button } from '@/components/ui/button';
|
||
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
|
||
import { AttachmentChip } from '@/components/AttachmentChip';
|
||
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||
import { api } from '@/api/client';
|
||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||
import { useViewport } from '@/hooks/useViewport';
|
||
|
||
interface Props {
|
||
disabled?: boolean;
|
||
projectId: string;
|
||
onSend: (content: string) => void | Promise<void>;
|
||
onForceSend?: (content: string) => void | Promise<void>;
|
||
}
|
||
|
||
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||
const { isMobile } = useViewport();
|
||
const [value, setValue] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
||
const [mentionState, setMentionState] = useState<{
|
||
open: boolean;
|
||
query: string;
|
||
atIdx: number;
|
||
anchorRect: { top: number; left: number };
|
||
} | null>(null);
|
||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
|
||
function addAttachment(a: Attachment) {
|
||
setAttachments(prev => {
|
||
if (prev.length >= 10) {
|
||
toast.error('Max 10 attachments per message');
|
||
return prev;
|
||
}
|
||
return [...prev, a];
|
||
});
|
||
}
|
||
|
||
const addAttachmentRef = useRef(addAttachment);
|
||
addAttachmentRef.current = addAttachment;
|
||
|
||
useEffect(() => {
|
||
return sessionEvents.subscribe((event) => {
|
||
if (event.type !== 'attach_chat_file') return;
|
||
addAttachmentRef.current({
|
||
id: crypto.randomUUID(),
|
||
...event.attachment,
|
||
});
|
||
});
|
||
}, []);
|
||
|
||
function removeAttachment(id: string) {
|
||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||
}
|
||
|
||
async function submit() {
|
||
const text = value.trim();
|
||
if (!text && attachments.length === 0) return;
|
||
if (disabled || busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const body = flattenToMessage(attachments, text);
|
||
await onSend(body);
|
||
setValue('');
|
||
setAttachments([]);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
||
const mirror = document.createElement('div');
|
||
const style = window.getComputedStyle(textarea);
|
||
|
||
const properties = [
|
||
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
|
||
'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing',
|
||
'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
||
'boxSizing', 'whiteSpace', 'overflowWrap',
|
||
] as const;
|
||
|
||
mirror.style.position = 'absolute';
|
||
mirror.style.visibility = 'hidden';
|
||
mirror.style.overflow = 'hidden';
|
||
mirror.style.width = style.width;
|
||
for (const prop of properties) {
|
||
mirror.style[prop] = style[prop];
|
||
}
|
||
mirror.style.whiteSpace = 'pre-wrap';
|
||
mirror.style.overflowWrap = 'break-word';
|
||
|
||
const textBefore = textarea.value.slice(0, textarea.selectionStart);
|
||
mirror.textContent = textBefore;
|
||
|
||
const span = document.createElement('span');
|
||
span.textContent = ''; // zero-width space
|
||
mirror.appendChild(span);
|
||
|
||
document.body.appendChild(mirror);
|
||
|
||
const taRect = textarea.getBoundingClientRect();
|
||
const spanRect = span.getBoundingClientRect();
|
||
const mirrorRect = mirror.getBoundingClientRect();
|
||
|
||
const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight;
|
||
const left = taRect.left + (spanRect.left - mirrorRect.left);
|
||
|
||
document.body.removeChild(mirror);
|
||
|
||
return { top, left };
|
||
}
|
||
|
||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||
const newValue = e.target.value;
|
||
setValue(newValue);
|
||
|
||
const ta = e.target;
|
||
const pos = ta.selectionStart;
|
||
|
||
// Check for @ trigger
|
||
if (pos > 0 && newValue[pos - 1] === '@') {
|
||
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
||
if (charBefore === null || charBefore === ' ' || charBefore === '\n') {
|
||
const coords = getCaretCoords(ta);
|
||
setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords });
|
||
if (!fileIndex) {
|
||
api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Update query if popover is open — use stored atIdx
|
||
if (mentionState?.open) {
|
||
const { atIdx } = mentionState;
|
||
if (atIdx < pos && newValue[atIdx] === '@') {
|
||
const query = newValue.slice(atIdx + 1, pos);
|
||
setMentionState(prev => prev ? { ...prev, query } : null);
|
||
} else {
|
||
setMentionState(null);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleMentionSelect(path: string) {
|
||
const atIdx = mentionState?.atIdx ?? -1;
|
||
const ta = textareaRef.current;
|
||
const caretPos = ta?.selectionStart ?? value.length;
|
||
setMentionState(null);
|
||
|
||
try {
|
||
const result = await api.projects.viewFile(projectId, path);
|
||
if (atIdx >= 0) {
|
||
const cleaned = value.slice(0, atIdx) + value.slice(caretPos);
|
||
setValue(cleaned);
|
||
if (ta) {
|
||
requestAnimationFrame(() => {
|
||
ta.selectionStart = ta.selectionEnd = atIdx;
|
||
ta.focus();
|
||
});
|
||
}
|
||
}
|
||
addAttachment({
|
||
id: crypto.randomUUID(),
|
||
kind: 'file',
|
||
filename: path,
|
||
language: inferLanguage(path),
|
||
content: result.content,
|
||
source: '@',
|
||
});
|
||
} catch {
|
||
toast.error('Failed to load file');
|
||
}
|
||
}
|
||
|
||
const closeMention = useCallback(() => setMentionState(null), []);
|
||
|
||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (mentionState?.open) return;
|
||
// IME safety: never act on Enter while an IME composition is in flight
|
||
// (CJK input methods commit composition via Enter). Without this, the
|
||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||
// instead of finalizing the candidate.
|
||
if (e.nativeEvent.isComposing) return;
|
||
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
|
||
e.preventDefault();
|
||
void forceSubmit();
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
void submit();
|
||
return;
|
||
}
|
||
// Bare Enter: sends on desktop, inserts a newline on mobile (per spec —
|
||
// send is via the dedicated button on touch devices).
|
||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||
e.preventDefault();
|
||
void submit();
|
||
}
|
||
}
|
||
|
||
async function forceSubmit() {
|
||
const text = value.trim();
|
||
if (!text || !onForceSend) return;
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const body = flattenToMessage(attachments, text);
|
||
await onForceSend(body);
|
||
setValue('');
|
||
setAttachments([]);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'force send failed');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||
<div className="max-w-[1000px] mx-auto w-full">
|
||
{attachments.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||
{attachments.map(a => (
|
||
<AttachmentChip
|
||
key={a.id}
|
||
attachment={a}
|
||
onRemove={removeAttachment}
|
||
onPreview={setPreviewAttachment}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="px-4 py-3 flex items-end gap-2">
|
||
<Textarea
|
||
ref={textareaRef}
|
||
value={value}
|
||
onChange={handleChange}
|
||
onKeyDown={onKeyDown}
|
||
placeholder={
|
||
isMobile
|
||
? 'Ask about this project. Tap send to submit.'
|
||
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||
}
|
||
disabled={disabled || busy}
|
||
rows={3}
|
||
className="resize-none min-h-[68px] max-h-[240px]"
|
||
/>
|
||
<Button
|
||
onClick={() => void submit()}
|
||
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
|
||
size="icon-lg"
|
||
aria-label="Send"
|
||
>
|
||
<Send />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<AttachmentPreviewModal
|
||
attachment={previewAttachment}
|
||
onClose={() => setPreviewAttachment(null)}
|
||
/>
|
||
{mentionState?.open && (
|
||
<FileMentionPopover
|
||
query={mentionState.query}
|
||
files={fileIndex ?? []}
|
||
anchorRect={mentionState.anchorRect}
|
||
onSelect={handleMentionSelect}
|
||
onClose={closeMention}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|