Archived
fix(ui): streamline ask_user question forms
This commit is contained in:
@@ -723,14 +723,14 @@ describe("API parsers", () => {
|
||||
})).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() });
|
||||
|
||||
expect(parsed.pendingAsk).toEqual({
|
||||
askId: "ask-1",
|
||||
askedAt: "2026-07-20T00:00:00.000Z",
|
||||
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 },
|
||||
],
|
||||
});
|
||||
@@ -740,13 +740,15 @@ describe("API parsers", () => {
|
||||
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 first = ask.questions[0];
|
||||
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, 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: "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",
|
||||
askedAt: "2026-07-20T00:00:00.000Z",
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -218,17 +218,16 @@ function parseAskUserQuestion(value: unknown): AskUserQuestion {
|
||||
const record = requireRecord(value);
|
||||
const options = boundedArrayOf(record["options"], parseAskUserQuestionOption, ASK_USER_OPTION_LIMIT, "options");
|
||||
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");
|
||||
// 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 {
|
||||
id: requireBoundedNonEmptyString(record, "id", ASK_USER_ID_MAX_LENGTH),
|
||||
question: requireBoundedNonEmptyString(record, "question", ASK_USER_TEXT_MAX_LENGTH),
|
||||
...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)),
|
||||
options,
|
||||
...(allowOther === undefined ? {} : { allowOther }),
|
||||
allowOther: true,
|
||||
...(multiple === undefined ? {} : { multiple }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,11 +50,10 @@ const singleSelect: AskUserQuestion = {
|
||||
options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }],
|
||||
};
|
||||
|
||||
const multiSelectWithOther: AskUserQuestion = {
|
||||
const multiSelect: AskUserQuestion = {
|
||||
id: "q2",
|
||||
question: "Which extras?",
|
||||
options: [{ value: "metrics", label: "Metrics" }, { value: "tracing", label: "Tracing" }],
|
||||
allowOther: true,
|
||||
multiple: true,
|
||||
};
|
||||
|
||||
@@ -62,10 +61,9 @@ const freeTextOnly: AskUserQuestion = {
|
||||
id: "q3",
|
||||
question: "Anything else?",
|
||||
options: [],
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
const questions = [singleSelect, multiSelectWithOther, freeTextOnly];
|
||||
const questions = [singleSelect, multiSelect, freeTextOnly];
|
||||
|
||||
afterEach(() => {
|
||||
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"]);
|
||||
});
|
||||
|
||||
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" } };
|
||||
|
||||
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", () => {
|
||||
const singleWithOther: AskUserQuestion = { ...singleSelect, allowOther: true };
|
||||
|
||||
expect(toSubmission([singleWithOther], { q1: { values: [], otherText: "neither" } })).toEqual({
|
||||
it("keeps custom text for every single-select question when no option is selected", () => {
|
||||
expect(toSubmission([singleSelect], { q1: { values: [], otherText: "neither" } })).toEqual({
|
||||
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) } };
|
||||
|
||||
expect(toSubmission(questions, answers).answers[0]?.otherText).toHaveLength(ASK_USER_OTHER_TEXT_MAX_LENGTH);
|
||||
|
||||
@@ -120,7 +120,7 @@ function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | u
|
||||
if (answer === undefined) return undefined;
|
||||
const offered = new Set(question.options.map((option) => option.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) {
|
||||
const single = values[0];
|
||||
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 }) };
|
||||
}
|
||||
|
||||
function normalizedOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined {
|
||||
if (otherText === undefined || question.allowOther !== true) return undefined;
|
||||
function normalizedOtherText(otherText: string | undefined): string | undefined {
|
||||
if (otherText === undefined) return undefined;
|
||||
const trimmed = otherText.trim().slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH);
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ const askUserOutcome: AskUserOutcome = {
|
||||
closedAt: "2026-07-20T10:05:00.000Z",
|
||||
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,
|
||||
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,
|
||||
values: [],
|
||||
},
|
||||
|
||||
@@ -12,12 +12,12 @@ const askUserOutcome: AskUserOutcome = {
|
||||
closedAt: "2026-07-20T10:05:00.000Z",
|
||||
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,
|
||||
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,
|
||||
values: [],
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ask-user-card live form", () => {
|
||||
expect(code.name).toBe(vim.name);
|
||||
expect(web.type).toBe("checkbox");
|
||||
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
|
||||
// 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(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 () => {
|
||||
@@ -58,7 +58,7 @@ describe("ask-user-card live form", () => {
|
||||
inputWithValue(root, "desktop").click();
|
||||
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();
|
||||
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 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);
|
||||
const root = renderRoot(card);
|
||||
|
||||
@@ -79,9 +79,9 @@ describe("ask-user-card live form", () => {
|
||||
await card.updateComplete;
|
||||
await Promise.resolve();
|
||||
|
||||
const textarea = requiredElement(root.querySelector("textarea"), "other textarea");
|
||||
const label = requiredElement(textarea.closest("label"), "other label");
|
||||
expect(label.textContent).toContain("Your answer for “Pick the stack”");
|
||||
const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
|
||||
const label = requiredElement(textarea.closest("label"), "custom label");
|
||||
expect(label.textContent).toContain("Custom answer");
|
||||
expect(root.activeElement).toBe(textarea);
|
||||
|
||||
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 () => {
|
||||
const onSubmit = vi.fn<AskUserSubmitCallback>();
|
||||
const card = await mountOpenAsk(openAsk([
|
||||
question("editor", "Choose an editor", [option("vim", "Vim")]),
|
||||
question("deploy", "Choose a deployment target", [option("cloud", "Cloud")]),
|
||||
question("notes", "Add implementation notes", [], { allowOther: true }),
|
||||
question("notes", "Add implementation notes", []),
|
||||
]), onSubmit);
|
||||
const root = renderRoot(card);
|
||||
|
||||
@@ -139,7 +160,7 @@ describe("ask-user-card record mode", () => {
|
||||
closedAt: "2026-07-20T10:05:00.000Z",
|
||||
questions: [
|
||||
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")])),
|
||||
],
|
||||
answeredCount: 0,
|
||||
@@ -154,7 +175,7 @@ describe("ask-user-card record mode", () => {
|
||||
const root = renderRoot(card);
|
||||
|
||||
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("It keeps the feedback loop short.");
|
||||
expect(root.textContent).toContain("Draft answer · not sent");
|
||||
@@ -200,7 +221,7 @@ function question(
|
||||
id: string,
|
||||
text: string,
|
||||
options: AskUserQuestion["options"],
|
||||
settings: { detail?: string; multiple?: boolean; allowOther?: boolean } = {},
|
||||
settings: { detail?: string; multiple?: boolean } = {},
|
||||
): AskUserQuestion {
|
||||
return {
|
||||
id,
|
||||
@@ -208,7 +229,6 @@ function question(
|
||||
options,
|
||||
...(settings.detail === undefined ? {} : { detail: settings.detail }),
|
||||
...(settings.multiple === undefined ? {} : { multiple: settings.multiple }),
|
||||
...(settings.allowOther === undefined ? {} : { allowOther: settings.allowOther }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -70,20 +70,16 @@ export class AskUserCard extends LitElement {
|
||||
return html`
|
||||
<article class="card open-card" aria-labelledby="ask-user-heading">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Pi needs your input</p>
|
||||
<h2 id="ask-user-heading">Questions from the model</h2>
|
||||
</div>
|
||||
<span class="question-total">${ask.questions.length} ${ask.questions.length === 1 ? "question" : "questions"}</span>
|
||||
<h2 id="ask-user-heading">Questions</h2>
|
||||
<span class="header-status" role="status" aria-live="polite" aria-atomic="true">
|
||||
${count} of ${ask.questions.length} answered
|
||||
</span>
|
||||
</header>
|
||||
<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))}
|
||||
</div>
|
||||
<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.renderPartialSubmitConfirmation(ask, unanswered)
|
||||
: html`
|
||||
@@ -101,7 +97,8 @@ export class AskUserCard extends LitElement {
|
||||
const answer = this.answers[question.id];
|
||||
const answered = answeredCount([question], this.answers) === 1;
|
||||
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";
|
||||
return html`
|
||||
<fieldset
|
||||
@@ -113,7 +110,6 @@ export class AskUserCard extends LitElement {
|
||||
<legend>
|
||||
<span class="question-number">${String(index + 1)}.</span>
|
||||
<span>${question.question}</span>
|
||||
<span class=${`answer-marker${answered ? " complete" : ""}`} aria-hidden="true">${answered ? "Answered" : "Unanswered"}</span>
|
||||
</legend>
|
||||
${question.detail === undefined ? null : html`<p class="question-detail" id=${detailId}>${question.detail}</p>`}
|
||||
<div class="options">
|
||||
@@ -132,29 +128,29 @@ export class AskUserCard extends LitElement {
|
||||
</span>
|
||||
</label>
|
||||
`)}
|
||||
${question.allowOther === true ? html`
|
||||
${freeTextOnly ? null : html`
|
||||
<label class="option other-option">
|
||||
<input
|
||||
type=${inputType}
|
||||
name=${this.questionGroupName(ask, question)}
|
||||
value="__pi_web_other__"
|
||||
.checked=${otherSelected}
|
||||
.checked=${customSelected}
|
||||
@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>
|
||||
`}
|
||||
${customSelected ? html`
|
||||
<label class="other-answer" for=${this.otherInputId(index)}>
|
||||
<span>Custom answer</span>
|
||||
<textarea
|
||||
id=${this.otherInputId(index)}
|
||||
rows="3"
|
||||
maxlength=${String(ASK_USER_OTHER_TEXT_MAX_LENGTH)}
|
||||
.value=${answer?.otherText ?? ""}
|
||||
@input=${(event: Event) => { this.changeOtherText(question, event); }}
|
||||
></textarea>
|
||||
</label>
|
||||
${otherSelected ? html`
|
||||
<label class="other-answer" for=${this.otherInputId(index)}>
|
||||
<span>Your answer for “${question.question}”</span>
|
||||
<textarea
|
||||
id=${this.otherInputId(index)}
|
||||
rows="3"
|
||||
maxlength=${String(ASK_USER_OTHER_TEXT_MAX_LENGTH)}
|
||||
.value=${answer?.otherText ?? ""}
|
||||
@input=${(event: Event) => { this.changeOtherText(question, event); }}
|
||||
></textarea>
|
||||
</label>
|
||||
` : null}
|
||||
` : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -186,16 +182,13 @@ export class AskUserCard extends LitElement {
|
||||
const recordLabel = outcome.reason === "submitted"
|
||||
? "Answers sent"
|
||||
: outcome.reason === "superseded"
|
||||
? "Questions superseded"
|
||||
: "Questions cancelled";
|
||||
? "Superseded"
|
||||
: "Cancelled";
|
||||
return html`
|
||||
<article class="card record-card" aria-labelledby="ask-user-record-heading">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Question record</p>
|
||||
<h2 id="ask-user-record-heading">${recordLabel}</h2>
|
||||
</div>
|
||||
<span class=${`record-reason ${outcome.reason}`}>${outcome.reason}</span>
|
||||
<h2 id="ask-user-record-heading">Questions</h2>
|
||||
<span class=${`header-status ${outcome.reason}`}>${recordLabel}</span>
|
||||
</header>
|
||||
<p class="record-summary">
|
||||
${outcome.reason === "superseded"
|
||||
@@ -223,7 +216,7 @@ export class AskUserCard extends LitElement {
|
||||
: html`
|
||||
<ul class="record-answers">
|
||||
${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>
|
||||
${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 {
|
||||
if (question.allowOther !== true || answer?.otherText === undefined) return false;
|
||||
if (answer?.otherText === undefined) return false;
|
||||
return question.multiple === true || answer.values.length === 0;
|
||||
}
|
||||
|
||||
@@ -400,93 +393,69 @@ export class AskUserCard extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: min(100%, 780px);
|
||||
margin: 18px auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
color: var(--pi-text);
|
||||
font: 14px system-ui, sans-serif;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pi-border);
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--pi-surface);
|
||||
box-shadow: 0 10px 30px var(--pi-shadow-soft);
|
||||
}
|
||||
.card-header {
|
||||
position: sticky;
|
||||
top: var(--pi-chat-sticky-top, 0px);
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--pi-border-muted);
|
||||
background: var(--pi-bg-overlay);
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 3px;
|
||||
color: var(--pi-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
min-height: 22px;
|
||||
padding: 7px 10px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent);
|
||||
border-radius: 9px 9px 0 0;
|
||||
background: var(--pi-surface);
|
||||
box-shadow: 0 8px 18px var(--pi-shadow-soft);
|
||||
}
|
||||
h2, h3, p { margin-top: 0; }
|
||||
h2 { margin-bottom: 0; font-size: 16px; line-height: 1.3; }
|
||||
.question-total, .record-reason {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--pi-border-muted);
|
||||
border-radius: 999px;
|
||||
color: var(--pi-muted);
|
||||
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;
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
color: var(--pi-accent);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--pi-border-muted);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: var(--pi-bg);
|
||||
border: 0;
|
||||
border-top: 1px solid var(--pi-border-muted);
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
fieldset.question.answered { border-color: var(--pi-success-border); }
|
||||
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
||||
fieldset.question:first-child { border-top: 0; }
|
||||
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 5px;
|
||||
color: var(--pi-text);
|
||||
padding: 0 2px;
|
||||
padding: 0;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.question-number { color: var(--pi-muted); }
|
||||
.answer-marker {
|
||||
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); }
|
||||
fieldset.question.answered .question-number { color: var(--pi-success); }
|
||||
.question-detail {
|
||||
margin: 4px 0 10px;
|
||||
color: var(--pi-muted);
|
||||
@@ -511,7 +480,8 @@ export class AskUserCard extends LitElement {
|
||||
.option-copy { min-width: 0; display: grid; gap: 2px; }
|
||||
.option-label { 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 {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -526,20 +496,13 @@ export class AskUserCard extends LitElement {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.form-footer {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--pi-border);
|
||||
background: var(--pi-bg-overlay);
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 -8px 18px var(--pi-shadow-soft);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--pi-border-muted);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.progress { flex: 0 0 auto; color: var(--pi-muted); font-size: 12px; font-weight: 650; }
|
||||
button {
|
||||
border: 1px solid var(--pi-border);
|
||||
border-radius: 8px;
|
||||
@@ -566,24 +529,22 @@ export class AskUserCard extends LitElement {
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.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-questions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 14px; }
|
||||
.record-question { min-width: 0; border: 1px solid var(--pi-border-muted); border-radius: 10px; background: var(--pi-bg); padding: 12px; }
|
||||
.record-summary { margin: 0; color: var(--pi-muted); padding: 12px 16px; font-size: 12px; }
|
||||
.record-questions { display: grid; }
|
||||
.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-answers { display: grid; gap: 4px; margin: 0; padding-left: 22px; line-height: 1.4; }
|
||||
.other-record-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.unanswered-record { margin: 0; color: var(--pi-muted); font-style: italic; }
|
||||
.draft-note { margin: 7px 0 0; color: var(--pi-warning); font-size: 11px; }
|
||||
@container (max-width: 580px) {
|
||||
:host { margin: 12px 0; }
|
||||
.questions-grid, .record-questions { grid-template-columns: minmax(0, 1fr); padding: 10px; }
|
||||
.card-header { padding: 12px; }
|
||||
.form-footer { align-items: stretch; flex-direction: column; }
|
||||
fieldset.question, .record-question { padding: 14px 12px; }
|
||||
.record-summary { padding-inline: 12px; }
|
||||
.form-footer { align-items: stretch; flex-direction: column; padding: 12px; }
|
||||
.partial-confirmation { align-items: stretch; flex-direction: column; }
|
||||
.confirmation-actions { justify-content: flex-end; }
|
||||
.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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => {
|
||||
const outcome: AskUserOutcome = {
|
||||
|
||||
@@ -221,6 +221,7 @@ export class ChatView extends LitElement {
|
||||
private suppressLoadMoreRequests = false;
|
||||
private loadMoreCheckFrame: number | undefined;
|
||||
private scrollToBottomFrame: number | undefined;
|
||||
private scrollToOpenAskFrame: number | undefined;
|
||||
private conversationRailFrame: number | undefined;
|
||||
private groupedMessagesInput?: ChatLine[];
|
||||
private groupedMessagesStart = 0;
|
||||
@@ -279,6 +280,10 @@ export class ChatView extends LitElement {
|
||||
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
||||
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
|
||||
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
|
||||
if (this.scrollToOpenAskFrame !== undefined) {
|
||||
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
||||
this.scrollToOpenAskFrame = undefined;
|
||||
}
|
||||
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
|
||||
window.removeEventListener("resize", this.onViewportResize);
|
||||
window.removeEventListener("pagehide", this.onPageHide);
|
||||
@@ -305,6 +310,10 @@ export class ChatView extends LitElement {
|
||||
cancelAnimationFrame(this.restoreScrollFrame);
|
||||
this.restoreScrollFrame = undefined;
|
||||
}
|
||||
if (this.scrollToOpenAskFrame !== undefined) {
|
||||
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
||||
this.scrollToOpenAskFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
||||
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("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("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
|
||||
if (changed.has("zoomedImage")) this.syncImageZoomDialog();
|
||||
@@ -647,6 +660,7 @@ export class ChatView extends LitElement {
|
||||
if (this.pendingAsk === undefined) return null;
|
||||
return html`
|
||||
<ask-user-card
|
||||
data-scroll-anchor-id=${`ask:${this.pendingAsk.askId}`}
|
||||
.ask=${this.pendingAsk}
|
||||
.draftSessionId=${this.askDraftSessionId}
|
||||
.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() {
|
||||
const sessionId = this.sessionId;
|
||||
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
||||
@@ -1018,6 +1059,7 @@ export class ChatView extends LitElement {
|
||||
this.restoreScrollFrame = undefined;
|
||||
if (this.sessionId !== sessionId) return;
|
||||
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() });
|
||||
this.handleScrollRestoreResult(sessionId, result);
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ export const chatStyles = css`
|
||||
.notification-header { gap: 4px; 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; }
|
||||
.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); }
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("notification socket guards", () => {
|
||||
const ask = {
|
||||
askId: "ask-1",
|
||||
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 });
|
||||
|
||||
@@ -29,7 +29,7 @@ function toolOverStore(askIds: string[] = ["ask-1", "ask-2"]) {
|
||||
const twoQuestions = {
|
||||
questions: [
|
||||
{ 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"],
|
||||
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 () => {
|
||||
@@ -63,7 +64,7 @@ describe("createAskUserToolDefinition", () => {
|
||||
expect(open).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
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 },
|
||||
],
|
||||
});
|
||||
@@ -75,7 +76,7 @@ describe("createAskUserToolDefinition", () => {
|
||||
it("defaults a question without options to an empty option list so free text alone is expressible", async () => {
|
||||
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({
|
||||
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();
|
||||
|
||||
await tool.execute("call-rich", {
|
||||
@@ -103,6 +104,7 @@ describe("createAskUserToolDefinition", () => {
|
||||
question: "Which targets?",
|
||||
detail: "Pick every platform we should build for.",
|
||||
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
|
||||
allowOther: true,
|
||||
multiple: true,
|
||||
}],
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface AskUserInvocation {
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -53,10 +53,7 @@ const AskUserQuestionParams = Type.Object({
|
||||
})),
|
||||
options: Type.Optional(Type.Array(AskUserOptionParams, {
|
||||
maxItems: ASK_USER_OPTION_LIMIT,
|
||||
description: "Options to choose from. Omit only when free text is the whole answer, and then set allowOther.",
|
||||
})),
|
||||
allowOther: Type.Optional(Type.Boolean({
|
||||
description: "Offer a free-text field alongside the options.",
|
||||
description: "Options to choose from. Omit when free text is the whole answer; the browser always adds a Custom choice.",
|
||||
})),
|
||||
multiple: Type.Optional(Type.Boolean({
|
||||
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. */
|
||||
function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion {
|
||||
const { detail, options, allowOther, multiple } = param;
|
||||
const { detail, options, multiple } = param;
|
||||
return {
|
||||
id: param.id,
|
||||
question: param.question,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("PendingAskStore validation", () => {
|
||||
question: "Which database?",
|
||||
detail: "Only the primary store matters here.",
|
||||
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
|
||||
allowOther: true,
|
||||
allowOther: false,
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
@@ -79,7 +79,6 @@ describe("PendingAskStore validation", () => {
|
||||
expect(reject([question("q1"), question("q1")])).toThrow(/Duplicate question id q1/);
|
||||
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", { options: [] })])).toThrow(/must offer options or allow other text/);
|
||||
expect(reject([question("q1", { options: [{ value: "a", label: "A" }, { value: "a", label: "Again" }] })]))
|
||||
.toThrow(/Duplicate option value a in question q1/);
|
||||
expect(reject([question("q1", { options: [{ value: "a", label: " " }] })]))
|
||||
@@ -90,9 +89,9 @@ describe("PendingAskStore validation", () => {
|
||||
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 { 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 });
|
||||
});
|
||||
});
|
||||
@@ -143,7 +142,7 @@ describe("PendingAskStore submit", () => {
|
||||
|
||||
it("treats an empty answer as leaving the question untouched", () => {
|
||||
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, {
|
||||
answers: [{ id: "q1", values: [] }, { id: "q2", values: [], otherText: " " }],
|
||||
@@ -161,11 +160,11 @@ describe("PendingAskStore submit", () => {
|
||||
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 { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1", { multiple: true, allowOther: true })],
|
||||
questions: [question("q1", { multiple: true })],
|
||||
});
|
||||
|
||||
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 { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(() => store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] }))
|
||||
.toThrow(/Question q1 does not accept other text/);
|
||||
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] });
|
||||
|
||||
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 { 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" }] });
|
||||
|
||||
@@ -294,7 +296,7 @@ describe("ask outcome rendering", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1"), question("q2", { allowOther: true }), question("q3")],
|
||||
questions: [question("q1"), question("q2"), question("q3")],
|
||||
});
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: [], otherText: "something else" }],
|
||||
@@ -307,7 +309,7 @@ describe("ask outcome rendering", () => {
|
||||
"- q1: Question q1?",
|
||||
" Answered: selected yes",
|
||||
"- q2: Question q2?",
|
||||
` Answered: other: "something else"`,
|
||||
` Answered: custom: "something else"`,
|
||||
"- q3: Question q3?",
|
||||
" Unanswered.",
|
||||
"",
|
||||
|
||||
@@ -47,7 +47,7 @@ export type PendingAskCloseResult =
|
||||
| { status: "closed"; outcome: AskUserOutcome }
|
||||
| { 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 {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -181,7 +181,7 @@ function questionLines(record: AskUserQuestionRecord): string[] {
|
||||
const header = `- ${record.question.id}: ${record.question.question}`;
|
||||
if (!record.answered) return [header, " Unanswered."];
|
||||
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("; ")}`];
|
||||
}
|
||||
|
||||
@@ -240,12 +240,6 @@ function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestio
|
||||
if (question.options.length > ASK_USER_OPTION_LIMIT) {
|
||||
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 options = question.options.map((option) => {
|
||||
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}`),
|
||||
...(detail === undefined ? {} : { detail: requireText(detail, `detail of question ${id}`) }),
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -307,7 +303,6 @@ function validateAnswer(question: AskUserQuestion, answer: AskUserAnswer): AskUs
|
||||
|
||||
function normalizeOtherText(question: AskUserQuestion, otherText: string | undefined): string | 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) {
|
||||
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 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
|
||||
@@ -98,13 +98,14 @@ describe("PiSessionService.openAsk", () => {
|
||||
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();
|
||||
|
||||
await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] }))
|
||||
.rejects.toThrow(PendingAskValidationError);
|
||||
expect(store.pendingAsk("session-1")).toBeUndefined();
|
||||
expect(askEvents(events)).toEqual([]);
|
||||
const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "Anything else?", options: [] }] });
|
||||
|
||||
expect(result.ask.questions).toEqual([{ id: "empty", question: "Anything else?", options: [], allowOther: true }]);
|
||||
expect(store.pendingAsk("session-1")).toEqual(result.ask);
|
||||
expect(askEvents(events)).toHaveLength(1);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
@@ -163,11 +164,16 @@ describe("PiSessionService ask status projection", () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
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.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.display).toBe(true);
|
||||
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.details).toMatchObject({ askId: "ask-1", reason: "submitted" });
|
||||
expect(delivered?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
||||
|
||||
@@ -462,7 +462,7 @@ export const ASK_USER_OPTION_LIMIT = 12;
|
||||
export const ASK_USER_ID_MAX_LENGTH = 128;
|
||||
/** Length bound for model-authored prose: questions, details, and option labels. */
|
||||
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;
|
||||
|
||||
/** One selectable option of an {@link AskUserQuestion}. */
|
||||
@@ -489,7 +489,10 @@ export interface AskUserQuestion {
|
||||
detail?: string;
|
||||
/** Offered options; may be empty when only free text makes sense. */
|
||||
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;
|
||||
/** When true, several options may be selected at once. */
|
||||
multiple?: boolean;
|
||||
@@ -519,7 +522,7 @@ export interface AskUserAnswer {
|
||||
id: string;
|
||||
/** Selected {@link AskUserQuestionOption.value} entries; several only when the question allows it. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -534,7 +537,7 @@ export interface AskUserSubmission {
|
||||
*/
|
||||
export interface AskUserQuestionRecord {
|
||||
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;
|
||||
values: string[];
|
||||
otherText?: string;
|
||||
|
||||
Reference in New Issue
Block a user