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}
- : undefined}
- comboboxProps={{ withinPortal: false }}
- maw={200}
- className={classes.modelSelect}
- />
{!stream.isStreaming && composerEmpty ? (