mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🪜 style: Step Through Batched Questions One at a Time (#14935)
A batched `ask_user_question` interrupt rendered every question stacked in one scrolling form, which reads as a wall on mobile and desktop alike. Show one question per step instead, with clickable progress dots, Back/Next, and Submit only on the last step. The batch contract is untouched: one interrupt, one answer map, Submit still gated on every question having an answer, Skip still declines the whole batch from any step. Single-question batches render exactly as before.
This commit is contained in:
parent
df294fa474
commit
f9876eaaf0
4 changed files with 431 additions and 107 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { useCallback, useEffect, useId, useMemo, useRef } from 'react';
|
||||
import { Button, TextareaAutosize } from '@librechat/client';
|
||||
import { Check, ChevronUp, TriangleAlert } from 'lucide-react';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
|
|
@ -6,6 +7,12 @@ import { splitOtherOption } from '~/utils/approval';
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* One bounded batch of `ask_user_question` items, presented a single question at
|
||||
* a time. The batch arrives as one interrupt and submits as one answer map — the
|
||||
* stepper is purely presentational, so Submit still waits until every question
|
||||
* has an answer and Skip still declines the whole batch from any step.
|
||||
*/
|
||||
export default function AskUserQuestions({
|
||||
actionId,
|
||||
questions,
|
||||
|
|
@ -18,12 +25,81 @@ export default function AskUserQuestions({
|
|||
onExpand?: () => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const promptId = useId();
|
||||
const form = useAskQuestionsForm(actionId, questions);
|
||||
const { goToStep, selectOption } = form;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const stepRef = useRef<HTMLFieldSetElement>(null);
|
||||
/** Set only when a choice click is about to unmount the button that owns
|
||||
* focus, which would otherwise drop focus to <body> mid-batch. */
|
||||
const refocusRef = useRef(false);
|
||||
|
||||
const total = questions.length;
|
||||
const stepped = total > 1;
|
||||
const activeIndex = form.step;
|
||||
const isLastStep = activeIndex === total - 1;
|
||||
/** Narrower than `locked`: an expired or errored batch is unanswerable but
|
||||
* still worth paging through, so only an in-flight submit freezes the steps. */
|
||||
const navLocked = form.status === 'submitting';
|
||||
|
||||
const firstUnanswered = useMemo(() => {
|
||||
for (let index = 0; index < questions.length; index++) {
|
||||
if (!Object.hasOwn(form.answers, questions[index].id)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [questions, form.answers]);
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
(question: Agents.AskUserQuestionBatchItem, value: string) => {
|
||||
selectOption(question, value);
|
||||
if (question.multiSelect === true || activeIndex >= total - 1) {
|
||||
return;
|
||||
}
|
||||
refocusRef.current = true;
|
||||
goToStep(activeIndex + 1);
|
||||
},
|
||||
[activeIndex, goToStep, selectOption, total],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current != null) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
if (!refocusRef.current) {
|
||||
return;
|
||||
}
|
||||
refocusRef.current = false;
|
||||
stepRef.current?.focus();
|
||||
}, [activeIndex]);
|
||||
|
||||
if (form.status === 'submitted') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = questions[activeIndex];
|
||||
if (question == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { choices, otherLabel } = splitOtherOption(question.options);
|
||||
const selected = Object.hasOwn(form.state.selected, question.id)
|
||||
? form.state.selected[question.id]
|
||||
: [];
|
||||
const text = Object.hasOwn(form.state.text, question.id) ? form.state.text[question.id] : '';
|
||||
const legend = question.header ?? (stepped ? null : localize('com_ui_question_number', { 0: 1 }));
|
||||
/** Only worth surfacing when the gap is somewhere the user cannot see: the
|
||||
* last step's own blank textarea already explains a disabled Submit. */
|
||||
const remaining = total - Object.keys(form.answers).length;
|
||||
const showRemaining =
|
||||
stepped &&
|
||||
isLastStep &&
|
||||
!form.locked &&
|
||||
firstUnanswered >= 0 &&
|
||||
firstUnanswered !== activeIndex;
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
{onExpand != null && (
|
||||
|
|
@ -38,70 +114,108 @@ export default function AskUserQuestions({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3">
|
||||
{questions.map((question, questionIndex) => {
|
||||
const { choices, otherLabel } = splitOtherOption(question.options);
|
||||
const selected = Object.hasOwn(form.state.selected, question.id)
|
||||
? form.state.selected[question.id]
|
||||
: [];
|
||||
const text = Object.hasOwn(form.state.text, question.id)
|
||||
? form.state.text[question.id]
|
||||
: '';
|
||||
return (
|
||||
<fieldset
|
||||
key={question.id}
|
||||
className={cn('py-3', questionIndex > 0 && 'border-t border-border-light')}
|
||||
>
|
||||
<legend className="mb-1 text-xs font-medium text-text-secondary">
|
||||
{question.header ?? localize('com_ui_question_number', { 0: questionIndex + 1 })}
|
||||
</legend>
|
||||
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
|
||||
{question.question}
|
||||
</p>
|
||||
{question.description != null && question.description.length > 0 && (
|
||||
<p className="mt-1 text-xs text-text-secondary [overflow-wrap:anywhere]">
|
||||
{question.description}
|
||||
</p>
|
||||
)}
|
||||
{choices.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2" role="group">
|
||||
{choices.map((option) => {
|
||||
const isSelected = selected.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isSelected ? 'submit' : 'outline'}
|
||||
role={question.multiSelect === true ? 'checkbox' : undefined}
|
||||
aria-checked={question.multiSelect === true ? isSelected : undefined}
|
||||
aria-pressed={question.multiSelect === true ? undefined : isSelected}
|
||||
disabled={form.locked}
|
||||
className="h-auto min-h-9 max-w-full whitespace-normal py-1.5 text-left [overflow-wrap:anywhere]"
|
||||
onClick={() => form.selectOption(question, option.value)}
|
||||
>
|
||||
{question.multiSelect === true && isSelected && (
|
||||
<Check className="mr-1.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<TextareaAutosize
|
||||
value={text}
|
||||
disabled={form.locked}
|
||||
onChange={(event) => form.setText(question, event.target.value)}
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
placeholder={otherLabel ?? localize('com_ui_your_answer')}
|
||||
className="mt-2 w-full resize-none rounded-md border border-border-light bg-surface-primary p-2 text-sm text-text-primary"
|
||||
aria-label={`${question.question} ${localize('com_ui_your_answer')}`}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
{stepped && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-light px-3 py-2">
|
||||
<p className="text-xs font-medium text-text-secondary" aria-live="polite">
|
||||
{localize('com_ui_question_step', { 0: activeIndex + 1, 1: total })}
|
||||
</p>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={localize('com_ui_question_navigation')}
|
||||
className="flex flex-wrap items-center justify-end"
|
||||
>
|
||||
{questions.map((item, index) => {
|
||||
const isAnswered = Object.hasOwn(form.answers, item.id);
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={navLocked}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
aria-label={localize(
|
||||
isAnswered
|
||||
? 'com_ui_question_step_answered'
|
||||
: 'com_ui_question_step_unanswered',
|
||||
{ 0: index + 1 },
|
||||
)}
|
||||
className="flex h-6 items-center justify-center px-1"
|
||||
onClick={() => goToStep(index)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 rounded-full',
|
||||
isActive ? 'w-4' : 'w-2',
|
||||
isAnswered ? 'bg-surface-submit' : 'bg-border-heavy',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-3">
|
||||
{/* The height floor sits on the step itself, not the scroll container:
|
||||
the container needs `min-h-0` to shrink inside the flex column, and
|
||||
tailwind-merge would drop it for a second `min-h-*`. */}
|
||||
<fieldset
|
||||
ref={stepRef}
|
||||
tabIndex={-1}
|
||||
aria-labelledby={promptId}
|
||||
className={cn('py-3 outline-none', stepped && 'min-h-40')}
|
||||
>
|
||||
{legend != null && (
|
||||
<legend className="mb-1 text-xs font-medium text-text-secondary">{legend}</legend>
|
||||
)}
|
||||
<p
|
||||
id={promptId}
|
||||
className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]"
|
||||
>
|
||||
{question.question}
|
||||
</p>
|
||||
{question.description != null && question.description.length > 0 && (
|
||||
<p className="mt-1 text-xs text-text-secondary [overflow-wrap:anywhere]">
|
||||
{question.description}
|
||||
</p>
|
||||
)}
|
||||
{choices.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2" role="group">
|
||||
{choices.map((option) => {
|
||||
const isSelected = selected.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isSelected ? 'submit' : 'outline'}
|
||||
role={question.multiSelect === true ? 'checkbox' : undefined}
|
||||
aria-checked={question.multiSelect === true ? isSelected : undefined}
|
||||
aria-pressed={question.multiSelect === true ? undefined : isSelected}
|
||||
disabled={form.locked}
|
||||
className="h-auto min-h-9 max-w-full whitespace-normal py-1.5 text-left [overflow-wrap:anywhere]"
|
||||
onClick={() => handleSelectOption(question, option.value)}
|
||||
>
|
||||
{question.multiSelect === true && isSelected && (
|
||||
<Check className="mr-1.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<TextareaAutosize
|
||||
value={text}
|
||||
disabled={form.locked}
|
||||
onChange={(event) => form.setText(question, event.target.value)}
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
placeholder={otherLabel ?? localize('com_ui_your_answer')}
|
||||
className="mt-2 w-full resize-none rounded-md border border-border-light bg-surface-primary p-2 text-sm text-text-primary"
|
||||
aria-label={`${question.question} ${localize('com_ui_your_answer')}`}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
{(form.status === 'error' || form.status === 'expired') && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-text-warning">
|
||||
|
|
@ -111,7 +225,24 @@ export default function AskUserQuestions({
|
|||
: localize('com_ui_ask_answer_error')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-border-light p-3">
|
||||
{showRemaining && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 px-3 py-1 text-left text-xs text-text-secondary hover:text-text-primary hover:underline"
|
||||
onClick={() => goToStep(firstUnanswered)}
|
||||
>
|
||||
{localize(
|
||||
remaining === 1 ? 'com_ui_questions_remaining_one' : 'com_ui_questions_remaining',
|
||||
{ 0: remaining },
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2 border-t border-border-light p-3',
|
||||
stepped ? 'justify-between' : 'justify-end',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -121,15 +252,42 @@ export default function AskUserQuestions({
|
|||
>
|
||||
{localize('com_ui_skip')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="submit"
|
||||
disabled={!form.canSubmit}
|
||||
onClick={form.submit}
|
||||
>
|
||||
{form.status === 'submitting' ? localize('com_ui_submitting') : localize('com_ui_submit')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{stepped && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={navLocked || activeIndex === 0}
|
||||
onClick={() => goToStep(activeIndex - 1)}
|
||||
>
|
||||
{localize('com_ui_back')}
|
||||
</Button>
|
||||
)}
|
||||
{isLastStep ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="submit"
|
||||
disabled={!form.canSubmit}
|
||||
onClick={form.submit}
|
||||
>
|
||||
{form.status === 'submitting'
|
||||
? localize('com_ui_submitting')
|
||||
: localize('com_ui_submit')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="submit"
|
||||
disabled={navLocked}
|
||||
onClick={() => goToStep(activeIndex + 1)}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import { ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
|
||||
import AskUserQuestions from '../AskUserQuestions';
|
||||
|
||||
const mockSubmitAskAnswer = jest.fn();
|
||||
let mockStatus = 'idle';
|
||||
|
||||
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
|
||||
useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }),
|
||||
useAskSubmitStatus: () => ({ getAskStatus: () => mockStatus }),
|
||||
useResumeSubmit: () => ({ submitAskAnswer: mockSubmitAskAnswer }),
|
||||
}));
|
||||
|
||||
|
|
@ -14,7 +17,15 @@ jest.mock('~/hooks', () => ({
|
|||
useLocalize: () => (key: string, values?: Record<number, number>) => {
|
||||
const labels: Record<string, string> = {
|
||||
com_ui_question_number: `Question ${values?.[0] ?? ''}`,
|
||||
com_ui_question_step: `Question ${values?.[0] ?? ''} of ${values?.[1] ?? ''}`,
|
||||
com_ui_question_step_answered: `Go to question ${values?.[0] ?? ''}, answered`,
|
||||
com_ui_question_step_unanswered: `Go to question ${values?.[0] ?? ''}, not answered`,
|
||||
com_ui_question_navigation: 'Question navigation',
|
||||
com_ui_questions_remaining_one: `${values?.[0] ?? ''} question still needs an answer`,
|
||||
com_ui_questions_remaining: `${values?.[0] ?? ''} questions still need an answer`,
|
||||
com_ui_your_answer: 'Your answer',
|
||||
com_ui_back: 'Back',
|
||||
com_ui_next: 'Next',
|
||||
com_ui_skip: 'Skip',
|
||||
com_ui_submit: 'Submit',
|
||||
com_ui_submitting: 'Submitting',
|
||||
|
|
@ -23,7 +34,7 @@ jest.mock('~/hooks', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const questions = [
|
||||
const questions: Agents.AskUserQuestionBatchItem[] = [
|
||||
{
|
||||
id: 'environment',
|
||||
header: 'Environment',
|
||||
|
|
@ -36,23 +47,78 @@ const questions = [
|
|||
{ id: 'window', question: 'Which time window?' },
|
||||
];
|
||||
|
||||
const renderBatch = (actionId: string, batch: Agents.AskUserQuestionBatchItem[] = questions) =>
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<AskUserQuestions actionId={actionId} questions={batch} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
describe('AskUserQuestions', () => {
|
||||
beforeEach(() => mockSubmitAskAnswer.mockClear());
|
||||
beforeEach(() => {
|
||||
mockStatus = 'idle';
|
||||
mockSubmitAskAnswer.mockClear();
|
||||
});
|
||||
|
||||
test('shows one question at a time and walks the batch with Next/Back', () => {
|
||||
renderBatch('ask-steps');
|
||||
|
||||
expect(screen.getByText('Where should this run?')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Which time window?')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Question 1 of 2')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Which time window?')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Where should this run?')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Question 2 of 2')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(screen.getByText('Where should this run?')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('advances automatically when a single-select choice is picked', () => {
|
||||
renderBatch('ask-advance');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Staging' }));
|
||||
|
||||
expect(screen.getByText('Which time window?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Question 2 of 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not auto-advance a multi-select question', () => {
|
||||
renderBatch('ask-multi', [
|
||||
{
|
||||
id: 'regions',
|
||||
question: 'Which regions?',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'us-east', value: 'us-east' },
|
||||
{ label: 'eu-west', value: 'eu-west' },
|
||||
],
|
||||
},
|
||||
{ id: 'window', question: 'Which time window?' },
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'us-east' }));
|
||||
|
||||
expect(screen.getByText('Which regions?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Question 1 of 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('submits one answer map after every question is complete', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<AskUserQuestions actionId="ask-batch" questions={questions} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const submit = screen.getByRole('button', { name: 'Submit' });
|
||||
expect(submit).toBeDisabled();
|
||||
renderBatch('ask-batch');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Staging' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Which time window/ }), {
|
||||
target: { value: 'Last seven days' },
|
||||
});
|
||||
|
||||
const submit = screen.getByRole('button', { name: 'Submit' });
|
||||
expect(submit).toBeEnabled();
|
||||
fireEvent.click(submit);
|
||||
|
||||
|
|
@ -66,12 +132,45 @@ describe('AskUserQuestions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('retains partial answers across surface remounts', () => {
|
||||
const view = render(
|
||||
<RecoilRoot>
|
||||
<AskUserQuestions actionId="ask-remount" questions={questions} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
test('keeps Submit gated on the last step until every question is answered', () => {
|
||||
renderBatch('ask-gated');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '2 questions still need an answer' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Which time window/ }), {
|
||||
target: { value: 'Today' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 question still needs an answer' }));
|
||||
expect(screen.getByText('Where should this run?')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Staging' }));
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('jumps to any question from the step dots and marks answered ones', () => {
|
||||
renderBatch('ask-dots');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to question 2, not answered' }));
|
||||
expect(screen.getByText('Which time window?')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Which time window/ }), {
|
||||
target: { value: 'Today' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Go to question 2, answered' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to question 1, not answered' }));
|
||||
expect(screen.getByText('Where should this run?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('retains partial answers and the current step across surface remounts', () => {
|
||||
const view = renderBatch('ask-remount');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Which time window/ }), {
|
||||
target: { value: 'Today' },
|
||||
});
|
||||
|
|
@ -82,24 +181,37 @@ describe('AskUserQuestions', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Question 2 of 2')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: /Which time window/ })).toHaveValue('Today');
|
||||
});
|
||||
|
||||
test('supports question ids inherited by ordinary objects', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<AskUserQuestions
|
||||
actionId="ask-prototype-id"
|
||||
questions={[
|
||||
{
|
||||
id: 'constructor',
|
||||
question: 'Continue?',
|
||||
options: [{ label: 'Yes', value: 'yes' }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
test('leaves a single-question batch free of stepper chrome', () => {
|
||||
renderBatch('ask-single', [
|
||||
{ id: 'confirmation', question: 'Continue?', options: [{ label: 'Yes', value: 'yes' }] },
|
||||
]);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('group', { name: 'Question navigation' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
expect(mockSubmitAskAnswer).toHaveBeenCalledWith(
|
||||
'ask-single',
|
||||
{ confirmation: 'yes' },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('supports question ids inherited by ordinary objects', () => {
|
||||
renderBatch('ask-prototype-id', [
|
||||
{
|
||||
id: 'constructor',
|
||||
question: 'Continue?',
|
||||
options: [{ label: 'Yes', value: 'yes' }],
|
||||
},
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
|
@ -110,4 +222,33 @@ describe('AskUserQuestions', () => {
|
|||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps an expired batch readable but freezes steps mid-submit', () => {
|
||||
mockStatus = 'expired';
|
||||
const view = renderBatch('ask-expired');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(screen.getByText('Which time window?')).toBeInTheDocument();
|
||||
|
||||
mockStatus = 'submitting';
|
||||
view.rerender(
|
||||
<RecoilRoot>
|
||||
<AskUserQuestions actionId="ask-expired" questions={questions} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('skips the whole batch from any step', () => {
|
||||
renderBatch('ask-skip');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Skip' }));
|
||||
|
||||
expect(mockSubmitAskAnswer).toHaveBeenCalledWith(
|
||||
'ask-skip',
|
||||
{ environment: ASK_USER_DECLINED_ANSWER, window: ASK_USER_DECLINED_ANSWER },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import {
|
|||
import { ASK_USER_DECLINED_ANSWER } from '~/utils/approval';
|
||||
|
||||
interface AskQuestionsFormState {
|
||||
/** Index of the question currently on screen. Lives beside the answers so the
|
||||
* composer popover and the chat card resume on the same step when one
|
||||
* surface hands over to the other. */
|
||||
step: number;
|
||||
text: Record<string, string>;
|
||||
selected: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const askQuestionsFormState = atomFamily<AskQuestionsFormState, string>({
|
||||
key: 'askQuestionsFormState',
|
||||
default: { text: {}, selected: {} },
|
||||
default: { step: 0, text: {}, selected: {} },
|
||||
});
|
||||
|
||||
function ownValue<T>(record: Record<string, T>, key: string): T | undefined {
|
||||
|
|
@ -35,6 +39,7 @@ export default function useAskQuestionsForm(
|
|||
const setText = useCallback(
|
||||
(question: Agents.AskUserQuestionBatchItem, value: string) => {
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
text: { ...previous.text, [question.id]: value },
|
||||
selected:
|
||||
question.multiSelect === true || value.length === 0
|
||||
|
|
@ -56,6 +61,7 @@ export default function useAskQuestionsForm(
|
|||
: [...current, value];
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
text:
|
||||
question.multiSelect === true ? previous.text : { ...previous.text, [question.id]: '' },
|
||||
selected: { ...previous.selected, [question.id]: selected },
|
||||
|
|
@ -65,6 +71,16 @@ export default function useAskQuestionsForm(
|
|||
[setState],
|
||||
);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(index: number) => {
|
||||
const bounded = Math.min(Math.max(index, 0), Math.max(questions.length - 1, 0));
|
||||
setState((previous) =>
|
||||
previous.step === bounded ? previous : { ...previous, step: bounded },
|
||||
);
|
||||
},
|
||||
[questions.length, setState],
|
||||
);
|
||||
|
||||
const answers = useMemo(() => {
|
||||
const resolved = Object.create(null) as Record<string, string>;
|
||||
for (const question of questions) {
|
||||
|
|
@ -81,7 +97,7 @@ export default function useAskQuestionsForm(
|
|||
}
|
||||
}
|
||||
return resolved;
|
||||
}, [questions, state]);
|
||||
}, [questions, state.text, state.selected]);
|
||||
|
||||
const canSubmit =
|
||||
!locked && questions.every((question) => (ownValue(answers, question.id)?.length ?? 0) > 0);
|
||||
|
|
@ -107,11 +123,14 @@ export default function useAskQuestionsForm(
|
|||
|
||||
return {
|
||||
state,
|
||||
step: Math.min(state.step, Math.max(questions.length - 1, 0)),
|
||||
status,
|
||||
locked,
|
||||
answers,
|
||||
canSubmit,
|
||||
setText,
|
||||
selectOption,
|
||||
goToStep,
|
||||
submit,
|
||||
skip,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1741,6 +1741,12 @@
|
|||
"com_ui_question_failed": "Question wasn't shown",
|
||||
"com_ui_question_failed_description": "The agent couldn't show this question and may retry automatically.",
|
||||
"com_ui_question_number": "Question {{0}}",
|
||||
"com_ui_question_step": "Question {{0}} of {{1}}",
|
||||
"com_ui_question_navigation": "Question navigation",
|
||||
"com_ui_question_step_answered": "Go to question {{0}}, answered",
|
||||
"com_ui_question_step_unanswered": "Go to question {{0}}, not answered",
|
||||
"com_ui_questions_remaining_one": "{{0}} question still needs an answer",
|
||||
"com_ui_questions_remaining": "{{0}} questions still need an answer",
|
||||
"com_ui_answer_questions_above": "Answer the questions above",
|
||||
"com_ui_asking_questions_one": "Asking {{0}} question",
|
||||
"com_ui_asking_questions": "Asking {{0}} questions",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue