From eedf2faf029abede4bbd93bee9ce423640073575 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 17 Aug 2026 22:32:33 +0200 Subject: [PATCH] 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. --- .../agent/-components/agent-chat.test.tsx | 59 +++- .../routes/agent/-components/agent-chat.tsx | 153 +++++++--- .../$resumeId/-components/ai-assistant.tsx | 38 ++- packages/ui/src/components/empty.tsx | 88 ++++++ packages/ui/src/components/questionnaire.tsx | 282 ++++++++++++++++++ 5 files changed, 572 insertions(+), 48 deletions(-) create mode 100644 packages/ui/src/components/empty.tsx create mode 100644 packages/ui/src/components/questionnaire.tsx diff --git a/apps/web/src/routes/agent/-components/agent-chat.test.tsx b/apps/web/src/routes/agent/-components/agent-chat.test.tsx index 2b2670874..4677ab4ce 100644 --- a/apps/web/src/routes/agent/-components/agent-chat.test.tsx +++ b/apps/web/src/routes/agent/-components/agent-chat.test.tsx @@ -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( + + + , + ); + +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(); + }); +}); diff --git a/apps/web/src/routes/agent/-components/agent-chat.tsx b/apps/web/src/routes/agent/-components/agent-chat.tsx index 272e7ddea..3e0191f40 100644 --- a/apps/web/src/routes/agent/-components/agent-chat.tsx +++ b/apps/web/src/routes/agent/-components/agent-chat.tsx @@ -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) : 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) : {}; + 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 ( +
+

{question}

+

{answer ?? Waiting for the agent…}

+
+ ); + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const value = String(new FormData(event.currentTarget).get(ANSWER_FIELD) ?? "").trim(); + if (value) onAnswer(toolCallId, value); + }; + + return ( + ({ value: choice })) }]} + onSubmit={submit} + > + + {question} + + {choices.map((choice) => ( + + {choice} + + ))} + + + + + + + Send answer + + + + ); +} + 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) : {}; - 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 ( -
-
{question}
-
- {choices.map((choice) => ( - - ))} -
-
+
); @@ -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({ {messages.length === 0 ? ( -
- -

- What do you want to do? -

- -
+ + + + + + + What do you want to do? + + + + + + ) : null} {messages.map((message) => ( diff --git a/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx b/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx index cf01dc7c3..24c752726 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx @@ -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
{children}
; + return {children}; } function NoProviderHint() { return ( -
-
- -

+ + + + + + + No AI provider connected + + Set up an AI provider to chat about this resume and apply edits automatically. -

+ + + -
-
+ + ); } @@ -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.` }), + }); }, }, ); diff --git a/packages/ui/src/components/empty.tsx b/packages/ui/src/components/empty.tsx new file mode 100644 index 000000000..4b5f674f0 --- /dev/null +++ b/packages/ui/src/components/empty.tsx @@ -0,0 +1,88 @@ +import type { VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cva } from "class-variance-authority"; +import { cn } from "@reactive-resume/utils/style"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle }; diff --git a/packages/ui/src/components/questionnaire.tsx b/packages/ui/src/components/questionnaire.tsx new file mode 100644 index 000000000..f7e65def1 --- /dev/null +++ b/packages/ui/src/components/questionnaire.tsx @@ -0,0 +1,282 @@ +import type * as React from "react"; +import type { Button } from "./button"; +import { CheckIcon } from "@phosphor-icons/react"; +import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire"; +import { cn } from "@reactive-resume/utils/style"; +import { buttonVariants } from "./button"; + +function Questionnaire({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireProgress({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireChoices({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireChoice({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + input:focus-visible]:border-ring has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50 data-checked:border-primary/40 data-invalid:border-destructive data-checked:bg-muted dark:bg-input/20 dark:data-checked:bg-muted", + "data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50", + className, + )} + {...props} + > + + + ); +} + +function QuestionnaireChoiceDescription({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function QuestionnaireInput({ className, ...props }: React.ComponentProps) { + return ( +
+ +
+ ); +} + +function QuestionnaireError({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function QuestionnairePrevious({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Previous"} + + ); +} + +function QuestionnaireSkip({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Skip"} + + ); +} + +function QuestionnaireNext({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Next"} + + ); +} + +function QuestionnaireSubmit({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Submit"} + + ); +} + +export { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoiceDescription, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +};