bce9fd266a
* feat(backend): multi-question support for ask_user_question_kandev
Reshape the MCP tool from a single prompt+options pair to a questions[]
array (1-4 entries, single-choice each, custom text fallback). The
clarification HTTP handler gates the response until every question has an
answer, fans out one chat message per question (sharing pending_id, with
question_index/question_total in metadata), and returns a JSON map keyed
by question id back to the agent. The orchestrator resume prompt now
formats multi-line Q1/A1, Q2/A2 summaries when the bundle has more than
one question. Mock-agent gets a clarification-multi scenario and
ask_user_question_test.go covers the new schema + map response. No
back-compat shim — local-first tool, single-PR breaking change.
* feat(web): refresh clarification overlay with multi-question + modern UI
Each per-question chat message gets its own card with a "Question N of M"
progress chip, a ✓ Answered badge once committed, and shadcn-aligned
option buttons with kbd hints. A new useClarificationGroup hook coordinates
the per-question commits (decision A) and posts every answer in a single
batch once the user finishes the bundle; X rejects the whole group. The
single-question case keeps the 1-9 / Esc keyboard shortcuts. Playwright
e2e adds 6 multi-question scenarios (rendering, full happy path, partial
state, mixed option/custom, skip-rejects-bundle, sibling-state) on top of
the existing single-question regression suite.
* fix(web): clarification carousel UX with stepper navigation + race fix
Replaces the vertically stacked card layout with a horizontal stepper:
numbered circle dots up top show step state (active/answered/pending),
the single active question card sits in the middle, and Back / Next /
Submit buttons sit at the bottom. Picking an option auto-advances to the
next step (single-question case auto-submits, no explicit Submit button).
The stepper steps are clickable so users can revise prior answers.
Keyboard shortcuts: 1-9 for option pick, ArrowLeft/Right for navigation,
Esc for skip.
Also fixes a race that surfaced as a 400 BadRequest on the
/api/v1/clarification/:id/respond endpoint when the user clicked an
option before all N messages of a multi-question bundle had streamed in:
findPendingClarificationGroup now gates on metadata.question_total and
returns an empty array until the full bundle has arrived.
Playwright suite expanded from 6 to 12 multi-question scenarios:
stepper rendering, auto-advance, Back, stepper jump, Submit gating,
happy path, mixed custom-text, revise-via-jump, skip-from-mid-step,
number-key shortcuts, Esc skip, first-step Back disabled.
* fix(clarification): address CodeRabbit/Greptile/Claude review comments
Backend:
- Single source of truth for question validation: rename/export
NormalizeAndValidateQuestions and call it from both the HTTP and the
WebSocket-side MCP handlers, so duplicate question ids and bad option
counts can't slip through either path.
- httpRespond now validates that every answer references a real question
id from the bundle and that no id is answered twice (regression tests
added).
- questionsFromMessages reorders by metadata.question_index so the
reconstructed bundle preserves the original question order.
- adapters.CreateClarificationRequestMessages rolls back already-created
messages when a later write in the bundle fails.
- extractQuestionAnswers always returns a JSON map (even on rejection,
with an envelope { rejected, reject_reason }) and simplifyAnswer
preserves both selected_option and custom_text when both are present.
- MCP tool description: rewrite the response example to match the actual
output shape; add minItems / maxItems to the questions and options
arrays in the JSON Schema.
- Test helper: drop the local contains() shim, use strings.Contains.
Frontend:
- useMemo([props]) was a no-op (props is always a fresh ref); split into
[message, messages] so it actually memoizes.
- Drop the dead "expired" SubmitState branch — the backend never returns
410 from this endpoint.
- Remove the asymmetric option-list dimming: the agent now receives both
selected_option and custom_text together, so a half-grey state was
misleading.
- New tests: pending-clarification.test.ts covers
findPendingClarificationGroup including the question_total gate, and
use-clarification-group.test.ts covers the hook's record/submit/skip
flows and the override-merge path used by the single-question case.
* fix(web): treat clarification 409 as success so duplicate submit doesn't stick the overlay
* fix(clarification): unblock agent on persistence failure + nil-guard rejected branch
- Add Store.CancelRequest(pendingID) and call it from both the WebSocket
MCP handler and the HTTP create-request handler when message creation
fails. Without it, the agent's WaitForResponse parked for the full
2-hour MCP timeout while no clarification cards ever reached the user.
Both handlers now return an explicit error response on failure.
- applyAnswersToMessages now guards h.repo == nil in the rejected branch
(parity with sibling expectedQuestionIDs).
- extractQuestionAnswers stubs for unanswered questions in a rejected
bundle now carry { answered: false, rejected: true } so agents reading
per-question payloads see the rejection without inspecting the
envelope.
- Hook test: added 409-as-ok regression coverage for both submit and
skip paths so the previous fix can't drift.
- Store test: covers CancelRequest unblocking a parked WaitForResponse.
* fix: drop unused e2e helper + use channel sync in CancelRequest test
* refactor(web): drop dead 'message' prop from clarification overlay
Both call sites pass 'messages' (the bundle group), so the legacy
single-message prop was unused. Tightening the prop type also lets the
useMemo depend on a single value.
* fix(web): ignore modifier-keyed events in clarification carousel shortcuts
Bare 1..9 / ArrowLeft / ArrowRight / Esc still drive the carousel, but
Cmd/Ctrl+1..9 (browser tab switch) and Alt+ArrowLeft (browser back) now
pass through as the user expects.
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { IconX, IconMessageQuestion, IconInfoCircle } from "@tabler/icons-react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
|
|
import type {
|
|
Message,
|
|
ClarificationRequestMetadata,
|
|
ClarificationAnswer,
|
|
ClarificationQuestion,
|
|
} from "@/lib/types/http";
|
|
import { useClarificationGroup } from "@/hooks/domains/session/use-clarification-group";
|
|
import {
|
|
ClarificationCarouselNav,
|
|
ClarificationCustomInput,
|
|
ClarificationOptions,
|
|
ClarificationStepper,
|
|
} from "./clarification-overlay-parts";
|
|
|
|
type ClarificationInputOverlayProps = {
|
|
messages: readonly Message[] | null | undefined;
|
|
onResolved: () => void;
|
|
};
|
|
|
|
type SingleQuestionMeta = {
|
|
message: Message;
|
|
metadata: ClarificationRequestMetadata;
|
|
question: ClarificationQuestion;
|
|
questionId: string;
|
|
};
|
|
|
|
function readSingleQuestionMeta(message: Message | null | undefined): SingleQuestionMeta | null {
|
|
if (!message) return null;
|
|
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
|
|
if (!metadata?.question) return null;
|
|
const questionId = metadata.question_id ?? metadata.question.id;
|
|
if (!questionId) return null;
|
|
return { message, metadata, question: metadata.question, questionId };
|
|
}
|
|
|
|
function resolveQuestionMessages(messages: readonly Message[] | null | undefined): Message[] {
|
|
if (messages && messages.length > 0) return [...messages];
|
|
return [];
|
|
}
|
|
|
|
function sortMessagesByQuestionIndex(messages: Message[]): Message[] {
|
|
return messages.slice().sort((a, b) => {
|
|
const ai = (a.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0;
|
|
const bi = (b.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0;
|
|
return ai - bi;
|
|
});
|
|
}
|
|
|
|
type CardProps = {
|
|
meta: SingleQuestionMeta;
|
|
index: number;
|
|
total: number;
|
|
selectedOption: string | null;
|
|
customCommittedText: string | null;
|
|
customDraft: string;
|
|
isSubmitting: boolean;
|
|
showAgentDisconnected: boolean;
|
|
onSelectOption: (optionId: string) => void;
|
|
onCustomDraftChange: (text: string) => void;
|
|
onSubmitCustom: (text: string) => void;
|
|
};
|
|
|
|
function ClarificationCard(props: CardProps) {
|
|
const {
|
|
meta,
|
|
index,
|
|
total,
|
|
selectedOption,
|
|
customCommittedText,
|
|
customDraft,
|
|
isSubmitting,
|
|
showAgentDisconnected,
|
|
onSelectOption,
|
|
onCustomDraftChange,
|
|
onSubmitCustom,
|
|
} = props;
|
|
const { question, metadata } = meta;
|
|
return (
|
|
<div
|
|
data-testid="clarification-question-card"
|
|
data-question-id={meta.questionId}
|
|
data-question-index={String(index)}
|
|
className="px-4 pt-3 pb-4"
|
|
>
|
|
<div className="flex items-center gap-2 mb-2 text-xs text-muted-foreground">
|
|
<IconMessageQuestion className="h-4 w-4 text-blue-500" />
|
|
{total > 1 && (
|
|
<span data-testid="clarification-progress-chip">
|
|
Question {index + 1} of {total}
|
|
</span>
|
|
)}
|
|
{metadata.question.title && (
|
|
<span className="text-muted-foreground/70">
|
|
{total > 1 ? "· " : ""}
|
|
{metadata.question.title}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="markdown-body max-w-none text-sm font-medium [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 mb-3">
|
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
|
{question.prompt}
|
|
</ReactMarkdown>
|
|
</div>
|
|
<ClarificationOptions
|
|
options={question.options}
|
|
selectedOption={selectedOption}
|
|
isSubmitting={isSubmitting}
|
|
onSelectOption={onSelectOption}
|
|
/>
|
|
{showAgentDisconnected && (
|
|
<div
|
|
data-testid="clarification-deferred-notice"
|
|
className="mt-2 text-xs text-amber-500/90 flex items-center gap-1.5"
|
|
>
|
|
<IconInfoCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
|
The agent has moved on. Your response will be sent as a new message.
|
|
</div>
|
|
)}
|
|
<ClarificationCustomInput
|
|
draft={customDraft}
|
|
isSubmitting={isSubmitting}
|
|
committedText={customCommittedText}
|
|
onChange={onCustomDraftChange}
|
|
onSubmit={onSubmitCustom}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useResolveCallback(
|
|
submitState: ReturnType<typeof useClarificationGroup>["submitState"],
|
|
onResolved: () => void,
|
|
) {
|
|
const last = useRef(submitState);
|
|
useEffect(() => {
|
|
if (last.current !== submitState && submitState === "ok") {
|
|
onResolved();
|
|
}
|
|
last.current = submitState;
|
|
}, [submitState, onResolved]);
|
|
}
|
|
|
|
type CarouselShortcutArgs = {
|
|
meta: SingleQuestionMeta;
|
|
activeIndex: number;
|
|
total: number;
|
|
canSubmit: boolean;
|
|
onPick: (index: number) => void;
|
|
onPrev: () => void;
|
|
onNext: () => void;
|
|
onSkip: () => void;
|
|
onSubmit: () => void;
|
|
};
|
|
|
|
// shouldIgnoreShortcut filters out events that the overlay must not handle:
|
|
// keystrokes inside an input/textarea (the user is typing) and any modifier
|
|
// combo (so we don't hijack browser shortcuts like Cmd/Ctrl+1..9 for tab
|
|
// switching or Alt+ArrowLeft for back-navigation).
|
|
function shouldIgnoreShortcut(e: KeyboardEvent): boolean {
|
|
if (
|
|
e.target instanceof HTMLElement &&
|
|
(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
|
) {
|
|
return true;
|
|
}
|
|
return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
|
|
}
|
|
|
|
function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) {
|
|
const optionsCount = args.meta.question.options.length;
|
|
const isLast = args.activeIndex === args.total - 1;
|
|
const { canSubmit, onPick, onPrev, onNext, onSkip, onSubmit } = args;
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (shouldIgnoreShortcut(e)) return;
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onSkip();
|
|
return;
|
|
}
|
|
if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
onPrev();
|
|
return;
|
|
}
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
if (isLast && canSubmit) onSubmit();
|
|
else onNext();
|
|
return;
|
|
}
|
|
const num = Number.parseInt(e.key, 10);
|
|
if (Number.isFinite(num) && num >= 1 && num <= optionsCount) {
|
|
e.preventDefault();
|
|
onPick(num - 1);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]);
|
|
return null;
|
|
}
|
|
|
|
type CarouselBodyProps = {
|
|
sortedMessages: Message[];
|
|
group: ReturnType<typeof useClarificationGroup>;
|
|
activeIndex: number;
|
|
setActiveIndex: (idx: number) => void;
|
|
customDrafts: Record<string, string>;
|
|
setCustomDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
};
|
|
|
|
function ClarificationCarouselBody({
|
|
sortedMessages,
|
|
group,
|
|
activeIndex,
|
|
setActiveIndex,
|
|
customDrafts,
|
|
setCustomDrafts,
|
|
}: CarouselBodyProps) {
|
|
const total = sortedMessages.length;
|
|
const activeMessage = sortedMessages[Math.min(activeIndex, total - 1)] ?? null;
|
|
const meta = activeMessage ? readSingleQuestionMeta(activeMessage) : null;
|
|
const showAgentDisconnectedAtTop = sortedMessages.some(
|
|
(m) => (m.metadata as ClarificationRequestMetadata | undefined)?.agent_disconnected === true,
|
|
);
|
|
const isSubmitting = group.submitState === "submitting";
|
|
|
|
const isSingleQuestion = total === 1;
|
|
|
|
const allAnswered = sortedMessages.every((m) => {
|
|
const id = readSingleQuestionMeta(m)?.questionId;
|
|
return id ? Boolean(group.answers[id]) : false;
|
|
});
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
if (allAnswered) void group.submitCollected();
|
|
}, [allAnswered, group]);
|
|
|
|
if (!meta) return null;
|
|
|
|
const stored = group.answers[meta.questionId];
|
|
const selectedOption =
|
|
stored?.selected_options && stored.selected_options.length > 0
|
|
? stored.selected_options[0]
|
|
: null;
|
|
const customCommittedText = stored?.custom_text ?? null;
|
|
const draft = customDrafts[meta.questionId] ?? "";
|
|
|
|
// Records the new answer, then either auto-submits (single-question case
|
|
// takes the override path so the freshly recorded answer is included even
|
|
// though setState is async) or auto-advances to the next step.
|
|
const commitAnswer = (answer: ClarificationAnswer) => {
|
|
group.recordAnswer(meta.questionId, answer);
|
|
if (isSingleQuestion) {
|
|
void group.submitCollected({ [meta.questionId]: answer });
|
|
return;
|
|
}
|
|
if (activeIndex < total - 1) {
|
|
setActiveIndex(activeIndex + 1);
|
|
}
|
|
};
|
|
|
|
const onSelectOption = (optionId: string) => {
|
|
commitAnswer({
|
|
question_id: meta.questionId,
|
|
selected_options: [optionId],
|
|
});
|
|
};
|
|
const onSubmitCustom = (text: string) => {
|
|
if (!text.trim()) return;
|
|
commitAnswer({
|
|
question_id: meta.questionId,
|
|
selected_options: [],
|
|
custom_text: text.trim(),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ClarificationCard
|
|
meta={meta}
|
|
index={activeIndex}
|
|
total={total}
|
|
selectedOption={selectedOption}
|
|
customCommittedText={customCommittedText}
|
|
customDraft={draft}
|
|
isSubmitting={isSubmitting}
|
|
showAgentDisconnected={activeIndex === 0 && showAgentDisconnectedAtTop}
|
|
onSelectOption={onSelectOption}
|
|
onCustomDraftChange={(value) =>
|
|
setCustomDrafts((prev) => ({ ...prev, [meta.questionId]: value }))
|
|
}
|
|
onSubmitCustom={onSubmitCustom}
|
|
/>
|
|
{!isSingleQuestion && (
|
|
<ClarificationCarouselNav
|
|
activeIndex={activeIndex}
|
|
total={total}
|
|
isSubmitting={isSubmitting}
|
|
onPrev={() => setActiveIndex(Math.max(0, activeIndex - 1))}
|
|
onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))}
|
|
onSubmit={handleSubmit}
|
|
canSubmit={allAnswered}
|
|
/>
|
|
)}
|
|
<CarouselKeyboardShortcuts
|
|
meta={meta}
|
|
activeIndex={activeIndex}
|
|
total={total}
|
|
canSubmit={allAnswered}
|
|
onPick={(idx) => onSelectOption(meta.question.options[idx].option_id)}
|
|
onPrev={() => setActiveIndex(Math.max(0, activeIndex - 1))}
|
|
onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))}
|
|
onSkip={() => void group.skipAll("User skipped")}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ClarificationInputOverlay({
|
|
messages,
|
|
onResolved,
|
|
}: ClarificationInputOverlayProps) {
|
|
const sortedMessages = useMemo(
|
|
() => sortMessagesByQuestionIndex(resolveQuestionMessages(messages)),
|
|
[messages],
|
|
);
|
|
const group = useClarificationGroup(sortedMessages);
|
|
const [customDrafts, setCustomDrafts] = useState<Record<string, string>>({});
|
|
const [rawActiveIndex, setActiveIndex] = useState(0);
|
|
// Clamp the active index to the current bundle size so late-arriving
|
|
// messages or shrunk bundles never put us out of range.
|
|
const total = sortedMessages.length;
|
|
const activeIndex = total === 0 ? 0 : Math.min(rawActiveIndex, total - 1);
|
|
|
|
useResolveCallback(group.submitState, onResolved);
|
|
|
|
if (sortedMessages.length === 0) return null;
|
|
const isSubmitting = group.submitState === "submitting";
|
|
|
|
const isAnsweredAt = (index: number) => {
|
|
const m = sortedMessages[index];
|
|
if (!m) return false;
|
|
const id = readSingleQuestionMeta(m)?.questionId;
|
|
return id ? Boolean(group.answers[id]) : false;
|
|
};
|
|
|
|
return (
|
|
<div className="relative" data-testid="clarification-overlay">
|
|
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-2">
|
|
<div className="flex items-center gap-3">
|
|
{total > 1 && (
|
|
<ClarificationStepper
|
|
total={total}
|
|
activeIndex={activeIndex}
|
|
isAnswered={isAnsweredAt}
|
|
onJump={setActiveIndex}
|
|
isSubmitting={isSubmitting}
|
|
/>
|
|
)}
|
|
{total > 1 && (
|
|
<span
|
|
data-testid="clarification-group-progress"
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
{group.answeredCount} of {group.total} answered
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void group.skipAll("User skipped")}
|
|
disabled={isSubmitting}
|
|
className="text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
|
|
data-testid="clarification-skip"
|
|
aria-label="Skip all questions"
|
|
>
|
|
<IconX className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<ClarificationCarouselBody
|
|
sortedMessages={sortedMessages}
|
|
group={group}
|
|
activeIndex={activeIndex}
|
|
setActiveIndex={setActiveIndex}
|
|
customDrafts={customDrafts}
|
|
setCustomDrafts={setCustomDrafts}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type { ClarificationAnswer };
|