feat: add clarifying questions to ai chat#3825
Conversation
✅ No New Circular DependenciesNo new circular dependencies detected. Current count: 0 |
📦 Alpha Package Version PublishedUse Use |
🔍 Visual review for your branch is published 🔍Here are the links to: |
There was a problem hiding this comment.
Pull request overview
This PR adds an inline “clarifying questions” flow to F0AiChat, allowing the AI backend to request structured, multi-step user input (radio/checkbox + optional custom answer) rendered inside the chat input area.
Changes:
- Extended
AiChatStateProvider/useAiChat()to store and expose an activeclarifyingQuestionstate. - Added a CopilotKit frontend action (
AiWidgets.F0ClarifyingQuestion) that manages multi-step state and triggers the clarifying-question UI. - Updated
ChatTextareato swap the textarea UI for the newClarifyingQuestionPanelwhile clarifying is active (with animations + reduced-motion support), plus added i18n strings.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/sds/ai/F0AiChat/providers/AiChatStateProvider.tsx | Adds clarifying-question state to the provider context. |
| packages/react/src/sds/ai/F0AiChat/internal-types.ts | Extends AiChatProviderReturnValue with clarifying-question fields. |
| packages/react/src/sds/ai/F0AiChat/components/input/ClarifyingQuestionPanel.tsx | New UI panel for multi-step radio/checkbox + custom answer inside the input area. |
| packages/react/src/sds/ai/F0AiChat/components/input/ChatTextarea.tsx | Integrates the panel via AnimatePresence and disables textarea submission while clarifying. |
| packages/react/src/sds/ai/F0AiChat/components/input/stories/ClarifyingQuestion/ClarifyingQuestion.stories.tsx | Adds Storybook coverage for clarifying-question scenarios. |
| packages/react/src/sds/ai/F0AiChat/actions/registry.ts | Registers the new clarifying-question Copilot action. |
| packages/react/src/sds/ai/F0AiChat/actions/index.ts | Exposes useClarifyingQuestionAction from the actions barrel. |
| packages/react/src/sds/ai/F0AiChat/actions/core/clarifyingQuestion/useClarifyingQuestionAction.tsx | Implements the Copilot action + controller that drives clarifying-question state and submission. |
| packages/react/src/sds/ai/F0AiChat/actions/core/clarifyingQuestion/types.ts | Defines ClarifyingQuestion / option types used across controller and UI. |
| packages/react/src/lib/providers/i18n/i18n-provider-defaults.ts | Adds default i18n strings for clarifying-question UI labels. |
| // Save selections, custom text, and custom active state for current step | ||
| const updatedSelections = { | ||
| ...allSelectionsRef.current, | ||
| [currentStep.question]: selected, | ||
| } | ||
| setAllSelections(updatedSelections) | ||
|
|
||
| const updatedCustomTexts = { | ||
| ...allCustomTextsRef.current, | ||
| [currentStep.question]: currentCustomText, | ||
| } | ||
| setAllCustomTexts(updatedCustomTexts) | ||
|
|
||
| const updatedCustomActives = { | ||
| ...allCustomActivesRef.current, | ||
| [currentStep.question]: currentCustomActive, | ||
| } | ||
| setAllCustomActives(updatedCustomActives) |
There was a problem hiding this comment.
The per-step persistence maps (allSelections/allCustomTexts/allCustomActives) are keyed by currentStep.question. If two steps share the same question text (or the backend changes wording), state can collide across steps and produce incorrect restored selections. Prefer a stable key (e.g., step index, or an explicit stepId field from the backend) for storage and retrieval.
| parts.push(`${step.question} → ${answer}`) | ||
| } | ||
| const message = parts.join("\n") | ||
| sendMessage(message) |
There was a problem hiding this comment.
On the final step, confirm() calls sendMessage(message) but never dismisses the clarifying UI (e.g. setClarifyingQuestion(null)) or resets local step state. If the CopilotKit action render stays mounted, the textarea can remain blocked on the panel even after submission. Consider clearing clarifyingQuestion (and optionally resetting step state) immediately after sending the message.
| sendMessage(message) | |
| sendMessage(message) | |
| // Dismiss clarifying UI and reset local step state after final submission | |
| setClarifyingQuestion(null) | |
| setCurrentStepIndex(0) | |
| setSelectedIds([]) | |
| setCustomAnswerTextState("") | |
| setIsCustomAnswerActive(false) | |
| setAllSelections({}) | |
| setAllCustomTexts({}) | |
| setAllCustomActives({}) |
| const confirm = useCallback(() => { | ||
| const idx = currentStepIndexRef.current | ||
| const currentStep = stepsRef.current[idx] | ||
| const selected = selectedIdsRef.current | ||
| const currentCustomText = customAnswerTextRef.current | ||
| const currentCustomActive = isCustomAnswerActiveRef.current | ||
|
|
||
| // Save selections, custom text, and custom active state for current step | ||
| const updatedSelections = { | ||
| ...allSelectionsRef.current, | ||
| [currentStep.question]: selected, | ||
| } | ||
| setAllSelections(updatedSelections) |
There was a problem hiding this comment.
confirm() assumes stepsRef.current[idx] is always defined and immediately dereferences currentStep.question. If the backend updates steps to a shorter array (or empty) while the component is mounted, this can throw before the reset effect runs. Add a defensive guard to early-return (and clear the panel) when currentStep is missing.
| // Build a structured user message so the AI knows each answer per question | ||
| const parts: string[] = [] | ||
| for (const step of stepsRef.current) { | ||
| const stepSelected = updatedSelections[step.question] ?? [] | ||
| const stepCustom = updatedCustomTexts[step.question] ?? "" | ||
| const stepCustomActive = updatedCustomActives[step.question] ?? false | ||
| const labels = step.options | ||
| .filter((o) => stepSelected.includes(o.id)) | ||
| .map((o) => o.label) | ||
|
|
||
| // In radio mode, custom text is mutually exclusive with predefined options. | ||
| // Only include custom text when no predefined option is selected. | ||
| // In checkbox mode, include custom text only when explicitly activated. | ||
| const isRadio = (step.selectionMode ?? "checkbox") === "radio" | ||
| const includeCustom = isRadio | ||
| ? stepSelected.length === 0 && stepCustom.trim().length > 0 | ||
| : stepCustomActive && stepCustom.trim().length > 0 | ||
|
|
||
| if (includeCustom) { | ||
| labels.push(`(custom) ${stepCustom.trim()}`) | ||
| } | ||
|
|
||
| const answer = labels.length > 0 ? labels.join(", ") : "(skipped)" | ||
| parts.push(`${step.question} → ${answer}`) | ||
| } | ||
| const message = parts.join("\n") | ||
| sendMessage(message) |
There was a problem hiding this comment.
The submitted message is built as a user-visible English summary (uses "→", "(custom)", "(skipped)"). This will show up in the chat transcript via UserMessage and isn't localized, which conflicts with the repo’s i18n convention for user-facing strings (see packages/react/.skills/f0-code-review/SKILL.md i18n section). Consider sending a hidden machine-readable payload (e.g. in the existing <tool-context ...> prefix which the UI strips) and/or localizing any visible text.
| const isFirstStep = currentStepIndex === 0 | ||
| const isFinalStep = currentStepIndex === totalSteps - 1 | ||
| const stepLabel = isMultiStep | ||
| ? `${currentStepIndex + 1} of ${totalSteps}` |
There was a problem hiding this comment.
stepLabel uses a hardcoded English string ("${currentStepIndex + 1} of ${totalSteps}") which is user-facing in the UI. Please move this to i18n (e.g. add a translation key with placeholders, or reuse an existing one like wizard.stepOf).
| ? `${currentStepIndex + 1} of ${totalSteps}` | |
| ? (translation.wizard?.stepOf | |
| ?.replace("{current}", String(currentStepIndex + 1)) | |
| ?.replace("{total}", String(totalSteps)) ?? | |
| `${currentStepIndex + 1}/${totalSteps}`) |
| {allowCustomAnswer && !isEditingCustom && ( | ||
| <div | ||
| className={cn( | ||
| "flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", | ||
| "transition-colors", | ||
| "text-f1-foreground-secondary hover:bg-f1-background-secondary" | ||
| )} | ||
| > | ||
| {hasCustomText ? ( | ||
| selectionMode === "radio" ? ( | ||
| <button | ||
| type="button" | ||
| onClick={handleActivateCustom} | ||
| className={cn( | ||
| "flex h-5 w-5 shrink-0 items-center justify-center rounded-full transition-colors", | ||
| focusRing("rounded-full"), | ||
| !hasSelection | ||
| ? "bg-f1-background-selected-bold" | ||
| : "border-2 border-solid border-f1-border bg-f1-background" | ||
| )} | ||
| > | ||
| {!hasSelection && ( | ||
| <span className="h-2 w-2 rounded-full bg-f1-background" /> | ||
| )} | ||
| </button> | ||
| ) : ( | ||
| <F0Checkbox | ||
| checked={isCustomAnswerActive} | ||
| onCheckedChange={() => { | ||
| setCustomAnswerActive(!isCustomAnswerActive) | ||
| }} | ||
| /> | ||
| ) | ||
| ) : ( | ||
| <span | ||
| className={cn( | ||
| "flex shrink-0 items-center justify-center", | ||
| selectionMode === "radio" ? "size-5" : "size-6" | ||
| )} | ||
| > | ||
| <Pencil className="size-3.5 text-f1-foreground-secondary" /> | ||
| </span> | ||
| )} | ||
| <button | ||
| type="button" | ||
| onClick={handleActivateCustom} | ||
| className={cn( | ||
| "min-w-0 flex-1 text-left", | ||
| focusRing("rounded-sm"), | ||
| hasCustomText ? "text-f1-foreground" : "" | ||
| )} | ||
| > | ||
| {hasCustomText | ||
| ? customAnswerText | ||
| : translation.ai.clarifyingQuestion.typeYourAnswer} | ||
| </button> |
There was a problem hiding this comment.
In radio mode, the custom answer row isn’t represented as a radio option within the radiogroup (it’s a plain div with buttons/inputs). This means screen readers won’t announce it as a selectable radio item, and keyboard radio-group navigation semantics won’t include it. Consider giving the custom row role="radio" + aria-checked (and consistent focus/keyboard handling) or switching to an actual radio input so it participates in the same group as the predefined options.
| const meta = { | ||
| title: "AI/F0AiChat/Input/ChatTextarea/ClarifyingQuestion", | ||
| parameters: { | ||
| layout: "centered", | ||
| }, | ||
| decorators: [ | ||
| (Story) => ( | ||
| <F0AiChatProvider> | ||
| <Story /> | ||
| </F0AiChatProvider> | ||
| ), | ||
| ], | ||
| } satisfies Meta | ||
|
|
There was a problem hiding this comment.
Story meta is missing the standard tags: ["autodocs"] used elsewhere in F0AiChat stories, and satisfies Meta is untyped. For consistency and better type safety, add tags and use satisfies Meta<typeof SomeComponent> (and optionally set component in meta, as done in ChatTextarea.stories.tsx).
6cd0483 to
4e90ae1
Compare
Description
Description
Add inline clarifying questions to the F0AiChat component. When the AI backend needs structured user input before proceeding, it renders a multi-step question panel directly inside the chat textarea area. Supports radio (single-select) and checkbox (multi-select) modes, with an optional free-text custom answer.
Screenshots (if applicable)
clarifyingQuestions.mp4
Implementation details
New files
types.ts—ClarifyingQuestioninterface with selection state, custom answer state, step navigation, and callbacksuseClarifyingQuestionAction.tsx— CopilotKit action registration + headlessClarifyingQuestionControllermanaging multi-step state, per-step persistence of selections/custom text, and structured message submission on completionClarifyingQuestionPanel.tsx— UI panel with radio/checkbox option rendering, custom answer row (idle + editing states), step navigation header, loading skeleton, and confirm buttonClarifyingQuestion.stories.tsx— 5 stories (Checkbox, Radio, Multi-Step, Custom Answer Radio, Custom Answer Checkbox) with a shareduseClarifyingQuestionStoryhookModified files
ChatTextarea.tsx— Integrates the clarifying question panel viaAnimatePresence, disables textarea input while clarifying is activeKey design decisions
isCustomAnswerActiveboolean controls inclusion in submissionuseReducedMotion, all focusables usefocusRing()This is an improve of what Daviz proposed on his PR: #3611