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:
@@ -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]"
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user