fix assistant prompt submission

This commit is contained in:
ajnart committed 2026-08-13 19:56:30 +02:00
1 parent fc07e911d1
commit 427db07a95
6 files changed
+141 -47

No files matched your search

+3 -1
View File
@@ -14,7 +14,9 @@ It is available to signed-in users from the user menu, with `Shift + A`, and fro
search. Selecting it with an existing query sends that query in the background; selecting it without a 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 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 `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. focus whenever the command menu opens, including when it is opened by an assistant tool. Sending from the
command menu preserves any draft in the expanded conversation. The local result is temporarily disabled while
the assistant is responding or waiting for an approval.
While a response is running, a compact activity bar appears at the bottom of the page and the user-menu While a response is running, a compact activity bar appears at the bottom of the page and the user-menu
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
@@ -22,7 +22,12 @@ import { createId } from "@homarr/common";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications"; import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight"; import {
closeSpotlight,
openMediaRequestSearch,
openSpotlight,
useRegisterSpotlightContextResults,
} from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets"; import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit"; import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
@@ -34,7 +39,7 @@ import { getPendingAssistantAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences"; import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"; import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send"; import { sendAssistantPrompt as sendPromptThroughRuntime } from "./assistant-send";
import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts"; import { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool"; import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
@@ -57,7 +62,7 @@ interface AssistantContextValue {
open: () => void; open: () => void;
close: () => void; close: () => void;
toggle: () => void; toggle: () => void;
sendPrompt: (prompt: string) => void; sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>; refreshCurrentView: () => Promise<void>;
} }
@@ -598,20 +603,20 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const sendPrompt = useCallback( const sendPrompt = useCallback(
(prompt: string) => { (prompt: string) => {
const text = prompt.trim(); const text = prompt.trim();
if (text.length === 0) return; if (text.length === 0) return false;
if (assistantIsRunning || latestStatus?.type === "requires-action") { if (assistantIsRunning || latestStatus?.type === "requires-action") {
showWarningNotification({ showWarningNotification({
title: t("busy.title"), title: t("busy.title"),
message: t("busy.description"), message: t("busy.description"),
}); });
return; return false;
} }
setActivityDismissed(false); setActivityDismissed(false);
if (isLoading) { if (isLoading) {
setQueuedPrompt(text); setQueuedPrompt(text);
return; return true;
} }
sendPromptThroughComposer(aui.composer(), text); return sendPromptThroughRuntime(aui, text);
}, },
[assistantIsRunning, aui, isLoading, latestStatus?.type, t], [assistantIsRunning, aui, isLoading, latestStatus?.type, t],
); );
@@ -633,7 +638,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
if (isLoading || isRunning || queuedPrompt === null) return; if (isLoading || isRunning || queuedPrompt === null) return;
const prompt = queuedPrompt; const prompt = queuedPrompt;
setQueuedPrompt(null); setQueuedPrompt(null);
sendPromptThroughComposer(aui.composer(), prompt); sendPromptThroughRuntime(aui, prompt);
}, [aui, isLoading, isRunning, queuedPrompt]); }, [aui, isLoading, isRunning, queuedPrompt]);
useEffect(() => { useEffect(() => {
@@ -675,17 +680,24 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]); useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
const spotlightItem = useMemo( const spotlightItem = useMemo(() => {
() => ({ const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
return {
id: "homarr-assistant", id: "homarr-assistant",
name: t("spotlight"), name: t("spotlight"),
icon: "/logo/logo.png", icon: "/logo/logo.png",
description: t("spotlightDescription"), description: canSend ? t("spotlightDescription") : t("busy.description"),
unavailable: !canSend,
alwaysVisible: true, alwaysVisible: true,
interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }), interaction: (query: string) =>
}), createAssistantPromptInteraction({
[sendPrompt, t], sendPrompt,
); onPromptAccepted: closeSpotlight,
prompt: query,
canSend,
}),
};
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo( const value = useMemo(
@@ -769,7 +781,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
open: () => undefined, open: () => undefined,
close: () => undefined, close: () => undefined,
toggle: () => undefined, toggle: () => undefined,
sendPrompt: () => undefined, sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(), refreshCurrentView: () => Promise.resolve(),
}), }),
[description], [description],
@@ -3,22 +3,44 @@ import { describe, expect, test, vi } from "vitest";
import { sendAssistantPrompt } from "./assistant-send"; import { sendAssistantPrompt } from "./assistant-send";
describe("sendAssistantPrompt", () => { describe("sendAssistantPrompt", () => {
test("uses the composer lifecycle so programmatic prompts start a run", () => { test("appends a trimmed user prompt and explicitly starts a run", () => {
const calls: string[] = []; const append = vi.fn();
const composer = { const runtime = {
setText: vi.fn((text: string) => calls.push(`set:${text}`)), composer: () => ({ getState: () => ({ runConfig: { custom: { source: "spotlight" } } }) }),
send: vi.fn(() => calls.push("send")), thread: () => ({ append }),
}; };
expect(sendAssistantPrompt(composer, " Check my services ")).toBe(true); expect(sendAssistantPrompt(runtime, " Check my services ")).toBe(true);
expect(calls).toEqual(["set:Check my services", "send"]); expect(append).toHaveBeenCalledWith({
role: "user",
content: [{ type: "text", text: "Check my services" }],
runConfig: { custom: { source: "spotlight" } },
startRun: true,
});
}); });
test("ignores empty prompts", () => { test("does not replace an existing composer draft", () => {
const composer = { setText: vi.fn(), send: vi.fn() }; const append = vi.fn();
const getState = vi.fn(() => ({ text: "Keep this draft", runConfig: {} }));
const runtime = {
composer: () => ({ getState }),
thread: () => ({ append }),
};
expect(sendAssistantPrompt(composer, " ")).toBe(false); sendAssistantPrompt(runtime, "Run a health check");
expect(composer.setText).not.toHaveBeenCalled();
expect(composer.send).not.toHaveBeenCalled(); expect(getState().text).toBe("Keep this draft");
expect(append).toHaveBeenCalledOnce();
});
test("ignores empty prompts without touching the runtime", () => {
const runtime = {
composer: vi.fn(),
thread: vi.fn(),
};
expect(sendAssistantPrompt(runtime, " ")).toBe(false);
expect(runtime.composer).not.toHaveBeenCalled();
expect(runtime.thread).not.toHaveBeenCalled();
}); });
}); });
@@ -1,13 +1,22 @@
type AssistantComposerActions = { import type { AssistantClient } from "@assistant-ui/react";
setText: (text: string) => void;
send: () => void; type AssistantRunConfig = ReturnType<ReturnType<AssistantClient["composer"]>["getState"]>["runConfig"];
type AssistantAppendMessage = Parameters<ReturnType<AssistantClient["thread"]>["append"]>[0];
type AssistantPromptRuntime = {
composer: () => { getState: () => { runConfig: AssistantRunConfig } };
thread: () => { append: (message: AssistantAppendMessage) => void };
}; };
export const sendAssistantPrompt = (composer: AssistantComposerActions, prompt: string) => { export const sendAssistantPrompt = (runtime: AssistantPromptRuntime, prompt: string) => {
const text = prompt.trim(); const text = prompt.trim();
if (text.length === 0) return false; if (text.length === 0) return false;
composer.setText(text); runtime.thread().append({
composer.send(); role: "user",
content: [{ type: "text", text }],
runConfig: runtime.composer().getState().runConfig,
startRun: true,
});
return true; return true;
}; };
@@ -4,9 +4,11 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
describe("createAssistantPromptInteraction", () => { describe("createAssistantPromptInteraction", () => {
it("sends an existing default-search query immediately", () => { it("sends an existing default-search query immediately", () => {
const sendPrompt = vi.fn(); const sendPrompt = vi.fn(() => true);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ const interaction = createAssistantPromptInteraction({
sendPrompt, sendPrompt,
onPromptAccepted,
prompt: " Check my services ", prompt: " Check my services ",
}); });
@@ -14,13 +16,15 @@ describe("createAssistantPromptInteraction", () => {
if (interaction.type !== "javaScript") return; if (interaction.type !== "javaScript") return;
interaction.onSelect(); interaction.onSelect();
expect(interaction.closeSpotlightOnTrigger).toBe(true); expect(interaction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services"); expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).toHaveBeenCalledOnce();
}); });
it("keeps Spotlight open when the prompt is empty", () => { it("keeps Spotlight open when the prompt is empty", () => {
const sendPrompt = vi.fn(); const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt }); const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
expect(interaction.type).toBe("children"); expect(interaction.type).toBe("children");
if (interaction.type !== "children") return; if (interaction.type !== "children") return;
@@ -35,11 +39,13 @@ describe("createAssistantPromptInteraction", () => {
sendInteraction.onSelect(); sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false); expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).not.toHaveBeenCalled(); expect(sendPrompt).not.toHaveBeenCalled();
expect(onPromptAccepted).not.toHaveBeenCalled();
}); });
it("trims and sends a prompt in the background", () => { it("trims and sends a prompt in the background", () => {
const sendPrompt = vi.fn(); const sendPrompt = vi.fn(() => true);
const interaction = createAssistantPromptInteraction({ sendPrompt }); const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
expect(interaction.type).toBe("children"); expect(interaction.type).toBe("children");
if (interaction.type !== "children") return; if (interaction.type !== "children") return;
@@ -52,7 +58,39 @@ describe("createAssistantPromptInteraction", () => {
if (sendInteraction.type !== "javaScript") return; if (sendInteraction.type !== "javaScript") return;
sendInteraction.onSelect(); sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(true); expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services"); expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).toHaveBeenCalledOnce();
});
it("keeps Spotlight open when the runtime rejects the prompt", () => {
const sendPrompt = vi.fn(() => false);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted,
prompt: "Check my services",
});
expect(interaction.type).toBe("javaScript");
if (interaction.type !== "javaScript") return;
interaction.onSelect();
expect(interaction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).not.toHaveBeenCalled();
});
it("cannot trigger while the assistant is already handling a request", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted: vi.fn(),
prompt: "Check my services",
canSend: false,
});
expect(interaction).toEqual({ type: "none" });
expect(sendPrompt).not.toHaveBeenCalled();
}); });
}); });
@@ -6,8 +6,10 @@ import { IconArrowUp, IconRobot } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
interface AssistantPromptInteractionOptions { interface AssistantPromptInteractionOptions {
sendPrompt: (prompt: string) => void; sendPrompt: (prompt: string) => boolean;
onPromptAccepted: () => void;
prompt?: string; prompt?: string;
canSend?: boolean;
} }
const AssistantPromptDetail = () => { const AssistantPromptDetail = () => {
@@ -40,13 +42,22 @@ const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => {
); );
}; };
export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: AssistantPromptInteractionOptions) => { export const createAssistantPromptInteraction = ({
sendPrompt,
onPromptAccepted,
prompt = "",
canSend = true,
}: AssistantPromptInteractionOptions) => {
if (!canSend) return { type: "none" as const };
const trimmedPrompt = prompt.trim(); const trimmedPrompt = prompt.trim();
if (trimmedPrompt.length > 0) { if (trimmedPrompt.length > 0) {
return { return {
type: "javaScript" as const, type: "javaScript" as const,
onSelect: () => sendPrompt(trimmedPrompt), onSelect: () => {
closeSpotlightOnTrigger: true, if (sendPrompt(trimmedPrompt)) onPromptAccepted();
},
closeSpotlightOnTrigger: false,
}; };
} }
@@ -63,9 +74,9 @@ export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: As
useInteraction: () => ({ useInteraction: () => ({
type: "javaScript" as const, type: "javaScript" as const,
onSelect: () => { onSelect: () => {
if (nextPrompt.length > 0) sendPrompt(nextPrompt); if (nextPrompt.length > 0 && sendPrompt(nextPrompt)) onPromptAccepted();
}, },
closeSpotlightOnTrigger: nextPrompt.length > 0, closeSpotlightOnTrigger: false,
}), }),
}, },
]; ];