Files
boocode/apps/web/src/components/ChatInput.tsx
indifferentketchup 273eeac68c feat(mobile): chat input keybinds + safe-area + tap targets + overflow safety
- 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>
2026-05-16 05:55:34 +00:00

286 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}