Mercurial
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 } |