Mercurial
diff 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 |
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js Thu Aug 06 11:31:30 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Fri Aug 07 07:34:12 2026 -0700 @@ -15,8 +15,7 @@ const PREVIEWS = Object.freeze({ resume: { - copy: "Experience, projects, and the systems I have helped build.", - kicker: "CHARACTER RECORD", + 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.", title: "Resume", url: "/resume", works: [ @@ -27,27 +26,22 @@ url: "/resume", }, { - detail: "Agentic execution", + detail: "Led engineering", label: "Copilot Tasks", url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", }, { - detail: "AI platform", - label: "Copilot SuperApp", + detail: "Foundational platform", + label: "AIX Harness / Copilot", url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", }, { - detail: "Build 2026", - label: "Code", + detail: "Build 2026 products", + label: "Code & Autopilot", url: "https://news.microsoft.com/build-2026/", }, { - detail: "Personal agent", - label: "Autopilot", - url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", - }, - { - detail: "Ads systems", + detail: "Full-stack ads systems", label: "Meta", url: "https://www.meta.com/", }, @@ -56,11 +50,20 @@ label: "Google", url: "https://www.google.com/", }, + { + detail: "Technical lead", + label: "Warner Music Group", + url: "https://www.wmg.com/", + }, + { + detail: "Personal agent launch", + label: "Microsoft Scout", + url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", + }, ], }, tools: { - copy: "Small, focused utilities for writing, media, and experimentation.", - kicker: "ITEM INVENTORY", + copy: "Useful browser tools backed by first-party C, WASM, media, and document-processing systems.", title: "Tools", url: "/tools", works: [ @@ -70,8 +73,7 @@ ], }, blog: { - copy: "Notes from building systems, games, web tools, and curious prototypes.", - kicker: "QUEST ARCHIVE", + copy: "Technical writing about networking, rendering, performance, developer tooling, and experiments.", title: "Blogs", url: "/blog", works: [ @@ -81,8 +83,12 @@ }); const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; +const CONVERSATION_LEGACY_KEY = "mjj-jrpg-legacy-claim"; +const ARCHIVE_PAGE_SIZE = 20; const TYPING_INTERVAL_MS = 18; const SCRIPT_LOADS = new Map(); +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]); const TOOL_ICON_NAMES = Object.freeze({ "/notes": "repository", "/tools/file_converter": "retry", @@ -598,7 +604,6 @@ this.cleanupTool(); const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; - this.querySelector("[data-preview-kicker]").textContent = preview.kicker; this.querySelector("[data-preview-title]").textContent = preview.title; this.querySelector("[data-preview-copy]").textContent = preview.copy; this.querySelector("[data-dialog-title]").textContent = preview.title; @@ -1401,9 +1406,384 @@ } } -class MjjJrpgShell extends HTMLElement {} +/* ===================================================================== + MjjConversationArchive — archive panel within the scene + ===================================================================== */ + +class MjjConversationArchive extends HTMLElement { + connectedCallback() { + this._list = this.querySelector("[data-archive-list]"); + this._status = this.querySelector("[data-archive-status]"); + this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]"); + this._loadMoreButton = this.querySelector("[data-archive-load-more]"); + this._newButton = this.querySelector("[data-archive-new]"); + this._closeButton = this.querySelector("[data-archive-close]"); + this._deleteDialog = this.querySelector("[data-archive-delete-dialog]"); + this._deleteNameEl = this.querySelector("[data-archive-delete-name]"); + this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]"); + this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]"); + this._renameDialog = this.querySelector("[data-archive-rename-dialog]"); + this._renameInput = this.querySelector("[data-archive-rename-input]"); + this._renameForm = this.querySelector("[data-archive-rename-form]"); + this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]"); + this._pendingDeleteId = null; + this._pendingDeleteTrigger = null; + this._pendingRenameId = null; + this._pendingRenameTrigger = null; + + this._closeButton?.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("mjj-archive-toggle", { bubbles: true })); + }); + + this._newButton?.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("mjj-archive-new", { bubbles: true })); + }); + + this._loadMoreButton?.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true })); + }); + + this._deleteCancelButton?.addEventListener("click", () => { + this._deleteDialog?.close(); + const t = this._pendingDeleteTrigger; + this._pendingDeleteId = null; + this._pendingDeleteTrigger = null; + t?.focus(); + }); + + this._deleteConfirmButton?.addEventListener("click", () => { + const id = this._pendingDeleteId; + const trigger = this._pendingDeleteTrigger; + this._deleteDialog?.close(); + this._pendingDeleteId = null; + this._pendingDeleteTrigger = null; + if (id) { + this.dispatchEvent(new CustomEvent("mjj-archive-delete", { + bubbles: true, + detail: { id, trigger }, + })); + } + }); + + this._deleteDialog?.addEventListener("close", () => { + const t = this._pendingDeleteTrigger; + this._pendingDeleteId = null; + this._pendingDeleteTrigger = null; + t?.focus(); + }); + + this._renameCancelButton?.addEventListener("click", () => { + this._renameDialog?.close(); + const t = this._pendingRenameTrigger; + this._pendingRenameId = null; + this._pendingRenameTrigger = null; + t?.focus(); + }); + + this._renameForm?.addEventListener("submit", event => { + event.preventDefault(); + const id = this._pendingRenameId; + const trigger = this._pendingRenameTrigger; + const newTitle = this._renameInput?.value.trim(); + if (!id || !newTitle) return; + this._renameDialog?.close(); + this._pendingRenameId = null; + this._pendingRenameTrigger = null; + this.dispatchEvent(new CustomEvent("mjj-archive-rename", { + bubbles: true, + detail: { id, newTitle, trigger }, + })); + }); + + this._renameDialog?.addEventListener("close", () => { + const t = this._pendingRenameTrigger; + this._pendingRenameId = null; + this._pendingRenameTrigger = null; + t?.focus(); + }); + + this._onListClick = event => { + const openButton = event.target.closest("[data-conv-open]"); + if (openButton) { + const id = openButton.closest("[data-conv-item]")?._convId; + if (id) { + this.dispatchEvent(new CustomEvent("mjj-archive-open", { + bubbles: true, + detail: { id }, + })); + } + return; + } + const renameButton = event.target.closest("[data-conv-rename]"); + if (renameButton) { + const item = renameButton.closest("[data-conv-item]"); + if (item?._convId) { + this._pendingRenameId = item._convId; + this._pendingRenameTrigger = renameButton; + if (this._renameInput) this._renameInput.value = item._convTitle || ""; + this._renameDialog?.showModal(); + requestAnimationFrame(() => { this._renameInput?.select(); }); + } + return; + } + const deleteButton = event.target.closest("[data-conv-delete]"); + if (deleteButton) { + const item = deleteButton.closest("[data-conv-item]"); + if (item?._convId) { + this._pendingDeleteId = item._convId; + this._pendingDeleteTrigger = deleteButton; + if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || ""; + this._deleteDialog?.showModal(); + requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); }); + } + return; + } + }; + this._list?.addEventListener("click", this._onListClick); + + this._onListKeydown = event => { + if (!["ArrowUp", "ArrowDown"].includes(event.key)) return; + const items = [...(this._list?.querySelectorAll("[data-conv-open]") || [])]; + const index = items.indexOf(document.activeElement); + if (index < 0) return; + event.preventDefault(); + const next = event.key === "ArrowDown" + ? items[Math.min(index + 1, items.length - 1)] + : items[Math.max(index - 1, 0)]; + next?.focus(); + }; + this._list?.addEventListener("keydown", this._onListKeydown); + } + + disconnectedCallback() { + this._list?.removeEventListener("click", this._onListClick); + this._list?.removeEventListener("keydown", this._onListKeydown); + } + + setConversations(conversations, cursor, append = false) { + if (!this._list) return; + if (!append) this._list.replaceChildren(); + for (const conv of conversations) this._list.append(this._createItem(conv)); + if (this._loadMoreOwner) this._loadMoreOwner.hidden = !cursor; + if (!conversations.length && !append) { + this._showStatus("No conversations yet. Start a new one!"); + } else { + this._hideStatus(); + } + } + + setCurrentId(id) { + for (const button of (this._list?.querySelectorAll("[data-conv-open]") || [])) { + const isCurrent = id && button.closest("[data-conv-item]")?._convId === id; + button.setAttribute("aria-current", String(Boolean(isCurrent))); + } + } + + addConversation(conv) { + if (!this._list) return; + this._list.prepend(this._createItem(conv)); + this._hideStatus(); + } + + updateConversation(conv) { + if (!this._list) return; + for (const item of this._list.querySelectorAll("[data-conv-item]")) { + if (item._convId === conv.id) { + const titleEl = item.querySelector("[data-conv-title]"); + const metaEl = item.querySelector("[data-conv-meta]"); + if (titleEl) titleEl.textContent = conv.title; + if (metaEl) metaEl.textContent = this._formatMeta(conv); + item._convTitle = conv.title; + return; + } + } + this.addConversation(conv); + } + + removeConversation(id) { + for (const item of (this._list?.querySelectorAll("[data-conv-item]") || [])) { + if (item._convId === id) { + item.remove(); + break; + } + } + if (!this._list?.querySelector("[data-conv-item]")) { + this._showStatus("No conversations yet. Start a new one!"); + } + } + + setLoading(loading) { + if (loading) { + this._showStatus("Loading conversations..."); + } else if (!this._list?.querySelector("[data-conv-item]")) { + this._showStatus("No conversations yet. Start a new one!"); + } else { + this._hideStatus(); + } + } + + setError(message) { + this._showStatus(message); + if (this._status) this._status.dataset.state = "error"; + } + + setLoadMoreDisabled(disabled) { + if (this._loadMoreButton) this._loadMoreButton.disabled = disabled; + } + + /* Disable/enable destructive archive controls while a stream is active. */ + setStreamActive(active) { + const controls = this._list?.querySelectorAll( + "[data-conv-delete], [data-conv-rename]", + ) || []; + for (const el of controls) el.disabled = active; + if (this._newButton) this._newButton.disabled = active; + if (this._deleteConfirmButton) this._deleteConfirmButton.disabled = active; + this.dataset.streamActive = active ? "true" : ""; + if (!active) delete this.dataset.streamActive; + } + + /* Return the open-button of the adjacent surviving item, or New button. */ + adjacentItemOrNewButton(id) { + const items = [...(this._list?.querySelectorAll("[data-conv-item]") || [])]; + const idx = items.findIndex(el => el._convId === id); + if (idx < 0) return this._newButton; + const sibling = items[idx + 1] || items[idx - 1]; + return sibling?.querySelector("[data-conv-open]") || this._newButton; + } + + showLegacyClaim(statusMessage = null, showButton = true) { + if (!this._status) return; + this._status.hidden = false; + delete this._status.dataset.state; + this._status.textContent = ""; + const msg = document.createElement("span"); + msg.textContent = statusMessage ?? "You have a prior conversation. "; + this._status.append(msg); + if (showButton) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Claim it"; + btn.dataset.archiveClaim = ""; + btn.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("mjj-archive-claim", { bubbles: true })); + }); + this._status.append(btn); + } + } + + _showStatus(text) { + if (this._status) { + this._status.textContent = text; + this._status.hidden = false; + delete this._status.dataset.state; + } + } + + _hideStatus() { + if (this._status) this._status.hidden = true; + } + + _createItem(conv) { + const item = document.createElement("li"); + item.dataset.convItem = ""; + item._convId = conv.id; + item._convTitle = conv.title; + + const openButton = document.createElement("button"); + openButton.type = "button"; + openButton.dataset.convOpen = ""; + openButton.setAttribute("aria-current", "false"); + + const titleEl = document.createElement("span"); + titleEl.dataset.convTitle = ""; + titleEl.textContent = conv.title; + + const metaEl = document.createElement("small"); + metaEl.dataset.convMeta = ""; + metaEl.textContent = this._formatMeta(conv); + + openButton.append(titleEl, metaEl); + + const actions = document.createElement("div"); + actions.className = "jrpg-archive-item-actions"; + + const renameButton = document.createElement("button"); + renameButton.type = "button"; + renameButton.dataset.convRename = ""; + renameButton.setAttribute("aria-label", `Rename "${conv.title}"`); + renameButton.textContent = "Rename"; + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.dataset.convDelete = ""; + deleteButton.setAttribute("aria-label", `Delete "${conv.title}"`); + deleteButton.textContent = "Delete"; + + actions.append(renameButton, deleteButton); + item.append(openButton, actions); + return item; + } + + _formatMeta(conv) { + const count = conv.turn_count ?? 0; + const turns = count === 1 ? "1 turn" : `${count} turns`; + if (conv.last_message_preview) { + return `${turns} · ${conv.last_message_preview.slice(0, 28)}`; + } + return turns; + } +} + +class MjjJrpgShell extends HTMLElement { + connectedCallback() { + this._mobileMenuQuery = window.matchMedia( + "(max-width: 52rem) and (orientation: portrait)", + ); + this._syncMobileMenu = expanded => { + const button = this.querySelector("[data-mobile-menu-toggle]"); + const menu = this.querySelector("mjj-jrpg-menu"); + if (!button || !menu) return; + if (!this._mobileMenuQuery.matches) { + button.setAttribute("aria-expanded", "true"); + menu.removeAttribute("data-mobile-menu-collapsed"); + return; + } + button.setAttribute("aria-expanded", String(expanded)); + menu.toggleAttribute("data-mobile-menu-collapsed", !expanded); + }; + this._onMenuToggle = event => { + const button = event.target.closest("[data-mobile-menu-toggle]"); + if (!button || !this.contains(button)) return; + const expanded = button.getAttribute("aria-expanded") === "true"; + this._syncMobileMenu(!expanded); + if (!expanded) { + const firstItem = this.querySelector( + "mjj-jrpg-menu button[data-preview]", + ); + if (firstItem) firstItem.focus(); + } + }; + this._onMobileMenuMediaChange = () => this._syncMobileMenu(true); + this.addEventListener("click", this._onMenuToggle); + this._mobileMenuQuery.addEventListener( + "change", + this._onMobileMenuMediaChange, + ); + this._syncMobileMenu(true); + } + + disconnectedCallback() { + this.removeEventListener("click", this._onMenuToggle); + this._mobileMenuQuery?.removeEventListener( + "change", + this._onMobileMenuMediaChange, + ); + } +} for (const [name, constructor] of Object.entries({ + "mjj-conversation-archive": MjjConversationArchive, "mjj-jrpg-character": MjjJrpgCharacter, "mjj-jrpg-chat": MjjJrpgChat, "mjj-jrpg-composer": MjjJrpgComposer, @@ -1417,6 +1797,7 @@ async function requestJson(url, options = {}) { const response = await fetch(url, { ...options, + credentials: "same-origin", headers: { "Content-Type": "application/json", ...options.headers, @@ -1435,6 +1816,102 @@ return response.status === 204 ? null : response.json(); } +/* Session state for CSRF: bootstrapped once, refreshed at most once on 401/403 */ +let _csrfToken = null; +let _sessionData = null; +let _sessionRefreshInFlight = null; /* single-flight guard */ + +function _principalFingerprint(data) { + if (!data) return null; + if (data.kind === "user") return `user:${data.username || ""}:${data.role || ""}`; + if (data.kind === "guest") return `guest:${data.csrfToken || ""}`; + return null; +} + +async function _fetchSession() { + try { + const response = await fetch("/api/auth/session", { + credentials: "same-origin", + }); + if (!response.ok) return null; + const data = await response.json().catch(() => null); + if (!data) return null; + _sessionData = data; + return data.csrfToken || null; + } catch { + return null; + } +} + +/* Single-flight session refresh: dedupe concurrent callers. */ +async function _refreshSessionOnce() { + if (_sessionRefreshInFlight) return _sessionRefreshInFlight; + _sessionRefreshInFlight = (async () => { + const prev = _principalFingerprint(_sessionData); + const token = await _fetchSession(); + const next = _principalFingerprint(_sessionData); + return { token, prev, next }; + })(); + try { + return await _sessionRefreshInFlight; + } finally { + _sessionRefreshInFlight = null; + } +} + +/* + * Perform a state-changing (mutating) fetch with CSRF token and same-origin + * credentials. On 401 or CSRF-specific 403 (csrf_invalid), refreshes the + * session once and retries — but only if the refreshed principal matches the + * original. If the user becomes a guest or a different account, rejects and + * triggers the session-changed callback. + */ +let _sessionRefreshCallback = null; + +async function mutatingFetch(url, options = {}) { + const buildHeaders = () => ({ + ...(options.headers || {}), + Origin: window.location.origin, + ...(_csrfToken ? { "X-CSRF-Token": _csrfToken } : {}), + }); + + const response = await fetch(url, { + ...options, + credentials: "same-origin", + headers: buildHeaders(), + }); + + const shouldRefresh = response.status === 401 || + (response.status === 403 && await (async () => { + try { + const clone = response.clone(); + const body = await clone.json(); + return body?.error?.code === "csrf_invalid"; + } catch { return false; } + })()); + + if (shouldRefresh) { + const prevFingerprint = _principalFingerprint(_sessionData); + const { token, next } = await _refreshSessionOnce(); + _csrfToken = token; + if (next !== prevFingerprint) { + if (_sessionRefreshCallback) _sessionRefreshCallback(); + const err = Object.assign(new Error("Session changed"), { status: 401 }); + throw err; + } + const retried = await fetch(url, { + ...options, + credentials: "same-origin", + headers: buildHeaders(), + }); + if (retried.status === 401 && _sessionRefreshCallback) { + _sessionRefreshCallback(); + } + return retried; + } + return response; +} + async function consumeSse(response, onEvent) { if (!response.body) throw new Error("Streaming response body is unavailable"); const reader = response.body.getReader(); @@ -1467,6 +1944,132 @@ if (buffer.trim()) dispatch(buffer); } +/* ===================================================================== + Account slot rendering + ===================================================================== */ + +function renderAccountSlot(slotEl, sessionData) { + if (!slotEl) return; + slotEl.textContent = ""; + if (!sessionData || sessionData.kind === "guest") { + const owner = document.createElement("zen-button"); + const btn = document.createElement("button"); + owner.setAttribute("appearance", "plain"); + owner.setAttribute("size", "xs"); + btn.type = "button"; + btn.dataset.loginOpen = ""; + btn.textContent = "LOGIN"; + owner.append(btn); + slotEl.setAttribute("aria-label", "Not signed in"); + slotEl.append(owner); + return; + } + const username = (sessionData.username || "").slice(0, 8).toUpperCase(); + if (sessionData.mustChangePassword) { + const link = document.createElement("a"); + link.href = "/account/password"; + link.textContent = username ? `${username}\u26A0` : "PASSWD"; + link.title = "Password change required"; + slotEl.setAttribute("aria-label", `${sessionData.username}: password change required`); + slotEl.append(link); + return; + } + const nameSpan = document.createElement("span"); + nameSpan.dataset.accountUser = ""; + nameSpan.textContent = username; + slotEl.append(nameSpan); + if (sessionData.role === "admin") { + const adminLink = document.createElement("a"); + adminLink.href = "/admin/users"; + adminLink.textContent = "\u00A0ADM"; + slotEl.append(adminLink); + } + const logoutBtn = document.createElement("button"); + logoutBtn.type = "button"; + logoutBtn.textContent = "\u00A0LGOUT"; + logoutBtn.addEventListener("click", () => { + logoutBtn.disabled = true; + mutatingFetch("/api/auth/logout", { method: "POST" }).then(response => { + if (!response.ok) throw new Error(`Logout failed (${response.status})`); + window.dispatchEvent(new CustomEvent("mjj-logout")); + }).catch(() => { + logoutBtn.disabled = false; + }); + }); + slotEl.append(logoutBtn); + slotEl.setAttribute("aria-label", `Signed in as ${sessionData.username}`); +} + +/* ===================================================================== + Quota display helpers + ===================================================================== */ + +function formatQuotaText(quota) { + if (!quota) return null; + const turns = quota.turnsRemaining ?? 0; + const tokens = quota.outputTokensRemaining ?? 0; + if (turns <= 0) { + const resetAt = quota.resetsAt ? new Date(quota.resetsAt * 1000) : null; + const resetStr = resetAt + ? ` Resets ${resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : ""; + return { text: `Quota exhausted.${resetStr}`, state: "exhausted" }; + } + if (turns <= 2) { + return { + text: `${turns} turn${turns === 1 ? "" : "s"} remaining (${tokens.toLocaleString()} tokens)`, + state: "warning", + }; + } + return { + text: `${turns} turns remaining (${tokens.toLocaleString()} tokens)`, + state: "ok", + }; +} + +function updateQuotaDisplay(quotaEl, quota) { + if (!quotaEl) return; + if (!quota) { + quotaEl.hidden = true; + return; + } + const result = formatQuotaText(quota); + /* Only show the bar for low or exhausted quota; hide when turns are plentiful */ + if (!result || result.state === "ok") { + quotaEl.hidden = true; + return; + } + quotaEl.textContent = result.text; + if (result.state === "exhausted") { + quotaEl.dataset.state = "exhausted"; + } else if (result.state === "warning") { + quotaEl.dataset.state = "warning"; + } else { + delete quotaEl.dataset.state; + } + quotaEl.hidden = false; +} + +/* ===================================================================== + Conversation archive helpers + ===================================================================== */ + +async function fetchConversationList(cursor) { + const url = cursor + ? `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}` + : `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}`; + const response = await fetch(url, { credentials: "same-origin" }); + if (!response.ok) { + let message = `Failed to load conversations (${response.status})`; + try { + const body = await response.json(); + message = body.error?.message || message; + } catch { /* keep status message */ } + throw new Error(message); + } + return response.json(); +} + async function initializeJrpgPage() { const colorProbe = document.createElement("span"); colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; @@ -1477,19 +2080,46 @@ meta.setAttribute("content", themeColor); } + /* Bootstrap session to obtain CSRF token and session data */ + _csrfToken = await _fetchSession(); + const shell = document.querySelector("mjj-jrpg-shell"); const character = shell?.querySelector("mjj-jrpg-character"); const chat = shell?.querySelector("mjj-jrpg-chat"); const composer = shell?.querySelector("mjj-jrpg-composer"); const preview = shell?.querySelector("mjj-jrpg-preview"); + const archive = shell?.querySelector("mjj-conversation-archive"); + const accountSlot = shell?.querySelector("[data-frame-account]"); + const quotaEl = shell?.querySelector("[data-quota]"); const frameNetwork = shell?.querySelector("[data-frame-network]"); const frameUptime = shell?.querySelector("[data-frame-uptime]"); const minimizeButton = shell?.querySelector("[data-frame-minimize]"); const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); + let characterTimer = 0; - let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); + let currentConversationId = null; + let currentPanel = "resume"; + let archiveCursor = null; let activeController = null; + let archiveLoadInFlight = false; /* serialize pagination requests */ + let openSeq = 0; /* sequence guard for conversation open */ + let popSeq = 0; /* sequence guard for popstate */ + let _pendingPopState = null; /* queued popstate received during active stream */ + let _claimInFlight = false; /* guard against duplicate concurrent claims */ + let legacyClaimId = sessionStorage.getItem(CONVERSATION_LEGACY_KEY); + let principalEpoch = 0; /* rejects responses from a prior owner */ + /* Dedupe conversation IDs: set of IDs currently rendered in archive */ + const _renderedIds = new Set(); + const invalidatePrincipalRequests = () => { + principalEpoch += 1; + openSeq += 1; + popSeq += 1; + archiveCursor = null; + }; + + /* Disable composer until fully initialized */ composer?.setDisabled(true); + const setFrameStatus = (label, state, title = "") => { if (frameNetwork) { frameNetwork.textContent = label; @@ -1498,7 +2128,16 @@ frameNetwork.title = title; } }; + + /* Bootstrap failure: keep controls disabled and expose retry */ + if (!_csrfToken) { + setFrameStatus("OFFLINE", "offline", "Bootstrap failed"); + archive?.setError("Connection failed. Reload to retry."); + return; + } + setFrameStatus("ONLINE", "online"); + const startedAt = Date.now(); const updateUptime = () => { const elapsed = Math.floor((Date.now() - startedAt) / 1000); @@ -1536,21 +2175,705 @@ ); }); - if (conversationId) { + /* Render account slot */ + renderAccountSlot(accountSlot, _sessionData); + + /* ---- Login modal ---- */ + const loginDialogOwner = shell?.querySelector("[data-login-dialog-owner]"); + const loginDialog = loginDialogOwner?.querySelector("[data-login-dialog]"); + const loginForm = loginDialog?.querySelector("[data-login-form]"); + const loginSubmitBtn = loginDialog?.querySelector("[data-login-submit]"); + const loginCancelBtn = loginDialog?.querySelector("[data-login-cancel]"); + const loginErrorEl = loginDialog?.querySelector("[data-login-error]"); + const loginUsernameInput = loginDialog?.querySelector("#jrpg-login-username"); + const loginPasswordInput = loginDialog?.querySelector("#jrpg-login-password"); + let _loginTrigger = null; + const setAccountActionsDisabled = disabled => { + for (const control of accountSlot?.querySelectorAll("button") || []) { + control.disabled = disabled; + } + }; + + const _clearLoginError = () => { + if (loginErrorEl) { + loginErrorEl.textContent = ""; + loginErrorEl.hidden = true; + } + }; + + const _showLoginError = (message) => { + if (!loginErrorEl) return; + loginErrorEl.textContent = message; + loginErrorEl.hidden = false; + }; + + const openLoginModal = (triggerEl) => { + if (!loginDialogOwner || !loginDialog) return; + _loginTrigger = triggerEl || null; + _clearLoginError(); + loginForm?.reset(); + loginDialogOwner.open(); + requestAnimationFrame(() => { loginUsernameInput?.focus(); }); + }; + + /* Delegate clicks on data-login-open in the account slot */ + accountSlot?.addEventListener("click", event => { + const btn = event.target.closest("[data-login-open]"); + if (!btn || activeController) return; + openLoginModal(btn); + }); + + /* Cancel button */ + loginCancelBtn?.addEventListener("click", () => { loginDialog?.close(); }); + + /* Cleanup on any close (Escape, X button, cancel) */ + loginDialog?.addEventListener("close", () => { + if (loginPasswordInput) loginPasswordInput.value = ""; + _clearLoginError(); + const trigger = _loginTrigger; + _loginTrigger = null; + requestAnimationFrame(() => { trigger?.focus(); }); + }); + + /* Login form submission */ + loginForm?.addEventListener("submit", async event => { + event.preventDefault(); + if (loginSubmitBtn?.disabled) return; + const username = loginUsernameInput?.value.trim() || ""; + const password = loginPasswordInput?.value || ""; + if (!username || !password) { + _showLoginError("Please enter your username and password."); + return; + } + if (loginSubmitBtn) loginSubmitBtn.disabled = true; + _clearLoginError(); try { - const conversation = await requestJson( - `/api/conversations/${encodeURIComponent(conversationId)}`, - { headers: {} }, + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Origin": window.location.origin, + }, + credentials: "same-origin", + body: JSON.stringify({ username, password, csrfToken: _csrfToken }), + }); + if (loginPasswordInput) loginPasswordInput.value = ""; + if (response.status === 429) { + _showLoginError("Too many attempts. Please try again later."); + return; + } + if (!response.ok) { + _showLoginError("Invalid username or password."); + return; + } + let data = await response.json().catch(() => null); + if (!data) { + _csrfToken = await _fetchSession(); + if (_sessionData?.kind !== "user") { + loginDialog?.close(); + window.location.reload(); + return; + } + data = _sessionData; + } + invalidatePrincipalRequests(); + loginDialog?.close(); + _sessionData = { + kind: "user", + username: data.username || username, + role: data.role || "member", + mustChangePassword: Boolean(data.mustChangePassword), + csrfToken: data.csrfToken, + quota: null, + }; + _csrfToken = data.csrfToken || null; + if (data.mustChangePassword) { + window.location.href = "/account/password"; + return; + } + /* Commit the returned principal before optional UI refresh work. */ + renderAccountSlot(accountSlot, _sessionData); + updateQuotaDisplay(quotaEl, _sessionData?.quota || null); + composer?.setDisabled(false); + /* Clear conversation state — guest convs may transfer to user */ + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); + legacyClaimId = null; + _replaceUrlState(currentPanel, null); + chat?.replaceMessages([]); + try { + await refreshArchiveFromScratch(); + } catch (error) { + archive?.setError(`Signed in, but archive refresh failed: ${error.message}`); + } + requestAnimationFrame(() => { + accountSlot?.querySelector("button, a")?.focus(); + }); + } catch { + if (loginPasswordInput) loginPasswordInput.value = ""; + _showLoginError("Sign-in failed. Please try again."); + } finally { + if (loginSubmitBtn) loginSubmitBtn.disabled = false; + } + }); + + /* Handle logout */ + _sessionRefreshCallback = () => { + /* Account changed or session lost — clear active state */ + invalidatePrincipalRequests(); + if (activeController) { + activeController.abort(); + activeController = null; + } + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); + legacyClaimId = null; + _replaceUrlState(currentPanel, null); + chat?.replaceMessages([]); + _renderedIds.clear(); + archiveCursor = null; + archive?.setConversations([], null); + renderAccountSlot(accountSlot, _sessionData); + archive?.setError("Session changed. Loading conversations..."); + void refreshArchiveFromScratch(); + }; + window.addEventListener("mjj-logout", () => { + invalidatePrincipalRequests(); + if (activeController) { + activeController.abort(); + activeController = null; + } + archive?.setStreamActive(false); + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); + legacyClaimId = null; + _replaceUrlState(currentPanel, null); + chat?.replaceMessages([]); + _renderedIds.clear(); + archiveCursor = null; + archive?.setConversations([], null); + void refreshArchiveFromScratch(); + void (async () => { + _csrfToken = await _fetchSession(); + renderAccountSlot(accountSlot, _sessionData); + updateQuotaDisplay(quotaEl, _sessionData?.quota || null); + if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { + composer?.setDisabled(true); + } else { + composer?.setDisabled(false); + } + })(); + }); + + /* Session quota display */ + if (_sessionData?.kind === "guest") { + updateQuotaDisplay(quotaEl, _sessionData.quota || null); + } + + /* Disable composer for forced-password-change */ + const isForced = _sessionData?.kind === "user" && _sessionData?.mustChangePassword; + + /* ---- URL state helpers ---- */ + const _buildStateUrl = (panel, convId) => { + const url = new URL(window.location.href); + if (panel && VALID_PANELS.includes(panel)) { + url.searchParams.set("panel", panel); + } else { + url.searchParams.delete("panel"); + } + if (convId && UUID_REGEX.test(convId)) { + url.searchParams.set("conversation", convId); + } else { + url.searchParams.delete("conversation"); + } + return url; + }; + + const _pushUrlState = (panel, convId) => { + history.pushState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); + }; + + const _replaceUrlState = (panel, convId) => { + history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); + }; + + /* ---- Panel selection: shows archive or preview in utility ---- */ + const utilityEl = shell?.querySelector(".jrpg-utility"); + + const selectPanel = (panel, pushHistory = true) => { + const validPanel = VALID_PANELS.includes(panel) ? panel : "resume"; + currentPanel = validPanel; + for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) { + btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel)); + } + if (validPanel === "conversations") { + if (archive) archive.hidden = false; + if (preview) preview.hidden = true; + if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations"); + } else { + if (archive) archive.hidden = true; + if (preview) preview.hidden = false; + preview?.show(validPanel); + if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination"); + } + if (pushHistory) _pushUrlState(validPanel, currentConversationId); + }; + + /* ---- Archive close button: navigate back to resume panel ---- */ + shell?.addEventListener("mjj-archive-toggle", () => { + selectPanel("resume"); + }); + + shell?.addEventListener("mjj-archive-new", () => { + if (activeController) return; + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + _pushUrlState(currentPanel, null); + chat?.replaceMessages([]); + archive?.setCurrentId(null); + composer?.querySelector("textarea")?.focus(); + }); + + /* ---- Popstate: re-apply panel and conversation on back/forward ---- */ + /* _applyPopState always increments openSeq so any pending archive-open + fetch (including a transition to no conversation) cannot overwrite the + state the user navigated to (Fix 2). */ + const _applyPopState = async (seq, popPanel, popConvId) => { + const localSeq = ++openSeq; + const epoch = principalEpoch; + + selectPanel(popPanel, false); + + if (popConvId && popConvId !== currentConversationId) { + try { + const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`); + if (seq !== popSeq || localSeq !== openSeq || + epoch !== principalEpoch) return; + currentConversationId = popConvId; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, popConvId); + chat?.replaceMessages(conversation.turns); + archive?.setCurrentId(popConvId); + } catch { + if (seq !== popSeq || localSeq !== openSeq || + epoch !== principalEpoch) return; + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + chat?.replaceMessages([]); + archive?.setCurrentId(null); + history.replaceState( + { panel: popPanel, conversation: null }, + "", + _buildStateUrl(popPanel, null), + ); + } + } else if (!popConvId && currentConversationId) { + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + chat?.replaceMessages([]); + archive?.setCurrentId(null); + } + }; + + /* When popstate fires while a stream is active the URL has already changed. + Queue the parsed destination, abort the stream, and apply exactly once in + the stream's finally block so URL and UI always converge (Fix 1). */ + window.addEventListener("popstate", async event => { + const seq = ++popSeq; + ++openSeq; + const state = event.state || {}; + const popPanel = VALID_PANELS.includes(state.panel) ? state.panel : "resume"; + const popConvId = (state.conversation && UUID_REGEX.test(state.conversation)) + ? state.conversation : null; + + if (activeController) { + _pendingPopState = { seq, popPanel, popConvId }; + activeController.abort(); + return; + } + + await _applyPopState(seq, popPanel, popConvId); + }); + + shell?.addEventListener("mjj-archive-rename", async event => { + const { id, newTitle, trigger } = event.detail; + if (activeController) { trigger?.focus(); return; } + try { + const response = await mutatingFetch( + `/api/conversations/${encodeURIComponent(id)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: newTitle }), + }, ); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error?.message || `Rename failed (${response.status})`); + } + archive?.updateConversation({ id, title: newTitle, turn_count: null }); + } catch (error) { + archive?.setError(`Rename failed: ${error.message}`); + } finally { + trigger?.focus(); + } + }); + + shell?.addEventListener("mjj-archive-open", async event => { + const { id } = event.detail; + if (id === currentConversationId) return; + if (activeController) return; + const seq = ++openSeq; + const epoch = principalEpoch; + try { + const conversation = await requestJson(`/api/conversations/${encodeURIComponent(id)}`); + if (seq !== openSeq || epoch !== principalEpoch) return; + currentConversationId = id; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id); + _pushUrlState(currentPanel, id); chat?.replaceMessages(conversation.turns); + archive?.setCurrentId(id); + } catch (error) { + if (seq !== openSeq || epoch !== principalEpoch) return; + archive?.setError(`Unable to open: ${error.message}`); + } + }); + + shell?.addEventListener("mjj-archive-delete", async event => { + const { id, trigger } = event.detail; + if (activeController) { trigger?.focus(); return; } /* block while streaming */ + try { + const response = await mutatingFetch( + `/api/conversations/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + if (response.ok || response.status === 404) { + /* Focus adjacent archive item or New button before removing the node */ + const focusTarget = archive?.adjacentItemOrNewButton(id); + _renderedIds.delete(id); + archive?.removeConversation(id); + if (id === currentConversationId) { + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + _replaceUrlState(currentPanel, null); + chat?.replaceMessages([]); + } + /* Focus surviving element — never the detached trigger */ + requestAnimationFrame(() => { + (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus(); + }); + } else { + const body = await response.json().catch(() => ({})); + archive?.setError(`Delete failed: ${body.error?.message || response.status}`); + } + } catch (error) { + archive?.setError(`Delete failed: ${error.message}`); + /* On error, the trigger is still attached — focus it */ + trigger?.focus(); + } + }); + + shell?.addEventListener("mjj-archive-load-more", async () => { + if (!archiveCursor || archiveLoadInFlight) return; + archiveLoadInFlight = true; + const epoch = principalEpoch; + archive?.setLoadMoreDisabled(true); + try { + const data = await fetchConversationList(archiveCursor); + if (epoch !== principalEpoch) return; + archiveCursor = data.cursor || null; + const newConvs = (data.conversations || []).filter(c => !_renderedIds.has(c.id)); + newConvs.forEach(c => _renderedIds.add(c.id)); + archive?.setConversations(newConvs, archiveCursor, true); + } catch (error) { + archive?.setError(`Load failed: ${error.message}`); + } finally { + archiveLoadInFlight = false; + archive?.setLoadMoreDisabled(false); + } + }); + + shell?.addEventListener("mjj-archive-claim", async () => { + const legacyId = + legacyClaimId || sessionStorage.getItem(CONVERSATION_LEGACY_KEY); + if (!legacyId || _sessionData?.kind !== "user" || _claimInFlight) return; + _claimInFlight = true; + archive?.showLegacyClaim("Claiming prior conversation...", false); + let _claimOutcome = null; + try { + const response = await mutatingFetch( + "/api/conversations/claim", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: legacyId }), + }, + ); + if (response.ok) { + sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); + legacyClaimId = null; + _claimOutcome = "success"; + } else if (response.status === 409) { + /* Definitive non-legacy conflict: clear key, no retry needed */ + sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); + legacyClaimId = null; + _claimOutcome = "conflict"; + } else { + /* Retryable (401, 403, 429, 5xx): retain key */ + sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId); + legacyClaimId = legacyId; + _claimOutcome = "retry"; + } } catch { - sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); - conversationId = null; + /* Network error: retain key */ + sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId); + legacyClaimId = legacyId; + _claimOutcome = "network-error"; + } finally { + /* Reset guard BEFORE re-rendering so the retry button is immediately usable */ + _claimInFlight = false; + if (_claimOutcome === "success") { + void refreshArchiveFromScratch(); + } else if (_claimOutcome === "conflict") { + archive?.showLegacyClaim("Prior conversation already claimed.", false); + } else if (_claimOutcome === "retry") { + selectPanel("conversations", false); + archive?.showLegacyClaim("Claim failed. Try again. "); + } else if (_claimOutcome === "network-error") { + selectPanel("conversations", false); + archive?.showLegacyClaim("Claim failed. Check connection. "); + } + } + }); + + async function refreshArchiveFromScratch() { + if (!archive) return; + const epoch = principalEpoch; + archive.setLoading(true); + _renderedIds.clear(); + try { + const data = await fetchConversationList(null); + if (epoch !== principalEpoch) return; + archiveCursor = data.cursor || null; + const convs = data.conversations || []; + convs.forEach(c => _renderedIds.add(c.id)); + archive.setConversations(convs, archiveCursor); + archive.setCurrentId(currentConversationId); + } catch (error) { + archive.setError(`Archive error: ${error.message}`); } } + async function refreshArchiveHead() { + if (!archive) return; + const epoch = principalEpoch; + try { + const data = await fetchConversationList(null); + if (epoch !== principalEpoch) return; + const fresh = data.conversations || []; + for (const conv of fresh) archive.updateConversation(conv); + if (fresh.length > 0) archive.setCurrentId(currentConversationId); + } catch { /* silent refresh failure */ } + } + + /* ---- Parse and validate URL params at startup ---- */ + /* Track whether ?conversation was explicitly present (even if malformed) + so we never migrate sessionStorage when the param was given but invalid. */ + let _convParamWasExplicit = false; + { + const initParams = new URLSearchParams(window.location.search); + const rawPanel = initParams.get("panel"); + const rawConvId = initParams.get("conversation"); + _convParamWasExplicit = rawConvId !== null; + const validPanel = VALID_PANELS.includes(rawPanel) ? rawPanel : null; + const validConvId = (rawConvId && UUID_REGEX.test(rawConvId)) ? rawConvId : null; + /* Remove invalid params with replaceState */ + if ((rawPanel && !validPanel) || (rawConvId && !validConvId)) { + const normalUrl = new URL(window.location.href); + if (!validPanel) normalUrl.searchParams.delete("panel"); + if (!validConvId) normalUrl.searchParams.delete("conversation"); + history.replaceState( + { panel: validPanel, conversation: validConvId }, + "", + normalUrl, + ); + } + currentPanel = validPanel || "resume"; + } + + /* ---- Load archive and restore conversation from URL or sessionStorage ---- */ + const startupEpoch = principalEpoch; + const ensureStartupPrincipal = () => { + if (startupEpoch !== principalEpoch) { + const error = new Error("Principal changed during startup"); + error.name = "StalePrincipalError"; + throw error; + } + }; + archive?.setLoading(true); + try { + const data = await fetchConversationList(null); + ensureStartupPrincipal(); + archiveCursor = data.cursor || null; + const conversations = data.conversations || []; + conversations.forEach(c => _renderedIds.add(c.id)); + archive?.setConversations(conversations, archiveCursor); + + /* Determine target conversation: URL param is canonical; migrate sessionStorage once */ + const initParams2 = new URLSearchParams(window.location.search); + const urlConvId = initParams2.get("conversation"); + const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null; + + let resolvedConvId = urlConvValid; + let migratedFromStorage = false; + if (!resolvedConvId && !_convParamWasExplicit) { + const stored = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); + if (stored && UUID_REGEX.test(stored)) { + resolvedConvId = stored; + migratedFromStorage = true; + } + } + + if (resolvedConvId) { + const inArchive = conversations.find(c => c.id === resolvedConvId); + if (inArchive) { + try { + const conversation = await requestJson( + `/api/conversations/${encodeURIComponent(resolvedConvId)}`, + ); + ensureStartupPrincipal(); + currentConversationId = resolvedConvId; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); + chat?.replaceMessages(conversation.turns); + archive?.setCurrentId(resolvedConvId); + } catch (error) { + if (error.name === "StalePrincipalError" || + startupEpoch !== principalEpoch) throw error; + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + } + } else { + /* Not on first page — fetch directly to verify ownership */ + let ownedConversation = null; + try { + ownedConversation = await requestJson( + `/api/conversations/${encodeURIComponent(resolvedConvId)}`, + ); + ensureStartupPrincipal(); + } catch (error) { + if (error.name === "StalePrincipalError" || + startupEpoch !== principalEpoch) throw error; + ownedConversation = null; + } + if (ownedConversation) { + currentConversationId = resolvedConvId; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); + chat?.replaceMessages(ownedConversation.turns); + if (!_renderedIds.has(resolvedConvId)) { + _renderedIds.add(resolvedConvId); + archive?.addConversation({ + id: resolvedConvId, + title: ownedConversation.title || "Conversation", + turn_count: ownedConversation.turns?.length || 0, + }); + } + archive?.setCurrentId(resolvedConvId); + } else if (_sessionData?.kind === "user") { + /* Authenticated and 404 — offer legacy claim for sessionStorage migrations */ + if (migratedFromStorage) { + sessionStorage.setItem(CONVERSATION_LEGACY_KEY, resolvedConvId); + legacyClaimId = resolvedConvId; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + archive?.showLegacyClaim(); + if (conversations.length > 0) { + currentConversationId = conversations[0].id; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); + try { + const conv = await requestJson( + `/api/conversations/${encodeURIComponent(currentConversationId)}`, + ); + ensureStartupPrincipal(); + chat?.replaceMessages(conv.turns); + archive?.setCurrentId(currentConversationId); + } catch (error) { + if (error.name === "StalePrincipalError" || + startupEpoch !== principalEpoch) throw error; + currentConversationId = null; + } + } + } else { + /* URL-specified conv returned 404 — clear it */ + currentConversationId = null; + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + } + } else { + /* Guest — not owned, clear stale ID */ + currentConversationId = null; + if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + } + } + } else if (conversations.length > 0) { + /* No target conversation — open the most recent */ + const most_recent = conversations[0]; + try { + const conversation = await requestJson( + `/api/conversations/${encodeURIComponent(most_recent.id)}`, + ); + ensureStartupPrincipal(); + currentConversationId = most_recent.id; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); + chat?.replaceMessages(conversation.turns); + archive?.setCurrentId(most_recent.id); + } catch (error) { + if (error.name === "StalePrincipalError" || + startupEpoch !== principalEpoch) throw error; + currentConversationId = null; + } + } + } catch (error) { + if (error.name !== "StalePrincipalError" && + startupEpoch === principalEpoch) { + archive?.setError(`Archive error: ${error.message}`); + } + /* Fall back to direct fetch of URL/stored ID */ + const initParams3 = new URLSearchParams(window.location.search); + const fallbackId = ( + initParams3.get("conversation") || + (!_convParamWasExplicit + ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY) + : null) + ) || null; + if (startupEpoch === principalEpoch && + fallbackId && UUID_REGEX.test(fallbackId)) { + try { + const conversation = await requestJson( + `/api/conversations/${encodeURIComponent(fallbackId)}`, + ); + ensureStartupPrincipal(); + currentConversationId = fallbackId; + chat?.replaceMessages(conversation.turns); + } catch (fallbackError) { + if (fallbackError.name === "StalePrincipalError" || + startupEpoch !== principalEpoch) { + // The new principal owns subsequent rendering. + } else { + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + currentConversationId = null; + } + } + } + } + + /* Apply startup panel and normalize URL (replaceState — not a user navigation) */ + if (startupEpoch === principalEpoch) { + selectPanel(currentPanel, false); + _replaceUrlState(currentPanel, currentConversationId); + } + shell?.addEventListener("mjj-jrpg-preview-change", event => { - preview?.show(event.detail.selection); + selectPanel(event.detail.selection); character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); characterTimer = window.setTimeout(() => { @@ -1564,6 +2887,10 @@ shell?.addEventListener("mjj-jrpg-submit", async event => { if (activeController) return; + /* Block sends when password change is required */ + if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { + return; + } chat?.appendMessage("June", event.detail.message); composer?.setBusy(true); character?.setAttribute("state", "thinking"); @@ -1579,51 +2906,130 @@ assistantText.cancel(); }, { once: true }); + /* Disable archive/account mutations while stream is active (finding #7) */ + archive?.setStreamActive(true); + setAccountActionsDisabled(true); + try { const createConversation = async () => { - const conversation = await requestJson("/api/conversations", { + const resp = await mutatingFetch("/api/conversations", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Shiba Quest" }), }); - conversationId = conversation.id; - sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId); + if (!resp.ok) { + let message = `Create failed (${resp.status})`; + try { + const body = await resp.json(); + message = body.error?.message || message; + } catch { /* keep */ } + throw new Error(message); + } + const conversation = await resp.json(); + currentConversationId = conversation.id; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); + _pushUrlState(currentPanel, currentConversationId); + if (!_renderedIds.has(currentConversationId)) { + _renderedIds.add(currentConversationId); + archive?.addConversation({ + id: currentConversationId, + title: "Shiba Quest", + turn_count: 0, + last_message_preview: event.detail.message.slice(0, 32), + }); + } + archive?.setCurrentId(currentConversationId); }; - if (!conversationId) { + + if (!currentConversationId) { await createConversation(); } - const postTurn = () => fetch( - `/api/conversations/${encodeURIComponent(conversationId)}/turns`, { + const postTurn = () => mutatingFetch( + `/api/conversations/${encodeURIComponent(currentConversationId)}/turns`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: event.detail.message }), signal: activeController.signal, }, ); + let response = await postTurn(); + + /* Handle 404: conversation may have expired — recreate it */ if (response.status === 404) { sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); - conversationId = null; + currentConversationId = null; await createConversation(); response = await postTurn(); } + + /* Handle 429: distinguish permanent quota from temporary capacity */ + if (response.status === 429) { + let errorBody = null; + try { errorBody = await response.json(); } catch { /* keep */ } + const code = errorBody?.error?.code || ""; + const isQuotaExhausted = + code === "guest_quota_turns_exhausted" || + code === "guest_quota_tokens_exhausted" || + code === "guest_turn_quota_exhausted" || + code === "guest_token_quota_exhausted"; + + if (isQuotaExhausted) { + const quotaState = errorBody?.error?.quota || null; + updateQuotaDisplay(quotaEl, quotaState); + composer?.setDisabled(true); + chat?.setMessageStreaming(assistantItem, false); + chat?.setMessageText( + assistantItem, + quotaState + ? `Quota exhausted. ${formatQuotaText(quotaState)?.text || ""}` + : "Quota exhausted. Try again later.", + ); + character?.setAttribute("state", "sad"); + setFrameStatus("QUOTA", "offline"); + /* Re-enable when quota resets */ + const resetsAt = quotaState?.resetsAt; + if (resetsAt) { + const delay = Math.max(0, resetsAt * 1000 - Date.now()); + window.setTimeout(() => { + if (_sessionData?.kind !== "user" || !_sessionData?.mustChangePassword) { + composer?.setDisabled(false); + setFrameStatus("ONLINE", "online"); + } + }, Math.min(delay + 1000, 24 * 3600 * 1000)); + } + } else { + /* Temporary capacity 429 — use Retry-After or default 5 s */ + const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10); + chat?.setMessageStreaming(assistantItem, false); + chat?.setMessageText(assistantItem, "Server busy. Retrying shortly…"); + character?.setAttribute("state", "sad"); + setFrameStatus("BUSY", "offline"); + window.setTimeout(() => { + if (!activeController?.signal.aborted) { + setFrameStatus("ONLINE", "online"); + composer?.setDisabled(false); + } + }, retryAfter * 1000); + } + return; + } + if (!response.ok) { let message = `Turn failed (${response.status})`; try { const body = await response.json(); message = body.error?.message || message; - } catch { - // Preserve the status-derived message. - } + } catch { /* keep */ } throw new Error(message); } + await consumeSse(response, (eventName, data) => { shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { bubbles: true, - detail: { - data, - type: eventName, - }, + detail: { data, type: eventName }, })); if (eventName === "assistant.delta") { assistantText.append(data.delta || ""); @@ -1643,6 +3049,7 @@ } } }); + if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); await assistantText.waitForIdle(); if (activeController.signal.aborted) { @@ -1651,6 +3058,13 @@ chat?.setMessageStreaming(assistantItem, false); character?.setAttribute("state", "default"); setFrameStatus("ONLINE", "online"); + + /* Refresh archive and quota after successful turn */ + void refreshArchiveHead(); + if (_sessionData?.kind === "guest") { + _csrfToken = await _fetchSession(); + updateQuotaDisplay(quotaEl, _sessionData?.quota || null); + } } catch (error) { const aborted = error instanceof DOMException && error.name === "AbortError"; assistantText.cancel(); @@ -1665,9 +3079,24 @@ composer?.setBusy(false); composer?.querySelector("textarea")?.focus(); activeController = null; + /* Re-enable archive/account mutations exactly once */ + archive?.setStreamActive(false); + setAccountActionsDisabled(false); + /* Apply popstate that was queued during the stream (Fix 1) */ + if (_pendingPopState) { + const pending = _pendingPopState; + _pendingPopState = null; + if (pending.seq === popSeq) { + await _applyPopState(pending.seq, pending.popPanel, pending.popConvId); + } + } } }); - composer?.setDisabled(false); + + /* Enable composer unless forced password change */ + if (!isForced) { + composer?.setDisabled(false); + } } if (document.readyState === "loading") {