From 5c33d5c4cdfbf5237eb40fa047091d623ae01d96 Mon Sep 17 00:00:00 2001 From: Hiram Chirino Date: Fri, 20 Mar 2026 21:01:05 -0400 Subject: [PATCH 01/15] feat(web): collapse mobile composer by default Collapse the chat composer into a compact mobile preview until the user focuses or taps it, while preserving quick-send behavior for existing prompt content. Signed-off-by: Hiram Chirino --- apps/web/src/components/ChatView.tsx | 56 ++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a9..5da0d0252fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -142,6 +142,7 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; +import { useMediaQuery } from "../hooks/useMediaQuery"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -336,6 +337,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + const [isComposerFocused, setIsComposerFocused] = useState(false); + const isMobileViewport = useMediaQuery("max-sm"); + const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -3586,8 +3590,24 @@ export default function ChatView({ threadId }: ChatViewProps) { isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", composerProviderState.composerSurfaceClassName, )} + onFocusCapture={() => setIsComposerFocused(true)} + onBlurCapture={(e) => { + // Only collapse if focus leaves the composer entirely + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsComposerFocused(false); + } + }} + onClick={() => { + if (isComposerCollapsedMobile) { + // First expand the composer, then focus the editor after it renders + setIsComposerFocused(true); + requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } + }} > - {activePendingApproval ? ( + {!isComposerCollapsedMobile && (activePendingApproval ? (
- ) : null} + ) : null)} + {isComposerCollapsedMobile && ( +
+ + {activePendingProgress + ? (activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option") + : (prompt.trim() || "Ask anything...")} + + +
+ )}
{composerMenuOpen && !isComposerApprovalState && ( @@ -3633,7 +3680,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
)} - {!isComposerApprovalState && + {!isComposerCollapsedMobile && + !isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.length > 0 && (
@@ -3738,7 +3786,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Bottom toolbar */} - {activePendingApproval ? ( + {isComposerCollapsedMobile ? null : activePendingApproval ? (
Date: Sun, 22 Mar 2026 14:13:31 -0400 Subject: [PATCH 02/15] fix(web): handle mobile send button taps Signed-off-by: Hiram Chirino --- apps/web/src/components/ChatView.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5da0d0252fd..2c720718e1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3646,12 +3646,17 @@ export default function ChatView({ threadId }: ChatViewProps) { : (prompt.trim() || "Ask anything...")}
@@ -3969,6 +4021,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="rounded-full px-4" + onPointerDown={(e) => e.preventDefault()} disabled={ activePendingIsResponding || (activePendingProgress.isLastQuestion @@ -4007,6 +4060,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-full px-4 sm:h-8" + onPointerDown={(e) => e.preventDefault()} disabled={isSendBusy || isConnecting} > {isConnecting || isSendBusy ? "Sending..." : "Refine"} @@ -4017,6 +4071,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" + onPointerDown={(e) => e.preventDefault()} disabled={isSendBusy || isConnecting} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -4050,6 +4105,7 @@ export default function ChatView({ threadId }: ChatViewProps) { + {activePendingProgress?.activeQuestion?.multiSelect ? (
) : null} - {isComposerCollapsedMobile && !activePendingApproval ? ( + {showCollapsedMobilePromptRow ? (
-
- {activePendingProgress?.activeQuestion?.multiSelect ? ( -
- + + {activePendingProgress?.activeQuestion?.multiSelect ? ( + + ) : null}
- ) : null} +
) : null} @@ -2212,39 +2221,64 @@ export const ChatComposer = memo( )} - +
+ + {showMobilePendingAnswerActions ? ( +
+ +
+ ) : null} +
{/* Bottom toolbar */} @@ -2263,6 +2297,7 @@ export const ChatComposer = memo( className={cn( "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3", isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", + showMobilePendingAnswerActions && "hidden sm:flex", )} >
From 5068ae4bde9f9dfa5893151f648aa2a5a670cd14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 18:05:56 -0700 Subject: [PATCH 12/15] fix(web): tighten mobile composer submit guards Remove unreachable collapsed pending action branches and avoid mobile blur when Enter cannot send. Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 47 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 18 +++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f276970b74d..1d75436e098 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2523,6 +2523,53 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the mobile composer expanded when Enter is pressed while send is busy", async () => { + const mounted = await mountChatView({ + viewport: COMPACT_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-mobile-composer-busy-enter-target" as MessageId, + targetText: "mobile composer busy enter thread", + sessionStatus: "running", + }), + }); + + try { + const expandButton = await waitForElement( + () => document.querySelector('button[aria-label="Expand composer"]'), + "Unable to find collapsed composer expand button.", + ); + expandButton.click(); + + await waitForElement( + () => document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), + "Mobile composer should expand when tapped.", + ); + const composerEditor = await waitForComposerEditor(); + await page.getByTestId("composer-editor").fill("busy enter should not blur"); + + await pressComposerKey("Enter"); + + await vi.waitFor( + () => { + expect( + document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), + ).toBeTruthy(); + expect(document.activeElement).toBe(composerEditor); + expect( + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ), + ).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("shows pending user input choices while the mobile composer stays collapsed", async () => { const mounted = await mountChatView({ viewport: COMPACT_FOOTER_VIEWPORT, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 71c759777ca..fadbb43f6af 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1043,17 +1043,9 @@ export const ChatComposer = memo( : null, [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); - const collapsedComposerPrimaryActionDisabled = activePendingProgress - ? activePendingIsResponding || - (activePendingProgress.isLastQuestion - ? !activePendingResolvedAnswers - : !activePendingProgress.canAdvance) - : isSendBusy || isConnecting || !composerSendState.hasSendableContent; - const collapsedComposerPrimaryActionLabel = activePendingProgress - ? activePendingProgress.isLastQuestion - ? "Submit answer" - : "Next question" - : "Send message"; + const collapsedComposerPrimaryActionDisabled = + isSendBusy || isConnecting || !composerSendState.hasSendableContent; + const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1600,6 +1592,7 @@ export const ChatComposer = memo( const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; + if (isSendBusy || isConnecting || phase === "running") return false; if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } @@ -1608,7 +1601,10 @@ export const ChatComposer = memo( activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, + isConnecting, isMobileViewport, + isSendBusy, + phase, showPlanFollowUpPrompt, ]); From 28063d0532d1385b0d1e4ea7e37390623509eb6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 18:13:11 -0700 Subject: [PATCH 13/15] Discard changes to apps/web/src/components/ChatView.browser.tsx --- apps/web/src/components/ChatView.browser.tsx | 295 +------------------ 1 file changed, 1 insertion(+), 294 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1d75436e098..2a1edb91813 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -822,9 +822,7 @@ function createSnapshotWithSecondaryProject(options?: { }; } -function createSnapshotWithPendingUserInput(options?: { - firstQuestionMultiSelect?: boolean; -}): OrchestrationReadModel { +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-pending-input-target" as MessageId, targetText: "question thread", @@ -849,7 +847,6 @@ function createSnapshotWithPendingUserInput(options?: { id: "scope", header: "Scope", question: "What should this change cover?", - multiSelect: options?.firstQuestionMultiSelect, options: [ { label: "Tight", @@ -2445,296 +2442,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("blurs and collapses the composer after sending on mobile", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-composer-blur-target" as MessageId, - targetText: "mobile composer blur thread", - }), - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - const collapsedSurface = await waitForElement( - () => document.querySelector('[data-chat-composer-mobile-collapsed="true"]'), - "Mobile composer should start collapsed.", - ); - expect(collapsedSurface).toBeTruthy(); - const expandButton = await waitForElement( - () => document.querySelector('button[aria-label="Expand composer"]'), - "Unable to find collapsed composer expand button.", - ); - expandButton.click(); - - await waitForElement( - () => document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), - "Mobile composer should expand when tapped.", - ); - const composerEditor = await waitForComposerEditor(); - await vi.waitFor( - () => { - expect(document.activeElement).toBe(composerEditor); - }, - { timeout: 8_000, interval: 16 }, - ); - await page.getByTestId("composer-editor").fill("send and blur"); - - await vi.waitFor( - () => { - expect(document.activeElement).toBe(composerEditor); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - sendButton.click(); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.turn.start", - ); - expect(dispatchRequest).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - expect( - document.querySelector('[data-chat-composer-mobile-collapsed="true"]'), - ).toBeTruthy(); - expect(document.activeElement).not.toBe(composerEditor); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the mobile composer expanded when Enter is pressed while send is busy", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-composer-busy-enter-target" as MessageId, - targetText: "mobile composer busy enter thread", - sessionStatus: "running", - }), - }); - - try { - const expandButton = await waitForElement( - () => document.querySelector('button[aria-label="Expand composer"]'), - "Unable to find collapsed composer expand button.", - ); - expandButton.click(); - - await waitForElement( - () => document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), - "Mobile composer should expand when tapped.", - ); - const composerEditor = await waitForComposerEditor(); - await page.getByTestId("composer-editor").fill("busy enter should not blur"); - - await pressComposerKey("Enter"); - - await vi.waitFor( - () => { - expect( - document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), - ).toBeTruthy(); - expect(document.activeElement).toBe(composerEditor); - expect( - wsRequests.some( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.turn.start", - ), - ).toBe(false); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows pending user input choices while the mobile composer stays collapsed", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput(), - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - await waitForElement( - () => document.querySelector('[data-chat-composer-mobile-collapsed="true"]'), - "Mobile composer should stay collapsed for pending user input.", - ); - expect(document.body.textContent).toContain("What should this change cover?"); - expect(document.querySelector('button[aria-label="Write custom answer"]')).toBeTruthy(); - expect(document.querySelector('button[aria-label="Expand composer"]')).toBeNull(); - expect(document.querySelector('button[aria-label="Send message"]')).toBeNull(); - - const firstOption = await waitForButtonContainingText("Tight"); - firstOption.click(); - - await vi.waitFor( - () => { - expect( - document.querySelector('[data-chat-composer-mobile-collapsed="true"]'), - ).toBeTruthy(); - expect(document.body.textContent).toContain( - "How aggressive should the imaginary plan be?", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - const finalOption = await waitForButtonContainingText("Conservative"); - finalOption.click(); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.user-input.respond", - ) as - | { - _tag: string; - type?: string; - requestId?: string; - answers?: Record; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "thread.user-input.respond", - requestId: "req-browser-user-input", - answers: { - scope: "Tight", - risk: "Conservative", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps mobile pending submit actions inside the custom answer composer", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput({ firstQuestionMultiSelect: true }), - }); - - try { - const compactPendingComposer = await waitForElement( - () => - document.querySelector('[data-chat-composer-mobile-pending-compact="true"]'), - "Unable to find collapsed pending custom answer composer.", - ); - expect(compactPendingComposer.textContent).toContain("Next"); - - const customAnswerButton = await waitForElement( - () => document.querySelector('button[aria-label="Write custom answer"]'), - "Unable to find collapsed pending custom answer button.", - ); - customAnswerButton.click(); - - await waitForElement( - () => document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), - "Mobile composer should expand for a custom pending answer.", - ); - const mobilePendingActions = await waitForElement( - () => - document.querySelector('[data-chat-composer-mobile-pending-actions="true"]'), - "Unable to find mobile pending answer actions.", - ); - const editor = await waitForElement( - () => document.querySelector('[data-testid="composer-editor"]'), - "Unable to find composer editor.", - ); - const footer = document.querySelector('[data-chat-composer-footer="true"]'); - const actionsBounds = mobilePendingActions.getBoundingClientRect(); - const editorBounds = editor.getBoundingClientRect(); - - expect(getComputedStyle(footer!).display).toBe("none"); - expect(actionsBounds.top).toBeGreaterThan(editorBounds.top); - expect(actionsBounds.bottom).toBeLessThanOrEqual(editorBounds.bottom + 1); - expect(actionsBounds.right).toBeLessThanOrEqual(editorBounds.right + 1); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the mobile composer expanded when focus moves into the model picker portal", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-model-picker-target" as MessageId, - targetText: "mobile model picker thread", - }), - }); - - try { - const expandButton = await waitForElement( - () => document.querySelector('button[aria-label="Expand composer"]'), - "Unable to find collapsed composer expand button.", - ); - expandButton.click(); - - await waitForElement( - () => document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), - "Mobile composer should expand before opening the model picker.", - ); - - const modelPickerButton = await waitForElement( - findComposerProviderModelPicker, - "Unable to find provider model picker.", - ); - modelPickerButton.click(); - - await vi.waitFor( - () => { - const searchInput = document.querySelector( - 'input[placeholder="Search models..."]', - ); - expect(searchInput).not.toBeNull(); - expect(document.activeElement).toBe(searchInput); - expect( - document.querySelector('[data-chat-composer-mobile-collapsed="false"]'), - ).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { setDraftThreadWithoutWorktree(); const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); From 35b4b8b640a237b2bd4b2415a58d875e1f8eae04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 18:40:07 -0700 Subject: [PATCH 14/15] fix(web): disable collapsed send while running Guard the collapsed mobile send button during active turns. Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fadbb43f6af..d7085fb5e1a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1044,7 +1044,7 @@ export const ChatComposer = memo( [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - isSendBusy || isConnecting || !composerSendState.hasSendableContent; + phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; From bbccb42a7f99d32127037269a388cb66a4b87e43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 19:23:20 -0700 Subject: [PATCH 15/15] fix(web): preserve desktop composer action focus Only prevent primary action pointer focus on mobile composer surfaces that need to keep the editor active. Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 5 ++++ .../chat/ComposerPrimaryActions.tsx | 28 +++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d7085fb5e1a..6be51f5d835 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -302,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isSendBusy: boolean; isConnecting: boolean; hasSendableContent: boolean; + preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -322,6 +323,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isConnecting={props.isConnecting} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} + preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -2070,6 +2072,7 @@ export const ChatComposer = memo( isConnecting={isConnecting} isPreparingWorktree={false} hasSendableContent={false} + preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} @@ -2268,6 +2271,7 @@ export const ChatComposer = memo( isConnecting={isConnecting} isPreparingWorktree={false} hasSendableContent={false} + preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} @@ -2382,6 +2386,7 @@ export const ChatComposer = memo( isConnecting={isConnecting} isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} + preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 17ec8794df7..9e60671ab5b 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, type PointerEventHandler } from "react"; import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; @@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps { isConnecting: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; + preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -45,6 +46,10 @@ export const formatPendingPrimaryActionLabel = (input: { return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; }; +const preventPointerFocus: PointerEventHandler = (event) => { + event.preventDefault(); +}; + export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, pendingAction, @@ -55,10 +60,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isConnecting, isPreparingWorktree, hasSendableContent, + preserveComposerFocusOnPointerDown = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, }: ComposerPrimaryActionsProps) { + const pointerFocusProps = preserveComposerFocusOnPointerDown + ? { onPointerDown: preventPointerFocus } + : undefined; + if (pendingAction) { return (
@@ -68,7 +78,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="icon-sm" variant="outline" className="rounded-full" - onPointerDown={(event) => event.preventDefault()} + {...pointerFocusProps} onClick={onPreviousPendingQuestion} disabled={pendingAction.isResponding} aria-label="Previous question" @@ -80,7 +90,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" variant="outline" className="rounded-full" - onPointerDown={(event) => event.preventDefault()} + {...pointerFocusProps} onClick={onPreviousPendingQuestion} disabled={pendingAction.isResponding} > @@ -92,7 +102,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="submit" size="sm" className={cn("rounded-full", compact ? "px-3" : "px-4")} - onPointerDown={(event) => event.preventDefault()} + {...pointerFocusProps} disabled={ pendingAction.isResponding || (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) @@ -114,7 +124,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({