Archived
feat(client): render ask_user question cards
This commit is contained in:
Generated
+60
@@ -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",
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user