From 427db07a95fc85880668bb4319b63ef5bd2cbf55 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 3 Aug 2026 14:12:13 +0200 Subject: [PATCH] fix assistant prompt submission --- apps/docs/docs/management/assistant.mdx | 4 +- .../assistant/assistant-provider.tsx | 44 ++++++++++------ .../assistant/assistant-send.spec.ts | 46 ++++++++++++----- .../components/assistant/assistant-send.ts | 21 +++++--- .../assistant/assistant-spotlight.spec.ts | 50 ++++++++++++++++--- .../assistant/assistant-spotlight.tsx | 23 ++++++--- 6 files changed, 141 insertions(+), 47 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index d58e3a0db..cf9c7eb31 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index d15ed3b0b..710b246a1 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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; } @@ -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], diff --git a/apps/nextjs/src/components/assistant/assistant-send.spec.ts b/apps/nextjs/src/components/assistant/assistant-send.spec.ts index eb8894014..e0a981238 100644 --- a/apps/nextjs/src/components/assistant/assistant-send.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-send.spec.ts @@ -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(); }); }); diff --git a/apps/nextjs/src/components/assistant/assistant-send.ts b/apps/nextjs/src/components/assistant/assistant-send.ts index a442c06a9..c633f6d6c 100644 --- a/apps/nextjs/src/components/assistant/assistant-send.ts +++ b/apps/nextjs/src/components/assistant/assistant-send.ts @@ -1,13 +1,22 @@ -type AssistantComposerActions = { - setText: (text: string) => void; - send: () => void; +import type { AssistantClient } from "@assistant-ui/react"; + +type AssistantRunConfig = ReturnType["getState"]>["runConfig"]; +type AssistantAppendMessage = Parameters["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; }; diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts index 72f386641..bb46b981c 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts @@ -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(); }); }); diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx index 9cb431717..2e55a53a7 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx @@ -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, }), }, ];