diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index bc0117510b..4c9583127c 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -138,8 +138,9 @@ position: absolute; bottom: calc(100% + 6px); left: 0; - min-width: 220px; - max-width: 280px; + width: 220px; + min-width: 0; + max-width: calc(100vw - 16px); background: var(--color-bg-elevated); border: 1px solid var(--color-overlay-white-12); border-radius: 6px; @@ -158,6 +159,8 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; + min-width: 0; padding: 8px 12px; border-bottom: 1px solid var(--color-overlay-white-08); font-size: var(--flowchat-font-size-xxs); @@ -168,6 +171,10 @@ } &__dropdown-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: var(--flowchat-font-size-2xs); color: var(--color-text-muted); font-weight: 400; @@ -212,6 +219,7 @@ align-items: center; justify-content: space-between; gap: 8px; + min-width: 0; padding: 7px 12px; cursor: pointer; transition: all 0.15s ease; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 949c62f42e..41ff90321b 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -29,6 +29,7 @@ import { type ContextUsageSource, } from '../utils/tokenUsageDisplay'; import { createLogger } from '@/shared/utils/logger'; +import { getModelSelectorDropdownStyle } from './modelSelectorDropdownPosition'; import './ModelSelector.scss'; const log = createLogger('ModelSelector'); @@ -340,18 +341,14 @@ export const ModelSelector: React.FC = ({ const updatePosition = () => { if (!dropdownRef.current) return; - const rect = dropdownRef.current.getBoundingClientRect(); - const placementStyle = dropdownPlacement === 'bottom' - ? { top: `${rect.bottom + 6}px`, bottom: 'auto' } - : { top: 'auto', bottom: `${window.innerHeight - rect.top + 6}px` }; - setDropdownStyle({ - position: 'fixed', - visibility: 'visible', - left: `${rect.left}px`, - minWidth: '220px', - maxWidth: '280px', - ...placementStyle, - }); + setDropdownStyle(getModelSelectorDropdownStyle( + dropdownRef.current.getBoundingClientRect(), + dropdownPlacement, + { + width: window.innerWidth, + height: window.innerHeight, + }, + )); }; updatePosition(); diff --git a/src/web-ui/src/flow_chat/components/modelSelectorDropdownPosition.test.ts b/src/web-ui/src/flow_chat/components/modelSelectorDropdownPosition.test.ts new file mode 100644 index 0000000000..850278dcae --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modelSelectorDropdownPosition.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getModelSelectorDropdownStyle } from './modelSelectorDropdownPosition'; + +describe('getModelSelectorDropdownStyle', () => { + it('keeps the dropdown inside the right edge of a narrow viewport', () => { + const style = getModelSelectorDropdownStyle( + { left: 250, top: 700, bottom: 724, width: 90 }, + 'top', + { width: 320, height: 800 }, + ); + + expect(style.left).toBe('92px'); + expect(style.width).toBe('220px'); + expect(style.maxWidth).toBe('304px'); + expect(style.bottom).toBe('106px'); + }); + + it('shrinks the dropdown when the viewport cannot fit the preferred minimum width', () => { + const style = getModelSelectorDropdownStyle( + { left: 80, top: 600, bottom: 624, width: 80 }, + 'bottom', + { width: 180, height: 700 }, + ); + + expect(style.left).toBe('8px'); + expect(style.width).toBe('164px'); + expect(style.maxWidth).toBe('164px'); + expect(style.top).toBe('630px'); + }); + + it('caps wide triggers at the dropdown maximum width', () => { + const style = getModelSelectorDropdownStyle( + { left: 32, top: 400, bottom: 424, width: 360 }, + 'bottom', + { width: 900, height: 700 }, + ); + + expect(style.left).toBe('32px'); + expect(style.width).toBe('280px'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modelSelectorDropdownPosition.ts b/src/web-ui/src/flow_chat/components/modelSelectorDropdownPosition.ts new file mode 100644 index 0000000000..c659dcb426 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modelSelectorDropdownPosition.ts @@ -0,0 +1,62 @@ +export type ModelSelectorDropdownPlacement = 'top' | 'bottom'; + +export interface ModelSelectorDropdownAnchorRect { + left: number; + top: number; + bottom: number; + width: number; +} + +export interface ModelSelectorDropdownViewport { + width: number; + height: number; +} + +export interface ModelSelectorDropdownStyle { + position: 'fixed'; + visibility: 'visible'; + left: string; + top: string; + bottom: string; + width: string; + minWidth: string; + maxWidth: string; +} + +const DROPDOWN_GAP_PX = 6; +const DROPDOWN_MAX_WIDTH_PX = 280; +const DROPDOWN_MIN_WIDTH_PX = 220; +const VIEWPORT_PADDING_PX = 8; + +const clamp = (value: number, min: number, max: number): number => { + return Math.min(Math.max(value, min), Math.max(min, max)); +}; + +export function getModelSelectorDropdownStyle( + rect: ModelSelectorDropdownAnchorRect, + placement: ModelSelectorDropdownPlacement, + viewport: ModelSelectorDropdownViewport, +): ModelSelectorDropdownStyle { + const availableWidth = Math.max(viewport.width - VIEWPORT_PADDING_PX * 2, 1); + const preferredWidth = Math.min( + DROPDOWN_MAX_WIDTH_PX, + Math.max(DROPDOWN_MIN_WIDTH_PX, rect.width), + ); + const width = Math.min(preferredWidth, availableWidth); + const maxLeft = viewport.width - VIEWPORT_PADDING_PX - width; + const left = clamp(rect.left, VIEWPORT_PADDING_PX, maxLeft); + + const placementStyle = placement === 'bottom' + ? { top: `${rect.bottom + DROPDOWN_GAP_PX}px`, bottom: 'auto' } + : { top: 'auto', bottom: `${viewport.height - rect.top + DROPDOWN_GAP_PX}px` }; + + return { + position: 'fixed', + visibility: 'visible', + left: `${left}px`, + width: `${width}px`, + minWidth: '0', + maxWidth: `${availableWidth}px`, + ...placementStyle, + }; +}