From bf53a2a26280df24f22fc90e311f33feec9ef5bd Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 31 Jul 2026 06:32:57 +0200 Subject: [PATCH] fix: keep assistant menu selection visible --- apps/docs/docs/management/assistant.mdx | 3 +- .../components/assistant/assistant-panel.tsx | 40 +++++++++++++- .../assistant-trigger-scroll.spec.ts | 53 +++++++++++++++++++ .../assistant/assistant-trigger-scroll.ts | 17 ++++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index fa6254957..43baf7d27 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 654d44a56..94e7aa0b1 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 }) => { + const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = + unstable_useTriggerPopoverScopeContext(); + + useLayoutEffect(() => { + if (!open) return; + + const viewport = viewportRef.current; + const highlightedItem = viewport?.querySelector("[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(null); + const slashViewportRef = useRef(null); const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000, }); @@ -1150,12 +1182,14 @@ const ComposerTriggers = () => { return ( <> + {(items) => @@ -1186,11 +1220,13 @@ const ComposerTriggers = () => { + {(items) => items.map((item, index) => )} diff --git a/apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts new file mode 100644 index 000000000..b00fb18d0 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.spec.ts @@ -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); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts new file mode 100644 index 000000000..d571b22dc --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-trigger-scroll.ts @@ -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; +};