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

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