refactor: group assistant runtime controls

This commit is contained in:
ajnart committed 2026-07-31 06:00:49 +02:00
1 parent e6981a7f44
commit 1d15b614f3
3 files changed
+192 -64

No files matched your search

+5 -5
View File
@@ -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,
@@ -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 {
@@ -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 (
<Group className={classes.runtimeControls} gap={6} wrap="nowrap">
<Select
className={classes.modelSelect}
classNames={{ input: classes.runtimeSelectInput }}
size="xs"
value={modelId}
onChange={(value) => {
if (value) onModelChange(value);
}}
data={models.map((model) => ({ value: model.id, label: model.name }))}
searchable
allowDeselect={false}
disabled={modelOptionsLoading || models.length === 0}
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
aria-label={t("runtime.model")}
placeholder={t("runtime.model")}
nothingFoundMessage={t("runtime.noModels")}
comboboxProps={{ withinPortal: true, position: "top-start" }}
/>
<Select
className={classes.reasoningSelect}
classNames={{ input: classes.runtimeSelectInput }}
size="xs"
value={reasoning}
onChange={(value) => {
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={<IconBrain size={14} />}
aria-label={t("runtime.thinking")}
comboboxProps={{ withinPortal: true, position: "top-end" }}
/>
<Group className={classes.runtimeControls} gap={0} wrap="nowrap">
<Button.Group
className={classes.runtimeButtonGroup}
aria-label={`${t("runtime.model")}, ${t("runtime.thinking")}`}
>
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={320}>
<Combobox.Target>
<Button
className={classes.modelButton}
classNames={{ root: classes.runtimeButton, label: classes.runtimeButtonLabel }}
variant="default"
size="compact-sm"
disabled={modelOptionsLoading || models.length === 0}
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
rightSection={<Combobox.Chevron size="xs" />}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}`}
title={selectedModel?.name}
>
{selectedModel?.name ?? t("runtime.model")}
</Button>
</Combobox.Target>
<Combobox.Dropdown className={classes.modelDropdown}>
<Combobox.Search
value={modelSearch}
onChange={(event) => {
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.model")}
aria-label={t("runtime.model")}
/>
<Combobox.Options className={classes.modelOptions}>
{visibleModels.map((model) => (
<Combobox.Option
className={classes.modelOption}
key={model.id}
value={model.id}
active={model.id === modelId}
>
<Group gap="xs" wrap="nowrap">
<Stack gap={0} className={classes.modelOptionText}>
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
{model.name}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{model.id}
</Text>
</Stack>
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
</Group>
</Combobox.Option>
))}
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Menu position="top-end" width={210} withinPortal>
<Menu.Target>
<Button
className={classes.reasoningButton}
classNames={{ root: classes.runtimeButton, label: classes.runtimeButtonLabel }}
variant="default"
size="compact-sm"
leftSection={<IconBrain size={14} />}
rightSection={<IconChevronUp size={14} />}
aria-label={`${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
title={t(`runtime.reasoning.${reasoning}`)}
>
{t(`runtime.reasoning.${reasoning}`)}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("runtime.thinking")}</Menu.Label>
{assistantReasoningModes.map((mode) => (
<Menu.Item
key={mode}
className={classes.runtimeMenuItem}
data-selected={mode === reasoning || undefined}
rightSection={mode === reasoning ? <IconCheck size={15} /> : null}
onClick={() => onReasoningChange(mode)}
>
{t(`runtime.reasoning.${mode}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Button.Group>
</Group>
);
};