From bd96c481bda38a03dc3a925ccfdf42ab0308e471 Mon Sep 17 00:00:00 2001 From: AlanAAG Date: Wed, 8 Jul 2026 22:23:36 -0600 Subject: [PATCH 1/3] feature/chat questionaire --- agent_core/core/prompts/action.py | 10 +- app/data/action/ask_user_questions.py | 140 ++++++++++ app/ui_layer/adapters/base.py | 10 +- app/ui_layer/adapters/browser_adapter.py | 172 ++++++++++++ .../frontend/src/components/Chat/Chat.tsx | 2 + .../components/ui/QuestionStepper.module.css | 245 ++++++++++++++++++ .../src/components/ui/QuestionStepper.tsx | 204 +++++++++++++++ .../frontend/src/components/ui/index.ts | 3 + .../src/contexts/WebSocketContext.tsx | 24 ++ .../frontend/src/pages/Chat/ChatMessage.tsx | 31 ++- .../src/store/slices/messagesSlice.ts | 15 ++ .../browser/frontend/src/types/index.ts | 10 + app/ui_layer/components/types.py | 28 +- app/usage/chat_storage.py | 75 +++++- 14 files changed, 954 insertions(+), 15 deletions(-) create mode 100644 app/data/action/ask_user_questions.py create mode 100644 app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css create mode 100644 app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index bc52767b..81e8c010 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -50,7 +50,7 @@ - To reply to the user, send on the platform the incoming message came from — check its source in the event stream. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. -- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Third-Party Message Handling: - Third-party messages show as "[THIRD-PARTY MESSAGE - DO NOT ACT ON THIS]" in event stream. @@ -171,7 +171,7 @@ Todo Workflow Phases (follow this order): Clarify before planning: -- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), use a send message action with wait_for_user_reply=true to ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. +- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. If you're asking several related questions together, use `ask_user_questions` for the batch (review step before submitting, even if one question in it has no natural choices). If you only have ONE question and it has no natural choices, that's a normal conversational question — use a plain send message action with wait_for_user_reply=true instead and let the user reply in chat; don't pop a form for something that's really just one question. Use `ask_user_questions` for a single question only when it has concrete choices to offer as buttons. 0. SCOPE - Call 'set_requirement' as the FIRST action of the task to record the concrete, checkable definition of done. Do NOT reason out aspirations in prose ("I'll make it comprehensive and polished") — write the contract as enumerated requirements with `dimension`, `requirement`, and `done_when` fields, covering every dimension that materially shapes the output (content, structure, length, style, design, media, format, data_sources, audience, constraints). Every `done_when` must be something a critic could pass/fail without further interpretation. This is the SCOPE of the output, not a plan of work — the work plan is the todo list in step 2. 1. Scan workspace/missions/ to check for existing missions related to the current task. 2. ACKNOWLEDGE - Send message to user confirming task receipt, you can adjust this based on the requirements @@ -205,7 +205,8 @@ - To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. -- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. +- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Adaptive Execution: - If you lack information during EXECUTE, go back to COLLECT phase (add new collect todos) @@ -406,7 +407,8 @@ - To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. -- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. +- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Action Selection: - Choose the most direct action to accomplish the goal diff --git a/app/data/action/ask_user_questions.py b/app/data/action/ask_user_questions.py new file mode 100644 index 00000000..3504f403 --- /dev/null +++ b/app/data/action/ask_user_questions.py @@ -0,0 +1,140 @@ +from agent_core import action + + +@action( + name="ask_user_questions", + irreversible=True, + description=( + "Ask the user one or more clarifying questions before continuing, when you genuinely " + "cannot proceed without more information. USE THIS when you have concrete choices to put " + "in front of the user (which connected account, which of these files, yes/no-style " + "decisions) — buttons are faster and less ambiguous than free text. Also use it to ask " + "SEVERAL related questions together as one batch (with a review step before submitting), " + "even if one of those questions in the batch doesn't have natural choices. " + "DO NOT use this for a single standalone question that has no natural choices (e.g. just " + "'what's the recipient's email?' alone) — that is a normal conversation, not a form: use a " + "send message action with wait_for_user_reply=true instead and let the user reply in the " + "chat like normal. Popping a structured question card for something that's really just one " + "plain question breaks the conversational flow and trains the user to stop paying attention " + "to these prompts. Also use send_message/wait_for_user_reply for anything that must go out " + "on an external platform (Telegram, WhatsApp, etc.), since this action only renders in the " + "local CraftBot chat. Each question can offer multiple-choice buttons; the user can also " + "always type a free-text answer or escape out of the whole batch without answering. Do NOT " + "use this for trivial details you could reasonably infer or default — only for genuine " + "ambiguity that would produce a wrong or unwanted result if you guessed. This action always " + "pauses the task until the user responds; do not pair it with task_start (it would park the " + "task before it runs). If the user escapes without answering, you will see 'The user " + "declined to answer.' in their reply — do NOT immediately re-ask the same questions; ask in " + "plain chat what they'd like you to do instead. During a proactive/scheduled run with " + "nobody connected to the interface, this action refuses with an error instead of parking " + "indefinitely — proceed with your best judgment or end the task instead." + ), + default=True, + action_sets=["core"], + parallelizable=False, + input_schema={ + "questions": { + "type": "array", + "description": ( + "Array of question objects, asked together as one batch. Each object has " + "'question' (string: the question text, REQUIRED), 'choices' (array of strings: " + "2-6 short answers; can be an EMPTY array for one question within a multi-question " + "batch that has no natural choices, but don't call this action with a single " + "question that has empty choices — that's a plain conversational question, use " + "send_message instead), and 'multi_select' (boolean, OPTIONAL, default false: true " + "if the user may pick more than one choice, shown as checkboxes; false shows " + "single-pick buttons). The user can always free-type an answer instead of picking a " + "choice, or decline, so choices don't need to be exhaustive. " + 'Example single-pick: [{"question": "Which Gmail account should I use?", "choices": ' + '["alan@work.com", "alan@personal.com"]}]\n' + 'Example multi-pick: [{"question": "Which recipients should get this?", "choices": ' + '["alan@work.com", "team@work.com", "boss@work.com"], "multi_select": true}]\n' + 'Example batch mixing a choice question with an open-ended one: ' + '[{"question": "Which account?", "choices": ["work", "personal"]}, ' + '{"question": "What should the subject be?", "choices": []}]' + ), + "example": [ + { + "question": "Which Gmail account should I use to send this?", + "choices": ["alan@work.com", "alan@personal.com"], + } + ], + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "ok", + "description": "Indicates the action completed successfully.", + }, + "fire_at_delay": { + "type": "number", + "example": 10800, + "description": "Delay in seconds before the next follow-up action should be scheduled. Always 10800 (3 hours) — this action always pauses for a reply.", + }, + }, + test_payload={ + "questions": [ + { + "question": "Which Gmail account should I use to send this?", + "choices": ["alan@work.com", "alan@personal.com"], + } + ], + "simulated_mode": True, + }, +) +async def ask_user_questions(input_data: dict) -> dict: + questions = input_data.get("questions") or [] + simulated_mode = input_data.get("simulated_mode", False) + session_id = input_data.get("_session_id") + + if not questions: + return {"status": "error", "message": "questions must be a non-empty array"} + + if not simulated_mode: + import app.internal_action_interface as internal_action_interface + + ui_adapter = internal_action_interface.InternalActionInterface.ui_adapter + ws_clients = getattr(ui_adapter, "_ws_clients", None) + if ws_clients is not None and len(ws_clients) == 0: + return { + "status": "error", + "message": ( + "Cannot ask the user right now — no one is connected to the CraftBot " + "interface (this looks like a proactive/scheduled run with nobody watching). " + "Do not wait on a reply that may not come for hours: proceed with your best " + "reasonable judgment and note the assumption you made, or end the task if you " + "truly cannot proceed without this answer." + ), + } + + from app.ui_layer.components.types import ChatMessageOption, ChatMessageQuestion + + question_objs = [ + ChatMessageQuestion( + id=f"q{i + 1}", + text=q.get("question", ""), + choices=[ + ChatMessageOption(label=c, value=c) + for c in q.get("choices", []) + ], + multi_select=bool(q.get("multi_select", False)), + ) + for i, q in enumerate(questions) + ] + content = "\n".join(f"{i + 1}. {q.text}" for i, q in enumerate(question_objs)) + + if ui_adapter is not None: + from app.onboarding import onboarding_manager + + agent_name = onboarding_manager.state.agent_name or "Agent" + await ui_adapter._display_chat_message( + agent_name, + content, + "agent", + task_session_id=session_id, + questions=question_objs, + ) + + status = "success" if simulated_mode else "ok" + return {"status": status, "fire_at_delay": 10800} diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 6e3ae69f..8c5857a5 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -15,7 +15,12 @@ InputComponentProtocol, FootageComponentProtocol, ) -from app.ui_layer.components.types import ChatMessage, ChatMessageOption, ActionItem +from app.ui_layer.components.types import ( + ChatMessage, + ChatMessageOption, + ChatMessageQuestion, + ActionItem, +) if TYPE_CHECKING: from app.ui_layer.controller.ui_controller import UIController @@ -547,6 +552,7 @@ async def _display_chat_message( style: str, task_session_id: Optional[str] = None, options: Optional[List[ChatMessageOption]] = None, + questions: Optional[List[ChatMessageQuestion]] = None, client_id: Optional[str] = None, ) -> None: """ @@ -558,6 +564,7 @@ async def _display_chat_message( style: Style identifier task_session_id: Optional task session ID for reply feature options: Optional list of interactive options/buttons + questions: Optional batch of clarifying questions to ask the user client_id: Optional client-generated UUID for reconciling with optimistic UI """ import time @@ -570,6 +577,7 @@ async def _display_chat_message( timestamp=time.time(), task_session_id=task_session_id, options=options, + questions=questions, client_id=client_id, ) ) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index c1868125..db10eeb3 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -232,6 +232,27 @@ def _init_storage(self) -> None: ) for o in stored.options ] + questions = None + if stored.questions: + from app.ui_layer.components.types import ( + ChatMessageOption, + ChatMessageQuestion, + ) + + questions = [ + ChatMessageQuestion( + id=q.get("id", ""), + text=q.get("text", ""), + choices=[ + ChatMessageOption( + label=c.get("label", ""), value=c.get("value", "") + ) + for c in q.get("choices", []) + ], + multi_select=q.get("multiSelect", False), + ) + for q in stored.questions + ] self._messages.append( ChatMessage( sender=stored.sender, @@ -243,6 +264,9 @@ def _init_storage(self) -> None: task_session_id=stored.task_session_id, options=options, option_selected=stored.option_selected, + questions=questions, + question_answers=stored.question_answers, + questions_declined=stored.questions_declined, ) ) except Exception: @@ -276,6 +300,20 @@ async def append_message(self, message: ChatMessage) -> None: {"label": o.label, "value": o.value, "style": o.style} for o in message.options ] + questions_data = None + if message.questions: + questions_data = [ + { + "id": q.id, + "text": q.text, + "choices": [ + {"label": c.label, "value": c.value} + for c in q.choices + ], + "multiSelect": q.multi_select, + } + for q in message.questions + ] stored = StoredChatMessage( message_id=message.message_id or f"{message.sender}:{message.timestamp}", @@ -286,6 +324,9 @@ async def append_message(self, message: ChatMessage) -> None: attachments=attachments_data, task_session_id=message.task_session_id, options=options_data, + questions=questions_data, + question_answers=message.question_answers, + questions_declined=message.questions_declined, ) self._storage.insert_message(stored) except Exception: @@ -330,6 +371,24 @@ async def append_message(self, message: ChatMessage) -> None: if message.option_selected: message_data["optionSelected"] = message.option_selected + # Include clarifying-question batch if present + if message.questions: + message_data["questions"] = [ + { + "id": q.id, + "text": q.text, + "choices": [ + {"label": c.label, "value": c.value} for c in q.choices + ], + "multiSelect": q.multi_select, + } + for q in message.questions + ] + if message.question_answers: + message_data["questionAnswers"] = message.question_answers + if message.questions_declined: + message_data["questionsDeclined"] = message.questions_declined + await self._adapter._broadcast( { "type": "chat_message", @@ -396,6 +455,27 @@ def get_messages_before( ) for o in s.options ] + questions = None + if s.questions: + from app.ui_layer.components.types import ( + ChatMessageOption, + ChatMessageQuestion, + ) + + questions = [ + ChatMessageQuestion( + id=q.get("id", ""), + text=q.get("text", ""), + choices=[ + ChatMessageOption( + label=c.get("label", ""), value=c.get("value", "") + ) + for c in q.get("choices", []) + ], + multi_select=q.get("multiSelect", False), + ) + for q in s.questions + ] messages.append( ChatMessage( sender=s.sender, @@ -404,8 +484,12 @@ def get_messages_before( timestamp=s.timestamp, message_id=s.message_id, attachments=attachments, + task_session_id=s.task_session_id, options=options, option_selected=s.option_selected, + questions=questions, + question_answers=s.question_answers, + questions_declined=s.questions_declined, ) ) return messages @@ -1643,6 +1727,15 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: message_id = data.get("messageId", "") await self._handle_option_click(value, session_id, message_id) + elif msg_type == "question_answers_submit": + session_id = data.get("sessionId", "") + message_id = data.get("messageId", "") + answers = data.get("answers") + declined = bool(data.get("declined", False)) + await self._handle_question_answers_submit( + session_id, message_id, answers, declined + ) + # Settings operations elif msg_type == "settings_get": await self._handle_settings_get() @@ -4059,6 +4152,57 @@ async def _handle_option_click( f"[OPTION_CLICK] Error handling option click: {e}", exc_info=True ) + async def _handle_question_answers_submit( + self, + session_id: str, + message_id: str, + answers: Optional[Dict[str, str]], + declined: bool, + ) -> None: + """Handle the user resolving a batch of agent-asked clarifying questions. + + Persists the resolution, then resumes the parked task the same way a + normal reply to a waiting task does (see ``submit_message``'s + ``reply_context`` handling) — no separate resume path needed. + """ + try: + question_text_by_id: Dict[str, str] = {} + if self._chat and message_id: + for m in self._chat._messages: + if m.message_id == message_id: + if m.questions: + question_text_by_id = {q.id: q.text for q in m.questions} + m.question_answers = answers if not declined else None + m.questions_declined = declined + break + if self._chat._storage: + try: + self._chat._storage.update_question_answers( + message_id, answers if not declined else None, declined + ) + except Exception: + pass + + if declined: + resume_text = "The user declined to answer." + elif answers: + lines = [ + f"{i + 1}. {question_text_by_id.get(qid, qid)} → {answer}" + for i, (qid, answer) in enumerate(answers.items()) + ] + resume_text = "Answered clarifying questions:\n" + "\n".join(lines) + else: + resume_text = "The user declined to answer." + + await self.submit_message( + resume_text, reply_context={"sessionId": session_id} + ) + except Exception as e: + logger.error( + f"[QUESTION_ANSWERS] Error handling question answers: {e}", + exc_info=True, + ) + # ───────────────────────────────────────────────────────────────────── # Settings Operation Handlers # ───────────────────────────────────────────────────────────────────── @@ -8817,6 +8961,34 @@ def _get_initial_state(self) -> Dict[str, Any]: if m.option_selected else {} ), + **( + { + "questions": [ + { + "id": q.id, + "text": q.text, + "choices": [ + {"label": c.label, "value": c.value} + for c in q.choices + ], + "multiSelect": q.multi_select, + } + for q in m.questions + ] + } + if m.questions + else {} + ), + **( + {"questionAnswers": m.question_answers} + if m.question_answers + else {} + ), + **( + {"questionsDeclined": m.questions_declined} + if m.questions_declined + else {} + ), } for m in self._chat.get_messages() ], diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index 10dfcb3d..361b53f0 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -104,6 +104,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { sendMessage, sendCommand, sendOptionClick, + sendQuestionAnswers, openFile, openFolder, lastSeenMessageId, @@ -726,6 +727,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { onOpenFolder={openFolder} onReply={handleChatReply} onOptionClick={sendOptionClick} + onQuestionAnswers={sendQuestionAnswers} /> ) diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css new file mode 100644 index 00000000..444116bc --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css @@ -0,0 +1,245 @@ +.stepper { + display: flex; + flex-direction: column; + margin-top: var(--space-2); + border-top: 1px solid var(--border-primary); + padding-top: var(--space-2); + gap: var(--space-2); +} + +.nav { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.backButton { + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; +} + +.backButton:hover { + color: var(--text-primary); + background: var(--bg-primary); +} + +.navDot { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 0; +} + +.navDot:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.navDotActive { + border-color: var(--text-primary); + color: var(--text-primary); + font-weight: var(--font-semibold); +} + +.navDotDone { + background: var(--bg-primary); +} + +.navReview { + font-size: var(--text-xs); + color: var(--text-secondary); + margin-left: var(--space-1); +} + +.question { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.questionText { + font-size: var(--text-sm); + color: var(--text-primary); + padding-bottom: var(--space-1); +} + +.choiceButton { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.choiceButton:hover { + background: var(--bg-primary); +} + +.choiceIndex { + font-weight: var(--font-semibold); + color: var(--text-secondary); +} + +.checkboxRow { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + border-radius: var(--radius-sm); +} + +.checkboxRow:hover { + background: var(--bg-primary); +} + +.checkbox { + width: 16px; + height: 16px; + accent-color: var(--text-primary); + cursor: pointer; +} + +.otherRow { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.otherInput { + flex: 1; + padding: var(--space-1) 0; + border: none; + background: transparent; + color: var(--text-primary); + font-size: var(--text-sm); + font-family: inherit; +} + +.otherInput:focus { + outline: none; +} + +.otherSend { + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-medium); + cursor: pointer; + align-self: center; +} + +.otherSend:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--text-primary); +} + +.otherSend:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.review { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.reviewRow { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + text-align: left; +} + +.reviewRow:hover { + background: var(--bg-primary); +} + +.submitButton { + margin-top: var(--space-1); + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: var(--text-primary); + color: var(--bg-primary); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + cursor: pointer; +} + +.submitButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.resolved { + display: flex; + flex-direction: column; + margin-top: var(--space-2); + border-top: 1px solid var(--border-primary); + padding-top: var(--space-2); + gap: 2px; +} + +.resolvedRow { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-sm); + padding: var(--space-1) 0; +} + +.resolvedQuestion { + color: var(--text-secondary); + font-size: var(--text-xs); +} + +.resolvedAnswer { + color: var(--text-primary); +} + +.declinedNote { + color: var(--text-secondary); + font-size: var(--text-sm); + font-style: italic; +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx new file mode 100644 index 00000000..fae225fc --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react' +import type { ChatMessageQuestion } from '../../types' +import styles from './QuestionStepper.module.css' + +export interface QuestionStepperProps { + messageId: string + sessionId?: string + questions: ChatMessageQuestion[] + answers?: Record + declined?: boolean + onSubmit: (messageId: string, sessionId: string | undefined, answers: Record | undefined, declined: boolean) => void +} + +export function QuestionStepper({ messageId, sessionId, questions, answers, declined, onSubmit }: QuestionStepperProps) { + const resolved = !!answers || !!declined + const [step, setStep] = useState(0) + const [localAnswers, setLocalAnswers] = useState>({}) + const [otherText, setOtherText] = useState('') + const [checked, setChecked] = useState>(new Set()) + + // Esc abandons the whole batch — only while it's still unanswered. + useEffect(() => { + if (resolved) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onSubmit(messageId, sessionId, undefined, true) + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [resolved, messageId, sessionId, onSubmit]) + + if (resolved) { + return ( +
+ {declined ? ( + Declined to answer. + ) : ( + questions.map(q => ( +
+ {q.text} + {answers?.[q.id]} +
+ )) + )} +
+ ) + } + + const single = questions.length === 1 + const isReview = !single && step === questions.length + const current = questions[step] + + const goToStep = (i: number) => { + setStep(i) + setOtherText('') + setChecked(new Set()) + } + + const answerCurrent = (value: string) => { + const next = { ...localAnswers, [current.id]: value } + setLocalAnswers(next) + setOtherText('') + setChecked(new Set()) + if (single) { + onSubmit(messageId, sessionId, next, false) + } else { + setStep(step + 1) + } + } + + const toggleChecked = (value: string) => { + const next = new Set(checked) + if (next.has(value)) next.delete(value) + else next.add(value) + setChecked(next) + } + + const confirmMultiSelect = () => { + const values = [...checked, ...(otherText.trim() ? [otherText.trim()] : [])] + if (values.length === 0) return + answerCurrent(values.join(', ')) + } + + const submitOther = () => { + if (!otherText.trim()) return + answerCurrent(otherText.trim()) + } + + return ( +
+ {!single && ( +
+ {step > 0 && ( + + )} + {questions.map((q, i) => ( + + ))} + {isReview ? 'Review' : `${step + 1} of ${questions.length}`} +
+ )} + + {isReview ? ( +
+ {questions.map(q => ( + + ))} + +
+ ) : ( +
+ {!single && {current.text}} + + {current.multiSelect ? ( + <> + {current.choices.map(c => ( + + ))} +
+ setOtherText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmMultiSelect() }} + placeholder="Other (optional)..." + /> +
+ + + ) : ( + <> + {current.choices.map((c, i) => ( + + ))} +
+ setOtherText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') submitOther() }} + placeholder={current.choices.length > 0 ? 'Or type your own answer...' : 'Type your answer...'} + /> + +
+ + )} +
+ )} +
+ ) +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/index.ts b/app/ui_layer/browser/frontend/src/components/ui/index.ts index f0193089..c5f67a71 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/index.ts +++ b/app/ui_layer/browser/frontend/src/components/ui/index.ts @@ -14,6 +14,9 @@ export { MarkdownContent } from './MarkdownContent' export { AttachmentDisplay } from './AttachmentDisplay' +export { QuestionStepper } from './QuestionStepper' +export type { QuestionStepperProps } from './QuestionStepper' + export { AttachmentPreviewModal } from './AttachmentPreviewModal' export type { AttachmentPreviewModalProps, AttachmentPreviewItem } from './AttachmentPreviewModal' diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 28cc09d4..84bb9de4 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -19,6 +19,7 @@ import { addOptimistic as messagesAddOptimistic, setLoadingOlder as messagesSetLoadingOlder, markOptionSelected as messagesMarkOptionSelected, + markQuestionsAnswered as messagesMarkQuestionsAnswered, clear as messagesClear, } from '../store/slices/messagesSlice' import { @@ -219,6 +220,13 @@ interface WebSocketContextType extends WebSocketState { pullOllamaModel: (model: string) => void // Option click (interactive buttons in chat) sendOptionClick: (value: string, sessionId?: string, messageId?: string) => void + // Clarifying-question batch answers (agent-asked Q&A stepper in chat) + sendQuestionAnswers: ( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => void // Agent profile picture uploadAgentProfilePicture: (name: string, mimeType: string, contentBase64: string) => void removeAgentProfilePicture: () => void @@ -498,6 +506,21 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + const sendQuestionAnswers = useCallback(( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => { + // Optimistically resolve the stepper locally, same reasoning as sendOptionClick. + dispatch(messagesMarkQuestionsAnswered({ messageId, answers, declined })) + if (client.isConnected) { + client.sendString(JSON.stringify({ + type: 'question_answers_submit', messageId, sessionId, answers, declined, + })) + } + }, []) + const uploadAgentProfilePicture = useCallback( (name: string, mimeType: string, contentBase64: string) => { if (client.isConnected) { @@ -771,6 +794,7 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { enhancePrompt, clearEnhancedPrompt, sendOptionClick, + sendQuestionAnswers, uploadAgentProfilePicture, removeAgentProfilePicture, // Living UI methods diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx index 8d3b79f9..e7a96ba0 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -1,6 +1,6 @@ import React, { memo, useState, useMemo, useRef, useEffect } from 'react' import { Reply, Copy, Check } from 'lucide-react' -import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton } from '../../components/ui' +import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton, QuestionStepper } from '../../components/ui' import type { Attachment, ChatMessage as ChatMessageType } from '../../types' import { useWebSocket } from '../../contexts/WebSocketContext' import styles from './ChatPage.module.css' @@ -15,6 +15,12 @@ interface ChatMessageProps { fullContent: string ) => void onOptionClick?: (value: string, sessionId?: string, messageId?: string) => void + onQuestionAnswers?: ( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => void } // Parse reply context from message content @@ -36,6 +42,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ onOpenFolder, onReply, onOptionClick, + onQuestionAnswers, }: ChatMessageProps) { const [isHovered, setIsHovered] = useState(false) const [copied, setCopied] = useState(false) @@ -51,10 +58,14 @@ export const ChatMessageItem = memo(function ChatMessageItem({ }, [selected]) const { agentProfilePictureUrl } = useWebSocket() - // Show reply for agent messages, except those presenting options that - // require the user to make an explicit choice via the option buttons. + // Show reply for agent messages, except those presenting options/questions + // that require the user to make an explicit choice via the buttons. const hasPendingOptions = !!(message.options && message.options.length > 0) - const canReply = message.style === 'agent' && onReply && !hasPendingOptions + const hasPendingQuestions = !!( + message.questions && message.questions.length > 0 + && !message.questionAnswers && !message.questionsDeclined + ) + const canReply = message.style === 'agent' && onReply && !hasPendingOptions && !hasPendingQuestions const canCopy = message.style === 'user' || message.style === 'agent' // Parse reply context for user messages @@ -126,6 +137,16 @@ export const ChatMessageItem = memo(function ChatMessageItem({ ))} )} + {message.questions && message.questions.length > 0 && ( + onQuestionAnswers?.(msgId, sid, ans, dec)} + /> + )} {message.attachments && message.attachments.length > 0 && (
@@ -192,5 +213,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ }, (prev, next) => prev.message.messageId === next.message.messageId && prev.message.optionSelected === next.message.optionSelected + && prev.message.questionAnswers === next.message.questionAnswers + && prev.message.questionsDeclined === next.message.questionsDeclined && prev.message.content === next.message.content ) diff --git a/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts b/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts index c2e23a3f..ce48a056 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts @@ -68,6 +68,20 @@ const messagesSlice = createSlice({ entry.optionSelected = value } }, + markQuestionsAnswered( + state, + action: PayloadAction<{ messageId: string; answers?: Record; declined?: boolean }> + ) { + const { messageId, answers, declined } = action.payload + const entry = state.entities[messageId] + if (entry && !entry.questionAnswers && !entry.questionsDeclined) { + if (declined) { + entry.questionsDeclined = true + } else { + entry.questionAnswers = answers + } + } + }, }, }) @@ -79,6 +93,7 @@ export const { clear, setLoadingOlder, markOptionSelected, + markQuestionsAnswered, } = messagesSlice.actions export const messagesAdapter = adapter diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index a5f21f3e..c41d6e96 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -18,6 +18,13 @@ export interface ChatMessageOption { style?: 'primary' | 'danger' | 'default' } +export interface ChatMessageQuestion { + id: string + text: string + choices: ChatMessageOption[] + multiSelect?: boolean +} + export interface ChatMessage { sender: string content: string @@ -28,6 +35,9 @@ export interface ChatMessage { taskSessionId?: string // Links message to a task session for reply feature options?: ChatMessageOption[] optionSelected?: string // Value of the option that was selected + questions?: ChatMessageQuestion[] // Batch of clarifying questions the agent is waiting on + questionAnswers?: Record // Answer text per question id, once submitted + questionsDeclined?: boolean // True if the user escaped the batch without answering clientId?: string // Client-generated UUID for reconciling optimistic pending messages with server echo pending?: boolean // True while an optimistic message is awaiting server acknowledgment } diff --git a/app/ui_layer/components/types.py b/app/ui_layer/components/types.py index 85fa9c9b..67ba30ce 100644 --- a/app/ui_layer/components/types.py +++ b/app/ui_layer/components/types.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, List +from typing import Optional, List, Dict import time @@ -45,6 +45,26 @@ class ChatMessageOption: style: str = "default" +@dataclass +class ChatMessageQuestion: + """ + Data structure for one clarifying question in a batch asked by the agent. + + Attributes: + id: Stable identifier for this question within the batch (e.g. "q1") + text: The question text shown to the user + choices: Multiple-choice options; the frontend always adds an + implicit free-text "Other" option after these + multi_select: If True, the user can pick more than one choice + (checkboxes); if False, picking one answers the question (radio) + """ + + id: str + text: str + choices: List[ChatMessageOption] = field(default_factory=list) + multi_select: bool = False + + @dataclass class ChatMessage: """ @@ -62,6 +82,9 @@ class ChatMessage: task_session_id: Optional task session ID for reply feature options: Optional list of interactive options/buttons option_selected: Value of the option that was selected, if any + questions: Optional batch of clarifying questions the agent is waiting on + question_answers: Answer text per question id, once submitted + questions_declined: True if the user escaped out of the question batch """ sender: str @@ -73,6 +96,9 @@ class ChatMessage: task_session_id: Optional[str] = None options: Optional[List[ChatMessageOption]] = None option_selected: Optional[str] = None + questions: Optional[List[ChatMessageQuestion]] = None + question_answers: Optional[Dict[str, str]] = None + questions_declined: Optional[bool] = None # Client-generated UUID from the sender; echoed back so the browser can # reconcile optimistic-pending messages with the server-acknowledged copy. client_id: Optional[str] = None diff --git a/app/usage/chat_storage.py b/app/usage/chat_storage.py index 9ec4ef84..f926865c 100644 --- a/app/usage/chat_storage.py +++ b/app/usage/chat_storage.py @@ -35,6 +35,9 @@ class StoredChatMessage: task_session_id: Optional[str] = None options: Optional[List[Dict[str, Any]]] = None option_selected: Optional[str] = None + questions: Optional[List[Dict[str, Any]]] = None + question_answers: Optional[Dict[str, str]] = None + questions_declined: Optional[bool] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" @@ -53,6 +56,12 @@ def to_dict(self) -> Dict[str, Any]: result["options"] = self.options if self.option_selected: result["optionSelected"] = self.option_selected + if self.questions: + result["questions"] = self.questions + if self.question_answers: + result["questionAnswers"] = self.question_answers + if self.questions_declined: + result["questionsDeclined"] = self.questions_declined return result @@ -131,6 +140,24 @@ def _init_db(self) -> None: ADD COLUMN option_selected TEXT """) logger.info("[ChatStorage] Migrated: added option_selected column") + if "questions" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN questions TEXT + """) + logger.info("[ChatStorage] Migrated: added questions column") + if "question_answers" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN question_answers TEXT + """) + logger.info("[ChatStorage] Migrated: added question_answers column") + if "questions_declined" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN questions_declined INTEGER + """) + logger.info("[ChatStorage] Migrated: added questions_declined column") conn.commit() @@ -149,8 +176,8 @@ def insert_message(self, message: StoredChatMessage) -> int: cursor.execute( """ INSERT OR REPLACE INTO chat_messages - (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( message.message_id, @@ -162,6 +189,9 @@ def insert_message(self, message: StoredChatMessage) -> int: message.task_session_id, json.dumps(message.options) if message.options else None, message.option_selected, + json.dumps(message.questions) if message.questions else None, + json.dumps(message.question_answers) if message.question_answers else None, + message.questions_declined, ), ) conn.commit() @@ -186,7 +216,7 @@ def get_messages( cursor = conn.cursor() cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages ORDER BY timestamp ASC LIMIT ? OFFSET ? @@ -206,6 +236,9 @@ def get_messages( task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] @@ -225,7 +258,7 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: # Get last N messages ordered by timestamp DESC, then reverse cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages ORDER BY timestamp DESC LIMIT ? @@ -245,6 +278,9 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] @@ -287,6 +323,32 @@ def update_option_selected(self, message_id: str, option_value: str) -> bool: conn.commit() return cursor.rowcount > 0 + def update_question_answers( + self, + message_id: str, + answers: Optional[Dict[str, str]], + declined: bool, + ) -> bool: + """ + Record the resolution of a clarifying-question batch on a message. + + Args: + message_id: The message ID to update. + answers: Answer text per question id, or None if declined. + declined: True if the user escaped out of the batch instead of answering. + + Returns: + True if the message was updated, False if not found. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE chat_messages SET question_answers = ?, questions_declined = ? WHERE message_id = ?", + (json.dumps(answers) if answers else None, declined, message_id), + ) + conn.commit() + return cursor.rowcount > 0 + def delete_message(self, message_id: str) -> bool: """ Delete a message by ID. @@ -324,7 +386,7 @@ def get_messages_before( cursor = conn.cursor() cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages WHERE timestamp < ? ORDER BY timestamp DESC @@ -345,6 +407,9 @@ def get_messages_before( task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] From 01a2009a9584b84688d66635946d42682a078c67 Mon Sep 17 00:00:00 2001 From: AlanAAG Date: Thu, 9 Jul 2026 00:48:53 -0600 Subject: [PATCH 2/3] bug fixes and corrections --- app/data/action/ask_user_questions.py | 7 ++- app/ui_layer/adapters/browser_adapter.py | 17 +++++++ .../src/components/ui/QuestionStepper.tsx | 47 ++++++++++++------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/data/action/ask_user_questions.py b/app/data/action/ask_user_questions.py index 3504f403..cd563143 100644 --- a/app/data/action/ask_user_questions.py +++ b/app/data/action/ask_user_questions.py @@ -72,6 +72,11 @@ "example": 10800, "description": "Delay in seconds before the next follow-up action should be scheduled. Always 10800 (3 hours) — this action always pauses for a reply.", }, + "wait_for_user_reply": { + "type": "boolean", + "example": True, + "description": "Always true — tells the agent loop to mark the task as waiting and hold off the next trigger until the user replies.", + }, }, test_payload={ "questions": [ @@ -137,4 +142,4 @@ async def ask_user_questions(input_data: dict) -> dict: ) status = "success" if simulated_mode else "ok" - return {"status": status, "fire_at_delay": 10800} + return {"status": status, "fire_at_delay": 10800, "wait_for_user_reply": True} diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index db10eeb3..c5d24d46 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -8252,6 +8252,23 @@ async def _handle_chat_history( ] if m.option_selected: msg_data["optionSelected"] = m.option_selected + if m.questions: + msg_data["questions"] = [ + { + "id": q.id, + "text": q.text, + "choices": [ + {"label": c.label, "value": c.value} + for c in q.choices + ], + "multiSelect": q.multi_select, + } + for q in m.questions + ] + if m.question_answers: + msg_data["questionAnswers"] = m.question_answers + if m.questions_declined: + msg_data["questionsDeclined"] = m.questions_declined messages_data.append(msg_data) await self._broadcast( diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx index fae225fc..ee1c44c9 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState, type KeyboardEvent } from 'react' import type { ChatMessageQuestion } from '../../types' import styles from './QuestionStepper.module.css' @@ -18,15 +18,12 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl const [otherText, setOtherText] = useState('') const [checked, setChecked] = useState>(new Set()) - // Esc abandons the whole batch — only while it's still unanswered. - useEffect(() => { - if (resolved) return - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onSubmit(messageId, sessionId, undefined, true) - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [resolved, messageId, sessionId, onSubmit]) + // Esc abandons the whole batch — scoped to this stepper via bubbling from + // its own focused controls, not a window-wide listener (which would decline + // whichever batch happened to be mounted regardless of what's focused). + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onSubmit(messageId, sessionId, undefined, true) + } if (resolved) { return ( @@ -49,21 +46,39 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl const isReview = !single && step === questions.length const current = questions[step] + // Navigating to a question rehydrates its previous answer (if any) instead + // of blindly clearing it — otherwise revisiting an already-answered + // question shows it as unanswered, and re-confirming would silently + // overwrite the earlier answer with an empty/partial one. const goToStep = (i: number) => { setStep(i) - setOtherText('') - setChecked(new Set()) + const q = questions[i] + if (!q) return // i === questions.length: moving into the review step, nothing to rehydrate + const existing = localAnswers[q.id] + if (existing === undefined) { + setOtherText('') + setChecked(new Set()) + return + } + if (q.multiSelect) { + const knownValues = new Set(q.choices.map(c => c.value)) + const parts = existing.split(', ') + setChecked(new Set(parts.filter(p => knownValues.has(p)))) + setOtherText(parts.filter(p => !knownValues.has(p)).join(', ')) + } else { + const isKnownChoice = q.choices.some(c => c.value === existing) + setChecked(new Set()) + setOtherText(isKnownChoice ? '' : existing) + } } const answerCurrent = (value: string) => { const next = { ...localAnswers, [current.id]: value } setLocalAnswers(next) - setOtherText('') - setChecked(new Set()) if (single) { onSubmit(messageId, sessionId, next, false) } else { - setStep(step + 1) + goToStep(step + 1) } } @@ -86,7 +101,7 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl } return ( -
+
{!single && (
{step > 0 && ( From 07ddba4476245d35981d4aa88016b5b16a93a0c4 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Thu, 9 Jul 2026 14:17:34 +0500 Subject: [PATCH 3/3] fix: harden clarifying-questions flow per PR #377 review - Error instead of parking 3h when no UI can answer (CLI mode / no clients), via new InterfaceAdapter.can_prompt_user() - Drop duplicate submits from stale tabs (atomic first-submission-wins in storage) and broadcast resolution to all clients - QuestionStepper: structured per-question drafts (comma values no longer corrupt on back-nav), selected-choice highlight, Dismiss button, Esc in free-text clears instead of declining - Resume text falls back to storage after restart (no more raw "q1" ids) - Dedupe question serializers into ChatMessageQuestion to_dict/from_dict - Extract WAIT_FOR_REPLY_PARK_DELAY_SECONDS (imported inside action bodies - executor exec()s function source only) --- app/agent_base.py | 7 +- app/data/action/ask_user_questions.py | 41 ++-- app/data/action/constants.py | 6 + app/data/action/send_message.py | 3 +- .../action/send_message_with_attachment.py | 6 +- app/ui_layer/adapters/base.py | 11 ++ app/ui_layer/adapters/browser_adapter.py | 186 ++++++++---------- .../components/ui/QuestionStepper.module.css | 22 +++ .../src/components/ui/QuestionStepper.tsx | 179 ++++++++++------- .../src/store/slices/messagesSlice.ts | 9 + app/ui_layer/components/types.py | 22 +++ app/usage/chat_storage.py | 50 ++++- 12 files changed, 346 insertions(+), 196 deletions(-) create mode 100644 app/data/action/constants.py diff --git a/app/agent_base.py b/app/agent_base.py index d701b44e..26215bc9 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -64,6 +64,7 @@ initialize_manager, ) +from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS from app.internal_action_interface import InternalActionInterface from app.llm import LLMInterface @@ -552,9 +553,9 @@ async def react(self, trigger: Trigger) -> None: await self._create_new_trigger( session_id, { - "fire_at_delay": 10800, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS, "wait_for_user_reply": True, - }, # 3 hours + }, STATE, ) return @@ -1776,7 +1777,7 @@ async def _pause_task_for_limit_choice(self, session_id: str) -> None: TriggerSpec( source=TriggerSource.LIMIT_REACHED, description="Waiting for user decision on limit reached", - fire_at=time.time() + 10800, + fire_at=time.time() + WAIT_FOR_REPLY_PARK_DELAY_SECONDS, priority=5, session_id=session_id, payload={"gui_mode": STATE.gui_mode}, diff --git a/app/data/action/ask_user_questions.py b/app/data/action/ask_user_questions.py index cd563143..19912e09 100644 --- a/app/data/action/ask_user_questions.py +++ b/app/data/action/ask_user_questions.py @@ -89,6 +89,8 @@ }, ) async def ask_user_questions(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS + questions = input_data.get("questions") or [] simulated_mode = input_data.get("simulated_mode", False) session_id = input_data.get("_session_id") @@ -100,8 +102,18 @@ async def ask_user_questions(input_data: dict) -> dict: import app.internal_action_interface as internal_action_interface ui_adapter = internal_action_interface.InternalActionInterface.ui_adapter - ws_clients = getattr(ui_adapter, "_ws_clients", None) - if ws_clients is not None and len(ws_clients) == 0: + if ui_adapter is None: + return { + "status": "error", + "message": ( + "Cannot ask the user interactively — no CraftBot browser interface " + "is attached (CLI/headless mode). Ask conversationally with a send " + "message action (wait_for_user_reply=true) instead." + ), + } + # Fail closed: only park the task when the adapter affirms a user is + # present to answer (browser adapter: at least one connected client). + if not ui_adapter.can_prompt_user(): return { "status": "error", "message": ( @@ -129,17 +141,20 @@ async def ask_user_questions(input_data: dict) -> dict: ] content = "\n".join(f"{i + 1}. {q.text}" for i, q in enumerate(question_objs)) - if ui_adapter is not None: - from app.onboarding import onboarding_manager + from app.onboarding import onboarding_manager - agent_name = onboarding_manager.state.agent_name or "Agent" - await ui_adapter._display_chat_message( - agent_name, - content, - "agent", - task_session_id=session_id, - questions=question_objs, - ) + agent_name = onboarding_manager.state.agent_name or "Agent" + await ui_adapter._display_chat_message( + agent_name, + content, + "agent", + task_session_id=session_id, + questions=question_objs, + ) status = "success" if simulated_mode else "ok" - return {"status": status, "fire_at_delay": 10800, "wait_for_user_reply": True} + return { + "status": status, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS, + "wait_for_user_reply": True, + } diff --git a/app/data/action/constants.py b/app/data/action/constants.py new file mode 100644 index 00000000..019aecb0 --- /dev/null +++ b/app/data/action/constants.py @@ -0,0 +1,6 @@ +"""Shared constants for agent actions and the agent loop.""" + +# How long a task parks while waiting on a user reply (3 hours). Used by every +# action that returns wait_for_user_reply and by agent_base's wait re-schedule +# paths — keep them in sync by importing this rather than inlining the number. +WAIT_FOR_REPLY_PARK_DELAY_SECONDS = 10800 diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index f486cc60..f96aef3b 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -39,6 +39,7 @@ }, ) async def send_message(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS message = input_data["message"] wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False)) @@ -65,7 +66,7 @@ async def send_message(input_data: dict) -> dict: except Exception: pass - fire_at_delay = 10800 if wait_for_user_reply else 0 + fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0 # Return 'success' for test compatibility, but keep 'ok' in production if needed status = "success" if simulated_mode else "ok" return { diff --git a/app/data/action/send_message_with_attachment.py b/app/data/action/send_message_with_attachment.py index 1546252d..fe28094f 100644 --- a/app/data/action/send_message_with_attachment.py +++ b/app/data/action/send_message_with_attachment.py @@ -59,6 +59,8 @@ }, ) async def send_message_with_attachment(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS + message = input_data["message"] file_paths = input_data.get("file_paths", []) wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False)) @@ -93,7 +95,7 @@ async def send_message_with_attachment(input_data: dict) -> dict: if simulated_mode: return { "status": "success", - "fire_at_delay": 10800 if wait_for_user_reply else 0, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0, "wait_for_user_reply": wait_for_user_reply, "files_sent": len(file_paths), } @@ -105,7 +107,7 @@ async def send_message_with_attachment(input_data: dict) -> dict: message, file_paths, session_id=session_id ) - fire_at_delay = 10800 if wait_for_user_reply else 0 + fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0 files_sent = result.get("files_sent", 0) errors = result.get("errors") diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 8c5857a5..1562fd72 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -545,6 +545,17 @@ def _handle_shutdown(self, event: UIEvent) -> None: # Helper methods # ───────────────────────────────────────────────────────────────────── + def can_prompt_user(self) -> bool: + """ + Whether a user is present and able to answer an interactive prompt + (e.g. a clarifying-question stepper) right now. + + Defaults to False: an adapter must explicitly declare interactive + capability, so actions that park a task waiting on an answer fail + closed on adapters that can't deliver one. + """ + return False + async def _display_chat_message( self, label: str, diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index c5d24d46..91d89004 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -234,24 +234,10 @@ def _init_storage(self) -> None: ] questions = None if stored.questions: - from app.ui_layer.components.types import ( - ChatMessageOption, - ChatMessageQuestion, - ) + from app.ui_layer.components.types import ChatMessageQuestion questions = [ - ChatMessageQuestion( - id=q.get("id", ""), - text=q.get("text", ""), - choices=[ - ChatMessageOption( - label=c.get("label", ""), value=c.get("value", "") - ) - for c in q.get("choices", []) - ], - multi_select=q.get("multiSelect", False), - ) - for q in stored.questions + ChatMessageQuestion.from_dict(q) for q in stored.questions ] self._messages.append( ChatMessage( @@ -277,6 +263,11 @@ async def append_message(self, message: ChatMessage) -> None: """Append message and broadcast to clients.""" self._messages.append(message) + # Serialized once, shared by the storage insert and the broadcast + questions_data = ( + [q.to_dict() for q in message.questions] if message.questions else None + ) + # Persist to storage if self._storage: try: @@ -300,20 +291,6 @@ async def append_message(self, message: ChatMessage) -> None: {"label": o.label, "value": o.value, "style": o.style} for o in message.options ] - questions_data = None - if message.questions: - questions_data = [ - { - "id": q.id, - "text": q.text, - "choices": [ - {"label": c.label, "value": c.value} - for c in q.choices - ], - "multiSelect": q.multi_select, - } - for q in message.questions - ] stored = StoredChatMessage( message_id=message.message_id or f"{message.sender}:{message.timestamp}", @@ -372,18 +349,8 @@ async def append_message(self, message: ChatMessage) -> None: message_data["optionSelected"] = message.option_selected # Include clarifying-question batch if present - if message.questions: - message_data["questions"] = [ - { - "id": q.id, - "text": q.text, - "choices": [ - {"label": c.label, "value": c.value} for c in q.choices - ], - "multiSelect": q.multi_select, - } - for q in message.questions - ] + if questions_data: + message_data["questions"] = questions_data if message.question_answers: message_data["questionAnswers"] = message.question_answers if message.questions_declined: @@ -457,24 +424,10 @@ def get_messages_before( ] questions = None if s.questions: - from app.ui_layer.components.types import ( - ChatMessageOption, - ChatMessageQuestion, - ) + from app.ui_layer.components.types import ChatMessageQuestion questions = [ - ChatMessageQuestion( - id=q.get("id", ""), - text=q.get("text", ""), - choices=[ - ChatMessageOption( - label=c.get("label", ""), value=c.get("value", "") - ) - for c in q.get("choices", []) - ], - multi_select=q.get("multiSelect", False), - ) - for q in s.questions + ChatMessageQuestion.from_dict(q) for q in s.questions ] messages.append( ChatMessage( @@ -1184,6 +1137,10 @@ def metrics_collector(self) -> MetricsCollector: """Get the metrics collector for dashboard data.""" return self._metrics_collector + def can_prompt_user(self) -> bool: + """A user can answer an interactive prompt when a browser tab is connected.""" + return len(self._ws_clients) > 0 + async def submit_message( self, message: str, @@ -4164,31 +4121,84 @@ async def _handle_question_answers_submit( Persists the resolution, then resumes the parked task the same way a normal reply to a waiting task does (see ``submit_message``'s ``reply_context`` handling) — no separate resume path needed. + + First submission wins: the storage UPDATE only matches an unresolved + row, so a duplicate submit (e.g. from a second browser tab that still + showed the live stepper) is dropped instead of resuming the task twice. """ try: - question_text_by_id: Dict[str, str] = {} + resolved_answers = answers if not declined else None + storage = self._chat._storage if self._chat else None + + in_memory = None if self._chat and message_id: for m in self._chat._messages: if m.message_id == message_id: - if m.questions: - question_text_by_id = {q.id: q.text for q in m.questions} - m.question_answers = answers if not declined else None - m.questions_declined = declined + in_memory = m break - if self._chat._storage: - try: - self._chat._storage.update_question_answers( - message_id, answers if not declined else None, declined - ) - except Exception: - pass - if declined: - resume_text = "The user declined to answer." - elif answers: + already_resolved_in_memory = in_memory is not None and ( + in_memory.question_answers is not None + or in_memory.questions_declined + ) + if storage: + claimed = False + try: + claimed = storage.update_question_answers( + message_id, resolved_answers, declined + ) + except Exception: + # Storage unavailable: fall back to the in-memory guard. + claimed = not already_resolved_in_memory + # update_question_answers returns False for a missing row too; + # only treat it as a duplicate when memory confirms resolution + # or has no unresolved copy to contradict it. + if not claimed and (in_memory is None or already_resolved_in_memory): + logger.info( + f"[QUESTION_ANSWERS] Ignoring duplicate submit for {message_id}" + ) + return + elif already_resolved_in_memory: + return + + question_text_by_id: Dict[str, str] = {} + if in_memory is not None: + if in_memory.questions: + question_text_by_id = {q.id: q.text for q in in_memory.questions} + in_memory.question_answers = resolved_answers + in_memory.questions_declined = declined + elif storage: + # Message aged out of the in-memory window (e.g. answered via + # paginated history after a restart): recover the question + # texts from storage so the resume text isn't raw ids. + try: + stored = storage.get_message(message_id) + if stored and stored.questions: + question_text_by_id = { + q.get("id", ""): q.get("text", "") + for q in stored.questions + } + except Exception: + pass + + # Collapse the stepper on every connected client — the submitting + # tab already resolved optimistically, but other tabs would keep + # showing a live stepper and could submit again. + await self._broadcast( + { + "type": "question_answers_resolved", + "data": { + "messageId": message_id, + "answers": resolved_answers, + "declined": declined, + }, + } + ) + + if resolved_answers: lines = [ f"{i + 1}. {question_text_by_id.get(qid, qid)} → {answer}" - for i, (qid, answer) in enumerate(answers.items()) + for i, (qid, answer) in enumerate(resolved_answers.items()) ] resume_text = "Answered clarifying questions:\n" + "\n".join(lines) else: @@ -8253,18 +8263,7 @@ async def _handle_chat_history( if m.option_selected: msg_data["optionSelected"] = m.option_selected if m.questions: - msg_data["questions"] = [ - { - "id": q.id, - "text": q.text, - "choices": [ - {"label": c.label, "value": c.value} - for c in q.choices - ], - "multiSelect": q.multi_select, - } - for q in m.questions - ] + msg_data["questions"] = [q.to_dict() for q in m.questions] if m.question_answers: msg_data["questionAnswers"] = m.question_answers if m.questions_declined: @@ -8979,20 +8978,7 @@ def _get_initial_state(self) -> Dict[str, Any]: else {} ), **( - { - "questions": [ - { - "id": q.id, - "text": q.text, - "choices": [ - {"label": c.label, "value": c.value} - for c in q.choices - ], - "multiSelect": q.multi_select, - } - for q in m.questions - ] - } + {"questions": [q.to_dict() for q in m.questions]} if m.questions else {} ), diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css index 444116bc..3b93ae33 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css @@ -61,6 +61,22 @@ margin-left: var(--space-1); } +.dismissButton { + margin-left: auto; + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; +} + +.dismissButton:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + .question { display: flex; flex-direction: column; @@ -95,6 +111,12 @@ background: var(--bg-primary); } +/* inset shadow instead of a border so selection doesn't shift layout */ +.choiceButtonSelected { + background: var(--bg-primary); + box-shadow: inset 0 0 0 1px var(--text-primary); +} + .choiceIndex { font-weight: var(--font-semibold); color: var(--text-secondary); diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx index ee1c44c9..2671f3df 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx @@ -11,16 +11,30 @@ export interface QuestionStepperProps { onSubmit: (messageId: string, sessionId: string | undefined, answers: Record | undefined, declined: boolean) => void } +// Per-question draft kept structured (which boxes are checked vs. what was +// free-typed) so navigating between questions never has to reverse-engineer +// state from the joined answer string — choice values containing ", " would +// mis-parse, and a free-typed value equal to a choice would be reclassified. +interface QuestionDraft { + checked: string[] + other: string +} + +const draftText = (d: QuestionDraft) => + [...d.checked, ...(d.other ? [d.other] : [])].join(', ') + export function QuestionStepper({ messageId, sessionId, questions, answers, declined, onSubmit }: QuestionStepperProps) { const resolved = !!answers || !!declined const [step, setStep] = useState(0) - const [localAnswers, setLocalAnswers] = useState>({}) + const [drafts, setDrafts] = useState>({}) const [otherText, setOtherText] = useState('') const [checked, setChecked] = useState>(new Set()) // Esc abandons the whole batch — scoped to this stepper via bubbling from // its own focused controls, not a window-wide listener (which would decline // whichever batch happened to be mounted regardless of what's focused). + // The free-text inputs intercept Esc to clear themselves instead, and the + // Dismiss button covers the case where nothing in the stepper has focus. const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onSubmit(messageId, sessionId, undefined, true) } @@ -46,39 +60,31 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl const isReview = !single && step === questions.length const current = questions[step] - // Navigating to a question rehydrates its previous answer (if any) instead - // of blindly clearing it — otherwise revisiting an already-answered - // question shows it as unanswered, and re-confirming would silently - // overwrite the earlier answer with an empty/partial one. - const goToStep = (i: number) => { + const answerTextFor = (qid: string): string | undefined => { + const d = drafts[qid] + return d === undefined ? undefined : draftText(d) + } + + // Navigating to a question rehydrates its saved draft (if any) so a + // previously-given answer shows exactly as it was entered. + // `source` lets callers that just updated the drafts pass the fresh map, + // since the `drafts` in this closure is one render behind. + const goToStep = (i: number, source: Record = drafts) => { setStep(i) const q = questions[i] - if (!q) return // i === questions.length: moving into the review step, nothing to rehydrate - const existing = localAnswers[q.id] - if (existing === undefined) { - setOtherText('') - setChecked(new Set()) - return - } - if (q.multiSelect) { - const knownValues = new Set(q.choices.map(c => c.value)) - const parts = existing.split(', ') - setChecked(new Set(parts.filter(p => knownValues.has(p)))) - setOtherText(parts.filter(p => !knownValues.has(p)).join(', ')) - } else { - const isKnownChoice = q.choices.some(c => c.value === existing) - setChecked(new Set()) - setOtherText(isKnownChoice ? '' : existing) - } + if (!q) return // i === questions.length: moving into the review step + const d = source[q.id] + setChecked(new Set(d?.checked ?? [])) + setOtherText(d?.other ?? '') } - const answerCurrent = (value: string) => { - const next = { ...localAnswers, [current.id]: value } - setLocalAnswers(next) + const saveCurrent = (d: QuestionDraft) => { + const next = { ...drafts, [current.id]: d } + setDrafts(next) if (single) { - onSubmit(messageId, sessionId, next, false) + onSubmit(messageId, sessionId, { [current.id]: draftText(d) }, false) } else { - goToStep(step + 1) + goToStep(step + 1, next) } } @@ -90,40 +96,64 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl } const confirmMultiSelect = () => { - const values = [...checked, ...(otherText.trim() ? [otherText.trim()] : [])] - if (values.length === 0) return - answerCurrent(values.join(', ')) + const other = otherText.trim() + if (checked.size === 0 && !other) return + saveCurrent({ checked: [...checked], other }) } const submitOther = () => { - if (!otherText.trim()) return - answerCurrent(otherText.trim()) + const other = otherText.trim() + if (!other) return + saveCurrent({ checked: [], other }) + } + + const submitAll = () => { + const all: Record = {} + for (const q of questions) { + const text = answerTextFor(q.id) + if (text !== undefined) all[q.id] = text + } + onSubmit(messageId, sessionId, all, false) + } + + const clearOnEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() // clear the field, don't decline the batch + setOtherText('') + } } return (
- {!single && ( -
- {step > 0 && ( - - )} - {questions.map((q, i) => ( - - ))} +
+ {!single && step > 0 && ( + + )} + {!single && questions.map((q, i) => ( + + ))} + {!single && ( {isReview ? 'Review' : `${step + 1} of ${questions.length}`} -
- )} + )} + +
{isReview ? (
@@ -135,14 +165,10 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl onClick={() => goToStep(questions.indexOf(q))} > {q.text} - {localAnswers[q.id]} + {answerTextFor(q.id)} ))} -
@@ -169,7 +195,7 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl className={styles.otherInput} value={otherText} onChange={e => setOtherText(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') confirmMultiSelect() }} + onKeyDown={e => { if (e.key === 'Enter') confirmMultiSelect(); clearOnEscape(e) }} placeholder="Other (optional)..." />
@@ -184,26 +210,29 @@ export function QuestionStepper({ messageId, sessionId, questions, answers, decl ) : ( <> - {current.choices.map((c, i) => ( - - ))} + {current.choices.map((c, i) => { + const selected = checked.has(c.value) + return ( + + ) + })}
setOtherText(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') submitOther() }} + onKeyDown={e => { if (e.key === 'Enter') submitOther(); clearOnEscape(e) }} placeholder={current.choices.length > 0 ? 'Or type your own answer...' : 'Type your answer...'} />