From 0186b1b10cac2e730e43d859fcd9df28e8300236 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:04:28 +0000 Subject: [PATCH 1/2] fix(web): hold the resting prompt to its reserved width The prompt row now starts the prompt from its reserved width and gives the controls cluster the row's shrink weight, so a picker the measurement allowed to contract does so before the prompt loses its reservation. Image previews sit beside the prompt as their own row item and the measurement reads them from the row. Mention drags landing on the inline controls or previews reach the composer's drop handling again; only the branch strip below the input and floating menus bypass it. --- .../components/BranchToolbar.logic.test.ts | 42 +- .../web/src/components/BranchToolbar.logic.ts | 4 +- apps/web/src/components/BranchToolbar.tsx | 43 +- apps/web/src/components/ChatView.tsx | 62 +-- apps/web/src/components/chat/ChatComposer.tsx | 410 ++++++------------ .../src/components/chat/ComposerSurface.tsx | 4 +- .../chat/composerEventScope.test.ts | 12 + .../src/components/chat/composerEventScope.ts | 11 + ...restingComposerControlsMeasurement.test.ts | 47 +- .../restingComposerControlsMeasurement.ts | 47 +- .../components/composerFooterLayout.test.ts | 70 +-- .../src/components/composerFooterLayout.ts | 38 +- 12 files changed, 270 insertions(+), 520 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 4de529a3d558..4a4b4b9bb5e2 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -425,22 +425,16 @@ describe("shouldShowEnvironmentIndicator", () => { }); describe("shouldShowComposerContextStrip", () => { - it.each([false, true])( - "honors the active-thread preference with resting controls %s", - (hostsRestingComposerControls) => { - const input = { - isDraftHeroState: false, - hasActiveProject: true, - isGitRepo: true, - showEnvironmentIndicator: true, - hostsRestingComposerControls, - }; - expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: false })).toBe( - false, - ); - expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: true })).toBe(true); - }, - ); + it("honors the active-thread preference", () => { + const input = { + isDraftHeroState: false, + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: true, + }; + expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: false })).toBe(false); + expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: true })).toBe(true); + }); it("keeps the environment indicator visible for a non-Git project", () => { expect( @@ -450,7 +444,6 @@ describe("shouldShowComposerContextStrip", () => { hasActiveProject: true, isGitRepo: false, showEnvironmentIndicator: true, - hostsRestingComposerControls: false, }), ).toBe(true); }); @@ -463,24 +456,10 @@ describe("shouldShowComposerContextStrip", () => { hasActiveProject: true, isGitRepo: false, showEnvironmentIndicator: false, - hostsRestingComposerControls: false, }), ).toBe(false); }); - it("keeps the strip for visible resting composer controls in a non-Git thread", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: false, - hostsRestingComposerControls: true, - }), - ).toBe(true); - }); - it("shows Git controls without requiring an environment indicator", () => { expect( shouldShowComposerContextStrip({ @@ -489,7 +468,6 @@ describe("shouldShowComposerContextStrip", () => { hasActiveProject: true, isGitRepo: true, showEnvironmentIndicator: false, - hostsRestingComposerControls: false, }), ).toBe(true); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index cfa62ad7fe92..ee795f42b274 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -69,13 +69,11 @@ export function shouldShowComposerContextStrip(input: { hasActiveProject: boolean; isGitRepo: boolean; showEnvironmentIndicator: boolean; - /** A collapsed composer's controls currently fit in their measured strip host. */ - hostsRestingComposerControls: boolean; }): boolean { return ( input.hasActiveProject && (input.isDraftHeroState || input.persistInActiveThreads) && - (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) + (input.isGitRepo || input.showEnvironmentIndicator) ); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 7e7d4a71d7d6..fb87b6a553a8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -57,8 +57,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; -import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; -import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; export interface BranchToolbarHandle { @@ -89,8 +87,6 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; - composerControlsHostRef?: (element: HTMLDivElement | null) => void; - contextStripVisible?: boolean; } interface MobileRunContextSelectorProps { @@ -384,20 +380,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let groups = 0; for (const child of current.children) { if (!(child instanceof HTMLElement)) continue; - // The host itself flexes into all remaining room. Reserve the natural - // width of the controls inside it, blocks in overflow included, so Git - // labels compact before squeezing out the model picker. Reserving only - // the visible controls would let the labels expand into room the - // composer just freed, shrink the host, and hide the controls again. - const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') - ? child.querySelector('[data-chat-composer-resting-controls="true"]') - : null; - const hostedMeasurement = hostedControls - ? measureRestingComposerControls(hostedControls) - : null; - const width = hostedMeasurement - ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) - : contentWidth(hostedControls ?? child); + const width = contentWidth(child); if (width <= 1) continue; groups += 1; needed += width; @@ -520,8 +503,6 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, - composerControlsHostRef, - contextStripVisible = true, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = useMemo( @@ -652,13 +633,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {showGitControls ? (
@@ -687,7 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ className={cn( "min-h-7 min-w-10 items-center gap-1 sm:min-h-6", showGitControls ? "hidden @3xl/composer-surface:flex" : "flex", - composerControlsHostRef ? "shrink" : "flex-1", + "flex-1", )} > {showEnvironmentIndicator && availableEnvironments && ( @@ -724,18 +699,6 @@ export const BranchToolbar = memo(function BranchToolbar({
) : null} - {composerControlsHostRef ? ( - // The host takes whatever the workspace and branch controls leave - // over, in both strip layouts, so a collapsed composer can show its - // model and mode controls wherever they fit. -
- ) : null} - {showGitControls ? ( (null); const pasteAsTextShortcutUntilRef = useRef(0); - const [restingComposerControlsHost, setRestingComposerControlsHost] = - useState(null); - const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = useCallback( (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; @@ -4046,9 +4043,6 @@ export default function ChatView(props: ChatViewProps) { } }, [environmentId, gitStatusCwd, liveIsGitRepo]); const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; - // When context is enabled, keep a hidden, off-flow strip mounted so the composer - // can measure whether its relocated controls fit. The visible chrome remains - // content-driven: Git/environment context or controls that actually fit. // A provider-native subagent cannot take messages: a status bar replaces the // composer and its strips. Its approvals and questions are asked on the // top-level parent thread. @@ -4065,25 +4059,13 @@ export default function ChatView(props: ChatViewProps) { activeThread === undefined ? null : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels); - const mountComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server", - }); const showComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); - const mountComposerModelStrip = - routeKind === "server" && !mountComposerContextStrip && !showProviderSubagentBar; - const showComposerModelStrip = mountComposerModelStrip && restingComposerControlsVisible; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -6453,23 +6435,10 @@ export default function ChatView(props: ChatViewProps) { composerMounted: composerMountedRef.current, composerReportedResting: composerRestingRef.current, }); - const modelStrip = composerOverlayElement?.querySelector( - '[data-composer-model-strip="true"]', - ); - // The model-only strip disappears on expansion; counting it again would - // add 32px of timeline padding as soon as the empty composer collapses. - const restingOnlyHeight = - modelStrip && isResting - ? Math.max( - 0, - modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), - ) - : 0; const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, overlayHeight: nextHeight, isResting, - restingOnlyHeight, }); if (composerTimelineInsetRef.current !== nextInset) { composerTimelineInsetRef.current = nextInset; @@ -10783,9 +10752,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > - + - {mountComposerModelStrip ? ( - -
- - ) : null} - {mountComposerContextStrip && ( + {showComposerContextStrip && (
)} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a5ca8d1d5a7c..641a29387175 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -101,6 +101,7 @@ import { useComposerMenuProps, isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + shouldBypassComposerDrag, } from "./composerEventScope"; import { type ComposerFileAttachment, @@ -190,6 +191,7 @@ import { import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { + COMPOSER_RESTING_PROMPT_MIN_PX, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, @@ -197,7 +199,10 @@ import { shouldUseCompactComposerFooter, shouldUseRestingComposerLayout, } from "../composerFooterLayout"; -import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; +import { + measureRestingComposerControls, + measureRestingComposerControlsHostWidth, +} from "./restingComposerControlsMeasurement"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ComposerContextActionsContext, @@ -411,7 +416,6 @@ const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; -const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; function useComposerRestingTransition( isCollapsed: boolean, @@ -426,7 +430,6 @@ function useComposerRestingTransition( const previousCollapsedRef = useRef(isCollapsed); const previousRestingRef = useRef(isResting); const previousHeightRef = useRef(null); - const previousModelStripHeightRef = useRef(null); const previousContentOffsetsRef = useRef<{ promptFromTop: number | null; promptHeight: number | null; @@ -467,8 +470,6 @@ function useComposerRestingTransition( const element = elementRef.current; const footer = element?.querySelector('[data-chat-composer-footer="true"]'); element?.style.removeProperty("overflow"); - element?.style.removeProperty("clip-path"); - element?.style.removeProperty("overflow-clip-margin"); element ?.querySelector('[data-chat-composer-surface="true"]') ?.style.removeProperty("height"); @@ -478,22 +479,6 @@ function useComposerRestingTransition( footer?.style.removeProperty("left"); footer?.style.removeProperty("right"); footer?.style.removeProperty("height"); - const shell = element?.closest('[data-slot="composer-shell"]'); - shell?.removeAttribute("data-model-strip-transition"); - const modelStrip = shell?.querySelector('[data-composer-model-strip="true"]'); - for (const property of [ - "position", - "top", - "visibility", - "height", - "min-height", - "padding-top", - "align-items", - "z-index", - "pointer-events", - ]) { - modelStrip?.style.removeProperty(property); - } clearOverlayPin(); }, [clearOverlayPin]); @@ -516,17 +501,10 @@ function useComposerRestingTransition( ); const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); const footer = element.querySelector('[data-chat-composer-footer="true"]'); - const continuousControls = - element.dataset.inlineRestingControls === "true" || - element.dataset.modelOnlyStrip === "true"; const controls = nextIsCollapsed ? restingControlsRef.current : element.querySelector('[data-chat-composer-controls="left"]'); - const shell = element.closest('[data-slot="composer-shell"]'); - const modelStrip = shell?.querySelector('[data-composer-model-strip="true"]'); - const interruptedStripHeight = - animationRef.current && modelStrip ? modelStrip.getBoundingClientRect().height : null; const interruptedAnimation = animationRef.current; const interruptedPromptTop = interruptedAnimation ? (prompt?.getBoundingClientRect().top ?? null) @@ -569,7 +547,6 @@ function useComposerRestingTransition( const nextRect = element.getBoundingClientRect(); const nextHeight = nextRect.height; - const nextModelStripHeight = modelStrip?.getBoundingClientRect().height ?? null; // The chat view resize-observes the overlay to place the timeline // inset, the scroll-to-end pill, and the mini player. Publishing the // destination height here turns that feedback into one update instead @@ -608,15 +585,6 @@ function useComposerRestingTransition( const duration = interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs; element.style.overflow = "clip"; - if (modelStrip) { - // The model controls cross the input's lower edge on their way to the strip. - const controlsClearance = Math.max( - nextModelStripHeight ?? 0, - previousModelStripHeightRef.current ?? 0, - ); - element.style.overflowClipMargin = `${controlsClearance}px`; - element.style.clipPath = `inset(0 0 -${controlsClearance}px 0)`; - } surface.style.height = "100%"; // Pinning the overlay at the destination height keeps the resize @@ -650,33 +618,6 @@ function useComposerRestingTransition( } } - let stripAnimation: Animation | null = null; - if (modelStrip) { - const stripHeight = modelStrip.getBoundingClientRect().height; - const stripOverlap = -Number.parseFloat(getComputedStyle(modelStrip).marginTop); - const fromHeight = - interruptedStripHeight ?? - (previousCollapsedRef.current - ? (previousModelStripHeightRef.current ?? stripHeight) - : stripOverlap); - const toHeight = nextIsCollapsed ? stripHeight : stripOverlap; - modelStrip.style.position = "relative"; - modelStrip.style.top = "auto"; - modelStrip.style.visibility = "visible"; - modelStrip.style.minHeight = "0"; - // Padding must not impose a 24px minimum on the 16px overlap endpoint. - // Keep the controls bottom-aligned while the strip retracts behind the input. - modelStrip.style.paddingTop = "0"; - modelStrip.style.alignItems = "flex-end"; - modelStrip.style.pointerEvents = "none"; - modelStrip.style.zIndex = "20"; - shell?.setAttribute("data-model-strip-transition", "true"); - stripAnimation = modelStrip.animate( - [{ height: `${fromHeight}px` }, { height: `${toHeight}px` }], - { duration, easing: COMPOSER_RESTING_TRANSITION_EASING, fill: "both" }, - ); - } - const animation = element.animate( [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], { @@ -704,7 +645,7 @@ function useComposerRestingTransition( (previousContentOffsetsRef.current.actionFromBottom === null ? null : previousBottom - previousContentOffsetsRef.current.actionFromBottom); - const contentAnimations: Animation[] = stripAnimation ? [stripAnimation] : []; + const contentAnimations: Animation[] = []; const animateContentPosition = ( content: HTMLElement | null, previousTop: number | null, @@ -727,19 +668,20 @@ function useComposerRestingTransition( }; animateContentPosition(prompt, previousPromptTop); animateContentPosition(action, previousActionTop); - if (continuousControls) { - const previousControlsTop = - interruptedControlsTop ?? - (previousContentOffsetsRef.current.controlsFromBottom === null - ? null - : previousBottom - previousContentOffsetsRef.current.controlsFromBottom); - const previousControlsLeft = - interruptedControlsLeft ?? - (previousContentOffsetsRef.current.controlsFromLeft === null - ? null - : animatedRect.left + previousContentOffsetsRef.current.controlsFromLeft); - animateContentPosition(controls, previousControlsTop, previousControlsLeft); - } + // The model and mode controls stay mounted through both layouts, so + // they slide between the footer row and the prompt row instead of + // teleporting. + const previousControlsTop = + interruptedControlsTop ?? + (previousContentOffsetsRef.current.controlsFromBottom === null + ? null + : previousBottom - previousContentOffsetsRef.current.controlsFromBottom); + const previousControlsLeft = + interruptedControlsLeft ?? + (previousContentOffsetsRef.current.controlsFromLeft === null + ? null + : animatedRect.left + previousContentOffsetsRef.current.controlsFromLeft); + animateContentPosition(controls, previousControlsTop, previousControlsLeft); contentAnimationsRef.current = contentAnimations; if (stateChanged) { @@ -772,37 +714,6 @@ function useComposerRestingTransition( ); } - // The footer controls teleport between the composer footer and the - // context strip below it in a single commit. Fading the arriving - // cluster in along its direction of travel reads as one continuous - // move instead of a pop. Collapsing controls land in empty strip - // space and can appear immediately, but expanding controls return - // to the bottom row the prompt still occupies while the surface is - // short, so they stay hidden through the first half of the tween - // and fade in once the geometry has mostly settled. - const arrivingControls = nextIsCollapsed - ? restingControlsRef.current - : element.querySelector('[data-chat-composer-controls="left"]'); - if (arrivingControls && !continuousControls) { - const drift = nextIsCollapsed - ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX - : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX; - stateChangeAnimations.push( - arrivingControls.animate( - [ - { opacity: 0, transform: `translateY(${String(drift)}px)` }, - { opacity: 1, transform: "none" }, - ], - { - duration: nextIsCollapsed ? duration : duration / 2, - delay: nextIsCollapsed ? 0 : duration / 2, - fill: "backwards", - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - } - const arrivingImagePreviews = nextIsCollapsed ? Array.from( element.querySelectorAll('[data-chat-composer-resting-images="true"]'), @@ -843,7 +754,6 @@ function useComposerRestingTransition( contentAnimationsRef.current = []; stateChangeAnimationsRef.current = []; animation.cancel(); - stripAnimation?.cancel(); clearTransitionStyles(); }; void animation.finished.catch(() => undefined).then(() => finishTransition(false)); @@ -860,7 +770,6 @@ function useComposerRestingTransition( previousCollapsedRef.current = nextIsCollapsed; previousHeightRef.current = nextHeight; - previousModelStripHeightRef.current = nextModelStripHeight; previousContentOffsetsRef.current = { promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top, promptHeight: nextPromptRect?.height ?? null, @@ -883,8 +792,7 @@ function useComposerRestingTransition( const requestId = transitionLayoutRequestRef.current + 1; transitionLayoutRequestRef.current = requestId; const stateChanged = previousCollapsedRef.current !== isCollapsed; - // A non-Git context strip enters or leaves flow through ChatView state in - // an earlier layout effect. Let React flush that parent update before the + // Let React flush parent updates from earlier layout effects before the // FLIP reads its destination geometry, while still running before paint. queueMicrotask(() => { if (transitionLayoutRequestRef.current !== requestId) return; @@ -1166,11 +1074,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro const measurement = measureRestingComposerControls(controls); if (!measurement) return; - const style = getComputedStyle(currentHost); - const hostWidth = - currentHost.clientWidth - - (Number.parseFloat(style.paddingInlineStart) || 0) - - (Number.parseFloat(style.paddingInlineEnd) || 0); + const hostWidth = measureRestingComposerControlsHostWidth(currentHost); setLayout((current) => { const next = resolveRestingComposerControlsLayout({ @@ -1596,9 +1500,6 @@ export interface ChatComposerProps { gitCwd: string | null; pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; - restingControlsHost: HTMLDivElement | null; - restingControlsHaveLeadingContext: boolean; - onRestingControlsVisibilityChange: (visible: boolean) => void; getTimelineScrollableNode: () => HTMLElement | null; isTimelineAtLogicalEnd: () => boolean; /** Whether the timeline has more content than fits above the composer. */ @@ -1739,9 +1640,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) gitCwd, pullRequestProjectId, pullRequestRepository, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, getTimelineScrollableNode, isTimelineAtLogicalEnd, timelineOverflows, @@ -2895,16 +2793,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerOwned: true, } satisfies Parameters[0]; const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); - const [inlineRestingControlsHost, setInlineRestingControlsHost] = useState( - null, - ); + // The prompt row on desktop and the collapsed row on phones never render + // together, so one callback ref serves whichever hosts the controls. + const [restingControlsHost, setRestingControlsHost] = useState(null); const { controlsRef: restingComposerControlsRef, attachControls: attachRestingComposerControls, hiddenBlockCount: restingControlsHiddenBlockCount, iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost ?? inlineRestingControlsHost); + } = useRestingComposerControlsLayout(restingControlsHost); const expandedControlsLayout = useRestingComposerControlsLayout(null, true); const pendingPrimaryAction = useMemo( () => @@ -5078,18 +4976,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const expandedComposerImages = isComposerResting ? standaloneComposerImages.filter((image) => pendingSnapShotIdSet.has(image.id)) : standaloneComposerImages; - // Keep collapsed controls inside the input when workspace context is hidden. const composerControlsCollapsed = isComposerResting || isComposerCollapsedMobile; - const showInlineRestingControls = composerControlsCollapsed && restingControlsHost === null; - const composerControlsVisibleInStrip = - composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible; const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible; if (composerControlsHidden && isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } - useLayoutEffect(() => { - onRestingControlsVisibilityChange(composerControlsVisibleInStrip); - }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); useLayoutEffect(() => { onRestingChange(isComposerResting); }, [isComposerResting, onRestingChange]); @@ -5347,15 +5238,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( <> - {composerControlsCollapsed && - restingControlsHost !== null && - restingControlsHaveLeadingContext ? ( - - ) : null} ); + const restingComposerControls = ( +
+ {composerControls} +
+ ); const showTasksTab = !hasBannerItems && !showComposerTopDrawer && @@ -6525,42 +6420,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerCollapseCheck(); }} onDragEnterCapture={(event) => { - if (isInsideRestingComposerControlScope(event.target)) return; + if (shouldBypassComposerDrag(event.target)) return; composerMentionDragHandlers.onDragEnter(event); }} onDragOverCapture={(event) => { - if (isInsideRestingComposerControlScope(event.target)) return; + if (shouldBypassComposerDrag(event.target)) return; composerMentionDragHandlers.onDragOver(event); }} onDragLeaveCapture={(event) => { - if (isInsideRestingComposerControlScope(event.target)) return; + if (shouldBypassComposerDrag(event.target)) return; onComposerMentionDragLeaveCapture(event); }} onDropCapture={(event) => { - if (isInsideRestingComposerControlScope(event.target)) return; + if (shouldBypassComposerDrag(event.target)) return; composerMentionDragHandlers.onDrop(event); }} className="mx-auto w-full min-w-0 max-w-(--chat-content-max-width)" data-chat-composer-form="true" {...threadContextDropTargetProps()} > - {composerControlsCollapsed && restingControlsHost - ? createPortal( -
- {composerControls} -
, - restingControlsHost, - ) - : null} {props.queuedRunsControl} @@ -6739,10 +6617,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) : null} - - - + {/* The prompt starts from its reserved width and grows into what + the controls leave; the same number feeds their measurement, so + they fold into overflow before the prompt gives anything up. */} +
+ + + +
{isComposerResting ? collapsedComposerImagePreviews : null} + {isComposerResting ? restingComposerControls : null} {showMobilePendingAnswerActions ? (
0 && "pt-2", isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", + // The overlaid footer keeps its padding, which reaches over the + // inline controls; only its actions may take the pointer. isComposerResting && - "absolute right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", - isComposerResting && - (showInlineRestingControls ? "bottom-[calc(2rem+1px)]" : "bottom-px"), + "pointer-events-none absolute right-px bottom-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", )} >
{showComposerAttachAction ? ( <> @@ -7514,24 +7400,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} - {showInlineRestingControls ? ( + {isComposerCollapsedMobile ? (
-
-
- {composerControls} -
+
+ {restingComposerControls}
) : null} diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index a468c862cd19..f5d98ad2412d 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -21,7 +21,7 @@ function Shell({ "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-highlight:color-mix(in_srgb,#432d48_12%,transparent)] dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-outline:#241e28]", "before:pointer-events-none before:absolute before:inset-0 before:z-0 before:rounded-3xl before:bg-(--chat-composer-glass-surface)/(--glass-opacity) before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-glass-surface)", - "has-data-[composer-banner-surface=attached]:before:hidden data-model-strip-transition:before:hidden", + "has-data-[composer-banner-surface=attached]:before:hidden", contextStrip && [ "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]", // Keep one continuous backdrop around the fixed-pixel corners and rem-sized strip inset. @@ -65,8 +65,6 @@ function Main({ className, ...props }: ComponentProps<"div">) { data-chat-composer-main-surface="true" className={cn( "group relative z-10 rounded-3xl p-px", - // The host also wraps shoulder tabs, so its backdrop would extend above the input. - "group-data-model-strip-transition/composer-surface:bg-(--chat-composer-glass-surface)/(--glass-opacity) group-data-model-strip-transition/composer-surface:backdrop-blur-(--glass-blur) group-data-model-strip-transition/composer-surface:backdrop-saturate-(--glass-saturation)", outlineClasses, contextSeamClasses, "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block", diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index f40c9a68e6ee..7e945c61483a 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -7,6 +7,7 @@ import { isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, + shouldBypassComposerDrag, useComposerMenuProps, } from "./composerEventScope"; @@ -99,6 +100,17 @@ describe("composer event scopes", () => { expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); }); + it("lets mention drags land on the inline resting controls but not the strip", () => { + vi.stubGlobal("Element", FakeElement); + + const controls = new FakeElement('[data-chat-composer-resting-controls="true"]'); + const strip = new FakeElement("[data-composer-context-control]"); + const menu = new FakeElement('[data-chat-composer-floating-layer="true"]'); + expect(shouldBypassComposerDrag(controls as unknown as EventTarget)).toBe(false); + expect(shouldBypassComposerDrag(strip as unknown as EventTarget)).toBe(true); + expect(shouldBypassComposerDrag(menu as unknown as EventTarget)).toBe(true); + }); + it("leaves ordinary composer targets outside the portaled control scope", () => { vi.stubGlobal("Element", FakeElement); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 391c468d0d03..b3c3650b8cef 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -41,6 +41,17 @@ export function isInsideCollapsedComposerControls(target: EventTarget | null): b ); } +// Drags over the branch strip below the input or over a floating menu are +// not drops into the prompt. The inline resting controls and image previews +// sit inside the input, so a mention dragged onto them still lands. +export function shouldBypassComposerDrag(target: EventTarget | null): boolean { + return ( + target instanceof Element && + (target.closest("[data-composer-context-control]") !== null || + isInsideComposerFloatingLayer(target)) + ); +} + export function isInsideRestingComposerControlScope(target: EventTarget | null): boolean { return ( target instanceof Element && diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts index 8920bd52d2b1..5c7c30ecbc2e 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { resolveRestingComposerControlsLayout } from "../composerFooterLayout"; import { - resolveRestingComposerControlsLayout, - resolveRestingComposerControlsNaturalWidth, -} from "../composerFooterLayout"; -import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; + measureRestingComposerControls, + measureRestingComposerControlsHostWidth, +} from "./restingComposerControlsMeasurement"; function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?: string }) { const label = { clientWidth: input.clientWidth, scrollWidth: 160 }; @@ -38,12 +38,49 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth? afterEach(() => vi.unstubAllGlobals()); +describe("measureRestingComposerControlsHostWidth", () => { + function hostWith(input: { promptWidth: number; previewsWidth?: number }) { + const previews = + input.previewsWidth === undefined + ? null + : { getBoundingClientRect: () => ({ width: input.previewsWidth }) }; + const reserved = { dataset: { restingControlsReserved: String(input.promptWidth) } }; + const host = { + clientWidth: 600, + querySelector: (selector: string) => + selector === "[data-resting-controls-reserved]" ? reserved : previews, + }; + vi.stubGlobal("getComputedStyle", (target: unknown) => { + if (target === host) return { paddingLeft: "16px", paddingRight: "80px", columnGap: "4px" }; + return { marginInlineStart: "0px", marginInlineEnd: "0px" }; + }); + return host as unknown as HTMLElement; + } + + it("subtracts the host padding and the prompt's reservation", () => { + expect(measureRestingComposerControlsHostWidth(hostWith({ promptWidth: 160 }))).toBe( + 600 - 16 - 80 - 160 - 4, + ); + }); + + it("also reserves the rendered width of image previews beside the prompt", () => { + expect( + measureRestingComposerControlsHostWidth(hostWith({ promptWidth: 160, previewsWidth: 132 })), + ).toBe(600 - 16 - 80 - (160 + 4 + 132) - 4); + }); + + it("uses the whole content box without a reserved group", () => { + const host = { clientWidth: 300, querySelector: () => null }; + vi.stubGlobal("getComputedStyle", () => ({ paddingLeft: "0px", paddingRight: "0px" })); + expect(measureRestingComposerControlsHostWidth(host as unknown as HTMLElement)).toBe(300); + }); +}); + describe("measureRestingComposerControls", () => { it("keeps controls inline when the model label is deliberately collapsed", () => { const measurement = measurePicker({ clientWidth: 0, flexGrow: "0" }); expect(measurement.naturalFixedWidth).toBe(52); - expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 0, iconOnlyCount: 0, diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts index 82aa882db53a..46753f09692a 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -72,13 +72,10 @@ function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: nu /** * Read the natural widths of the resting composer controls from the DOM. * - * Both the composer (deciding which blocks move into overflow) and the - * context strip (deciding whether its labels may expand) read the same - * numbers, so neither decision depends on what the other one hid last render. - * * Hidden blocks and the unused overflow trigger stay mounted out of flow at - * full size. The picker is the one flexible item: its intended width is - * recovered from the truncated label. + * full size, so every pass measures the same natural numbers regardless of + * what the previous pass hid. The picker is the one flexible item: its + * intended width is recovered from the truncated label. */ export function measureRestingComposerControls( controls: HTMLElement, @@ -88,23 +85,41 @@ export function measureRestingComposerControls( const leadingControl = picker ?? controls.querySelector('[data-chat-provider-unavailable="true"]'); if (!leadingControl) return null; - // Separators are display:none on phone widths; a hidden one takes no gap. - const separator = controls.querySelector("[data-resting-controls-separator]"); - const separatorWidth = separator ? elementOuterWidth(separator) : 0; const overflow = controls.querySelector("[data-resting-controls-overflow]"); - const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; const blocks = Array.from(controls.querySelectorAll("[data-resting-block]")); const widths = blocks.map(controlBlockWidths); return { gap, - naturalFixedWidth: - (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) + - separatorAndGapWidth, - minimumFixedWidth: - (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + - separatorAndGapWidth, + naturalFixedWidth: picker + ? providerModelPickerNaturalWidth(picker) + : elementOuterWidth(leadingControl), + minimumFixedWidth: picker + ? providerModelPickerMinimumWidth(picker) + : elementOuterWidth(leadingControl), blockWidths: widths.map((width) => width.natural), iconOnlyBlockWidths: widths.map((width) => width.iconOnly), overflowWidth: overflow ? elementOuterWidth(overflow) : 0, }; } + +/** + * The room a host leaves for the resting controls: its content box minus + * the prompt's reservation (the pixel value its reserved element carries) + * and the rendered width of any image previews, each with the row gap. + * Reading a constant for the prompt instead of its rendered width keeps the + * answer independent of how much room the controls took last render. + */ +export function measureRestingComposerControlsHostWidth(host: HTMLElement): number { + const style = getComputedStyle(host); + const contentWidth = + host.clientWidth - + (Number.parseFloat(style.paddingLeft) || 0) - + (Number.parseFloat(style.paddingRight) || 0); + const reserved = host.querySelector("[data-resting-controls-reserved]"); + if (!reserved) return contentWidth; + const gap = Number.parseFloat(style.columnGap) || 0; + const promptWidth = Number.parseFloat(reserved.dataset.restingControlsReserved ?? "") || 0; + const previews = host.querySelector('[data-chat-composer-resting-images="true"]'); + const previewsWidth = previews ? elementOuterWidth(previews) : 0; + return contentWidth - promptWidth - gap - (previewsWidth > 0 ? previewsWidth + gap : 0); +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 4f9c87da7832..21627a172b0e 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -10,7 +10,6 @@ import { resolveComposerTimelineInset, resolveScrollToEndClearance, resolveRestingComposerControlsLayout, - resolveRestingComposerControlsNaturalWidth, shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -109,13 +108,13 @@ describe("resolveComposerTimelineInset", () => { ).toBe(200); }); - it("uses the measured expanded height while the resting strip host is mounting", () => { + it("keeps the measured expanded height while resting", () => { expect( resolveComposerTimelineInset({ currentInset: 172, overlayHeight: 110, isResting: true }), ).toBe(172); }); - it("keeps timeline padding stable when the model-only strip appears on collapse", () => { + it("keeps timeline padding stable across a collapse and expansion", () => { const expanded = resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 172, @@ -123,9 +122,8 @@ describe("resolveComposerTimelineInset", () => { }); const collapsed = resolveComposerTimelineInset({ currentInset: expanded, - overlayHeight: 110, + overlayHeight: 78, isResting: true, - restingOnlyHeight: 32, }); expect(collapsed).toBe(expanded); expect( @@ -305,68 +303,6 @@ describe("resolveRestingComposerControlsLayout", () => { }); }); -describe("context strip labels and resting composer controls", () => { - // Widths captured from a desktop renderer that crashed with React error - // 185. The strip is 724px wide. Its expanded labels need 327px, and the - // rest of its chrome needs 125px. The composer controls sit in the host - // that takes whatever is left. - const stripWidth = 724; - const labelWidth = 327; - const chromeWidth = 125; - const measurement = { - gap: 4, - naturalFixedWidth: 96.3828125 + 5 + 4, - minimumFixedWidth: 52 + 5 + 4, - blockWidths: [130.6953125, 119.671875], - overflowWidth: 28, - }; - const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement); - - function hostWidth(compact: boolean): number { - return stripWidth - chromeWidth - (compact ? 0 : labelWidth); - } - - it("keeps the labels compact when the full controls only fit beside compact labels", () => { - // Compact labels leave 599px, so the composer shows every block. - const layout = resolveRestingComposerControlsLayout({ - ...measurement, - hostWidth: hostWidth(true), - }); - expect(layout).toEqual({ hiddenCount: 0, visible: true }); - - // The strip reserves the natural controls width, so expanding the - // labels is off the table: 125 + 327 + 364 > 724. - const compact = resolveContextStripLabelsCompact({ - compact: true, - neededWidth: chromeWidth + labelWidth + naturalWidth, - availableWidth: stripWidth, - }); - expect(compact).toBe(true); - - // The next pass sees the same inputs and lands on the same answer. - expect( - resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }), - ).toEqual(layout); - }); - - it("does not settle when the strip only reserves the visible controls", () => { - // Regression guard for the alternating layout. Reserving only the - // controls left visible after two blocks moved into overflow makes the - // strip expand its labels, which shrinks the host below what the full - // controls need, which hides the blocks again. - const hiddenControlsWidth = 137; - const expands = !resolveContextStripLabelsCompact({ - compact: true, - neededWidth: chromeWidth + labelWidth + hiddenControlsWidth, - availableWidth: stripWidth, - }); - expect(expands).toBe(true); - expect( - resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }), - ).toEqual({ hiddenCount: 2, visible: true }); - }); -}); - describe("resolveRestingComposerControlsLayout hysteresis", () => { // Same cluster as above: picker 140 natural / 96 minimum plus a 9px // separator, traits 60, mode 140, overflow 24, gap 4. diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 48297caf59af..76ca4640df84 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -36,8 +36,7 @@ export function shouldUseRestingComposerLayout(input: { // line and overlays its actions; non-image attachment and context // rows keep their natural height above it while image previews move inline. // Banners and the tasks badge dock above the surface, so they are absent - // too. The context strip is optional: collapsed controls use it when - // present and otherwise occupy a compact row inside the composer. + // too. The collapsed model and mode controls share the prompt row. // // Only a timeline scroll rests the composer: the user asked for it with the // gesture, and it lifts on the next composer interaction. Losing focus never @@ -65,6 +64,14 @@ export function shouldUseRestingComposerLayout(input: { */ export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; +/** + * The width of the resting row the prompt keeps before the inline controls + * start folding into overflow. The prompt itself stays shrinkable so a very + * narrow pane never pushes it under the overlaid actions; the controls hide + * entirely once even their minimum no longer fits beside this. + */ +export const COMPOSER_RESTING_PROMPT_MIN_PX = 160; + /** * The space the timeline reserves at its end for the composer overlay. * @@ -72,24 +79,19 @@ export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; * an expanded one. Reserving only the resting height lets a scroll to the end * land flush against the short composer, and the expansion that follows then * covers the last rows because the timeline never moves for footer growth. - * While resting, keep the measured expanded height. Estimate the empty - * expansion only before that measurement exists: strip mounting can otherwise - * inflate the estimate mid-transition and move the timeline. An expanded - * measurement is authoritative and may shrink the reservation. + * While resting, keep the measured expanded height, and estimate the empty + * expansion only before that measurement exists. An expanded measurement is + * authoritative and may shrink the reservation. */ export function resolveComposerTimelineInset(input: { currentInset: number; overlayHeight: number; isResting: boolean; - restingOnlyHeight?: number; }): number { return input.isResting ? Math.max( input.currentInset, - input.overlayHeight + - (input.currentInset === 0 - ? COMPOSER_RESTING_EXPANSION_MIN_PX - (input.restingOnlyHeight ?? 0) - : 0), + input.overlayHeight + (input.currentInset === 0 ? COMPOSER_RESTING_EXPANSION_MIN_PX : 0), ) : input.overlayHeight; } @@ -158,20 +160,6 @@ function restingComposerControlsWidth( ); } -/** - * The width the resting controls take with nothing moved into overflow. - * - * The context strip reserves this much for the composer before deciding - * whether its own labels may expand. Judging against the currently visible - * controls instead lets the strip expand into space the composer just gave - * up, which shrinks the host, hides the controls again, and repeats. - */ -export function resolveRestingComposerControlsNaturalWidth( - input: RestingComposerControlsMeasurement, -): number { - return restingComposerControlsWidth(input, 0); -} - /** * Fit footer controls using natural widths: remove trailing labels first, * then move trailing blocks into overflow. Resting and expanded share this From 7ae53636ced306c495ca811bb76c816561ac6e22 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 6 Oct 2026 16:55:19 +0000 Subject: [PATCH 2/2] fix(web): keep draft branch defaults from detaching new turns --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 434516808aa1..9ff6e4fb164a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -508,6 +508,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { if ( + hasServerThread || effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || @@ -520,6 +521,7 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, activeWorktreePath, effectiveEnvMode, + hasServerThread, setThreadBranch, worktreeBaseBranchCandidate, ]);