public/app.js
import { api, ApiError, readEvents, setCsrf } from "./api.js";
import { h, renderComponent } from "./agent-ui.js";
const $ = (sel) => document.querySelector(sel);
const IDLE_WARNING_MS = 15 * 60 * 1000;
const state = { user: null, controller: null, lastMessage: "" };
// ------------------------------------------------------------- auth ----
function show(view) {
$("#login-view").hidden = view !== "login";
$("#app-view").hidden = view !== "app";
$("#whoami").hidden = view !== "app";
}
async function boot() {
try {
const { user, csrf } = await (await api("/api/me")).json();
signedIn(user, csrf);
} catch {
show("login");
}
}
function signedIn(user, csrf) {
state.user = user;
setCsrf(csrf);
$("#user-name").textContent = user.name;
show("app");
loadOrders();
if (!$("#chat-log").children.length) {
addBubble("assistant").append(`Hi ${user.name}! Ask me about an order, or say "refund A-1001".`);
}
armIdleTimer();
$("#message").focus();
}
$("#login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.currentTarget; // currentTarget is null again after the first await
$("#login-error").textContent = "";
try {
const res = await api("/api/login", { method: "POST", body: Object.fromEntries(new FormData(form)) });
const { user, csrf } = await res.json();
form.reset();
signedIn(user, csrf);
} catch (err) {
$("#login-error").textContent = err.message;
}
});
$("#logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" }).catch(() => {});
state.user = null;
$("#chat-log").replaceChildren();
show("login");
});
// ----------------------------------------------------- idle warning ----
let idleTimer;
function armIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => state.user && $("#session-dialog").showModal(), IDLE_WARNING_MS);
}
for (const type of ["pointerdown", "keydown"]) addEventListener(type, () => state.user && armIdleTimer(), { passive: true });
$("#stay").addEventListener("click", async () => {
$("#session-dialog").close();
await api("/api/me").catch(() => show("login")); // refreshes the server's sliding expiry
armIdleTimer();
});
// ----------------------------------------------------------- orders ----
async function loadOrders() {
const list = await (await api("/api/orders")).json();
$("#orders").replaceChildren(
...list.map((o) => h("li", { "data-order": o.id }, h("strong", {}, o.id), ` ${o.item} — `, h("span", { class: "status" }, o.status))),
);
}
const actions = {
async requestRefund(orderId) {
await api("/api/refunds", { method: "POST", body: { orderId } });
await loadOrders();
},
};
// ------------------------------------------------------------- chat ----
function addBubble(role, text = "") {
const body = h("div", { class: "body" }, text);
$("#chat-log").append(h("div", { class: `msg ${role}`, "data-role": role }, body));
return body;
}
function setBusy(busy) {
$("#send").disabled = busy;
$("#stop").hidden = !busy;
$("#chat-log").setAttribute("aria-busy", String(busy));
}
async function send(message) {
state.lastMessage = message;
$("#chat-error").hidden = true;
const controller = (state.controller = new AbortController());
setBusy(true);
let bubble = addBubble("assistant");
try {
const res = await api("/api/chat", { method: "POST", body: { message }, signal: controller.signal });
// Batch text into one DOM write per frame: a fast stream can deliver
// hundreds of deltas a second and each write would re-layout (§59).
let pending = "";
let scheduled = false;
const flush = () => {
bubble.append(pending);
pending = "";
scheduled = false;
};
for await (const event of readEvents(res)) {
if (event.type === "text-delta") {
pending += event.delta;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(flush);
}
} else if (event.type === "ui") {
if (pending) flush();
const node = renderComponent(event, { actions });
if (node) bubble.append(node);
}
}
if (pending) flush();
} catch (err) {
if (err.name === "AbortError") {
bubble.append(h("em", { class: "stopped" }, " (stopped)"));
} else {
bubble.closest(".msg").remove();
$("#chat-error span").textContent =
err instanceof ApiError && err.status === 429 ? "You're sending messages too fast." : "The assistant is unavailable.";
$("#chat-error").hidden = false;
if (err instanceof ApiError && err.status === 401) show("login");
}
} finally {
setBusy(false);
state.controller = null;
}
}
$("#chat-form").addEventListener("submit", (e) => {
e.preventDefault();
const message = $("#message").value.trim();
if (!message) return;
addBubble("user", message); // textContent, so "<img onerror=…>" is just text
$("#message").value = "";
send(message);
});
$("#message").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$("#chat-form").requestSubmit();
}
});
$("#stop").addEventListener("click", () => state.controller?.abort());
$("#retry").addEventListener("click", () => send(state.lastMessage));
boot();