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
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.
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
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 { showErrorNotification, showWarningNotification } from "@homarr/notifications";
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 { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
@@ -34,7 +39,7 @@ import { getPendingAssistantAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
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 { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
@@ -57,7 +62,7 @@ interface AssistantContextValue {
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => void;
sendPrompt: (prompt: string) => boolean;
refreshCurrentView: () => Promise<void>;
}
@@ -598,20 +603,20 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const sendPrompt = useCallback(
(prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return;
if (text.length === 0) return false;
if (assistantIsRunning || latestStatus?.type === "requires-action") {
showWarningNotification({
title: t("busy.title"),
message: t("busy.description"),
});
return;
return false;
}
setActivityDismissed(false);
if (isLoading) {
setQueuedPrompt(text);
return;
return true;
}
sendPromptThroughComposer(aui.composer(), text);
return sendPromptThroughRuntime(aui, text);
},
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
);
@@ -633,7 +638,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
if (isLoading || isRunning || queuedPrompt === null) return;
const prompt = queuedPrompt;
setQueuedPrompt(null);
sendPromptThroughComposer(aui.composer(), prompt);
sendPromptThroughRuntime(aui, prompt);
}, [aui, isLoading, isRunning, queuedPrompt]);
useEffect(() => {
@@ -675,17 +680,24 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
const spotlightItem = useMemo(
() => ({
const spotlightItem = useMemo(() => {
const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
return {
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description: t("spotlightDescription"),
description: canSend ? t("spotlightDescription") : t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, prompt: query }),
}),
[sendPrompt, t],
);
interaction: (query: string) =>
createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted: closeSpotlight,
prompt: query,
canSend,
}),
};
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(
@@ -769,7 +781,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => undefined,
sendPrompt: () => false,
refreshCurrentView: () => Promise.resolve(),
}),
[description],
@@ -3,22 +3,44 @@ import { describe, expect, test, vi } from "vitest";
import { sendAssistantPrompt } from "./assistant-send";
describe("sendAssistantPrompt", () => {
test("uses the composer lifecycle so programmatic prompts start a run", () => {
const calls: string[] = [];
const composer = {
setText: vi.fn((text: string) => calls.push(`set:${text}`)),
send: vi.fn(() => calls.push("send")),
test("appends a trimmed user prompt and explicitly starts a run", () => {
const append = vi.fn();
const runtime = {
composer: () => ({ getState: () => ({ runConfig: { custom: { source: "spotlight" } } }) }),
thread: () => ({ append }),
};
expect(sendAssistantPrompt(composer, " Check my services ")).toBe(true);
expect(calls).toEqual(["set:Check my services", "send"]);
expect(sendAssistantPrompt(runtime, " Check my services ")).toBe(true);
expect(append).toHaveBeenCalledWith({
role: "user",
content: [{ type: "text", text: "Check my services" }],
runConfig: { custom: { source: "spotlight" } },
startRun: true,
});
});
test("ignores empty prompts", () => {
const composer = { setText: vi.fn(), send: vi.fn() };
test("does not replace an existing composer draft", () => {
const append = vi.fn();
const getState = vi.fn(() => ({ text: "Keep this draft", runConfig: {} }));
const runtime = {
composer: () => ({ getState }),
thread: () => ({ append }),
};
expect(sendAssistantPrompt(composer, " ")).toBe(false);
expect(composer.setText).not.toHaveBeenCalled();
expect(composer.send).not.toHaveBeenCalled();
sendAssistantPrompt(runtime, "Run a health check");
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 = {
setText: (text: string) => void;
send: () => void;
import type { AssistantClient } from "@assistant-ui/react";
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();
if (text.length === 0) return false;
composer.setText(text);
composer.send();
runtime.thread().append({
role: "user",
content: [{ type: "text", text }],
runConfig: runtime.composer().getState().runConfig,
startRun: true,
});
return true;
};
@@ -4,9 +4,11 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
describe("createAssistantPromptInteraction", () => {
it("sends an existing default-search query immediately", () => {
const sendPrompt = vi.fn();
const sendPrompt = vi.fn(() => true);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({
sendPrompt,
onPromptAccepted,
prompt: " Check my services ",
});
@@ -14,13 +16,15 @@ describe("createAssistantPromptInteraction", () => {
if (interaction.type !== "javaScript") return;
interaction.onSelect();
expect(interaction.closeSpotlightOnTrigger).toBe(true);
expect(interaction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
expect(onPromptAccepted).toHaveBeenCalledOnce();
});
it("keeps Spotlight open when the prompt is empty", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt });
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
expect(interaction.type).toBe("children");
if (interaction.type !== "children") return;
@@ -35,11 +39,13 @@ describe("createAssistantPromptInteraction", () => {
sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).not.toHaveBeenCalled();
expect(onPromptAccepted).not.toHaveBeenCalled();
});
it("trims and sends a prompt in the background", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt });
const sendPrompt = vi.fn(() => true);
const onPromptAccepted = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt, onPromptAccepted });
expect(interaction.type).toBe("children");
if (interaction.type !== "children") return;
@@ -52,7 +58,39 @@ describe("createAssistantPromptInteraction", () => {
if (sendInteraction.type !== "javaScript") return;
sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(true);
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
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";
interface AssistantPromptInteractionOptions {
sendPrompt: (prompt: string) => void;
sendPrompt: (prompt: string) => boolean;
onPromptAccepted: () => void;
prompt?: string;
canSend?: boolean;
}
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();
if (trimmedPrompt.length > 0) {
return {
type: "javaScript" as const,
onSelect: () => sendPrompt(trimmedPrompt),
closeSpotlightOnTrigger: true,
onSelect: () => {
if (sendPrompt(trimmedPrompt)) onPromptAccepted();
},
closeSpotlightOnTrigger: false,
};
}
@@ -63,9 +74,9 @@ export const createAssistantPromptInteraction = ({ sendPrompt, prompt = "" }: As
useInteraction: () => ({
type: "javaScript" as const,
onSelect: () => {
if (nextPrompt.length > 0) sendPrompt(nextPrompt);
if (nextPrompt.length > 0 && sendPrompt(nextPrompt)) onPromptAccepted();
},
closeSpotlightOnTrigger: nextPrompt.length > 0,
closeSpotlightOnTrigger: false,
}),
},
];