Mercurial
diff mrjunejune/src/jrpg/jrpg.js @ 272:41a49c29a28f
polish JRPG conversation experience
Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 16:05:29 -0700 |
| parents | 056790c4fb0d |
| children | e02e2036ef84 |
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js Fri Aug 07 13:24:05 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Fri Aug 07 16:05:29 2026 -0700 @@ -119,11 +119,15 @@ return loading; } -function greetingForSession(sessionData) { - if (sessionData?.kind !== "user") return ROLE_GREETINGS.guest; - return sessionData.role === "admin" - ? ROLE_GREETINGS.admin - : ROLE_GREETINGS.invited; +function greetingForSession(sessionData, hasConversations = false) { + const greeting = sessionData?.kind !== "user" + ? ROLE_GREETINGS.guest + : sessionData.role === "admin" + ? ROLE_GREETINGS.admin + : ROLE_GREETINGS.invited; + return hasConversations + ? `${greeting} We’ve talked before. Open Conversations if you’d like to continue one.` + : greeting; } class StreamingTextAnimator { @@ -315,14 +319,25 @@ _createMessage(speaker, text, group, select) { if (!this._messages) return; const item = document.createElement("li"); + const speakerCell = document.createElement("span"); const name = document.createElement("strong"); const copy = document.createElement("p"); item.className = "jrpg-message"; item.dataset.speaker = speaker; item.dataset.turnGroup = group; + speakerCell.className = "jrpg-message-speaker"; + if (speaker === "Epi") { + const avatar = document.createElement("img"); + avatar.className = "jrpg-message-avatar"; + avatar.src = "/public/jrpg/shiba-default.webp"; + avatar.alt = ""; + avatar.setAttribute("aria-hidden", "true"); + speakerCell.append(avatar); + } name.textContent = speaker; copy.textContent = text; - item.append(name, copy); + speakerCell.append(name); + item.append(speakerCell, copy); this._messages.append(item); this._refreshTurnGroups(select ? group : this._currentGroup); return item; @@ -441,63 +456,6 @@ } } -class MjjJrpgComposer extends HTMLElement { - connectedCallback() { - this._form = this.querySelector("[data-composer]"); - this._textarea = this.querySelector("textarea"); - this._button = this.querySelector('button[type="submit"]'); - this._cancel = this.querySelector("[data-cancel]"); - this._cancelControl = this.querySelector("[data-cancel-control]"); - this._onSubmit = event => { - event.preventDefault(); - const message = this._textarea?.value.trim(); - if (!message || this._button?.disabled) return; - this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", { - bubbles: true, - detail: { message }, - })); - this._form.reset(); - }; - this._onKeydown = event => { - if (event.key !== "Enter" || event.shiftKey || event.isComposing) return; - event.preventDefault(); - this._form?.requestSubmit(); - }; - this._form?.addEventListener("submit", this._onSubmit); - this._textarea?.addEventListener("keydown", this._onKeydown); - this._onCancel = () => { - this.dispatchEvent(new CustomEvent("mjj-jrpg-cancel", { - bubbles: true, - })); - }; - this._cancel?.addEventListener("click", this._onCancel); - } - - disconnectedCallback() { - this._form?.removeEventListener("submit", this._onSubmit); - this._textarea?.removeEventListener("keydown", this._onKeydown); - this._cancel?.removeEventListener("click", this._onCancel); - } - - setBusy(busy) { - if (this._textarea) this._textarea.disabled = busy; - if (this._button) { - this._button.disabled = busy; - this._button.setAttribute("aria-busy", String(busy)); - } - if (this._cancelControl) this._cancelControl.hidden = !busy; - } - - setCancellable(cancellable) { - if (this._cancelControl) this._cancelControl.hidden = !cancellable; - } - - setDisabled(disabled) { - if (this._textarea) this._textarea.disabled = disabled; - if (this._button) this._button.disabled = disabled; - } -} - class MjjJrpgMenu extends HTMLElement { connectedCallback() { this._onClick = event => { @@ -1790,41 +1748,122 @@ 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"); + this._mobileDestinationOwner = this.querySelector( + "[data-mobile-destination-owner]", + ); + this._mobileDestinationDialog = this.querySelector( + "[data-mobile-destination-dialog]", + ); + this._mobileDestinationContent = this.querySelector( + "[data-mobile-destination-content]", + ); + this._mobileDestinationTitle = this.querySelector( + "[data-mobile-destination-title]", + ); + this._mobileDestinationBackOwner = this.querySelector( + "[data-mobile-destination-back-owner]", + ); + this._mobileDestinationBack = this.querySelector( + "[data-mobile-destination-back]", + ); + this._utility = this.querySelector(".jrpg-utility"); + this._menu = this.querySelector("mjj-jrpg-menu"); + this._utilityPlaceholder = document.createComment("jrpg-utility"); + this._menuPlaceholder = document.createComment("jrpg-menu"); + this._utility?.before(this._utilityPlaceholder); + this._menu?.before(this._menuPlaceholder); + + this._syncMobileLayout = () => { + if (!this._utility || !this._menu || !this._mobileDestinationContent) { return; } - button.setAttribute("aria-expanded", String(expanded)); - menu.toggleAttribute("data-mobile-menu-collapsed", !expanded); + if (this._mobileMenuQuery.matches) { + this._mobileDestinationContent.append(this._menu, this._utility); + this.showMobileMenu(); + return; + } + this._mobileDestinationOwner?.close(); + this._utilityPlaceholder.after(this._utility); + this._menuPlaceholder.after(this._menu); + this._utility.hidden = false; + this._menu.hidden = false; }; - 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._onMobileMenuMediaChange = () => this._syncMobileLayout(); + this._onMobileDestinationBack = () => this.showMobileMenu(); + this._onMobileDestinationClose = () => this.showMobileMenu(); + this._mobileDestinationBack?.addEventListener( + "click", + this._onMobileDestinationBack, + ); + this._mobileDestinationDialog?.addEventListener( + "close", + this._onMobileDestinationClose, + ); this._mobileMenuQuery.addEventListener( "change", this._onMobileMenuMediaChange, ); - this._syncMobileMenu(true); + this._syncMobileLayout(); + } + + isMobileDestinationModal() { + return Boolean(this._mobileMenuQuery?.matches); + } + + isMobileDestinationOpen() { + return this.isMobileDestinationModal() && + Boolean(this._mobileDestinationDialog?.open); + } + + showMobileMenu() { + if (!this.isMobileDestinationModal() || !this._menu || !this._utility) { + return; + } + this._menu.hidden = false; + this._utility.hidden = true; + if (this._mobileDestinationTitle) { + this._mobileDestinationTitle.textContent = "Menu"; + } + if (this._mobileDestinationBackOwner) { + this._mobileDestinationBackOwner.hidden = true; + } + } + + showMobileDestination(selection) { + if (!this.isMobileDestinationModal() || !this._menu || !this._utility) { + return; + } + const labels = { + blog: "Blogs", + conversations: "Conversations", + resume: "Resume", + tools: "Tools", + }; + this._menu.hidden = true; + this._utility.hidden = false; + if (this._mobileDestinationTitle) { + this._mobileDestinationTitle.textContent = labels[selection] || "Destination"; + } + if (this._mobileDestinationBackOwner) { + this._mobileDestinationBackOwner.hidden = false; + } + } + + closeMobileDestinations() { + if (this.isMobileDestinationModal()) { + this._mobileDestinationOwner?.close(); + } } disconnectedCallback() { - this.removeEventListener("click", this._onMenuToggle); + this._mobileDestinationBack?.removeEventListener( + "click", + this._onMobileDestinationBack, + ); + this._mobileDestinationDialog?.removeEventListener( + "close", + this._onMobileDestinationClose, + ); this._mobileMenuQuery?.removeEventListener( "change", this._onMobileMenuMediaChange, @@ -1836,7 +1875,6 @@ "mjj-conversation-archive": MjjConversationArchive, "mjj-jrpg-character": MjjJrpgCharacter, "mjj-jrpg-chat": MjjJrpgChat, - "mjj-jrpg-composer": MjjJrpgComposer, "mjj-jrpg-menu": MjjJrpgMenu, "mjj-jrpg-preview": MjjJrpgPreview, "mjj-jrpg-shell": MjjJrpgShell, @@ -2136,7 +2174,7 @@ 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 composer = shell?.querySelector("mjj-composer"); const preview = shell?.querySelector("mjj-jrpg-preview"); const archive = shell?.querySelector("mjj-conversation-archive"); const accountSlot = shell?.querySelector("[data-frame-account]"); @@ -2453,6 +2491,7 @@ /* ---- Panel selection: shows archive or preview in utility ---- */ const utilityEl = shell?.querySelector(".jrpg-utility"); + const previewContent = preview?.querySelector("[data-preview-content]"); const selectPanel = (panel, pushHistory = true) => { const validPanel = VALID_PANELS.includes(panel) ? panel : "resume"; @@ -2462,11 +2501,11 @@ } if (validPanel === "conversations") { if (archive) archive.hidden = false; - if (preview) preview.hidden = true; + if (previewContent) previewContent.hidden = true; if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations"); } else { if (archive) archive.hidden = true; - if (preview) preview.hidden = false; + if (previewContent) previewContent.hidden = false; preview?.show(validPanel); if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination"); } @@ -2475,6 +2514,10 @@ /* ---- Archive close button: navigate back to resume panel ---- */ shell?.addEventListener("mjj-archive-toggle", () => { + if (shell?.isMobileDestinationModal()) { + shell.showMobileMenu(); + return; + } selectPanel("resume"); }); @@ -2485,6 +2528,7 @@ _pushUrlState(currentPanel, null); chat?.setGreeting(greetingForSession(_sessionData), true); archive?.setCurrentId(null); + shell?.closeMobileDestinations(); composer?.querySelector("textarea")?.focus(); }); @@ -2497,6 +2541,9 @@ const epoch = principalEpoch; selectPanel(popPanel, false); + if (shell?.isMobileDestinationOpen()) { + shell.showMobileDestination(popPanel); + } if (popConvId && popConvId !== currentConversationId) { try { @@ -2586,6 +2633,7 @@ _pushUrlState(currentPanel, id); chat?.replaceMessages(conversation.turns); archive?.setCurrentId(id); + shell?.closeMobileDestinations(); } catch (error) { if (seq !== openSeq || epoch !== principalEpoch) return; archive?.setError(`Unable to open: ${error.message}`); @@ -2754,7 +2802,7 @@ currentPanel = validPanel || "resume"; } - /* ---- Load archive and restore conversation from URL or sessionStorage ---- */ + /* ---- Load archive; only an explicit URL restores a conversation ---- */ const startupEpoch = principalEpoch; const ensureStartupPrincipal = () => { if (startupEpoch !== principalEpoch) { @@ -2772,33 +2820,26 @@ conversations.forEach(c => _renderedIds.add(c.id)); archive?.setConversations(conversations, archiveCursor); - /* Determine target conversation: URL param is canonical; migrate sessionStorage once */ + /* A conversation opens only through an explicit URL or a user archive action. */ const initParams2 = new URLSearchParams(window.location.search); const urlConvId = initParams2.get("conversation"); const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null; + const storedConvId = !_convParamWasExplicit + ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY) + : 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 (urlConvValid) { + const inArchive = conversations.find(c => c.id === urlConvValid); if (inArchive) { try { const conversation = await requestJson( - `/api/conversations/${encodeURIComponent(resolvedConvId)}`, + `/api/conversations/${encodeURIComponent(urlConvValid)}`, ); ensureStartupPrincipal(); - currentConversationId = resolvedConvId; - sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); + currentConversationId = urlConvValid; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid); chat?.replaceMessages(conversation.turns); - archive?.setCurrentId(resolvedConvId); + archive?.setCurrentId(urlConvValid); } catch (error) { if (error.name === "StalePrincipalError" || startupEpoch !== principalEpoch) throw error; @@ -2810,7 +2851,7 @@ let ownedConversation = null; try { ownedConversation = await requestJson( - `/api/conversations/${encodeURIComponent(resolvedConvId)}`, + `/api/conversations/${encodeURIComponent(urlConvValid)}`, ); ensureStartupPrincipal(); } catch (error) { @@ -2819,68 +2860,39 @@ ownedConversation = null; } if (ownedConversation) { - currentConversationId = resolvedConvId; - sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); + currentConversationId = urlConvValid; + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid); chat?.replaceMessages(ownedConversation.turns); - if (!_renderedIds.has(resolvedConvId)) { - _renderedIds.add(resolvedConvId); + if (!_renderedIds.has(urlConvValid)) { + _renderedIds.add(urlConvValid); archive?.addConversation({ - id: resolvedConvId, + id: urlConvValid, 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); - } + archive?.setCurrentId(urlConvValid); } else { - /* Guest — not owned, clear stale ID */ currentConversationId = null; - if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); + 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)}`, + } else { + currentConversationId = null; + archive?.setCurrentId(null); + if (conversations.length > 0) { + chat?.setGreeting(greetingForSession(_sessionData, true), true); + } + if (storedConvId && UUID_REGEX.test(storedConvId)) { + const storedConversationIsOwned = conversations.some( + conversation => conversation.id === storedConvId, ); - 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; + if (_sessionData?.kind === "user" && !storedConversationIsOwned) { + sessionStorage.setItem(CONVERSATION_LEGACY_KEY, storedConvId); + legacyClaimId = storedConvId; + archive?.showLegacyClaim(); + } + sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); } } } catch (error) { @@ -2888,14 +2900,9 @@ startupEpoch === principalEpoch) { archive?.setError(`Archive error: ${error.message}`); } - /* Fall back to direct fetch of URL/stored ID */ + /* Fall back only for an explicit deep-linked conversation. */ const initParams3 = new URLSearchParams(window.location.search); - const fallbackId = ( - initParams3.get("conversation") || - (!_convParamWasExplicit - ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY) - : null) - ) || null; + const fallbackId = initParams3.get("conversation"); if (startupEpoch === principalEpoch && fallbackId && UUID_REGEX.test(fallbackId)) { try { @@ -2925,6 +2932,7 @@ shell?.addEventListener("mjj-jrpg-preview-change", event => { selectPanel(event.detail.selection); + shell?.showMobileDestination(event.detail.selection); character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); characterTimer = window.setTimeout(() => { @@ -2932,11 +2940,11 @@ }, 650); }); - shell?.addEventListener("mjj-jrpg-cancel", () => { + shell?.addEventListener("mjj-composer-cancel", () => { activeController?.abort(); }); - shell?.addEventListener("mjj-jrpg-submit", async event => { + shell?.addEventListener("mjj-composer-submit", async event => { if (activeController) return; /* Block sends when password change is required */ if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {