From 73e4b80a263bb80fad4f6fc6f4d37aad943d33b2 Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 31 Jul 2026 06:58:39 +0200 Subject: [PATCH] feat: expose assistant global shortcuts --- apps/docs/docs/management/assistant.mdx | 6 +++-- .../assistant/assistant-provider.tsx | 15 ++++------- .../assistant/assistant-spotlight.spec.ts | 21 ++++++++++++++++ .../assistant/assistant-spotlight.tsx | 25 +++++++++++++------ .../spotlight/src/components/spotlight.tsx | 5 +++- .../modes/command/context-specific-group.tsx | 4 +-- .../modes/home/context-specific-group.spec.ts | 25 +++++++++++++++++++ .../src/modes/home/context-specific-group.tsx | 14 +++++++---- packages/spotlight/src/modes/home/context.tsx | 3 ++- 9 files changed, 90 insertions(+), 28 deletions(-) create mode 100644 packages/spotlight/src/modes/home/context-specific-group.spec.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 43baf7d27..cd9b0e796 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -9,8 +9,10 @@ tags: # Assistant Administrators can configure the instance-wide AI assistant from **Management → Assistant**. -It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu. Selecting -**Ask Homarr Assistant** in the command menu turns its search field into a prompt composer. Press +It is available to signed-in users from the user menu, with `Cmd/Ctrl + Shift + K`, and from the +`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default +search. Selecting it with an existing query sends that query in the background; selecting it without a +query turns the command-menu search field into a prompt composer. Press `Enter` to send the prompt in the background without opening the conversation. The search field receives focus whenever the command menu opens, including when it is opened by an assistant tool. diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 1e3d1f79d..360c13a9c 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -30,12 +30,7 @@ import { createId } from "@homarr/common"; import { hotkeys } from "@homarr/definitions"; import { showErrorNotification, showWarningNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { - openMediaRequestSearch, - openSpotlight, - useRegisterSpotlightContextActions, - useRegisterSpotlightContextResults, -} from "@homarr/spotlight"; +import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit"; import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools"; @@ -638,12 +633,12 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { name: t("spotlight"), icon: "/logo/logo.png", description: t("spotlightDescription"), - interaction: () => createAssistantPromptInteraction({ sendPrompt }), + alwaysVisible: true, + interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }), }), [sendPrompt, t], ); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); - useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( () => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }), @@ -691,12 +686,12 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP icon: "/logo/logo.png", description, unavailable: true, - interaction: () => ({ type: "none" as const }), + alwaysVisible: true, + interaction: (_query: string) => ({ type: "none" as const }), }), [description, t], ); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); - useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( () => ({ diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts index 896bbfab5..72f386641 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts @@ -3,9 +3,27 @@ import { describe, expect, it, vi } from "vitest"; import { createAssistantPromptInteraction } from "./assistant-spotlight"; describe("createAssistantPromptInteraction", () => { + it("sends an existing default-search query immediately", () => { + const sendPrompt = vi.fn(); + const interaction = createAssistantPromptInteraction({ + sendPrompt, + prompt: " Check my services ", + }); + + expect(interaction.type).toBe("javaScript"); + if (interaction.type !== "javaScript") return; + + interaction.onSelect(); + expect(interaction.closeSpotlightOnTrigger).toBe(true); + expect(sendPrompt).toHaveBeenCalledWith("Check my services"); + }); + it("keeps Spotlight open when the prompt is empty", () => { const sendPrompt = vi.fn(); const interaction = createAssistantPromptInteraction({ sendPrompt }); + expect(interaction.type).toBe("children"); + if (interaction.type !== "children") return; + const [action] = interaction.useActions({}, " "); expect(action).toBeDefined(); if (!action) return; @@ -22,6 +40,9 @@ describe("createAssistantPromptInteraction", () => { it("trims and sends a prompt in the background", () => { const sendPrompt = vi.fn(); const interaction = createAssistantPromptInteraction({ sendPrompt }); + expect(interaction.type).toBe("children"); + if (interaction.type !== "children") return; + const [action] = interaction.useActions({}, " Check my services "); expect(action).toBeDefined(); if (!action) return; diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx index 2319cf82a..9cb431717 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx @@ -7,6 +7,7 @@ import { useScopedI18n } from "@homarr/translation/client"; interface AssistantPromptInteractionOptions { sendPrompt: (prompt: string) => void; + prompt?: string; } const AssistantPromptDetail = () => { @@ -39,25 +40,35 @@ const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => { ); }; -export const createAssistantPromptInteraction = ({ sendPrompt }: AssistantPromptInteractionOptions) => - ({ +export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: AssistantPromptInteractionOptions) => { + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt.length > 0) { + return { + type: "javaScript" as const, + onSelect: () => sendPrompt(trimmedPrompt), + closeSpotlightOnTrigger: true, + }; + } + + return { type: "children", option: {}, DetailComponent: AssistantPromptDetail, useActions: (_options: Record, query: string) => { - const prompt = query.trim(); + const nextPrompt = query.trim(); return [ { key: "send-assistant-prompt", - Component: () => 0} />, + Component: () => 0} />, useInteraction: () => ({ type: "javaScript" as const, onSelect: () => { - if (prompt.length > 0) sendPrompt(prompt); + if (nextPrompt.length > 0) sendPrompt(nextPrompt); }, - closeSpotlightOnTrigger: prompt.length > 0, + closeSpotlightOnTrigger: nextPrompt.length > 0, }), }, ]; }, - }) as const; + } as const; +}; diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index b73539e56..6ed32c5be 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -22,6 +22,7 @@ import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups"; +import { contextSpecificSearchGroups } from "../modes/home/context-specific-group"; import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store"; import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightActionGroups } from "./actions/groups/action-group"; @@ -244,7 +245,9 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh pushChildrenOptions(options); }} query={query} - groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups} + groups={ + mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups + } /> )} diff --git a/packages/spotlight/src/modes/command/context-specific-group.tsx b/packages/spotlight/src/modes/command/context-specific-group.tsx index 7ed445b51..c5cebd219 100644 --- a/packages/spotlight/src/modes/command/context-specific-group.tsx +++ b/packages/spotlight/src/modes/command/context-specific-group.tsx @@ -29,8 +29,8 @@ export const contextSpecificActionsSearchGroups = createGroup ); }, - useInteraction(option) { - return option.interaction(); + useInteraction(option, query) { + return option.interaction(query); }, filter(query, option) { return option.name.toLowerCase().includes(query.toLowerCase()); diff --git a/packages/spotlight/src/modes/home/context-specific-group.spec.ts b/packages/spotlight/src/modes/home/context-specific-group.spec.ts new file mode 100644 index 000000000..a29063676 --- /dev/null +++ b/packages/spotlight/src/modes/home/context-specific-group.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import type { ContextSpecificItem } from "./context"; +import { shouldShowContextSpecificResult } from "./context-specific-group"; + +const item = (overrides: Partial = {}): ContextSpecificItem => ({ + id: "result", + name: "Matching result", + icon: "/icon.png", + interaction: () => ({ type: "none" }), + ...overrides, +}); + +describe("contextSpecificSearchGroups", () => { + test("keeps an always-visible fallback in every default search", () => { + expect(shouldShowContextSpecificResult("unrelated query", item({ alwaysVisible: true }))).toBe(true); + expect(shouldShowContextSpecificResult("", item({ alwaysVisible: true }))).toBe(true); + }); + + test("shows ordinary local results only when their names match", () => { + expect(shouldShowContextSpecificResult("matching", item())).toBe(true); + expect(shouldShowContextSpecificResult("unrelated query", item())).toBe(false); + expect(shouldShowContextSpecificResult("", item())).toBe(false); + }); +}); diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx index a51887966..2978b37d8 100644 --- a/packages/spotlight/src/modes/home/context-specific-group.tsx +++ b/packages/spotlight/src/modes/home/context-specific-group.tsx @@ -4,6 +4,12 @@ import { createGroup } from "../../lib/group"; import type { ContextSpecificItem } from "./context"; import { useSpotlightContextResults } from "./context"; +export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => { + if (option.alwaysVisible) return true; + if (query.length === 0) return false; + return option.name.toLowerCase().includes(query.toLowerCase()); +}; + export const contextSpecificSearchGroups = createGroup({ title: (t) => t("search.mode.home.group.local.title"), keyPath: "id", @@ -29,12 +35,10 @@ export const contextSpecificSearchGroups = createGroup({ ); }, - useInteraction(option) { - return option.interaction(); - }, - filter(query, option) { - return option.name.toLowerCase().includes(query.toLowerCase()); + useInteraction(option, query) { + return option.interaction(query); }, + filter: shouldShowContextSpecificResult, useOptions() { return useSpotlightContextResults(); }, diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx index 76e628cde..3aec94589 100644 --- a/packages/spotlight/src/modes/home/context.tsx +++ b/packages/spotlight/src/modes/home/context.tsx @@ -10,10 +10,11 @@ export type ContextSpecificItem = { id: string; name: string; icon: TablerIcon | string; - interaction: () => inferSearchInteractionDefinition; + interaction: (query: string) => inferSearchInteractionDefinition; disabled?: boolean; description?: string; unavailable?: boolean; + alwaysVisible?: boolean; }; interface SpotlightContextProps {