feat(open-webui): searchable model picker in floating header

Replace the inline Select with a Combobox-based ModelSelect that filters
models via an in-dropdown search field, and move the model picker, new
chat and history controls into a floating header above the messages.
Assistant avatar now uses the Open WebUI brand logo.
This commit is contained in:
Lok3sh-Kumar committed 2026-06-27 14:18:15 +05:30
1 parent 8e0f8cd46a
commit 41b75ef5a0
4 files changed
+183 -47

No files matched your search

+1
View File
@@ -3554,6 +3554,7 @@
}
},
"selectModel": "Select a model",
"searchModel": "Search models…",
"noModels": "No models available",
"emptyState": "Start a conversation",
"messagePlaceholder": "Type a message…",
@@ -7,8 +7,11 @@ import {
Button,
Checkbox,
CloseButton,
Combobox,
Group,
Image,
Input,
InputBase,
Loader,
Paper,
Popover,
@@ -17,10 +20,14 @@ import {
Text,
TextInput,
Tooltip,
useCombobox,
} from "@mantine/core";
import {
IconArrowLeft,
IconCamera,
IconCaretDownFilled,
IconCaretUpFilled,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDatabase,
@@ -36,12 +43,104 @@ import {
import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { getIconUrl } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AttachView, ChatMessage, FileAttachment } from "./chat-types";
import type { useAttachments } from "./use-attachments";
import classes from "./chat.module.css";
// Model picker: a non-editable button target showing the selected model, with a
// search field inside the dropdown to filter the list on the frontend.
export function ModelSelect({
data,
value,
onChange,
loading,
disabled,
}: {
data: { value: string; label: string }[];
value: string | null;
onChange: (value: string) => void;
loading: boolean;
disabled: boolean;
}) {
const t = useI18n();
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
onDropdownOpen: () => combobox.focusSearchInput(),
});
const selectedLabel = data.find((item) => item.value === value)?.label;
const filtered = data.filter((item) => item.label.toLowerCase().includes(search.trim().toLowerCase()));
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(submitted) => {
onChange(submitted);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
variant="unstyled"
size="sm"
disabled={disabled}
w={200}
classNames={{ input: classes.modelSelectControl }}
rightSection={
loading ? (
<Loader size="xs" />
) : combobox.dropdownOpened ? (
<IconCaretUpFilled size={14} />
) : (
<IconCaretDownFilled size={14} />
)
}
rightSectionPointerEvents="none"
onClick={() => combobox.toggleDropdown()}
>
{selectedLabel ? (
<span className={classes.modelSelectLabel}>{selectedLabel}</span>
) : (
<Input.Placeholder>{t("widget.openWebUi.selectModel")}</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={t("widget.openWebUi.searchModel")}
/>
<Combobox.Options mah={220} style={{ overflowY: "auto" }}>
{filtered.length > 0 ? (
filtered.map((item) => (
<Combobox.Option value={item.value} key={item.value} active={item.value === value}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<span className={classes.modelOptionLabel}>{item.label}</span>
{item.value === value ? <IconCheck size={14} /> : null}
</Group>
</Combobox.Option>
))
) : (
<Combobox.Empty>{t("widget.openWebUi.noModels")}</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
export function MenuRow({
icon,
label,
@@ -226,7 +325,6 @@ export function AttachPopover({
<ActionIcon
variant="subtle"
color="gray"
loading={attachments.isUploading}
onClick={() => attachments.openAttach(!attachments.attachOpened)}
aria-label={t("widget.openWebUi.attach")}
>
@@ -443,9 +541,15 @@ export function MessageBubble({
loading?: boolean;
}) {
const isUser = from === "user";
const avatar = (
<Avatar size={22} radius="xl" variant="filled" color={isUser ? "gray" : "blue"} className={classes.avatar}>
{isUser ? <IconUser size={14} /> : <IconRobot size={14} />}
// The assistant shows the Open WebUI brand logo (IconRobot is the fallback if
// the image fails to load); the user keeps a plain icon avatar.
const avatar = isUser ? (
<Avatar size={22} radius="xl" variant="filled" color="gray" className={classes.avatar}>
<IconUser size={14} />
</Avatar>
) : (
<Avatar size={22} radius="xl" src={getIconUrl("openWebUi")} alt="Open WebUI" className={classes.avatar}>
<IconRobot size={14} />
</Avatar>
);
return (
@@ -12,6 +12,16 @@
inset: 0;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 3;
background-color: var(--mantine-color-body);
border-bottom: 1px solid var(--mantine-color-default-border);
}
.composer {
position: absolute;
left: 0;
@@ -23,10 +33,24 @@
box-shadow: var(--mantine-shadow-md);
}
.modelSelect input {
.modelSelectControl {
font-size: var(--mantine-font-size-xs);
cursor: pointer;
overflow: hidden;
}
/* Truncate the selected model name once it hits the control's max width. */
.modelSelectLabel {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelOptionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Markdown-rendered assistant replies: keep block spacing tight inside the bubble. */
@@ -189,7 +213,7 @@
.historyOverlay {
position: absolute;
inset: 0;
z-index: 2;
z-index: 4;
display: flex;
flex-direction: column;
background-color: var(--mantine-color-body);
+48 -41
View File
@@ -8,12 +8,10 @@ import {
CloseButton,
Group,
Image,
Loader,
Modal,
NavLink,
Paper,
ScrollArea,
Select,
Stack,
Text,
Textarea,
@@ -43,7 +41,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { AttachmentBadge, AttachPopover, MessageBubble, RecordingWaveform } from "./chat-components";
import { AttachmentBadge, AttachPopover, MessageBubble, ModelSelect, RecordingWaveform } from "./chat-components";
import classes from "./chat.module.css";
import { VISION_MODEL_PATTERN, formatSeconds, hostnameOf } from "./chat-utils";
import { useAttachments } from "./use-attachments";
@@ -60,9 +58,11 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const [error, setError] = useState<string | null>(null);
const [historyOpened, setHistoryOpened] = useState(false);
const [composerHeight, setComposerHeight] = useState(72);
const [headerHeight, setHeaderHeight] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: models = [], isLoading: modelsLoading } = clientApi.widget.openWebUi.getModels.useQuery(
@@ -128,6 +128,16 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
return () => observer.disconnect();
}, []);
// Track the floating header's height so messages can pad above it.
useEffect(() => {
const element = headerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => setHeaderHeight(element.offsetHeight));
observer.observe(element);
setHeaderHeight(element.offsetHeight);
return () => observer.disconnect();
}, []);
// Auto-scroll to the bottom as messages or streamed tokens arrive.
useEffect(() => {
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
@@ -175,8 +185,42 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
return (
<Box className={classes.root} style={isEditMode ? { pointerEvents: "none" } : undefined}>
<Group ref={headerRef} className={classes.header} justify="space-between" gap="xs" wrap="nowrap" px="xs" py={6}>
<ModelSelect
data={modelData}
value={model}
onChange={setModel}
loading={modelsLoading}
disabled={stream.isStreaming}
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("widget.openWebUi.newChat")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("widget.openWebUi.newChat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
{options.showHistory ? (
<Tooltip label={t("widget.openWebUi.history")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryOpened(true)}
aria-label={t("widget.openWebUi.history")}
>
<IconHistory size={18} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
<Stack gap="xs" p="xs" pb={composerHeight + 24}>
<Stack gap="xs" p="xs" pt={headerHeight + 8} pb={composerHeight + 24}>
{stream.messages.length === 0 && !stream.isStreaming ? (
<Stack align="center" justify="center" gap="xs" mt="xl" c="dimmed">
<IconRobot size={32} />
@@ -337,16 +381,6 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
/>
<Group justify="space-between" gap="xs" wrap="nowrap" mt={4}>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("widget.openWebUi.newChat")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("widget.openWebUi.newChat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
<AttachPopover
attachments={attachments}
chats={chats}
@@ -357,35 +391,8 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
void camera.openCapture();
}}
/>
{options.showHistory ? (
<Tooltip label={t("widget.openWebUi.history")} withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryOpened(true)}
aria-label={t("widget.openWebUi.history")}
>
<IconHistory size={18} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
<Group gap="xs" wrap="nowrap">
<Select
variant="unstyled"
size="sm"
searchable
data={modelData}
value={model}
onChange={setModel}
disabled={stream.isStreaming}
placeholder={t("widget.openWebUi.selectModel")}
nothingFoundMessage={t("widget.openWebUi.noModels")}
rightSection={modelsLoading ? <Loader size="xs" /> : undefined}
comboboxProps={{ withinPortal: false }}
maw={200}
className={classes.modelSelect}
/>
{!stream.isStreaming && composerEmpty ? (
<Tooltip label={t("widget.openWebUi.record")} withinPortal={false}>
<ActionIcon