From cc948f1e989fe9957593b8034e5161cf783c277b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Mon, 24 Aug 2026 18:02:13 -0300 Subject: [PATCH] feat(review-tutor): show a pulsing ember dot while the tutor answers Replace the plain Running label with a pulsing ember dot and Sending, Queued, and Answering labels on the busy Ask button. --- packages/review-tutor/src/page-script.ts | 18 +++++++++--------- packages/review-tutor/src/page-styles.ts | 2 ++ packages/review-tutor/test/page.test.ts | 12 ++++++++++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/review-tutor/src/page-script.ts b/packages/review-tutor/src/page-script.ts index d1721e6..296defc 100644 --- a/packages/review-tutor/src/page-script.ts +++ b/packages/review-tutor/src/page-script.ts @@ -956,13 +956,13 @@ export const pageScript = String.raw` const current = Number(element("files").value || 0); scrollFile((current + delta + files.length) % files.length); } - function setAskLabel(text, spinner = false) { + function setAskLabel(text, busy = false) { element("ask").replaceChildren(); - if (spinner) { - const icon = document.createElement("span"); - icon.className = "spinner"; - icon.setAttribute("aria-hidden", "true"); - element("ask").append(icon); + if (busy) { + const dot = document.createElement("span"); + dot.className = "busy-dot"; + dot.setAttribute("aria-hidden", "true"); + element("ask").append(dot); } element("ask").append(document.createTextNode(text)); } @@ -1008,9 +1008,9 @@ export const pageScript = String.raw` } else element("answer").classList.remove("streaming"); if (question.state === "queued") { announce("Question queued."); - setAskLabel("Queued"); + setAskLabel("Queued", true); } - if (question.state === "running") setAskLabel("Running"); + if (question.state === "running") setAskLabel("Answering", true); if (question.state === "answered") { announce("Answer complete."); setAskLabel("Ask"); @@ -1075,7 +1075,7 @@ export const pageScript = String.raw` setAnswerTail(""); setAnswer(""); element("ask").setAttribute("aria-busy", "true"); - setAskLabel("Asking…", true); + setAskLabel("Sending", true); announce("Question sent. Waiting for the tutor."); updateActions(); try { diff --git a/packages/review-tutor/src/page-styles.ts b/packages/review-tutor/src/page-styles.ts index 649a956..2f134ff 100644 --- a/packages/review-tutor/src/page-styles.ts +++ b/packages/review-tutor/src/page-styles.ts @@ -232,6 +232,8 @@ color:var(--subtle);font-size:12px;margin:8px 0 0} display:none;margin:0} .spinner { display:inline-block;width:12px;height:12px;border:2px solid rgba(0,0,0,.3);border-top-color:#160a02;border-radius:50%;margin-right:7px;vertical-align:-2px;animation:spin .7s linear infinite} +.busy-dot { +display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--ember);animation:blink 1.2s ease-in-out infinite} .answer { margin-top:12px;padding-top:10px;border-top:1px solid var(--hairline)} .answer-head { diff --git a/packages/review-tutor/test/page.test.ts b/packages/review-tutor/test/page.test.ts index 572ffab..1a66f73 100644 --- a/packages/review-tutor/test/page.test.ts +++ b/packages/review-tutor/test/page.test.ts @@ -513,7 +513,7 @@ describe("Review Tutor composed page", () => { it("submits contiguous selection and applies a synchronous duplicate Ask lock", async () => { let resolveAsk!: (response: Response) => void; const pending = new Promise((resolve) => { resolveAsk = resolve; }); - const { window, document, requests } = await boot({ askResponse: pending }); + const { window, document, requests, events } = await boot({ askResponse: pending }); const rows = selectableRows(document); const reachable = lineControl(rows.find((row) => lineControl(row).tabIndex === 0)); reachable.focus(); @@ -528,7 +528,8 @@ describe("Review Tutor composed page", () => { ask.click(); expect(ask.disabled).toBe(true); expect(ask.getAttribute("aria-busy")).toBe("true"); - expect(ask.textContent).toContain("Asking…"); + expect(ask.textContent).toBe("Sending"); + expect(ask.querySelector(".busy-dot")?.getAttribute("aria-hidden")).toBe("true"); expect(requests.filter((request) => request.path === "/api/ask")).toHaveLength(1); resolveAsk(json({ id: "q-1", state: "queued", answer: "", createdAt: new Date().toISOString() })); await flush(); @@ -539,7 +540,14 @@ describe("Review Tutor composed page", () => { expect(payload).not.toHaveProperty("harness"); expect(ask.getAttribute("aria-busy")).toBe("false"); expect(ask.textContent).toBe("Queued"); + expect(ask.querySelector(".busy-dot")).not.toBeNull(); expect(ask.classList.contains("busy-neutral")).toBe(true); + events?.emit("question", { id: "q-1", state: "running", answer: "" }); + expect(ask.textContent).toBe("Answering"); + expect(ask.querySelector(".busy-dot")).not.toBeNull(); + events?.emit("question", { id: "q-1", state: "answered", answer: "Done" }); + expect(ask.textContent).toBe("Ask"); + expect(ask.querySelector(".busy-dot")).toBeNull(); }); it("keeps Space and Shift+Arrow selection-only, then opens and focuses on Enter", async () => {