authorgravatar for hiram@hiramchirino.comHiram Chirino <hiram@hiramchirino.com> 2026-05-03 22:48:10-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-03 19:48:10-07:00
log92e340d80cff793bdb06ed9d6761dfb1bde35c52
tree69fd95ad23699c9ebe8a1ba8f3a5b182289e9448
parentd7969264de66dbe0367083af83c19f87162b494c
signature Signed by PGP key B5690EEEBB952194

feat(web): collapse mobile composer by default (#1263)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <jmarminge@gmail.com>

2 files changed, 372 insertions(+), 49 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+353-48
......@@ -111,6 +111,7 @@ import type { PendingApproval, PendingUserInput } from "../../session-logic";
111111import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
112112import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
113113import { searchProviderSkills } from "../../providerSkillSearch";
114import { useMediaQuery } from "../../hooks/useMediaQuery";
114115
115116const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
116117
......@@ -138,6 +139,13 @@ const runtimeModeConfig: Record<
138139const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
139140const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
140141const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
142const COMPOSER_FLOATING_LAYER_SELECTOR = [
143 '[data-slot="popover-popup"]',
144 '[data-slot="menu-popup"]',
145 '[data-slot="select-popup"]',
146 '[data-slot="combobox-popup"]',
147 '[data-slot="autocomplete-popup"]',
148].join(",");
141149
142150const extendReplacementRangeForTrailingSpace = (
143151 text: string,
......@@ -167,6 +175,10 @@ const terminalContextIdListsEqual = (
167175): boolean =>
168176 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
169177
178function isInsideComposerFloatingLayer(element: Element): boolean {
179 return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;
180}
181
170182const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
171183 showInteractionModeToggle: boolean;
172184 interactionMode: ProviderInteractionMode;
......@@ -290,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
290302 isSendBusy: boolean;
291303 isConnecting: boolean;
292304 hasSendableContent: boolean;
305 preserveComposerFocusOnPointerDown?: boolean;
293306 onPreviousPendingQuestion: () => void;
294307 onInterrupt: () => void;
295308 onImplementPlanInNewThread: () => void;
......@@ -310,6 +323,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
310323 isConnecting={props.isConnecting}
311324 isPreparingWorktree={props.isPreparingWorktree}
312325 hasSendableContent={props.hasSendableContent}
326 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
313327 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
314328 onInterrupt={props.onInterrupt}
315329 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
......@@ -389,7 +403,7 @@ export interface ChatComposerProps {
389403 isLastQuestion: boolean;
390404 canAdvance: boolean;
391405 customAnswer: string;
392 activeQuestion: { id: string } | null;
406 activeQuestion: { id: string; multiSelect?: boolean | undefined } | null;
393407 } | null;
394408 activePendingResolvedAnswers: Record<string, unknown> | null;
395409 activePendingIsResponding: boolean;
......@@ -774,17 +788,25 @@ export const ChatComposer = memo(
774788 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
775789 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
776790 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
791 const [isComposerFocused, setIsComposerFocused] = useState(false);
792 const isMobileViewport = useMediaQuery("max-sm");
793 const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
777794
778795 // ------------------------------------------------------------------
779796 // Refs
780797 // ------------------------------------------------------------------
781798 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
782799 const composerFormRef = useRef<HTMLFormElement>(null);
800 const composerSurfaceRef = useRef<HTMLDivElement>(null);
783801 const composerFormHeightRef = useRef(0);
784802 const composerSelectLockRef = useRef(false);
785803 const composerMenuOpenRef = useRef(false);
786804 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
787805 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
806 const composerBlurFrameRef = useRef<number | null>(null);
807 const mobileComposerExpandFrameRef = useRef<number | null>(null);
808 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
809 const mobileComposerExpandInFlightRef = useRef(false);
788810 const dragDepthRef = useRef(0);
789811
790812 // ------------------------------------------------------------------
......@@ -929,6 +951,8 @@ export const ChatComposer = memo(
929951 isComposerApprovalState ||
930952 pendingUserInputs.length > 0 ||
931953 (showPlanFollowUpPrompt && activeProposedPlan !== null);
954 const showCollapsedMobilePromptRow =
955 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
932956
933957 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
934958 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
......@@ -1021,6 +1045,11 @@ export const ChatComposer = memo(
10211045 : null,
10221046 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
10231047 );
1048 const collapsedComposerPrimaryActionDisabled =
1049 phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
1050 const collapsedComposerPrimaryActionLabel = "Send message";
1051 const showMobilePendingAnswerActions =
1052 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
10241053
10251054 // ------------------------------------------------------------------
10261055 // Prompt helpers
......@@ -1550,6 +1579,69 @@ export const ChatComposer = memo(
15501579 [composerHighlightedItemId, composerMenuItems],
15511580 );
15521581
1582 const blurMobileComposerAfterSend = useCallback(() => {
1583 if (!isMobileViewport) return;
1584 if (composerBlurFrameRef.current !== null) {
1585 window.cancelAnimationFrame(composerBlurFrameRef.current);
1586 composerBlurFrameRef.current = null;
1587 }
1588 const activeElement = document.activeElement;
1589 if (activeElement instanceof HTMLElement) {
1590 activeElement.blur();
1591 }
1592 setIsComposerFocused(false);
1593 }, [isMobileViewport]);
1594
1595 const shouldBlurMobileComposerOnSubmit = useCallback(() => {
1596 if (!isMobileViewport) return false;
1597 if (isSendBusy || isConnecting || phase === "running") return false;
1598 if (activePendingProgress) {
1599 return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers);
1600 }
1601 return showPlanFollowUpPrompt || composerSendState.hasSendableContent;
1602 }, [
1603 activePendingProgress,
1604 activePendingResolvedAnswers,
1605 composerSendState.hasSendableContent,
1606 isConnecting,
1607 isMobileViewport,
1608 isSendBusy,
1609 phase,
1610 showPlanFollowUpPrompt,
1611 ]);
1612
1613 const submitComposer = useCallback(
1614 (event?: { preventDefault: () => void }) => {
1615 onSend(event);
1616 if (shouldBlurMobileComposerOnSubmit()) {
1617 blurMobileComposerAfterSend();
1618 }
1619 },
1620 [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
1621 );
1622 const expandMobileComposer = useCallback(() => {
1623 if (composerBlurFrameRef.current !== null) {
1624 window.cancelAnimationFrame(composerBlurFrameRef.current);
1625 composerBlurFrameRef.current = null;
1626 }
1627 if (mobileComposerExpandFrameRef.current !== null) {
1628 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1629 }
1630 if (mobileComposerExpandReleaseFrameRef.current !== null) {
1631 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1632 }
1633 mobileComposerExpandInFlightRef.current = true;
1634 setIsComposerFocused(true);
1635 mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
1636 mobileComposerExpandFrameRef.current = null;
1637 composerEditorRef.current?.focusAtEnd();
1638 mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
1639 mobileComposerExpandReleaseFrameRef.current = null;
1640 mobileComposerExpandInFlightRef.current = false;
1641 });
1642 });
1643 }, []);
1644
15531645 // ------------------------------------------------------------------
15541646 // Callbacks: command key
15551647 // ------------------------------------------------------------------
......@@ -1580,7 +1672,7 @@ export const ChatComposer = memo(
15801672 }
15811673 }
15821674 if (key === "Enter" && !event.shiftKey) {
1583 void onSend();
1675 submitComposer();
15841676 return true;
15851677 }
15861678 return false;
......@@ -1690,6 +1782,50 @@ export const ChatComposer = memo(
16901782 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
16911783 void onImplementPlanInNewThread();
16921784 }, [onImplementPlanInNewThread]);
1785 const scheduleComposerCollapseCheck = useCallback(() => {
1786 if (!isMobileViewport) {
1787 return;
1788 }
1789 if (mobileComposerExpandInFlightRef.current) {
1790 return;
1791 }
1792 if (composerBlurFrameRef.current !== null) {
1793 window.cancelAnimationFrame(composerBlurFrameRef.current);
1794 }
1795 composerBlurFrameRef.current = window.requestAnimationFrame(() => {
1796 composerBlurFrameRef.current = null;
1797 if (mobileComposerExpandInFlightRef.current) {
1798 return;
1799 }
1800 const composerSurface = composerSurfaceRef.current;
1801 const activeElement = document.activeElement;
1802 if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
1803 return;
1804 }
1805 if (
1806 composerSurface &&
1807 activeElement instanceof Node &&
1808 composerSurface.contains(activeElement)
1809 ) {
1810 return;
1811 }
1812 setIsComposerFocused(false);
1813 });
1814 }, [isMobileViewport]);
1815
1816 useEffect(() => {
1817 return () => {
1818 if (composerBlurFrameRef.current !== null) {
1819 window.cancelAnimationFrame(composerBlurFrameRef.current);
1820 }
1821 if (mobileComposerExpandFrameRef.current !== null) {
1822 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1823 }
1824 if (mobileComposerExpandReleaseFrameRef.current !== null) {
1825 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1826 }
1827 };
1828 }, []);
16931829
16941830 // ------------------------------------------------------------------
16951831 // Imperative handle
......@@ -1803,7 +1939,7 @@ export const ChatComposer = memo(
18031939 return (
18041940 <form
18051941 ref={composerFormRef}
1806 onSubmit={onSend}
1942 onSubmit={submitComposer}
18071943 className="mx-auto w-full min-w-0 max-w-208"
18081944 data-chat-composer-form="true"
18091945 >
......@@ -1818,21 +1954,82 @@ export const ChatComposer = memo(
18181954 onDrop={onComposerDrop}
18191955 >
18201956 <div
1957 ref={composerSurfaceRef}
1958 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
18211959 className={cn(
18221960 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
18231961 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
18241962 composerProviderState.composerSurfaceClassName,
18251963 )}
1964 onFocusCapture={(event) => {
1965 const activeElement = event.target;
1966 if (
1967 isComposerCollapsedMobile &&
1968 activeElement instanceof HTMLElement &&
1969 activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
1970 ) {
1971 return;
1972 }
1973 if (composerBlurFrameRef.current !== null) {
1974 window.cancelAnimationFrame(composerBlurFrameRef.current);
1975 composerBlurFrameRef.current = null;
1976 }
1977 setIsComposerFocused(true);
1978 }}
1979 onBlurCapture={() => {
1980 scheduleComposerCollapseCheck();
1981 }}
18261982 >
1827 {activePendingApproval ? (
1828 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1983 {!isComposerCollapsedMobile &&
1984 (activePendingApproval ? (
1985 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1986 <ComposerPendingApprovalPanel
1987 approval={activePendingApproval}
1988 pendingCount={pendingApprovals.length}
1989 />
1990 </div>
1991 ) : pendingUserInputs.length > 0 ? (
1992 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1993 <ComposerPendingUserInputPanel
1994 pendingUserInputs={pendingUserInputs}
1995 respondingRequestIds={respondingRequestIds}
1996 answers={activePendingDraftAnswers}
1997 questionIndex={activePendingQuestionIndex}
1998 onToggleOption={onSelectActivePendingUserInputOption}
1999 onAdvance={onAdvanceActivePendingUserInput}
2000 />
2001 </div>
2002 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
2003 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2004 <ComposerPlanFollowUpBanner
2005 key={activeProposedPlan.id}
2006 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
2007 />
2008 </div>
2009 ) : null)}
2010
2011 {isComposerCollapsedMobile && activePendingApproval ? (
2012 <div
2013 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2014 data-chat-composer-collapsed-controls="true"
2015 >
18292016 <ComposerPendingApprovalPanel
18302017 approval={activePendingApproval}
18312018 pendingCount={pendingApprovals.length}
18322019 />
2020 <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4">
2021 <ComposerPendingApprovalActions
2022 requestId={activePendingApproval.requestId}
2023 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2024 onRespondToApproval={onRespondToApproval}
2025 />
2026 </div>
18332027 </div>
1834 ) : pendingUserInputs.length > 0 ? (
1835 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2028 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2029 <div
2030 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2031 data-chat-composer-collapsed-controls="true"
2032 >
18362033 <ComposerPendingUserInputPanel
18372034 pendingUserInputs={pendingUserInputs}
18382035 respondingRequestIds={respondingRequestIds}
......@@ -1841,13 +2038,91 @@ export const ChatComposer = memo(
18412038 onToggleOption={onSelectActivePendingUserInputOption}
18422039 onAdvance={onAdvanceActivePendingUserInput}
18432040 />
2041 <div className="px-3 pb-3 sm:px-4">
2042 <div
2043 data-chat-composer-mobile-pending-compact="true"
2044 className={cn(
2045 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
2046 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
2047 )}
2048 >
2049 <button
2050 type="button"
2051 className={cn(
2052 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
2053 activePendingProgress?.customAnswer
2054 ? "text-foreground"
2055 : "text-muted-foreground/60",
2056 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
2057 )}
2058 onPointerDown={(event) => event.preventDefault()}
2059 onClick={expandMobileComposer}
2060 aria-label="Write custom answer"
2061 >
2062 {activePendingProgress?.customAnswer || "Write custom answer"}
2063 </button>
2064 {activePendingProgress?.activeQuestion?.multiSelect ? (
2065 <ComposerPrimaryActions
2066 compact
2067 pendingAction={pendingPrimaryAction}
2068 isRunning={false}
2069 showPlanFollowUpPrompt={false}
2070 promptHasText={false}
2071 isSendBusy={isSendBusy}
2072 isConnecting={isConnecting}
2073 isPreparingWorktree={false}
2074 hasSendableContent={false}
2075 preserveComposerFocusOnPointerDown
2076 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2077 onInterrupt={handleInterruptPrimaryAction}
2078 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2079 />
2080 ) : null}
2081 </div>
2082 </div>
18442083 </div>
1845 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
1846 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
1847 <ComposerPlanFollowUpBanner
1848 key={activeProposedPlan.id}
1849 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
1850 />
2084 ) : null}
2085
2086 {showCollapsedMobilePromptRow ? (
2087 <div className="flex items-center justify-between gap-2 px-3 py-2">
2088 <button
2089 type="button"
2090 className={cn(
2091 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2092 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2093 ? "text-foreground"
2094 : "text-muted-foreground/35",
2095 )}
2096 onPointerDown={(event) => event.preventDefault()}
2097 onClick={expandMobileComposer}
2098 aria-label="Expand composer"
2099 >
2100 {activePendingProgress
2101 ? activePendingProgress.customAnswer ||
2102 "Type your own answer, or leave this blank to use the selected option"
2103 : prompt.trim() || "Ask anything..."}
2104 </button>
2105 <button
2106 type="button"
2107 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"
2108 disabled={collapsedComposerPrimaryActionDisabled}
2109 aria-label={collapsedComposerPrimaryActionLabel}
2110 onPointerDown={(event) => event.preventDefault()}
2111 onClick={(event) => {
2112 event.stopPropagation();
2113 submitComposer();
2114 }}
2115 >
2116 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2117 <path
2118 d="M8 3L8 13M8 3L4 7M8 3L12 7"
2119 stroke="currentColor"
2120 strokeWidth="2"
2121 strokeLinecap="round"
2122 strokeLinejoin="round"
2123 />
2124 </svg>
2125 </button>
18512126 </div>
18522127 ) : null}
18532128
......@@ -1855,6 +2130,7 @@ export const ChatComposer = memo(
18552130 className={cn(
18562131 "relative px-3 pb-2 sm:px-4",
18572132 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2133 isComposerCollapsedMobile && "hidden",
18582134 )}
18592135 >
18602136 {composerMenuOpen && !isComposerApprovalState && (
......@@ -1876,7 +2152,8 @@ export const ChatComposer = memo(
18762152 </div>
18772153 )}
18782154
1879 {!isComposerApprovalState &&
2155 {!isComposerCollapsedMobile &&
2156 !isComposerApprovalState &&
18802157 pendingUserInputs.length === 0 &&
18812158 composerImages.length > 0 && (
18822159 <div className="mb-3 flex flex-wrap gap-2">
......@@ -1943,43 +2220,69 @@ export const ChatComposer = memo(
19432220 </div>
19442221 )}
19452222
1946 <ComposerPromptEditor
1947 ref={composerEditorRef}
1948 value={
1949 isComposerApprovalState
1950 ? ""
1951 : activePendingProgress
1952 ? activePendingProgress.customAnswer
1953 : prompt
1954 }
1955 cursor={composerCursor}
1956 terminalContexts={
1957 !isComposerApprovalState && pendingUserInputs.length === 0
1958 ? composerTerminalContexts
1959 : []
1960 }
1961 skills={selectedProviderStatus?.skills ?? []}
1962 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
1963 onChange={onPromptChange}
1964 onCommandKeyDown={onComposerCommandKey}
1965 onPaste={onComposerPaste}
1966 placeholder={
1967 isComposerApprovalState
1968 ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
1969 : activePendingProgress
1970 ? "Type your own answer, or leave this blank to use the selected option"
1971 : showPlanFollowUpPrompt && activeProposedPlan
1972 ? "Add feedback to refine the plan, or leave this blank to implement it"
1973 : phase === "disconnected"
1974 ? "Ask for follow-up changes or attach images"
1975 : "Ask anything, @tag files/folders, or use / to show available commands"
1976 }
1977 disabled={isConnecting || isComposerApprovalState}
1978 />
2223 <div className="relative">
2224 <ComposerPromptEditor
2225 ref={composerEditorRef}
2226 value={
2227 isComposerApprovalState
2228 ? ""
2229 : activePendingProgress
2230 ? activePendingProgress.customAnswer
2231 : prompt
2232 }
2233 cursor={composerCursor}
2234 terminalContexts={
2235 !isComposerApprovalState && pendingUserInputs.length === 0
2236 ? composerTerminalContexts
2237 : []
2238 }
2239 skills={selectedProviderStatus?.skills ?? []}
2240 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
2241 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
2242 onChange={onPromptChange}
2243 onCommandKeyDown={onComposerCommandKey}
2244 onPaste={onComposerPaste}
2245 placeholder={
2246 isComposerApprovalState
2247 ? (activePendingApproval?.detail ??
2248 "Resolve this approval request to continue")
2249 : activePendingProgress
2250 ? "Type your own answer, or leave this blank to use the selected option"
2251 : showPlanFollowUpPrompt && activeProposedPlan
2252 ? "Add feedback to refine the plan, or leave this blank to implement it"
2253 : phase === "disconnected"
2254 ? "Ask for follow-up changes or attach images"
2255 : "Ask anything, @tag files/folders, or use / to show available commands"
2256 }
2257 disabled={isConnecting || isComposerApprovalState}
2258 />
2259 {showMobilePendingAnswerActions ? (
2260 <div
2261 data-chat-composer-mobile-pending-actions="true"
2262 className="absolute bottom-0 right-0 flex justify-end"
2263 >
2264 <ComposerPrimaryActions
2265 compact
2266 pendingAction={pendingPrimaryAction}
2267 isRunning={false}
2268 showPlanFollowUpPrompt={false}
2269 promptHasText={false}
2270 isSendBusy={isSendBusy}
2271 isConnecting={isConnecting}
2272 isPreparingWorktree={false}
2273 hasSendableContent={false}
2274 preserveComposerFocusOnPointerDown
2275 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2276 onInterrupt={handleInterruptPrimaryAction}
2277 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2278 />
2279 </div>
2280 ) : null}
2281 </div>
19792282 </div>
19802283
19812284 {/* Bottom toolbar */}
1982 {activePendingApproval ? (
2285 {isComposerCollapsedMobile ? null : activePendingApproval ? (
19832286 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
19842287 <ComposerPendingApprovalActions
19852288 requestId={activePendingApproval.requestId}
......@@ -1994,6 +2297,7 @@ export const ChatComposer = memo(
19942297 className={cn(
19952298 "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",
19962299 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
2300 showMobilePendingAnswerActions && "hidden sm:flex",
19972301 )}
19982302 >
19992303 <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
......@@ -2082,6 +2386,7 @@ export const ChatComposer = memo(
20822386 isConnecting={isConnecting}
20832387 isPreparingWorktree={isPreparingWorktree}
20842388 hasSendableContent={composerSendState.hasSendableContent}
2389 preserveComposerFocusOnPointerDown={isMobileViewport}
20852390 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
20862391 onInterrupt={handleInterruptPrimaryAction}
20872392 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+19-1
......@@ -1,4 +1,4 @@
1import { memo } from "react";
1import { memo, type PointerEventHandler } from "react";
22import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
33import { cn } from "~/lib/utils";
44import { Button } from "../ui/button";
......@@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps {
2222 isConnecting: boolean;
2323 isPreparingWorktree: boolean;
2424 hasSendableContent: boolean;
25 preserveComposerFocusOnPointerDown?: boolean;
2526 onPreviousPendingQuestion: () => void;
2627 onInterrupt: () => void;
2728 onImplementPlanInNewThread: () => void;
......@@ -45,6 +46,10 @@ export const formatPendingPrimaryActionLabel = (input: {
4546 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";
4647};
4748
49const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
50 event.preventDefault();
51};
52
4853export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
4954 compact,
5055 pendingAction,
......@@ -55,10 +60,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
5560 isConnecting,
5661 isPreparingWorktree,
5762 hasSendableContent,
63 preserveComposerFocusOnPointerDown = false,
5864 onPreviousPendingQuestion,
5965 onInterrupt,
6066 onImplementPlanInNewThread,
6167}: ComposerPrimaryActionsProps) {
68 const pointerFocusProps = preserveComposerFocusOnPointerDown
69 ? { onPointerDown: preventPointerFocus }
70 : undefined;
71
6272 if (pendingAction) {
6373 return (
6474 <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
......@@ -68,6 +78,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
6878 size="icon-sm"
6979 variant="outline"
7080 className="rounded-full"
81 {...pointerFocusProps}
7182 onClick={onPreviousPendingQuestion}
7283 disabled={pendingAction.isResponding}
7384 aria-label="Previous question"
......@@ -79,6 +90,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7990 size="sm"
8091 variant="outline"
8192 className="rounded-full"
93 {...pointerFocusProps}
8294 onClick={onPreviousPendingQuestion}
8395 disabled={pendingAction.isResponding}
8496 >
......@@ -90,6 +102,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
90102 type="submit"
91103 size="sm"
92104 className={cn("rounded-full", compact ? "px-3" : "px-4")}
105 {...pointerFocusProps}
93106 disabled={
94107 pendingAction.isResponding ||
95108 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
......@@ -111,6 +124,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
111124 <button
112125 type="button"
113126 className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
127 {...pointerFocusProps}
114128 onClick={onInterrupt}
115129 aria-label="Stop generation"
116130 >
......@@ -128,6 +142,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
128142 type="submit"
129143 size="sm"
130144 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
145 {...pointerFocusProps}
131146 disabled={isSendBusy || isConnecting}
132147 >
133148 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
......@@ -141,6 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
141156 type="submit"
142157 size="sm"
143158 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
159 {...pointerFocusProps}
144160 disabled={isSendBusy || isConnecting}
145161 >
146162 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
......@@ -153,6 +169,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
153169 variant="default"
154170 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
155171 aria-label="Implementation actions"
172 {...pointerFocusProps}
156173 disabled={isSendBusy || isConnecting}
157174 />
158175 }
......@@ -176,6 +193,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
176193 <button
177194 type="submit"
178195 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
196 {...pointerFocusProps}
179197 disabled={isSendBusy || isConnecting || !hasSendableContent}
180198 aria-label={
181199 isConnecting