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";...@@ -111,6 +111,7 @@ import type { PendingApproval, PendingUserInput } from "../../session-logic";
111import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";111import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
112import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";112import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
113import { searchProviderSkills } from "../../providerSkillSearch";113import { searchProviderSkills } from "../../providerSkillSearch";
114import { useMediaQuery } from "../../hooks/useMediaQuery";
114115
115const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;116const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
116117
...@@ -138,6 +139,13 @@ const runtimeModeConfig: Record<...@@ -138,6 +139,13 @@ const runtimeModeConfig: Record<
138const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];139const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
139const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;140const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
140const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];141const 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
142const extendReplacementRangeForTrailingSpace = (150const extendReplacementRangeForTrailingSpace = (
143 text: string,151 text: string,
...@@ -167,6 +175,10 @@ const terminalContextIdListsEqual = (...@@ -167,6 +175,10 @@ const terminalContextIdListsEqual = (
167): boolean =>175): boolean =>
168 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);176 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
170const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {182const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
171 showInteractionModeToggle: boolean;183 showInteractionModeToggle: boolean;
172 interactionMode: ProviderInteractionMode;184 interactionMode: ProviderInteractionMode;
...@@ -290,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -290,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
290 isSendBusy: boolean;302 isSendBusy: boolean;
291 isConnecting: boolean;303 isConnecting: boolean;
292 hasSendableContent: boolean;304 hasSendableContent: boolean;
305 preserveComposerFocusOnPointerDown?: boolean;
293 onPreviousPendingQuestion: () => void;306 onPreviousPendingQuestion: () => void;
294 onInterrupt: () => void;307 onInterrupt: () => void;
295 onImplementPlanInNewThread: () => void;308 onImplementPlanInNewThread: () => void;
...@@ -310,6 +323,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -310,6 +323,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
310 isConnecting={props.isConnecting}323 isConnecting={props.isConnecting}
311 isPreparingWorktree={props.isPreparingWorktree}324 isPreparingWorktree={props.isPreparingWorktree}
312 hasSendableContent={props.hasSendableContent}325 hasSendableContent={props.hasSendableContent}
326 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
313 onPreviousPendingQuestion={props.onPreviousPendingQuestion}327 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
314 onInterrupt={props.onInterrupt}328 onInterrupt={props.onInterrupt}
315 onImplementPlanInNewThread={props.onImplementPlanInNewThread}329 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
...@@ -389,7 +403,7 @@ export interface ChatComposerProps {...@@ -389,7 +403,7 @@ export interface ChatComposerProps {
389 isLastQuestion: boolean;403 isLastQuestion: boolean;
390 canAdvance: boolean;404 canAdvance: boolean;
391 customAnswer: string;405 customAnswer: string;
392 activeQuestion: { id: string } | null;406 activeQuestion: { id: string; multiSelect?: boolean | undefined } | null;
393 } | null;407 } | null;
394 activePendingResolvedAnswers: Record<string, unknown> | null;408 activePendingResolvedAnswers: Record<string, unknown> | null;
395 activePendingIsResponding: boolean;409 activePendingIsResponding: boolean;
...@@ -774,17 +788,25 @@ export const ChatComposer = memo(...@@ -774,17 +788,25 @@ export const ChatComposer = memo(
774 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);788 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
775 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);789 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
776 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);790 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
791 const [isComposerFocused, setIsComposerFocused] = useState(false);
792 const isMobileViewport = useMediaQuery("max-sm");
793 const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
777794
778 // ------------------------------------------------------------------795 // ------------------------------------------------------------------
779 // Refs796 // Refs
780 // ------------------------------------------------------------------797 // ------------------------------------------------------------------
781 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);798 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
782 const composerFormRef = useRef<HTMLFormElement>(null);799 const composerFormRef = useRef<HTMLFormElement>(null);
800 const composerSurfaceRef = useRef<HTMLDivElement>(null);
783 const composerFormHeightRef = useRef(0);801 const composerFormHeightRef = useRef(0);
784 const composerSelectLockRef = useRef(false);802 const composerSelectLockRef = useRef(false);
785 const composerMenuOpenRef = useRef(false);803 const composerMenuOpenRef = useRef(false);
786 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);804 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
787 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);805 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);
788 const dragDepthRef = useRef(0);810 const dragDepthRef = useRef(0);
789811
790 // ------------------------------------------------------------------812 // ------------------------------------------------------------------
...@@ -929,6 +951,8 @@ export const ChatComposer = memo(...@@ -929,6 +951,8 @@ export const ChatComposer = memo(
929 isComposerApprovalState ||951 isComposerApprovalState ||
930 pendingUserInputs.length > 0 ||952 pendingUserInputs.length > 0 ||
931 (showPlanFollowUpPrompt && activeProposedPlan !== null);953 (showPlanFollowUpPrompt && activeProposedPlan !== null);
954 const showCollapsedMobilePromptRow =
955 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
932956
933 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;957 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
934 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);958 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
...@@ -1021,6 +1045,11 @@ export const ChatComposer = memo(...@@ -1021,6 +1045,11 @@ export const ChatComposer = memo(
1021 : null,1045 : null,
1022 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],1046 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
1023 );1047 );
1048 const collapsedComposerPrimaryActionDisabled =
1049 phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
1050 const collapsedComposerPrimaryActionLabel = "Send message";
1051 const showMobilePendingAnswerActions =
1052 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
10241053
1025 // ------------------------------------------------------------------1054 // ------------------------------------------------------------------
1026 // Prompt helpers1055 // Prompt helpers
...@@ -1550,6 +1579,69 @@ export const ChatComposer = memo(...@@ -1550,6 +1579,69 @@ export const ChatComposer = memo(
1550 [composerHighlightedItemId, composerMenuItems],1579 [composerHighlightedItemId, composerMenuItems],
1551 );1580 );
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
1553 // ------------------------------------------------------------------1645 // ------------------------------------------------------------------
1554 // Callbacks: command key1646 // Callbacks: command key
1555 // ------------------------------------------------------------------1647 // ------------------------------------------------------------------
...@@ -1580,7 +1672,7 @@ export const ChatComposer = memo(...@@ -1580,7 +1672,7 @@ export const ChatComposer = memo(
1580 }1672 }
1581 }1673 }
1582 if (key === "Enter" && !event.shiftKey) {1674 if (key === "Enter" && !event.shiftKey) {
1583 void onSend();1675 submitComposer();
1584 return true;1676 return true;
1585 }1677 }
1586 return false;1678 return false;
...@@ -1690,6 +1782,50 @@ export const ChatComposer = memo(...@@ -1690,6 +1782,50 @@ export const ChatComposer = memo(
1690 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {1782 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
1691 void onImplementPlanInNewThread();1783 void onImplementPlanInNewThread();
1692 }, [onImplementPlanInNewThread]);1784 }, [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
1694 // ------------------------------------------------------------------1830 // ------------------------------------------------------------------
1695 // Imperative handle1831 // Imperative handle
...@@ -1803,7 +1939,7 @@ export const ChatComposer = memo(...@@ -1803,7 +1939,7 @@ export const ChatComposer = memo(
1803 return (1939 return (
1804 <form1940 <form
1805 ref={composerFormRef}1941 ref={composerFormRef}
1806 onSubmit={onSend}1942 onSubmit={submitComposer}
1807 className="mx-auto w-full min-w-0 max-w-208"1943 className="mx-auto w-full min-w-0 max-w-208"
1808 data-chat-composer-form="true"1944 data-chat-composer-form="true"
1809 >1945 >
...@@ -1818,21 +1954,82 @@ export const ChatComposer = memo(...@@ -1818,21 +1954,82 @@ export const ChatComposer = memo(
1818 onDrop={onComposerDrop}1954 onDrop={onComposerDrop}
1819 >1955 >
1820 <div1956 <div
1957 ref={composerSurfaceRef}
1958 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
1821 className={cn(1959 className={cn(
1822 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",1960 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
1823 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",1961 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1824 composerProviderState.composerSurfaceClassName,1962 composerProviderState.composerSurfaceClassName,
1825 )}1963 )}
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 }}
1826 >1982 >
1827 {activePendingApproval ? (1983 {!isComposerCollapsedMobile &&
1828 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">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 >
1829 <ComposerPendingApprovalPanel2016 <ComposerPendingApprovalPanel
1830 approval={activePendingApproval}2017 approval={activePendingApproval}
1831 pendingCount={pendingApprovals.length}2018 pendingCount={pendingApprovals.length}
1832 />2019 />
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>
1833 </div>2027 </div>
1834 ) : pendingUserInputs.length > 0 ? (2028 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
1835 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">2029 <div
2030 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2031 data-chat-composer-collapsed-controls="true"
2032 >
1836 <ComposerPendingUserInputPanel2033 <ComposerPendingUserInputPanel
1837 pendingUserInputs={pendingUserInputs}2034 pendingUserInputs={pendingUserInputs}
1838 respondingRequestIds={respondingRequestIds}2035 respondingRequestIds={respondingRequestIds}
...@@ -1841,13 +2038,91 @@ export const ChatComposer = memo(...@@ -1841,13 +2038,91 @@ export const ChatComposer = memo(
1841 onToggleOption={onSelectActivePendingUserInputOption}2038 onToggleOption={onSelectActivePendingUserInputOption}
1842 onAdvance={onAdvanceActivePendingUserInput}2039 onAdvance={onAdvanceActivePendingUserInput}
1843 />2040 />
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>
1844 </div>2083 </div>
1845 ) : showPlanFollowUpPrompt && activeProposedPlan ? (2084 ) : null}
1846 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">2085
1847 <ComposerPlanFollowUpBanner2086 {showCollapsedMobilePromptRow ? (
1848 key={activeProposedPlan.id}2087 <div className="flex items-center justify-between gap-2 px-3 py-2">
1849 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}2088 <button
1850 />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>
1851 </div>2126 </div>
1852 ) : null}2127 ) : null}
18532128
...@@ -1855,6 +2130,7 @@ export const ChatComposer = memo(...@@ -1855,6 +2130,7 @@ export const ChatComposer = memo(
1855 className={cn(2130 className={cn(
1856 "relative px-3 pb-2 sm:px-4",2131 "relative px-3 pb-2 sm:px-4",
1857 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",2132 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2133 isComposerCollapsedMobile && "hidden",
1858 )}2134 )}
1859 >2135 >
1860 {composerMenuOpen && !isComposerApprovalState && (2136 {composerMenuOpen && !isComposerApprovalState && (
...@@ -1876,7 +2152,8 @@ export const ChatComposer = memo(...@@ -1876,7 +2152,8 @@ export const ChatComposer = memo(
1876 </div>2152 </div>
1877 )}2153 )}
18782154
1879 {!isComposerApprovalState &&2155 {!isComposerCollapsedMobile &&
2156 !isComposerApprovalState &&
1880 pendingUserInputs.length === 0 &&2157 pendingUserInputs.length === 0 &&
1881 composerImages.length > 0 && (2158 composerImages.length > 0 && (
1882 <div className="mb-3 flex flex-wrap gap-2">2159 <div className="mb-3 flex flex-wrap gap-2">
...@@ -1943,43 +2220,69 @@ export const ChatComposer = memo(...@@ -1943,43 +2220,69 @@ export const ChatComposer = memo(
1943 </div>2220 </div>
1944 )}2221 )}
19452222
1946 <ComposerPromptEditor2223 <div className="relative">
1947 ref={composerEditorRef}2224 <ComposerPromptEditor
1948 value={2225 ref={composerEditorRef}
1949 isComposerApprovalState2226 value={
1950 ? ""2227 isComposerApprovalState
1951 : activePendingProgress2228 ? ""
1952 ? activePendingProgress.customAnswer2229 : activePendingProgress
1953 : prompt2230 ? activePendingProgress.customAnswer
1954 }2231 : prompt
1955 cursor={composerCursor}2232 }
1956 terminalContexts={2233 cursor={composerCursor}
1957 !isComposerApprovalState && pendingUserInputs.length === 02234 terminalContexts={
1958 ? composerTerminalContexts2235 !isComposerApprovalState && pendingUserInputs.length === 0
1959 : []2236 ? composerTerminalContexts
1960 }2237 : []
1961 skills={selectedProviderStatus?.skills ?? []}2238 }
1962 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}2239 skills={selectedProviderStatus?.skills ?? []}
1963 onChange={onPromptChange}2240 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
1964 onCommandKeyDown={onComposerCommandKey}2241 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
1965 onPaste={onComposerPaste}2242 onChange={onPromptChange}
1966 placeholder={2243 onCommandKeyDown={onComposerCommandKey}
1967 isComposerApprovalState2244 onPaste={onComposerPaste}
1968 ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")2245 placeholder={
1969 : activePendingProgress2246 isComposerApprovalState
1970 ? "Type your own answer, or leave this blank to use the selected option"2247 ? (activePendingApproval?.detail ??
1971 : showPlanFollowUpPrompt && activeProposedPlan2248 "Resolve this approval request to continue")
1972 ? "Add feedback to refine the plan, or leave this blank to implement it"2249 : activePendingProgress
1973 : phase === "disconnected"2250 ? "Type your own answer, or leave this blank to use the selected option"
1974 ? "Ask for follow-up changes or attach images"2251 : showPlanFollowUpPrompt && activeProposedPlan
1975 : "Ask anything, @tag files/folders, or use / to show available commands"2252 ? "Add feedback to refine the plan, or leave this blank to implement it"
1976 }2253 : phase === "disconnected"
1977 disabled={isConnecting || isComposerApprovalState}2254 ? "Ask for follow-up changes or attach images"
1978 />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>
1979 </div>2282 </div>
19802283
1981 {/* Bottom toolbar */}2284 {/* Bottom toolbar */}
1982 {activePendingApproval ? (2285 {isComposerCollapsedMobile ? null : activePendingApproval ? (
1983 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">2286 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
1984 <ComposerPendingApprovalActions2287 <ComposerPendingApprovalActions
1985 requestId={activePendingApproval.requestId}2288 requestId={activePendingApproval.requestId}
...@@ -1994,6 +2297,7 @@ export const ChatComposer = memo(...@@ -1994,6 +2297,7 @@ export const ChatComposer = memo(
1994 className={cn(2297 className={cn(
1995 "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",2298 "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",
1996 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",2299 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
2300 showMobilePendingAnswerActions && "hidden sm:flex",
1997 )}2301 )}
1998 >2302 >
1999 <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">2303 <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(...@@ -2082,6 +2386,7 @@ export const ChatComposer = memo(
2082 isConnecting={isConnecting}2386 isConnecting={isConnecting}
2083 isPreparingWorktree={isPreparingWorktree}2387 isPreparingWorktree={isPreparingWorktree}
2084 hasSendableContent={composerSendState.hasSendableContent}2388 hasSendableContent={composerSendState.hasSendableContent}
2389 preserveComposerFocusOnPointerDown={isMobileViewport}
2085 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}2390 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2086 onInterrupt={handleInterruptPrimaryAction}2391 onInterrupt={handleInterruptPrimaryAction}
2087 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}2392 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+19-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { memo } from "react";1import { memo, type PointerEventHandler } from "react";
2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
4import { Button } from "../ui/button";4import { Button } from "../ui/button";
...@@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps {...@@ -22,6 +22,7 @@ interface ComposerPrimaryActionsProps {
22 isConnecting: boolean;22 isConnecting: boolean;
23 isPreparingWorktree: boolean;23 isPreparingWorktree: boolean;
24 hasSendableContent: boolean;24 hasSendableContent: boolean;
25 preserveComposerFocusOnPointerDown?: boolean;
25 onPreviousPendingQuestion: () => void;26 onPreviousPendingQuestion: () => void;
26 onInterrupt: () => void;27 onInterrupt: () => void;
27 onImplementPlanInNewThread: () => void;28 onImplementPlanInNewThread: () => void;
...@@ -45,6 +46,10 @@ export const formatPendingPrimaryActionLabel = (input: {...@@ -45,6 +46,10 @@ export const formatPendingPrimaryActionLabel = (input: {
45 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";46 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";
46};47};
4748
49const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
50 event.preventDefault();
51};
52
48export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({53export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
49 compact,54 compact,
50 pendingAction,55 pendingAction,
...@@ -55,10 +60,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -55,10 +60,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
55 isConnecting,60 isConnecting,
56 isPreparingWorktree,61 isPreparingWorktree,
57 hasSendableContent,62 hasSendableContent,
63 preserveComposerFocusOnPointerDown = false,
58 onPreviousPendingQuestion,64 onPreviousPendingQuestion,
59 onInterrupt,65 onInterrupt,
60 onImplementPlanInNewThread,66 onImplementPlanInNewThread,
61}: ComposerPrimaryActionsProps) {67}: ComposerPrimaryActionsProps) {
68 const pointerFocusProps = preserveComposerFocusOnPointerDown
69 ? { onPointerDown: preventPointerFocus }
70 : undefined;
71
62 if (pendingAction) {72 if (pendingAction) {
63 return (73 return (
64 <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>74 <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
...@@ -68,6 +78,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -68,6 +78,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
68 size="icon-sm"78 size="icon-sm"
69 variant="outline"79 variant="outline"
70 className="rounded-full"80 className="rounded-full"
81 {...pointerFocusProps}
71 onClick={onPreviousPendingQuestion}82 onClick={onPreviousPendingQuestion}
72 disabled={pendingAction.isResponding}83 disabled={pendingAction.isResponding}
73 aria-label="Previous question"84 aria-label="Previous question"
...@@ -79,6 +90,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -79,6 +90,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
79 size="sm"90 size="sm"
80 variant="outline"91 variant="outline"
81 className="rounded-full"92 className="rounded-full"
93 {...pointerFocusProps}
82 onClick={onPreviousPendingQuestion}94 onClick={onPreviousPendingQuestion}
83 disabled={pendingAction.isResponding}95 disabled={pendingAction.isResponding}
84 >96 >
...@@ -90,6 +102,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -90,6 +102,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
90 type="submit"102 type="submit"
91 size="sm"103 size="sm"
92 className={cn("rounded-full", compact ? "px-3" : "px-4")}104 className={cn("rounded-full", compact ? "px-3" : "px-4")}
105 {...pointerFocusProps}
93 disabled={106 disabled={
94 pendingAction.isResponding ||107 pendingAction.isResponding ||
95 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)108 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
...@@ -111,6 +124,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -111,6 +124,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
111 <button124 <button
112 type="button"125 type="button"
113 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"126 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}
114 onClick={onInterrupt}128 onClick={onInterrupt}
115 aria-label="Stop generation"129 aria-label="Stop generation"
116 >130 >
...@@ -128,6 +142,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -128,6 +142,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
128 type="submit"142 type="submit"
129 size="sm"143 size="sm"
130 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}144 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
145 {...pointerFocusProps}
131 disabled={isSendBusy || isConnecting}146 disabled={isSendBusy || isConnecting}
132 >147 >
133 {isConnecting || isSendBusy ? "Sending..." : "Refine"}148 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
...@@ -141,6 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -141,6 +156,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
141 type="submit"156 type="submit"
142 size="sm"157 size="sm"
143 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"158 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
159 {...pointerFocusProps}
144 disabled={isSendBusy || isConnecting}160 disabled={isSendBusy || isConnecting}
145 >161 >
146 {isConnecting || isSendBusy ? "Sending..." : "Implement"}162 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
...@@ -153,6 +169,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -153,6 +169,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
153 variant="default"169 variant="default"
154 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"170 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
155 aria-label="Implementation actions"171 aria-label="Implementation actions"
172 {...pointerFocusProps}
156 disabled={isSendBusy || isConnecting}173 disabled={isSendBusy || isConnecting}
157 />174 />
158 }175 }
...@@ -176,6 +193,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -176,6 +193,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
176 <button193 <button
177 type="submit"194 type="submit"
178 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"195 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}
179 disabled={isSendBusy || isConnecting || !hasSendableContent}197 disabled={isSendBusy || isConnecting || !hasSendableContent}
180 aria-label={198 aria-label={
181 isConnecting199 isConnecting