Skip to content

Commit e9ed779

Browse files
committed
Model Select Combobox #951
1 parent 65ee74c commit e9ed779

File tree

9 files changed

+366
-20
lines changed

9 files changed

+366
-20
lines changed

browser/data-browser/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@emoji-mart/react": "^1.1.1",
1616
"@emotion/is-prop-valid": "^1.3.1",
1717
"@modelcontextprotocol/sdk": "^1.13.3",
18+
"@oddbird/css-anchor-positioning": "^0.6.1",
1819
"@openrouter/ai-sdk-provider": "^0.4.3",
1920
"@radix-ui/react-popover": "^1.1.2",
2021
"@radix-ui/react-scroll-area": "^1.2.0",
@@ -31,6 +32,7 @@
3132
"@tiptap/suggestion": "^2.11.7",
3233
"@tomic/react": "workspace:*",
3334
"ai": "^4.3.16",
35+
"downshift": "^9.0.9",
3436
"emoji-mart": "^5.6.0",
3537
"polished": "^4.3.1",
3638
"prismjs": "^1.29.0",

browser/data-browser/src/App.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { registerHandlers } from './handlers';
55
import { getAgentFromLocalStorage } from './helpers/agentStorage';
66
import { registerCustomCreateActions } from './components/forms/NewForm/CustomCreateActions';
77
import { serverURLStorage } from './helpers/serverURLStorage';
8-
98
import type { JSX } from 'react';
109
import { RouterProvider } from '@tanstack/react-router';
1110
import { router } from './routes/Router';

browser/data-browser/src/Providers.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ const ErrBoundary = window.bugsnagApiKey
2828
// This implements the default behavior from styled-components v5
2929
const shouldForwardProp: ShouldForwardProp<'web'> = (propName, target) => {
3030
if (typeof target === 'string') {
31+
// @emotion/is-prop-valid does not support popover, so we need to forward it manually.
32+
if (propName === 'popover') {
33+
return true;
34+
}
35+
3136
// For HTML elements, forward the prop if it is a valid HTML attribute
3237
return isPropValid(propName);
3338
}

browser/data-browser/src/components/AI/AIChatMessageParts/BasicMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const MessageWrapper = styled.div`
1010
export const BasicMessage = ({ text }: { text: string }) => {
1111
return (
1212
<MessageWrapper>
13-
<Markdown text={text} maxLength={Infinity} />
13+
<Markdown markExternalLinks text={text} maxLength={Infinity} />
1414
</MessageWrapper>
1515
);
1616
};

browser/data-browser/src/components/AI/ModelSelect.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { useState } from 'react';
22
import { Column, Row } from '../Row';
33
import Markdown from '../datatypes/Markdown';
44
import styled from 'styled-components';
5-
import { BasicSelect } from '../forms/BasicSelect';
65
import { FaTriangleExclamation } from 'react-icons/fa6';
76
import { useOpenRouterModels } from './useOpenRouterModels';
7+
import { ComboBox } from '../ComboBox';
88

99
interface ModelSelectProps {
1010
onSelect?: (model: string) => void;
@@ -35,24 +35,26 @@ export const ModelSelect = ({
3535
const showSupportWarning =
3636
selectedModel && !modelList.includes(selectedModel);
3737

38+
const options = modelList.map(model => ({
39+
label: model.name,
40+
searchLabel: model.name.toLowerCase(),
41+
value: model.id,
42+
}));
43+
3844
return (
3945
<Wrapper>
4046
<Column>
4147
<Column gap='0.2rem'>
4248
<ModelAmount>{modelList.length} Models</ModelAmount>
43-
<BasicSelect
44-
value={selectedModel?.id}
45-
onChange={e => {
46-
setSelectedId(e.target.value);
47-
onSelect?.(e.target.value);
49+
<ComboBox
50+
selectedItem={selectedId}
51+
options={options}
52+
onSelect={value => {
53+
const newVal = value ?? defaultModel;
54+
setSelectedId(newVal);
55+
onSelect?.(newVal);
4856
}}
49-
>
50-
{modelList.map(model => (
51-
<option key={model.id} value={model.id}>
52-
{model.name}
53-
</option>
54-
))}
55-
</BasicSelect>
57+
/>
5658
{showSupportWarning && (
5759
<SupportWarning center gap='1ch'>
5860
<FaTriangleExclamation />
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import {
2+
useCallback,
3+
useEffect,
4+
useId,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
import { InputStyled, InputWrapper } from './forms/InputStyles';
10+
import { IconButton } from './IconButton/IconButton';
11+
import { FaChevronDown } from 'react-icons/fa6';
12+
import { useCombobox } from 'downshift';
13+
import { Column } from './Row';
14+
import styled from 'styled-components';
15+
import { QuickScore } from 'quick-score';
16+
17+
const supportsAnchorPositioning =
18+
'anchorName' in document.documentElement.style;
19+
20+
export type ComboBoxOption = {
21+
label: string;
22+
searchLabel: string;
23+
description?: string;
24+
value: string;
25+
};
26+
27+
type ComboBoxProps = {
28+
options: ComboBoxOption[];
29+
selectedItem: string | undefined;
30+
onSelect: (value: string | undefined) => void;
31+
};
32+
33+
export const ComboBox: React.FC<ComboBoxProps> = ({
34+
options,
35+
selectedItem,
36+
onSelect,
37+
}) => {
38+
// Use Combobox does not work with the compiler.
39+
// eslint-disable-next-line react-compiler/react-compiler
40+
'use no memo';
41+
const id = useId();
42+
const anchorName = `--combo-box-${id.trim().replaceAll(':', '-')}`;
43+
const menuRef = useRef<HTMLUListElement>(null);
44+
const inputWrapperRef = useRef<HTMLDivElement>(null);
45+
const [menuAboveInput, setMenuAboveInput] = useState(false);
46+
47+
const [items, setItems] = useState(options);
48+
49+
const quickScore = useMemo(() => {
50+
return new QuickScore(options, ['label']);
51+
}, [options]);
52+
53+
const {
54+
isOpen,
55+
getInputProps,
56+
getToggleButtonProps,
57+
getMenuProps,
58+
getItemProps,
59+
highlightedIndex,
60+
setHighlightedIndex,
61+
} = useCombobox({
62+
items,
63+
onInputValueChange: ({ inputValue }) => {
64+
setHighlightedIndex(0);
65+
66+
if (inputValue === '') {
67+
setItems(options);
68+
}
69+
70+
setItems(quickScore.search(inputValue).map(r => r.item));
71+
},
72+
itemToString: item => item?.label ?? '',
73+
initialSelectedItem: options.find(option => option.value === selectedItem),
74+
onSelectedItemChange: ({ selectedItem: item }) => {
75+
onSelect(item?.value);
76+
},
77+
});
78+
79+
useEffect(() => {
80+
setItems(options);
81+
}, [options]);
82+
83+
const { ref: downShiftMenuRef, ...menuRest } = getMenuProps();
84+
85+
const setMenuRef = useCallback((node: HTMLUListElement) => {
86+
// @ts-expect-error - downshift types are not correct, it's a callback ref, not a ref object
87+
downShiftMenuRef(node);
88+
menuRef.current = node;
89+
}, []);
90+
91+
const checkMenuPosition = useCallback(() => {
92+
if (!inputWrapperRef.current || !menuRef.current) return;
93+
// NOTE: For some reason firefox does not measure the position correctly, could be due to the polyfill, not fixing this now.
94+
const inputWrapperPosition =
95+
inputWrapperRef.current.getBoundingClientRect();
96+
const menuPosition = menuRef.current.getBoundingClientRect();
97+
setMenuAboveInput(menuPosition.top < inputWrapperPosition.top);
98+
}, []);
99+
100+
useEffect(() => {
101+
if (!menuRef || !menuRef.current) return;
102+
103+
if (isOpen) {
104+
menuRef.current.showPopover();
105+
} else {
106+
menuRef.current.hidePopover();
107+
}
108+
109+
if (supportsAnchorPositioning)
110+
requestAnimationFrame(() => {
111+
checkMenuPosition();
112+
});
113+
else checkMenuPosition();
114+
}, [isOpen]);
115+
116+
useEffect(() => {
117+
requestAnimationFrame(() => {
118+
checkMenuPosition();
119+
});
120+
}, [items]);
121+
122+
useEffect(() => {
123+
if (!menuRef.current || !inputWrapperRef.current) return;
124+
125+
if (!supportsAnchorPositioning) {
126+
import('@oddbird/css-anchor-positioning/fn').then(module => {
127+
module.default();
128+
});
129+
}
130+
}, [menuRef, inputWrapperRef]);
131+
132+
return (
133+
<Wrapper>
134+
<StyledInputWrapper
135+
anchorName={anchorName}
136+
ref={inputWrapperRef}
137+
className={menuAboveInput ? 'menu-above-input' : ''}
138+
>
139+
<InputStyled {...getInputProps()} />
140+
<IconButton {...getToggleButtonProps()}>
141+
<FaChevronDown />
142+
</IconButton>
143+
</StyledInputWrapper>
144+
<List
145+
$open={isOpen}
146+
anchorName={anchorName}
147+
{...menuRest}
148+
ref={setMenuRef}
149+
popover='manual'
150+
className={menuAboveInput ? 'menu-above-input' : ''}
151+
>
152+
{isOpen && (
153+
<>
154+
{items.map((item, index) => (
155+
<ListItem
156+
key={item.value}
157+
data-selected={index === highlightedIndex}
158+
{...getItemProps({ item, index })}
159+
>
160+
<Column gap='0.2rem'>
161+
<span>{item.label}</span>
162+
{item.description && (
163+
<Description>{item.description}</Description>
164+
)}
165+
</Column>
166+
</ListItem>
167+
))}
168+
{items.length === 0 && (
169+
<ListItem>
170+
<Description>No results</Description>
171+
</ListItem>
172+
)}
173+
</>
174+
)}
175+
</List>
176+
</Wrapper>
177+
);
178+
};
179+
180+
const Wrapper = styled.div`
181+
position: relative;
182+
183+
&:has(li) {
184+
${InputWrapper} {
185+
box-shadow: ${p => p.theme.boxShadowSoft};
186+
border-radius: ${p => p.theme.radius} ${p => p.theme.radius} 0 0;
187+
border-bottom: none;
188+
189+
&.menu-above-input {
190+
border-radius: 0 0 ${p => p.theme.radius} ${p => p.theme.radius};
191+
border-bottom: solid 1px ${p => p.theme.colors.main};
192+
border-top: none;
193+
}
194+
}
195+
}
196+
`;
197+
198+
const ListItem = styled.li`
199+
list-style: none;
200+
margin: 0;
201+
padding: ${p => p.theme.size(1)} ${p => p.theme.size(2)};
202+
font-size: 0.9rem;
203+
&[data-selected='true'] {
204+
background-color: ${p => p.theme.colors.mainSelectedBg};
205+
color: ${p => p.theme.colors.mainSelectedFg};
206+
}
207+
`;
208+
209+
const Description = styled.span`
210+
font-size: 0.8rem;
211+
color: ${p => p.theme.colors.textLight};
212+
`;
213+
214+
const List = styled.ul<{ $open: boolean; anchorName: string }>`
215+
max-height: ${p => p.theme.size(15)};
216+
overflow: auto;
217+
margin: 0;
218+
box-shadow: ${p => p.theme.boxShadowSoft};
219+
border-radius: 0 0 ${p => p.theme.radius} ${p => p.theme.radius};
220+
221+
position-anchor: ${p => p.anchorName};
222+
top: anchor(bottom);
223+
left: anchor(left);
224+
right: anchor(right);
225+
bottom: unset;
226+
width: stretch;
227+
width: -webkit-fill-available;
228+
width: -moz-available;
229+
background-color: ${p => p.theme.colors.bg};
230+
scrollbar-color: ${p => p.theme.colors.bg2} transparent;
231+
border: solid 1px ${p => p.theme.colors.main};
232+
border-top: none;
233+
position-try: flip-block;
234+
235+
&.menu-above-input {
236+
border-radius: ${p => p.theme.radius} ${p => p.theme.radius} 0 0;
237+
border-bottom: none;
238+
border-top: solid 1px ${p => p.theme.colors.main};
239+
box-shadow: none;
240+
}
241+
`;
242+
243+
const StyledInputWrapper = styled(InputWrapper)<{ anchorName: string }>`
244+
anchor-name: ${p => p.anchorName};
245+
`;

browser/data-browser/src/components/datatypes/Markdown.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Props = {
1616
maxLength?: number;
1717
className?: string;
1818
nestedInLink?: boolean;
19+
markExternalLinks?: boolean;
1920
};
2021

2122
const disableElementsInLink = ['a'];
@@ -27,6 +28,7 @@ const Markdown: FC<Props> = ({
2728
maxLength = 5000,
2829
className,
2930
nestedInLink = false,
31+
markExternalLinks = false,
3032
}) => {
3133
const [collapsed, setCollapsed] = useState(true);
3234

@@ -39,11 +41,15 @@ const Markdown: FC<Props> = ({
3941
<ReactMarkdown
4042
remarkPlugins={renderGFM ? [remarkGFM] : []}
4143
disallowedElements={nestedInLink ? disableElementsInLink : undefined}
42-
components={{
43-
a: ({ node: _node, children, ...props }) => {
44-
return <AtomicLink {...props}>{children}</AtomicLink>;
45-
},
46-
}}
44+
components={
45+
markExternalLinks
46+
? {
47+
a: ({ node: _node, children, ...props }) => {
48+
return <AtomicLink {...props}>{children}</AtomicLink>;
49+
},
50+
}
51+
: {}
52+
}
4753
>
4854
{collapsed ? truncateMarkdown(text, maxLength) : text}
4955
</ReactMarkdown>

browser/data-browser/src/views/Card/AIChatContentCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const AIChatContentCard: React.FC<CardViewProps> = ({ resource }) => {
1818
</Row>
1919
</ResourceCardTitle>
2020
<Markdown
21+
markExternalLinks
2122
maxLength={1000}
2223
renderGFM
2324
text={resource.props.description ?? ''}

0 commit comments

Comments
 (0)