jostlebot's picture
Claude Opus 5
Second Thoughts: confidential space for second thoughts about a vote
570a433
Raw History Blame Contribute Delete
8.99 kB
// 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 = '<span class="typing"><span></span><span></span><span></span></span>';
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(); });
}