// Second Thoughts — front end. // Conversation state lives only in this tab's memory: no localStorage, no // analytics, nothing written to disk. Closing the tab ends it. const scroll = document.getElementById("scroll"); const thread = document.getElementById("thread"); const form = document.getElementById("composer"); const input = document.getElementById("msg"); const sendBtn = document.getElementById("send"); const starters = document.getElementById("starters"); const startersLabel = document.getElementById("starters-label"); const optional = document.getElementById("optional"); const hint = document.getElementById("hint"); const path = document.getElementById("path"); /** @type {{role: "user"|"assistant", content: string}[]} */ const messages = []; let busy = false; // Practice mode. `mode` is sent with each request; "practice_end" is a // one-shot note that the person stopped, so the model returns to listening. const practice = { active: false, step: 0, draft: "", endNote: false, breathed: false }; const MARKER = /\[\[step:(\d|done)\]\]/g; // Hide markers from the person, including one still arriving mid-stream. function visible(text) { return text.replace(MARKER, "").replace(/\[\[[^\]]*$/, "").trimEnd(); } function atBottom() { return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 80; } function scrollDown() { scroll.scrollTop = scroll.scrollHeight; } function bubble(kind, text) { const el = document.createElement("div"); el.className = `bubble bubble--${kind}`; el.textContent = text; thread.appendChild(el); scrollDown(); return el; } function typingIndicator() { const el = document.createElement("div"); el.className = "bubble bubble--them"; el.innerHTML = ''; thread.appendChild(el); scrollDown(); return el; } // Once the conversation starts, the starter prompts have done their job. The // optional doors are the exception — both act on what was already shared, so // they only become useful now. Dock them instead of dropping them. function retireStarters() { for (const el of [starters, startersLabel, hint]) { if (el) el.remove(); } if (optional) optional.classList.add("optional--docked"); } function fromTemplate(id) { const node = document.getElementById(id).content.firstElementChild.cloneNode(true); thread.appendChild(node); scrollDown(); return node; } function renderPath() { for (const li of path.querySelectorAll("li")) { const n = Number(li.dataset.step); li.classList.toggle("is-done", n < practice.step); li.classList.toggle("is-current", n === practice.step); if (n === practice.step) li.setAttribute("aria-current", "step"); else li.removeAttribute("aria-current"); } } function setPractice(active) { practice.active = active; path.hidden = !active; optional.hidden = active; if (active) path.classList.add("optional--docked"); renderPath(); } function beginPractice() { Object.assign(practice, { step: 1, draft: "", endNote: false, breathed: false }); setPractice(true); send("I'd like to practice saying it to someone."); } function stopPractice() { setPractice(false); practice.endNote = true; bubble("notice", "Practice stopped. That counts — you can start again anytime, or just keep talking here."); } function finishPractice() { setPractice(false); const card = fromTemplate("practice-done"); if (practice.draft) { card.querySelector(".pcard__draft").hidden = false; card.querySelector(".pcard__quote").textContent = practice.draft; } } // Apply the step marker from a finished reply. function advance(reply) { const marks = [...reply.matchAll(MARKER)]; if (!practice.active || !marks.length) return; const mark = marks[marks.length - 1][1]; if (mark === "done") return finishPractice(); practice.step = Number(mark); renderPath(); if (practice.step === 4 && !practice.breathed) { practice.breathed = true; fromTemplate("breathe"); } } function setBusy(state) { busy = state; sendBtn.disabled = state; input.disabled = state; if (!state) input.focus(); } function autoGrow() { input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 120)}px`; } async function send(text) { const content = text.trim(); if (!content || busy) return; retireStarters(); bubble("you", content); messages.push({ role: "user", content }); // What they say while on "Say it" is the draft they get to keep. if (practice.active && practice.step === 2) practice.draft = content; let mode; if (practice.active) mode = "practice"; else if (practice.endNote) mode = "practice_end"; practice.endNote = false; input.value = ""; autoGrow(); setBusy(true); const pending = typingIndicator(); let reply = ""; let target = null; const fail = (message) => { pending.remove(); if (target) target.remove(); bubble("notice", message); // Drop the unanswered turn so a retry doesn't double up. messages.pop(); }; try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages, mode }), }); if (!res.ok || !res.body) { let message = "I couldn't reach the other side of this just now. Try again in a moment."; try { const body = await res.json(); if (body && body.error) message = body.error; } catch (_) { /* keep the default */ } fail(message); setBusy(false); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let failed = false; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split("\n\n"); buffer = chunks.pop() || ""; for (const chunk of chunks) { const line = chunk.trim(); if (!line.startsWith("data:")) continue; let event; try { event = JSON.parse(line.slice(5).trim()); } catch (_) { continue; } if (event.type === "delta") { const stick = atBottom(); if (!target) { pending.remove(); target = bubble("them", ""); } reply += event.text; target.textContent = visible(reply); if (stick) scrollDown(); } else if (event.type === "error") { fail(event.message); failed = true; } } } if (failed) { setBusy(false); return; } pending.remove(); if (reply.trim()) { // Keep markers in history so the model can see where the steps stand. messages.push({ role: "assistant", content: reply }); advance(reply); } else if (!target) { fail("I went quiet there, which wasn't intentional. Try sending that again."); } } catch (_) { fail("The connection dropped. Whatever you typed is still yours — try again when you're ready."); } finally { setBusy(false); } } form.addEventListener("submit", (e) => { e.preventDefault(); send(input.value); }); // Enter sends; Shift+Enter makes a new line. On touch keyboards the return // key should insert a newline instead, so people can write more than a line. input.addEventListener("keydown", (e) => { const touch = window.matchMedia("(pointer: coarse)").matches; if (e.key === "Enter" && !e.shiftKey && !touch) { e.preventDefault(); send(input.value); } }); input.addEventListener("input", autoGrow); for (const btn of document.querySelectorAll("[data-send]")) { btn.addEventListener("click", () => send(btn.dataset.send)); } document.addEventListener("click", (e) => { const btn = e.target.closest("[data-action]"); if (!btn || busy) return; const action = btn.dataset.action; if (action === "practice-intro") { retireStarters(); fromTemplate("practice-intro"); } else if (action === "practice-begin") { btn.closest(".pcard").classList.add("pcard--started"); btn.remove(); beginPractice(); } else if (action === "practice-stop") { stopPractice(); } else if (action === "copy-draft") { navigator.clipboard?.writeText(practice.draft).then( () => { btn.textContent = "Copied"; }, () => { btn.textContent = "Couldn't copy — select the text instead"; }, ); } }); // "How this space works" sheet. for (const btn of document.querySelectorAll("[data-open]")) { btn.addEventListener("click", () => document.getElementById(btn.dataset.open).showModal()); } for (const dlg of document.querySelectorAll("dialog")) { dlg.querySelector("[data-close]").addEventListener("click", () => dlg.close()); // Tapping the dimmed backdrop closes it too. dlg.addEventListener("click", (e) => { if (e.target === dlg) dlg.close(); }); }