/** * The ask_clarifying_questions model: parsing tool-call args, and the reducers * over per-question UI state. Payloads and analytics live in answersUtils. * * Text-only, since the tool sets allow_images=False (ask_user_tool.py) — hence no * img handling, unlike the web helpers this mirrors. */ export const SOMETHING_ELSE_LABEL = 'Something else'; // matches the backend label const MAX_TEXT_OPTIONS = 7; const MAX_QUESTIONS = 3; export interface ClarifyingOption { label: string; description?: string; subtitle?: string; // img-only; kept for older calls, superseded by description } export interface ClarifyingQuestion { question: string; description?: string; options: ClarifyingOption[]; multi_select?: boolean; } // Shape read by the backend's _run_ask_user_tool. export interface SelectedAnswer { question_index: number; selected_label?: string; selected_labels?: string[]; custom_text?: string; } export interface QuestionState { answer: SelectedAnswer; customText: string; } export function emptyQuestionState(questionIndex: number): QuestionState { return { answer: { question_index: questionIndex }, customText: '' }; } /** * Lenient on purpose: new calls are backend-validated, but historical and * mid-stream args arrive as a JSON string, or with bare-string options. */ export function parseClarifyingQuestions(args: Record | null): ClarifyingQuestion[] { let raw: unknown = args?.questions; if (typeof raw === 'string') { try { raw = JSON.parse(raw.replace(/"(\s*)>(\s*[[{"])/g, '":$1$2')); } catch { return []; } } if (!Array.isArray(raw)) return []; return raw .map((entry): ClarifyingQuestion | null => { if (!entry || typeof entry !== 'object') return null; const q = entry as Record; if (!Array.isArray(q.options)) return null; const options = q.options .map((opt) => (typeof opt === 'string' ? { label: opt } : opt)) .filter( (opt): opt is ClarifyingOption => !!opt && typeof opt === 'object' && typeof (opt as ClarifyingOption).label === 'string', ) .slice(0, MAX_TEXT_OPTIONS); if (options.length < 2) return null; return { question: typeof q.question === 'string' ? q.question : '', ...(typeof q.description === 'string' && q.description ? { description: q.description } : {}), options, multi_select: q.multi_select === true, }; }) .filter((q): q is ClarifyingQuestion => q != null) .slice(0, MAX_QUESTIONS); } /** The UI adds its own "Something else", so drop any the model emitted. */ export function getRegularOptions(question: ClarifyingQuestion): ClarifyingOption[] { return (question.options || []).filter((opt) => opt.label.toLowerCase() !== SOMETHING_ELSE_LABEL.toLowerCase()); } export function isOptionSelected(question: ClarifyingQuestion, state: QuestionState, label: string): boolean { return question.multi_select ? (state.answer.selected_labels ?? []).includes(label) : state.answer.selected_label === label; } export function isSomethingElseSelected(question: ClarifyingQuestion, state: QuestionState): boolean { return isOptionSelected(question, state, SOMETHING_ELSE_LABEL); } /** Single-select replaces and clears the free text; multi-select toggles. */ export function selectOption(question: ClarifyingQuestion, state: QuestionState, label: string): QuestionState { const questionIndex = state.answer.question_index; if (question.multi_select) { const current = state.answer.selected_labels ?? []; const selected_labels = current.includes(label) ? current.filter((l) => l !== label) : [...current, label]; return { answer: { question_index: questionIndex, selected_labels }, customText: state.customText }; } return { answer: { question_index: questionIndex, selected_label: label }, customText: '' }; } /** Typing selects "Something else"; clearing de-selects it (web parity). */ export function changeCustomText(question: ClarifyingQuestion, state: QuestionState, text: string): QuestionState { const questionIndex = state.answer.question_index; if (question.multi_select) { const current = state.answer.selected_labels ?? []; const hasSomethingElse = current.includes(SOMETHING_ELSE_LABEL); let selected_labels = current; if (text.trim() && !hasSomethingElse) selected_labels = [...current, SOMETHING_ELSE_LABEL]; else if (!text.trim() && hasSomethingElse) selected_labels = current.filter((l) => l !== SOMETHING_ELSE_LABEL); return { answer: { question_index: questionIndex, selected_labels }, customText: text }; } const selected_label = text.trim() ? SOMETHING_ELSE_LABEL : state.answer.selected_label; return { answer: { question_index: questionIndex, ...(selected_label ? { selected_label } : {}) }, customText: text, }; } export function toggleSomethingElse(question: ClarifyingQuestion, state: QuestionState): QuestionState { const questionIndex = state.answer.question_index; if (question.multi_select) { const current = state.answer.selected_labels ?? []; const hasSomethingElse = current.includes(SOMETHING_ELSE_LABEL); const selected_labels = hasSomethingElse ? current.filter((l) => l !== SOMETHING_ELSE_LABEL) : [...current, SOMETHING_ELSE_LABEL]; return { answer: { question_index: questionIndex, selected_labels }, customText: hasSomethingElse ? '' : state.customText }; } return { answer: { question_index: questionIndex, selected_label: SOMETHING_ELSE_LABEL }, customText: state.customText }; } /** * Tapping the "Something else" row only ever selects it. Toggling would clear the * typed answer on a multi-select deselect, so a stray tap on the row can't destroy * text; clearing the field is what deselects (web CustomAnswerRow parity). */ export function pressSomethingElse(question: ClarifyingQuestion, state: QuestionState): QuestionState { return isSomethingElseSelected(question, state) ? state : toggleSomethingElse(question, state); } /** "Something else" also needs its free text before the answer counts. */ export function isQuestionComplete(question: ClarifyingQuestion | undefined, state: QuestionState | undefined): boolean { if (!question || !state) return false; if (question.multi_select) { const labels = state.answer.selected_labels ?? []; if (labels.length === 0) return false; if (labels.includes(SOMETHING_ELSE_LABEL) && !state.customText.trim()) return false; return true; } const label = state.answer.selected_label; if (label === SOMETHING_ELSE_LABEL && !state.customText.trim()) return false; return !!label; }