From 1d15b614f3b30ae1a30de830ea1228cf10a4b87a Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 31 Jul 2026 06:00:49 +0200 Subject: [PATCH] refactor: group assistant runtime controls --- apps/docs/docs/management/assistant.mdx | 10 +- .../assistant/assistant-panel.module.css | 88 +++++++--- .../components/assistant/assistant-panel.tsx | 158 +++++++++++++----- 3 files changed, 192 insertions(+), 64 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 2e070a3f1..fa6254957 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -95,11 +95,11 @@ as part of the prompt. Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations, boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on -the server before adding it to the model context. Type `/` for common prompt shortcuts. The compact -controls below the composer can switch between models discovered from the configured endpoint. Model -selection is stored on the conversation after a completed request. Thinking mode can be left on -**Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's -provider-neutral reasoning setting and still depend on support from the chosen model and endpoint. +the server before adding it to the model context. Type `/` for common prompt shortcuts. The joined +controls below the composer open a searchable model picker and the thinking-level menu. Model selection +is stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, +or set from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning +setting and still depend on support from the chosen model and endpoint. Each completed response includes a circular context-window meter. Select it to open request details when the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context, diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 961bce411..6624c14a7 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -740,30 +740,70 @@ flex: 1 1 auto; } -.modelSelect { - width: min(15rem, 52%); - min-width: 8rem; +.runtimeButtonGroup { + min-width: 0; + max-width: 100%; } -.reasoningSelect { - width: 8.75rem; - flex: 0 0 auto; -} - -.runtimeSelectInput { - border-color: transparent; - border-radius: var(--mantine-radius-md); +.runtimeButton { + min-width: 0; + border-color: var(--mantine-color-default-border); + color: var(--mantine-color-text); background: var(--mantine-color-body); font-weight: 600; } -.runtimeSelectInput:hover { +.runtimeButton:hover { background: var(--mantine-color-default-hover); } -.runtimeSelectInput:focus, -.runtimeSelectInput:focus-within { - border-color: var(--mantine-primary-color-filled); +.runtimeButtonLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelButton { + min-width: 8rem; + max-width: 15rem; + flex: 1 1 auto; +} + +.reasoningButton { + min-width: 7.5rem; + max-width: 10.5rem; + flex: 0 1 auto; +} + +.modelDropdown { + max-width: calc(100vw - 2rem); +} + +.modelOptions { + max-height: min(18rem, 42dvh); + overflow-y: auto; + overscroll-behavior: contain; +} + +.modelOption { + padding-block: 0.45rem; +} + +.modelOptionText { + min-width: 0; + flex: 1; +} + +.runtimeOptionCheck { + flex: 0 0 auto; + color: var(--mantine-primary-color-filled); +} + +.runtimeMenuItem[data-selected] { + color: var(--mantine-primary-color-filled); + background: var(--mantine-primary-color-light); + font-weight: 600; } .triggerPopover { @@ -918,10 +958,17 @@ width: 100%; } - .modelSelect { - width: auto; + .runtimeButtonGroup { + width: 100%; + } + + .modelButton { min-width: 0; - flex: 1 1 auto; + max-width: none; + } + + .reasoningButton { + max-width: 9rem; } .suggestions { @@ -970,8 +1017,9 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .reasoningSelect { - width: 7.75rem; + .reasoningButton { + min-width: 2.5rem; + max-width: 7.75rem; } .approvalActions { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index ccbde59ac..654d44a56 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -36,19 +36,21 @@ import { Box, Button, Collapse, + Combobox, Divider, FocusTrap, Group, Loader, + Menu, Popover, RingProgress, ScrollArea, - Select, Stack, Text, ThemeIcon, Tooltip, UnstyledButton, + useCombobox, } from "@mantine/core"; import { useWindowEvent } from "@mantine/hooks"; import { @@ -1212,45 +1214,123 @@ const RuntimeControls = ({ onReasoningChange, }: ComposerProps) => { const t = useScopedI18n("common.assistant"); + const [modelSearch, setModelSearch] = useState(""); + const selectedModel = models.find((model) => model.id === modelId); + const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase(); + const visibleModels = + normalizedModelSearch.length === 0 + ? models + : models.filter( + (model) => + model.name.toLocaleLowerCase().includes(normalizedModelSearch) || + model.id.toLocaleLowerCase().includes(normalizedModelSearch), + ); + const modelCombobox = useCombobox({ + onDropdownOpen: () => { + modelCombobox.selectActiveOption(); + requestAnimationFrame(() => modelCombobox.focusSearchInput()); + }, + onDropdownClose: () => { + modelCombobox.resetSelectedOption(); + setModelSearch(""); + }, + }); + + const selectModel = (value: string) => { + if (!models.some((model) => model.id === value)) return; + onModelChange(value); + modelCombobox.closeDropdown(); + }; + return ( - - { - if (value && assistantReasoningModes.includes(value as AssistantReasoningMode)) { - onReasoningChange(value as AssistantReasoningMode); - } - }} - data={assistantReasoningModes.map((mode) => ({ - value: mode, - label: t(`runtime.reasoning.${mode}`), - }))} - allowDeselect={false} - leftSection={} - aria-label={t("runtime.thinking")} - comboboxProps={{ withinPortal: true, position: "top-end" }} - /> + + + + + + + + { + setModelSearch(event.currentTarget.value); + modelCombobox.updateSelectedOptionIndex(); + }} + placeholder={t("runtime.model")} + aria-label={t("runtime.model")} + /> + + {visibleModels.map((model) => ( + + + + + {model.name} + + + {model.id} + + + {model.id === modelId && } + + + ))} + {visibleModels.length === 0 && {t("runtime.noModels")}} + + + + + + + + + {t("runtime.thinking")} + {assistantReasoningModes.map((mode) => ( + : null} + onClick={() => onReasoningChange(mode)} + > + {t(`runtime.reasoning.${mode}`)} + + ))} + + + ); };