Frontend Labs
⋯

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();

Generated from SOURCE.md · 8 Oct 2026 16:36