From a5fa6d3afebbf796736c20116c37c20386bc768c Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 09:53:19 +0200 Subject: [PATCH] fix: restore assistant approval flows --- apps/docs/docs/management/assistant.mdx | 5 +- .../assistant/assistant-human-tools.tsx | 23 +++- .../assistant/assistant-panel.module.css | 22 ++++ .../components/assistant/assistant-panel.tsx | 112 ++++++++++++++++-- .../assistant-pending-action.spec.ts | 93 +++++++++++++++ .../assistant/assistant-pending-action.ts | 45 +++++++ .../assistant/assistant-provider.tsx | 25 ++-- .../assistant-runtime-provider.spec.ts | 80 +++++++++++++ .../assistant/assistant-runtime-provider.tsx | 23 ++++ packages/translation/src/lang/en.json | 7 ++ 10 files changed, 398 insertions(+), 37 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-pending-action.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index cd9b0e796..f82402ae7 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -124,7 +124,10 @@ structured result remains available in the tool card details. When the assistant genuinely needs missing information or a choice, it can pause with a structured question. Users can select one of the proposed answers or choose **Other** and enter their own response. -Submitting the answer resumes the same agent run automatically. +Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the +question or approval that needs attention and opens directly back to the request. While an answer, app +form, or mutation approval is pending, the composer preserves its draft but does not replace the pending +tool call with a new message. App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker, including automatic matching and searches through the `icon.findIcons` procedure, before the final diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 83f8fa01f..e5ea434c3 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -32,6 +32,7 @@ export const AssistantAskUserTool = ({ const t = useScopedI18n("common.assistant.askUser"); const [showOther, setShowOther] = useState(false); const [other, setOther] = useState(""); + const [submitting, setSubmitting] = useState(false); const otherInputRef = useRef(null); const options = Array.isArray(args?.options) ? args.options : []; @@ -69,10 +70,16 @@ export const AssistantAskUserTool = ({ ); } + const submitResult = (answer: AskUserResult) => { + if (submitting) return; + setSubmitting(true); + addResult(answer); + }; + const submitOther = () => { const answer = other.trim(); if (!answer) return; - addResult({ answer, source: "other" }); + submitResult({ answer, source: "other" }); }; return ( @@ -102,7 +109,8 @@ export const AssistantAskUserTool = ({ size="md" fullWidth justify="space-between" - onClick={() => addResult({ answer: option.label, optionId: option.id, source: "option" })} + disabled={submitting} + onClick={() => submitResult({ answer: option.label, optionId: option.id, source: "option" })} > @@ -134,7 +142,7 @@ export const AssistantAskUserTool = ({ label={t("otherLabel")} placeholder={t("otherPlaceholder")} /> - @@ -422,7 +430,12 @@ const ToolPart = ({ fullWidth variant="default" leftSection={} - onClick={() => respondToApproval({ approved: false })} + loading={approvalResponse === "deny"} + disabled={approvalResponse !== null} + onClick={() => { + setApprovalResponse("deny"); + respondToApproval({ approved: false }); + }} > {t("deny")} @@ -1239,7 +1252,7 @@ const ComposerTriggers = () => { type ComposerProps = Pick< AssistantPanelProps, "modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange" ->; +> & { pendingAction: AssistantPendingAction | undefined }; const RuntimeControls = ({ modelId, @@ -1374,11 +1387,18 @@ const RuntimeControls = ({ const Composer = (props: ComposerProps) => { const t = useScopedI18n("common.assistant"); const running = useAuiState((state) => state.thread.isRunning); + const hasPendingAction = props.pendingAction !== undefined; return ( - + { + if (hasPendingAction) event.preventDefault(); + }} + > @@ -1414,7 +1434,13 @@ const Composer = (props: ComposerProps) => { ) : ( - + @@ -1438,12 +1464,55 @@ const Composer = (props: ComposerProps) => { ); }; +const usePendingActionCopy = (action: AssistantPendingAction | undefined) => { + const t = useScopedI18n("common.assistant"); + if (!action) return undefined; + if (action.kind === "question") { + return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") }; + } + if (action.kind === "form") { + return { title: t("pendingAction.formTitle"), detail: action.detail ?? t("configureApp.title") }; + } + const tool = action.toolName.replaceAll("_", " "); + return { title: t("activity.approval"), detail: action.detail ? `${tool} ยท ${action.detail}` : tool }; +}; + +const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => { + const t = useScopedI18n("common.assistant"); + const copy = usePendingActionCopy(pendingAction); + if (!copy) return null; + + return ( + + + + + + + + {copy.title} + + + {copy.detail} + + + + + + + + ); +}; + const AssistantActivityBar = ({ isRunning, unreadCount, latestAssistantText, latestUserText, latestStatus, + pendingAction, onOpen, onDismissActivity, activityDismissed, @@ -1454,12 +1523,14 @@ const AssistantActivityBar = ({ | "latestAssistantText" | "latestUserText" | "latestStatus" + | "pendingAction" | "onOpen" | "onDismissActivity" | "activityDismissed" >) => { const t = useScopedI18n("common.assistant"); - const needsApproval = latestStatus?.type === "requires-action"; + const pendingCopy = usePendingActionCopy(pendingAction); + const needsApproval = pendingAction !== undefined || latestStatus?.type === "requires-action"; const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled"; const visible = !activityDismissed && @@ -1469,14 +1540,16 @@ const AssistantActivityBar = ({ const title = isRunning ? t("activity.thinking") - : needsApproval - ? t("activity.approval") - : failed - ? t("activity.failed") - : t("activity.ready"); + : pendingCopy + ? pendingCopy.title + : needsApproval + ? t("activity.approval") + : failed + ? t("activity.failed") + : t("activity.ready"); const detail = isRunning ? latestUserText || t("activity.working") - : latestAssistantText || (failed ? t("responseError.description") : t("activity.completed")); + : pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed")); return ( @@ -1512,7 +1585,16 @@ const AssistantActivityBar = ({ {detail} - + {needsApproval ? ( + + + {t("pendingAction.review")} + + + + ) : ( + + )} {!isRunning && !needsApproval && ( @@ -1541,6 +1623,7 @@ export const AssistantPanel = ({ latestAssistantText, latestUserText, latestStatus, + pendingAction, modelId, models, modelOptionsLoading, @@ -1584,6 +1667,7 @@ export const AssistantPanel = ({ latestAssistantText={latestAssistantText} latestUserText={latestUserText} latestStatus={latestStatus} + pendingAction={pendingAction} /> )} {opened && ( @@ -1654,6 +1738,7 @@ export const AssistantPanel = ({ + diff --git a/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts b/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts new file mode 100644 index 000000000..3241d0538 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-pending-action.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import type { ThreadMessage } from "@assistant-ui/react"; + +import { getPendingAssistantAction } from "./assistant-pending-action"; + +const assistantMessage = (...content: ThreadMessage["content"]): ThreadMessage => + ({ + id: "assistant-message", + role: "assistant", + content, + status: { type: "requires-action", reason: "tool-calls" }, + createdAt: new Date(), + metadata: { custom: {} }, + }) as ThreadMessage; + +describe("getPendingAssistantAction", () => { + test("exposes an unanswered structured question", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "ask-1", + toolName: "ask_user", + args: { question: "Which board should receive the widget?" }, + argsText: "{}", + }), + ), + ).toEqual({ + kind: "question", + toolName: "ask_user", + detail: "Which board should receive the widget?", + }); + }); + + test("clears a human action after its result is supplied", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "ask-1", + toolName: "ask_user", + args: { question: "Continue?" }, + argsText: "{}", + result: { answer: "Yes" }, + }), + ), + ).toBeUndefined(); + }); + + test("exposes the app form while its details still need review", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "configure-1", + toolName: "configure_app", + args: { name: "Wikipedia" }, + argsText: "{}", + }), + ), + ).toEqual({ kind: "form", toolName: "configure_app", detail: "Wikipedia" }); + }); + + test("exposes an unresolved mutation approval", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "create-1", + toolName: "app_create", + args: { name: "Wikipedia" }, + argsText: "{}", + approval: { id: "approval-1" }, + }), + ), + ).toEqual({ kind: "approval", toolName: "app_create", detail: "Wikipedia" }); + }); + + test("clears an approval after the user responds", () => { + expect( + getPendingAssistantAction( + assistantMessage({ + type: "tool-call", + toolCallId: "create-1", + toolName: "app_create", + args: { name: "Wikipedia" }, + argsText: "{}", + approval: { id: "approval-1", approved: true }, + }), + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-pending-action.ts b/apps/nextjs/src/components/assistant/assistant-pending-action.ts new file mode 100644 index 000000000..41aae34df --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-pending-action.ts @@ -0,0 +1,45 @@ +import type { ThreadMessage } from "@assistant-ui/react"; + +export type AssistantPendingAction = { + kind: "question" | "form" | "approval"; + toolName: string; + detail?: string; +}; + +const humanToolKinds = { + ask_user: "question", + configure_app: "form", +} as const; + +const getStringArg = (args: unknown, key: string) => { + if (typeof args !== "object" || args === null || !(key in args)) return undefined; + const value = (args as Record)[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +}; + +export const getPendingAssistantAction = (message: ThreadMessage | undefined): AssistantPendingAction | undefined => { + if (message?.role !== "assistant") return undefined; + + for (const part of message.content.toReversed()) { + if (part.type !== "tool-call" || part.isError) continue; + + if (part.approval && part.approval.approved === undefined && !part.approval.resolution) { + return { + kind: "approval", + toolName: part.toolName, + detail: getStringArg(part.args, "name"), + }; + } + + const kind = humanToolKinds[part.toolName as keyof typeof humanToolKinds]; + if (kind === undefined || part.result !== undefined) continue; + + return { + kind, + toolName: part.toolName, + detail: getStringArg(part.args, kind === "question" ? "question" : "name"), + }; + } + + return undefined; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 360c13a9c..10477fee8 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -11,15 +11,7 @@ import type { ThreadMessage, Toolkit, } from "@assistant-ui/react"; -import { - AssistantRuntimeProvider, - defineToolkit, - Tools, - useAui, - useAuiEvent, - useAuiState, - useRemoteThreadListRuntime, -} from "@assistant-ui/react"; +import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react"; import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk"; import { useHotkeys } from "@mantine/hooks"; import { createAssistantStream } from "assistant-stream"; @@ -35,7 +27,9 @@ import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResul import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit"; import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools"; import { AssistantPanel } from "./assistant-panel"; +import { getPendingAssistantAction } from "./assistant-pending-action"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; +import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"; import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send"; import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { browserToolContracts } from "./assistant-tool-contracts"; @@ -461,14 +455,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => { }) as Toolkit, [router], ); - return ( - - + {children} - + ); }; @@ -495,11 +487,6 @@ const AssistantPreferenceSync = () => { return null; }; -const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => { - useAui({ tools: Tools({ toolkit }) }); - return null; -}; - const AssistantRuntimeEvents = () => { const t = useScopedI18n("common.assistant"); useAuiEvent("composer.attachmentAddError", ({ message }) => { @@ -543,6 +530,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const latestAssistantText = getMessageText(latestAssistantMessage); const latestUserText = getMessageText(latestUserMessage); const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined; + const pendingAction = getPendingAssistantAction(latestAssistantMessage); const assistantIsRunning = isRunning || queuedPrompt !== null; const notificationKey = getNotificationKey(latestAssistantMessage); const initializedRef = useRef(false); @@ -662,6 +650,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { latestAssistantText={latestAssistantText} latestUserText={queuedPrompt ?? latestUserText} latestStatus={latestStatus} + pendingAction={pendingAction} modelId={preferences.modelId} models={preferences.models} modelOptionsLoading={preferences.isLoading} diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts new file mode 100644 index 000000000..b5783a197 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react"; +import { defineToolkit, MessagePrimitive, ThreadPrimitive, useLocalRuntime } from "@assistant-ui/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { z } from "zod/v4"; + +import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const noOpAdapter: ChatModelAdapter = { async *run() {} }; +const toolkit = defineToolkit({ + ask_user: { + type: "human", + display: "standalone", + parameters: z.object({ question: z.string() }), + render: ({ args }) => createElement("div", { "data-testid": "ask-user-ui" }, `Question: ${args.question}`), + }, +}); +const messages: ThreadMessageLike[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "ask-1", + toolName: "ask_user", + args: { question: "Which board?" }, + argsText: JSON.stringify({ question: "Which board?" }), + }, + ], + status: { type: "requires-action", reason: "tool-calls" }, + }, +]; + +const Message = () => + createElement(MessagePrimitive.Parts, { + components: { + tools: { + Fallback: () => createElement("div", { "data-testid": "fallback-tool-ui" }, "Fallback"), + }, + }, + }); + +const TestAssistant = () => { + const runtime = useLocalRuntime(noOpAdapter, { initialMessages: messages }); + return createElement( + AssistantRuntimeProviderWithTools, + { runtime, toolkit }, + createElement(ThreadPrimitive.Messages, { components: { Message } }), + ); +}; + +const containers: HTMLDivElement[] = []; + +afterEach(() => { + for (const container of containers.splice(0)) container.remove(); +}); + +describe("AssistantRuntimeProviderWithTools", () => { + test("makes the registered human-tool renderer available to message parts", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + root.render(createElement(TestAssistant)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(container.querySelector('[data-testid="ask-user-ui"]')?.textContent).toBe("Question: Which board?"); + expect(container.querySelector('[data-testid="fallback-tool-ui"]')).toBeNull(); + + await act(async () => root.unmount()); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx new file mode 100644 index 000000000..fb6e74f14 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import type { AssistantRuntime, Toolkit } from "@assistant-ui/react"; +import { AssistantRuntimeProvider, Tools, useAui } from "@assistant-ui/react"; + +interface AssistantRuntimeProviderWithToolsProps extends PropsWithChildren { + runtime: AssistantRuntime; + toolkit: Toolkit; +} + +export const AssistantRuntimeProviderWithTools = ({ + children, + runtime, + toolkit, +}: AssistantRuntimeProviderWithToolsProps) => { + const toolsAui = useAui({ tools: Tools({ toolkit }) }); + return ( + + {children} + + ); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index beeab65ae..13d89b1c7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1637,6 +1637,13 @@ "title": "The assistant is already working", "description": "Wait for the current response or open it to stop the run." }, + "pendingAction": { + "answerTitle": "Answer needed", + "answerFallback": "Choose an answer to continue", + "formTitle": "Review app details", + "review": "Review", + "sendBlocked": "Resolve the pending request before sending" + }, "activity": { "thinking": "Assistant is thinking", "working": "Working on your prompt",