fix: keep assistant menu selection visible

This commit is contained in:
ajnart committed 2026-07-31 06:32:57 +02:00
1 parent 1d15b614f3
commit bf53a2a262
4 files changed
+110 -3

No files matched your search

+2 -1
View File
@@ -95,7 +95,8 @@ 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 joined
the server before adding it to the model context. Type `/` for common prompt shortcuts. Both menus support
keyboard navigation and keep the active result visible while moving through long lists. 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
@@ -1,7 +1,7 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { ComponentPropsWithoutRef, RefObject } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type {
FileMessagePartProps,
ImageMessagePartProps,
@@ -27,6 +27,7 @@ import {
useAuiState,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
unstable_useTriggerPopoverScopeContext,
} from "@assistant-ui/react";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import {
@@ -100,6 +101,7 @@ import classes from "./assistant-panel.module.css";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
interface AssistantPanelProps {
@@ -1066,6 +1068,34 @@ const contextIcons = {
tools: IconTool,
};
const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject<HTMLDivElement | null> }) => {
const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } =
unstable_useTriggerPopoverScopeContext();
useLayoutEffect(() => {
if (!open) return;
const viewport = viewportRef.current;
const highlightedItem = viewport?.querySelector<HTMLElement>("[data-highlighted]");
if (!viewport || !highlightedItem) return;
const viewportRect = viewport.getBoundingClientRect();
const itemRect = highlightedItem.getBoundingClientRect();
const viewportTop = viewportRect.top + viewport.clientTop;
const viewportBottom = viewportTop + viewport.clientHeight;
viewport.scrollTop = getNearestTriggerScrollTop({
scrollTop: viewport.scrollTop,
viewportTop,
viewportBottom,
itemTop: itemRect.top,
itemBottom: itemRect.bottom,
});
}, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]);
return null;
};
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
const Icon =
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
@@ -1093,6 +1123,8 @@ const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: numbe
const ComposerTriggers = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(null);
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
@@ -1150,12 +1182,14 @@ const ComposerTriggers = () => {
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
ref={mentionViewportRef}
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<TriggerPopoverAutoScroll viewportRef={mentionViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
{(items) =>
@@ -1186,11 +1220,13 @@ const ComposerTriggers = () => {
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Unstable_TriggerPopover
ref={slashViewportRef}
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<TriggerPopoverAutoScroll viewportRef={slashViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
describe("getNearestTriggerScrollTop", () => {
test("keeps a fully visible highlighted item in place", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 120,
viewportTop: 100,
viewportBottom: 400,
itemTop: 180,
itemBottom: 228,
}),
).toBe(120);
});
test("scrolls down only enough to reveal the highlighted item", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 120,
viewportTop: 100,
viewportBottom: 400,
itemTop: 388,
itemBottom: 436,
}),
).toBe(156);
});
test("scrolls up only enough to reveal the highlighted item", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 120,
viewportTop: 100,
viewportBottom: 400,
itemTop: 72,
itemBottom: 120,
}),
).toBe(92);
});
test("clamps upward scrolling at the start of the list", () => {
expect(
getNearestTriggerScrollTop({
scrollTop: 12,
viewportTop: 100,
viewportBottom: 400,
itemTop: 40,
itemBottom: 88,
}),
).toBe(0);
});
});
@@ -0,0 +1,17 @@
export const getNearestTriggerScrollTop = ({
scrollTop,
viewportTop,
viewportBottom,
itemTop,
itemBottom,
}: {
scrollTop: number;
viewportTop: number;
viewportBottom: number;
itemTop: number;
itemBottom: number;
}) => {
if (itemTop < viewportTop) return Math.max(0, scrollTop - (viewportTop - itemTop));
if (itemBottom > viewportBottom) return scrollTop + (itemBottom - viewportBottom);
return scrollTop;
};