feat: expose assistant global shortcuts

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 4b647ec2c9
commit 73e4b80a26
9 files changed
+90 -28

No files matched your search

+4 -2
View File
@@ -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.
@@ -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(
() => ({
@@ -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;
@@ -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<string, unknown>, query: string) => {
const prompt = query.trim();
const nextPrompt = query.trim();
return [
{
key: "send-assistant-prompt",
Component: () => <AssistantPromptAction hasPrompt={prompt.length > 0} />,
Component: () => <AssistantPromptAction hasPrompt={nextPrompt.length > 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;
};
@@ -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
}
/>
)}
</MantineSpotlight.ActionsList>
@@ -29,8 +29,8 @@ export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificIte
</Group>
);
},
useInteraction(option) {
return option.interaction();
useInteraction(option, query) {
return option.interaction(query);
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
@@ -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> = {}): 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);
});
});
@@ -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<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
@@ -29,12 +35,10 @@ export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
</Group>
);
},
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();
},
@@ -10,10 +10,11 @@ export type ContextSpecificItem = {
id: string;
name: string;
icon: TablerIcon | string;
interaction: () => inferSearchInteractionDefinition<SearchInteraction>;
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
disabled?: boolean;
description?: string;
unavailable?: boolean;
alwaysVisible?: boolean;
};
interface SpotlightContextProps {