Let bots ask multiple-choice questions before acting

ask_user puts up to six questions on one card (radios for one pick,
checkboxes for several, an optional typed Other) as a [question:ID] chip.
The user's answers are checked against the questions, stored in
bot_questions and sent to the bot as their chat message; Skip says so.
Offered in chat replies and user-started tasks, never in routines, hook
calls or chatroom sessions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 11:10:23 +01:00
co-authored by Claude Opus 5.5
parent 07f87ce4ea
commit d7dfe0e78e
45 changed files with 1448 additions and 6 deletions
@@ -0,0 +1,22 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BotQuestionAnswer, BotQuestionView } from "@/src/types";
type Params = {
question_id: number;
/**
* One per question; none skips them.
*/
answers?: BotQuestionAnswer[];
};
/**
* Answers questions a bot asked from their card, or skips them, returning
* the questions as they now stand.
*/
export default async function decideBotQuestion({ question_id, answers }: Params) {
return await fetchApi<{ question_id: number; answers?: BotQuestionAnswer[] }, APIResponseObject<BotQuestionView>>(
`/api/admin/bot-questions/${answers ? "answer" : "skip"}-question`,
{ method: "POST", body: answers ? { question_id, answers } : { question_id } },
);
}
@@ -0,0 +1,65 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { BotQuestionView } from "@/src/types";
type Params = {
question_id: number;
};
/**
* Questions already read, by id, so a thread naming them twice asks once.
* An answer replaces their entry.
*/
const loaded = new Map<number, Promise<BotQuestionView | null>>();
function loadQuestion({ question_id }: Params) {
const cached = loaded.get(question_id);
if (cached) return cached;
const pending = fetchApi<{}, APIResponseObject<BotQuestionView>>(
`/api/admin/bot-questions/get-question?question_id=${question_id}`,
{ method: "GET" },
)
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
.catch(() => null);
loaded.set(question_id, pending);
return pending;
}
/**
* Questions a bot asked (`[question:ID]`), for their chip and card: read
* when the chip renders, so it shows whether they're still waiting. Only
* the user they were asked of can read them; anyone else gets null.
*/
export default function useBotQuestion({ question_id }: Params) {
const [view, setViewState] = useState<BotQuestionView | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let live = true;
loadQuestion({ question_id }).then((result) => {
if (!live) return;
setViewState(result);
setLoading(false);
});
return () => {
live = false;
};
}, [question_id]);
const setView = useCallback(
(next: BotQuestionView) => {
loaded.set(question_id, Promise.resolve(next));
setViewState(next);
},
[question_id],
);
return { view, loading, setView };
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import type { BotQuestionView } from "@/src/types";
import BotQuestionCard from "./bot-question-card";
const deploy: BotQuestionView = {
id: 4,
title: "Before I deploy",
questions: [
{ question: "Which environment?", kind: "single", options: ["Staging (suggested)", "Production"], allow_other: true },
{ question: "Which checks should run first?", kind: "multiple", options: ["Lint", "Tests"], allow_other: false },
],
answers: null,
status: "pending",
bot_id: 6,
bot_name: "Ada",
};
function render({ view }: { view: BotQuestionView }) {
return renderToString(<BotQuestionCard view={view} onDecided={() => {}} onDone={() => {}} />);
}
describe("BotQuestionCard", () => {
test("asks each question with radios for one pick and checkboxes for several", () => {
const html = render({ view: deploy });
expect(html).toContain("Which environment?");
expect(html).toContain(`type="radio"`);
expect(html).toContain("Staging (suggested)");
expect(html).toContain(">Other<");
expect(html).toContain("twui-checkbox");
expect(html).toContain("Pick any");
expect(html).toContain(">Send answers<");
});
test("once answered, shows what was picked and offers no inputs", () => {
const html = render({
view: {
...deploy,
status: "answered",
answers: [
{ choices: [], other: "QA box" },
{ choices: [0, 1], other: "" },
],
},
});
expect(html).not.toContain(`type="radio"`);
expect(html).not.toContain(">Send answers<");
expect(html).toContain("Other: QA box");
expect(html).toContain("Lint; Tests");
});
test("once skipped, says so", () => {
expect(render({ view: { ...deploy, status: "skipped" } })).toContain("Nothing was answered");
});
});
@@ -0,0 +1,137 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import type { BotQuestionView } from "@/src/types";
import botQuestionAnswersText from "@/src/utils/bot-question-answers-text";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import decideBotQuestion from "../(functions)/decide-bot-question";
import sendBotMessage from "../(functions)/send-bot-message";
import botQuestionDraftAnswer, { type BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
import BotQuestionField from "./bot-question-field";
type Props = {
view: BotQuestionView;
onDecided: (view: BotQuestionView) => void;
onDone: () => void;
};
/**
* The card a user answers a bot's questions in: each question with its
* options (radios for one pick, checkboxes for several, "Other" to type
* their own). Sending or skipping tells the bot in chat, as the user's
* message; once decided it shows what they answered.
*/
export default function BotQuestionCard({ view, onDecided, onDone }: Props) {
const { pageProps } = useContext(AppContext);
const [drafts, setDrafts] = useState<BotQuestionDraft[]>(() =>
view.questions.map(() => ({ choices: [], other_on: false, other: "" })),
);
const [busy, setBusy] = useState<"answer" | "skip">();
const [error, setError] = useState<string>();
const open = view.status == "pending";
const setDraft = ({ index, draft }: { index: number; draft: BotQuestionDraft }) => {
setError(undefined);
setDrafts((current) => current.map((item, i) => (i == index ? draft : item)));
};
const decide = async (decision: "answer" | "skip") => {
const answers = drafts.map((draft) => botQuestionDraftAnswer({ draft }));
if (decision == "answer") {
try {
checkBotQuestionAnswers({ questions: view.questions, answers });
} catch (e: unknown) {
setError(e instanceof Error ? e.message : `Answer every question.`);
return;
}
}
const question =
decision == "answer"
? `Send your answers to ${view.bot_name}?`
: `Skip these questions? ${view.bot_name} is told you didn't answer them.`;
if (!window.confirm(question)) return;
setBusy(decision);
setError(undefined);
const res = await decideBotQuestion({
question_id: view.id,
answers: decision == "answer" ? answers : undefined,
}).catch(() => null);
if (!res?.success || !res.singleRes) {
setBusy(undefined);
setError(res?.msg || `Couldn't ${decision == "answer" ? "send your answers" : "skip them"}.`);
return;
}
onDecided(res.singleRes);
await sendBotMessage({
bot_id: Number(pageProps?.bot?.id) || 0,
workspace_id: pageProps?.workspace?.id || null,
text: botQuestionAnswersText({ view: res.singleRes }),
media_ids: [],
}).catch(() => null);
setBusy(undefined);
onDone();
};
return (
<Stack className="w-full gap-0! items-stretch">
{view.questions.map((item, index) => (
<BotQuestionField
key={index}
question_id={view.id}
index={index}
item={item}
draft={drafts[index]!}
answer={view.answers?.[index] || null}
open={open}
onChange={(draft) => setDraft({ index, draft })}
/>
))}
{view.status == "skipped" ? (
<Span className="mt-4 text-[13px] leading-5 text-ink">Skipped. Nothing was answered.</Span>
) : null}
{error ? <Span className="mt-3 text-[13px] leading-5 text-ink">{error}</Span> : null}
{open ? (
<Row className="mt-5 w-full gap-2! justify-end">
<Button
type="button"
variant="outlined"
color="gray"
size="small"
title="Skip: the bot is told you didn't answer"
loading={busy == "skip"}
disabled={Boolean(busy)}
onClick={() => decide("skip")}
>
Skip
</Button>
<Button
type="button"
size="small"
title="Send your answers"
loading={busy == "answer"}
disabled={Boolean(busy)}
onClick={() => decide("answer")}
>
Send answers
</Button>
</Row>
) : null}
</Stack>
);
}
@@ -0,0 +1,112 @@
import Input from "@moduletrace/twui/form/Input";
import Radios from "@moduletrace/twui/form/Radios";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
import type { BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
import BotQuestionOption from "./bot-question-option";
import MessageDetailsHeading from "./message-details-heading";
type Props = {
question_id: number;
index: number;
item: BotQuestionItem;
draft: BotQuestionDraft;
/**
* What the user answered, once they did.
*/
answer: BotQuestionAnswer | null;
/**
* Whether it still takes answers.
*/
open: boolean;
onChange: (draft: BotQuestionDraft) => void;
};
/**
* Value of the "Other" radio, beside the options' indexes.
*/
const OtherValue = "other";
/**
* One of a bot's questions in its card: the question, then its options as
* radios (one pick) or checkboxes (several), with "Other" and a field to
* type into when the bot allows it. Once answered, what the user picked.
*/
export default function BotQuestionField({ question_id, index, item, draft, answer, open, onChange }: Props) {
const single = item.kind == "single";
const picked = answer
? [...answer.choices.map((choice) => item.options[choice]), answer.other ? `Other: ${answer.other}` : ""].filter(Boolean)
: [];
return (
<Stack className="w-full gap-0! items-stretch">
<MessageDetailsHeading>{`${index + 1}. ${single ? "Pick one" : "Pick any"}`}</MessageDetailsHeading>
<Span className="whitespace-pre-wrap text-[13px] leading-5 font-bold text-ink break-words">{item.question}</Span>
{open ? (
<Stack className="mt-2 w-full gap-2! items-stretch">
{single ? (
<Radios
name={`question-${question_id}-${index}`}
values={[
...item.options.map((option, choice) => ({ value: String(choice), title: option })),
...(item.allow_other ? [{ value: OtherValue, title: "Other" }] : []),
]}
wrapperProps={{ className: "w-full flex-col items-start gap-2!" }}
labelProps={{ className: "text-[13px] leading-5 text-ink break-words cursor-pointer" }}
changeHandler={(value) =>
onChange(
value == OtherValue
? { ...draft, choices: [], other_on: true }
: { ...draft, choices: [Number(value)], other_on: false },
)
}
/>
) : (
<Stack className="w-full gap-2! items-stretch">
{item.options.map((option, choice) => (
<BotQuestionOption
key={choice}
label={option}
checked={draft.choices.includes(choice)}
onChange={(checked) =>
onChange({
...draft,
choices: checked
? [...new Set([...draft.choices, choice])]
: draft.choices.filter((c) => c != choice),
})
}
/>
))}
{item.allow_other ? (
<BotQuestionOption
label="Other"
checked={draft.other_on}
onChange={(checked) => onChange({ ...draft, other_on: checked })}
/>
) : null}
</Stack>
)}
{draft.other_on ? (
<Input
id={`question-${question_id}-${index}-other`}
title="Your answer"
placeholder="Your own answer"
maxLength={AppData.BotQuestionOtherMaxChars}
value={draft.other}
onChange={(e) => onChange({ ...draft, other: e.target.value })}
/>
) : null}
</Stack>
) : answer ? (
<Span className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-ink-secondary break-words">
{picked.join("; ")}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,26 @@
import Checkbox from "@moduletrace/twui/form/Checkbox";
type Props = {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
};
/**
* One option of a multiple-choice question in a bot's question card.
*/
export default function BotQuestionOption({ label, checked, onChange }: Props) {
return (
<Checkbox
title={label}
label={label}
size={18}
checked={checked}
changeHandler={(value) => {
if (value != checked) onChange(value);
}}
wrapperClassName="flex-nowrap! md:flex-nowrap items-start! cursor-pointer"
labelProps={{ className: "whitespace-normal md:whitespace-normal text-[13px] leading-5 text-ink break-words" }}
/>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageCircleQuestion } from "lucide-react";
import useBotQuestion from "../(hooks)/use-bot-question";
import BotQuestionCard from "./bot-question-card";
type Props = {
question_id: number;
};
const StatusLabels = {
pending: "waiting for you",
answered: "answered",
skipped: "skipped",
} as const;
/**
* Questions a bot asked before acting (`botQuestionRef`): a chip naming
* them and where they stand, which opens the card the user answers them
* in. Anyone they weren't asked of sees their number only.
*/
export default function BotQuestionRefChip({ question_id }: Props) {
const { view, loading, setView } = useBotQuestion({ question_id });
const [open, setOpen] = useState(false);
if (!view) {
return (
<Span className="font-bold">
{loading ? `questions ${question_id}` : `questions ${question_id} (not available)`}
</Span>
);
}
const count = view.questions.length;
const name = view.title || (count == 1 ? "1 question" : `${count} questions`);
const status = StatusLabels[view.status];
const pending = view.status == "pending";
return (
<Modal
target={
<Button
type="button"
variant={pending ? undefined : "outlined"}
color={pending ? undefined : "gray"}
size="smaller"
title={pending ? `Answer ${name}` : `${name}: ${status}`}
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap",
!pending && "text-ink",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<MessageCircleQuestion size={14} aria-hidden className="shrink-0" />
<Span className="min-w-0 truncate">{`${name} · ${status}`}</Span>
</Button>
}
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{view.title || `${view.bot_name} asks before going on`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{pending
? `${view.bot_name} wants to be sure what you want before it acts. Your answers go to it as your message.`
: `What ${view.bot_name} asked before going on.`}
</Span>
</Stack>
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
<BotQuestionCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
</Stack>
</Modal>
);
}
@@ -13,8 +13,10 @@ import {
WorkspaceRefHrefPrefix,
} from "@/src/utils/link-computer-refs";
import { parseFolderRefHref } from "@/src/utils/folder-ref";
import { botQuestionRefId } from "@/src/utils/bot-question-ref";
import { graphicRefId } from "@/src/utils/graphic-ref";
import { chatroomRefId } from "@/src/utils/chatroom-ref";
import BotQuestionRefChip from "./bot-question-ref-chip";
import BotRefChip from "./bot-ref-chip";
import ChatroomRefChip from "./chatroom-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
@@ -36,7 +38,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card, a
* secret it showed a masked chip, a secret it asked for a chip that opens
* the card it's typed into, a graphic it made the graphic itself, a
* the card it's typed into, questions it asked a chip that opens the card
* they're answered in, a graphic it made the graphic itself, a
* chatroom a chip that opens it;
* anything else opens in a new tab.
*/
@@ -104,6 +107,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
}
const question_id = botQuestionRefId({ href });
if (question_id) return <BotQuestionRefChip question_id={question_id} />;
const chatroom_id = chatroomRefId({ href });
if (chatroom_id) return <ChatroomRefChip chatroom_id={chatroom_id} />;
@@ -0,0 +1,22 @@
import type { BotQuestionAnswer } from "@/src/types";
/**
* One question's answer while the user fills in the card: the options
* picked, whether "Other" is picked, and what's typed into it.
*/
export type BotQuestionDraft = {
choices: number[];
other_on: boolean;
other: string;
};
type Params = {
draft: BotQuestionDraft;
};
/**
* A question's draft as the answer sent: "Other" counts only while picked.
*/
export default function botQuestionDraftAnswer({ draft }: Params): BotQuestionAnswer {
return { choices: draft.choices, other: draft.other_on ? draft.other.trim() : "" };
}