From 41b75ef5a093ae322213d42be822650aad365cf9 Mon Sep 17 00:00:00 2001 From: Lok3sh-Kumar Date: Sat, 27 Jun 2026 14:18:15 +0530 Subject: [PATCH] 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. --- packages/translation/src/lang/en.json | 1 + .../src/open-webui/chat-components.tsx | 112 +++++++++++++++++- .../widgets/src/open-webui/chat.module.css | 28 ++++- packages/widgets/src/open-webui/chat.tsx | 89 +++++++------- 4 files changed, 183 insertions(+), 47 deletions(-) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5defbdf6c..21727fa3f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3554,6 +3554,7 @@ } }, "selectModel": "Select a model", + "searchModel": "Search models…", "noModels": "No models available", "emptyState": "Start a conversation", "messagePlaceholder": "Type a message…", diff --git a/packages/widgets/src/open-webui/chat-components.tsx b/packages/widgets/src/open-webui/chat-components.tsx index 32b62d21d..6561fccc1 100644 --- a/packages/widgets/src/open-webui/chat-components.tsx +++ b/packages/widgets/src/open-webui/chat-components.tsx @@ -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 ( + { + onChange(submitted); + combobox.closeDropdown(); + }} + > + + + ) : combobox.dropdownOpened ? ( + + ) : ( + + ) + } + rightSectionPointerEvents="none" + onClick={() => combobox.toggleDropdown()} + > + {selectedLabel ? ( + {selectedLabel} + ) : ( + {t("widget.openWebUi.selectModel")} + )} + + + + setSearch(event.currentTarget.value)} + placeholder={t("widget.openWebUi.searchModel")} + /> + + {filtered.length > 0 ? ( + filtered.map((item) => ( + + + {item.label} + {item.value === value ? : null} + + + )) + ) : ( + {t("widget.openWebUi.noModels")} + )} + + + + ); +} + export function MenuRow({ icon, label, @@ -226,7 +325,6 @@ export function AttachPopover({ 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 = ( - - {isUser ? : } + // 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 ? ( + + + + ) : ( + + ); return ( diff --git a/packages/widgets/src/open-webui/chat.module.css b/packages/widgets/src/open-webui/chat.module.css index 938d997cb..5c7997a0b 100644 --- a/packages/widgets/src/open-webui/chat.module.css +++ b/packages/widgets/src/open-webui/chat.module.css @@ -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); diff --git a/packages/widgets/src/open-webui/chat.tsx b/packages/widgets/src/open-webui/chat.tsx index b049feeef..2dbb8318f 100644 --- a/packages/widgets/src/open-webui/chat.tsx +++ b/packages/widgets/src/open-webui/chat.tsx @@ -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(null); const [historyOpened, setHistoryOpened] = useState(false); const [composerHeight, setComposerHeight] = useState(72); + const [headerHeight, setHeaderHeight] = useState(0); const viewportRef = useRef(null); const composerRef = useRef(null); + const headerRef = useRef(null); const fileInputRef = useRef(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 ( + + + + + + + + + {options.showHistory ? ( + + setHistoryOpened(true)} + aria-label={t("widget.openWebUi.history")} + > + + + + ) : null} + + + - + {stream.messages.length === 0 && !stream.isStreaming ? ( @@ -337,16 +381,6 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro /> - - - - - - {options.showHistory ? ( - - setHistoryOpened(true)} - aria-label={t("widget.openWebUi.history")} - > - - - - ) : null} -