comparison 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
comparison
equal deleted inserted replaced
271:13d61401c57d 272:41a49c29a28f
117 }); 117 });
118 SCRIPT_LOADS.set(source, loading); 118 SCRIPT_LOADS.set(source, loading);
119 return loading; 119 return loading;
120 } 120 }
121 121
122 function greetingForSession(sessionData) { 122 function greetingForSession(sessionData, hasConversations = false) {
123 if (sessionData?.kind !== "user") return ROLE_GREETINGS.guest; 123 const greeting = sessionData?.kind !== "user"
124 return sessionData.role === "admin" 124 ? ROLE_GREETINGS.guest
125 ? ROLE_GREETINGS.admin 125 : sessionData.role === "admin"
126 : ROLE_GREETINGS.invited; 126 ? ROLE_GREETINGS.admin
127 : ROLE_GREETINGS.invited;
128 return hasConversations
129 ? `${greeting} We’ve talked before. Open Conversations if you’d like to continue one.`
130 : greeting;
127 } 131 }
128 132
129 class StreamingTextAnimator { 133 class StreamingTextAnimator {
130 constructor(render) { 134 constructor(render) {
131 this._render = render; 135 this._render = render;
313 } 317 }
314 318
315 _createMessage(speaker, text, group, select) { 319 _createMessage(speaker, text, group, select) {
316 if (!this._messages) return; 320 if (!this._messages) return;
317 const item = document.createElement("li"); 321 const item = document.createElement("li");
322 const speakerCell = document.createElement("span");
318 const name = document.createElement("strong"); 323 const name = document.createElement("strong");
319 const copy = document.createElement("p"); 324 const copy = document.createElement("p");
320 item.className = "jrpg-message"; 325 item.className = "jrpg-message";
321 item.dataset.speaker = speaker; 326 item.dataset.speaker = speaker;
322 item.dataset.turnGroup = group; 327 item.dataset.turnGroup = group;
328 speakerCell.className = "jrpg-message-speaker";
329 if (speaker === "Epi") {
330 const avatar = document.createElement("img");
331 avatar.className = "jrpg-message-avatar";
332 avatar.src = "/public/jrpg/shiba-default.webp";
333 avatar.alt = "";
334 avatar.setAttribute("aria-hidden", "true");
335 speakerCell.append(avatar);
336 }
323 name.textContent = speaker; 337 name.textContent = speaker;
324 copy.textContent = text; 338 copy.textContent = text;
325 item.append(name, copy); 339 speakerCell.append(name);
340 item.append(speakerCell, copy);
326 this._messages.append(item); 341 this._messages.append(item);
327 this._refreshTurnGroups(select ? group : this._currentGroup); 342 this._refreshTurnGroups(select ? group : this._currentGroup);
328 return item; 343 return item;
329 } 344 }
330 345
436 requestAnimationFrame(() => { 451 requestAnimationFrame(() => {
437 if (this._viewport) { 452 if (this._viewport) {
438 this._viewport.scrollTop = this._viewport.scrollHeight; 453 this._viewport.scrollTop = this._viewport.scrollHeight;
439 } 454 }
440 }); 455 });
441 }
442 }
443
444 class MjjJrpgComposer extends HTMLElement {
445 connectedCallback() {
446 this._form = this.querySelector("[data-composer]");
447 this._textarea = this.querySelector("textarea");
448 this._button = this.querySelector('button[type="submit"]');
449 this._cancel = this.querySelector("[data-cancel]");
450 this._cancelControl = this.querySelector("[data-cancel-control]");
451 this._onSubmit = event => {
452 event.preventDefault();
453 const message = this._textarea?.value.trim();
454 if (!message || this._button?.disabled) return;
455 this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", {
456 bubbles: true,
457 detail: { message },
458 }));
459 this._form.reset();
460 };
461 this._onKeydown = event => {
462 if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
463 event.preventDefault();
464 this._form?.requestSubmit();
465 };
466 this._form?.addEventListener("submit", this._onSubmit);
467 this._textarea?.addEventListener("keydown", this._onKeydown);
468 this._onCancel = () => {
469 this.dispatchEvent(new CustomEvent("mjj-jrpg-cancel", {
470 bubbles: true,
471 }));
472 };
473 this._cancel?.addEventListener("click", this._onCancel);
474 }
475
476 disconnectedCallback() {
477 this._form?.removeEventListener("submit", this._onSubmit);
478 this._textarea?.removeEventListener("keydown", this._onKeydown);
479 this._cancel?.removeEventListener("click", this._onCancel);
480 }
481
482 setBusy(busy) {
483 if (this._textarea) this._textarea.disabled = busy;
484 if (this._button) {
485 this._button.disabled = busy;
486 this._button.setAttribute("aria-busy", String(busy));
487 }
488 if (this._cancelControl) this._cancelControl.hidden = !busy;
489 }
490
491 setCancellable(cancellable) {
492 if (this._cancelControl) this._cancelControl.hidden = !cancellable;
493 }
494
495 setDisabled(disabled) {
496 if (this._textarea) this._textarea.disabled = disabled;
497 if (this._button) this._button.disabled = disabled;
498 } 456 }
499 } 457 }
500 458
501 class MjjJrpgMenu extends HTMLElement { 459 class MjjJrpgMenu extends HTMLElement {
502 connectedCallback() { 460 connectedCallback() {
1788 class MjjJrpgShell extends HTMLElement { 1746 class MjjJrpgShell extends HTMLElement {
1789 connectedCallback() { 1747 connectedCallback() {
1790 this._mobileMenuQuery = window.matchMedia( 1748 this._mobileMenuQuery = window.matchMedia(
1791 "(max-width: 52rem) and (orientation: portrait)", 1749 "(max-width: 52rem) and (orientation: portrait)",
1792 ); 1750 );
1793 this._syncMobileMenu = expanded => { 1751 this._mobileDestinationOwner = this.querySelector(
1794 const button = this.querySelector("[data-mobile-menu-toggle]"); 1752 "[data-mobile-destination-owner]",
1795 const menu = this.querySelector("mjj-jrpg-menu"); 1753 );
1796 if (!button || !menu) return; 1754 this._mobileDestinationDialog = this.querySelector(
1797 if (!this._mobileMenuQuery.matches) { 1755 "[data-mobile-destination-dialog]",
1798 button.setAttribute("aria-expanded", "true"); 1756 );
1799 menu.removeAttribute("data-mobile-menu-collapsed"); 1757 this._mobileDestinationContent = this.querySelector(
1758 "[data-mobile-destination-content]",
1759 );
1760 this._mobileDestinationTitle = this.querySelector(
1761 "[data-mobile-destination-title]",
1762 );
1763 this._mobileDestinationBackOwner = this.querySelector(
1764 "[data-mobile-destination-back-owner]",
1765 );
1766 this._mobileDestinationBack = this.querySelector(
1767 "[data-mobile-destination-back]",
1768 );
1769 this._utility = this.querySelector(".jrpg-utility");
1770 this._menu = this.querySelector("mjj-jrpg-menu");
1771 this._utilityPlaceholder = document.createComment("jrpg-utility");
1772 this._menuPlaceholder = document.createComment("jrpg-menu");
1773 this._utility?.before(this._utilityPlaceholder);
1774 this._menu?.before(this._menuPlaceholder);
1775
1776 this._syncMobileLayout = () => {
1777 if (!this._utility || !this._menu || !this._mobileDestinationContent) {
1800 return; 1778 return;
1801 } 1779 }
1802 button.setAttribute("aria-expanded", String(expanded)); 1780 if (this._mobileMenuQuery.matches) {
1803 menu.toggleAttribute("data-mobile-menu-collapsed", !expanded); 1781 this._mobileDestinationContent.append(this._menu, this._utility);
1782 this.showMobileMenu();
1783 return;
1784 }
1785 this._mobileDestinationOwner?.close();
1786 this._utilityPlaceholder.after(this._utility);
1787 this._menuPlaceholder.after(this._menu);
1788 this._utility.hidden = false;
1789 this._menu.hidden = false;
1804 }; 1790 };
1805 this._onMenuToggle = event => { 1791 this._onMobileMenuMediaChange = () => this._syncMobileLayout();
1806 const button = event.target.closest("[data-mobile-menu-toggle]"); 1792 this._onMobileDestinationBack = () => this.showMobileMenu();
1807 if (!button || !this.contains(button)) return; 1793 this._onMobileDestinationClose = () => this.showMobileMenu();
1808 const expanded = button.getAttribute("aria-expanded") === "true"; 1794 this._mobileDestinationBack?.addEventListener(
1809 this._syncMobileMenu(!expanded); 1795 "click",
1810 if (!expanded) { 1796 this._onMobileDestinationBack,
1811 const firstItem = this.querySelector( 1797 );
1812 "mjj-jrpg-menu button[data-preview]", 1798 this._mobileDestinationDialog?.addEventListener(
1813 ); 1799 "close",
1814 if (firstItem) firstItem.focus(); 1800 this._onMobileDestinationClose,
1815 } 1801 );
1816 };
1817 this._onMobileMenuMediaChange = () => this._syncMobileMenu(true);
1818 this.addEventListener("click", this._onMenuToggle);
1819 this._mobileMenuQuery.addEventListener( 1802 this._mobileMenuQuery.addEventListener(
1820 "change", 1803 "change",
1821 this._onMobileMenuMediaChange, 1804 this._onMobileMenuMediaChange,
1822 ); 1805 );
1823 this._syncMobileMenu(true); 1806 this._syncMobileLayout();
1807 }
1808
1809 isMobileDestinationModal() {
1810 return Boolean(this._mobileMenuQuery?.matches);
1811 }
1812
1813 isMobileDestinationOpen() {
1814 return this.isMobileDestinationModal() &&
1815 Boolean(this._mobileDestinationDialog?.open);
1816 }
1817
1818 showMobileMenu() {
1819 if (!this.isMobileDestinationModal() || !this._menu || !this._utility) {
1820 return;
1821 }
1822 this._menu.hidden = false;
1823 this._utility.hidden = true;
1824 if (this._mobileDestinationTitle) {
1825 this._mobileDestinationTitle.textContent = "Menu";
1826 }
1827 if (this._mobileDestinationBackOwner) {
1828 this._mobileDestinationBackOwner.hidden = true;
1829 }
1830 }
1831
1832 showMobileDestination(selection) {
1833 if (!this.isMobileDestinationModal() || !this._menu || !this._utility) {
1834 return;
1835 }
1836 const labels = {
1837 blog: "Blogs",
1838 conversations: "Conversations",
1839 resume: "Resume",
1840 tools: "Tools",
1841 };
1842 this._menu.hidden = true;
1843 this._utility.hidden = false;
1844 if (this._mobileDestinationTitle) {
1845 this._mobileDestinationTitle.textContent = labels[selection] || "Destination";
1846 }
1847 if (this._mobileDestinationBackOwner) {
1848 this._mobileDestinationBackOwner.hidden = false;
1849 }
1850 }
1851
1852 closeMobileDestinations() {
1853 if (this.isMobileDestinationModal()) {
1854 this._mobileDestinationOwner?.close();
1855 }
1824 } 1856 }
1825 1857
1826 disconnectedCallback() { 1858 disconnectedCallback() {
1827 this.removeEventListener("click", this._onMenuToggle); 1859 this._mobileDestinationBack?.removeEventListener(
1860 "click",
1861 this._onMobileDestinationBack,
1862 );
1863 this._mobileDestinationDialog?.removeEventListener(
1864 "close",
1865 this._onMobileDestinationClose,
1866 );
1828 this._mobileMenuQuery?.removeEventListener( 1867 this._mobileMenuQuery?.removeEventListener(
1829 "change", 1868 "change",
1830 this._onMobileMenuMediaChange, 1869 this._onMobileMenuMediaChange,
1831 ); 1870 );
1832 } 1871 }
1834 1873
1835 for (const [name, constructor] of Object.entries({ 1874 for (const [name, constructor] of Object.entries({
1836 "mjj-conversation-archive": MjjConversationArchive, 1875 "mjj-conversation-archive": MjjConversationArchive,
1837 "mjj-jrpg-character": MjjJrpgCharacter, 1876 "mjj-jrpg-character": MjjJrpgCharacter,
1838 "mjj-jrpg-chat": MjjJrpgChat, 1877 "mjj-jrpg-chat": MjjJrpgChat,
1839 "mjj-jrpg-composer": MjjJrpgComposer,
1840 "mjj-jrpg-menu": MjjJrpgMenu, 1878 "mjj-jrpg-menu": MjjJrpgMenu,
1841 "mjj-jrpg-preview": MjjJrpgPreview, 1879 "mjj-jrpg-preview": MjjJrpgPreview,
1842 "mjj-jrpg-shell": MjjJrpgShell, 1880 "mjj-jrpg-shell": MjjJrpgShell,
1843 })) { 1881 })) {
1844 if (!customElements.get(name)) customElements.define(name, constructor); 1882 if (!customElements.get(name)) customElements.define(name, constructor);
2134 _csrfToken = await _fetchSession(); 2172 _csrfToken = await _fetchSession();
2135 2173
2136 const shell = document.querySelector("mjj-jrpg-shell"); 2174 const shell = document.querySelector("mjj-jrpg-shell");
2137 const character = shell?.querySelector("mjj-jrpg-character"); 2175 const character = shell?.querySelector("mjj-jrpg-character");
2138 const chat = shell?.querySelector("mjj-jrpg-chat"); 2176 const chat = shell?.querySelector("mjj-jrpg-chat");
2139 const composer = shell?.querySelector("mjj-jrpg-composer"); 2177 const composer = shell?.querySelector("mjj-composer");
2140 const preview = shell?.querySelector("mjj-jrpg-preview"); 2178 const preview = shell?.querySelector("mjj-jrpg-preview");
2141 const archive = shell?.querySelector("mjj-conversation-archive"); 2179 const archive = shell?.querySelector("mjj-conversation-archive");
2142 const accountSlot = shell?.querySelector("[data-frame-account]"); 2180 const accountSlot = shell?.querySelector("[data-frame-account]");
2143 const quotaEl = shell?.querySelector("[data-quota]"); 2181 const quotaEl = shell?.querySelector("[data-quota]");
2144 const frameNetwork = shell?.querySelector("[data-frame-network]"); 2182 const frameNetwork = shell?.querySelector("[data-frame-network]");
2451 history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); 2489 history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
2452 }; 2490 };
2453 2491
2454 /* ---- Panel selection: shows archive or preview in utility ---- */ 2492 /* ---- Panel selection: shows archive or preview in utility ---- */
2455 const utilityEl = shell?.querySelector(".jrpg-utility"); 2493 const utilityEl = shell?.querySelector(".jrpg-utility");
2494 const previewContent = preview?.querySelector("[data-preview-content]");
2456 2495
2457 const selectPanel = (panel, pushHistory = true) => { 2496 const selectPanel = (panel, pushHistory = true) => {
2458 const validPanel = VALID_PANELS.includes(panel) ? panel : "resume"; 2497 const validPanel = VALID_PANELS.includes(panel) ? panel : "resume";
2459 currentPanel = validPanel; 2498 currentPanel = validPanel;
2460 for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) { 2499 for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) {
2461 btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel)); 2500 btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel));
2462 } 2501 }
2463 if (validPanel === "conversations") { 2502 if (validPanel === "conversations") {
2464 if (archive) archive.hidden = false; 2503 if (archive) archive.hidden = false;
2465 if (preview) preview.hidden = true; 2504 if (previewContent) previewContent.hidden = true;
2466 if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations"); 2505 if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations");
2467 } else { 2506 } else {
2468 if (archive) archive.hidden = true; 2507 if (archive) archive.hidden = true;
2469 if (preview) preview.hidden = false; 2508 if (previewContent) previewContent.hidden = false;
2470 preview?.show(validPanel); 2509 preview?.show(validPanel);
2471 if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination"); 2510 if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination");
2472 } 2511 }
2473 if (pushHistory) _pushUrlState(validPanel, currentConversationId); 2512 if (pushHistory) _pushUrlState(validPanel, currentConversationId);
2474 }; 2513 };
2475 2514
2476 /* ---- Archive close button: navigate back to resume panel ---- */ 2515 /* ---- Archive close button: navigate back to resume panel ---- */
2477 shell?.addEventListener("mjj-archive-toggle", () => { 2516 shell?.addEventListener("mjj-archive-toggle", () => {
2517 if (shell?.isMobileDestinationModal()) {
2518 shell.showMobileMenu();
2519 return;
2520 }
2478 selectPanel("resume"); 2521 selectPanel("resume");
2479 }); 2522 });
2480 2523
2481 shell?.addEventListener("mjj-archive-new", () => { 2524 shell?.addEventListener("mjj-archive-new", () => {
2482 if (activeController) return; 2525 if (activeController) return;
2483 currentConversationId = null; 2526 currentConversationId = null;
2484 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); 2527 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2485 _pushUrlState(currentPanel, null); 2528 _pushUrlState(currentPanel, null);
2486 chat?.setGreeting(greetingForSession(_sessionData), true); 2529 chat?.setGreeting(greetingForSession(_sessionData), true);
2487 archive?.setCurrentId(null); 2530 archive?.setCurrentId(null);
2531 shell?.closeMobileDestinations();
2488 composer?.querySelector("textarea")?.focus(); 2532 composer?.querySelector("textarea")?.focus();
2489 }); 2533 });
2490 2534
2491 /* ---- Popstate: re-apply panel and conversation on back/forward ---- */ 2535 /* ---- Popstate: re-apply panel and conversation on back/forward ---- */
2492 /* _applyPopState always increments openSeq so any pending archive-open 2536 /* _applyPopState always increments openSeq so any pending archive-open
2495 const _applyPopState = async (seq, popPanel, popConvId) => { 2539 const _applyPopState = async (seq, popPanel, popConvId) => {
2496 const localSeq = ++openSeq; 2540 const localSeq = ++openSeq;
2497 const epoch = principalEpoch; 2541 const epoch = principalEpoch;
2498 2542
2499 selectPanel(popPanel, false); 2543 selectPanel(popPanel, false);
2544 if (shell?.isMobileDestinationOpen()) {
2545 shell.showMobileDestination(popPanel);
2546 }
2500 2547
2501 if (popConvId && popConvId !== currentConversationId) { 2548 if (popConvId && popConvId !== currentConversationId) {
2502 try { 2549 try {
2503 const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`); 2550 const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`);
2504 if (seq !== popSeq || localSeq !== openSeq || 2551 if (seq !== popSeq || localSeq !== openSeq ||
2584 currentConversationId = id; 2631 currentConversationId = id;
2585 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id); 2632 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id);
2586 _pushUrlState(currentPanel, id); 2633 _pushUrlState(currentPanel, id);
2587 chat?.replaceMessages(conversation.turns); 2634 chat?.replaceMessages(conversation.turns);
2588 archive?.setCurrentId(id); 2635 archive?.setCurrentId(id);
2636 shell?.closeMobileDestinations();
2589 } catch (error) { 2637 } catch (error) {
2590 if (seq !== openSeq || epoch !== principalEpoch) return; 2638 if (seq !== openSeq || epoch !== principalEpoch) return;
2591 archive?.setError(`Unable to open: ${error.message}`); 2639 archive?.setError(`Unable to open: ${error.message}`);
2592 } 2640 }
2593 }); 2641 });
2752 ); 2800 );
2753 } 2801 }
2754 currentPanel = validPanel || "resume"; 2802 currentPanel = validPanel || "resume";
2755 } 2803 }
2756 2804
2757 /* ---- Load archive and restore conversation from URL or sessionStorage ---- */ 2805 /* ---- Load archive; only an explicit URL restores a conversation ---- */
2758 const startupEpoch = principalEpoch; 2806 const startupEpoch = principalEpoch;
2759 const ensureStartupPrincipal = () => { 2807 const ensureStartupPrincipal = () => {
2760 if (startupEpoch !== principalEpoch) { 2808 if (startupEpoch !== principalEpoch) {
2761 const error = new Error("Principal changed during startup"); 2809 const error = new Error("Principal changed during startup");
2762 error.name = "StalePrincipalError"; 2810 error.name = "StalePrincipalError";
2770 archiveCursor = data.cursor || null; 2818 archiveCursor = data.cursor || null;
2771 const conversations = data.conversations || []; 2819 const conversations = data.conversations || [];
2772 conversations.forEach(c => _renderedIds.add(c.id)); 2820 conversations.forEach(c => _renderedIds.add(c.id));
2773 archive?.setConversations(conversations, archiveCursor); 2821 archive?.setConversations(conversations, archiveCursor);
2774 2822
2775 /* Determine target conversation: URL param is canonical; migrate sessionStorage once */ 2823 /* A conversation opens only through an explicit URL or a user archive action. */
2776 const initParams2 = new URLSearchParams(window.location.search); 2824 const initParams2 = new URLSearchParams(window.location.search);
2777 const urlConvId = initParams2.get("conversation"); 2825 const urlConvId = initParams2.get("conversation");
2778 const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null; 2826 const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null;
2779 2827 const storedConvId = !_convParamWasExplicit
2780 let resolvedConvId = urlConvValid; 2828 ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
2781 let migratedFromStorage = false; 2829 : null;
2782 if (!resolvedConvId && !_convParamWasExplicit) { 2830
2783 const stored = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); 2831 if (urlConvValid) {
2784 if (stored && UUID_REGEX.test(stored)) { 2832 const inArchive = conversations.find(c => c.id === urlConvValid);
2785 resolvedConvId = stored;
2786 migratedFromStorage = true;
2787 }
2788 }
2789
2790 if (resolvedConvId) {
2791 const inArchive = conversations.find(c => c.id === resolvedConvId);
2792 if (inArchive) { 2833 if (inArchive) {
2793 try { 2834 try {
2794 const conversation = await requestJson( 2835 const conversation = await requestJson(
2795 `/api/conversations/${encodeURIComponent(resolvedConvId)}`, 2836 `/api/conversations/${encodeURIComponent(urlConvValid)}`,
2796 ); 2837 );
2797 ensureStartupPrincipal(); 2838 ensureStartupPrincipal();
2798 currentConversationId = resolvedConvId; 2839 currentConversationId = urlConvValid;
2799 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); 2840 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid);
2800 chat?.replaceMessages(conversation.turns); 2841 chat?.replaceMessages(conversation.turns);
2801 archive?.setCurrentId(resolvedConvId); 2842 archive?.setCurrentId(urlConvValid);
2802 } catch (error) { 2843 } catch (error) {
2803 if (error.name === "StalePrincipalError" || 2844 if (error.name === "StalePrincipalError" ||
2804 startupEpoch !== principalEpoch) throw error; 2845 startupEpoch !== principalEpoch) throw error;
2805 currentConversationId = null; 2846 currentConversationId = null;
2806 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); 2847 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2808 } else { 2849 } else {
2809 /* Not on first page — fetch directly to verify ownership */ 2850 /* Not on first page — fetch directly to verify ownership */
2810 let ownedConversation = null; 2851 let ownedConversation = null;
2811 try { 2852 try {
2812 ownedConversation = await requestJson( 2853 ownedConversation = await requestJson(
2813 `/api/conversations/${encodeURIComponent(resolvedConvId)}`, 2854 `/api/conversations/${encodeURIComponent(urlConvValid)}`,
2814 ); 2855 );
2815 ensureStartupPrincipal(); 2856 ensureStartupPrincipal();
2816 } catch (error) { 2857 } catch (error) {
2817 if (error.name === "StalePrincipalError" || 2858 if (error.name === "StalePrincipalError" ||
2818 startupEpoch !== principalEpoch) throw error; 2859 startupEpoch !== principalEpoch) throw error;
2819 ownedConversation = null; 2860 ownedConversation = null;
2820 } 2861 }
2821 if (ownedConversation) { 2862 if (ownedConversation) {
2822 currentConversationId = resolvedConvId; 2863 currentConversationId = urlConvValid;
2823 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); 2864 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid);
2824 chat?.replaceMessages(ownedConversation.turns); 2865 chat?.replaceMessages(ownedConversation.turns);
2825 if (!_renderedIds.has(resolvedConvId)) { 2866 if (!_renderedIds.has(urlConvValid)) {
2826 _renderedIds.add(resolvedConvId); 2867 _renderedIds.add(urlConvValid);
2827 archive?.addConversation({ 2868 archive?.addConversation({
2828 id: resolvedConvId, 2869 id: urlConvValid,
2829 title: ownedConversation.title || "Conversation", 2870 title: ownedConversation.title || "Conversation",
2830 turn_count: ownedConversation.turns?.length || 0, 2871 turn_count: ownedConversation.turns?.length || 0,
2831 }); 2872 });
2832 } 2873 }
2833 archive?.setCurrentId(resolvedConvId); 2874 archive?.setCurrentId(urlConvValid);
2834 } else if (_sessionData?.kind === "user") {
2835 /* Authenticated and 404 — offer legacy claim for sessionStorage migrations */
2836 if (migratedFromStorage) {
2837 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, resolvedConvId);
2838 legacyClaimId = resolvedConvId;
2839 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2840 archive?.showLegacyClaim();
2841 if (conversations.length > 0) {
2842 currentConversationId = conversations[0].id;
2843 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
2844 try {
2845 const conv = await requestJson(
2846 `/api/conversations/${encodeURIComponent(currentConversationId)}`,
2847 );
2848 ensureStartupPrincipal();
2849 chat?.replaceMessages(conv.turns);
2850 archive?.setCurrentId(currentConversationId);
2851 } catch (error) {
2852 if (error.name === "StalePrincipalError" ||
2853 startupEpoch !== principalEpoch) throw error;
2854 currentConversationId = null;
2855 }
2856 }
2857 } else {
2858 /* URL-specified conv returned 404 — clear it */
2859 currentConversationId = null;
2860 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2861 }
2862 } else { 2875 } else {
2863 /* Guest — not owned, clear stale ID */
2864 currentConversationId = null; 2876 currentConversationId = null;
2865 if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); 2877 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2866 } 2878 }
2867 } 2879 }
2868 } else if (conversations.length > 0) { 2880 } else {
2869 /* No target conversation — open the most recent */ 2881 currentConversationId = null;
2870 const most_recent = conversations[0]; 2882 archive?.setCurrentId(null);
2871 try { 2883 if (conversations.length > 0) {
2872 const conversation = await requestJson( 2884 chat?.setGreeting(greetingForSession(_sessionData, true), true);
2873 `/api/conversations/${encodeURIComponent(most_recent.id)}`, 2885 }
2886 if (storedConvId && UUID_REGEX.test(storedConvId)) {
2887 const storedConversationIsOwned = conversations.some(
2888 conversation => conversation.id === storedConvId,
2874 ); 2889 );
2875 ensureStartupPrincipal(); 2890 if (_sessionData?.kind === "user" && !storedConversationIsOwned) {
2876 currentConversationId = most_recent.id; 2891 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, storedConvId);
2877 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); 2892 legacyClaimId = storedConvId;
2878 chat?.replaceMessages(conversation.turns); 2893 archive?.showLegacyClaim();
2879 archive?.setCurrentId(most_recent.id); 2894 }
2880 } catch (error) { 2895 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
2881 if (error.name === "StalePrincipalError" ||
2882 startupEpoch !== principalEpoch) throw error;
2883 currentConversationId = null;
2884 } 2896 }
2885 } 2897 }
2886 } catch (error) { 2898 } catch (error) {
2887 if (error.name !== "StalePrincipalError" && 2899 if (error.name !== "StalePrincipalError" &&
2888 startupEpoch === principalEpoch) { 2900 startupEpoch === principalEpoch) {
2889 archive?.setError(`Archive error: ${error.message}`); 2901 archive?.setError(`Archive error: ${error.message}`);
2890 } 2902 }
2891 /* Fall back to direct fetch of URL/stored ID */ 2903 /* Fall back only for an explicit deep-linked conversation. */
2892 const initParams3 = new URLSearchParams(window.location.search); 2904 const initParams3 = new URLSearchParams(window.location.search);
2893 const fallbackId = ( 2905 const fallbackId = initParams3.get("conversation");
2894 initParams3.get("conversation") ||
2895 (!_convParamWasExplicit
2896 ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
2897 : null)
2898 ) || null;
2899 if (startupEpoch === principalEpoch && 2906 if (startupEpoch === principalEpoch &&
2900 fallbackId && UUID_REGEX.test(fallbackId)) { 2907 fallbackId && UUID_REGEX.test(fallbackId)) {
2901 try { 2908 try {
2902 const conversation = await requestJson( 2909 const conversation = await requestJson(
2903 `/api/conversations/${encodeURIComponent(fallbackId)}`, 2910 `/api/conversations/${encodeURIComponent(fallbackId)}`,
2923 _replaceUrlState(currentPanel, currentConversationId); 2930 _replaceUrlState(currentPanel, currentConversationId);
2924 } 2931 }
2925 2932
2926 shell?.addEventListener("mjj-jrpg-preview-change", event => { 2933 shell?.addEventListener("mjj-jrpg-preview-change", event => {
2927 selectPanel(event.detail.selection); 2934 selectPanel(event.detail.selection);
2935 shell?.showMobileDestination(event.detail.selection);
2928 character?.setAttribute("state", "thinking"); 2936 character?.setAttribute("state", "thinking");
2929 window.clearTimeout(characterTimer); 2937 window.clearTimeout(characterTimer);
2930 characterTimer = window.setTimeout(() => { 2938 characterTimer = window.setTimeout(() => {
2931 character?.setAttribute("state", "default"); 2939 character?.setAttribute("state", "default");
2932 }, 650); 2940 }, 650);
2933 }); 2941 });
2934 2942
2935 shell?.addEventListener("mjj-jrpg-cancel", () => { 2943 shell?.addEventListener("mjj-composer-cancel", () => {
2936 activeController?.abort(); 2944 activeController?.abort();
2937 }); 2945 });
2938 2946
2939 shell?.addEventListener("mjj-jrpg-submit", async event => { 2947 shell?.addEventListener("mjj-composer-submit", async event => {
2940 if (activeController) return; 2948 if (activeController) return;
2941 /* Block sends when password change is required */ 2949 /* Block sends when password change is required */
2942 if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { 2950 if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
2943 return; 2951 return;
2944 } 2952 }