diff --git a/package-lock.json b/package-lock.json index 2092f3f..25bc991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 729c4f5..4328f0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/src/components/AskUserCard.test.ts b/src/client/src/components/AskUserCard.test.ts new file mode 100644 index 0000000..eda1be2 --- /dev/null +++ b/src/client/src/components/AskUserCard.test.ts @@ -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(); + 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(); + 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(); + 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 { + 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(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: [] }; +} diff --git a/src/client/src/components/AskUserCard.ts b/src/client/src/components/AskUserCard.ts new file mode 100644 index 0000000..f9d6b62 --- /dev/null +++ b/src/client/src/components/AskUserCard.ts @@ -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; + +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): 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` +
+
+
+

Pi needs your input

+

Questions from the model

+
+ ${ask.questions.length} ${ask.questions.length === 1 ? "question" : "questions"} +
+
{ this.handleSubmit(event, ask); }}> +
+ ${ask.questions.map((question, index) => this.renderQuestion(ask, question, index))} +
+
+
+ Answered ${count} of ${ask.questions.length} +
+ ${this.confirmingPartialSubmit && unanswered.length > 0 + ? this.renderPartialSubmitConfirmation(ask, unanswered) + : html` + + `} +
+
+
+ `; + } + + 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` +
+ + ${String(index + 1)}. + ${question.question} + + + ${question.detail === undefined ? null : html`

${question.detail}

`} +
+ ${question.options.map((option) => html` + + `)} + ${question.allowOther === true ? html` + + ${otherSelected ? html` + + ` : null} + ` : null} +
+
+ `; + } + + private renderPartialSubmitConfirmation(ask: PendingAskUser, unanswered: AskUserQuestion[]): TemplateResult { + return html` +
+

+ Send without answering: + ${unanswered.map((question, index) => html`${index === 0 ? " " : ", "}`)}? +

+
+ + +
+
+ `; + } + + private renderRecord(outcome: AskUserOutcome): TemplateResult { + const recordLabel = outcome.reason === "submitted" + ? "Answers sent" + : outcome.reason === "superseded" + ? "Questions superseded" + : "Questions cancelled"; + return html` +
+
+
+

Question record

+

${recordLabel}

+
+ ${outcome.reason} +
+

+ ${outcome.reason === "superseded" + ? "A newer question set replaced this one. Draft answers shown below were not sent to the model." + : outcome.summary} +

+
+ ${outcome.questions.map((record, index) => this.renderQuestionRecord(outcome, record, index))} +
+
+ `; + } + + private renderQuestionRecord(outcome: AskUserOutcome, record: AskUserQuestionRecord, index: number): TemplateResult { + const answer = this.displayedRecordAnswer(outcome, record); + return html` +
+

+ ${String(index + 1)}. + ${record.question.question} +

+ ${record.question.detail === undefined ? null : html`

${record.question.detail}

`} + ${answer === undefined + ? html`

Unanswered

` + : html` +
    + ${answer.values.map((value) => html`
  • ${this.optionLabel(record.question, value)}
  • `)} + ${answer.otherText === undefined ? null : html`
  • Other: ${answer.otherText}
  • `} +
+ ${answer.fromDraft ? html`

Draft answer · not sent

` : null} + `} +
+ `; + } + + 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 { + this.confirmingPartialSubmit = true; + await this.updateComplete; + this.renderRoot.querySelector(".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(`#${this.questionFieldsetId(index)}`); + const firstControl = fieldset?.querySelector("input, textarea"); + (firstControl ?? fieldset)?.focus(); + }); + } + + private async focusOtherInput(index: number): Promise { + await this.updateComplete; + this.renderRoot.querySelector(`#${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; + } +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index b17e8ee..7d48704 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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; @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): 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()} ${this.renderActivityDock()} @@ -638,6 +643,17 @@ export class ChatView extends LitElement { `; } + private renderOpenAsk() { + if (this.pendingAsk === undefined) return null; + return html` + + `; + } + private renderSessionActivity() { if (!this.isCompacting) return null; return html` diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 2581a2e..c453c04 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 => 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` - 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())}> + 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())}> `; }