fix: restore assistant approval flows

This commit is contained in:
ajnart committed 2026-08-01 09:54:11 +02:00
1 parent d0e28a5fc9
commit a5fa6d3afe
10 files changed
+398 -37

No files matched your search

+4 -1
View File
@@ -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
@@ -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<HTMLInputElement>(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" })}
>
<Box ta="start">
<Text component="span" size="sm" fw={650}>
@@ -134,7 +142,7 @@ export const AssistantAskUserTool = ({
label={t("otherLabel")}
placeholder={t("otherPlaceholder")}
/>
<Button size="md" onClick={submitOther} disabled={!other.trim()}>
<Button size="md" onClick={submitOther} disabled={!other.trim() || submitting} loading={submitting}>
{t("submit")}
</Button>
<Button
@@ -174,6 +182,7 @@ export const AssistantConfigureAppTool = ({
addResult,
}: ToolCallMessagePartProps<ConfigureAppArgs, AppValues>) => {
const t = useScopedI18n("common.assistant.configureApp");
const [submitting, setSubmitting] = useState(false);
if (result) {
return (
@@ -205,8 +214,12 @@ export const AssistantConfigureAppTool = ({
initialValues={toAppValues(args)}
buttonLabels={{ submit: t("continue") }}
showBackToOverview={false}
handleSubmit={(values) => addResult(values)}
isPending={false}
handleSubmit={(values) => {
if (submitting) return;
setSubmitting(true);
addResult(values);
}}
isPending={submitting}
/>
</Box>
);
@@ -39,6 +39,17 @@
color: var(--mantine-color-dimmed);
}
.activityReviewAction {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0.5rem;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-yellow-light-color);
background: var(--mantine-color-yellow-light);
}
.activityDismiss {
width: 2.5rem;
height: 2.5rem;
@@ -346,6 +357,13 @@
margin-top: 0.85rem;
}
.pendingActionBanner {
flex: 0 0 auto;
padding: 0.6rem 0.75rem;
border-block: 1px solid var(--mantine-color-default-border);
background: var(--mantine-color-yellow-light);
}
.humanTool,
.appTool {
width: 100%;
@@ -982,6 +1000,10 @@
}
@media (max-width: 30em) {
.activityReviewAction > span {
display: none;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
@@ -100,6 +100,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
import type { AssistantPendingAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
@@ -115,6 +116,7 @@ interface AssistantPanelProps {
latestAssistantText: string;
latestUserText: string;
latestStatus: MessageStatus | undefined;
pendingAction: AssistantPendingAction | undefined;
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
@@ -346,6 +348,7 @@ const ToolPart = ({
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
@@ -413,7 +416,12 @@ const ToolPart = ({
size="md"
fullWidth
leftSection={<IconCheck size={18} />}
onClick={() => respondToApproval({ approved: true })}
loading={approvalResponse === "approve"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("approve");
respondToApproval({ approved: true });
}}
>
{t("approveAndRun")}
</Button>
@@ -422,7 +430,12 @@ const ToolPart = ({
fullWidth
variant="default"
leftSection={<IconX size={18} />}
onClick={() => respondToApproval({ approved: false })}
loading={approvalResponse === "deny"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("deny");
respondToApproval({ approved: false });
}}
>
{t("deny")}
</Button>
@@ -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 (
<Box className={classes.composerWrap}>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
<ComposerPrimitive.Root className={classes.composer}>
<ComposerPrimitive.Root
className={classes.composer}
data-pending-action={hasPendingAction || undefined}
onSubmit={(event) => {
if (hasPendingAction) event.preventDefault();
}}
>
<ComposerTriggers />
<ComposerPrimitive.Quote className={classes.composerQuote}>
<IconQuote size={15} />
@@ -1414,7 +1434,13 @@ const Composer = (props: ComposerProps) => {
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
<ActionIcon
color="red"
variant="filled"
size="lg"
aria-label={hasPendingAction ? t("pendingAction.sendBlocked") : t("send")}
disabled={hasPendingAction}
>
<IconArrowUp size={18} />
</ActionIcon>
</ComposerPrimitive.Send>
@@ -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 (
<Box component="output" className={classes.pendingActionBanner}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="yellow" radius="xl">
<IconMessage size={17} />
</ThemeIcon>
<Stack gap={0} flex={1} miw={0}>
<Text size="sm" fw={700}>
{copy.title}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{copy.detail}
</Text>
</Stack>
<ThreadPrimitive.ScrollToBottom asChild>
<Button variant="light" color="yellow" size="compact-sm">
{t("pendingAction.review")}
</Button>
</ThreadPrimitive.ScrollToBottom>
</Group>
</Box>
);
};
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 (
<Box component="output" className={classes.activityBar} aria-live="polite">
@@ -1512,7 +1585,16 @@ const AssistantActivityBar = ({
{detail}
</Text>
</Stack>
<IconChevronUp size={17} className={classes.activityExpandIcon} />
{needsApproval ? (
<Box className={classes.activityReviewAction}>
<Text component="span" size="xs" fw={700}>
{t("pendingAction.review")}
</Text>
<IconChevronUp size={15} />
</Box>
) : (
<IconChevronUp size={17} className={classes.activityExpandIcon} />
)}
</Group>
</UnstyledButton>
{!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 = ({
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<PendingActionBanner pendingAction={pendingAction} />
<Composer
modelId={modelId}
models={models}
@@ -1661,6 +1746,7 @@ export const AssistantPanel = ({
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
pendingAction={pendingAction}
/>
</ThreadPrimitive.Root>
</dialog>
@@ -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();
});
});
@@ -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<string, unknown>)[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;
};
@@ -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 (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantTools toolkit={toolkit} />
<AssistantRuntimeProviderWithTools runtime={runtime} toolkit={toolkit}>
<AssistantRuntimeEvents />
<AssistantPreferenceSync />
{children}
</AssistantRuntimeProvider>
</AssistantRuntimeProviderWithTools>
);
};
@@ -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}
@@ -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());
});
});
@@ -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 (
<AssistantRuntimeProvider aui={toolsAui} runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
};
+7
View File
@@ -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",