diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 181d02a2aba..7d278c2ce48 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -916,6 +916,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); + const hasCoarsePointer = useMediaQuery({ pointer: "coarse" }); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; @@ -1800,7 +1801,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if ( key === "Enter" && - shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) + shouldSubmitComposerOnEnter({ isMobileViewport, hasCoarsePointer, shiftKey: event.shiftKey }) ) { submitComposer(); return true; @@ -2006,8 +2007,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting || isComposerApprovalState || pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) + projectSelectionRequired ) { return false; } @@ -2542,12 +2542,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" } - disabled={ - isConnecting || - isComposerApprovalState || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - } + // An unavailable environment must not lock the editor: this fork + // queues sends made while disconnected (`shouldQueueInitialSend`), + // so the user has to be able to type the message that gets queued. + disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> {showMobilePendingAnswerActions ? (
{ it("inserts a newline for Shift+Enter", () => { expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: true })).toBe(false); }); + + it("inserts a newline on a wide touch device, where Enter comes from an on-screen keyboard", () => { + expect( + shouldSubmitComposerOnEnter({ + isMobileViewport: false, + hasCoarsePointer: true, + shiftKey: false, + }), + ).toBe(false); + }); + + it("still submits on a wide fine-pointer device", () => { + expect( + shouldSubmitComposerOnEnter({ + isMobileViewport: false, + hasCoarsePointer: false, + shiftKey: false, + }), + ).toBe(true); + }); }); describe("detectComposerTrigger", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2d1d3aed3b1..d5c06d1f40c 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -11,11 +11,15 @@ export interface ComposerTrigger { rangeEnd: number; } +// A coarse pointer means an on-screen keyboard, where Enter has to insert a +// newline rather than send — the viewport check alone misses tablets and +// 2-in-1s held in landscape, whose viewport is wider than the mobile breakpoint. export function shouldSubmitComposerOnEnter(input: { isMobileViewport: boolean; shiftKey: boolean; + hasCoarsePointer?: boolean; }): boolean { - return !input.isMobileViewport && !input.shiftKey; + return !input.isMobileViewport && !input.hasCoarsePointer && !input.shiftKey; } const isInlineTokenSegment = (