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/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..afed9fa733c6 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"; @@ -131,6 +132,91 @@ 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]); + + 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(); + 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, @@ -325,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 = @@ -341,16 +489,22 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const interactionModeToggle = props.showInteractionModeToggle ? ( <> - +