From 11b3b5c1eee66a3e7a46b90620c87159a903131f Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 21 Aug 2026 23:23:07 +0100 Subject: [PATCH 1/6] fix(web): collapse the resting composer without replacing it --- .../src/components/ComposerPromptEditor.tsx | 11 ++- apps/web/src/components/chat/ChatComposer.tsx | 87 ++++++++++++++++--- .../components/composerFooterLayout.test.ts | 30 +++++++ .../src/components/composerFooterLayout.ts | 11 +++ 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..78ef40f5fafa 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -885,6 +885,7 @@ interface ComposerPromptEditorProps { disabled: boolean; placeholder: string; className?: string; + placeholderClassName?: string; onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, @@ -1534,6 +1535,7 @@ function ComposerPromptEditorInner({ disabled, placeholder, className, + placeholderClassName, onRemoveTerminalContext, onChange, onCommandKeyDown, @@ -1765,7 +1767,12 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) @@ -1795,6 +1802,7 @@ export function ComposerPromptEditor({ disabled, placeholder, className, + placeholderClassName, onRemoveTerminalContext, onChange, onCommandKeyDown, @@ -1838,6 +1846,7 @@ export function ComposerPromptEditor({ editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} + {...(placeholderClassName ? { placeholderClassName } : {})} /> ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f06a9658225f..fd7d07a89b84 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -92,6 +92,7 @@ import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, + shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; @@ -439,19 +440,20 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; showSendWhileRunning?: boolean; + showSecondaryStatus: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; }) { return ( <> - {props.activeContextWindow ? ( + {props.showSecondaryStatus && props.activeContextWindow ? ( ) : null} - {props.isPreparingWorktree ? ( + {props.showSecondaryStatus && props.isPreparingWorktree ? ( Preparing worktree... ) : null} 0 || + composerTerminalContexts.length > 0 || + composerElementContexts.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0; + const isComposerResting = shouldUseRestingComposerLayout({ + isMobileViewport, + isFocused: isComposerFocused, + hasAttachments: composerHasAttachments, + hasExpandedChrome: + showComposerTopDrawer || + isTasksDrawerOpen || + composerMenuOpen || + isStashMenuOpen || + isComposerModelPickerOpen || + isDragOverComposer || + isPreparingWorktree || + noProviderAvailable || + projectSelectionRequired || + environmentUnavailable !== null || + composerSubmissionError !== null || + providerInputSubmissionError !== null, + }); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; const composerFooterActionLayoutKey = useMemo(() => { @@ -2635,10 +2661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void onImplementPlanInNewThread(); }, [onImplementPlanInNewThread]); const scheduleComposerCollapseCheck = useCallback(() => { - if (!isMobileViewport) { - return; - } - if (mobileComposerExpandInFlightRef.current) { + if (isMobileViewport && mobileComposerExpandInFlightRef.current) { return; } if (composerBlurFrameRef.current !== null) { @@ -2646,7 +2669,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } composerBlurFrameRef.current = window.requestAnimationFrame(() => { composerBlurFrameRef.current = null; - if (mobileComposerExpandInFlightRef.current) { + if (isMobileViewport && mobileComposerExpandInFlightRef.current) { return; } const composerSurface = composerSurfaceRef.current; @@ -2666,6 +2689,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }, [isMobileViewport]); + useEffect(() => { + if (isMobileViewport || !isComposerFocused) return; + + const reconcileDesktopFocus = (target: EventTarget | null) => { + if ( + target instanceof Node && + (composerFormRef.current?.contains(target) || + (target instanceof Element && isInsideComposerFloatingLayer(target))) + ) { + return; + } + setIsComposerFocused(false); + }; + const handleFocusIn = (event: FocusEvent) => reconcileDesktopFocus(event.target); + const handlePointerDown = (event: PointerEvent) => reconcileDesktopFocus(event.target); + + document.addEventListener("focusin", handleFocusIn, true); + document.addEventListener("pointerdown", handlePointerDown, true); + return () => { + document.removeEventListener("focusin", handleFocusIn, true); + document.removeEventListener("pointerdown", handlePointerDown, true); + }; + }, [isComposerFocused, isMobileViewport]); + useEffect(() => { return () => { if (composerBlurFrameRef.current !== null) { @@ -3001,6 +3048,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ref={composerSurfaceRef} data-chat-composer-surface="true" data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} + data-chat-composer-resting={isComposerResting ? "true" : "false"} className={cn( "rounded-[20px] transition-[background-color] duration-200", isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null, @@ -3061,6 +3109,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "pt-3.5 sm:pt-4", isComposerApprovalState && "pb-3 sm:pb-4", isComposerCollapsedMobile && "hidden", + isComposerResting && "py-2 sm:py-2", )} > {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( @@ -3228,7 +3277,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : [] } skills={selectedProviderStatus?.skills ?? []} - {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} + className={cn( + showMobilePendingAnswerActions && "max-sm:pb-11", + isComposerResting && + "max-h-8 min-h-8 overflow-hidden whitespace-nowrap pr-12 leading-8", + )} + placeholderClassName={cn( + isComposerResting && + "flex items-center overflow-hidden whitespace-nowrap pr-12 leading-8", + )} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} onCommandKeyDown={onComposerCommandKey} @@ -3298,9 +3355,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs.length > 0 && "pt-2", isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", + isComposerResting && + "absolute inset-y-px right-px z-10 w-auto gap-0 px-2 py-0 sm:gap-0 sm:px-2 sm:py-0", )} > -
+
{noProviderAvailable ? (
)} -
+
Date: Sat, 22 Aug 2026 22:41:38 +0100 Subject: [PATCH 4/6] fix(web): collapse the composer smoothly on scroll --- apps/web/src/components/chat/ChatComposer.tsx | 227 ++++++++++++++++-- .../src/components/chat/MessagesTimeline.tsx | 1 + 2 files changed, 213 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3b31236e6d3f..5a86867b181c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -132,6 +132,79 @@ type ComposerCommandMenuPosition = { width: number; }; +const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; +const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; +const COMPOSER_RESTING_TRANSITION_DURATION_MS = 180; +const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)"; + +function useComposerRestingTransition(isResting: boolean) { + const elementRef = useRef(null); + const previousRestingRef = useRef(isResting); + const previousHeightRef = useRef(null); + const animationRef = useRef(null); + + useLayoutEffect(() => { + const element = elementRef.current; + const surface = element?.querySelector('[data-chat-composer-surface="true"]'); + if (!element || !surface) return; + + const interruptedHeight = animationRef.current ? element.getBoundingClientRect().height : null; + animationRef.current?.cancel(); + animationRef.current = null; + element.style.removeProperty("overflow"); + surface.style.removeProperty("height"); + + const nextHeight = element.getBoundingClientRect().height; + const previousHeight = interruptedHeight ?? previousHeightRef.current; + const stateChanged = previousRestingRef.current !== isResting; + const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + + if ( + stateChanged && + !prefersReducedMotion && + previousHeight !== null && + Math.abs(previousHeight - nextHeight) >= 0.5 + ) { + element.style.overflow = "clip"; + surface.style.height = "100%"; + const animation = element.animate( + [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], + { + duration: COMPOSER_RESTING_TRANSITION_DURATION_MS, + easing: COMPOSER_RESTING_TRANSITION_EASING, + }, + ); + animation.id = "t3-composer-resting-transition"; + animationRef.current = animation; + void animation.finished + .catch(() => undefined) + .then(() => { + if (animationRef.current !== animation) return; + animationRef.current = null; + element.style.removeProperty("overflow"); + surface.style.removeProperty("height"); + }); + } + + previousRestingRef.current = isResting; + previousHeightRef.current = nextHeight; + }, [isResting]); + + useEffect(() => { + return () => { + animationRef.current?.cancel(); + animationRef.current = null; + const element = elementRef.current; + element?.style.removeProperty("overflow"); + element + ?.querySelector('[data-chat-composer-surface="true"]') + ?.style.removeProperty("height"); + }; + }, []); + + return elementRef; +} + function composerCommandMenuPositionsEqual( a: ComposerCommandMenuPosition, b: ComposerCommandMenuPosition, @@ -986,6 +1059,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1017,6 +1091,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); + const desktopOutsidePointerInFlightRef = useRef(false); + const desktopOutsidePointerReleaseTimeoutRef = useRef(null); + const composerScrollCollapseDeltaRef = useRef(0); + const composerScrollCollapseTimeoutRef = useRef(null); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); /** @@ -1490,6 +1568,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); + setIsComposerScrollCollapsed(false); }, [draftId, activeThreadId, promptRef]); // ------------------------------------------------------------------ @@ -1617,6 +1696,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => { + setIsComposerScrollCollapsed(false); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); setComposerTrigger( @@ -2391,25 +2471,88 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) steps={visibleTaskSteps} /> ) : null; + const composerHasExpandedChrome = + showComposerTopDrawer || + isTasksDrawerOpen || + composerMenuOpen || + isStashMenuOpen || + isComposerModelPickerOpen || + isDragOverComposer || + isPreparingWorktree || + noProviderAvailable || + projectSelectionRequired || + environmentUnavailable !== null || + composerSubmissionError !== null || + providerInputSubmissionError !== null; const isComposerResting = shouldUseRestingComposerLayout({ isMobileViewport, - isFocused: isComposerFocused, + isFocused: isComposerFocused && !isComposerScrollCollapsed, hasAttachments: composerHasAttachments, - hasExpandedChrome: - showComposerTopDrawer || - isTasksDrawerOpen || - composerMenuOpen || - isStashMenuOpen || - isComposerModelPickerOpen || - isDragOverComposer || - isPreparingWorktree || - noProviderAvailable || - projectSelectionRequired || - environmentUnavailable !== null || - composerSubmissionError !== null || - providerInputSubmissionError !== null, + hasExpandedChrome: composerHasExpandedChrome, hasInlineAccessories: showInlineTasksBadge || showInlineStashBadge, }); + const composerMainSurfaceRef = useComposerRestingTransition(isComposerResting); + const canScrollCollapseComposer = + routeKind === "server" && + activeThreadId !== null && + !isMobileViewport && + !composerHasAttachments && + !composerHasExpandedChrome && + !showInlineTasksBadge && + !showInlineStashBadge; + + useEffect(() => { + if (!canScrollCollapseComposer) return; + + const resetAccumulatedScroll = () => { + if (composerScrollCollapseTimeoutRef.current !== null) { + window.clearTimeout(composerScrollCollapseTimeoutRef.current); + } + composerScrollCollapseTimeoutRef.current = null; + composerScrollCollapseDeltaRef.current = 0; + }; + const handleTimelineWheel = (event: WheelEvent) => { + if (!isComposerFocused || event.ctrlKey || !(event.target instanceof Element)) { + return; + } + + const scrollNode = event.target.closest('[data-chat-messages-timeline="true"]'); + if (!scrollNode) return; + const canScrollInGestureDirection = + event.deltaY < 0 + ? scrollNode.scrollTop > 0 + : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight; + if (!canScrollInGestureDirection) return; + + const deltaPx = + Math.abs(event.deltaY) * + (event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? scrollNode.clientHeight + : 1); + composerScrollCollapseDeltaRef.current += deltaPx; + if (composerScrollCollapseTimeoutRef.current !== null) { + window.clearTimeout(composerScrollCollapseTimeoutRef.current); + } + composerScrollCollapseTimeoutRef.current = window.setTimeout( + resetAccumulatedScroll, + COMPOSER_SCROLL_GESTURE_RESET_MS, + ); + if (composerScrollCollapseDeltaRef.current < COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX) { + return; + } + + resetAccumulatedScroll(); + setIsComposerScrollCollapsed(true); + }; + + document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); + return () => { + document.removeEventListener("wheel", handleTimelineWheel, true); + resetAccumulatedScroll(); + }; + }, [canScrollCollapseComposer, isComposerFocused]); const showShoulderTabs = !props.externalDrawerAttached && !showComposerTopDrawer && @@ -2673,6 +2816,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isMobileViewport && mobileComposerExpandInFlightRef.current) { return; } + if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) { + return; + } const composerSurface = composerSurfaceRef.current; const composerForm = composerFormRef.current; const activeElement = document.activeElement; @@ -2701,20 +2847,59 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const handleFocusIn = (event: FocusEvent) => { if (!isInsideDesktopComposerFocusScope(event.target)) { + if (desktopOutsidePointerInFlightRef.current) { + return; + } setIsComposerFocused(false); } }; const handlePointerDown = (event: PointerEvent) => { if (!isInsideDesktopComposerFocusScope(event.target)) { - scheduleComposerCollapseCheck(); + desktopOutsidePointerInFlightRef.current = true; + if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { + window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); + desktopOutsidePointerReleaseTimeoutRef.current = null; + } + } + }; + const finishOutsidePointerInteraction = () => { + desktopOutsidePointerInFlightRef.current = false; + if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { + window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); + desktopOutsidePointerReleaseTimeoutRef.current = null; + } + scheduleComposerCollapseCheck(); + }; + const handlePointerUp = () => { + if (!desktopOutsidePointerInFlightRef.current) return; + desktopOutsidePointerReleaseTimeoutRef.current = window.setTimeout(() => { + if (desktopOutsidePointerInFlightRef.current) { + finishOutsidePointerInteraction(); + } + }, 0); + }; + const handleClick = () => { + if (desktopOutsidePointerInFlightRef.current) { + finishOutsidePointerInteraction(); } }; document.addEventListener("focusin", handleFocusIn, true); document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("pointerup", handlePointerUp, true); + document.addEventListener("pointercancel", handlePointerUp, true); + document.addEventListener("click", handleClick); return () => { document.removeEventListener("focusin", handleFocusIn, true); document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("pointerup", handlePointerUp, true); + document.removeEventListener("pointercancel", handlePointerUp, true); + document.removeEventListener("click", handleClick); + if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { + window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); + desktopOutsidePointerReleaseTimeoutRef.current = null; + } + desktopOutsidePointerInFlightRef.current = false; }; }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]); @@ -2875,6 +3060,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setIsComposerScrollCollapsed(false)} + onPointerDownCapture={(event) => { + const target = event.target; + if ( + !(target instanceof Element) || + !target.closest('button, a, input, select, [role="button"], [role="menuitem"]') + ) { + setIsComposerScrollCollapsed(false); + } + }} onFocusCapture={(event) => { const activeElement = event.target; if ( @@ -2884,6 +3079,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } + setIsComposerScrollCollapsed(false); if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -3043,6 +3239,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null}
ref={listRef} + data-chat-messages-timeline="true" data={rows} keyExtractor={keyExtractor} getItemType={getItemType} From 2ee2ae14a2b7e02915fa86474be8eff57bae2562 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 22 Aug 2026 23:05:05 +0100 Subject: [PATCH 5/6] fix(web): stabilize composer scroll collapse --- apps/web/src/components/chat/ChatComposer.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5a86867b181c..7bfbf89bee22 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -190,6 +190,18 @@ function useComposerRestingTransition(isResting: boolean) { previousHeightRef.current = nextHeight; }, [isResting]); + useLayoutEffect(() => { + const element = elementRef.current; + if (!element || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(() => { + if (animationRef.current) return; + previousHeightRef.current = element.getBoundingClientRect().height; + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + useEffect(() => { return () => { animationRef.current?.cancel(); @@ -2512,7 +2524,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerScrollCollapseDeltaRef.current = 0; }; const handleTimelineWheel = (event: WheelEvent) => { - if (!isComposerFocused || event.ctrlKey || !(event.target instanceof Element)) { + const activeElement = document.activeElement; + const isPromptEditorFocused = + activeElement instanceof HTMLElement && + activeElement.isContentEditable && + composerFormRef.current?.contains(activeElement) === true; + if (!isPromptEditorFocused || event.ctrlKey || !(event.target instanceof Element)) { return; } @@ -2552,7 +2569,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) document.removeEventListener("wheel", handleTimelineWheel, true); resetAccumulatedScroll(); }; - }, [canScrollCollapseComposer, isComposerFocused]); + }, [canScrollCollapseComposer]); const showShoulderTabs = !props.externalDrawerAttached && !showComposerTopDrawer && From c80648642a71f9a418520dc8a77ea9b949e80413 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 22 Aug 2026 22:21:42 -0400 Subject: [PATCH 6/6] fix(web): keep resting composer controls inline --- apps/web/src/components/BranchToolbar.tsx | 30 +- .../BranchToolbarBranchSelector.tsx | 2 +- .../BranchToolbarEnvModeSelector.tsx | 4 +- .../BranchToolbarEnvironmentSelector.tsx | 4 +- apps/web/src/components/ChatView.tsx | 4 + apps/web/src/components/chat/ChatComposer.tsx | 355 ++++++++++++++---- .../src/components/chat/ComposerControl.tsx | 7 +- .../components/chat/ProviderInstanceIcon.tsx | 1 + .../components/chat/ProviderModelPicker.tsx | 17 +- apps/web/src/components/chat/TraitsPicker.tsx | 16 +- .../components/chat/composerProviderState.tsx | 11 + 11 files changed, 353 insertions(+), 98 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..0950d2003061 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; +import { cn } from "~/lib/utils"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -57,6 +58,7 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + composerControlsHostRef?: (element: HTMLDivElement | null) => void; } interface MobileRunContextSelectorProps { @@ -126,7 +128,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -136,7 +138,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } - className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + className="min-w-0 max-w-[48%] flex-1 justify-start font-normal text-muted-foreground/70 hover:text-foreground/80 md:hidden" > {triggerContent} @@ -265,6 +267,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let groups = 0; for (const child of current.children) { if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + if (child.matches('[data-chat-resting-composer-controls-host="true"]')) continue; needed += contentWidth(child); groups += 1; } @@ -389,6 +392,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + composerControlsHostRef, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -469,7 +473,11 @@ export const BranchToolbar = memo(function BranchToolbar({
{isMobile && showGitControls ? ( ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> )} + {composerControlsHostRef ? ( +
+ ) : null} + {showGitControls ? ( } - className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" + className="min-w-0 max-w-full font-normal text-muted-foreground/70 hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 9fc2d4892e27..bd8d1adfd239 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -51,7 +51,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( {activeWorktreePath ? ( @@ -85,7 +85,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index b5d5751a280b..1ef8af471e56 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { return ( {activeEnvironment?.isPrimary ? ( @@ -82,7 +82,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8dcb6b8ed932..8a80c9e71174 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1379,6 +1379,8 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [restingComposerControlsHost, setRestingComposerControlsHost] = + useState(null); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -6671,6 +6673,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + restingControlsHost={restingComposerControlsHost} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} @@ -6733,6 +6736,7 @@ function ChatViewContent(props: ChatViewProps) { : {})} {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} availableEnvironments={logicalProjectEnvironments} + composerControlsHostRef={setRestingComposerControlsHost} />
)} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7bfbf89bee22..afed9fa733c6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -411,13 +411,75 @@ function isInsideComposerFloatingLayer(element: Element): boolean { return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; } +function useRestingComposerControlsLayout(host: HTMLDivElement | null) { + const controlsRef = useRef(null); + const hostRef = useRef(host); + hostRef.current = host; + const [hiddenCount, setHiddenCount] = useState(0); + + const measure = useCallback(() => { + const currentHost = hostRef.current; + const controls = controlsRef.current; + if (!currentHost || !controls || currentHost.clientWidth === 0) return; + + const blocks = Array.from(controls.querySelectorAll("[data-resting-block]")); + if (blocks.length === 0) return; + + const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0; + const picker = controls.querySelector("[data-chat-provider-model-picker]"); + const separator = controls.querySelector("[data-resting-controls-separator]"); + const overflow = controls.querySelector("[data-resting-controls-overflow]"); + const fixedWidth = + (picker?.getBoundingClientRect().width ?? 0) + + (separator ? separator.getBoundingClientRect().width + gap : 0); + const blockWidths = blocks.map((block) => block.getBoundingClientRect().width); + const overflowWidth = overflow?.getBoundingClientRect().width ?? 0; + + setHiddenCount((current) => { + const widthWithHidden = (hidden: number) => { + const visibleCount = blockWidths.length - hidden; + return ( + fixedWidth + + blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + + (hidden > 0 ? overflowWidth : 0) + + gap * (visibleCount + (hidden > 0 ? 1 : 0)) + ); + }; + + let next = Math.min(current, blockWidths.length); + while (next < blockWidths.length && widthWithHidden(next) > currentHost.clientWidth) + next += 1; + while (next > 0 && widthWithHidden(next - 1) <= currentHost.clientWidth - 16) { + next -= 1; + } + return next; + }); + }, []); + + useLayoutEffect(measure); + useEffect(() => { + if (!host) return; + const observer = new ResizeObserver(measure); + observer.observe(host); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [host, measure]); + + return { controlsRef, hiddenBlockCount: hiddenCount }; +} + const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + size?: "sm" | "xs"; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const size = props.size ?? "sm"; const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -427,16 +489,22 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const interactionModeToggle = props.showInteractionModeToggle ? ( <> - +