feat(client): render ask_user question cards

This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 00:52:47 +02:00
parent 862ae73fcb
commit 4973adaa80
6 changed files with 900 additions and 5 deletions
+60
View File
@@ -50,6 +50,7 @@
"@types/ws": "^8.18.1",
"eslint": "^10.6.0",
"globals": "^17.7.0",
"happy-dom": "^20.11.1",
"knip": "^6.25.0",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
@@ -5549,6 +5550,13 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -6211,6 +6219,19 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -6428,6 +6449,16 @@
"node": ">=8.6"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
}
},
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -7377,6 +7408,25 @@
"dev": true,
"license": "ISC"
},
"node_modules/happy-dom": {
"version": "20.11.1",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz",
"integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9898,6 +9948,16 @@
"node": ">= 8"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -91,6 +91,7 @@
"@types/ws": "^8.18.1",
"eslint": "^10.6.0",
"globals": "^17.7.0",
"happy-dom": "^20.11.1",
"knip": "^6.25.0",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AskUserOutcome, AskUserQuestion, PendingAskUser } from "../../../shared/apiTypes";
import { saveAskDraft } from "../askDrafts";
import { AskUserCard, type AskUserSubmitCallback } from "./AskUserCard";
afterEach(() => {
document.body.replaceChildren();
localStorage.clear();
});
describe("ask-user-card live form", () => {
it("uses native labelled groups and updates progress for a keyboard-focusable single select", async () => {
const card = await mountOpenAsk(openAsk([
question("editor", "Choose an editor", [option("vim", "Vim"), option("code", "VS Code")], { detail: "Used for examples." }),
question("platforms", "Target platforms", [option("web", "Web"), option("desktop", "Desktop")], { multiple: true }),
]));
const root = renderRoot(card);
expect(card).toBeInstanceOf(HTMLElement);
expect(customElements.get("ask-user-card")).toBe(AskUserCard);
const fieldsets = root.querySelectorAll("fieldset");
const legends = root.querySelectorAll("legend");
const vim = inputWithValue(root, "vim");
const code = inputWithValue(root, "code");
const web = inputWithValue(root, "web");
expect(fieldsets).toHaveLength(2);
expect(legends[0]?.textContent).toContain("Choose an editor");
expect(fieldsets[0]?.getAttribute("aria-describedby")).toBe("ask-user-question-detail-0");
expect(root.querySelector("#ask-user-question-detail-0")?.textContent).toBe("Used for examples.");
expect(vim.type).toBe("radio");
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");
// Focus and interaction run through the rendered native control rather than
// extracting Lit handlers, so this exercises the form's browser boundary.
vim.focus();
expect(root.activeElement).toBe(vim);
vim.click();
await card.updateComplete;
expect(vim.checked).toBe(true);
expect(code.checked).toBe(false);
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 1 of 2");
});
it("accumulates several checkbox values for a multi-select question", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([
question("platforms", "Target platforms", [option("web", "Web"), option("desktop", "Desktop")], { multiple: true }),
]), onSubmit);
const root = renderRoot(card);
inputWithValue(root, "web").click();
inputWithValue(root, "desktop").click();
await card.updateComplete;
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 1 of 1");
buttonWithText(root, "Send answers").click();
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledWith("ask-1", {
answers: [{ id: "platforms", values: ["web", "desktop"] }],
});
});
it("reveals and focuses a labelled other 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 }),
]), onSubmit);
const root = renderRoot(card);
inputWithValue(root, "lit").click();
inputWithValue(root, "__pi_web_other__").click();
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”");
expect(root.activeElement).toBe(textarea);
textarea.value = "Svelte";
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: "stack", values: ["lit"], otherText: "Svelte" }],
});
});
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 }),
]), onSubmit);
const root = renderRoot(card);
inputWithValue(root, "vim").click();
await card.updateComplete;
buttonWithText(root, "Send answers").click();
await card.updateComplete;
await Promise.resolve();
const confirmation = requiredElement(root.querySelector("[aria-label='Confirm partial answers']"), "partial confirmation");
expect(confirmation.textContent).toContain("Send without answering:");
expect(confirmation.textContent).toContain("Choose a deployment target");
expect(confirmation.textContent).toContain("Add implementation notes");
expect(onSubmit).not.toHaveBeenCalled();
expect(root.activeElement).toBe(buttonWithText(root, "Send anyway"));
buttonWithText(root, "Send anyway").click();
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledWith("ask-1", {
answers: [{ id: "editor", values: ["vim"] }],
});
});
});
describe("ask-user-card record mode", () => {
it("has no answer controls and displays draft answers retained for a superseded ask", async () => {
const draftSessionId = "remote-a:session-1";
saveAskDraft(draftSessionId, "ask-old", {
speed: { values: ["fast"] },
rationale: { values: [], otherText: "It keeps the feedback loop short." },
});
const outcome: AskUserOutcome = {
askId: "ask-old",
reason: "superseded",
askedAt: "2026-07-20T10:00:00.000Z",
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("region", "Deployment region", [option("eu", "Europe")])),
],
answeredCount: 0,
unansweredIds: ["speed", "rationale", "region"],
summary: "Answered 0 of 3; unanswered: speed, rationale, region",
};
const card = new AskUserCard();
card.draftSessionId = draftSessionId;
card.outcome = outcome;
document.body.append(card);
await card.updateComplete;
const root = renderRoot(card);
expect(root.querySelector("input, textarea, button, select")).toBeNull();
expect(root.textContent).toContain("Questions superseded");
expect(root.textContent).toContain("Fast");
expect(root.textContent).toContain("It keeps the feedback loop short.");
expect(root.textContent).toContain("Draft answer · not sent");
expect(root.textContent).toContain("Deployment region");
expect(root.textContent).toContain("Unanswered");
});
});
async function mountOpenAsk(ask: PendingAskUser, onSubmit?: AskUserSubmitCallback): Promise<AskUserCard> {
const card = new AskUserCard();
card.ask = ask;
card.draftSessionId = "local:session-1";
if (onSubmit !== undefined) card.onSubmit = onSubmit;
document.body.append(card);
await card.updateComplete;
return card;
}
function renderRoot(card: AskUserCard): ShadowRoot {
return requiredElement(card.shadowRoot, "ask-user-card shadow root");
}
function inputWithValue(root: ShadowRoot, value: string): HTMLInputElement {
const input = [...root.querySelectorAll("input")].find((candidate) => candidate.value === value);
return requiredElement(input, `input with value ${value}`);
}
function buttonWithText(root: ShadowRoot, text: string): HTMLButtonElement {
const button = [...root.querySelectorAll("button")].find((candidate) => candidate.textContent.trim() === text);
return requiredElement(button, `button named ${text}`);
}
function requiredElement<T>(value: T | null | undefined, label: string): T {
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
return value;
}
function openAsk(questions: AskUserQuestion[]): PendingAskUser {
return { askId: "ask-1", askedAt: "2026-07-20T10:00:00.000Z", questions };
}
function question(
id: string,
text: string,
options: AskUserQuestion["options"],
settings: { detail?: string; multiple?: boolean; allowOther?: boolean } = {},
): AskUserQuestion {
return {
id,
question: text,
options,
...(settings.detail === undefined ? {} : { detail: settings.detail }),
...(settings.multiple === undefined ? {} : { multiple: settings.multiple }),
...(settings.allowOther === undefined ? {} : { allowOther: settings.allowOther }),
};
}
function option(value: string, label: string): AskUserQuestion["options"][number] {
return { value, label };
}
function unansweredRecord(questionValue: AskUserQuestion): AskUserOutcome["questions"][number] {
return { question: questionValue, answered: false, values: [] };
}
+595
View File
@@ -0,0 +1,595 @@
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
ASK_USER_OTHER_TEXT_MAX_LENGTH,
type AskUserOutcome,
type AskUserQuestion,
type AskUserQuestionRecord,
type AskUserSubmission,
type PendingAskUser,
} from "../../../shared/apiTypes";
import {
answeredCount,
loadAskDraft,
saveAskDraft,
toSubmission,
unansweredQuestions,
type AskDraftAnswer,
type AskDraftAnswers,
} from "../askDrafts";
export type AskUserSubmitCallback = (askId: string, submission: AskUserSubmission) => void | Promise<void>;
interface DisplayedRecordAnswer {
values: string[];
otherText?: string;
fromDraft: boolean;
}
/**
* One question set posted by `ask_user`.
*
* The live mode owns only browser-local draft state; the daemon remains the
* source of truth for whether the ask is open. Record mode consumes the closed
* daemon outcome and, for a superseded ask, can recover the unsent local draft
* so text the user had entered is not silently hidden.
*/
@customElement("ask-user-card")
export class AskUserCard extends LitElement {
@property({ attribute: false }) ask?: PendingAskUser;
@property({ attribute: false }) outcome?: AskUserOutcome;
/** Machine-scoped session cache key used by the ask draft store. */
@property({ attribute: false }) draftSessionId = "";
@property({ attribute: false }) onSubmit?: AskUserSubmitCallback;
@state() private answers: AskDraftAnswers = {};
@state() private confirmingPartialSubmit = false;
@state() private submitting = false;
private modelIdentity: string | undefined;
protected override willUpdate(changed: PropertyValues<this>): void {
if (!changed.has("ask") && !changed.has("outcome") && !changed.has("draftSessionId")) return;
const identity = this.currentModelIdentity();
if (identity === this.modelIdentity) return;
this.modelIdentity = identity;
this.answers = this.loadCurrentDraft();
this.confirmingPartialSubmit = false;
this.submitting = false;
}
override render(): TemplateResult | null {
if (this.outcome !== undefined) return this.renderRecord(this.outcome);
if (this.ask !== undefined) return this.renderOpenAsk(this.ask);
return null;
}
private renderOpenAsk(ask: PendingAskUser): TemplateResult {
const count = answeredCount(ask.questions, this.answers);
const unanswered = unansweredQuestions(ask.questions, this.answers);
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>
</header>
<form class="ask-form" @submit=${(event: SubmitEvent) => { this.handleSubmit(event, ask); }}>
<div class="questions-grid">
${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`
<button class="primary-action" type="submit" ?disabled=${this.submitting}>
${this.submitting ? "Sending…" : "Send answers"}
</button>
`}
</footer>
</form>
</article>
`;
}
private renderQuestion(ask: PendingAskUser, question: AskUserQuestion, index: number): TemplateResult {
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 inputType = question.multiple === true ? "checkbox" : "radio";
return html`
<fieldset
id=${this.questionFieldsetId(index)}
class=${`question${answered ? " answered" : ""}`}
aria-describedby=${ifDefined(detailId)}
tabindex="-1"
>
<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">
${question.options.map((option) => html`
<label class="option">
<input
type=${inputType}
name=${this.questionGroupName(ask, question)}
value=${option.value}
.checked=${answer?.values.includes(option.value) === true}
@change=${(event: Event) => { this.changeOption(question, option.value, event); }}
/>
<span class="option-copy">
<span class="option-label">${option.label}</span>
${option.detail === undefined ? null : html`<span class="option-detail">${option.detail}</span>`}
</span>
</label>
`)}
${question.allowOther === true ? html`
<label class="option other-option">
<input
type=${inputType}
name=${this.questionGroupName(ask, question)}
value="__pi_web_other__"
.checked=${otherSelected}
@change=${(event: Event) => { this.changeOther(question, index, event); }}
/>
<span class="option-copy"><span class="option-label">Other</span></span>
</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>
`;
}
private renderPartialSubmitConfirmation(ask: PendingAskUser, unanswered: AskUserQuestion[]): TemplateResult {
return html`
<div class="partial-confirmation" role="group" aria-label="Confirm partial answers">
<p>
<strong>Send without answering:</strong>
${unanswered.map((question, index) => html`${index === 0 ? " " : ", "}<button
class="question-jump"
type="button"
@click=${() => { this.focusQuestion(ask.questions.indexOf(question)); }}
>${question.question}</button>`)}?
</p>
<div class="confirmation-actions">
<button class="secondary-action" type="button" @click=${() => { this.keepEditing(ask, unanswered); }}>Keep editing</button>
<button class="primary-action send-anyway" type="button" ?disabled=${this.submitting} @click=${() => { this.submitAnswers(ask); }}>
${this.submitting ? "Sending…" : "Send anyway"}
</button>
</div>
</div>
`;
}
private renderRecord(outcome: AskUserOutcome): TemplateResult {
const recordLabel = outcome.reason === "submitted"
? "Answers sent"
: outcome.reason === "superseded"
? "Questions superseded"
: "Questions 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>
</header>
<p class="record-summary">
${outcome.reason === "superseded"
? "A newer question set replaced this one. Draft answers shown below were not sent to the model."
: outcome.summary}
</p>
<div class="record-questions">
${outcome.questions.map((record, index) => this.renderQuestionRecord(outcome, record, index))}
</div>
</article>
`;
}
private renderQuestionRecord(outcome: AskUserOutcome, record: AskUserQuestionRecord, index: number): TemplateResult {
const answer = this.displayedRecordAnswer(outcome, record);
return html`
<section class="record-question" aria-labelledby=${this.recordQuestionHeadingId(index)}>
<h3 id=${this.recordQuestionHeadingId(index)}>
<span class="question-number">${String(index + 1)}.</span>
${record.question.question}
</h3>
${record.question.detail === undefined ? null : html`<p class="question-detail">${record.question.detail}</p>`}
${answer === undefined
? html`<p class="unanswered-record">Unanswered</p>`
: 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>`}
</ul>
${answer.fromDraft ? html`<p class="draft-note">Draft answer · not sent</p>` : null}
`}
</section>
`;
}
private changeOption(question: AskUserQuestion, value: string, event: Event): void {
const input = event.currentTarget;
if (!(input instanceof HTMLInputElement)) return;
if (question.multiple !== true) {
if (input.checked) this.setAnswer(question, { values: [value] });
return;
}
const current = this.answers[question.id];
const values = input.checked
? [...new Set([...(current?.values ?? []), value])]
: (current?.values ?? []).filter((selected) => selected !== value);
this.setAnswer(question, {
values,
...(current?.otherText === undefined ? {} : { otherText: current.otherText }),
});
}
private changeOther(question: AskUserQuestion, index: number, event: Event): void {
const input = event.currentTarget;
if (!(input instanceof HTMLInputElement)) return;
const current = this.answers[question.id];
if (question.multiple === true) {
this.setAnswer(question, {
values: [...(current?.values ?? [])],
...(input.checked ? { otherText: current?.otherText ?? "" } : {}),
});
} else if (input.checked) {
this.setAnswer(question, { values: [], otherText: this.isOtherSelected(question, current) ? current?.otherText ?? "" : "" });
}
if (input.checked) void this.focusOtherInput(index);
}
private changeOtherText(question: AskUserQuestion, event: Event): void {
const input = event.currentTarget;
if (!(input instanceof HTMLTextAreaElement)) return;
const current = this.answers[question.id];
this.setAnswer(question, {
values: [...(current?.values ?? [])],
otherText: input.value.slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH),
});
}
private setAnswer(question: AskUserQuestion, answer: AskDraftAnswer): void {
const next: AskDraftAnswers = answer.values.length === 0 && answer.otherText === undefined
? Object.fromEntries(Object.entries(this.answers).filter(([id]) => id !== question.id))
: { ...this.answers, [question.id]: answer };
this.answers = next;
this.confirmingPartialSubmit = false;
if (this.ask !== undefined && this.draftSessionId !== "") saveAskDraft(this.draftSessionId, this.ask.askId, next);
}
private handleSubmit(event: SubmitEvent, ask: PendingAskUser): void {
event.preventDefault();
if (this.submitting) return;
if (unansweredQuestions(ask.questions, this.answers).length > 0) {
void this.showPartialSubmitConfirmation();
return;
}
this.submitAnswers(ask);
}
private submitAnswers(ask: PendingAskUser): void {
if (this.submitting) return;
this.submitting = true;
const callback = this.onSubmit;
if (callback === undefined) {
this.submitting = false;
return;
}
const askId = ask.askId;
void Promise.resolve()
.then(() => callback(askId, toSubmission(ask.questions, this.answers)))
.catch(() => {
// The parent controller owns the visible transport error. Keeping this
// card and its draft intact is the only recovery needed at this boundary.
})
.finally(() => {
if (this.ask?.askId === askId) this.submitting = false;
});
}
private async showPartialSubmitConfirmation(): Promise<void> {
this.confirmingPartialSubmit = true;
await this.updateComplete;
this.renderRoot.querySelector<HTMLElement>(".send-anyway")?.focus();
}
private keepEditing(ask: PendingAskUser, unanswered: AskUserQuestion[]): void {
this.confirmingPartialSubmit = false;
const first = unanswered[0];
if (first !== undefined) this.focusQuestion(ask.questions.indexOf(first));
}
private focusQuestion(index: number): void {
if (index < 0) return;
void this.updateComplete.then(() => {
const fieldset = this.renderRoot.querySelector<HTMLElement>(`#${this.questionFieldsetId(index)}`);
const firstControl = fieldset?.querySelector<HTMLElement>("input, textarea");
(firstControl ?? fieldset)?.focus();
});
}
private async focusOtherInput(index: number): Promise<void> {
await this.updateComplete;
this.renderRoot.querySelector<HTMLElement>(`#${this.otherInputId(index)}`)?.focus();
}
private isOtherSelected(question: AskUserQuestion, answer: AskDraftAnswer | undefined): boolean {
if (question.allowOther !== true || answer?.otherText === undefined) return false;
return question.multiple === true || answer.values.length === 0;
}
private displayedRecordAnswer(outcome: AskUserOutcome, record: AskUserQuestionRecord): DisplayedRecordAnswer | undefined {
if (record.answered) {
return {
values: [...record.values],
...(record.otherText === undefined ? {} : { otherText: record.otherText }),
fromDraft: false,
};
}
if (outcome.reason !== "superseded") return undefined;
const answer = toSubmission([record.question], this.answers).answers[0];
if (answer === undefined) return undefined;
return {
values: [...answer.values],
...(answer.otherText === undefined ? {} : { otherText: answer.otherText }),
fromDraft: true,
};
}
private optionLabel(question: AskUserQuestion, value: string): string {
return question.options.find((option) => option.value === value)?.label ?? value;
}
private currentModelIdentity(): string | undefined {
if (this.outcome !== undefined) return `record:${this.draftSessionId}:${this.outcome.askId}`;
if (this.ask !== undefined) return `open:${this.draftSessionId}:${this.ask.askId}`;
return undefined;
}
private loadCurrentDraft(): AskDraftAnswers {
const askId = this.outcome?.askId ?? this.ask?.askId;
if (askId === undefined || this.draftSessionId === "") return {};
return loadAskDraft(this.draftSessionId, askId);
}
private questionGroupName(ask: PendingAskUser, question: AskUserQuestion): string {
return `ask-user:${ask.askId}:${question.id}`;
}
private questionFieldsetId(index: number): string {
return `ask-user-question-${String(index)}`;
}
private questionDetailId(index: number): string {
return `ask-user-question-detail-${String(index)}`;
}
private otherInputId(index: number): string {
return `ask-user-other-${String(index)}`;
}
private recordQuestionHeadingId(index: number): string {
return `ask-user-record-question-${String(index)}`;
}
static override styles = css`
:host {
display: block;
width: min(100%, 780px);
margin: 18px auto;
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;
background: var(--pi-surface);
box-shadow: 0 10px 30px var(--pi-shadow-soft);
}
.card-header {
display: flex;
align-items: flex-start;
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;
}
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;
}
fieldset.question {
min-width: 0;
margin: 0;
border: 1px solid var(--pi-border-muted);
border-radius: 10px;
padding: 12px;
background: var(--pi-bg);
}
fieldset.question.answered { border-color: var(--pi-success-border); }
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
legend {
box-sizing: border-box;
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 5px;
color: var(--pi-text);
padding: 0 2px;
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); }
.question-detail {
margin: 4px 0 10px;
color: var(--pi-muted);
font-size: 12px;
line-height: 1.4;
}
.options { display: grid; gap: 7px; }
.option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
border: 1px solid transparent;
border-radius: 8px;
padding: 7px 8px;
cursor: pointer;
}
.option:hover { border-color: var(--pi-border-muted); background: var(--pi-surface-hover); }
.option:has(input:checked) { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
input { margin: 2px 0 0; accent-color: var(--pi-accent); }
input:focus-visible, textarea:focus-visible, button:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
.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; }
textarea {
box-sizing: border-box;
width: 100%;
min-height: 68px;
resize: vertical;
border: 1px solid var(--pi-border);
border-radius: 8px;
background: var(--pi-bg);
color: var(--pi-text);
padding: 8px;
font: inherit;
line-height: 1.4;
}
.form-footer {
position: sticky;
z-index: 2;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
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);
}
.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;
background: var(--pi-surface);
color: var(--pi-text);
padding: 7px 10px;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) { background: var(--pi-surface-hover); }
button:disabled { cursor: wait; opacity: .65; }
.primary-action { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast, white); font-weight: 650; }
.primary-action:hover:not(:disabled) { background: color-mix(in srgb, var(--pi-accent) 86%, white); }
.partial-confirmation { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
.partial-confirmation p { min-width: 0; margin: 0; color: var(--pi-warning); font-size: 12px; line-height: 1.4; }
.question-jump {
display: inline;
border: 0;
border-radius: 3px;
background: transparent;
color: inherit;
padding: 0;
text-decoration: underline;
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-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; }
.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; }
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ask-user-card": AskUserCard;
}
}
+19 -3
View File
@@ -7,7 +7,7 @@ import { writeClipboardText } from "../clipboard";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
import type { AskUserSubmission, PendingAskUser, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
import {
notificationAnnouncementLabel,
notificationDismissLabel,
@@ -26,6 +26,7 @@ import {
} from "../sessionNotifications";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles, renderSessionWarningIcon } from "./shared";
import "./AskUserCard";
import "./ConversationMeter";
import "./FormattedText";
import "./ToolExecutionView";
@@ -192,6 +193,9 @@ export class ChatView extends LitElement {
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) pendingAsk?: PendingAskUser;
@property({ attribute: false }) askDraftSessionId = "";
@property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise<void>;
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void;
@@ -311,7 +315,7 @@ export class ChatView extends LitElement {
this.pendingNotificationFocus = undefined;
this.retainedEmptyNotificationTrayTargetKey = undefined;
}
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
if (changed.has("messages") || changed.has("pendingAsk")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
}
protected override update(changed: Map<string, unknown>): void {
@@ -324,7 +328,7 @@ 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") && this.pinnedToBottom) this.scrollToBottom();
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("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
@@ -365,6 +369,7 @@ export class ChatView extends LitElement {
)}
${this.renderQueuedMessages()}
${this.renderSessionActivity()}
${this.renderOpenAsk()}
</div>
${this.renderActivityDock()}
</div>
@@ -638,6 +643,17 @@ export class ChatView extends LitElement {
`;
}
private renderOpenAsk() {
if (this.pendingAsk === undefined) return null;
return html`
<ask-user-card
.ask=${this.pendingAsk}
.draftSessionId=${this.askDraftSessionId}
.onSubmit=${this.onSubmitAsk}
></ask-user-card>
`;
}
private renderSessionActivity() {
if (!this.isCompacting) return null;
return html`
+4 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -2100,6 +2100,8 @@ export class PiWebApp extends LitElement {
void this.sessions.dismissWarning(dismissId);
};
private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise<void> => this.sessions.submitAsk(askId, submission);
private readonly handleDismissNotification = (notificationId: string): void => {
void this.notifications.dismissNotification(notificationId);
};
@@ -2125,7 +2127,7 @@ export class PiWebApp extends LitElement {
private renderChatView(state: AppState, session: SessionInfo) {
return html`
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .pendingAsk=${state.pendingAsk} .askDraftSessionId=${machineSessionKey(selectedMachineId(state), session.id)} .onSubmitAsk=${this.handleSubmitAsk} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
`;
}