fix(ui): constrain draft menus to chat area

This commit is contained in:
Bohdan Triapitsyn
2026-08-23 18:27:35 +03:00
parent 2ca3280b39
commit 264ea02ee1
4 changed files with 33 additions and 11 deletions
@@ -2342,9 +2342,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
className="w-[min(380px,calc(100vw-2rem))] max-h-[calc(100dvh-var(--oc-header-height,56px)-0.5rem)] p-0 flex flex-col" className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden"
align="end" align="end"
alignOffset={-40} alignOffset={-40}
constrainToMain
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture} onKeyDownCapture={handleModelShortcutKeyDownCapture}
> >
<div className="p-1 border-b border-border/40"> <div className="p-1 border-b border-border/40">
@@ -2401,7 +2403,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
); );
}} }}
maxHeightClassName="max-h-[min(400px,calc(100dvh-var(--oc-header-height,56px)-4rem))] flex-1" maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1"
tooltipsEnabled={agentMenuOpen} tooltipsEnabled={agentMenuOpen}
onEscape={() => setAgentMenuOpen(false)} onEscape={() => setAgentMenuOpen(false)}
/> />
@@ -2735,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] max-h-[calc(100dvh-var(--oc-header-height,56px)-0.5rem)] p-0 flex flex-col"> <DropdownMenuContent side="top" align="end" alignOffset={-40} constrainToMain collisionAvoidance={{ side: 'none', align: 'shift' }} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden">
<div className="p-2 border-b border-border/40"> <div className="p-2 border-b border-border/40">
<div className="relative"> <div className="relative">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" /> <Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -2751,7 +2753,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
/> />
</div> </div>
</div> </div>
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-var(--oc-header-height,56px)-4rem))] flex-1"> <ScrollableOverlay outerClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1">
<div className="p-1"> <div className="p-1">
{!agentSearchQuery.trim() && defaultAgentName && ( {!agentSearchQuery.trim() && defaultAgentName && (
<> <>
@@ -122,7 +122,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
: <ProjectLabel project={selectedProject} theme={theme} />} : <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} fitContent> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent>
{projects.map((project) => ( {projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate"> <SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} /> <ProjectLabel project={project} theme={theme} />
@@ -144,7 +144,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')} {selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} className="w-max min-w-48"> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48">
{projectRootBranchOption ? ( {projectRootBranchOption ? (
<SelectGroup> <SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel> <SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -13,7 +13,9 @@ type AsChildRenderProps = {
type DropdownPortalContextValue = { type DropdownPortalContextValue = {
portalContainer: HTMLElement | null; portalContainer: HTMLElement | null;
collisionBoundary: Element | null;
setPortalContainer: (container: HTMLElement | null) => void; setPortalContainer: (container: HTMLElement | null) => void;
setCollisionBoundary: (boundary: Element | null) => void;
}; };
const DropdownPortalContext = React.createContext<DropdownPortalContextValue | null>(null); const DropdownPortalContext = React.createContext<DropdownPortalContextValue | null>(null);
@@ -36,10 +38,13 @@ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof BaseMenu.Root>) { }: React.ComponentProps<typeof BaseMenu.Root>) {
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null); const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null);
const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({ const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({
portalContainer, portalContainer,
collisionBoundary,
setPortalContainer, setPortalContainer,
}), [portalContainer]); setCollisionBoundary,
}), [collisionBoundary, portalContainer]);
return ( return (
<DropdownPortalContext.Provider value={portalContextValue}> <DropdownPortalContext.Provider value={portalContextValue}>
@@ -62,6 +67,7 @@ function DropdownMenuTrigger({
} }
const element = target instanceof HTMLElement ? target : null; const element = target instanceof HTMLElement ? target : null;
portalContext.setPortalContainer(resolveDialogContainer(element)); portalContext.setPortalContainer(resolveDialogContainer(element));
portalContext.setCollisionBoundary(element?.closest('main') ?? null);
}, [portalContext]); }, [portalContext]);
const r = renderFromAsChild(asChild, children); const r = renderFromAsChild(asChild, children);
@@ -89,6 +95,8 @@ type ContentProps = {
alignOffset?: number; alignOffset?: number;
portalToBody?: boolean; portalToBody?: boolean;
positionerClassName?: string; positionerClassName?: string;
constrainToMain?: boolean;
collisionAvoidance?: React.ComponentProps<typeof BaseMenu.Positioner>["collisionAvoidance"];
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
@@ -103,6 +111,8 @@ function DropdownMenuContent({
alignOffset, alignOffset,
portalToBody = false, portalToBody = false,
positionerClassName, positionerClassName,
constrainToMain = false,
collisionAvoidance,
style, style,
children, children,
onCloseAutoFocus, onCloseAutoFocus,
@@ -118,6 +128,8 @@ function DropdownMenuContent({
align={align} align={align}
side={side} side={side}
alignOffset={alignOffset} alignOffset={alignOffset}
collisionBoundary={constrainToMain ? portalContext?.collisionBoundary ?? undefined : undefined}
collisionAvoidance={collisionAvoidance}
className={cn("app-region-no-drag z-50", positionerClassName)} className={cn("app-region-no-drag z-50", positionerClassName)}
> >
<BaseMenu.Popup <BaseMenu.Popup
@@ -128,7 +140,6 @@ function DropdownMenuContent({
}} }}
className={cn( className={cn(
dropdownMenuPopupClass, dropdownMenuPopupClass,
"max-h-[calc(100dvh-var(--oc-header-height,56px)-0.5rem)] overflow-y-auto",
className className
)} )}
{...props} {...props}
+12 -3
View File
@@ -17,7 +17,9 @@ type AsChildRenderProps = {
type SelectPortalContextValue = { type SelectPortalContextValue = {
portalContainer: HTMLElement | null; portalContainer: HTMLElement | null;
collisionBoundary: Element | null;
setPortalContainer: (container: HTMLElement | null) => void; setPortalContainer: (container: HTMLElement | null) => void;
setCollisionBoundary: (boundary: Element | null) => void;
}; };
const SelectPortalContext = React.createContext<SelectPortalContextValue | null>(null); const SelectPortalContext = React.createContext<SelectPortalContextValue | null>(null);
@@ -44,10 +46,13 @@ function Select<Value extends string = string>({
...props ...props
}: SelectRootProps<Value>) { }: SelectRootProps<Value>) {
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null); const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null);
const portalContextValue = React.useMemo<SelectPortalContextValue>(() => ({ const portalContextValue = React.useMemo<SelectPortalContextValue>(() => ({
portalContainer, portalContainer,
collisionBoundary,
setPortalContainer, setPortalContainer,
}), [portalContainer]); setCollisionBoundary,
}), [collisionBoundary, portalContainer]);
const handleValueChange = React.useCallback( const handleValueChange = React.useCallback(
(value: unknown, eventDetails: SelectRootChangeEventDetails) => { (value: unknown, eventDetails: SelectRootChangeEventDetails) => {
@@ -119,6 +124,7 @@ function SelectTrigger({
} }
const element = target instanceof HTMLElement ? target : null; const element = target instanceof HTMLElement ? target : null;
portalContext.setPortalContainer(resolveDialogContainer(element)); portalContext.setPortalContainer(resolveDialogContainer(element));
portalContext.setCollisionBoundary(element?.closest('main') ?? null);
}, [portalContext]); }, [portalContext]);
const asChildRender: AsChildRenderProps | null = asChild && React.isValidElement(children) const asChildRender: AsChildRenderProps | null = asChild && React.isValidElement(children)
@@ -164,6 +170,7 @@ type SelectContentExtra = {
side?: "top" | "right" | "bottom" | "left"; side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end"; align?: "start" | "center" | "end";
collisionAvoidance?: React.ComponentProps<typeof BaseSelect.Positioner>["collisionAvoidance"]; collisionAvoidance?: React.ComponentProps<typeof BaseSelect.Positioner>["collisionAvoidance"];
constrainToMain?: boolean;
}; };
function SelectContent({ function SelectContent({
@@ -176,6 +183,7 @@ function SelectContent({
side, side,
align, align,
collisionAvoidance, collisionAvoidance,
constrainToMain = false,
...props ...props
}: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) { }: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) {
const portalContext = React.useContext(SelectPortalContext); const portalContext = React.useContext(SelectPortalContext);
@@ -190,6 +198,7 @@ function SelectContent({
side={side} side={side}
align={align} align={align}
collisionAvoidance={collisionAvoidance} collisionAvoidance={collisionAvoidance}
collisionBoundary={constrainToMain ? portalContext?.collisionBoundary ?? undefined : undefined}
className="absolute z-[120] pointer-events-auto" className="absolute z-[120] pointer-events-auto"
> >
<BaseSelect.Popup <BaseSelect.Popup
@@ -198,7 +207,7 @@ function SelectContent({
color: 'var(--surface-elevated-foreground)', color: 'var(--surface-elevated-foreground)',
}} }}
className={cn( className={cn(
"oc-glass-popover oc-glass-floating pointer-events-auto transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 relative z-[120] max-h-[min(var(--available-height),calc(100dvh-var(--oc-header-height,56px)-0.5rem))] min-w-[8rem] origin-[var(--transform-origin)] overflow-x-hidden rounded-xl", "oc-glass-popover oc-glass-floating pointer-events-auto transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 relative z-[120] max-h-[var(--available-height)] min-w-[8rem] origin-[var(--transform-origin)] overflow-x-hidden rounded-xl",
!alignItemWithTrigger && !alignItemWithTrigger &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
fitContent && "w-max min-w-0", fitContent && "w-max min-w-0",
@@ -208,7 +217,7 @@ function SelectContent({
> >
<ScrollableOverlay <ScrollableOverlay
outerClassName={cn( outerClassName={cn(
"max-h-[min(var(--available-height),calc(100dvh-var(--oc-header-height,56px)-0.5rem))]", "max-h-[var(--available-height)]",
fitContent ? "w-max" : "w-full" fitContent ? "w-max" : "w-full"
)} )}
className={cn( className={cn(