Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/web-ui/src/flow_chat/components/ModelSelector.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 9 additions & 12 deletions src/web-ui/src/flow_chat/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -340,18 +341,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({

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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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;

Comment thread
Horizonll marked this conversation as resolved.
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,
};
}
Loading