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>
This commit is contained in:
2026-05-16 05:55:34 +00:00
parent cd897d6893
commit 273eeac68c
4 changed files with 30 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ 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;
@@ -18,6 +19,7 @@ interface Props {
}
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[]>([]);
@@ -185,6 +187,11 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
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();
@@ -195,7 +202,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
void submit();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
// 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();
}
@@ -219,7 +228,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
}
return (
<div className="border-t">
<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">
@@ -239,7 +248,11 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
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]"