fix: ask user widget fallback

This commit is contained in:
Richard Tang
2026-04-14 16:27:12 -07:00
parent 8f5daf0569
commit c47987e73c
3 changed files with 267 additions and 19 deletions
+245 -13
View File
@@ -212,6 +212,211 @@ function ToolActivityRow({ content }: { content: string }) {
);
}
// --- Inline ask_user fallback ---------------------------------------------
// Sometimes the model prints the ask_user / ask_user_multiple payload as
// regular assistant text instead of invoking the tool. We detect that
// payload here and render a QuestionWidget / MultiQuestionWidget inline so
// the user still gets the nice button UI. Submissions are sent back as a
// regular user message via onSend (there is no pending backend state to
// fulfill, so we treat it like the user answering in chat).
type AskUserInlinePayload =
| { kind: "single"; question: string; options: string[] }
| {
kind: "multi";
questions: { id: string; prompt: string; options?: string[] }[];
};
function detectAskUserPayload(content: string): AskUserInlinePayload | null {
if (!content) return null;
let text = content.trim();
if (!text) return null;
// Strip an optional ```json ... ``` / ``` ... ``` code fence
const fence = text.match(/^```(?:json|JSON)?\s*([\s\S]*?)\s*```$/);
if (fence) text = fence[1].trim();
// Strip surrounding double quotes that fully wrap a JSON object
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
const inner = text.slice(1, -1).trim();
if (inner.startsWith("{") && inner.endsWith("}")) text = inner;
}
if (!text.startsWith("{") || !text.endsWith("}")) return null;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
// ask_user_multiple: { questions: [{ id, prompt, options? }, ...] }
if (Array.isArray(obj.questions)) {
const raw = obj.questions as unknown[];
if (raw.length < 1 || raw.length > 8) return null;
const questions: { id: string; prompt: string; options?: string[] }[] = [];
for (let i = 0; i < raw.length; i++) {
const q = raw[i];
if (!q || typeof q !== "object") return null;
const qo = q as Record<string, unknown>;
const prompt =
typeof qo.prompt === "string"
? qo.prompt
: typeof qo.question === "string"
? qo.question
: null;
if (!prompt) return null;
const id = typeof qo.id === "string" && qo.id ? qo.id : `q${i}`;
let options: string[] | undefined;
if (
Array.isArray(qo.options) &&
qo.options.every((o) => typeof o === "string")
) {
options = qo.options as string[];
}
questions.push({ id, prompt, options });
}
return { kind: "multi", questions };
}
// ask_user: { question: string, options: string[] }
const question = typeof obj.question === "string" ? obj.question : null;
const options =
Array.isArray(obj.options) &&
obj.options.every((o) => typeof o === "string")
? (obj.options as string[])
: null;
if (!question || !options || options.length < 2) return null;
return { kind: "single", question, options };
}
function InlineAskUserBubble({
msg,
payload,
activeThread,
onSend,
queenPhase,
showQueenPhaseBadge = true,
}: {
msg: ChatMessage;
payload: AskUserInlinePayload;
activeThread: string;
onSend: (
message: string,
thread: string,
images?: ImageContent[],
) => void;
queenPhase?: "planning" | "building" | "staging" | "running" | "independent";
showQueenPhaseBadge?: boolean;
}) {
const [state, setState] = useState<"pending" | "submitted" | "dismissed">(
"pending",
);
// Once the user submits an answer via the inline widget, hide the whole
// bubble — their reply appears right after as a normal user message.
if (state === "submitted") return null;
// If the user dismissed without answering, fall back to the regular
// MarkdownContent rendering so they can still see what the model said.
if (state === "dismissed") {
return (
<MessageBubble
msg={msg}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
/>
);
}
const isQueen = msg.role === "queen";
const color = getColor(msg.agent, msg.role);
const thread = msg.thread || activeThread;
const handleSingle = (answer: string) => {
setState("submitted");
onSend(answer, thread);
};
const handleMulti = (answers: Record<string, string>) => {
setState("submitted");
if (payload.kind !== "multi") return;
// Format answers as a readable, numbered list for the outgoing message.
const lines = payload.questions.map((q, i) => {
const a = answers[q.id] ?? "";
return `${i + 1}. ${q.prompt}\n ${a}`;
});
onSend(lines.join("\n"), thread);
};
return (
<div className="flex gap-3">
<div
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center`}
style={{
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
}}
>
{isQueen ? (
<Crown className="w-4 h-4" style={{ color }} />
) : (
<Cpu className="w-3.5 h-3.5" style={{ color }} />
)}
</div>
<div
className={`flex-1 min-w-0 ${isQueen ? "max-w-[85%]" : "max-w-[75%]"}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`font-medium ${isQueen ? "text-sm" : "text-xs"}`}
style={{ color }}
>
{msg.agent}
</span>
{(!isQueen || showQueenPhaseBadge) && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${
isQueen
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{isQueen
? (msg.phase ?? queenPhase) === "independent"
? "independent"
: (msg.phase ?? queenPhase) === "running"
? "running"
: (msg.phase ?? queenPhase) === "staging"
? "staging"
: (msg.phase ?? queenPhase) === "planning"
? "planning"
: "building"
: "Worker"}
</span>
)}
</div>
{payload.kind === "single" ? (
<QuestionWidget
inline
question={payload.question}
options={payload.options}
onSubmit={handleSingle}
onDismiss={() => setState("dismissed")}
/>
) : (
<MultiQuestionWidget
inline
questions={payload.questions}
onSubmit={handleMulti}
onDismiss={() => setState("dismissed")}
/>
)}
</div>
</div>
);
}
const MessageBubble = memo(
function MessageBubble({
msg,
@@ -596,24 +801,51 @@ export default function ChatPanel({
onScroll={handleScroll}
className="flex-1 overflow-auto px-5 py-4 space-y-3"
>
{renderItems.map((item) =>
item.kind === "parallel" ? (
<div key={item.groupId}>
<ParallelSubagentBubble
groupId={item.groupId}
groups={item.groups}
/>
</div>
) : (
<div key={item.msg.id}>
{renderItems.map((item) => {
if (item.kind === "parallel") {
return (
<div key={item.groupId}>
<ParallelSubagentBubble
groupId={item.groupId}
groups={item.groups}
/>
</div>
);
}
const msg = item.msg;
// Detect misformatted ask_user payloads emitted as plain text and
// substitute the nicer widget-based bubble. Only inspect regular
// agent messages — skip system rows, tool status, dividers, etc.
const askPayload =
(msg.role === "queen" || msg.role === "worker") &&
!msg.type &&
msg.content
? detectAskUserPayload(msg.content)
: null;
if (askPayload) {
return (
<div key={msg.id}>
<InlineAskUserBubble
msg={msg}
payload={askPayload}
activeThread={activeThread}
onSend={onSend}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
/>
</div>
);
}
return (
<div key={msg.id}>
<MessageBubble
msg={item.msg}
msg={msg}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
/>
</div>
),
)}
);
})}
{/* Show typing indicator while waiting for first queen response (disabled + empty chat) */}
{(isWaiting || (disabled && threadMessages.length === 0)) && (
@@ -11,9 +11,15 @@ export interface MultiQuestionWidgetProps {
questions: QuestionItem[];
onSubmit: (answers: Record<string, string>) => void;
onDismiss?: () => void;
/**
* When true, skip the global Enter-to-submit listener. Use this when rendering
* the widget inline alongside other inputs (e.g. the chat textarea) so Enter
* isn't hijacked from the surrounding UI.
*/
inline?: boolean;
}
export default function MultiQuestionWidget({ questions, onSubmit, onDismiss }: MultiQuestionWidgetProps) {
export default function MultiQuestionWidget({ questions, onSubmit, onDismiss, inline = false }: MultiQuestionWidgetProps) {
// Per-question state: selected index (null = nothing, options.length = "Other")
const [selections, setSelections] = useState<(number | null)[]>(
() => questions.map(() => null),
@@ -50,8 +56,10 @@ export default function MultiQuestionWidget({ questions, onSubmit, onDismiss }:
onSubmit(answers);
}, [canSubmit, submitted, questions, selections, customTexts, onSubmit]);
// Enter to submit (only when not focused on a text input)
// Enter to submit (only when not focused on a text input).
// Skipped in inline mode so the widget doesn't hijack keys from surrounding inputs.
useEffect(() => {
if (inline) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (submitted) return;
const target = e.target as HTMLElement;
@@ -63,7 +71,7 @@ export default function MultiQuestionWidget({ questions, onSubmit, onDismiss }:
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSubmit, submitted]);
}, [handleSubmit, submitted, inline]);
if (submitted) return null;
@@ -10,9 +10,15 @@ export interface QuestionWidgetProps {
onSubmit: (answer: string, isOther: boolean) => void;
/** Called when user dismisses the question without answering */
onDismiss?: () => void;
/**
* When true, the widget does not register a global keyboard listener. Set this
* when rendering the widget inline alongside other inputs (e.g. a chat textarea)
* so Enter / number keys do not get hijacked from the surrounding UI.
*/
inline?: boolean;
}
export default function QuestionWidget({ question, options, onSubmit, onDismiss }: QuestionWidgetProps) {
export default function QuestionWidget({ question, options, onSubmit, onDismiss, inline = false }: QuestionWidgetProps) {
const [selected, setSelected] = useState<number | null>(null);
const [customText, setCustomText] = useState("");
const [submitted, setSubmitted] = useState(false);
@@ -42,8 +48,10 @@ export default function QuestionWidget({ question, options, onSubmit, onDismiss
}
}, [canSubmit, submitted, isOtherSelected, customText, options, selected, onSubmit]);
// Keyboard: Enter to submit, number keys to select (only when text input is not focused)
// Keyboard: Enter to submit, number keys to select (only when text input is not focused).
// Skipped in inline mode so the widget doesn't hijack keys from surrounding inputs.
useEffect(() => {
if (inline) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (submitted) return;
const inTextInput = e.target === inputRef.current;
@@ -66,7 +74,7 @@ export default function QuestionWidget({ question, options, onSubmit, onDismiss
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSubmit, submitted, options.length]);
}, [handleSubmit, submitted, options.length, inline]);
if (submitted) return null;