EngineDependencies) => {
// Single-select questions clear their selection once the user starts
// typing a free-text answer.
if (docChanged && plainText.trim().length > 0) {
- const { askUser } = store.getSnapshot();
- const currentQuestion = askUser.questions?.[askUser.step];
- if (askUser.questions && !currentQuestion?.multiSelect) askUser.clearSelections();
- askUser.optionsRef.current?.clearHighlight();
+ const { questions } = store.getSnapshot();
+ const currentQuestion = questions.items?.[questions.step];
+ if (questions.items && !currentQuestion?.multiSelect) questions.clearSelections();
+ questions.optionsRef.current?.clearHighlight();
}
mirrorCommandState();
@@ -434,7 +434,7 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => {
{ key: event.key, shiftKey: event.shiftKey },
{
isCommandListOpen: commandState.isOpen,
- hasActiveAskUser: (store.getSnapshot().askUser.questions?.length ?? 0) > 0,
+ hasActiveQuestions: (store.getSnapshot().questions.items?.length ?? 0) > 0,
isEditorEmpty: getPlainText(doc) === "",
hasAttachments: store.getSnapshot().attachments.items.length > 0,
submitOn: options.submitOn,
@@ -476,17 +476,17 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => {
}
return;
}
- case "ask-user-arrow": {
+ case "question-arrow": {
// navigate() moves DOM focus onto the newly highlighted option
// (roving tabindex) — never blur to . At the list boundary
// (null) focus stays in the editor.
event.preventDefault();
- store.getSnapshot().askUser.optionsRef.current?.navigate(action.direction);
+ store.getSnapshot().questions.optionsRef.current?.navigate(action.direction);
return;
}
- case "ask-user-dismiss": {
+ case "question-dismiss": {
event.preventDefault();
- store.getSnapshot().askUser.dismissStep();
+ store.getSnapshot().questions.dismissStep();
return;
}
case "remove-last-attachment": {
@@ -529,7 +529,7 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => {
};
const handleFocus = () => {
- getDependencies().store.getSnapshot().askUser.optionsRef.current?.clearHighlight();
+ getDependencies().store.getSnapshot().questions.optionsRef.current?.clearHighlight();
if (!root) return;
const ownerDocument = root.ownerDocument;
ownerDocument.addEventListener("selectionchange", handleSelectionChange);
diff --git a/packages/ui/src/message-utils.ts b/packages/ui/src/message-utils.ts
index e8c07a99..e7228127 100644
--- a/packages/ui/src/message-utils.ts
+++ b/packages/ui/src/message-utils.ts
@@ -130,6 +130,6 @@ export const getFileParts = (segments: readonly MessageSegment[]): FilePart[] =>
.filter((s): s is MessageSegment & { type: "file" } => s.type === "file")
.flatMap((s) => s.parts);
-// Chain/reasoning/source extraction (and ask-user extraction) is the consuming
+// Chain/reasoning/source extraction (and question extraction) is the consuming
// app's concern — grouping conventions, header formats, and dedup rules are
// product policy. This package ships only the generic segmentation above.
diff --git a/packages/ui/src/thread/geometry.ts b/packages/ui/src/thread/geometry.ts
index 0f2f4f95..8d890b20 100644
--- a/packages/ui/src/thread/geometry.ts
+++ b/packages/ui/src/thread/geometry.ts
@@ -57,7 +57,7 @@ export const queryDock = (root: HTMLElement): HTMLElement | null =>
* root's bottom, plus the content gap. Measured against the root's bottom
* rather than the slot's own height, so a dock that floats above the bottom
* edge still reserves the space beneath it. Parts that must NOT reserve space
- * — the command-list / ask-user panel, the scroll button — are positioned out
+ * — the command-list / ask panel, the scroll button — are positioned out
* of the slot's flow. Returns null when the slot isn't mounted yet.
*/
export const measureDockInset = (root: HTMLElement): number | null => {
diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts
index da6de285..2a423f08 100644
--- a/packages/ui/src/types.ts
+++ b/packages/ui/src/types.ts
@@ -84,10 +84,6 @@ export type ChatMessage
{
- const askUser = useComposer((composer) => composer.askUser);
- const question = askUser.questions?.[askUser.step];
+const AskHarness = () => {
+ const questions = useComposer((composer) => composer.questions);
+ const question = questions.items?.[questions.step];
if (!question) return null;
- const entry = askUser.answers.get(askUser.step);
- const selected = entry?.selected ?? new Set();
+ const draft = questions.drafts.get(questions.step);
+ const selected = draft?.selected ?? new Set();
return (
-
-
- {question.question}
-
-
+
+
+ {question.label}
+
+
{question.options.map((option) => (
- askUser.toggleOption(option.label)}
+ onSelect={() => questions.toggleOption(option.label)}
>
- {option.label}
-
+ {option.label}
+
))}
-
-
+
+
);
};
-// activateAskUser defers focus/listener attachment one frame past the commit
+// activateQuestions defers focus/listener attachment one frame past the commit
// that mounts the options.
const flushFrames = async () => {
await act(async () => {
@@ -65,7 +66,7 @@ const flushFrames = async () => {
});
};
-describe("ask-user a11y", () => {
+describe("ask a11y", () => {
test("focus lands in a labelled radiogroup, arrows rove, and the question is answerable by keyboard alone", async () => {
const submitted: ComposerSubmitData[] = [];
const { container, unmount } = render(
@@ -75,7 +76,7 @@ describe("ask-user a11y", () => {
submitted.push(data);
}}
>
-
+
,
);
await flushFrames();
@@ -99,12 +100,12 @@ describe("ask-user a11y", () => {
await expectNoAxeViolations(container);
// Enter on a single-select selects AND advances — with one question, the
- // answers submit. The full flow just happened without a mouse.
+ // entries submit. The full flow just happened without a mouse.
fireEvent.keyDown(group, { key: "Enter" });
expect(submitted).toEqual([
{
kind: "answers",
- answers: [{ question: "Which database should we use?", option: "MongoDB" }],
+ answers: [{ id: "database", selected: ["MongoDB"] }],
},
]);
@@ -115,7 +116,7 @@ describe("ask-user a11y", () => {
test("multi-select checkboxes toggle aria-checked in place with Enter and Space", async () => {
const { container, unmount } = render(
{}}>
-
+
,
);
await flushFrames();
@@ -145,7 +146,7 @@ describe("ask-user a11y", () => {
test("arrows still navigate after a chrome click drops focus to body", async () => {
const { unmount } = render(
{}}>
-
+
,
);
await flushFrames();
@@ -169,7 +170,7 @@ describe("ask-user a11y", () => {
test("navigating past the list boundary keeps focus instead of dropping to body", async () => {
const { unmount } = render(
{}}>
-
+
,
);
await flushFrames();
diff --git a/packages/ui/tests/composer-ask-user-machine.test.ts b/packages/ui/tests/composer-ask-user-machine.test.ts
deleted file mode 100644
index f83ab13b..00000000
--- a/packages/ui/tests/composer-ask-user-machine.test.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- type AskUserState,
- compileAnswers,
- INITIAL_ASK_USER_STATE,
- isLastStep,
- transitionAskUser,
-} from "../src/composer/ask-user-machine";
-import type { AskUserQuestion } from "../src/composer/types";
-
-const questions: AskUserQuestion[] = [
- { question: "Q1?", options: [{ label: "A", description: "a" }] },
- {
- question: "Q2?",
- options: [
- { label: "B", description: "b" },
- { label: "C", description: "c" },
- ],
- multiSelect: true,
- },
-];
-
-describe("transitionAskUser", () => {
- test("select-option on single-select advances a step and clears input", () => {
- const { next, effects } = transitionAskUser(INITIAL_ASK_USER_STATE, questions, {
- type: "select-option",
- label: "A",
- });
-
- expect(next.step).toBe(1);
- expect(next.answers.get(0)?.selected.has("A")).toBe(true);
- expect(effects.map((e) => e.type)).toEqual(["clear-input", "reset-highlight", "focus-options"]);
- });
-
- test("select-option on the last step compiles and submits", () => {
- const onLast: AskUserState = { step: 1, answers: new Map() };
- const { next, effects } = transitionAskUser(onLast, questions, {
- type: "select-option",
- label: "B",
- });
-
- // Multi-select never auto-advances on select — it toggles.
- expect(next.step).toBe(1);
- expect(effects).toEqual([]);
- expect(next.answers.get(1)?.selected.has("B")).toBe(true);
- });
-
- test("continue-step on the last step emits submit-answers with compiled payload", () => {
- const state: AskUserState = {
- step: 1,
- answers: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]),
- };
- const withToggle = transitionAskUser(state, questions, { type: "toggle-option", label: "B" });
- const { effects } = transitionAskUser(withToggle.next, questions, {
- type: "continue-step",
- freeText: "extra context",
- });
-
- const submit = effects.find((e) => e.type === "submit-answers");
- expect(submit).toBeDefined();
- if (submit?.type !== "submit-answers") throw new Error("unreachable");
- expect(submit.answers).toEqual([
- { question: "Q1?", option: "A" },
- { question: "Q2?", options: ["B"], text: "extra context" },
- ]);
- expect(effects.at(-1)).toEqual({ type: "focus-input" });
- });
-
- test("multiSelect toggle flips membership and keeps free text", () => {
- const first = transitionAskUser({ step: 1, answers: new Map() }, questions, {
- type: "toggle-option",
- label: "B",
- });
- const second = transitionAskUser(first.next, questions, { type: "toggle-option", label: "B" });
- expect(second.next.answers.get(1)?.selected.size).toBe(0);
- });
-
- test("dismiss-step skips the answer entirely", () => {
- const { next } = transitionAskUser(INITIAL_ASK_USER_STATE, questions, {
- type: "dismiss-step",
- });
- expect(next.step).toBe(1);
- expect(next.answers.has(0)).toBe(false);
- });
-
- test("step-back preserves in-progress free text and restores the target's", () => {
- const state: AskUserState = {
- step: 1,
- answers: new Map([[0, { selected: new Set(), freeText: "saved" }]]),
- };
- const { next, effects } = transitionAskUser(state, questions, {
- type: "step-back",
- currentText: "typing on q2",
- });
-
- expect(next.step).toBe(0);
- expect(next.answers.get(1)?.freeText).toBe("typing on q2");
- expect(effects[0]).toEqual({ type: "set-input-text", text: "saved" });
- });
-
- test("step navigation clamps at the ends", () => {
- const atStart = transitionAskUser(INITIAL_ASK_USER_STATE, questions, {
- type: "step-back",
- currentText: "",
- });
- expect(atStart.next).toBe(INITIAL_ASK_USER_STATE);
- expect(atStart.effects).toEqual([]);
- });
-});
-
-describe("compileAnswers", () => {
- test("unanswered questions compile to bare entries", () => {
- expect(compileAnswers(INITIAL_ASK_USER_STATE, questions)).toEqual([
- { question: "Q1?" },
- { question: "Q2?" },
- ]);
- });
-
- test("free text on single-select compiles to text entry", () => {
- const state: AskUserState = {
- step: 0,
- answers: new Map([[0, { selected: new Set(), freeText: " custom " }]]),
- };
- expect(compileAnswers(state, questions)[0]).toEqual({ question: "Q1?", text: "custom" });
- });
-});
-
-describe("isLastStep", () => {
- test("reports the final question", () => {
- expect(isLastStep({ step: 0, answers: new Map() }, questions)).toBe(false);
- expect(isLastStep({ step: 1, answers: new Map() }, questions)).toBe(true);
- });
-});
diff --git a/packages/ui/tests/composer-escape.test.tsx b/packages/ui/tests/composer-escape.test.tsx
index fecfdb05..d908436c 100644
--- a/packages/ui/tests/composer-escape.test.tsx
+++ b/packages/ui/tests/composer-escape.test.tsx
@@ -2,10 +2,9 @@ import { describe, expect, mock, test } from "bun:test";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { createPortal } from "react-dom";
-import { AskUser } from "../src/ask-user";
import { Composer } from "../src/composer";
import { useComposer } from "../src/composer/store";
-import type { AskUserQuestion, ComposerSubmitData } from "../src/composer/types";
+import type { ComposerQuestion, ComposerSubmitData } from "../src/composer/types";
import { Tabs } from "../src/tabs";
// Escape stops a generating composer only when pressed inside that composer.
@@ -28,30 +27,34 @@ const ChatComposer = ({
const pressEscape = (target: Element | Document) =>
act(() => void fireEvent.keyDown(target, { key: "Escape" }));
-const QUESTIONS: AskUserQuestion[] = [
- { question: "Which database?", options: [{ label: "PostgreSQL" }, { label: "SQLite" }] },
+const REQUESTS: ComposerQuestion[] = [
+ {
+ id: "database",
+ label: "Which database?",
+ options: [{ label: "PostgreSQL" }, { label: "SQLite" }],
+ },
];
-const AskUserOptions = () => {
- const askUser = useComposer((composer) => composer.askUser);
- const question = askUser.questions?.[askUser.step];
+const RequestOptions = () => {
+ const questions = useComposer((composer) => composer.questions);
+ const question = questions.items?.[questions.step];
if (!question) return null;
return (
-
+
{question.options.map((option) => (
- askUser.toggleOption(option.label)}
+ onSelect={() => questions.toggleOption(option.label)}
>
{option.label}
-
+
))}
-
+
);
};
-// activateAskUser moves focus into the options one frame past the commit.
+// activateQuestions moves focus into the options one frame past the commit.
const flushFrames = () =>
act(
() =>
@@ -120,13 +123,13 @@ describe("Escape stops generation", () => {
expect(notPrevented).toBe(true);
});
- test("not while an ask-user question is open; Escape dismisses its step", async () => {
+ test("not while a question is open; Escape dismisses its step", async () => {
const onStop = mock();
const submitted: ComposerSubmitData[] = [];
render(
- void submitted.push(data)}>
+ void submitted.push(data)}>
-
+
,
);
diff --git a/packages/ui/tests/composer-keyboard.test.ts b/packages/ui/tests/composer-keyboard.test.ts
index d8da47a2..6a59c24b 100644
--- a/packages/ui/tests/composer-keyboard.test.ts
+++ b/packages/ui/tests/composer-keyboard.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
-import { interpretAskUserKey, interpretEditorKey } from "../src/composer/keyboard";
+import { interpretEditorKey, interpretQuestionKey } from "../src/composer/keyboard";
const baseContext = {
isCommandListOpen: false,
- hasActiveAskUser: false,
+ hasActiveQuestions: false,
isEditorEmpty: false,
hasAttachments: false,
};
@@ -26,10 +26,10 @@ describe("interpretEditorKey", () => {
});
});
- test("ask-user arrows take over when active", () => {
- const ctx = { ...baseContext, hasActiveAskUser: true };
+ test("question arrows take over when active", () => {
+ const ctx = { ...baseContext, hasActiveQuestions: true };
expect(interpretEditorKey({ key: "ArrowUp", shiftKey: false }, ctx)).toEqual({
- type: "ask-user-arrow",
+ type: "question-arrow",
direction: -1,
});
});
@@ -55,8 +55,8 @@ describe("interpretEditorKey", () => {
});
});
-describe("interpretAskUserKey", () => {
- const event = (key: string, extra: Partial[0]> = {}) => ({
+describe("interpretQuestionKey", () => {
+ const event = (key: string, extra: Partial[0]> = {}) => ({
key,
ctrlKey: false,
metaKey: false,
@@ -66,39 +66,39 @@ describe("interpretAskUserKey", () => {
});
test("escape dismisses regardless of highlight", () => {
- expect(interpretAskUserKey(event("Escape"), { hasHighlight: false })).toEqual({
+ expect(interpretQuestionKey(event("Escape"), { hasHighlight: false })).toEqual({
type: "dismiss-step",
});
});
test("without highlight, other keys pass through", () => {
- expect(interpretAskUserKey(event("ArrowDown"), { hasHighlight: false })).toBeNull();
+ expect(interpretQuestionKey(event("ArrowDown"), { hasHighlight: false })).toBeNull();
});
test("with highlight, arrows navigate and enter selects", () => {
- expect(interpretAskUserKey(event("ArrowDown"), { hasHighlight: true })).toEqual({
+ expect(interpretQuestionKey(event("ArrowDown"), { hasHighlight: true })).toEqual({
type: "navigate-options",
direction: 1,
});
- expect(interpretAskUserKey(event("Enter"), { hasHighlight: true })).toEqual({
+ expect(interpretQuestionKey(event("Enter"), { hasHighlight: true })).toEqual({
type: "select-option",
});
- expect(interpretAskUserKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({
+ expect(interpretQuestionKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({
type: "go-back",
});
});
test("printable characters route back to the input", () => {
- expect(interpretAskUserKey(event("x"), { hasHighlight: true })).toEqual({
+ expect(interpretQuestionKey(event("x"), { hasHighlight: true })).toEqual({
type: "insert-character",
character: "x",
});
- expect(interpretAskUserKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull();
+ expect(interpretQuestionKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull();
});
test("defaultPrevented events are ignored", () => {
expect(
- interpretAskUserKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }),
+ interpretQuestionKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }),
).toBeNull();
});
});
@@ -142,10 +142,10 @@ describe("interpretEditorKey: submitOn", () => {
});
});
-describe("interpretAskUserKey: Space selects", () => {
+describe("interpretQuestionKey: Space selects", () => {
test("Space toggles the highlighted option (APG radio/checkbox)", () => {
expect(
- interpretAskUserKey(
+ interpretQuestionKey(
{ key: " ", ctrlKey: false, metaKey: false, altKey: false, defaultPrevented: false },
{ hasHighlight: true },
),
@@ -153,21 +153,21 @@ describe("interpretAskUserKey: Space selects", () => {
});
});
-describe("interpretEditorKey: ask-user dismiss", () => {
+describe("interpretEditorKey: question dismiss", () => {
test("editor-focused Escape dismisses the active question", () => {
expect(
interpretEditorKey(
{ key: "Escape", shiftKey: false },
- { ...baseContext, hasActiveAskUser: true },
+ { ...baseContext, hasActiveQuestions: true },
),
- ).toEqual({ type: "ask-user-dismiss" });
+ ).toEqual({ type: "question-dismiss" });
});
test("command list still owns Escape when both are active", () => {
expect(
interpretEditorKey(
{ key: "Escape", shiftKey: false },
- { ...baseContext, hasActiveAskUser: true, isCommandListOpen: true },
+ { ...baseContext, hasActiveQuestions: true, isCommandListOpen: true },
),
).toEqual({ type: "command-close" });
});
diff --git a/packages/ui/tests/composer-question-machine.test.ts b/packages/ui/tests/composer-question-machine.test.ts
new file mode 100644
index 00000000..b61e81e1
--- /dev/null
+++ b/packages/ui/tests/composer-question-machine.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, test } from "bun:test";
+import {
+ compileAnswers,
+ INITIAL_QUESTIONS_STATE,
+ isLastStep,
+ type QuestionsState,
+ transitionQuestions,
+} from "../src/composer/question-machine";
+import type { ComposerQuestion } from "../src/composer/types";
+
+const questions: ComposerQuestion[] = [
+ { id: "q1", label: "Q1?", options: [{ label: "A", description: "a" }] },
+ {
+ id: "q2",
+ label: "Q2?",
+ options: [
+ { label: "B", description: "b" },
+ { label: "C", description: "c" },
+ ],
+ multiSelect: true,
+ },
+];
+
+describe("transitionQuestions", () => {
+ test("select-option on single-select advances a step and clears input", () => {
+ const { next, effects } = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, {
+ type: "select-option",
+ label: "A",
+ });
+
+ expect(next.step).toBe(1);
+ expect(next.drafts.get(0)?.selected.has("A")).toBe(true);
+ expect(effects.map((e) => e.type)).toEqual(["clear-input", "reset-highlight", "focus-options"]);
+ });
+
+ test("select-option on the last step compiles and submits", () => {
+ const onLast: QuestionsState = { step: 1, drafts: new Map() };
+ const { next, effects } = transitionQuestions(onLast, questions, {
+ type: "select-option",
+ label: "B",
+ });
+
+ // Multi-select never auto-advances on select — it toggles.
+ expect(next.step).toBe(1);
+ expect(effects).toEqual([]);
+ expect(next.drafts.get(1)?.selected.has("B")).toBe(true);
+ });
+
+ test("continue-step on the last step emits submit-questions with compiled payload", () => {
+ const state: QuestionsState = {
+ step: 1,
+ drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]),
+ };
+ const withToggle = transitionQuestions(state, questions, { type: "toggle-option", label: "B" });
+ const { effects } = transitionQuestions(withToggle.next, questions, {
+ type: "continue-step",
+ freeText: "extra context",
+ });
+
+ const submit = effects.find((e) => e.type === "submit-answers");
+ expect(submit).toBeDefined();
+ if (submit?.type !== "submit-answers") throw new Error("unreachable");
+ expect(submit.answers).toEqual([
+ { id: "q1", selected: ["A"] },
+ { id: "q2", selected: ["B"], text: "extra context" },
+ ]);
+ expect(effects.at(-1)).toEqual({ type: "focus-input" });
+ });
+
+ test("multiSelect toggle flips membership and keeps free text", () => {
+ const first = transitionQuestions({ step: 1, drafts: new Map() }, questions, {
+ type: "toggle-option",
+ label: "B",
+ });
+ const second = transitionQuestions(first.next, questions, {
+ type: "toggle-option",
+ label: "B",
+ });
+ expect(second.next.drafts.get(1)?.selected.size).toBe(0);
+ });
+
+ test("dismiss-step skips the draft entirely", () => {
+ const { next } = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, {
+ type: "dismiss-step",
+ });
+ expect(next.step).toBe(1);
+ expect(next.drafts.has(0)).toBe(false);
+ });
+
+ test("step-back preserves in-progress free text and restores the target's", () => {
+ const state: QuestionsState = {
+ step: 1,
+ drafts: new Map([[0, { selected: new Set(), freeText: "saved" }]]),
+ };
+ const { next, effects } = transitionQuestions(state, questions, {
+ type: "step-back",
+ currentText: "typing on q2",
+ });
+
+ expect(next.step).toBe(0);
+ expect(next.drafts.get(1)?.freeText).toBe("typing on q2");
+ expect(effects[0]).toEqual({ type: "set-input-text", text: "saved" });
+ });
+
+ test("step navigation clamps at the ends", () => {
+ const atStart = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, {
+ type: "step-back",
+ currentText: "",
+ });
+ expect(atStart.next).toBe(INITIAL_QUESTIONS_STATE);
+ expect(atStart.effects).toEqual([]);
+ });
+});
+
+describe("compileAnswers", () => {
+ test("skipped questions compile to empty-selection entries keyed by id", () => {
+ expect(compileAnswers(INITIAL_QUESTIONS_STATE, questions)).toEqual([
+ { id: "q1", selected: [] },
+ { id: "q2", selected: [] },
+ ]);
+ });
+
+ test("free text on single-select compiles to a text entry", () => {
+ const state: QuestionsState = {
+ step: 0,
+ drafts: new Map([[0, { selected: new Set(), freeText: " custom " }]]),
+ };
+ expect(compileAnswers(state, questions)[0]).toEqual({ id: "q1", selected: [], text: "custom" });
+ });
+
+ test("text is omitted entirely when the user typed nothing", () => {
+ const state: QuestionsState = {
+ step: 0,
+ drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]),
+ };
+ const entry = compileAnswers(state, questions)[0];
+ expect(entry).toBeDefined();
+ expect(entry && "text" in entry).toBe(false);
+ });
+
+ test("option values are echoed when present, labels as fallback", () => {
+ const valued: ComposerQuestion[] = [
+ {
+ id: "framework",
+ label: "Which framework?",
+ options: [{ value: "next", label: "Next.js" }, { label: "Remix" }],
+ multiSelect: true,
+ },
+ ];
+ // Toggle keys are labels (how the widget wires Option value today).
+ const state: QuestionsState = {
+ step: 0,
+ drafts: new Map([[0, { selected: new Set(["Next.js", "Remix"]), freeText: "" }]]),
+ };
+ expect(compileAnswers(state, valued)[0]).toEqual({
+ id: "framework",
+ selected: ["next", "Remix"],
+ });
+ });
+
+ test("a label colliding with another option's value resolves to the selected option", () => {
+ const colliding: ComposerQuestion[] = [
+ {
+ id: "pick",
+ label: "Pick one",
+ options: [
+ { value: "c", label: "b" }, // this label equals the next option's value
+ { value: "b", label: "Alpha" },
+ ],
+ },
+ ];
+ // The widget stores an option's identity (value ?? label) — here, "b" for
+ // the second option. A cross-field lookup would match the first option's
+ // label instead and wrongly emit "c".
+ const state: QuestionsState = {
+ step: 0,
+ drafts: new Map([[0, { selected: new Set(["b"]), freeText: "" }]]),
+ };
+ expect(compileAnswers(state, colliding)[0]).toEqual({ id: "pick", selected: ["b"] });
+ });
+
+ test("keys already stored as values resolve to the same values", () => {
+ const valued: ComposerQuestion[] = [
+ { id: "db", label: "Database?", options: [{ value: "pg", label: "PostgreSQL" }] },
+ ];
+ const state: QuestionsState = {
+ step: 0,
+ drafts: new Map([[0, { selected: new Set(["pg"]), freeText: "" }]]),
+ };
+ expect(compileAnswers(state, valued)[0]).toEqual({ id: "db", selected: ["pg"] });
+ });
+});
+
+describe("isLastStep", () => {
+ test("reports the final question", () => {
+ expect(isLastStep({ step: 0, drafts: new Map() }, questions)).toBe(false);
+ expect(isLastStep({ step: 1, drafts: new Map() }, questions)).toBe(true);
+ });
+});
diff --git a/packages/ui/tests/composer-scroll-containment.test.tsx b/packages/ui/tests/composer-scroll-containment.test.tsx
index f918dd5c..765896c2 100644
--- a/packages/ui/tests/composer-scroll-containment.test.tsx
+++ b/packages/ui/tests/composer-scroll-containment.test.tsx
@@ -1,14 +1,13 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act, fireEvent, render, screen } from "@testing-library/react";
-import { AskUser } from "../src/ask-user";
import { Composer } from "../src/composer";
import { scrollCaretIntoView } from "../src/composer/editor-dom";
import { useComposer } from "../src/composer/store";
-import type { AskUserQuestion } from "../src/composer/types";
+import type { ComposerQuestion } from "../src/composer/types";
import { scrollIntoViewWithin } from "../src/internal/scroll-into-view-within";
// A composer embedded in a scrolling page scrolls only its own scroll areas:
-// programmatic writes and the ask-user flow's focus moves must leave the page
+// programmatic writes and the question flow's focus moves must leave the page
// where it is (CHAT-42).
// happy-dom has no layout, so each element gets a stubbed box.
@@ -175,35 +174,39 @@ describe("scrollIntoViewWithin", () => {
});
// ---------------------------------------------------------------------------
-// The ask-user flow's own focus moves
+// The question flow's own focus moves
// ---------------------------------------------------------------------------
-const QUESTIONS: AskUserQuestion[] = [
- { question: "Which database?", options: [{ label: "PostgreSQL" }, { label: "SQLite" }] },
+const REQUESTS: ComposerQuestion[] = [
+ {
+ id: "database",
+ label: "Which database?",
+ options: [{ label: "PostgreSQL" }, { label: "SQLite" }],
+ },
];
-const AskUserOptions = () => {
- const askUser = useComposer((composer) => composer.askUser);
- const question = askUser.questions?.[askUser.step];
+const RequestOptions = () => {
+ const questions = useComposer((composer) => composer.questions);
+ const question = questions.items?.[questions.step];
if (!question) return null;
return (
-
-
+
+
{question.options.map((option) => (
- askUser.toggleOption(option.label)}
+ onSelect={() => questions.toggleOption(option.label)}
>
{option.label}
-
+
))}
-
-
+
+
);
};
-// activateAskUser moves focus into the options one frame past the commit.
+// activateQuestions moves focus into the options one frame past the commit.
const flushFrames = () =>
act(
() =>
@@ -212,7 +215,7 @@ const flushFrames = () =>
}),
);
-describe("ask-user focus moves keep the page in place", () => {
+describe("question focus moves keep the page in place", () => {
const focus = HTMLElement.prototype.focus;
let calls: { element: HTMLElement; options?: FocusOptions }[] = [];
@@ -230,9 +233,9 @@ describe("ask-user focus moves keep the page in place", () => {
const renderQuestion = async (container?: HTMLElement) => {
render(
- {}}>
+ {}}>
-
+
,
{ container },
);
diff --git a/packages/ui/tests/composer-store-present.test.ts b/packages/ui/tests/composer-store-present.test.ts
index dbdc58e5..d83b55e6 100644
--- a/packages/ui/tests/composer-store-present.test.ts
+++ b/packages/ui/tests/composer-store-present.test.ts
@@ -33,18 +33,18 @@ describe("composer store: sticky panel presence", () => {
expect(store.getSnapshot().commands.present).toBe(false);
});
- test("ask-user presence is sticky the same way", () => {
+ test("question presence is sticky the same way", () => {
const store = createComposerStore();
- store.setQuestions([{ question: "Pick one", options: [{ label: "A" }] }]);
- expect(store.getSnapshot().askUser.active).toBe(true);
- expect(store.getSnapshot().askUser.present).toBe(true);
+ store.setQuestions([{ id: "pick", label: "Pick one", options: [{ label: "A" }] }]);
+ expect(store.getSnapshot().questions.active).toBe(true);
+ expect(store.getSnapshot().questions.present).toBe(true);
store.setQuestions(null);
- expect(store.getSnapshot().askUser.active).toBe(false);
- expect(store.getSnapshot().askUser.present).toBe(true);
+ expect(store.getSnapshot().questions.active).toBe(false);
+ expect(store.getSnapshot().questions.present).toBe(true);
store.finalizePanelClose();
- expect(store.getSnapshot().askUser.present).toBe(false);
+ expect(store.getSnapshot().questions.present).toBe(false);
});
test("reset clears presence", () => {
diff --git a/packages/ui/tests/message-utils.test.ts b/packages/ui/tests/message-utils.test.ts
index 017598e7..7baae08b 100644
--- a/packages/ui/tests/message-utils.test.ts
+++ b/packages/ui/tests/message-utils.test.ts
@@ -50,6 +50,6 @@ describe("groupTurns", () => {
});
});
-// getAskUserInfo, getSourcesInfo, splitReasoningByHeaders, getChainInfo, and
+// getQuestionsInfo, getSourcesInfo, splitReasoningByHeaders, getChainInfo, and
// getReasoningInfo moved to the app (lib/ai/steps-info.ts, lib/ai/message-info.ts)
// — tool contracts, header conventions, and dedup rules are the consumer's.
diff --git a/tools/ask-user.ts b/tools/ask-user.ts
index cc429b11..a64c17e4 100644
--- a/tools/ask-user.ts
+++ b/tools/ask-user.ts
@@ -25,7 +25,7 @@ const questionSchema = z.object({
),
});
-const askUserInputSchema = z.object({
+const questionsInputSchema = z.object({
questions: z
.array(questionSchema)
.min(1)
@@ -43,7 +43,7 @@ export const askUser = tool({
- You want to confirm a potentially destructive or irreversible action
Provide 1-4 questions, each with 2-5 concrete options with short labels and helpful descriptions. Users can also type a custom answer for any question.`,
- inputSchema: askUserInputSchema,
+ inputSchema: questionsInputSchema,
// The chat answers with addToolOutput: a JSON object of question → answer.
outputSchema: z.string(),
});