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

@@ -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,
}),
},
];