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 d018802a590a..ee8ce5e4ac41 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -70,13 +70,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 ? ( { if ( + hasServerThread || effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || @@ -520,6 +521,7 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, activeWorktreePath, effectiveEnvMode, + hasServerThread, setThreadBranch, worktreeBaseBranchCandidate, ]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f6256ff7d76..7c8ff292fd0c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1788,9 +1788,6 @@ export default function ChatView(props: ChatViewProps) { const composerRef = useComposerHandleContext() ?? localComposerRef; const branchToolbarRef = useRef(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; @@ -4178,9 +4175,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. @@ -4209,25 +4203,14 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - 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: { @@ -6776,23 +6759,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; @@ -11229,9 +11199,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 ee9c313cbec7..b8210e812189 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; @@ -1167,11 +1075,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({ @@ -1598,9 +1502,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. */ @@ -1742,9 +1643,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) gitCwd, pullRequestProjectId, pullRequestRepository, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, getTimelineScrollableNode, isTimelineAtLogicalEnd, timelineOverflows, @@ -2945,16 +2843,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( () => @@ -5128,18 +5026,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]); @@ -5397,15 +5288,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( <> - {composerControlsCollapsed && - restingControlsHost !== null && - restingControlsHaveLeadingContext ? ( - - ) : null} ); + const restingComposerControls = ( +
+ {composerControls} +
+ ); const showTasksTab = !hasBannerItems && !showComposerTopDrawer && @@ -6575,42 +6470,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}
) : 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 ? ( <> @@ -7564,24 +7450,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