comparison mrjunejune/src/jrpg/jrpg.js @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents ee04e4e69fed
children 056790c4fb0d
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
13 }, 13 },
14 }); 14 });
15 15
16 const PREVIEWS = Object.freeze({ 16 const PREVIEWS = Object.freeze({
17 resume: { 17 resume: {
18 copy: "Experience, projects, and the systems I have helped build.", 18 copy: "Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems across Microsoft, Meta, Google, and growth-stage companies.",
19 kicker: "CHARACTER RECORD",
20 title: "Resume", 19 title: "Resume",
21 url: "/resume", 20 url: "/resume",
22 works: [ 21 works: [
23 { 22 {
24 detail: "Career dossier", 23 detail: "Career dossier",
25 label: "Full resume", 24 label: "Full resume",
26 modal: "resume", 25 modal: "resume",
27 url: "/resume", 26 url: "/resume",
28 }, 27 },
29 { 28 {
30 detail: "Agentic execution", 29 detail: "Led engineering",
31 label: "Copilot Tasks", 30 label: "Copilot Tasks",
32 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", 31 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/",
33 }, 32 },
34 { 33 {
35 detail: "AI platform", 34 detail: "Foundational platform",
36 label: "Copilot SuperApp", 35 label: "AIX Harness / Copilot",
37 url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", 36 url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html",
38 }, 37 },
39 { 38 {
40 detail: "Build 2026", 39 detail: "Build 2026 products",
41 label: "Code", 40 label: "Code & Autopilot",
42 url: "https://news.microsoft.com/build-2026/", 41 url: "https://news.microsoft.com/build-2026/",
43 }, 42 },
44 { 43 {
45 detail: "Personal agent", 44 detail: "Full-stack ads systems",
46 label: "Autopilot",
47 url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
48 },
49 {
50 detail: "Ads systems",
51 label: "Meta", 45 label: "Meta",
52 url: "https://www.meta.com/", 46 url: "https://www.meta.com/",
53 }, 47 },
54 { 48 {
55 detail: "Workspace platform", 49 detail: "Workspace platform",
56 label: "Google", 50 label: "Google",
57 url: "https://www.google.com/", 51 url: "https://www.google.com/",
58 }, 52 },
53 {
54 detail: "Technical lead",
55 label: "Warner Music Group",
56 url: "https://www.wmg.com/",
57 },
58 {
59 detail: "Personal agent launch",
60 label: "Microsoft Scout",
61 url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
62 },
59 ], 63 ],
60 }, 64 },
61 tools: { 65 tools: {
62 copy: "Small, focused utilities for writing, media, and experimentation.", 66 copy: "Useful browser tools backed by first-party C, WASM, media, and document-processing systems.",
63 kicker: "ITEM INVENTORY",
64 title: "Tools", 67 title: "Tools",
65 url: "/tools", 68 url: "/tools",
66 works: [ 69 works: [
67 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, 70 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" },
68 { detail: "Media", label: "Converter", url: "/tools/file_converter" }, 71 { detail: "Media", label: "Converter", url: "/tools/file_converter" },
69 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, 72 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" },
70 ], 73 ],
71 }, 74 },
72 blog: { 75 blog: {
73 copy: "Notes from building systems, games, web tools, and curious prototypes.", 76 copy: "Technical writing about networking, rendering, performance, developer tooling, and experiments.",
74 kicker: "QUEST ARCHIVE",
75 title: "Blogs", 77 title: "Blogs",
76 url: "/blog", 78 url: "/blog",
77 works: [ 79 works: [
78 { detail: "Archive", label: "All posts", url: "/blog" }, 80 { detail: "Archive", label: "All posts", url: "/blog" },
79 ], 81 ],
80 }, 82 },
81 }); 83 });
82 84
83 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; 85 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
86 const CONVERSATION_LEGACY_KEY = "mjj-jrpg-legacy-claim";
87 const ARCHIVE_PAGE_SIZE = 20;
84 const TYPING_INTERVAL_MS = 18; 88 const TYPING_INTERVAL_MS = 18;
85 const SCRIPT_LOADS = new Map(); 89 const SCRIPT_LOADS = new Map();
90 const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
91 const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]);
86 const TOOL_ICON_NAMES = Object.freeze({ 92 const TOOL_ICON_NAMES = Object.freeze({
87 "/notes": "repository", 93 "/notes": "repository",
88 "/tools/file_converter": "retry", 94 "/tools/file_converter": "retry",
89 "/tools/hls_player": "play", 95 "/tools/hls_player": "play",
90 "/tools/latex_editor": "file", 96 "/tools/latex_editor": "file",
596 602
597 show(selection) { 603 show(selection) {
598 this.cleanupTool(); 604 this.cleanupTool();
599 const preview = PREVIEWS[selection] || PREVIEWS.resume; 605 const preview = PREVIEWS[selection] || PREVIEWS.resume;
600 this.dataset.selection = selection; 606 this.dataset.selection = selection;
601 this.querySelector("[data-preview-kicker]").textContent = preview.kicker;
602 this.querySelector("[data-preview-title]").textContent = preview.title; 607 this.querySelector("[data-preview-title]").textContent = preview.title;
603 this.querySelector("[data-preview-copy]").textContent = preview.copy; 608 this.querySelector("[data-preview-copy]").textContent = preview.copy;
604 this.querySelector("[data-dialog-title]").textContent = preview.title; 609 this.querySelector("[data-dialog-title]").textContent = preview.title;
605 this.querySelector("[data-dialog-copy]").textContent = preview.copy; 610 this.querySelector("[data-dialog-copy]").textContent = preview.copy;
606 this.renderShowcase(preview.works); 611 this.renderShowcase(preview.works);
1399 "Read-only preview. Open the full page to use this tool."; 1404 "Read-only preview. Open the full page to use this tool.";
1400 return introduction; 1405 return introduction;
1401 } 1406 }
1402 } 1407 }
1403 1408
1404 class MjjJrpgShell extends HTMLElement {} 1409 /* =====================================================================
1410 MjjConversationArchive — archive panel within the scene
1411 ===================================================================== */
1412
1413 class MjjConversationArchive extends HTMLElement {
1414 connectedCallback() {
1415 this._list = this.querySelector("[data-archive-list]");
1416 this._status = this.querySelector("[data-archive-status]");
1417 this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]");
1418 this._loadMoreButton = this.querySelector("[data-archive-load-more]");
1419 this._newButton = this.querySelector("[data-archive-new]");
1420 this._closeButton = this.querySelector("[data-archive-close]");
1421 this._deleteDialog = this.querySelector("[data-archive-delete-dialog]");
1422 this._deleteNameEl = this.querySelector("[data-archive-delete-name]");
1423 this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]");
1424 this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]");
1425 this._renameDialog = this.querySelector("[data-archive-rename-dialog]");
1426 this._renameInput = this.querySelector("[data-archive-rename-input]");
1427 this._renameForm = this.querySelector("[data-archive-rename-form]");
1428 this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]");
1429 this._pendingDeleteId = null;
1430 this._pendingDeleteTrigger = null;
1431 this._pendingRenameId = null;
1432 this._pendingRenameTrigger = null;
1433
1434 this._closeButton?.addEventListener("click", () => {
1435 this.dispatchEvent(new CustomEvent("mjj-archive-toggle", { bubbles: true }));
1436 });
1437
1438 this._newButton?.addEventListener("click", () => {
1439 this.dispatchEvent(new CustomEvent("mjj-archive-new", { bubbles: true }));
1440 });
1441
1442 this._loadMoreButton?.addEventListener("click", () => {
1443 this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true }));
1444 });
1445
1446 this._deleteCancelButton?.addEventListener("click", () => {
1447 this._deleteDialog?.close();
1448 const t = this._pendingDeleteTrigger;
1449 this._pendingDeleteId = null;
1450 this._pendingDeleteTrigger = null;
1451 t?.focus();
1452 });
1453
1454 this._deleteConfirmButton?.addEventListener("click", () => {
1455 const id = this._pendingDeleteId;
1456 const trigger = this._pendingDeleteTrigger;
1457 this._deleteDialog?.close();
1458 this._pendingDeleteId = null;
1459 this._pendingDeleteTrigger = null;
1460 if (id) {
1461 this.dispatchEvent(new CustomEvent("mjj-archive-delete", {
1462 bubbles: true,
1463 detail: { id, trigger },
1464 }));
1465 }
1466 });
1467
1468 this._deleteDialog?.addEventListener("close", () => {
1469 const t = this._pendingDeleteTrigger;
1470 this._pendingDeleteId = null;
1471 this._pendingDeleteTrigger = null;
1472 t?.focus();
1473 });
1474
1475 this._renameCancelButton?.addEventListener("click", () => {
1476 this._renameDialog?.close();
1477 const t = this._pendingRenameTrigger;
1478 this._pendingRenameId = null;
1479 this._pendingRenameTrigger = null;
1480 t?.focus();
1481 });
1482
1483 this._renameForm?.addEventListener("submit", event => {
1484 event.preventDefault();
1485 const id = this._pendingRenameId;
1486 const trigger = this._pendingRenameTrigger;
1487 const newTitle = this._renameInput?.value.trim();
1488 if (!id || !newTitle) return;
1489 this._renameDialog?.close();
1490 this._pendingRenameId = null;
1491 this._pendingRenameTrigger = null;
1492 this.dispatchEvent(new CustomEvent("mjj-archive-rename", {
1493 bubbles: true,
1494 detail: { id, newTitle, trigger },
1495 }));
1496 });
1497
1498 this._renameDialog?.addEventListener("close", () => {
1499 const t = this._pendingRenameTrigger;
1500 this._pendingRenameId = null;
1501 this._pendingRenameTrigger = null;
1502 t?.focus();
1503 });
1504
1505 this._onListClick = event => {
1506 const openButton = event.target.closest("[data-conv-open]");
1507 if (openButton) {
1508 const id = openButton.closest("[data-conv-item]")?._convId;
1509 if (id) {
1510 this.dispatchEvent(new CustomEvent("mjj-archive-open", {
1511 bubbles: true,
1512 detail: { id },
1513 }));
1514 }
1515 return;
1516 }
1517 const renameButton = event.target.closest("[data-conv-rename]");
1518 if (renameButton) {
1519 const item = renameButton.closest("[data-conv-item]");
1520 if (item?._convId) {
1521 this._pendingRenameId = item._convId;
1522 this._pendingRenameTrigger = renameButton;
1523 if (this._renameInput) this._renameInput.value = item._convTitle || "";
1524 this._renameDialog?.showModal();
1525 requestAnimationFrame(() => { this._renameInput?.select(); });
1526 }
1527 return;
1528 }
1529 const deleteButton = event.target.closest("[data-conv-delete]");
1530 if (deleteButton) {
1531 const item = deleteButton.closest("[data-conv-item]");
1532 if (item?._convId) {
1533 this._pendingDeleteId = item._convId;
1534 this._pendingDeleteTrigger = deleteButton;
1535 if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || "";
1536 this._deleteDialog?.showModal();
1537 requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); });
1538 }
1539 return;
1540 }
1541 };
1542 this._list?.addEventListener("click", this._onListClick);
1543
1544 this._onListKeydown = event => {
1545 if (!["ArrowUp", "ArrowDown"].includes(event.key)) return;
1546 const items = [...(this._list?.querySelectorAll("[data-conv-open]") || [])];
1547 const index = items.indexOf(document.activeElement);
1548 if (index < 0) return;
1549 event.preventDefault();
1550 const next = event.key === "ArrowDown"
1551 ? items[Math.min(index + 1, items.length - 1)]
1552 : items[Math.max(index - 1, 0)];
1553 next?.focus();
1554 };
1555 this._list?.addEventListener("keydown", this._onListKeydown);
1556 }
1557
1558 disconnectedCallback() {
1559 this._list?.removeEventListener("click", this._onListClick);
1560 this._list?.removeEventListener("keydown", this._onListKeydown);
1561 }
1562
1563 setConversations(conversations, cursor, append = false) {
1564 if (!this._list) return;
1565 if (!append) this._list.replaceChildren();
1566 for (const conv of conversations) this._list.append(this._createItem(conv));
1567 if (this._loadMoreOwner) this._loadMoreOwner.hidden = !cursor;
1568 if (!conversations.length && !append) {
1569 this._showStatus("No conversations yet. Start a new one!");
1570 } else {
1571 this._hideStatus();
1572 }
1573 }
1574
1575 setCurrentId(id) {
1576 for (const button of (this._list?.querySelectorAll("[data-conv-open]") || [])) {
1577 const isCurrent = id && button.closest("[data-conv-item]")?._convId === id;
1578 button.setAttribute("aria-current", String(Boolean(isCurrent)));
1579 }
1580 }
1581
1582 addConversation(conv) {
1583 if (!this._list) return;
1584 this._list.prepend(this._createItem(conv));
1585 this._hideStatus();
1586 }
1587
1588 updateConversation(conv) {
1589 if (!this._list) return;
1590 for (const item of this._list.querySelectorAll("[data-conv-item]")) {
1591 if (item._convId === conv.id) {
1592 const titleEl = item.querySelector("[data-conv-title]");
1593 const metaEl = item.querySelector("[data-conv-meta]");
1594 if (titleEl) titleEl.textContent = conv.title;
1595 if (metaEl) metaEl.textContent = this._formatMeta(conv);
1596 item._convTitle = conv.title;
1597 return;
1598 }
1599 }
1600 this.addConversation(conv);
1601 }
1602
1603 removeConversation(id) {
1604 for (const item of (this._list?.querySelectorAll("[data-conv-item]") || [])) {
1605 if (item._convId === id) {
1606 item.remove();
1607 break;
1608 }
1609 }
1610 if (!this._list?.querySelector("[data-conv-item]")) {
1611 this._showStatus("No conversations yet. Start a new one!");
1612 }
1613 }
1614
1615 setLoading(loading) {
1616 if (loading) {
1617 this._showStatus("Loading conversations...");
1618 } else if (!this._list?.querySelector("[data-conv-item]")) {
1619 this._showStatus("No conversations yet. Start a new one!");
1620 } else {
1621 this._hideStatus();
1622 }
1623 }
1624
1625 setError(message) {
1626 this._showStatus(message);
1627 if (this._status) this._status.dataset.state = "error";
1628 }
1629
1630 setLoadMoreDisabled(disabled) {
1631 if (this._loadMoreButton) this._loadMoreButton.disabled = disabled;
1632 }
1633
1634 /* Disable/enable destructive archive controls while a stream is active. */
1635 setStreamActive(active) {
1636 const controls = this._list?.querySelectorAll(
1637 "[data-conv-delete], [data-conv-rename]",
1638 ) || [];
1639 for (const el of controls) el.disabled = active;
1640 if (this._newButton) this._newButton.disabled = active;
1641 if (this._deleteConfirmButton) this._deleteConfirmButton.disabled = active;
1642 this.dataset.streamActive = active ? "true" : "";
1643 if (!active) delete this.dataset.streamActive;
1644 }
1645
1646 /* Return the open-button of the adjacent surviving item, or New button. */
1647 adjacentItemOrNewButton(id) {
1648 const items = [...(this._list?.querySelectorAll("[data-conv-item]") || [])];
1649 const idx = items.findIndex(el => el._convId === id);
1650 if (idx < 0) return this._newButton;
1651 const sibling = items[idx + 1] || items[idx - 1];
1652 return sibling?.querySelector("[data-conv-open]") || this._newButton;
1653 }
1654
1655 showLegacyClaim(statusMessage = null, showButton = true) {
1656 if (!this._status) return;
1657 this._status.hidden = false;
1658 delete this._status.dataset.state;
1659 this._status.textContent = "";
1660 const msg = document.createElement("span");
1661 msg.textContent = statusMessage ?? "You have a prior conversation. ";
1662 this._status.append(msg);
1663 if (showButton) {
1664 const btn = document.createElement("button");
1665 btn.type = "button";
1666 btn.textContent = "Claim it";
1667 btn.dataset.archiveClaim = "";
1668 btn.addEventListener("click", () => {
1669 this.dispatchEvent(new CustomEvent("mjj-archive-claim", { bubbles: true }));
1670 });
1671 this._status.append(btn);
1672 }
1673 }
1674
1675 _showStatus(text) {
1676 if (this._status) {
1677 this._status.textContent = text;
1678 this._status.hidden = false;
1679 delete this._status.dataset.state;
1680 }
1681 }
1682
1683 _hideStatus() {
1684 if (this._status) this._status.hidden = true;
1685 }
1686
1687 _createItem(conv) {
1688 const item = document.createElement("li");
1689 item.dataset.convItem = "";
1690 item._convId = conv.id;
1691 item._convTitle = conv.title;
1692
1693 const openButton = document.createElement("button");
1694 openButton.type = "button";
1695 openButton.dataset.convOpen = "";
1696 openButton.setAttribute("aria-current", "false");
1697
1698 const titleEl = document.createElement("span");
1699 titleEl.dataset.convTitle = "";
1700 titleEl.textContent = conv.title;
1701
1702 const metaEl = document.createElement("small");
1703 metaEl.dataset.convMeta = "";
1704 metaEl.textContent = this._formatMeta(conv);
1705
1706 openButton.append(titleEl, metaEl);
1707
1708 const actions = document.createElement("div");
1709 actions.className = "jrpg-archive-item-actions";
1710
1711 const renameButton = document.createElement("button");
1712 renameButton.type = "button";
1713 renameButton.dataset.convRename = "";
1714 renameButton.setAttribute("aria-label", `Rename "${conv.title}"`);
1715 renameButton.textContent = "Rename";
1716
1717 const deleteButton = document.createElement("button");
1718 deleteButton.type = "button";
1719 deleteButton.dataset.convDelete = "";
1720 deleteButton.setAttribute("aria-label", `Delete "${conv.title}"`);
1721 deleteButton.textContent = "Delete";
1722
1723 actions.append(renameButton, deleteButton);
1724 item.append(openButton, actions);
1725 return item;
1726 }
1727
1728 _formatMeta(conv) {
1729 const count = conv.turn_count ?? 0;
1730 const turns = count === 1 ? "1 turn" : `${count} turns`;
1731 if (conv.last_message_preview) {
1732 return `${turns} · ${conv.last_message_preview.slice(0, 28)}`;
1733 }
1734 return turns;
1735 }
1736 }
1737
1738 class MjjJrpgShell extends HTMLElement {
1739 connectedCallback() {
1740 this._mobileMenuQuery = window.matchMedia(
1741 "(max-width: 52rem) and (orientation: portrait)",
1742 );
1743 this._syncMobileMenu = expanded => {
1744 const button = this.querySelector("[data-mobile-menu-toggle]");
1745 const menu = this.querySelector("mjj-jrpg-menu");
1746 if (!button || !menu) return;
1747 if (!this._mobileMenuQuery.matches) {
1748 button.setAttribute("aria-expanded", "true");
1749 menu.removeAttribute("data-mobile-menu-collapsed");
1750 return;
1751 }
1752 button.setAttribute("aria-expanded", String(expanded));
1753 menu.toggleAttribute("data-mobile-menu-collapsed", !expanded);
1754 };
1755 this._onMenuToggle = event => {
1756 const button = event.target.closest("[data-mobile-menu-toggle]");
1757 if (!button || !this.contains(button)) return;
1758 const expanded = button.getAttribute("aria-expanded") === "true";
1759 this._syncMobileMenu(!expanded);
1760 if (!expanded) {
1761 const firstItem = this.querySelector(
1762 "mjj-jrpg-menu button[data-preview]",
1763 );
1764 if (firstItem) firstItem.focus();
1765 }
1766 };
1767 this._onMobileMenuMediaChange = () => this._syncMobileMenu(true);
1768 this.addEventListener("click", this._onMenuToggle);
1769 this._mobileMenuQuery.addEventListener(
1770 "change",
1771 this._onMobileMenuMediaChange,
1772 );
1773 this._syncMobileMenu(true);
1774 }
1775
1776 disconnectedCallback() {
1777 this.removeEventListener("click", this._onMenuToggle);
1778 this._mobileMenuQuery?.removeEventListener(
1779 "change",
1780 this._onMobileMenuMediaChange,
1781 );
1782 }
1783 }
1405 1784
1406 for (const [name, constructor] of Object.entries({ 1785 for (const [name, constructor] of Object.entries({
1786 "mjj-conversation-archive": MjjConversationArchive,
1407 "mjj-jrpg-character": MjjJrpgCharacter, 1787 "mjj-jrpg-character": MjjJrpgCharacter,
1408 "mjj-jrpg-chat": MjjJrpgChat, 1788 "mjj-jrpg-chat": MjjJrpgChat,
1409 "mjj-jrpg-composer": MjjJrpgComposer, 1789 "mjj-jrpg-composer": MjjJrpgComposer,
1410 "mjj-jrpg-menu": MjjJrpgMenu, 1790 "mjj-jrpg-menu": MjjJrpgMenu,
1411 "mjj-jrpg-preview": MjjJrpgPreview, 1791 "mjj-jrpg-preview": MjjJrpgPreview,
1415 } 1795 }
1416 1796
1417 async function requestJson(url, options = {}) { 1797 async function requestJson(url, options = {}) {
1418 const response = await fetch(url, { 1798 const response = await fetch(url, {
1419 ...options, 1799 ...options,
1800 credentials: "same-origin",
1420 headers: { 1801 headers: {
1421 "Content-Type": "application/json", 1802 "Content-Type": "application/json",
1422 ...options.headers, 1803 ...options.headers,
1423 }, 1804 },
1424 }); 1805 });
1433 throw new Error(message); 1814 throw new Error(message);
1434 } 1815 }
1435 return response.status === 204 ? null : response.json(); 1816 return response.status === 204 ? null : response.json();
1436 } 1817 }
1437 1818
1819 /* Session state for CSRF: bootstrapped once, refreshed at most once on 401/403 */
1820 let _csrfToken = null;
1821 let _sessionData = null;
1822 let _sessionRefreshInFlight = null; /* single-flight guard */
1823
1824 function _principalFingerprint(data) {
1825 if (!data) return null;
1826 if (data.kind === "user") return `user:${data.username || ""}:${data.role || ""}`;
1827 if (data.kind === "guest") return `guest:${data.csrfToken || ""}`;
1828 return null;
1829 }
1830
1831 async function _fetchSession() {
1832 try {
1833 const response = await fetch("/api/auth/session", {
1834 credentials: "same-origin",
1835 });
1836 if (!response.ok) return null;
1837 const data = await response.json().catch(() => null);
1838 if (!data) return null;
1839 _sessionData = data;
1840 return data.csrfToken || null;
1841 } catch {
1842 return null;
1843 }
1844 }
1845
1846 /* Single-flight session refresh: dedupe concurrent callers. */
1847 async function _refreshSessionOnce() {
1848 if (_sessionRefreshInFlight) return _sessionRefreshInFlight;
1849 _sessionRefreshInFlight = (async () => {
1850 const prev = _principalFingerprint(_sessionData);
1851 const token = await _fetchSession();
1852 const next = _principalFingerprint(_sessionData);
1853 return { token, prev, next };
1854 })();
1855 try {
1856 return await _sessionRefreshInFlight;
1857 } finally {
1858 _sessionRefreshInFlight = null;
1859 }
1860 }
1861
1862 /*
1863 * Perform a state-changing (mutating) fetch with CSRF token and same-origin
1864 * credentials. On 401 or CSRF-specific 403 (csrf_invalid), refreshes the
1865 * session once and retries — but only if the refreshed principal matches the
1866 * original. If the user becomes a guest or a different account, rejects and
1867 * triggers the session-changed callback.
1868 */
1869 let _sessionRefreshCallback = null;
1870
1871 async function mutatingFetch(url, options = {}) {
1872 const buildHeaders = () => ({
1873 ...(options.headers || {}),
1874 Origin: window.location.origin,
1875 ...(_csrfToken ? { "X-CSRF-Token": _csrfToken } : {}),
1876 });
1877
1878 const response = await fetch(url, {
1879 ...options,
1880 credentials: "same-origin",
1881 headers: buildHeaders(),
1882 });
1883
1884 const shouldRefresh = response.status === 401 ||
1885 (response.status === 403 && await (async () => {
1886 try {
1887 const clone = response.clone();
1888 const body = await clone.json();
1889 return body?.error?.code === "csrf_invalid";
1890 } catch { return false; }
1891 })());
1892
1893 if (shouldRefresh) {
1894 const prevFingerprint = _principalFingerprint(_sessionData);
1895 const { token, next } = await _refreshSessionOnce();
1896 _csrfToken = token;
1897 if (next !== prevFingerprint) {
1898 if (_sessionRefreshCallback) _sessionRefreshCallback();
1899 const err = Object.assign(new Error("Session changed"), { status: 401 });
1900 throw err;
1901 }
1902 const retried = await fetch(url, {
1903 ...options,
1904 credentials: "same-origin",
1905 headers: buildHeaders(),
1906 });
1907 if (retried.status === 401 && _sessionRefreshCallback) {
1908 _sessionRefreshCallback();
1909 }
1910 return retried;
1911 }
1912 return response;
1913 }
1914
1438 async function consumeSse(response, onEvent) { 1915 async function consumeSse(response, onEvent) {
1439 if (!response.body) throw new Error("Streaming response body is unavailable"); 1916 if (!response.body) throw new Error("Streaming response body is unavailable");
1440 const reader = response.body.getReader(); 1917 const reader = response.body.getReader();
1441 const decoder = new TextDecoder(); 1918 const decoder = new TextDecoder();
1442 let buffer = ""; 1919 let buffer = "";
1465 if (done) break; 1942 if (done) break;
1466 } 1943 }
1467 if (buffer.trim()) dispatch(buffer); 1944 if (buffer.trim()) dispatch(buffer);
1468 } 1945 }
1469 1946
1947 /* =====================================================================
1948 Account slot rendering
1949 ===================================================================== */
1950
1951 function renderAccountSlot(slotEl, sessionData) {
1952 if (!slotEl) return;
1953 slotEl.textContent = "";
1954 if (!sessionData || sessionData.kind === "guest") {
1955 const owner = document.createElement("zen-button");
1956 const btn = document.createElement("button");
1957 owner.setAttribute("appearance", "plain");
1958 owner.setAttribute("size", "xs");
1959 btn.type = "button";
1960 btn.dataset.loginOpen = "";
1961 btn.textContent = "LOGIN";
1962 owner.append(btn);
1963 slotEl.setAttribute("aria-label", "Not signed in");
1964 slotEl.append(owner);
1965 return;
1966 }
1967 const username = (sessionData.username || "").slice(0, 8).toUpperCase();
1968 if (sessionData.mustChangePassword) {
1969 const link = document.createElement("a");
1970 link.href = "/account/password";
1971 link.textContent = username ? `${username}\u26A0` : "PASSWD";
1972 link.title = "Password change required";
1973 slotEl.setAttribute("aria-label", `${sessionData.username}: password change required`);
1974 slotEl.append(link);
1975 return;
1976 }
1977 const nameSpan = document.createElement("span");
1978 nameSpan.dataset.accountUser = "";
1979 nameSpan.textContent = username;
1980 slotEl.append(nameSpan);
1981 if (sessionData.role === "admin") {
1982 const adminLink = document.createElement("a");
1983 adminLink.href = "/admin/users";
1984 adminLink.textContent = "\u00A0ADM";
1985 slotEl.append(adminLink);
1986 }
1987 const logoutBtn = document.createElement("button");
1988 logoutBtn.type = "button";
1989 logoutBtn.textContent = "\u00A0LGOUT";
1990 logoutBtn.addEventListener("click", () => {
1991 logoutBtn.disabled = true;
1992 mutatingFetch("/api/auth/logout", { method: "POST" }).then(response => {
1993 if (!response.ok) throw new Error(`Logout failed (${response.status})`);
1994 window.dispatchEvent(new CustomEvent("mjj-logout"));
1995 }).catch(() => {
1996 logoutBtn.disabled = false;
1997 });
1998 });
1999 slotEl.append(logoutBtn);
2000 slotEl.setAttribute("aria-label", `Signed in as ${sessionData.username}`);
2001 }
2002
2003 /* =====================================================================
2004 Quota display helpers
2005 ===================================================================== */
2006
2007 function formatQuotaText(quota) {
2008 if (!quota) return null;
2009 const turns = quota.turnsRemaining ?? 0;
2010 const tokens = quota.outputTokensRemaining ?? 0;
2011 if (turns <= 0) {
2012 const resetAt = quota.resetsAt ? new Date(quota.resetsAt * 1000) : null;
2013 const resetStr = resetAt
2014 ? ` Resets ${resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
2015 : "";
2016 return { text: `Quota exhausted.${resetStr}`, state: "exhausted" };
2017 }
2018 if (turns <= 2) {
2019 return {
2020 text: `${turns} turn${turns === 1 ? "" : "s"} remaining (${tokens.toLocaleString()} tokens)`,
2021 state: "warning",
2022 };
2023 }
2024 return {
2025 text: `${turns} turns remaining (${tokens.toLocaleString()} tokens)`,
2026 state: "ok",
2027 };
2028 }
2029
2030 function updateQuotaDisplay(quotaEl, quota) {
2031 if (!quotaEl) return;
2032 if (!quota) {
2033 quotaEl.hidden = true;
2034 return;
2035 }
2036 const result = formatQuotaText(quota);
2037 /* Only show the bar for low or exhausted quota; hide when turns are plentiful */
2038 if (!result || result.state === "ok") {
2039 quotaEl.hidden = true;
2040 return;
2041 }
2042 quotaEl.textContent = result.text;
2043 if (result.state === "exhausted") {
2044 quotaEl.dataset.state = "exhausted";
2045 } else if (result.state === "warning") {
2046 quotaEl.dataset.state = "warning";
2047 } else {
2048 delete quotaEl.dataset.state;
2049 }
2050 quotaEl.hidden = false;
2051 }
2052
2053 /* =====================================================================
2054 Conversation archive helpers
2055 ===================================================================== */
2056
2057 async function fetchConversationList(cursor) {
2058 const url = cursor
2059 ? `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}`
2060 : `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}`;
2061 const response = await fetch(url, { credentials: "same-origin" });
2062 if (!response.ok) {
2063 let message = `Failed to load conversations (${response.status})`;
2064 try {
2065 const body = await response.json();
2066 message = body.error?.message || message;
2067 } catch { /* keep status message */ }
2068 throw new Error(message);
2069 }
2070 return response.json();
2071 }
2072
1470 async function initializeJrpgPage() { 2073 async function initializeJrpgPage() {
1471 const colorProbe = document.createElement("span"); 2074 const colorProbe = document.createElement("span");
1472 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; 2075 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)";
1473 document.body.append(colorProbe); 2076 document.body.append(colorProbe);
1474 const themeColor = getComputedStyle(colorProbe).backgroundColor; 2077 const themeColor = getComputedStyle(colorProbe).backgroundColor;
1475 colorProbe.remove(); 2078 colorProbe.remove();
1476 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { 2079 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
1477 meta.setAttribute("content", themeColor); 2080 meta.setAttribute("content", themeColor);
1478 } 2081 }
1479 2082
2083 /* Bootstrap session to obtain CSRF token and session data */
2084 _csrfToken = await _fetchSession();
2085
1480 const shell = document.querySelector("mjj-jrpg-shell"); 2086 const shell = document.querySelector("mjj-jrpg-shell");
1481 const character = shell?.querySelector("mjj-jrpg-character"); 2087 const character = shell?.querySelector("mjj-jrpg-character");
1482 const chat = shell?.querySelector("mjj-jrpg-chat"); 2088 const chat = shell?.querySelector("mjj-jrpg-chat");
1483 const composer = shell?.querySelector("mjj-jrpg-composer"); 2089 const composer = shell?.querySelector("mjj-jrpg-composer");
1484 const preview = shell?.querySelector("mjj-jrpg-preview"); 2090 const preview = shell?.querySelector("mjj-jrpg-preview");
2091 const archive = shell?.querySelector("mjj-conversation-archive");
2092 const accountSlot = shell?.querySelector("[data-frame-account]");
2093 const quotaEl = shell?.querySelector("[data-quota]");
1485 const frameNetwork = shell?.querySelector("[data-frame-network]"); 2094 const frameNetwork = shell?.querySelector("[data-frame-network]");
1486 const frameUptime = shell?.querySelector("[data-frame-uptime]"); 2095 const frameUptime = shell?.querySelector("[data-frame-uptime]");
1487 const minimizeButton = shell?.querySelector("[data-frame-minimize]"); 2096 const minimizeButton = shell?.querySelector("[data-frame-minimize]");
1488 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); 2097 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]");
2098
1489 let characterTimer = 0; 2099 let characterTimer = 0;
1490 let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); 2100 let currentConversationId = null;
2101 let currentPanel = "resume";
2102 let archiveCursor = null;
1491 let activeController = null; 2103 let activeController = null;
2104 let archiveLoadInFlight = false; /* serialize pagination requests */
2105 let openSeq = 0; /* sequence guard for conversation open */
2106 let popSeq = 0; /* sequence guard for popstate */
2107 let _pendingPopState = null; /* queued popstate received during active stream */
2108 let _claimInFlight = false; /* guard against duplicate concurrent claims */
2109 let legacyClaimId = sessionStorage.getItem(CONVERSATION_LEGACY_KEY);
2110 let principalEpoch = 0; /* rejects responses from a prior owner */
2111 /* Dedupe conversation IDs: set of IDs currently rendered in archive */
2112 const _renderedIds = new Set();
2113 const invalidatePrincipalRequests = () => {
2114 principalEpoch += 1;
2115 openSeq += 1;
2116 popSeq += 1;
2117 archiveCursor = null;
2118 };
2119
2120 /* Disable composer until fully initialized */
1492 composer?.setDisabled(true); 2121 composer?.setDisabled(true);
2122
1493 const setFrameStatus = (label, state, title = "") => { 2123 const setFrameStatus = (label, state, title = "") => {
1494 if (frameNetwork) { 2124 if (frameNetwork) {
1495 frameNetwork.textContent = label; 2125 frameNetwork.textContent = label;
1496 frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); 2126 frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`);
1497 frameNetwork.dataset.state = state; 2127 frameNetwork.dataset.state = state;
1498 frameNetwork.title = title; 2128 frameNetwork.title = title;
1499 } 2129 }
1500 }; 2130 };
2131
2132 /* Bootstrap failure: keep controls disabled and expose retry */
2133 if (!_csrfToken) {
2134 setFrameStatus("OFFLINE", "offline", "Bootstrap failed");
2135 archive?.setError("Connection failed. Reload to retry.");
2136 return;
2137 }
2138
1501 setFrameStatus("ONLINE", "online"); 2139 setFrameStatus("ONLINE", "online");
2140
1502 const startedAt = Date.now(); 2141 const startedAt = Date.now();
1503 const updateUptime = () => { 2142 const updateUptime = () => {
1504 const elapsed = Math.floor((Date.now() - startedAt) / 1000); 2143 const elapsed = Math.floor((Date.now() - startedAt) / 1000);
1505 const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); 2144 const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0");
1506 const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); 2145 const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0");
1534 "aria-pressed", 2173 "aria-pressed",
1535 String(Boolean(document.fullscreenElement)), 2174 String(Boolean(document.fullscreenElement)),
1536 ); 2175 );
1537 }); 2176 });
1538 2177
1539 if (conversationId) { 2178 /* Render account slot */
2179 renderAccountSlot(accountSlot, _sessionData);
2180
2181 /* ---- Login modal ---- */
2182 const loginDialogOwner = shell?.querySelector("[data-login-dialog-owner]");
2183 const loginDialog = loginDialogOwner?.querySelector("[data-login-dialog]");
2184 const loginForm = loginDialog?.querySelector("[data-login-form]");
2185 const loginSubmitBtn = loginDialog?.querySelector("[data-login-submit]");
2186 const loginCancelBtn = loginDialog?.querySelector("[data-login-cancel]");
2187 const loginErrorEl = loginDialog?.querySelector("[data-login-error]");
2188 const loginUsernameInput = loginDialog?.querySelector("#jrpg-login-username");
2189 const loginPasswordInput = loginDialog?.querySelector("#jrpg-login-password");
2190 let _loginTrigger = null;
2191 const setAccountActionsDisabled = disabled => {
2192 for (const control of accountSlot?.querySelectorAll("button") || []) {
2193 control.disabled = disabled;
2194 }
2195 };
2196
2197 const _clearLoginError = () => {
2198 if (loginErrorEl) {
2199 loginErrorEl.textContent = "";
2200 loginErrorEl.hidden = true;
2201 }
2202 };
2203
2204 const _showLoginError = (message) => {
2205 if (!loginErrorEl) return;
2206 loginErrorEl.textContent = message;
2207 loginErrorEl.hidden = false;
2208 };
2209
2210 const openLoginModal = (triggerEl) => {
2211 if (!loginDialogOwner || !loginDialog) return;
2212 _loginTrigger = triggerEl || null;
2213 _clearLoginError();
2214 loginForm?.reset();
2215 loginDialogOwner.open();
2216 requestAnimationFrame(() => { loginUsernameInput?.focus(); });
2217 };
2218
2219 /* Delegate clicks on data-login-open in the account slot */
2220 accountSlot?.addEventListener("click", event => {
2221 const btn = event.target.closest("[data-login-open]");
2222 if (!btn || activeController) return;
2223 openLoginModal(btn);
2224 });
2225
2226 /* Cancel button */
2227 loginCancelBtn?.addEventListener("click", () => { loginDialog?.close(); });
2228
2229 /* Cleanup on any close (Escape, X button, cancel) */
2230 loginDialog?.addEventListener("close", () => {
2231 if (loginPasswordInput) loginPasswordInput.value = "";
2232 _clearLoginError();
2233 const trigger = _loginTrigger;
2234 _loginTrigger = null;
2235 requestAnimationFrame(() => { trigger?.focus(); });
2236 });
2237
2238 /* Login form submission */
2239 loginForm?.addEventListener("submit", async event => {
2240 event.preventDefault();
2241 if (loginSubmitBtn?.disabled) return;
2242 const username = loginUsernameInput?.value.trim() || "";
2243 const password = loginPasswordInput?.value || "";
2244 if (!username || !password) {
2245 _showLoginError("Please enter your username and password.");
2246 return;
2247 }
2248 if (loginSubmitBtn) loginSubmitBtn.disabled = true;
2249 _clearLoginError();
1540 try { 2250 try {
1541 const conversation = await requestJson( 2251 const response = await fetch("/api/auth/login", {
1542 `/api/conversations/${encodeURIComponent(conversationId)}`, 2252 method: "POST",
1543 { headers: {} }, 2253 headers: {
2254 "Content-Type": "application/json",
2255 "Origin": window.location.origin,
2256 },
2257 credentials: "same-origin",
2258 body: JSON.stringify({ username, password, csrfToken: _csrfToken }),
2259 });
2260 if (loginPasswordInput) loginPasswordInput.value = "";
2261 if (response.status === 429) {
2262 _showLoginError("Too many attempts. Please try again later.");
2263 return;
2264 }
2265 if (!response.ok) {
2266 _showLoginError("Invalid username or password.");
2267 return;
2268 }
2269 let data = await response.json().catch(() => null);
2270 if (!data) {
2271 _csrfToken = await _fetchSession();
2272 if (_sessionData?.kind !== "user") {
2273 loginDialog?.close();
2274 window.location.reload();
2275 return;
2276 }
2277 data = _sessionData;
2278 }
2279 invalidatePrincipalRequests();
2280 loginDialog?.close();
2281 _sessionData = {
2282 kind: "user",
2283 username: data.username || username,
2284 role: data.role || "member",
2285 mustChangePassword: Boolean(data.mustChangePassword),
2286 csrfToken: data.csrfToken,
2287 quota: null,
2288 };
2289 _csrfToken = data.csrfToken || null;
2290 if (data.mustChangePassword) {
2291 window.location.href = "/account/password";
2292 return;
2293 }
2294 /* Commit the returned principal before optional UI refresh work. */
2295 renderAccountSlot(accountSlot, _sessionData);
2296 updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
2297 composer?.setDisabled(false);
2298 /* Clear conversation state — guest convs may transfer to user */
2299 currentConversationId = null;
2300 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2301 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
2302 legacyClaimId = null;
2303 _replaceUrlState(currentPanel, null);
2304 chat?.replaceMessages([]);
2305 try {
2306 await refreshArchiveFromScratch();
2307 } catch (error) {
2308 archive?.setError(`Signed in, but archive refresh failed: ${error.message}`);
2309 }
2310 requestAnimationFrame(() => {
2311 accountSlot?.querySelector("button, a")?.focus();
2312 });
2313 } catch {
2314 if (loginPasswordInput) loginPasswordInput.value = "";
2315 _showLoginError("Sign-in failed. Please try again.");
2316 } finally {
2317 if (loginSubmitBtn) loginSubmitBtn.disabled = false;
2318 }
2319 });
2320
2321 /* Handle logout */
2322 _sessionRefreshCallback = () => {
2323 /* Account changed or session lost — clear active state */
2324 invalidatePrincipalRequests();
2325 if (activeController) {
2326 activeController.abort();
2327 activeController = null;
2328 }
2329 currentConversationId = null;
2330 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2331 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
2332 legacyClaimId = null;
2333 _replaceUrlState(currentPanel, null);
2334 chat?.replaceMessages([]);
2335 _renderedIds.clear();
2336 archiveCursor = null;
2337 archive?.setConversations([], null);
2338 renderAccountSlot(accountSlot, _sessionData);
2339 archive?.setError("Session changed. Loading conversations...");
2340 void refreshArchiveFromScratch();
2341 };
2342 window.addEventListener("mjj-logout", () => {
2343 invalidatePrincipalRequests();
2344 if (activeController) {
2345 activeController.abort();
2346 activeController = null;
2347 }
2348 archive?.setStreamActive(false);
2349 currentConversationId = null;
2350 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2351 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
2352 legacyClaimId = null;
2353 _replaceUrlState(currentPanel, null);
2354 chat?.replaceMessages([]);
2355 _renderedIds.clear();
2356 archiveCursor = null;
2357 archive?.setConversations([], null);
2358 void refreshArchiveFromScratch();
2359 void (async () => {
2360 _csrfToken = await _fetchSession();
2361 renderAccountSlot(accountSlot, _sessionData);
2362 updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
2363 if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
2364 composer?.setDisabled(true);
2365 } else {
2366 composer?.setDisabled(false);
2367 }
2368 })();
2369 });
2370
2371 /* Session quota display */
2372 if (_sessionData?.kind === "guest") {
2373 updateQuotaDisplay(quotaEl, _sessionData.quota || null);
2374 }
2375
2376 /* Disable composer for forced-password-change */
2377 const isForced = _sessionData?.kind === "user" && _sessionData?.mustChangePassword;
2378
2379 /* ---- URL state helpers ---- */
2380 const _buildStateUrl = (panel, convId) => {
2381 const url = new URL(window.location.href);
2382 if (panel && VALID_PANELS.includes(panel)) {
2383 url.searchParams.set("panel", panel);
2384 } else {
2385 url.searchParams.delete("panel");
2386 }
2387 if (convId && UUID_REGEX.test(convId)) {
2388 url.searchParams.set("conversation", convId);
2389 } else {
2390 url.searchParams.delete("conversation");
2391 }
2392 return url;
2393 };
2394
2395 const _pushUrlState = (panel, convId) => {
2396 history.pushState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
2397 };
2398
2399 const _replaceUrlState = (panel, convId) => {
2400 history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
2401 };
2402
2403 /* ---- Panel selection: shows archive or preview in utility ---- */
2404 const utilityEl = shell?.querySelector(".jrpg-utility");
2405
2406 const selectPanel = (panel, pushHistory = true) => {
2407 const validPanel = VALID_PANELS.includes(panel) ? panel : "resume";
2408 currentPanel = validPanel;
2409 for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) {
2410 btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel));
2411 }
2412 if (validPanel === "conversations") {
2413 if (archive) archive.hidden = false;
2414 if (preview) preview.hidden = true;
2415 if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations");
2416 } else {
2417 if (archive) archive.hidden = true;
2418 if (preview) preview.hidden = false;
2419 preview?.show(validPanel);
2420 if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination");
2421 }
2422 if (pushHistory) _pushUrlState(validPanel, currentConversationId);
2423 };
2424
2425 /* ---- Archive close button: navigate back to resume panel ---- */
2426 shell?.addEventListener("mjj-archive-toggle", () => {
2427 selectPanel("resume");
2428 });
2429
2430 shell?.addEventListener("mjj-archive-new", () => {
2431 if (activeController) return;
2432 currentConversationId = null;
2433 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2434 _pushUrlState(currentPanel, null);
2435 chat?.replaceMessages([]);
2436 archive?.setCurrentId(null);
2437 composer?.querySelector("textarea")?.focus();
2438 });
2439
2440 /* ---- Popstate: re-apply panel and conversation on back/forward ---- */
2441 /* _applyPopState always increments openSeq so any pending archive-open
2442 fetch (including a transition to no conversation) cannot overwrite the
2443 state the user navigated to (Fix 2). */
2444 const _applyPopState = async (seq, popPanel, popConvId) => {
2445 const localSeq = ++openSeq;
2446 const epoch = principalEpoch;
2447
2448 selectPanel(popPanel, false);
2449
2450 if (popConvId && popConvId !== currentConversationId) {
2451 try {
2452 const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`);
2453 if (seq !== popSeq || localSeq !== openSeq ||
2454 epoch !== principalEpoch) return;
2455 currentConversationId = popConvId;
2456 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, popConvId);
2457 chat?.replaceMessages(conversation.turns);
2458 archive?.setCurrentId(popConvId);
2459 } catch {
2460 if (seq !== popSeq || localSeq !== openSeq ||
2461 epoch !== principalEpoch) return;
2462 currentConversationId = null;
2463 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2464 chat?.replaceMessages([]);
2465 archive?.setCurrentId(null);
2466 history.replaceState(
2467 { panel: popPanel, conversation: null },
2468 "",
2469 _buildStateUrl(popPanel, null),
2470 );
2471 }
2472 } else if (!popConvId && currentConversationId) {
2473 currentConversationId = null;
2474 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2475 chat?.replaceMessages([]);
2476 archive?.setCurrentId(null);
2477 }
2478 };
2479
2480 /* When popstate fires while a stream is active the URL has already changed.
2481 Queue the parsed destination, abort the stream, and apply exactly once in
2482 the stream's finally block so URL and UI always converge (Fix 1). */
2483 window.addEventListener("popstate", async event => {
2484 const seq = ++popSeq;
2485 ++openSeq;
2486 const state = event.state || {};
2487 const popPanel = VALID_PANELS.includes(state.panel) ? state.panel : "resume";
2488 const popConvId = (state.conversation && UUID_REGEX.test(state.conversation))
2489 ? state.conversation : null;
2490
2491 if (activeController) {
2492 _pendingPopState = { seq, popPanel, popConvId };
2493 activeController.abort();
2494 return;
2495 }
2496
2497 await _applyPopState(seq, popPanel, popConvId);
2498 });
2499
2500 shell?.addEventListener("mjj-archive-rename", async event => {
2501 const { id, newTitle, trigger } = event.detail;
2502 if (activeController) { trigger?.focus(); return; }
2503 try {
2504 const response = await mutatingFetch(
2505 `/api/conversations/${encodeURIComponent(id)}`,
2506 {
2507 method: "PATCH",
2508 headers: { "Content-Type": "application/json" },
2509 body: JSON.stringify({ title: newTitle }),
2510 },
1544 ); 2511 );
2512 if (!response.ok) {
2513 const body = await response.json().catch(() => ({}));
2514 throw new Error(body.error?.message || `Rename failed (${response.status})`);
2515 }
2516 archive?.updateConversation({ id, title: newTitle, turn_count: null });
2517 } catch (error) {
2518 archive?.setError(`Rename failed: ${error.message}`);
2519 } finally {
2520 trigger?.focus();
2521 }
2522 });
2523
2524 shell?.addEventListener("mjj-archive-open", async event => {
2525 const { id } = event.detail;
2526 if (id === currentConversationId) return;
2527 if (activeController) return;
2528 const seq = ++openSeq;
2529 const epoch = principalEpoch;
2530 try {
2531 const conversation = await requestJson(`/api/conversations/${encodeURIComponent(id)}`);
2532 if (seq !== openSeq || epoch !== principalEpoch) return;
2533 currentConversationId = id;
2534 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id);
2535 _pushUrlState(currentPanel, id);
1545 chat?.replaceMessages(conversation.turns); 2536 chat?.replaceMessages(conversation.turns);
2537 archive?.setCurrentId(id);
2538 } catch (error) {
2539 if (seq !== openSeq || epoch !== principalEpoch) return;
2540 archive?.setError(`Unable to open: ${error.message}`);
2541 }
2542 });
2543
2544 shell?.addEventListener("mjj-archive-delete", async event => {
2545 const { id, trigger } = event.detail;
2546 if (activeController) { trigger?.focus(); return; } /* block while streaming */
2547 try {
2548 const response = await mutatingFetch(
2549 `/api/conversations/${encodeURIComponent(id)}`,
2550 { method: "DELETE" },
2551 );
2552 if (response.ok || response.status === 404) {
2553 /* Focus adjacent archive item or New button before removing the node */
2554 const focusTarget = archive?.adjacentItemOrNewButton(id);
2555 _renderedIds.delete(id);
2556 archive?.removeConversation(id);
2557 if (id === currentConversationId) {
2558 currentConversationId = null;
2559 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2560 _replaceUrlState(currentPanel, null);
2561 chat?.replaceMessages([]);
2562 }
2563 /* Focus surviving element — never the detached trigger */
2564 requestAnimationFrame(() => {
2565 (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus();
2566 });
2567 } else {
2568 const body = await response.json().catch(() => ({}));
2569 archive?.setError(`Delete failed: ${body.error?.message || response.status}`);
2570 }
2571 } catch (error) {
2572 archive?.setError(`Delete failed: ${error.message}`);
2573 /* On error, the trigger is still attached — focus it */
2574 trigger?.focus();
2575 }
2576 });
2577
2578 shell?.addEventListener("mjj-archive-load-more", async () => {
2579 if (!archiveCursor || archiveLoadInFlight) return;
2580 archiveLoadInFlight = true;
2581 const epoch = principalEpoch;
2582 archive?.setLoadMoreDisabled(true);
2583 try {
2584 const data = await fetchConversationList(archiveCursor);
2585 if (epoch !== principalEpoch) return;
2586 archiveCursor = data.cursor || null;
2587 const newConvs = (data.conversations || []).filter(c => !_renderedIds.has(c.id));
2588 newConvs.forEach(c => _renderedIds.add(c.id));
2589 archive?.setConversations(newConvs, archiveCursor, true);
2590 } catch (error) {
2591 archive?.setError(`Load failed: ${error.message}`);
2592 } finally {
2593 archiveLoadInFlight = false;
2594 archive?.setLoadMoreDisabled(false);
2595 }
2596 });
2597
2598 shell?.addEventListener("mjj-archive-claim", async () => {
2599 const legacyId =
2600 legacyClaimId || sessionStorage.getItem(CONVERSATION_LEGACY_KEY);
2601 if (!legacyId || _sessionData?.kind !== "user" || _claimInFlight) return;
2602 _claimInFlight = true;
2603 archive?.showLegacyClaim("Claiming prior conversation...", false);
2604 let _claimOutcome = null;
2605 try {
2606 const response = await mutatingFetch(
2607 "/api/conversations/claim",
2608 {
2609 method: "POST",
2610 headers: { "Content-Type": "application/json" },
2611 body: JSON.stringify({ conversationId: legacyId }),
2612 },
2613 );
2614 if (response.ok) {
2615 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
2616 legacyClaimId = null;
2617 _claimOutcome = "success";
2618 } else if (response.status === 409) {
2619 /* Definitive non-legacy conflict: clear key, no retry needed */
2620 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
2621 legacyClaimId = null;
2622 _claimOutcome = "conflict";
2623 } else {
2624 /* Retryable (401, 403, 429, 5xx): retain key */
2625 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId);
2626 legacyClaimId = legacyId;
2627 _claimOutcome = "retry";
2628 }
1546 } catch { 2629 } catch {
1547 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); 2630 /* Network error: retain key */
1548 conversationId = null; 2631 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId);
1549 } 2632 legacyClaimId = legacyId;
2633 _claimOutcome = "network-error";
2634 } finally {
2635 /* Reset guard BEFORE re-rendering so the retry button is immediately usable */
2636 _claimInFlight = false;
2637 if (_claimOutcome === "success") {
2638 void refreshArchiveFromScratch();
2639 } else if (_claimOutcome === "conflict") {
2640 archive?.showLegacyClaim("Prior conversation already claimed.", false);
2641 } else if (_claimOutcome === "retry") {
2642 selectPanel("conversations", false);
2643 archive?.showLegacyClaim("Claim failed. Try again. ");
2644 } else if (_claimOutcome === "network-error") {
2645 selectPanel("conversations", false);
2646 archive?.showLegacyClaim("Claim failed. Check connection. ");
2647 }
2648 }
2649 });
2650
2651 async function refreshArchiveFromScratch() {
2652 if (!archive) return;
2653 const epoch = principalEpoch;
2654 archive.setLoading(true);
2655 _renderedIds.clear();
2656 try {
2657 const data = await fetchConversationList(null);
2658 if (epoch !== principalEpoch) return;
2659 archiveCursor = data.cursor || null;
2660 const convs = data.conversations || [];
2661 convs.forEach(c => _renderedIds.add(c.id));
2662 archive.setConversations(convs, archiveCursor);
2663 archive.setCurrentId(currentConversationId);
2664 } catch (error) {
2665 archive.setError(`Archive error: ${error.message}`);
2666 }
2667 }
2668
2669 async function refreshArchiveHead() {
2670 if (!archive) return;
2671 const epoch = principalEpoch;
2672 try {
2673 const data = await fetchConversationList(null);
2674 if (epoch !== principalEpoch) return;
2675 const fresh = data.conversations || [];
2676 for (const conv of fresh) archive.updateConversation(conv);
2677 if (fresh.length > 0) archive.setCurrentId(currentConversationId);
2678 } catch { /* silent refresh failure */ }
2679 }
2680
2681 /* ---- Parse and validate URL params at startup ---- */
2682 /* Track whether ?conversation was explicitly present (even if malformed)
2683 so we never migrate sessionStorage when the param was given but invalid. */
2684 let _convParamWasExplicit = false;
2685 {
2686 const initParams = new URLSearchParams(window.location.search);
2687 const rawPanel = initParams.get("panel");
2688 const rawConvId = initParams.get("conversation");
2689 _convParamWasExplicit = rawConvId !== null;
2690 const validPanel = VALID_PANELS.includes(rawPanel) ? rawPanel : null;
2691 const validConvId = (rawConvId && UUID_REGEX.test(rawConvId)) ? rawConvId : null;
2692 /* Remove invalid params with replaceState */
2693 if ((rawPanel && !validPanel) || (rawConvId && !validConvId)) {
2694 const normalUrl = new URL(window.location.href);
2695 if (!validPanel) normalUrl.searchParams.delete("panel");
2696 if (!validConvId) normalUrl.searchParams.delete("conversation");
2697 history.replaceState(
2698 { panel: validPanel, conversation: validConvId },
2699 "",
2700 normalUrl,
2701 );
2702 }
2703 currentPanel = validPanel || "resume";
2704 }
2705
2706 /* ---- Load archive and restore conversation from URL or sessionStorage ---- */
2707 const startupEpoch = principalEpoch;
2708 const ensureStartupPrincipal = () => {
2709 if (startupEpoch !== principalEpoch) {
2710 const error = new Error("Principal changed during startup");
2711 error.name = "StalePrincipalError";
2712 throw error;
2713 }
2714 };
2715 archive?.setLoading(true);
2716 try {
2717 const data = await fetchConversationList(null);
2718 ensureStartupPrincipal();
2719 archiveCursor = data.cursor || null;
2720 const conversations = data.conversations || [];
2721 conversations.forEach(c => _renderedIds.add(c.id));
2722 archive?.setConversations(conversations, archiveCursor);
2723
2724 /* Determine target conversation: URL param is canonical; migrate sessionStorage once */
2725 const initParams2 = new URLSearchParams(window.location.search);
2726 const urlConvId = initParams2.get("conversation");
2727 const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null;
2728
2729 let resolvedConvId = urlConvValid;
2730 let migratedFromStorage = false;
2731 if (!resolvedConvId && !_convParamWasExplicit) {
2732 const stored = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
2733 if (stored && UUID_REGEX.test(stored)) {
2734 resolvedConvId = stored;
2735 migratedFromStorage = true;
2736 }
2737 }
2738
2739 if (resolvedConvId) {
2740 const inArchive = conversations.find(c => c.id === resolvedConvId);
2741 if (inArchive) {
2742 try {
2743 const conversation = await requestJson(
2744 `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
2745 );
2746 ensureStartupPrincipal();
2747 currentConversationId = resolvedConvId;
2748 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
2749 chat?.replaceMessages(conversation.turns);
2750 archive?.setCurrentId(resolvedConvId);
2751 } catch (error) {
2752 if (error.name === "StalePrincipalError" ||
2753 startupEpoch !== principalEpoch) throw error;
2754 currentConversationId = null;
2755 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2756 }
2757 } else {
2758 /* Not on first page — fetch directly to verify ownership */
2759 let ownedConversation = null;
2760 try {
2761 ownedConversation = await requestJson(
2762 `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
2763 );
2764 ensureStartupPrincipal();
2765 } catch (error) {
2766 if (error.name === "StalePrincipalError" ||
2767 startupEpoch !== principalEpoch) throw error;
2768 ownedConversation = null;
2769 }
2770 if (ownedConversation) {
2771 currentConversationId = resolvedConvId;
2772 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
2773 chat?.replaceMessages(ownedConversation.turns);
2774 if (!_renderedIds.has(resolvedConvId)) {
2775 _renderedIds.add(resolvedConvId);
2776 archive?.addConversation({
2777 id: resolvedConvId,
2778 title: ownedConversation.title || "Conversation",
2779 turn_count: ownedConversation.turns?.length || 0,
2780 });
2781 }
2782 archive?.setCurrentId(resolvedConvId);
2783 } else if (_sessionData?.kind === "user") {
2784 /* Authenticated and 404 — offer legacy claim for sessionStorage migrations */
2785 if (migratedFromStorage) {
2786 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, resolvedConvId);
2787 legacyClaimId = resolvedConvId;
2788 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2789 archive?.showLegacyClaim();
2790 if (conversations.length > 0) {
2791 currentConversationId = conversations[0].id;
2792 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
2793 try {
2794 const conv = await requestJson(
2795 `/api/conversations/${encodeURIComponent(currentConversationId)}`,
2796 );
2797 ensureStartupPrincipal();
2798 chat?.replaceMessages(conv.turns);
2799 archive?.setCurrentId(currentConversationId);
2800 } catch (error) {
2801 if (error.name === "StalePrincipalError" ||
2802 startupEpoch !== principalEpoch) throw error;
2803 currentConversationId = null;
2804 }
2805 }
2806 } else {
2807 /* URL-specified conv returned 404 — clear it */
2808 currentConversationId = null;
2809 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2810 }
2811 } else {
2812 /* Guest — not owned, clear stale ID */
2813 currentConversationId = null;
2814 if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2815 }
2816 }
2817 } else if (conversations.length > 0) {
2818 /* No target conversation — open the most recent */
2819 const most_recent = conversations[0];
2820 try {
2821 const conversation = await requestJson(
2822 `/api/conversations/${encodeURIComponent(most_recent.id)}`,
2823 );
2824 ensureStartupPrincipal();
2825 currentConversationId = most_recent.id;
2826 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
2827 chat?.replaceMessages(conversation.turns);
2828 archive?.setCurrentId(most_recent.id);
2829 } catch (error) {
2830 if (error.name === "StalePrincipalError" ||
2831 startupEpoch !== principalEpoch) throw error;
2832 currentConversationId = null;
2833 }
2834 }
2835 } catch (error) {
2836 if (error.name !== "StalePrincipalError" &&
2837 startupEpoch === principalEpoch) {
2838 archive?.setError(`Archive error: ${error.message}`);
2839 }
2840 /* Fall back to direct fetch of URL/stored ID */
2841 const initParams3 = new URLSearchParams(window.location.search);
2842 const fallbackId = (
2843 initParams3.get("conversation") ||
2844 (!_convParamWasExplicit
2845 ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
2846 : null)
2847 ) || null;
2848 if (startupEpoch === principalEpoch &&
2849 fallbackId && UUID_REGEX.test(fallbackId)) {
2850 try {
2851 const conversation = await requestJson(
2852 `/api/conversations/${encodeURIComponent(fallbackId)}`,
2853 );
2854 ensureStartupPrincipal();
2855 currentConversationId = fallbackId;
2856 chat?.replaceMessages(conversation.turns);
2857 } catch (fallbackError) {
2858 if (fallbackError.name === "StalePrincipalError" ||
2859 startupEpoch !== principalEpoch) {
2860 // The new principal owns subsequent rendering.
2861 } else {
2862 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2863 currentConversationId = null;
2864 }
2865 }
2866 }
2867 }
2868
2869 /* Apply startup panel and normalize URL (replaceState — not a user navigation) */
2870 if (startupEpoch === principalEpoch) {
2871 selectPanel(currentPanel, false);
2872 _replaceUrlState(currentPanel, currentConversationId);
1550 } 2873 }
1551 2874
1552 shell?.addEventListener("mjj-jrpg-preview-change", event => { 2875 shell?.addEventListener("mjj-jrpg-preview-change", event => {
1553 preview?.show(event.detail.selection); 2876 selectPanel(event.detail.selection);
1554 character?.setAttribute("state", "thinking"); 2877 character?.setAttribute("state", "thinking");
1555 window.clearTimeout(characterTimer); 2878 window.clearTimeout(characterTimer);
1556 characterTimer = window.setTimeout(() => { 2879 characterTimer = window.setTimeout(() => {
1557 character?.setAttribute("state", "default"); 2880 character?.setAttribute("state", "default");
1558 }, 650); 2881 }, 650);
1562 activeController?.abort(); 2885 activeController?.abort();
1563 }); 2886 });
1564 2887
1565 shell?.addEventListener("mjj-jrpg-submit", async event => { 2888 shell?.addEventListener("mjj-jrpg-submit", async event => {
1566 if (activeController) return; 2889 if (activeController) return;
2890 /* Block sends when password change is required */
2891 if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
2892 return;
2893 }
1567 chat?.appendMessage("June", event.detail.message); 2894 chat?.appendMessage("June", event.detail.message);
1568 composer?.setBusy(true); 2895 composer?.setBusy(true);
1569 character?.setAttribute("state", "thinking"); 2896 character?.setAttribute("state", "thinking");
1570 window.clearTimeout(characterTimer); 2897 window.clearTimeout(characterTimer);
1571 const assistantItem = chat?.startAssistantMessage(); 2898 const assistantItem = chat?.startAssistantMessage();
1577 activeController = new AbortController(); 2904 activeController = new AbortController();
1578 activeController.signal.addEventListener("abort", () => { 2905 activeController.signal.addEventListener("abort", () => {
1579 assistantText.cancel(); 2906 assistantText.cancel();
1580 }, { once: true }); 2907 }, { once: true });
1581 2908
2909 /* Disable archive/account mutations while stream is active (finding #7) */
2910 archive?.setStreamActive(true);
2911 setAccountActionsDisabled(true);
2912
1582 try { 2913 try {
1583 const createConversation = async () => { 2914 const createConversation = async () => {
1584 const conversation = await requestJson("/api/conversations", { 2915 const resp = await mutatingFetch("/api/conversations", {
1585 method: "POST", 2916 method: "POST",
2917 headers: { "Content-Type": "application/json" },
1586 body: JSON.stringify({ title: "Shiba Quest" }), 2918 body: JSON.stringify({ title: "Shiba Quest" }),
1587 }); 2919 });
1588 conversationId = conversation.id; 2920 if (!resp.ok) {
1589 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId); 2921 let message = `Create failed (${resp.status})`;
2922 try {
2923 const body = await resp.json();
2924 message = body.error?.message || message;
2925 } catch { /* keep */ }
2926 throw new Error(message);
2927 }
2928 const conversation = await resp.json();
2929 currentConversationId = conversation.id;
2930 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
2931 _pushUrlState(currentPanel, currentConversationId);
2932 if (!_renderedIds.has(currentConversationId)) {
2933 _renderedIds.add(currentConversationId);
2934 archive?.addConversation({
2935 id: currentConversationId,
2936 title: "Shiba Quest",
2937 turn_count: 0,
2938 last_message_preview: event.detail.message.slice(0, 32),
2939 });
2940 }
2941 archive?.setCurrentId(currentConversationId);
1590 }; 2942 };
1591 if (!conversationId) { 2943
2944 if (!currentConversationId) {
1592 await createConversation(); 2945 await createConversation();
1593 } 2946 }
1594 2947
1595 const postTurn = () => fetch( 2948 const postTurn = () => mutatingFetch(
1596 `/api/conversations/${encodeURIComponent(conversationId)}/turns`, { 2949 `/api/conversations/${encodeURIComponent(currentConversationId)}/turns`,
2950 {
1597 method: "POST", 2951 method: "POST",
1598 headers: { "Content-Type": "application/json" }, 2952 headers: { "Content-Type": "application/json" },
1599 body: JSON.stringify({ prompt: event.detail.message }), 2953 body: JSON.stringify({ prompt: event.detail.message }),
1600 signal: activeController.signal, 2954 signal: activeController.signal,
1601 }, 2955 },
1602 ); 2956 );
2957
1603 let response = await postTurn(); 2958 let response = await postTurn();
2959
2960 /* Handle 404: conversation may have expired — recreate it */
1604 if (response.status === 404) { 2961 if (response.status === 404) {
1605 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); 2962 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
1606 conversationId = null; 2963 currentConversationId = null;
1607 await createConversation(); 2964 await createConversation();
1608 response = await postTurn(); 2965 response = await postTurn();
1609 } 2966 }
2967
2968 /* Handle 429: distinguish permanent quota from temporary capacity */
2969 if (response.status === 429) {
2970 let errorBody = null;
2971 try { errorBody = await response.json(); } catch { /* keep */ }
2972 const code = errorBody?.error?.code || "";
2973 const isQuotaExhausted =
2974 code === "guest_quota_turns_exhausted" ||
2975 code === "guest_quota_tokens_exhausted" ||
2976 code === "guest_turn_quota_exhausted" ||
2977 code === "guest_token_quota_exhausted";
2978
2979 if (isQuotaExhausted) {
2980 const quotaState = errorBody?.error?.quota || null;
2981 updateQuotaDisplay(quotaEl, quotaState);
2982 composer?.setDisabled(true);
2983 chat?.setMessageStreaming(assistantItem, false);
2984 chat?.setMessageText(
2985 assistantItem,
2986 quotaState
2987 ? `Quota exhausted. ${formatQuotaText(quotaState)?.text || ""}`
2988 : "Quota exhausted. Try again later.",
2989 );
2990 character?.setAttribute("state", "sad");
2991 setFrameStatus("QUOTA", "offline");
2992 /* Re-enable when quota resets */
2993 const resetsAt = quotaState?.resetsAt;
2994 if (resetsAt) {
2995 const delay = Math.max(0, resetsAt * 1000 - Date.now());
2996 window.setTimeout(() => {
2997 if (_sessionData?.kind !== "user" || !_sessionData?.mustChangePassword) {
2998 composer?.setDisabled(false);
2999 setFrameStatus("ONLINE", "online");
3000 }
3001 }, Math.min(delay + 1000, 24 * 3600 * 1000));
3002 }
3003 } else {
3004 /* Temporary capacity 429 — use Retry-After or default 5 s */
3005 const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
3006 chat?.setMessageStreaming(assistantItem, false);
3007 chat?.setMessageText(assistantItem, "Server busy. Retrying shortly…");
3008 character?.setAttribute("state", "sad");
3009 setFrameStatus("BUSY", "offline");
3010 window.setTimeout(() => {
3011 if (!activeController?.signal.aborted) {
3012 setFrameStatus("ONLINE", "online");
3013 composer?.setDisabled(false);
3014 }
3015 }, retryAfter * 1000);
3016 }
3017 return;
3018 }
3019
1610 if (!response.ok) { 3020 if (!response.ok) {
1611 let message = `Turn failed (${response.status})`; 3021 let message = `Turn failed (${response.status})`;
1612 try { 3022 try {
1613 const body = await response.json(); 3023 const body = await response.json();
1614 message = body.error?.message || message; 3024 message = body.error?.message || message;
1615 } catch { 3025 } catch { /* keep */ }
1616 // Preserve the status-derived message.
1617 }
1618 throw new Error(message); 3026 throw new Error(message);
1619 } 3027 }
3028
1620 await consumeSse(response, (eventName, data) => { 3029 await consumeSse(response, (eventName, data) => {
1621 shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { 3030 shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", {
1622 bubbles: true, 3031 bubbles: true,
1623 detail: { 3032 detail: { data, type: eventName },
1624 data,
1625 type: eventName,
1626 },
1627 })); 3033 }));
1628 if (eventName === "assistant.delta") { 3034 if (eventName === "assistant.delta") {
1629 assistantText.append(data.delta || ""); 3035 assistantText.append(data.delta || "");
1630 } else if (eventName === "assistant.completed") { 3036 } else if (eventName === "assistant.completed") {
1631 assistantText.complete(data.content); 3037 assistantText.complete(data.content);
1641 data.aborted ? "Inference turn aborted" : "Inference turn failed", 3047 data.aborted ? "Inference turn aborted" : "Inference turn failed",
1642 ); 3048 );
1643 } 3049 }
1644 } 3050 }
1645 }); 3051 });
3052
1646 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); 3053 if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
1647 await assistantText.waitForIdle(); 3054 await assistantText.waitForIdle();
1648 if (activeController.signal.aborted) { 3055 if (activeController.signal.aborted) {
1649 throw new DOMException("Quest cancelled", "AbortError"); 3056 throw new DOMException("Quest cancelled", "AbortError");
1650 } 3057 }
1651 chat?.setMessageStreaming(assistantItem, false); 3058 chat?.setMessageStreaming(assistantItem, false);
1652 character?.setAttribute("state", "default"); 3059 character?.setAttribute("state", "default");
1653 setFrameStatus("ONLINE", "online"); 3060 setFrameStatus("ONLINE", "online");
3061
3062 /* Refresh archive and quota after successful turn */
3063 void refreshArchiveHead();
3064 if (_sessionData?.kind === "guest") {
3065 _csrfToken = await _fetchSession();
3066 updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
3067 }
1654 } catch (error) { 3068 } catch (error) {
1655 const aborted = error instanceof DOMException && error.name === "AbortError"; 3069 const aborted = error instanceof DOMException && error.name === "AbortError";
1656 assistantText.cancel(); 3070 assistantText.cancel();
1657 chat?.setMessageStreaming(assistantItem, false); 3071 chat?.setMessageStreaming(assistantItem, false);
1658 chat?.setMessageText( 3072 chat?.setMessageText(
1663 if (!aborted) setFrameStatus("OFFLINE", "offline"); 3077 if (!aborted) setFrameStatus("OFFLINE", "offline");
1664 } finally { 3078 } finally {
1665 composer?.setBusy(false); 3079 composer?.setBusy(false);
1666 composer?.querySelector("textarea")?.focus(); 3080 composer?.querySelector("textarea")?.focus();
1667 activeController = null; 3081 activeController = null;
3082 /* Re-enable archive/account mutations exactly once */
3083 archive?.setStreamActive(false);
3084 setAccountActionsDisabled(false);
3085 /* Apply popstate that was queued during the stream (Fix 1) */
3086 if (_pendingPopState) {
3087 const pending = _pendingPopState;
3088 _pendingPopState = null;
3089 if (pending.seq === popSeq) {
3090 await _applyPopState(pending.seq, pending.popPanel, pending.popConvId);
3091 }
3092 }
1668 } 3093 }
1669 }); 3094 });
1670 composer?.setDisabled(false); 3095
3096 /* Enable composer unless forced password change */
3097 if (!isForced) {
3098 composer?.setDisabled(false);
3099 }
1671 } 3100 }
1672 3101
1673 if (document.readyState === "loading") { 3102 if (document.readyState === "loading") {
1674 document.addEventListener("DOMContentLoaded", () => { 3103 document.addEventListener("DOMContentLoaded", () => {
1675 void initializeJrpgPage(); 3104 void initializeJrpgPage();