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
+7 -5
View File
@@ -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 },
],
};
+4 -5
View File
@@ -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 }),
};
}
+6 -10
View File
@@ -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);
+3 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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: [],
},
+2 -2
View File
@@ -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 -13
View File
@@ -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 }),
};
}
+73 -112
View File
@@ -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 = {
+44 -2
View File
@@ -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);
});
+1 -1
View File
@@ -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); }
+1 -1
View File
@@ -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 });
+6 -4
View File
@@ -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,
}],
});
+6 -7
View File
@@ -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 }),
};
}
+16 -14
View File
@@ -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.",
"",
+5 -10
View File
@@ -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" });
+7 -4
View File
@@ -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;