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 { FileMentionPopover } from '@/components/FileMentionPopover';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useViewport } from '@/hooks/useViewport';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
@@ -18,6 +19,7 @@ interface Props {
} }
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
@@ -185,6 +187,11 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return; 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) { if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
e.preventDefault(); e.preventDefault();
void forceSubmit(); void forceSubmit();
@@ -195,7 +202,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
void submit(); void submit();
return; 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(); e.preventDefault();
void submit(); void submit();
} }
@@ -219,7 +228,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
} }
return ( 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"> <div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3"> <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} value={value}
onChange={handleChange} onChange={handleChange}
onKeyDown={onKeyDown} 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} disabled={disabled || busy}
rows={3} rows={3}
className="resize-none min-h-[68px] max-h-[240px]" className="resize-none min-h-[68px] max-h-[240px]"

View File

@@ -115,7 +115,7 @@ export function ChatTabBar({
e.stopPropagation(); e.stopPropagation();
onRemoveTab(chat.id); onRemoveTab(chat.id);
}} }}
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0" className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100"
aria-label="Close tab" aria-label="Close tab"
> >
<X size={10} /> <X size={10} />
@@ -161,7 +161,7 @@ export function ChatTabBar({
<button <button
type="button" type="button"
onClick={onNewChat} onClick={onNewChat}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New chat" aria-label="New chat"
title="New chat" title="New chat"
> >
@@ -171,7 +171,7 @@ export function ChatTabBar({
type="button" type="button"
onClick={onShowHistory} onClick={onShowHistory}
className={cn( className={cn(
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground', 'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
pane.kind === 'empty' && 'text-foreground bg-muted/50' pane.kind === 'empty' && 'text-foreground bg-muted/50'
)} )}
aria-label="Session history" aria-label="Session history"
@@ -183,7 +183,7 @@ export function ChatTabBar({
<button <button
type="button" type="button"
onClick={onRemovePane} onClick={onRemovePane}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close pane" aria-label="Close pane"
title="Close pane" title="Close pane"
> >

View File

@@ -266,11 +266,11 @@ function ActionRow({
return ( return (
<> <>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
<button <button
type="button" type="button"
onClick={() => void copy()} onClick={() => void copy()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Copy message" aria-label="Copy message"
title="Copy" title="Copy"
> >
@@ -281,7 +281,7 @@ function ActionRow({
type="button" type="button"
onClick={() => void regenerate()} onClick={() => void regenerate()}
disabled={!canRegen || regenerating} disabled={!canRegen || regenerating}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed" className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Regenerate message" aria-label="Regenerate message"
title="Regenerate" title="Regenerate"
> >
@@ -292,7 +292,7 @@ function ActionRow({
type="button" type="button"
onClick={() => void fork()} onClick={() => void fork()}
disabled={!canFork || forking} disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed" className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here" aria-label="Fork from here"
title="Fork from here" title="Fork from here"
> >
@@ -302,7 +302,7 @@ function ActionRow({
type="button" type="button"
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
disabled={!canDelete} disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed" className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Delete message" aria-label="Delete message"
title="Delete message" title="Delete message"
> >
@@ -476,7 +476,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
if (message.role === 'user') { if (message.role === 'user') {
return ( return (
<div className="group flex flex-col items-end gap-1"> <div className="group flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap"> <div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content} {message.content}
</div> </div>
<ActionRow message={message} /> <ActionRow message={message} />
@@ -495,7 +495,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
<ToolCallCard key={tc.id} toolCall={tc} /> <ToolCallCard key={tc.id} toolCall={tc} />
))} ))}
{(hasContent || (!hasToolCalls && isStreaming)) && ( {(hasContent || (!hasToolCalls && isStreaming)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2"> <div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null} {hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && ( {isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" /> <span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />

View File

@@ -120,7 +120,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="p-0.5 hover:bg-muted rounded shrink-0" className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Queued message options" aria-label="Queued message options"
> >
<ChevronDown size={12} /> <ChevronDown size={12} />
@@ -138,7 +138,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<button <button
type="button" type="button"
onClick={() => removeQueued(i)} onClick={() => removeQueued(i)}
className="p-0.5 hover:bg-muted rounded shrink-0" className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Cancel queued message" aria-label="Cancel queued message"
> >
<X size={12} /> <X size={12} />
@@ -156,7 +156,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<button <button
type="button" type="button"
onClick={() => void handleStop()} onClick={() => void handleStop()}
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground" className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
> >
<Square size={10} className="fill-current" /> <Square size={10} className="fill-current" />
Stop generating Stop generating