fix(ui): streamline ask_user question forms

This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 10:55:49 +02:00
parent 4e47c23f23
commit 0e83146315
21 changed files with 261 additions and 209 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Add an `ask_user` session tool that lets agents post structured question sets as one browser form. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool with `askUser: false` or `PI_WEB_ASK_USER=false`. Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool with `askUser: false` or `PI_WEB_ASK_USER=false`.
+3 -3
View File
@@ -810,9 +810,9 @@
<p> <p>
The tool accepts one set of 120 questions. Each question has a unique <code>id</code>, its The tool accepts one set of 120 questions. Each question has a unique <code>id</code>, its
<code>question</code> text, optional supporting <code>detail</code>, up to 12 options with stable values and <code>question</code> text, optional supporting <code>detail</code>, up to 12 options with stable values and
user-facing labels, and optional <code>allowOther</code> and <code>multiple</code> flags. A question must user-facing labels, and an optional <code>multiple</code> flag. The browser always adds a
offer at least one option or allow free text. No question is required: the user may leave any of them <strong>Custom</strong> free-text answer, including when the model supplies no options. No question is
unanswered. required: the user may leave any of them unanswered.
</p> </p>
<p> <p>
Calling <code>ask_user</code> posts the whole set as one browser form and ends the current agent run Calling <code>ask_user</code> posts the whole set as one browser form and ends the current agent run
+1 -1
View File
@@ -275,7 +275,7 @@ In **Settings → Session daemon**, these keys are saved on the selected machine
`askUser` controls whether agents receive the core `ask_user` tool. It defaults to `true`; set it to `false`, or set `PI_WEB_ASK_USER=false`, to remove the tool. The environment override accepts `0|1|true|false` and takes precedence over the config file. `askUser` controls whether agents receive the core `ask_user` tool. It defaults to `true`; set it to `false`, or set `PI_WEB_ASK_USER=false`, to remove the tool. The environment override accepts `0|1|true|false` and takes precedence over the config file.
The tool accepts one set of 120 questions. Each question has a unique `id`, its `question` text, optional supporting `detail`, up to 12 options with stable values and user-facing labels, and optional `allowOther` and `multiple` flags. A question must offer at least one option or allow free text. No question is required: the user may leave any of them unanswered. The tool accepts one set of 120 questions. Each question has a unique `id`, its `question` text, optional supporting `detail`, up to 12 options with stable values and user-facing labels, and an optional `multiple` flag. The browser always adds a **Custom** free-text answer, including when the model supplies no options. No question is required: the user may leave any of them unanswered.
Calling `ask_user` posts the whole set as one browser form and ends the current agent run instead of waiting for the user. The open form is owned by the session daemon, so it survives a browser disconnect, browser reload, or web/API restart while that daemon keeps running. When the user submits, the answers arrive as a follow-up that wakes the session; each question is reported with its selected option values or free text, or explicitly as unanswered. Calling `ask_user` posts the whole set as one browser form and ends the current agent run instead of waiting for the user. The open form is owned by the session daemon, so it survives a browser disconnect, browser reload, or web/API restart while that daemon keeps running. When the user submits, the answers arrive as a follow-up that wakes the session; each question is reported with its selected option values or free text, or explicitly as unanswered.
+7 -5
View File
@@ -723,14 +723,14 @@ describe("API parsers", () => {
})).toThrow("Invalid notification clear reason"); })).toThrow("Invalid notification clear reason");
}); });
it("parses an open ask with options, details, other, and multi-select", () => { it("parses an open ask and normalizes every question to allow custom answers", () => {
const parsed = parseSessionStatus({ ...statusWire(), pendingAsk: pendingAskWire() }); const parsed = parseSessionStatus({ ...statusWire(), pendingAsk: pendingAskWire() });
expect(parsed.pendingAsk).toEqual({ expect(parsed.pendingAsk).toEqual({
askId: "ask-1", askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z", askedAt: "2026-07-20T00:00:00.000Z",
questions: [ questions: [
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }] }, { id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }], allowOther: true },
{ id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true }, { id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true },
], ],
}); });
@@ -740,13 +740,15 @@ describe("API parsers", () => {
expect(parseSessionStatus(statusWire()).pendingAsk).toBeUndefined(); expect(parseSessionStatus(statusWire()).pendingAsk).toBeUndefined();
}); });
it("rejects an ask that cannot be rendered or answered honestly", () => { it("validates an ask before rendering it", () => {
const ask = pendingAskWire(); const ask = pendingAskWire();
const first = ask.questions[0]; const first = ask.questions[0];
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [] } })).toThrow("Pending ask has no questions"); expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [] } })).toThrow("Pending ask has no questions");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [first, first] } })).toThrow("Duplicate ask question id"); expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [first, first] } })).toThrow("Duplicate ask question id");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, askId: "" } })).toThrow("Expected non-empty string field: askId"); expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, askId: "" } })).toThrow("Expected non-empty string field: askId");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [] }] } })).toThrow("Ask question offers no way to answer"); expect(parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [] }] } }).pendingAsk?.questions[0])
.toEqual({ id: "q1", question: "Anything?", options: [], allowOther: true });
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [], allowOther: "yes" }] } })).toThrow("Expected optional boolean field: allowOther");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Which?", options: [{ value: "a", label: "A" }, { value: "a", label: "Also A" }] }] } })).toThrow("Duplicate ask option value"); expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Which?", options: [{ value: "a", label: "A" }, { value: "a", label: "Also A" }] }] } })).toThrow("Duplicate ask option value");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "x".repeat(ASK_USER_TEXT_MAX_LENGTH + 1), options: [{ value: "a", label: "A" }] }] } })).toThrow("String field exceeds limit: question"); expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "x".repeat(ASK_USER_TEXT_MAX_LENGTH + 1), options: [{ value: "a", label: "A" }] }] } })).toThrow("String field exceeds limit: question");
}); });
@@ -814,7 +816,7 @@ function pendingAskWire() {
askId: "ask-1", askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z", askedAt: "2026-07-20T00:00:00.000Z",
questions: [ questions: [
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }] }, { id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }], allowOther: false },
{ id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true }, { id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true },
], ],
}; };
+4 -5
View File
@@ -218,17 +218,16 @@ function parseAskUserQuestion(value: unknown): AskUserQuestion {
const record = requireRecord(value); const record = requireRecord(value);
const options = boundedArrayOf(record["options"], parseAskUserQuestionOption, ASK_USER_OPTION_LIMIT, "options"); const options = boundedArrayOf(record["options"], parseAskUserQuestionOption, ASK_USER_OPTION_LIMIT, "options");
assertUniqueStrings(options.map((option) => option.value), "ask option value"); assertUniqueStrings(options.map((option) => option.value), "ask option value");
const allowOther = parseOptionalBoolean(record["allowOther"], "allowOther"); // Validate the legacy wire field when present, but normalize every question to
// the current invariant: the browser always offers a custom answer.
parseOptionalBoolean(record["allowOther"], "allowOther");
const multiple = parseOptionalBoolean(record["multiple"], "multiple"); const multiple = parseOptionalBoolean(record["multiple"], "multiple");
// A question offering neither options nor a free-text field cannot be answered
// at all, which would make reporting it as unanswered meaningless.
if (options.length === 0 && allowOther !== true) throw new Error("Ask question offers no way to answer");
return { return {
id: requireBoundedNonEmptyString(record, "id", ASK_USER_ID_MAX_LENGTH), id: requireBoundedNonEmptyString(record, "id", ASK_USER_ID_MAX_LENGTH),
question: requireBoundedNonEmptyString(record, "question", ASK_USER_TEXT_MAX_LENGTH), question: requireBoundedNonEmptyString(record, "question", ASK_USER_TEXT_MAX_LENGTH),
...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)), ...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)),
options, options,
...(allowOther === undefined ? {} : { allowOther }), allowOther: true,
...(multiple === undefined ? {} : { multiple }), ...(multiple === undefined ? {} : { multiple }),
}; };
} }
+6 -10
View File
@@ -50,11 +50,10 @@ const singleSelect: AskUserQuestion = {
options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }], options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }],
}; };
const multiSelectWithOther: AskUserQuestion = { const multiSelect: AskUserQuestion = {
id: "q2", id: "q2",
question: "Which extras?", question: "Which extras?",
options: [{ value: "metrics", label: "Metrics" }, { value: "tracing", label: "Tracing" }], options: [{ value: "metrics", label: "Metrics" }, { value: "tracing", label: "Tracing" }],
allowOther: true,
multiple: true, multiple: true,
}; };
@@ -62,10 +61,9 @@ const freeTextOnly: AskUserQuestion = {
id: "q3", id: "q3",
question: "Anything else?", question: "Anything else?",
options: [], options: [],
allowOther: true,
}; };
const questions = [singleSelect, multiSelectWithOther, freeTextOnly]; const questions = [singleSelect, multiSelect, freeTextOnly];
afterEach(() => { afterEach(() => {
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
@@ -151,7 +149,7 @@ describe("ask answer state", () => {
expect(toSubmission(questions, answers).answers.map((answer) => answer.id)).toEqual(["q1", "q3"]); expect(toSubmission(questions, answers).answers.map((answer) => answer.id)).toEqual(["q1", "q3"]);
}); });
it("keeps several values and other text together for a multi-select question", () => { it("keeps several values and custom text together for a multi-select question", () => {
const answers: AskDraftAnswers = { q2: { values: ["metrics", "tracing"], otherText: "profiling" } }; const answers: AskDraftAnswers = { q2: { values: ["metrics", "tracing"], otherText: "profiling" } };
expect(toSubmission(questions, answers)).toEqual({ expect(toSubmission(questions, answers)).toEqual({
@@ -178,15 +176,13 @@ describe("ask answer state", () => {
}); });
}); });
it("keeps other text for a single-select question that has no selected option", () => { it("keeps custom text for every single-select question when no option is selected", () => {
const singleWithOther: AskUserQuestion = { ...singleSelect, allowOther: true }; expect(toSubmission([singleSelect], { q1: { values: [], otherText: "neither" } })).toEqual({
expect(toSubmission([singleWithOther], { q1: { values: [], otherText: "neither" } })).toEqual({
answers: [{ id: "q1", values: [], otherText: "neither" }], answers: [{ id: "q1", values: [], otherText: "neither" }],
}); });
}); });
it("bounds other text at the shared limit", () => { it("bounds custom text at the shared limit", () => {
const answers: AskDraftAnswers = { q3: { values: [], otherText: "a".repeat(ASK_USER_OTHER_TEXT_MAX_LENGTH + 10) } }; const answers: AskDraftAnswers = { q3: { values: [], otherText: "a".repeat(ASK_USER_OTHER_TEXT_MAX_LENGTH + 10) } };
expect(toSubmission(questions, answers).answers[0]?.otherText).toHaveLength(ASK_USER_OTHER_TEXT_MAX_LENGTH); expect(toSubmission(questions, answers).answers[0]?.otherText).toHaveLength(ASK_USER_OTHER_TEXT_MAX_LENGTH);
+3 -3
View File
@@ -120,7 +120,7 @@ function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | u
if (answer === undefined) return undefined; if (answer === undefined) return undefined;
const offered = new Set(question.options.map((option) => option.value)); const offered = new Set(question.options.map((option) => option.value));
const values = [...new Set(answer.values)].filter((value) => offered.has(value)); const values = [...new Set(answer.values)].filter((value) => offered.has(value));
const otherText = normalizedOtherText(question, answer.otherText); const otherText = normalizedOtherText(answer.otherText);
if (question.multiple !== true && values.length + (otherText === undefined ? 0 : 1) > 1) { if (question.multiple !== true && values.length + (otherText === undefined ? 0 : 1) > 1) {
const single = values[0]; const single = values[0];
if (single !== undefined) return { id: question.id, values: [single] }; if (single !== undefined) return { id: question.id, values: [single] };
@@ -130,8 +130,8 @@ function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | u
return { id: question.id, values, ...(otherText === undefined ? {} : { otherText }) }; return { id: question.id, values, ...(otherText === undefined ? {} : { otherText }) };
} }
function normalizedOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined { function normalizedOtherText(otherText: string | undefined): string | undefined {
if (otherText === undefined || question.allowOther !== true) return undefined; if (otherText === undefined) return undefined;
const trimmed = otherText.trim().slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH); const trimmed = otherText.trim().slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH);
return trimmed === "" ? undefined : trimmed; return trimmed === "" ? undefined : trimmed;
} }
+2 -2
View File
@@ -10,12 +10,12 @@ const askUserOutcome: AskUserOutcome = {
closedAt: "2026-07-20T10:05:00.000Z", closedAt: "2026-07-20T10:05:00.000Z",
questions: [ questions: [
{ {
question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }, question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true },
answered: true, answered: true,
values: ["pg"], values: ["pg"],
}, },
{ {
question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }] }, question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }], allowOther: true },
answered: false, answered: false,
values: [], values: [],
}, },
+2 -2
View File
@@ -12,12 +12,12 @@ const askUserOutcome: AskUserOutcome = {
closedAt: "2026-07-20T10:05:00.000Z", closedAt: "2026-07-20T10:05:00.000Z",
questions: [ questions: [
{ {
question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] }, question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }], allowOther: true },
answered: true, answered: true,
values: ["vim"], values: ["vim"],
}, },
{ {
question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] }, question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }], allowOther: true },
answered: false, answered: false,
values: [], values: [],
}, },
+33 -13
View File
@@ -33,7 +33,7 @@ describe("ask-user-card live form", () => {
expect(code.name).toBe(vim.name); expect(code.name).toBe(vim.name);
expect(web.type).toBe("checkbox"); expect(web.type).toBe("checkbox");
expect(web.name).not.toBe(vim.name); expect(web.name).not.toBe(vim.name);
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 0 of 2"); expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("0 of 2 answered");
// Focus and interaction run through the rendered native control rather than // Focus and interaction run through the rendered native control rather than
// extracting Lit handlers, so this exercises the form's browser boundary. // extracting Lit handlers, so this exercises the form's browser boundary.
@@ -44,7 +44,7 @@ describe("ask-user-card live form", () => {
expect(vim.checked).toBe(true); expect(vim.checked).toBe(true);
expect(code.checked).toBe(false); expect(code.checked).toBe(false);
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 1 of 2"); expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("1 of 2 answered");
}); });
it("accumulates several checkbox values for a multi-select question", async () => { it("accumulates several checkbox values for a multi-select question", async () => {
@@ -58,7 +58,7 @@ describe("ask-user-card live form", () => {
inputWithValue(root, "desktop").click(); inputWithValue(root, "desktop").click();
await card.updateComplete; await card.updateComplete;
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 1 of 1"); expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("1 of 1 answered");
buttonWithText(root, "Send answers").click(); buttonWithText(root, "Send answers").click();
await Promise.resolve(); await Promise.resolve();
@@ -67,10 +67,10 @@ describe("ask-user-card live form", () => {
}); });
}); });
it("reveals and focuses a labelled other field while preserving multi-select options", async () => { it("always offers and focuses a labelled custom field while preserving multi-select options", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>(); const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([ const card = await mountOpenAsk(openAsk([
question("stack", "Pick the stack", [option("lit", "Lit"), option("react", "React")], { multiple: true, allowOther: true }), question("stack", "Pick the stack", [option("lit", "Lit"), option("react", "React")], { multiple: true }),
]), onSubmit); ]), onSubmit);
const root = renderRoot(card); const root = renderRoot(card);
@@ -79,9 +79,9 @@ describe("ask-user-card live form", () => {
await card.updateComplete; await card.updateComplete;
await Promise.resolve(); await Promise.resolve();
const textarea = requiredElement(root.querySelector("textarea"), "other textarea"); const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
const label = requiredElement(textarea.closest("label"), "other label"); const label = requiredElement(textarea.closest("label"), "custom label");
expect(label.textContent).toContain("Your answer for “Pick the stack”"); expect(label.textContent).toContain("Custom answer");
expect(root.activeElement).toBe(textarea); expect(root.activeElement).toBe(textarea);
textarea.value = "Svelte"; textarea.value = "Svelte";
@@ -95,12 +95,33 @@ describe("ask-user-card live form", () => {
}); });
}); });
it("shows and submits the custom field directly when no options were supplied", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([
question("notes", "Anything else?", []),
]), onSubmit);
const root = renderRoot(card);
const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
expect(root.querySelector("input")).toBeNull();
expect(requiredElement(textarea.closest("label"), "custom label").textContent).toContain("Custom answer");
textarea.value = "Keep the first version small.";
textarea.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await card.updateComplete;
buttonWithText(root, "Send answers").click();
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledWith("ask-1", {
answers: [{ id: "notes", values: [], otherText: "Keep the first version small." }],
});
});
it("names unanswered questions before allowing a partial submit", async () => { it("names unanswered questions before allowing a partial submit", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>(); const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([ const card = await mountOpenAsk(openAsk([
question("editor", "Choose an editor", [option("vim", "Vim")]), question("editor", "Choose an editor", [option("vim", "Vim")]),
question("deploy", "Choose a deployment target", [option("cloud", "Cloud")]), question("deploy", "Choose a deployment target", [option("cloud", "Cloud")]),
question("notes", "Add implementation notes", [], { allowOther: true }), question("notes", "Add implementation notes", []),
]), onSubmit); ]), onSubmit);
const root = renderRoot(card); const root = renderRoot(card);
@@ -139,7 +160,7 @@ describe("ask-user-card record mode", () => {
closedAt: "2026-07-20T10:05:00.000Z", closedAt: "2026-07-20T10:05:00.000Z",
questions: [ questions: [
unansweredRecord(question("speed", "Preferred pace", [option("fast", "Fast"), option("careful", "Careful")])), unansweredRecord(question("speed", "Preferred pace", [option("fast", "Fast"), option("careful", "Careful")])),
unansweredRecord(question("rationale", "Why?", [], { allowOther: true })), unansweredRecord(question("rationale", "Why?", [])),
unansweredRecord(question("region", "Deployment region", [option("eu", "Europe")])), unansweredRecord(question("region", "Deployment region", [option("eu", "Europe")])),
], ],
answeredCount: 0, answeredCount: 0,
@@ -154,7 +175,7 @@ describe("ask-user-card record mode", () => {
const root = renderRoot(card); const root = renderRoot(card);
expect(root.querySelector("input, textarea, button, select")).toBeNull(); expect(root.querySelector("input, textarea, button, select")).toBeNull();
expect(root.textContent).toContain("Questions superseded"); expect(root.textContent).toContain("Superseded");
expect(root.textContent).toContain("Fast"); expect(root.textContent).toContain("Fast");
expect(root.textContent).toContain("It keeps the feedback loop short."); expect(root.textContent).toContain("It keeps the feedback loop short.");
expect(root.textContent).toContain("Draft answer · not sent"); expect(root.textContent).toContain("Draft answer · not sent");
@@ -200,7 +221,7 @@ function question(
id: string, id: string,
text: string, text: string,
options: AskUserQuestion["options"], options: AskUserQuestion["options"],
settings: { detail?: string; multiple?: boolean; allowOther?: boolean } = {}, settings: { detail?: string; multiple?: boolean } = {},
): AskUserQuestion { ): AskUserQuestion {
return { return {
id, id,
@@ -208,7 +229,6 @@ function question(
options, options,
...(settings.detail === undefined ? {} : { detail: settings.detail }), ...(settings.detail === undefined ? {} : { detail: settings.detail }),
...(settings.multiple === undefined ? {} : { multiple: settings.multiple }), ...(settings.multiple === undefined ? {} : { multiple: settings.multiple }),
...(settings.allowOther === undefined ? {} : { allowOther: settings.allowOther }),
}; };
} }
+64 -103
View File
@@ -70,20 +70,16 @@ export class AskUserCard extends LitElement {
return html` return html`
<article class="card open-card" aria-labelledby="ask-user-heading"> <article class="card open-card" aria-labelledby="ask-user-heading">
<header class="card-header"> <header class="card-header">
<div> <h2 id="ask-user-heading">Questions</h2>
<p class="eyebrow">Pi needs your input</p> <span class="header-status" role="status" aria-live="polite" aria-atomic="true">
<h2 id="ask-user-heading">Questions from the model</h2> ${count} of ${ask.questions.length} answered
</div> </span>
<span class="question-total">${ask.questions.length} ${ask.questions.length === 1 ? "question" : "questions"}</span>
</header> </header>
<form class="ask-form" @submit=${(event: SubmitEvent) => { this.handleSubmit(event, ask); }}> <form class="ask-form" @submit=${(event: SubmitEvent) => { this.handleSubmit(event, ask); }}>
<div class="questions-grid"> <div class="questions">
${ask.questions.map((question, index) => this.renderQuestion(ask, question, index))} ${ask.questions.map((question, index) => this.renderQuestion(ask, question, index))}
</div> </div>
<footer class="form-footer"> <footer class="form-footer">
<div class="progress" role="status" aria-live="polite" aria-atomic="true">
Answered ${count} of ${ask.questions.length}
</div>
${this.confirmingPartialSubmit && unanswered.length > 0 ${this.confirmingPartialSubmit && unanswered.length > 0
? this.renderPartialSubmitConfirmation(ask, unanswered) ? this.renderPartialSubmitConfirmation(ask, unanswered)
: html` : html`
@@ -101,7 +97,8 @@ export class AskUserCard extends LitElement {
const answer = this.answers[question.id]; const answer = this.answers[question.id];
const answered = answeredCount([question], this.answers) === 1; const answered = answeredCount([question], this.answers) === 1;
const detailId = question.detail === undefined ? undefined : this.questionDetailId(index); const detailId = question.detail === undefined ? undefined : this.questionDetailId(index);
const otherSelected = this.isOtherSelected(question, answer); const freeTextOnly = question.options.length === 0;
const customSelected = freeTextOnly || this.isOtherSelected(question, answer);
const inputType = question.multiple === true ? "checkbox" : "radio"; const inputType = question.multiple === true ? "checkbox" : "radio";
return html` return html`
<fieldset <fieldset
@@ -113,7 +110,6 @@ export class AskUserCard extends LitElement {
<legend> <legend>
<span class="question-number">${String(index + 1)}.</span> <span class="question-number">${String(index + 1)}.</span>
<span>${question.question}</span> <span>${question.question}</span>
<span class=${`answer-marker${answered ? " complete" : ""}`} aria-hidden="true">${answered ? "Answered" : "Unanswered"}</span>
</legend> </legend>
${question.detail === undefined ? null : html`<p class="question-detail" id=${detailId}>${question.detail}</p>`} ${question.detail === undefined ? null : html`<p class="question-detail" id=${detailId}>${question.detail}</p>`}
<div class="options"> <div class="options">
@@ -132,20 +128,21 @@ export class AskUserCard extends LitElement {
</span> </span>
</label> </label>
`)} `)}
${question.allowOther === true ? html` ${freeTextOnly ? null : html`
<label class="option other-option"> <label class="option other-option">
<input <input
type=${inputType} type=${inputType}
name=${this.questionGroupName(ask, question)} name=${this.questionGroupName(ask, question)}
value="__pi_web_other__" value="__pi_web_other__"
.checked=${otherSelected} .checked=${customSelected}
@change=${(event: Event) => { this.changeOther(question, index, event); }} @change=${(event: Event) => { this.changeOther(question, index, event); }}
/> />
<span class="option-copy"><span class="option-label">Other</span></span> <span class="option-copy"><span class="option-label">Custom</span></span>
</label> </label>
${otherSelected ? html` `}
${customSelected ? html`
<label class="other-answer" for=${this.otherInputId(index)}> <label class="other-answer" for=${this.otherInputId(index)}>
<span>Your answer for “${question.question}</span> <span>Custom answer</span>
<textarea <textarea
id=${this.otherInputId(index)} id=${this.otherInputId(index)}
rows="3" rows="3"
@@ -155,7 +152,6 @@ export class AskUserCard extends LitElement {
></textarea> ></textarea>
</label> </label>
` : null} ` : null}
` : null}
</div> </div>
</fieldset> </fieldset>
`; `;
@@ -186,16 +182,13 @@ export class AskUserCard extends LitElement {
const recordLabel = outcome.reason === "submitted" const recordLabel = outcome.reason === "submitted"
? "Answers sent" ? "Answers sent"
: outcome.reason === "superseded" : outcome.reason === "superseded"
? "Questions superseded" ? "Superseded"
: "Questions cancelled"; : "Cancelled";
return html` return html`
<article class="card record-card" aria-labelledby="ask-user-record-heading"> <article class="card record-card" aria-labelledby="ask-user-record-heading">
<header class="card-header"> <header class="card-header">
<div> <h2 id="ask-user-record-heading">Questions</h2>
<p class="eyebrow">Question record</p> <span class=${`header-status ${outcome.reason}`}>${recordLabel}</span>
<h2 id="ask-user-record-heading">${recordLabel}</h2>
</div>
<span class=${`record-reason ${outcome.reason}`}>${outcome.reason}</span>
</header> </header>
<p class="record-summary"> <p class="record-summary">
${outcome.reason === "superseded" ${outcome.reason === "superseded"
@@ -223,7 +216,7 @@ export class AskUserCard extends LitElement {
: html` : html`
<ul class="record-answers"> <ul class="record-answers">
${answer.values.map((value) => html`<li>${this.optionLabel(record.question, value)}</li>`)} ${answer.values.map((value) => html`<li>${this.optionLabel(record.question, value)}</li>`)}
${answer.otherText === undefined ? null : html`<li><strong>Other:</strong> <span class="other-record-text">${answer.otherText}</span></li>`} ${answer.otherText === undefined ? null : html`<li><strong>Custom:</strong> <span class="other-record-text">${answer.otherText}</span></li>`}
</ul> </ul>
${answer.fromDraft ? html`<p class="draft-note">Draft answer · not sent</p>` : null} ${answer.fromDraft ? html`<p class="draft-note">Draft answer · not sent</p>` : null}
`} `}
@@ -339,7 +332,7 @@ export class AskUserCard extends LitElement {
} }
private isOtherSelected(question: AskUserQuestion, answer: AskDraftAnswer | undefined): boolean { private isOtherSelected(question: AskUserQuestion, answer: AskDraftAnswer | undefined): boolean {
if (question.allowOther !== true || answer?.otherText === undefined) return false; if (answer?.otherText === undefined) return false;
return question.multiple === true || answer.values.length === 0; return question.multiple === true || answer.values.length === 0;
} }
@@ -400,93 +393,69 @@ export class AskUserCard extends LitElement {
static override styles = css` static override styles = css`
:host { :host {
display: block; display: block;
width: min(100%, 780px); box-sizing: border-box;
margin: 18px auto; width: 100%;
margin: 0 0 14px;
color: var(--pi-text); color: var(--pi-text);
font: 14px system-ui, sans-serif; font: 14px system-ui, sans-serif;
container-type: inline-size; container-type: inline-size;
} }
.card { .card {
overflow: hidden;
border: 1px solid var(--pi-border); border: 1px solid var(--pi-border);
border-radius: 12px; border-radius: 10px;
background: var(--pi-surface); background: var(--pi-surface);
box-shadow: 0 10px 30px var(--pi-shadow-soft);
} }
.card-header { .card-header {
position: sticky;
top: var(--pi-chat-sticky-top, 0px);
z-index: 6;
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 14px 16px; min-height: 22px;
border-bottom: 1px solid var(--pi-border-muted); padding: 7px 10px 6px;
background: var(--pi-bg-overlay); border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent);
} border-radius: 9px 9px 0 0;
.eyebrow { background: var(--pi-surface);
margin: 0 0 3px; box-shadow: 0 8px 18px var(--pi-shadow-soft);
color: var(--pi-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
} }
h2, h3, p { margin-top: 0; } h2, h3, p { margin-top: 0; }
h2 { margin-bottom: 0; font-size: 16px; line-height: 1.3; } h2 {
.question-total, .record-reason { margin-bottom: 0;
flex: 0 0 auto; color: var(--pi-accent);
border: 1px solid var(--pi-border-muted); font-size: 12px;
border-radius: 999px; line-height: 1.3;
color: var(--pi-muted); text-transform: uppercase;
padding: 3px 8px;
font-size: 11px;
}
.record-reason { text-transform: capitalize; }
.record-reason.submitted { border-color: var(--pi-success-border); color: var(--pi-success); }
.record-reason.superseded { border-color: var(--pi-warning-border); color: var(--pi-warning); }
.ask-form {
max-height: min(72dvh, 680px);
overflow: auto;
overscroll-behavior: contain;
}
.questions-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 12px;
padding: 14px;
} }
.header-status { flex: 0 1 auto; color: var(--pi-muted); font-size: 11px; text-align: end; }
.header-status.submitted { color: var(--pi-success); }
.header-status.superseded { color: var(--pi-warning); }
.questions { display: grid; padding-top: 8px; }
fieldset.question { fieldset.question {
min-width: 0; min-width: 0;
margin: 0; margin: 0;
border: 1px solid var(--pi-border-muted); border: 0;
border-radius: 10px; border-top: 1px solid var(--pi-border-muted);
padding: 12px; padding: 16px;
background: var(--pi-bg); background: transparent;
} }
fieldset.question.answered { border-color: var(--pi-success-border); } fieldset.question:first-child { border-top: 0; }
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
legend { legend {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr);
align-items: start; align-items: start;
gap: 5px; gap: 5px;
color: var(--pi-text); color: var(--pi-text);
padding: 0 2px; padding: 0;
font-weight: 650; font-weight: 650;
line-height: 1.35; line-height: 1.35;
} }
.question-number { color: var(--pi-muted); } .question-number { color: var(--pi-muted); }
.answer-marker { fieldset.question.answered .question-number { color: var(--pi-success); }
border-radius: 999px;
background: var(--pi-surface-hover);
color: var(--pi-muted);
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
}
.answer-marker.complete { background: var(--pi-success-surface); color: var(--pi-success); }
.question-detail { .question-detail {
margin: 4px 0 10px; margin: 4px 0 10px;
color: var(--pi-muted); color: var(--pi-muted);
@@ -511,7 +480,8 @@ export class AskUserCard extends LitElement {
.option-copy { min-width: 0; display: grid; gap: 2px; } .option-copy { min-width: 0; display: grid; gap: 2px; }
.option-label { line-height: 1.35; } .option-label { line-height: 1.35; }
.option-detail { color: var(--pi-muted); font-size: 12px; line-height: 1.35; } .option-detail { color: var(--pi-muted); font-size: 12px; line-height: 1.35; }
.other-answer { display: grid; gap: 5px; color: var(--pi-muted); font-size: 12px; padding: 0 8px 4px 32px; } .other-answer { display: grid; gap: 5px; color: var(--pi-muted); font-size: 12px; padding: 4px 8px 4px 32px; }
.other-answer:only-child { padding-left: 0; padding-right: 0; }
textarea { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@@ -526,20 +496,13 @@ export class AskUserCard extends LitElement {
line-height: 1.4; line-height: 1.4;
} }
.form-footer { .form-footer {
position: sticky;
z-index: 2;
bottom: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 12px; gap: 12px;
border-top: 1px solid var(--pi-border); border-top: 1px solid var(--pi-border-muted);
background: var(--pi-bg-overlay); padding: 12px 16px;
padding: 10px 14px;
box-shadow: 0 -8px 18px var(--pi-shadow-soft);
backdrop-filter: blur(8px);
} }
.progress { flex: 0 0 auto; color: var(--pi-muted); font-size: 12px; font-weight: 650; }
button { button {
border: 1px solid var(--pi-border); border: 1px solid var(--pi-border);
border-radius: 8px; border-radius: 8px;
@@ -566,24 +529,22 @@ export class AskUserCard extends LitElement {
text-underline-offset: 2px; text-underline-offset: 2px;
} }
.confirmation-actions { flex: 0 0 auto; display: flex; gap: 7px; } .confirmation-actions { flex: 0 0 auto; display: flex; gap: 7px; }
.record-summary { margin: 0; border-bottom: 1px solid var(--pi-border-muted); color: var(--pi-muted); padding: 10px 16px; font-size: 12px; } .record-summary { margin: 0; color: var(--pi-muted); padding: 12px 16px; font-size: 12px; }
.record-questions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 14px; } .record-questions { display: grid; }
.record-question { min-width: 0; border: 1px solid var(--pi-border-muted); border-radius: 10px; background: var(--pi-bg); padding: 12px; } .record-question { min-width: 0; padding: 14px 16px; }
.record-question + .record-question { border-top: 1px solid var(--pi-border-muted); }
.record-question h3 { display: flex; gap: 5px; margin-bottom: 8px; font-size: 14px; line-height: 1.35; } .record-question h3 { display: flex; gap: 5px; margin-bottom: 8px; font-size: 14px; line-height: 1.35; }
.record-answers { display: grid; gap: 4px; margin: 0; padding-left: 22px; line-height: 1.4; } .record-answers { display: grid; gap: 4px; margin: 0; padding-left: 22px; line-height: 1.4; }
.other-record-text { white-space: pre-wrap; overflow-wrap: anywhere; } .other-record-text { white-space: pre-wrap; overflow-wrap: anywhere; }
.unanswered-record { margin: 0; color: var(--pi-muted); font-style: italic; } .unanswered-record { margin: 0; color: var(--pi-muted); font-style: italic; }
.draft-note { margin: 7px 0 0; color: var(--pi-warning); font-size: 11px; } .draft-note { margin: 7px 0 0; color: var(--pi-warning); font-size: 11px; }
@container (max-width: 580px) { @container (max-width: 580px) {
:host { margin: 12px 0; } fieldset.question, .record-question { padding: 14px 12px; }
.questions-grid, .record-questions { grid-template-columns: minmax(0, 1fr); padding: 10px; } .record-summary { padding-inline: 12px; }
.card-header { padding: 12px; } .form-footer { align-items: stretch; flex-direction: column; padding: 12px; }
.form-footer { align-items: stretch; flex-direction: column; }
.partial-confirmation { align-items: stretch; flex-direction: column; } .partial-confirmation { align-items: stretch; flex-direction: column; }
.confirmation-actions { justify-content: flex-end; } .confirmation-actions { justify-content: flex-end; }
.primary-action { min-height: 42px; } .primary-action { min-height: 42px; }
legend { grid-template-columns: auto minmax(0, 1fr); }
.answer-marker { grid-column: 2; justify-self: start; }
} }
`; `;
} }
@@ -9,6 +9,30 @@ afterEach(() => {
document.body.replaceChildren(); document.body.replaceChildren();
}); });
describe("ChatView open ask_user form", () => {
it("scrolls a newly opened form to its start and gives it a stable chat-scroll anchor", async () => {
const view = new ChatView();
view.sessionId = "session-1";
document.body.append(view);
await view.updateComplete;
let askStartScrolls = 0;
let bottomScrolls = 0;
if (!Reflect.set(view, "scrollToOpenAsk", () => { askStartScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToOpenAsk");
if (!Reflect.set(view, "scrollToBottom", () => { bottomScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
view.pendingAsk = {
askId: "ask-open",
askedAt: "2026-07-20T10:00:00.000Z",
questions: [{ id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] }],
};
await view.updateComplete;
expect(askStartScrolls).toBe(1);
expect(bottomScrolls).toBe(0);
expect(view.shadowRoot?.querySelector("ask-user-card")?.getAttribute("data-scroll-anchor-id")).toBe("ask:ask-open");
});
});
describe("ChatView ask_user transcript records", () => { describe("ChatView ask_user transcript records", () => {
it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => { it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => {
const outcome: AskUserOutcome = { const outcome: AskUserOutcome = {
+44 -2
View File
@@ -221,6 +221,7 @@ export class ChatView extends LitElement {
private suppressLoadMoreRequests = false; private suppressLoadMoreRequests = false;
private loadMoreCheckFrame: number | undefined; private loadMoreCheckFrame: number | undefined;
private scrollToBottomFrame: number | undefined; private scrollToBottomFrame: number | undefined;
private scrollToOpenAskFrame: number | undefined;
private conversationRailFrame: number | undefined; private conversationRailFrame: number | undefined;
private groupedMessagesInput?: ChatLine[]; private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0; private groupedMessagesStart = 0;
@@ -279,6 +280,10 @@ export class ChatView extends LitElement {
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame); if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame); if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
if (this.scrollToOpenAskFrame !== undefined) {
cancelAnimationFrame(this.scrollToOpenAskFrame);
this.scrollToOpenAskFrame = undefined;
}
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame); if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
window.removeEventListener("resize", this.onViewportResize); window.removeEventListener("resize", this.onViewportResize);
window.removeEventListener("pagehide", this.onPageHide); window.removeEventListener("pagehide", this.onPageHide);
@@ -305,6 +310,10 @@ export class ChatView extends LitElement {
cancelAnimationFrame(this.restoreScrollFrame); cancelAnimationFrame(this.restoreScrollFrame);
this.restoreScrollFrame = undefined; this.restoreScrollFrame = undefined;
} }
if (this.scrollToOpenAskFrame !== undefined) {
cancelAnimationFrame(this.scrollToOpenAskFrame);
this.scrollToOpenAskFrame = undefined;
}
} }
protected override willUpdate(changed: Map<string, unknown>): void { protected override willUpdate(changed: Map<string, unknown>): void {
@@ -328,9 +337,13 @@ export class ChatView extends LitElement {
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false; if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
if (changed.has("sessionId")) this.restoreScrollPosition(); if (changed.has("sessionId")) this.restoreScrollPosition();
if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom(); const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk"));
// The form uses the transcript scroller. Start a new long form at question
// one rather than applying the usual live-tail scroll and landing at its end.
if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk();
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom();
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate(); if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore(); if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget(); if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
if (changed.has("zoomedImage")) this.syncImageZoomDialog(); if (changed.has("zoomedImage")) this.syncImageZoomDialog();
@@ -647,6 +660,7 @@ export class ChatView extends LitElement {
if (this.pendingAsk === undefined) return null; if (this.pendingAsk === undefined) return null;
return html` return html`
<ask-user-card <ask-user-card
data-scroll-anchor-id=${`ask:${this.pendingAsk.askId}`}
.ask=${this.pendingAsk} .ask=${this.pendingAsk}
.draftSessionId=${this.askDraftSessionId} .draftSessionId=${this.askDraftSessionId}
.onSubmit=${this.onSubmitAsk} .onSubmit=${this.onSubmitAsk}
@@ -1011,6 +1025,33 @@ export class ChatView extends LitElement {
}); });
} }
private isNewPendingAsk(previous: unknown): boolean {
return this.pendingAsk !== undefined
&& (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId);
}
private scrollToOpenAsk(): void {
if (this.scrollToOpenAskFrame !== undefined) return;
if (this.scrollToBottomFrame !== undefined) {
cancelAnimationFrame(this.scrollToBottomFrame);
this.scrollToBottomFrame = undefined;
}
this.scrollToOpenAskFrame = requestAnimationFrame(() => {
this.scrollToOpenAskFrame = undefined;
this.withSuppressedScrollSave(() => { this.alignOpenAskToTop(); });
});
}
private alignOpenAskToTop(): boolean {
const chat = this.chat;
const card = this.renderRoot.querySelector<HTMLElement>(".chat > ask-user-card");
if (chat === undefined || card === null) return false;
chat.scrollTop += card.getBoundingClientRect().top - chat.getBoundingClientRect().top;
this.syncScrollMetrics();
this.pinnedToBottom = this.isNearBottom();
return true;
}
restoreScrollPosition() { restoreScrollPosition() {
const sessionId = this.sessionId; const sessionId = this.sessionId;
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
@@ -1018,6 +1059,7 @@ export class ChatView extends LitElement {
this.restoreScrollFrame = undefined; this.restoreScrollFrame = undefined;
if (this.sessionId !== sessionId) return; if (this.sessionId !== sessionId) return;
this.withSuppressedScrollSave(() => { this.withSuppressedScrollSave(() => {
if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return;
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() }); const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
this.handleScrollRestoreResult(sessionId, result); this.handleScrollRestoreResult(sessionId, result);
}); });
+1 -1
View File
@@ -365,7 +365,7 @@ export const chatStyles = css`
.notification-header { gap: 4px; padding-inline: 8px; } .notification-header { gap: 4px; padding-inline: 8px; }
.notification-list { padding-inline: 8px; } .notification-list { padding-inline: 8px; }
} }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; } .chat { --pi-chat-sticky-top: -26px; height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; } .scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); } .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); } .activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
+1 -1
View File
@@ -118,7 +118,7 @@ describe("notification socket guards", () => {
const ask = { const ask = {
askId: "ask-1", askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z", askedAt: "2026-07-20T00:00:00.000Z",
questions: [{ id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }], questions: [{ id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true }],
}; };
expect(parseSessionSocketEvent({ type: "ask.opened", ask })).toEqual({ type: "ask.opened", ask }); expect(parseSessionSocketEvent({ type: "ask.opened", ask })).toEqual({ type: "ask.opened", ask });
+6 -4
View File
@@ -29,7 +29,7 @@ function toolOverStore(askIds: string[] = ["ask-1", "ask-2"]) {
const twoQuestions = { const twoQuestions = {
questions: [ questions: [
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] }, { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] },
{ id: "why", question: "Why?", options: [], allowOther: true }, { id: "why", question: "Why?", options: [] },
], ],
}; };
@@ -53,6 +53,7 @@ describe("createAskUserToolDefinition", () => {
required: ["questions"], required: ["questions"],
properties: { questions: { type: "array", minItems: 1, maxItems: 20 } }, properties: { questions: { type: "array", minItems: 1, maxItems: 20 } },
}); });
expect(tool.parameters).not.toHaveProperty("properties.questions.items.properties.allowOther");
}); });
it("opens the ask for the calling session and terminates the run instead of awaiting the user", async () => { it("opens the ask for the calling session and terminates the run instead of awaiting the user", async () => {
@@ -63,7 +64,7 @@ describe("createAskUserToolDefinition", () => {
expect(open).toHaveBeenCalledWith({ expect(open).toHaveBeenCalledWith({
sessionId: "session-1", sessionId: "session-1",
questions: [ questions: [
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] }, { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }], allowOther: true },
{ id: "why", question: "Why?", options: [], allowOther: true }, { id: "why", question: "Why?", options: [], allowOther: true },
], ],
}); });
@@ -75,7 +76,7 @@ describe("createAskUserToolDefinition", () => {
it("defaults a question without options to an empty option list so free text alone is expressible", async () => { it("defaults a question without options to an empty option list so free text alone is expressible", async () => {
const { open, tool } = toolOverStore(); const { open, tool } = toolOverStore();
await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?", allowOther: true }] }, undefined, undefined, ctxFor("session-1")); await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?" }] }, undefined, undefined, ctxFor("session-1"));
expect(open).toHaveBeenCalledWith({ expect(open).toHaveBeenCalledWith({
sessionId: "session-1", sessionId: "session-1",
@@ -83,7 +84,7 @@ describe("createAskUserToolDefinition", () => {
}); });
}); });
it("forwards per-question detail, option detail, and multi-select without inventing defaults", async () => { it("adds custom answers while preserving detail, option detail, and multi-select", async () => {
const { open, tool } = toolOverStore(); const { open, tool } = toolOverStore();
await tool.execute("call-rich", { await tool.execute("call-rich", {
@@ -103,6 +104,7 @@ describe("createAskUserToolDefinition", () => {
question: "Which targets?", question: "Which targets?",
detail: "Pick every platform we should build for.", detail: "Pick every platform we should build for.",
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }], options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
allowOther: true,
multiple: true, multiple: true,
}], }],
}); });
+6 -7
View File
@@ -17,7 +17,7 @@ export interface AskUserInvocation {
} }
export interface AskUserToolDeps { export interface AskUserToolDeps {
/** Registers the ask as the session's open one; rejects question sets the user could not answer. */ /** Registers the ask as the session's open one; rejects malformed question sets. */
open(input: AskUserInvocation): Promise<PendingAskOpenResult>; open(input: AskUserInvocation): Promise<PendingAskOpenResult>;
} }
@@ -53,10 +53,7 @@ const AskUserQuestionParams = Type.Object({
})), })),
options: Type.Optional(Type.Array(AskUserOptionParams, { options: Type.Optional(Type.Array(AskUserOptionParams, {
maxItems: ASK_USER_OPTION_LIMIT, maxItems: ASK_USER_OPTION_LIMIT,
description: "Options to choose from. Omit only when free text is the whole answer, and then set allowOther.", description: "Options to choose from. Omit when free text is the whole answer; the browser always adds a Custom choice.",
})),
allowOther: Type.Optional(Type.Boolean({
description: "Offer a free-text field alongside the options.",
})), })),
multiple: Type.Optional(Type.Boolean({ multiple: Type.Optional(Type.Boolean({
description: "Allow several options at once. Default: one answer per question.", description: "Allow several options at once. Default: one answer per question.",
@@ -73,13 +70,15 @@ const AskUserParams = Type.Object({
/** Shapes one schema question into the domain question; the store owns validation. */ /** Shapes one schema question into the domain question; the store owns validation. */
function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion { function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion {
const { detail, options, allowOther, multiple } = param; const { detail, options, multiple } = param;
return { return {
id: param.id, id: param.id,
question: param.question, question: param.question,
...(detail === undefined ? {} : { detail }), ...(detail === undefined ? {} : { detail }),
options: (options ?? []).map(toOption), options: (options ?? []).map(toOption),
...(allowOther === undefined ? {} : { allowOther }), // Keep the compatibility marker on the daemon wire even though the model no
// longer chooses whether a question accepts a custom answer.
allowOther: true,
...(multiple === undefined ? {} : { multiple }), ...(multiple === undefined ? {} : { multiple }),
}; };
} }
+16 -14
View File
@@ -45,7 +45,7 @@ describe("PendingAskStore validation", () => {
question: "Which database?", question: "Which database?",
detail: "Only the primary store matters here.", detail: "Only the primary store matters here.",
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }], options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
allowOther: true, allowOther: false,
multiple: false, multiple: false,
}, },
], ],
@@ -79,7 +79,6 @@ describe("PendingAskStore validation", () => {
expect(reject([question("q1"), question("q1")])).toThrow(/Duplicate question id q1/); expect(reject([question("q1"), question("q1")])).toThrow(/Duplicate question id q1/);
expect(reject([question(" ")])).toThrow(/question id must not be empty/); expect(reject([question(" ")])).toThrow(/question id must not be empty/);
expect(reject([question("q1", { question: " " })])).toThrow(/text of question q1 must not be empty/); expect(reject([question("q1", { question: " " })])).toThrow(/text of question q1 must not be empty/);
expect(reject([question("q1", { options: [] })])).toThrow(/must offer options or allow other text/);
expect(reject([question("q1", { options: [{ value: "a", label: "A" }, { value: "a", label: "Again" }] })])) expect(reject([question("q1", { options: [{ value: "a", label: "A" }, { value: "a", label: "Again" }] })]))
.toThrow(/Duplicate option value a in question q1/); .toThrow(/Duplicate option value a in question q1/);
expect(reject([question("q1", { options: [{ value: "a", label: " " }] })])) expect(reject([question("q1", { options: [{ value: "a", label: " " }] })]))
@@ -90,9 +89,9 @@ describe("PendingAskStore validation", () => {
expect(store.pendingAsk(sessionId)).toBeUndefined(); expect(store.pendingAsk(sessionId)).toBeUndefined();
}); });
it("accepts a question that only offers free text", () => { it("accepts an optionless question and adds the custom-answer compatibility marker", () => {
const store = testStore(); const store = testStore();
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [], allowOther: true })] }); const { ask } = store.open({ sessionId, questions: [question("q1", { options: [] })] });
expect(ask.questions[0]).toEqual({ id: "q1", question: "Question q1?", options: [], allowOther: true }); expect(ask.questions[0]).toEqual({ id: "q1", question: "Question q1?", options: [], allowOther: true });
}); });
}); });
@@ -143,7 +142,7 @@ describe("PendingAskStore submit", () => {
it("treats an empty answer as leaving the question untouched", () => { it("treats an empty answer as leaving the question untouched", () => {
const store = testStore(); const store = testStore();
const { ask } = store.open({ sessionId, questions: [question("q1"), question("q2", { allowOther: true })] }); const { ask } = store.open({ sessionId, questions: [question("q1"), question("q2")] });
const result = store.submit(sessionId, ask.askId, { const result = store.submit(sessionId, ask.askId, {
answers: [{ id: "q1", values: [] }, { id: "q2", values: [], otherText: " " }], answers: [{ id: "q1", values: [] }, { id: "q2", values: [], otherText: " " }],
@@ -161,11 +160,11 @@ describe("PendingAskStore submit", () => {
expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId); expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId);
}); });
it("accepts several values and coexisting other text for a multi-select question", () => { it("accepts several values and coexisting custom text for a multi-select question", () => {
const store = testStore(); const store = testStore();
const { ask } = store.open({ const { ask } = store.open({
sessionId, sessionId,
questions: [question("q1", { multiple: true, allowOther: true })], questions: [question("q1", { multiple: true })],
}); });
const result = store.submit(sessionId, ask.askId, { const result = store.submit(sessionId, ask.askId, {
@@ -181,17 +180,20 @@ describe("PendingAskStore submit", () => {
}); });
}); });
it("rejects other text for a question that does not allow it", () => { it("accepts custom text for every question", () => {
const store = testStore(); const store = testStore();
const { ask } = openTwoQuestions(store); const { ask } = openTwoQuestions(store);
expect(() => store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] })) const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] });
.toThrow(/Question q1 does not accept other text/);
expect(result).toMatchObject({ status: "closed", outcome: { answeredCount: 1 } });
if (result.status !== "closed") throw new Error("expected the ask to close");
expect(result.outcome.questions[0]).toMatchObject({ answered: true, values: [], otherText: "custom" });
}); });
it("answers a free-text-only question with other text alone", () => { it("answers a free-text-only question with custom text alone", () => {
const store = testStore(); const store = testStore();
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [], allowOther: true })] }); const { ask } = store.open({ sessionId, questions: [question("q1", { options: [] })] });
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "a note" }] }); const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "a note" }] });
@@ -294,7 +296,7 @@ describe("ask outcome rendering", () => {
const store = testStore(); const store = testStore();
const { ask } = store.open({ const { ask } = store.open({
sessionId, sessionId,
questions: [question("q1"), question("q2", { allowOther: true }), question("q3")], questions: [question("q1"), question("q2"), question("q3")],
}); });
const result = store.submit(sessionId, ask.askId, { const result = store.submit(sessionId, ask.askId, {
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: [], otherText: "something else" }], answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: [], otherText: "something else" }],
@@ -307,7 +309,7 @@ describe("ask outcome rendering", () => {
"- q1: Question q1?", "- q1: Question q1?",
" Answered: selected yes", " Answered: selected yes",
"- q2: Question q2?", "- q2: Question q2?",
` Answered: other: "something else"`, ` Answered: custom: "something else"`,
"- q3: Question q3?", "- q3: Question q3?",
" Unanswered.", " Unanswered.",
"", "",
+5 -10
View File
@@ -47,7 +47,7 @@ export type PendingAskCloseResult =
| { status: "closed"; outcome: AskUserOutcome } | { status: "closed"; outcome: AskUserOutcome }
| { status: "stale" }; | { status: "stale" };
/** Rejected input: the model asked something unanswerable, or an answer does not fit its question. */ /** Rejected input: a question set is malformed, or an answer does not fit its question. */
export class PendingAskValidationError extends Error { export class PendingAskValidationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@@ -181,7 +181,7 @@ function questionLines(record: AskUserQuestionRecord): string[] {
const header = `- ${record.question.id}: ${record.question.question}`; const header = `- ${record.question.id}: ${record.question.question}`;
if (!record.answered) return [header, " Unanswered."]; if (!record.answered) return [header, " Unanswered."];
const parts = [...record.values.map((value) => `selected ${value}`)]; const parts = [...record.values.map((value) => `selected ${value}`)];
if (record.otherText !== undefined) parts.push(`other: ${JSON.stringify(record.otherText)}`); if (record.otherText !== undefined) parts.push(`custom: ${JSON.stringify(record.otherText)}`);
return [header, ` Answered: ${parts.join("; ")}`]; return [header, ` Answered: ${parts.join("; ")}`];
} }
@@ -240,12 +240,6 @@ function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestio
if (question.options.length > ASK_USER_OPTION_LIMIT) { if (question.options.length > ASK_USER_OPTION_LIMIT) {
throw new PendingAskValidationError(`Question ${id} must not offer more than ${ASK_USER_OPTION_LIMIT.toString()} options`); throw new PendingAskValidationError(`Question ${id} must not offer more than ${ASK_USER_OPTION_LIMIT.toString()} options`);
} }
const allowOther = question.allowOther === true;
// A question with neither options nor a free-text field cannot be answered at
// all, which would make its "unanswered" report meaningless.
if (question.options.length === 0 && !allowOther) {
throw new PendingAskValidationError(`Question ${id} must offer options or allow other text`);
}
const seenValues = new Set<string>(); const seenValues = new Set<string>();
const options = question.options.map((option) => { const options = question.options.map((option) => {
const value = requireId(option.value, `option value of question ${id}`); const value = requireId(option.value, `option value of question ${id}`);
@@ -259,7 +253,9 @@ function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestio
question: requireText(question.question, `text of question ${id}`), question: requireText(question.question, `text of question ${id}`),
...(detail === undefined ? {} : { detail: requireText(detail, `detail of question ${id}`) }), ...(detail === undefined ? {} : { detail: requireText(detail, `detail of question ${id}`) }),
options, options,
...(allowOther ? { allowOther: true } : {}), // Every question accepts a custom answer. Retain the marker so older web
// clients also expose the field when connected to this daemon.
allowOther: true,
...(question.multiple === true ? { multiple: true } : {}), ...(question.multiple === true ? { multiple: true } : {}),
}; };
} }
@@ -307,7 +303,6 @@ function validateAnswer(question: AskUserQuestion, answer: AskUserAnswer): AskUs
function normalizeOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined { function normalizeOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined {
if (otherText === undefined) return undefined; if (otherText === undefined) return undefined;
if (question.allowOther !== true) throw new PendingAskValidationError(`Question ${question.id} does not accept other text`);
if (otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) { if (otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) {
throw new PendingAskValidationError(`Other text of question ${question.id} exceeds its length limit`); throw new PendingAskValidationError(`Other text of question ${question.id} exceeds its length limit`);
} }
@@ -7,7 +7,7 @@ import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, runtimeCreato
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
const ACTIVE_SESSION_ID = "session-1"; const ACTIVE_SESSION_ID = "session-1";
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }]; const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true }];
/** /**
* Service over a clocked store with sequential ask ids, so asks are named * Service over a clocked store with sequential ask ids, so asks are named
@@ -98,13 +98,14 @@ describe("PiSessionService.openAsk", () => {
await service.dispose(); await service.dispose();
}); });
it("rejects an unanswerable question set without opening it", async () => { it("opens an optionless question with a custom answer", async () => {
const { service, store, events } = askService(); const { service, store, events } = askService();
await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] })) const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "Anything else?", options: [] }] });
.rejects.toThrow(PendingAskValidationError);
expect(store.pendingAsk("session-1")).toBeUndefined(); expect(result.ask.questions).toEqual([{ id: "empty", question: "Anything else?", options: [], allowOther: true }]);
expect(askEvents(events)).toEqual([]); expect(store.pendingAsk("session-1")).toEqual(result.ask);
expect(askEvents(events)).toHaveLength(1);
await service.dispose(); await service.dispose();
}); });
@@ -163,11 +164,16 @@ describe("PiSessionService ask status projection", () => {
}); });
describe("PiSessionService.submitAsk", () => { describe("PiSessionService.submitAsk", () => {
it("delivers the answers as a follow-up custom message that wakes the session", async () => { it("delivers a custom answer as a follow-up message that wakes the session", async () => {
const { service, store, events, fake } = askService({ withActiveSession: true }); const { service, store, events, fake } = askService({ withActiveSession: true });
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions }); await service.openAsk({
sessionId: ACTIVE_SESSION_ID,
questions: [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: false }],
});
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] }); const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", {
answers: [{ id: "db", values: [], otherText: "DuckDB" }],
});
expect(response).toMatchObject({ result: "closed", outcome: { askId: "ask-1", reason: "submitted", answeredCount: 1, unansweredIds: [] } }); expect(response).toMatchObject({ result: "closed", outcome: { askId: "ask-1", reason: "submitted", answeredCount: 1, unansweredIds: [] } });
expect(response.sessionStatus.sessionId).toBe(ACTIVE_SESSION_ID); expect(response.sessionStatus.sessionId).toBe(ACTIVE_SESSION_ID);
@@ -176,6 +182,7 @@ describe("PiSessionService.submitAsk", () => {
expect(delivered?.message.customType).toBe(ASK_USER_ANSWERS_CUSTOM_TYPE); expect(delivered?.message.customType).toBe(ASK_USER_ANSWERS_CUSTOM_TYPE);
expect(delivered?.message.display).toBe(true); expect(delivered?.message.display).toBe(true);
expect(delivered?.message.content).toContain("The user submitted answers to your questions."); expect(delivered?.message.content).toContain("The user submitted answers to your questions.");
expect(delivered?.message.content).toContain(`custom: "DuckDB"`);
expect(delivered?.message.content).toContain("Answered 1 of 1"); expect(delivered?.message.content).toContain("Answered 1 of 1");
expect(delivered?.message.details).toMatchObject({ askId: "ask-1", reason: "submitted" }); expect(delivered?.message.details).toMatchObject({ askId: "ask-1", reason: "submitted" });
expect(delivered?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" }); expect(delivered?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
+7 -4
View File
@@ -462,7 +462,7 @@ export const ASK_USER_OPTION_LIMIT = 12;
export const ASK_USER_ID_MAX_LENGTH = 128; export const ASK_USER_ID_MAX_LENGTH = 128;
/** Length bound for model-authored prose: questions, details, and option labels. */ /** Length bound for model-authored prose: questions, details, and option labels. */
export const ASK_USER_TEXT_MAX_LENGTH = 1_000; export const ASK_USER_TEXT_MAX_LENGTH = 1_000;
/** Length bound for the free text a user types into an "other" field. */ /** Length bound for the free text a user types as a custom answer. */
export const ASK_USER_OTHER_TEXT_MAX_LENGTH = 4_000; export const ASK_USER_OTHER_TEXT_MAX_LENGTH = 4_000;
/** One selectable option of an {@link AskUserQuestion}. */ /** One selectable option of an {@link AskUserQuestion}. */
@@ -489,7 +489,10 @@ export interface AskUserQuestion {
detail?: string; detail?: string;
/** Offered options; may be empty when only free text makes sense. */ /** Offered options; may be empty when only free text makes sense. */
options: AskUserQuestionOption[]; options: AskUserQuestionOption[];
/** When true, a labelled free-text field is offered alongside the options. */ /**
* Compatibility marker for older clients. Canonical questions set this to
* true because every question offers a custom free-text answer.
*/
allowOther?: boolean; allowOther?: boolean;
/** When true, several options may be selected at once. */ /** When true, several options may be selected at once. */
multiple?: boolean; multiple?: boolean;
@@ -519,7 +522,7 @@ export interface AskUserAnswer {
id: string; id: string;
/** Selected {@link AskUserQuestionOption.value} entries; several only when the question allows it. */ /** Selected {@link AskUserQuestionOption.value} entries; several only when the question allows it. */
values: string[]; values: string[];
/** Free text typed into the "other" field, when the question allows it. */ /** Free text typed as the question's custom answer. */
otherText?: string; otherText?: string;
} }
@@ -534,7 +537,7 @@ export interface AskUserSubmission {
*/ */
export interface AskUserQuestionRecord { export interface AskUserQuestionRecord {
question: AskUserQuestion; question: AskUserQuestion;
/** True when at least one option was selected or "other" text was given. */ /** True when at least one option was selected or custom text was given. */
answered: boolean; answered: boolean;
values: string[]; values: string[];
otherText?: string; otherText?: string;