feat(agent): let the assistant ask clarifying questions

Adds the questionnaire and empty-state primitives and renders the
ask_user_question tool call inline in the chat, so the agent can offer choices
instead of guessing when a request is ambiguous.
This commit is contained in:
Amruth Pillai
2026-08-17 22:32:33 +02:00
parent da2f1f8244
commit eedf2faf02
5 changed files with 572 additions and 48 deletions
@@ -1,8 +1,11 @@
// @vitest-environment happy-dom
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AssistantMarkdown } from "./agent-chat";
import type { UIMessage } from "ai";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { AskUserQuestion, AssistantMarkdown } from "./agent-chat";
describe("AssistantMarkdown", () => {
it("renders GitHub-style pipe tables as tables", () => {
@@ -23,3 +26,53 @@ describe("AssistantMarkdown", () => {
expect(screen.getByText("After")).toBeInTheDocument();
});
});
const questionPart = {
type: "tool-ask_user_question",
toolCallId: "call-1",
state: "input-available",
input: { question: "Which role are you targeting?", choices: ["Engineering manager", "Staff engineer"] },
} as unknown as UIMessage["parts"][number];
const renderQuestion = (answer: string | null, onAnswer: (toolCallId: string, value: string) => void) =>
render(
<I18nProvider i18n={i18n}>
<AskUserQuestion part={questionPart} answer={answer} onAnswer={onAnswer} />
</I18nProvider>,
);
describe("AskUserQuestion", () => {
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
it("submits the selected choice as the tool output", () => {
const onAnswer = vi.fn();
renderQuestion(null, onAnswer);
fireEvent.click(screen.getByRole("radio", { name: "Staff engineer" }));
fireEvent.click(screen.getByRole("button", { name: "Send answer" }));
expect(onAnswer).toHaveBeenCalledWith("call-1", "Staff engineer");
});
it("submits a freeform answer when no choice fits", () => {
const onAnswer = vi.fn();
renderQuestion(null, onAnswer);
fireEvent.change(screen.getByRole("textbox", { name: "Answer in your own words" }), {
target: { value: "Product designer" },
});
fireEvent.click(screen.getByRole("button", { name: "Send answer" }));
expect(onAnswer).toHaveBeenCalledWith("call-1", "Product designer");
});
it("renders the recorded answer once the question is answered", () => {
const onAnswer = vi.fn();
renderQuestion("Staff engineer", onAnswer);
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
expect(screen.getByText("Staff engineer")).toBeInTheDocument();
});
});
@@ -28,7 +28,6 @@ import { m } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import {
Attachment,
AttachmentContent,
@@ -45,6 +44,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Empty, EmptyContent, EmptyHeader, EmptyMedia, EmptyTitle } from "@reactive-resume/ui/components/empty";
import { Marker, MarkerContent, MarkerIcon } from "@reactive-resume/ui/components/marker";
import { Message, MessageContent } from "@reactive-resume/ui/components/message";
import {
@@ -55,7 +55,19 @@ import {
MessageScrollerProvider,
MessageScrollerViewport,
} from "@reactive-resume/ui/components/message-scroller";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@reactive-resume/ui/components/questionnaire";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
@@ -88,6 +100,12 @@ type FileAttachmentProps = {
state?: "idle" | "uploading" | "processing" | "error" | "done";
};
type AskUserQuestionProps = {
part: UIMessage["parts"][number];
answer: string | null;
onAnswer: (toolCallId: string, answer: string) => void;
};
type MessagePartProps = {
part: UIMessage["parts"][number];
isUser: boolean;
@@ -162,6 +180,8 @@ type AgentChatComposerProps = {
onUploadFiles: (files: FileList | null) => void;
};
const ANSWER_FIELD = "answer";
function toRecord(value: unknown) {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
}
@@ -433,6 +453,60 @@ function FileAttachment({ filename, mediaType, state = "done" }: FileAttachmentP
);
}
// The agent asks one question at a time, so this is a single-item Questionnaire: radio choices plus a
// freeform answer, submitted as the tool output. `Questionnaire` owns the fieldset/legend semantics,
// keyboard shortcuts, focus management, and required-answer validation.
export function AskUserQuestion({ part, answer, onAnswer }: AskUserQuestionProps) {
const input =
"input" in part && typeof part.input === "object" && part.input ? (part.input as Record<string, unknown>) : {};
const choices = Array.isArray(input.choices)
? input.choices.filter((choice): choice is string => typeof choice === "string")
: [];
const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`;
const toolCallId = "toolCallId" in part && typeof part.toolCallId === "string" ? part.toolCallId : null;
if (answer !== null || !toolCallId) {
return (
<div className="flex flex-col gap-1">
<p className="font-medium">{question}</p>
<p className="text-muted-foreground text-sm">{answer ?? <Trans>Waiting for the agent</Trans>}</p>
</div>
);
}
const submit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const value = String(new FormData(event.currentTarget).get(ANSWER_FIELD) ?? "").trim();
if (value) onAnswer(toolCallId, value);
};
return (
<Questionnaire
shortcuts="numbers"
items={[{ name: ANSWER_FIELD, required: true, choices: choices.map((choice) => ({ value: choice })) }]}
onSubmit={submit}
>
<QuestionnaireItem required name={ANSWER_FIELD}>
<QuestionnaireTitle>{question}</QuestionnaireTitle>
<QuestionnaireChoices>
{choices.map((choice) => (
<QuestionnaireChoice key={choice} value={choice}>
{choice}
</QuestionnaireChoice>
))}
<QuestionnaireInput aria-label={t`Answer in your own words`} placeholder={t`Something else…`} />
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnaireSubmit size="sm">
<Trans>Send answer</Trans>
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}
function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsById }: MessagePartProps) {
if (part.type === "text") {
return (
@@ -464,26 +538,12 @@ function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsByI
}
if (part.type === "tool-ask_user_question") {
const input =
"input" in part && typeof part.input === "object" && part.input ? (part.input as Record<string, unknown>) : {};
const choices = Array.isArray(input.choices)
? input.choices.filter((choice): choice is string => typeof choice === "string")
: [];
const question = typeof input.question === "string" ? input.question : t`The agent needs your input.`;
const answer = "output" in part && typeof part.output === "string" ? part.output : null;
return (
<Bubble variant="outline" className="max-w-full">
<BubbleContent className="w-full">
<div className="space-y-3">
<div className="font-medium">{question}</div>
<div className="flex flex-wrap gap-2">
{choices.map((choice) => (
<Button key={choice} size="sm" variant="outline" onClick={() => onAnswer(part.toolCallId, choice)}>
{choice}
</Button>
))}
</div>
</div>
<AskUserQuestion part={part} answer={answer} onAnswer={onAnswer} />
</BubbleContent>
</Bubble>
);
@@ -602,11 +662,14 @@ export function AgentChat({
{ id: threadId },
{
onSuccess: async () => {
toast.success(t`Thread archived.`);
toast.add({ type: "success", description: t`Thread archived.` });
await refreshThread();
},
onError: (error) => {
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` }));
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` }),
});
},
},
);
@@ -623,13 +686,16 @@ export function AgentChat({
{ id: threadId },
{
onSuccess: async () => {
toast.success(t`Thread deleted.`);
toast.add({ type: "success", description: t`Thread deleted.` });
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
if (onClose) onClose();
else void navigate({ to: "/agent" });
},
onError: (error) => {
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` }));
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` }),
});
},
},
);
@@ -730,9 +796,12 @@ export function AgentChat({
);
setPendingAttachments((current) => [...current, ...attachments]);
toast.success(t`Attachment uploaded.`);
toast.add({ type: "success", description: t`Attachment uploaded.` });
} catch (error) {
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }));
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }),
});
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
@@ -763,12 +832,12 @@ export function AgentChat({
2,
),
);
toast.success(t`Conversation JSON copied.`);
toast.add({ type: "success", description: t`Conversation JSON copied.` });
};
const copyConversationText = () => {
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
toast.success(t`Conversation copied.`);
toast.add({ type: "success", description: t`Conversation copied.` });
};
const answerToolCall = (toolCallId: string, answer: string) => {
@@ -786,13 +855,21 @@ export function AgentChat({
{
onSuccess: (action) => {
if (action.status === "conflicted") {
toast.error(action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`);
toast.add({
type: "error",
description:
action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`,
});
} else if (action.status === "rolled_back" || action.status === "reverted") {
toast.success(t`Patch rolled back.`);
toast.add({ type: "success", description: t`Patch rolled back.` });
}
void refreshThread();
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` })),
onError: (error) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` }),
}),
},
);
};
@@ -880,13 +957,19 @@ function AgentChatMessages({
<MessageScrollerViewport>
<MessageScrollerContent className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
{messages.length === 0 ? (
<div className="grid gap-6 py-12 text-center">
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
<h2 className="font-semibold text-2xl">
<Trans>What do you want to do?</Trans>
</h2>
<StarterPromptMarquee onSelect={onStarterSelect} />
</div>
<Empty className="py-12">
<EmptyHeader>
<EmptyMedia variant="icon">
<SparkleIcon />
</EmptyMedia>
<EmptyTitle className="text-2xl">
<Trans>What do you want to do?</Trans>
</EmptyTitle>
</EmptyHeader>
<EmptyContent className="max-w-full">
<StarterPromptMarquee onSelect={onStarterSelect} />
</EmptyContent>
</Empty>
) : null}
{messages.map((message) => (
@@ -5,10 +5,18 @@ import { GearSixIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@reactive-resume/ui/components/empty";
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -24,23 +32,30 @@ type AiAssistantThreadProps = {
};
function CenteredState({ children }: { children: ReactNode }) {
return <div className="grid h-full place-items-center p-6 text-center text-muted-foreground text-sm">{children}</div>;
return <Empty className="h-full text-muted-foreground text-sm">{children}</Empty>;
}
function NoProviderHint() {
return (
<div className="grid h-full place-items-center p-6">
<div className="max-w-xs space-y-4 text-center">
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Empty className="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<SparkleIcon />
</EmptyMedia>
<EmptyTitle>
<Trans>No AI provider connected</Trans>
</EmptyTitle>
<EmptyDescription>
<Trans>Set up an AI provider to chat about this resume and apply edits automatically.</Trans>
</p>
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button nativeButton={false} render={<Link to="/dashboard/settings/integrations" />}>
<GearSixIcon />
<Trans>Set up an AI provider</Trans>
</Button>
</div>
</div>
</EmptyContent>
</Empty>
);
}
@@ -91,7 +106,10 @@ function AiAssistantPanel({ resumeId, onClose }: BuilderAiAssistantProps & { onC
onSuccess: (thread) => setThreadId(thread.id),
onError: (mutationError) => {
setSetupFailed(true);
toast.error(getOrpcErrorMessage(mutationError, { fallback: t`Failed to start the AI assistant.` }));
toast.add({
type: "error",
description: getOrpcErrorMessage(mutationError, { fallback: t`Failed to start the AI assistant.` }),
});
},
},
);