810 lines
50 KiB
JavaScript
810 lines
50 KiB
JavaScript
// assembly.js — the 3-pane multiplayer Q&A session (Phase 1: named join + roster + questions + answer boxes).
|
||
// TOP = questions (scroll), BOTTOM-LEFT = roster (who joined, chosen names), BOTTOM-RIGHT = answer textareas + submit.
|
||
// Round-loop (agent-driven): the agent posts questions → everyone answers → backend waits-for-all → next round.
|
||
|
||
const el = (id) => document.getElementById(id)
|
||
const toast = (t) => { const e = el('toast'); e.textContent = t; e.style.display = 'block'; setTimeout(() => e.style.display = 'none', 2200) }
|
||
|
||
// ── subtle notification sounds (Web Audio — on-device, NO files, NO CDN). Soft, pleasant, not harsh. ──
|
||
let audioCtx = null
|
||
function beep(freq, durMs, type, gainPeak) {
|
||
try {
|
||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)()
|
||
const now = audioCtx.currentTime
|
||
const osc = audioCtx.createOscillator(), g = audioCtx.createGain()
|
||
osc.type = type || 'sine'; osc.frequency.value = freq
|
||
// gentle attack + decay envelope so it's soft, not a harsh click
|
||
g.gain.setValueAtTime(0, now)
|
||
g.gain.linearRampToValueAtTime(gainPeak || 0.06, now + 0.02)
|
||
g.gain.exponentialRampToValueAtTime(0.0001, now + durMs / 1000)
|
||
osc.connect(g); g.connect(audioCtx.destination)
|
||
osc.start(now); osc.stop(now + durMs / 1000)
|
||
} catch (_) {}
|
||
}
|
||
// ★ SOUND DESIGN (Driver's spec QF-28): sound on JOIN, LEAVE, and NEXT-ROUND-START; NO sound on chat messages.
|
||
const sounds = {
|
||
newRound: () => { beep(523, 140, 'sine'); setTimeout(() => beep(659, 180, 'sine'), 120) }, // gentle two-note rise (round start)
|
||
join: () => beep(440, 120, 'sine', 0.05), // soft single rising tone
|
||
leave: () => beep(330, 140, 'sine', 0.05), // soft single falling tone (someone left)
|
||
submitted: () => beep(392, 100, 'sine', 0.05), // low soft blip
|
||
// (intentionally NO chat sound — Driver: no audio for chat messages)
|
||
}
|
||
|
||
// ── solid SVG icons (NO emoji — Driver's rule: real solid icons, small). Inline so no external asset. ──
|
||
const ICON = {
|
||
speaker: '<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>',
|
||
chest: '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor"><path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3H4V6zm0 5h7v2h2v-2h7v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-7zm7 0v-1h2v1h-2z"/></svg>',
|
||
download: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" style="vertical-align:-2px"><path d="M12 3v10l4-4 1.4 1.4L12 16 6.6 10.4 8 9l4 4V3h0zm-7 15h14v2H5v-2z"/></svg>',
|
||
up: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 21h2V9H2v12zm20-11a2 2 0 0 0-2-2h-6.3l1-4.6v-.3a1.5 1.5 0 0 0-.4-1L13 1 6.6 7.4A2 2 0 0 0 6 9v10a2 2 0 0 0 2 2h9a2 2 0 0 0 1.8-1.2l3-7a2 2 0 0 0 .2-.8v-2z"/></svg>',
|
||
down: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M22 3h-2v12h2V3zM2 14a2 2 0 0 0 2 2h6.3l-1 4.6v.3a1.5 1.5 0 0 0 .4 1L11 23l6.4-6.4A2 2 0 0 0 18 15V5a2 2 0 0 0-2-2H7a2 2 0 0 0-1.8 1.2l-3 7a2 2 0 0 0-.2.8v2z"/></svg>',
|
||
mic: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21h2v-3.1A7 7 0 0 0 19 11h-2z"/></svg>',
|
||
}
|
||
|
||
// ── random username (no login needed) — adjective + animal + number, like GitHub's suggested handles ──
|
||
const ADJ = ['brave', 'quiet', 'swift', 'clever', 'gentle', 'bold', 'lunar', 'amber', 'cosmic', 'noble', 'vivid', 'calm', 'bright', 'keen']
|
||
const NOUN = ['otter', 'falcon', 'cedar', 'comet', 'willow', 'lynx', 'heron', 'maple', 'ibex', 'raven', 'koi', 'fox']
|
||
function randomName() {
|
||
const r = (a) => a[Math.floor(Math.random() * a.length)]
|
||
return r(ADJ) + '-' + r(NOUN) + '-' + (10 + Math.floor(Math.random() * 90))
|
||
}
|
||
// ── deterministic identicon (GitHub/Forgejo-style) — a 5x5 mirrored pixel grid from a hash of the name ──
|
||
function hashStr(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) } return h >>> 0 }
|
||
function identiconSVG(seed, size = 28) {
|
||
const h = hashStr(seed)
|
||
const hue = h % 360
|
||
const color = `hsl(${hue},60%,55%)`
|
||
const cell = size / 5
|
||
let rects = ''
|
||
for (let x = 0; x < 3; x++) {
|
||
for (let y = 0; y < 5; y++) {
|
||
if (((h >> (x * 5 + y)) & 1) === 1) {
|
||
for (const xx of [x, 4 - x]) rects += `<rect x="${xx * cell}" y="${y * cell}" width="${cell}" height="${cell}" fill="${color}"/>`
|
||
}
|
||
}
|
||
}
|
||
return `<svg class="ident" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#1b1d21"/>${rects}</svg>`
|
||
}
|
||
|
||
let sessionId = null, token = null, guestId = null, myName = null
|
||
let currentQuestions = [] // this round's questions
|
||
let votes = {} // question index -> 'up' | 'down' | null (the user's like/dislike per question)
|
||
let expiresAt = 0
|
||
let roundNum = 0 // which round we're on (updated when a new round of questions arrives)
|
||
|
||
// show a skribbl-style pill "[name] submitted their answers" in the questions pane, briefly.
|
||
function showAnsweredPill(name) {
|
||
const q = el('qList'); if (!q) return
|
||
let bar = document.getElementById('answeredBar')
|
||
if (!bar) { bar = document.createElement('div'); bar.id = 'answeredBar'; bar.className = 'answered-bar'; q.parentNode.insertBefore(bar, q) }
|
||
const pill = document.createElement('span'); pill.className = 'answered-pill'
|
||
pill.textContent = '✓ ' + name + ' submitted'
|
||
bar.appendChild(pill)
|
||
}
|
||
let totalRounds = 0 // set from the round payload when known ("Round 1 of 5")
|
||
function updateRound() {
|
||
const r = el('roundNum'); if (!r) return
|
||
// always show "of N" — if the total isn't known yet, show a ? placeholder so it never looks incomplete.
|
||
r.textContent = roundNum ? ('Round ' + roundNum + ' of ' + (totalRounds || '?')) : 'Round —'
|
||
}
|
||
|
||
// ── join with a chosen name ──
|
||
async function join() {
|
||
const code = el('code').value.trim().toUpperCase()
|
||
const name = el('name').value.trim()
|
||
if (!code) { toast('Enter the code'); return }
|
||
if (!name) { toast('Enter your name'); return }
|
||
el('joinBtn').disabled = true
|
||
try {
|
||
// ★ STABLE client_id (Driver ghost-on-rejoin bug): persist a random id in localStorage so a reload/rejoin
|
||
// RECLAIMS the same presence slot (the server derives a deterministic guest id from it) instead of leaving a
|
||
// ghost of the old user with a stale green check. One id per browser, reused across reloads.
|
||
let clientId
|
||
try {
|
||
clientId = localStorage.getItem('qf-client-id')
|
||
if (!clientId) { clientId = 'c_' + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem('qf-client-id', clientId) }
|
||
} catch (_) { clientId = 'c_' + Date.now().toString(36) }
|
||
const r = await fetch('/baas/sessions/join-by-code', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, client_id: clientId })
|
||
})
|
||
if (!r.ok) { toast(r.status === 404 ? 'No open session for that code' : 'Could not join'); el('joinBtn').disabled = false; return }
|
||
const d = await r.json()
|
||
sessionId = d.session_id; token = d.subscribe_token; guestId = d.guest; myName = name; expiresAt = d.expires_at || 0
|
||
// register the chosen name (roster broadcasts to everyone)
|
||
const nr = await registerName()
|
||
if (nr) renderRoster(nr.roster || [])
|
||
el('gate').style.display = 'none'; el('session').style.display = 'grid'
|
||
loadDecisions() // ★ restore any decisions from a prior load of this session (they don't disappear on reload now)
|
||
// ★ fetch the SERVER-AUTHORITATIVE session info (read-only) — total rounds + the SHARED timer end. This fixes
|
||
// "Round 1 of ?" and gives every guest the SAME clock (the server owns it; the browser just displays it).
|
||
try {
|
||
const ir = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/info?token=${encodeURIComponent(token)}`)
|
||
if (ir.ok) {
|
||
const info = await ir.json()
|
||
if (info.total_rounds) totalRounds = info.total_rounds
|
||
if (info.round_seconds) roundSeconds = info.round_seconds
|
||
if (info.current_round) roundNum = info.current_round - 1
|
||
if (info.round_ends_at) serverRoundEndsAt = info.round_ends_at // server-owned shared timer end
|
||
updateRound()
|
||
// ★ INSTANT ROUND (kills the ~10s "waiting for round 1" wait): the agent pre-staged the current round's
|
||
// questions into /info at session-create. If they're here, render them NOW — no waiting for the live SSE
|
||
// broadcast (which only arrives after the agent's ~10s claude call). showQuestions is idempotent, so when the
|
||
// SSE broadcast of the same round arrives it's a no-op and won't wipe anything.
|
||
if (Array.isArray(info.questions) && info.questions.length) {
|
||
showQuestions(info.questions)
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
startTimer(); openStream()
|
||
} catch (e) { toast('Network error'); el('joinBtn').disabled = false }
|
||
}
|
||
|
||
// registerName — (re)register my chosen name so I appear in the roster. Called at join AND on every SSE reconnect
|
||
// (es.onopen) so a mobile disconnect/reconnect restores my presence instead of dropping me as a ghost.
|
||
async function registerName() {
|
||
if (!sessionId || !token || !myName) return null
|
||
try {
|
||
const nr = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/name?token=${encodeURIComponent(token)}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: myName })
|
||
})
|
||
return nr.ok ? await nr.json() : null
|
||
} catch (_) { return null }
|
||
}
|
||
|
||
// ── the roster (bottom-left) ──
|
||
function renderRoster(roster) {
|
||
const list = el('rList'); list.innerHTML = ''
|
||
roster.forEach((p, idx) => {
|
||
const row = document.createElement('div')
|
||
row.className = 'r-item' + (p.guest === guestId ? ' me' : '')
|
||
// identicon avatar + name; the FIRST joiner is the moderator (crown). (moderator/kick wired in Task #6)
|
||
const isMod = idx === 0
|
||
const isReady = p.ready === 'true' || p.ready === true
|
||
// ★ (Driver) the ready state is a small CHECK BADGE overlaid on the corner of the AVATAR image (not a separate
|
||
// standalone check). role badge (agent) still sits after the name.
|
||
const roleBadge = p.role === 'agent' ? '<span class="role-tag">agent</span>' : ''
|
||
const avatarBadge = isReady ? '<span class="avatar-ready" title="submitted — ready">✓</span>' : ''
|
||
row.innerHTML = '<span class="avatar-wrap">' + identiconSVG(p.name) + avatarBadge + '</span>' +
|
||
'<span class="r-name"></span>' + roleBadge + (isMod ? '<span class="mod-tag">mod</span>' : '')
|
||
row.querySelector('.r-name').textContent = p.name + (p.guest === guestId ? ' (you)' : '')
|
||
// ★ VOTE-TO-KICK (Driver, asked for hours): click another user → a small menu → "Vote to kick". A kick REVOKES
|
||
// their token server-side (they lose API access), so it's a real security control. You can't kick yourself or an
|
||
// agent-role member from here (agents are managed by their operator).
|
||
if (p.guest !== guestId && p.role === 'user') {
|
||
row.style.cursor = 'pointer'
|
||
row.title = 'Click to vote to kick'
|
||
row.addEventListener('click', () => openKickMenu(p))
|
||
}
|
||
list.appendChild(row)
|
||
})
|
||
if (!roster.length) list.innerHTML = '<div class="waiting">No one yet…</div>'
|
||
}
|
||
function openKickMenu(p) {
|
||
const ov = document.createElement('div'); ov.className = 'modal-overlay'
|
||
const card = document.createElement('div'); card.className = 'modal-card'
|
||
card.innerHTML = '<div style="font-weight:600;margin-bottom:6px;">' + (p.name || 'this user') + '</div>' +
|
||
'<div style="font-size:.8rem;opacity:.75;margin-bottom:12px;">Vote to remove them from the session. A kick revokes their access — a majority of the people here must agree.</div>'
|
||
const kick = document.createElement('button'); kick.textContent = 'Vote to kick'
|
||
kick.style.cssText = 'width:100%;padding:9px;border-radius:6px;border:1px solid var(--danger);background:transparent;color:var(--danger);cursor:pointer;font-weight:600;'
|
||
kick.addEventListener('click', async () => {
|
||
kick.disabled = true; kick.textContent = 'Voting…'
|
||
try {
|
||
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/kick?token=${encodeURIComponent(token)}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: p.guest })
|
||
})
|
||
const d = await r.json().catch(() => ({}))
|
||
toast(d.kicked ? `${p.name} was kicked` : `Vote counted (${d.votes || 1}/${d.needed || '?'} to kick)`)
|
||
} catch (_) { toast('Kick failed') }
|
||
ov.remove()
|
||
})
|
||
const close = document.createElement('button'); close.textContent = 'Cancel'
|
||
close.style.cssText = 'width:100%;margin-top:8px;padding:8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--fg);cursor:pointer;'
|
||
close.addEventListener('click', () => ov.remove())
|
||
card.appendChild(kick); card.appendChild(close)
|
||
ov.appendChild(card); ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove() })
|
||
document.body.appendChild(ov)
|
||
}
|
||
|
||
// ── questions (top) + answer boxes (bottom-right) ──
|
||
function parseQuestions(text) {
|
||
// a round's questions may arrive as a JSON array, or newline-separated text — handle both.
|
||
try { const j = JSON.parse(text); if (Array.isArray(j)) return j } catch (_) {}
|
||
return String(text).split('\n').map(s => s.trim()).filter(Boolean)
|
||
}
|
||
function showQuestions(qs) {
|
||
// ★ P0 DATA-LOSS FIX (SURVEYINATOR bugs #1/#2): the runner REBROADCASTS the current round on EVERY roster event
|
||
// (any user join/leave) and on a late-joiner catch-up. Previously this re-ran showQuestions() unconditionally, which
|
||
// did aList.innerHTML='' → WIPED every user's in-progress answer textareas (and re-advanced the round + reset the
|
||
// timer). So a peer joining/leaving destroyed your typing. FIX: showQuestions is now IDEMPOTENT on the SAME round —
|
||
// if the incoming questions match what's already rendered, it's a rebroadcast, NOT a new round → do NOTHING to the
|
||
// answer area (preserve every user's unsent typing), do NOT re-advance, do NOT reset the timer.
|
||
const sameRound = Array.isArray(currentQuestions) && currentQuestions.length === qs.length &&
|
||
currentQuestions.every((q, i) => q === qs[i])
|
||
if (sameRound && el('qList') && el('qList').querySelector('textarea[data-q]')) {
|
||
// already showing this exact round with the form built → a rebroadcast (join/leave/late-joiner). No-op:
|
||
// the per-question textareas (client-owned until submit) are preserved. Don't wipe anyone's typing.
|
||
return
|
||
}
|
||
currentQuestions = qs
|
||
votes = {}
|
||
roundNum += 1; updateRound() // a new round of questions arrived → advance + show the round number
|
||
startRoundTimer() // reset the 5:00 round countdown for the new round
|
||
sounds.newRound() // soft two-note chime when a new round's questions come in
|
||
const bar = document.getElementById('answeredBar'); if (bar) bar.innerHTML = '' // clear last round's "answered" pills
|
||
const qList = el('qList'); qList.innerHTML = ''
|
||
const aList = el('aList'); if (aList) aList.innerHTML = '' // #aList removed in the form-layout redesign — guard null
|
||
// a "Play all" button reads every question aloud in order (on-device TTS, nothing sent out).
|
||
if (qs.length && 'speechSynthesis' in window) {
|
||
const playAll = document.createElement('button'); playAll.className = 'play-all'
|
||
playAll.innerHTML = ICON.speaker + '<span>Play all questions</span>'
|
||
playAll.addEventListener('click', () => speakAll(qs))
|
||
qList.appendChild(playAll)
|
||
}
|
||
qs.forEach((q, i) => {
|
||
const qi = document.createElement('div'); qi.className = 'q-item'
|
||
// question text + a 🔊 speaker (read aloud) + 👍 / 👎 vote control
|
||
const txt = document.createElement('div')
|
||
txt.innerHTML = '<span class="q-num">Q' + (i + 1) + '</span><span></span>'
|
||
txt.lastChild.textContent = q
|
||
const vote = document.createElement('div'); vote.className = 'q-vote'
|
||
if ('speechSynthesis' in window) {
|
||
const spk = document.createElement('button'); spk.className = 'vote-btn spk'; spk.innerHTML = ICON.speaker
|
||
spk.title = 'Read this question aloud'
|
||
spk.addEventListener('click', () => speakOne(q))
|
||
vote.appendChild(spk)
|
||
}
|
||
const up = document.createElement('button'); up.className = 'vote-btn up'; up.innerHTML = ICON.up; up.title = 'Like'
|
||
const down = document.createElement('button'); down.className = 'vote-btn down'; down.innerHTML = ICON.down; down.title = 'Dislike'
|
||
up.addEventListener('click', () => setVote(i, 'up', up, down))
|
||
down.addEventListener('click', () => setVote(i, 'down', up, down))
|
||
vote.appendChild(up); vote.appendChild(down)
|
||
qi.appendChild(txt); qi.appendChild(vote)
|
||
qList.appendChild(qi)
|
||
// ★ FORM LAYOUT (Driver): the answer textarea goes DIRECTLY UNDER its question (not a separate pane). Full width
|
||
// of the question area, 1 row tall by default (resizable), collapsible. This makes it read like a form.
|
||
if (mode === 'type' && !settings.typeOneBox) {
|
||
const aWrap = document.createElement('div'); aWrap.className = 'q-answer'
|
||
const ta = document.createElement('textarea'); ta.dataset.q = i; ta.rows = 1; ta.placeholder = 'Your answer…'
|
||
ta.className = 'q-answer-box'
|
||
aWrap.appendChild(ta); qList.appendChild(aWrap)
|
||
}
|
||
})
|
||
// one-box + talk modes still render a single combined input area under the questions
|
||
if (mode !== 'type' || settings.typeOneBox) renderAnswerArea(qList)
|
||
// ★ (Driver) a trailing OPTIONAL "Additional Notes" box at the END of the questions — for anything off-topic.
|
||
if (qs.length && mode === 'type' && !settings.typeOneBox) {
|
||
const nWrap = document.createElement('div'); nWrap.className = 'q-answer notes-block'
|
||
const lbl = document.createElement('div'); lbl.className = 'notes-label'; lbl.textContent = 'Optional: additional notes about anything'
|
||
const ta = document.createElement('textarea'); ta.id = 'notesBox'; ta.rows = 1; ta.className = 'q-answer-box'
|
||
ta.placeholder = 'Anything else you want to add…'
|
||
nWrap.appendChild(lbl); nWrap.appendChild(ta); qList.appendChild(nWrap)
|
||
}
|
||
// ★ (Driver) only SHOW the submit button when there are actually questions — no floating button on an empty screen.
|
||
el('submitAns').style.display = qs.length ? 'block' : 'none'
|
||
el('submitAns').disabled = qs.length === 0
|
||
el('submitAns').textContent = 'Submit answers'
|
||
el('waitMsg').style.display = 'none'
|
||
}
|
||
|
||
// setVote records a like/dislike for a question; clicking the same vote again clears it (toggle).
|
||
function setVote(i, dir, upBtn, downBtn) {
|
||
votes[i] = votes[i] === dir ? null : dir
|
||
upBtn.classList.toggle('active', votes[i] === 'up')
|
||
downBtn.classList.toggle('active', votes[i] === 'down')
|
||
}
|
||
|
||
// ── submit this round's answers ──
|
||
async function submitAnswers() {
|
||
let payload
|
||
if (mode === 'talk') {
|
||
// Talk mode: send the WHOLE transcript; the agent sorts it into the questions (Driver's design).
|
||
const transcript = (el('talkTranscript') || {}).textContent || ''
|
||
if (!transcript.trim()) { toast('Nothing transcribed yet'); return }
|
||
payload = { type: 'answers', name: myName, mode: 'talk', transcript, questions: currentQuestions, votes }
|
||
} else if (settings.typeOneBox && el('oneBox')) {
|
||
// one-box type mode: send the whole text as a transcript for the agent to sort (same as talk mode)
|
||
const transcript = el('oneBox').value || ''
|
||
if (!transcript.trim()) { toast('Enter your answers'); return }
|
||
payload = { type: 'answers', name: myName, mode: 'talk', transcript, questions: currentQuestions, votes }
|
||
} else {
|
||
const answers = currentQuestions.map((q, i) => ({
|
||
q, a: (el('qList').querySelector(`textarea[data-q="${i}"]`) || {}).value || '',
|
||
vote: votes[i] || null // like/dislike rides in the submission metadata
|
||
}))
|
||
const notes = (el('notesBox') || {}).value || '' // ★ the trailing Additional Notes box (Driver)
|
||
payload = { type: 'answers', name: myName, round_answers: answers, notes }
|
||
}
|
||
el('submitAns').disabled = true
|
||
const ok = await postSubmission(payload)
|
||
if (ok) {
|
||
clearDraftsForRound() // ★ submitted successfully → the drafts for THIS round are safe to drop
|
||
el('submitAns').textContent = 'Submitted ✓'
|
||
el('waitMsg').style.display = 'block'
|
||
// ★ (Driver) the message must be AWARE of the final round — on the LAST round we're NOT generating another round,
|
||
// we're wrapping up + building the summary (the treasure-chest done screen), so don't mislead.
|
||
const isFinal = totalRounds && roundNum >= totalRounds
|
||
const msg = isFinal
|
||
? '<b>Submitted — final round.</b> Wrapping up your session and building the summary…'
|
||
: '<b>Submitted — waiting on other users to submit their answers.</b> When everyone’s in, the round advances.'
|
||
el('waitMsg').innerHTML = msg
|
||
// ★ QF-29 (Driver): DIM the questions area + show the waiting message overlaid, so it's clear you're waiting on
|
||
// others (not that something's broken). The overlay is cleared when the next round's questions render.
|
||
showWaitingDim(msg)
|
||
} else { toast('Failed to submit'); el('submitAns').disabled = false }
|
||
}
|
||
// ★ QF-29 waiting-dim with a 'Review questions' escape (Driver): the grey waiting screen isn't a dead-end — you can
|
||
// go back and RE-READ this round's questions (read-only, you've already submitted) while waiting on others.
|
||
function showWaitingDim(msg) {
|
||
const q = el('qList'); if (!q) return
|
||
window.__submittedQuestions = currentQuestions.slice() // remember what to show on "review"
|
||
q.innerHTML = '<div class="waiting-dim"><div class="agent-thinking">' + msg +
|
||
'</div><button id="reviewQ" class="review-btn">Go back to review the questions</button></div>'
|
||
const rv = document.getElementById('reviewQ')
|
||
if (rv) rv.addEventListener('click', () => showReviewQuestions(msg))
|
||
}
|
||
function showReviewQuestions(waitMsgText) {
|
||
const q = el('qList'); if (!q) return
|
||
q.innerHTML = '' // rebuild the questions READ-ONLY (already submitted this round)
|
||
const banner = document.createElement('div'); banner.className = 'review-banner'
|
||
banner.innerHTML = '<span>Reviewing your submitted questions (waiting on others)</span>'
|
||
const back = document.createElement('button'); back.className = 'review-btn'; back.textContent = 'Back to waiting'
|
||
back.addEventListener('click', () => showWaitingDim(waitMsgText))
|
||
banner.appendChild(back); q.appendChild(banner)
|
||
;(window.__submittedQuestions || currentQuestions).forEach((qq, i) => {
|
||
const blk = document.createElement('div'); blk.className = 'q-item review-q'
|
||
const num = document.createElement('span'); num.className = 'q-num'; num.textContent = 'Q' + (i + 1)
|
||
const txt = document.createElement('span'); txt.textContent = ' ' + qq
|
||
blk.appendChild(num); blk.appendChild(txt)
|
||
q.appendChild(blk)
|
||
})
|
||
}
|
||
async function postSubmission(payload) {
|
||
try {
|
||
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/submit?token=${encodeURIComponent(token)}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sealed: false, payload })
|
||
})
|
||
return r.ok
|
||
} catch (e) { return false }
|
||
}
|
||
|
||
// ── on-device text-to-speech (reads questions aloud). 100% local — uses the phone's built-in voices,
|
||
// sends NOTHING to any server. This is TTS only (speaking), not STT (no mic, no recording, no cloud). ──
|
||
function speakOne(text) {
|
||
if (!('speechSynthesis' in window)) return
|
||
window.speechSynthesis.cancel() // stop anything currently speaking
|
||
const u = new SpeechSynthesisUtterance(text)
|
||
u.rate = 1.0; u.pitch = 1.0
|
||
window.speechSynthesis.speak(u)
|
||
}
|
||
function speakAll(questions) {
|
||
if (!('speechSynthesis' in window)) return
|
||
window.speechSynthesis.cancel()
|
||
questions.forEach((q, i) => {
|
||
const u = new SpeechSynthesisUtterance('Question ' + (i + 1) + '. ' + q)
|
||
u.rate = 1.0
|
||
window.speechSynthesis.speak(u) // queued — they play in order
|
||
})
|
||
}
|
||
|
||
// ── the treasure-chest DONE screen (session complete) ──
|
||
function showDone(p) {
|
||
const s = el('session')
|
||
s.style.display = 'flex'; s.style.flexDirection = 'column'
|
||
s.innerHTML =
|
||
'<div class="done-wrap">' +
|
||
'<div class="done-chest">' + ICON.chest + '</div>' +
|
||
'<h2>Your Q&A session is done</h2>' +
|
||
'<p class="done-stats">You did <b>' + (p.rounds || 0) + '</b> round' + (p.rounds === 1 ? '' : 's') +
|
||
' · answered <b>' + (p.questions || 0) + '</b> question' + (p.questions === 1 ? '' : 's') + '</p>' +
|
||
'<div class="done-summary"></div>' +
|
||
'<button id="dlQA">' + ICON.download + ' Download your QA file</button>' +
|
||
'</div>'
|
||
s.querySelector('.done-summary').textContent = p.summary || ''
|
||
el('dlQA').addEventListener('click', () => {
|
||
const md = p.qa_markdown || ('# QA Session\n\n' + (p.summary || ''))
|
||
const blob = new Blob([md], { type: 'text/markdown' })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob); a.download = 'my-qa-session.md'; a.click()
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000)
|
||
})
|
||
el('roundTag').textContent = 'Session complete'
|
||
}
|
||
|
||
// ── live stream: roster updates + new question rounds ──
|
||
function openStream() {
|
||
if (typeof EventSource === 'undefined') return
|
||
const es = new EventSource(`/baas/sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(token)}`)
|
||
// ★ PRESENCE-RECONNECT FIX (SURVEYINATOR/mobile bug: "my character disappeared from Who's here after a mobile
|
||
// disconnect"). On mobile the SSE stream drops when the screen locks / app backgrounds / network blips → the
|
||
// server stops getting heartbeats → after the 45s TTL it prunes you as a ghost → you vanish from the roster even
|
||
// though your page still works. EventSource auto-reconnects, but the name was only registered ONCE at join, so a
|
||
// reconnect came back nameless/ghosted. FIX: re-register the name on EVERY (re)open → you reappear immediately.
|
||
es.onopen = () => { registerName().then(r => { if (r) renderRoster(r.roster || []) }) }
|
||
es.addEventListener('roster', (e) => {
|
||
try {
|
||
const r = JSON.parse(e.data).roster || []
|
||
// ★ JOIN/LEAVE DETECTION (Driver QF-23/QF-28): diff the roster BY NAME so we catch who joined + who left (not
|
||
// just a count delta), post a system line to chat + flash a banner, and play the join/leave sound.
|
||
const now = new Set(r.map(p => p.name))
|
||
const prev = window.__rosterNames instanceof Set ? window.__rosterNames : new Set()
|
||
if (window.__rosterNames) { // skip the very first roster (initial load isn't a "join" event)
|
||
r.forEach(p => { if (!prev.has(p.name) && p.name !== myName) { sounds.join(); addChatMsg('system', `${p.name} joined`, false); flashActivity(`${p.name} joined`) } })
|
||
prev.forEach(name => { if (!now.has(name) && name !== myName) { sounds.leave(); addChatMsg('system', `${name} left`, false); flashActivity(`${name} left`) } })
|
||
}
|
||
window.__rosterNames = now
|
||
renderRoster(r)
|
||
} catch (_) {}
|
||
})
|
||
// when a user submits, show a little pill "[name] submitted their answers" above the answer area (skribbl-style).
|
||
es.addEventListener('submission', (e) => {
|
||
// ★ (Driver) the "X submitted" notice belongs in the bottom-right ACTIVITY FEED (flashActivity), NOT a bar at the
|
||
// top-left of the questions. It's a transient log entry like join/leave, so it uses the same stacking banners.
|
||
try { const d = JSON.parse(e.data); if (d.payload && d.payload.type === 'answers') { sounds.submitted(); flashActivity(`${d.payload.name || 'someone'} submitted`) } } catch (_) {}
|
||
})
|
||
// ★ in-session CHAT: a `chat` event from any user → show it in the Chat tab.
|
||
es.addEventListener('chat', (e) => {
|
||
try { const d = JSON.parse(e.data); if (d.text) addChatMsg(d.name || 'someone', d.text, d.guest === guestId) } catch (_) {}
|
||
})
|
||
// ★ DECISIONS: the QA-loop agent, after folding a round's answers, emits `decision` events for any QUESTION where
|
||
// users DISAGREED (divergent answers → something to reconcile). The Decisions tab charts these: each is a card
|
||
// with status `conflict` (needs agreement) or `agreed` (resolved). This is what the tab exists to chart — not a
|
||
// generic to-do list, but the specific questions where the group's answers don't yet line up.
|
||
es.addEventListener('decision', (e) => {
|
||
try { const d = JSON.parse(e.data); if (d && d.question) upsertDecision(d) } catch (_) {}
|
||
})
|
||
// ★ VOTE-TO-KICK events. `kick_vote` = a vote landed (show progress); `kicked` = a member was removed. If the
|
||
// kicked member is ME, my token is now revoked → close the stream + show a removed screen (can't keep pulling API).
|
||
es.addEventListener('kick_vote', (e) => {
|
||
try { const d = JSON.parse(e.data)
|
||
toast(`${d.voter || 'someone'} voted to kick ${d.name || 'a user'} (${d.votes}/${d.needed})`)
|
||
// ★ (Driver) post it into the chat as a visible record, ATTRIBUTING the voter — 'X voted to kick Y'.
|
||
addChatMsg('system', `${d.voter || 'Someone'} voted to kick ${d.name || 'a user'} — ${d.votes} of ${d.needed} needed`, false)
|
||
} catch (_) {}
|
||
})
|
||
es.addEventListener('kicked', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data)
|
||
addChatMsg('system', `${d.name || 'A user'} was kicked by group vote (final vote by ${d.voter || 'someone'}) — access revoked.`, false)
|
||
if (d.guest === guestId) {
|
||
es.close()
|
||
document.getElementById('session').innerHTML = '<div class="waiting" style="padding:40px;text-align:center;">You were removed from this session by a group vote. Your access has been revoked.</div>'
|
||
} else {
|
||
toast(`${d.name || 'A user'} was kicked`)
|
||
}
|
||
} catch (_) {}
|
||
})
|
||
es.addEventListener('message', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data)
|
||
if (d.from !== 'agent' || !d.text) return
|
||
// ★ is this the COMPLETION signal? (a JSON object with kind:'complete') → show the done-screen, NOT questions.
|
||
let payload = null
|
||
try { payload = JSON.parse(d.text) } catch (_) {}
|
||
if (payload && payload.kind === 'complete') { showDone(payload); return }
|
||
// ★ a DECISIONS batch: the agent found questions where users disagreed → render them in the Decisions tab.
|
||
if (payload && payload.kind === 'decisions' && Array.isArray(payload.items)) {
|
||
payload.items.forEach((it) => { if (it && it.question) upsertDecision(it) }); return
|
||
}
|
||
// a round object {questions:[...], round:N, total:M} carries the counts so we can show "Round N of M".
|
||
if (payload && Array.isArray(payload.questions)) {
|
||
if (payload.total) totalRounds = payload.total
|
||
if (payload.round) { roundNum = payload.round - 1 } // showQuestions() will +1
|
||
showQuestions(payload.questions); return
|
||
}
|
||
// otherwise it's a plain questions array (legacy).
|
||
showQuestions(parseQuestions(d.text))
|
||
} catch (_) {}
|
||
})
|
||
es.addEventListener('config', (e) => {
|
||
try { const c = JSON.parse(e.data)
|
||
if (c.total_rounds) totalRounds = c.total_rounds
|
||
if (c.round_seconds) roundSeconds = c.round_seconds
|
||
if (c.round_ends_at) { serverRoundEndsAt = c.round_ends_at; roundEndsAt = c.round_ends_at }
|
||
updateRound()
|
||
} catch (_) {}
|
||
})
|
||
// ★ (Driver) ROUND_COMPLETE = the server confirmed EVERYONE submitted → show the "bundling + generating" screen with
|
||
// a spinner (distinct from the "waiting on others" state after only YOU submit). The next round's questions arriving
|
||
// will replace it. This is the server-authoritative signal (QF-24) surfaced to the UI.
|
||
es.addEventListener('round_complete', (e) => {
|
||
const q = el('qList'); if (!q) return
|
||
const isFinal = totalRounds && roundNum >= totalRounds
|
||
const line = isFinal
|
||
? 'All answers are in — bundling your session and building the summary…'
|
||
: 'All answers are in — bundling them and sending to the agent. The next round of questions is generating…'
|
||
q.innerHTML = '<div class="waiting-dim"><div class="spinner" aria-hidden="true"></div>' +
|
||
'<div class="agent-thinking">' + line + '</div></div>'
|
||
})
|
||
es.addEventListener('closed', () => { el('roundTag').textContent = 'Session closed'; es.close() })
|
||
}
|
||
|
||
// ── the ROUND timer (the alarm clock) — a 5:00 countdown PER ROUND, shown as MM:SS in the square clock.
|
||
// (The session total is shown small underneath.) The round timer resets each round.
|
||
let roundSeconds = 300 // 5:00 default per round
|
||
let roundEndsAt = 0 // epoch seconds when this round's timer ends
|
||
let serverRoundEndsAt = 0 // ★ the SERVER's shared round-end (from /info or a config event) — authoritative
|
||
function startRoundTimer() {
|
||
// prefer the SERVER's shared end-time (all guests share ONE clock); fall back to local only if unknown.
|
||
roundEndsAt = serverRoundEndsAt || (Math.floor(Date.now() / 1000) + roundSeconds)
|
||
tickRound()
|
||
}
|
||
function tickRound() {
|
||
const left = Math.max(0, roundEndsAt - Math.floor(Date.now() / 1000))
|
||
const m = Math.floor(left / 60), s = left % 60
|
||
el('timer').textContent = m + ':' + String(s).padStart(2, '0') // the alarm-clock digits = the round countdown
|
||
// the tiny total-line = the whole session time remaining (labelled)
|
||
if (expiresAt) {
|
||
const sess = Math.max(0, expiresAt - Math.floor(Date.now() / 1000))
|
||
const sh = Math.floor(sess / 3600), sm = Math.floor((sess % 3600) / 60)
|
||
const t = el('totalLine'); if (t) t.textContent = 'session: ' + (sh ? sh + 'h' : '') + sm + 'm left'
|
||
}
|
||
if (left > 0) setTimeout(tickRound, 1000)
|
||
}
|
||
function startTimer() { /* session tracking handled inside tickRound via expiresAt */ startRoundTimer() }
|
||
|
||
// ── Talk / Type mode (Type is default). Type = per-question boxes. Talk = mic + streaming transcription (STT
|
||
// is the owed piece — Moonshine Web on-device; for now Talk shows the mic UI + a note). ──
|
||
let mode = 'type'
|
||
function setMode(m) {
|
||
mode = m
|
||
document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === m))
|
||
// re-render the answer area for the current questions in the chosen mode
|
||
if (currentQuestions.length) renderAnswerArea()
|
||
}
|
||
// ★ DRAFT PERSISTENCE (QF-1, the P0 the moderator hit repeatedly: "I was typing and a user left and my answers
|
||
// disappeared"). Any re-render (roster change, rebroadcast, reload) rebuilds the textareas from scratch, wiping
|
||
// in-progress typing. FIX: persist every keystroke to localStorage keyed by (session, round, slot), restore it on
|
||
// render, and clear it only after a successful submit. Typing now survives ANY re-render or reload.
|
||
function draftKey(slot) { return `qf-draft:${sessionId || 's'}:${roundNum}:${slot}` }
|
||
function saveDraft(slot, val) { try { localStorage.setItem(draftKey(slot), val) } catch (_) {} }
|
||
function loadDraft(slot) { try { return localStorage.getItem(draftKey(slot)) || '' } catch (_) { return '' } }
|
||
function wireDraft(ta, slot) {
|
||
ta.value = loadDraft(slot) // restore whatever was typed before this render
|
||
ta.addEventListener('input', () => saveDraft(slot, ta.value))
|
||
}
|
||
function clearDraftsForRound() {
|
||
try {
|
||
const pre = `qf-draft:${sessionId || 's'}:${roundNum}:`
|
||
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith(pre)) localStorage.removeItem(k) }
|
||
} catch (_) {}
|
||
}
|
||
function renderAnswerArea(container) {
|
||
const aList = container || el('qList')
|
||
if (mode === 'type' && settings.typeOneBox) {
|
||
// one big box — paste everything; the agent sorts it into the questions (talk-mode-style, but typed).
|
||
const ab = document.createElement('div'); ab.className = 'a-block'
|
||
const lbl = document.createElement('label'); lbl.textContent = 'All answers (the agent will sort them)'
|
||
const ta = document.createElement('textarea'); ta.id = 'oneBox'; ta.style.minHeight = '160px'; ta.placeholder = 'Type or paste all your answers here…'
|
||
wireDraft(ta, 'oneBox')
|
||
ab.appendChild(lbl); ab.appendChild(ta); aList.appendChild(ab)
|
||
} else if (mode === 'type') {
|
||
currentQuestions.forEach((q, i) => {
|
||
const ab = document.createElement('div'); ab.className = 'a-block'
|
||
const lbl = document.createElement('label'); lbl.textContent = 'Q' + (i + 1)
|
||
const ta = document.createElement('textarea'); ta.dataset.q = i; ta.placeholder = 'Your answer…'
|
||
wireDraft(ta, 'q' + i)
|
||
ab.appendChild(lbl); ab.appendChild(ta); aList.appendChild(ab)
|
||
})
|
||
} else {
|
||
// Talk mode: a mic + a live transcript. The whole transcript is sent to the agent, which sorts it into questions.
|
||
const panel = document.createElement('div'); panel.className = 'talk-panel'
|
||
const mic = document.createElement('button'); mic.className = 'talk-mic'; mic.id = 'talkMic'; mic.innerHTML = ICON.mic
|
||
const trans = document.createElement('div'); trans.className = 'talk-transcript'; trans.id = 'talkTranscript'
|
||
trans.textContent = ''
|
||
const note = document.createElement('div'); note.className = 'talk-note'
|
||
note.textContent = 'Speak your answers — on submit, the whole transcript goes to the agent, which sorts it into the questions. (On-device streaming STT — Moonshine — is the next build; the tunnel + sort path is ready.)'
|
||
mic.addEventListener('click', () => mic.classList.toggle('live')) // placeholder: real streaming STT = Moonshine Web (owed)
|
||
panel.appendChild(mic); panel.appendChild(trans); panel.appendChild(note)
|
||
aList.appendChild(panel)
|
||
}
|
||
}
|
||
document.querySelectorAll('.mode-tab').forEach(t => t.addEventListener('click', () => setMode(t.dataset.mode)))
|
||
|
||
// ── theme (light/dark) — persisted; ★ DEFAULT LIGHT (Driver). Dark still available in settings. [data-theme] on <html>. ──
|
||
function applyTheme(t) { document.documentElement.dataset.theme = t; try { localStorage.setItem('qf-theme', t) } catch (_) {} }
|
||
function currentTheme() { try { return localStorage.getItem('qf-theme') || 'light' } catch (_) { return 'light' } }
|
||
applyTheme(currentTheme())
|
||
|
||
// ── settings (gear button, top-right) — mode-specific options from the spec ──
|
||
const settings = { talkAutoRead: true, typeOneBox: false }
|
||
function openSettings() {
|
||
const wrap = document.createElement('div'); wrap.className = 'set-overlay'
|
||
const toggle = (on) => '<div class="set-toggle' + (on ? ' on' : '') + '"></div>'
|
||
const isLight = currentTheme() === 'light'
|
||
wrap.innerHTML =
|
||
'<div class="set-sheet">' +
|
||
'<div class="set-head"><span>Settings</span><button class="set-x">×</button></div>' +
|
||
'<div class="set-body">' +
|
||
'<div class="set-group">Appearance</div>' +
|
||
'<div class="set-row" data-k="theme"><div class="lbl"><b>Light mode</b>' +
|
||
'<span>Switch between light and dark. Off = dark.</span></div>' + toggle(isLight) + '</div>' +
|
||
'<div class="set-group">Answering</div>' +
|
||
'<div class="set-row" data-k="talkAutoRead"><div class="lbl"><b>Talk mode: read questions aloud</b>' +
|
||
'<span>Automatically speak each new round. Off = tap the speaker to hear them.</span></div>' + toggle(settings.talkAutoRead) + '</div>' +
|
||
'<div class="set-row" data-k="typeOneBox"><div class="lbl"><b>Type mode: single answer box</b>' +
|
||
'<span>One large box instead of a box per question — the agent sorts your answers.</span></div>' + toggle(settings.typeOneBox) + '</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
document.body.appendChild(wrap)
|
||
wrap.querySelector('.set-x').addEventListener('click', () => wrap.remove())
|
||
wrap.addEventListener('click', (e) => { if (e.target === wrap) wrap.remove() })
|
||
wrap.querySelectorAll('.set-row').forEach(row => {
|
||
row.addEventListener('click', (e) => {
|
||
if (e.target.closest('.set-x')) return
|
||
const k = row.dataset.k
|
||
if (k === 'theme') {
|
||
const next = currentTheme() === 'light' ? 'dark' : 'light'; applyTheme(next)
|
||
row.querySelector('.set-toggle').classList.toggle('on', next === 'light')
|
||
return
|
||
}
|
||
settings[k] = !settings[k]
|
||
row.querySelector('.set-toggle').classList.toggle('on', settings[k])
|
||
if (k === 'typeOneBox' && currentQuestions.length) renderAnswerArea()
|
||
})
|
||
})
|
||
}
|
||
const gear = el('gearBtn'); if (gear) gear.addEventListener('click', openSettings)
|
||
|
||
el('joinBtn').addEventListener('click', join)
|
||
el('submitAns').addEventListener('click', submitAnswers)
|
||
|
||
// ── the tabbed side panel: Chat | Pending Decisions ──
|
||
function switchSide(which) {
|
||
document.querySelectorAll('.side-tab').forEach(t => t.classList.toggle('active', t.dataset.side === which))
|
||
el('chatBody').style.display = which === 'chat' ? 'flex' : 'none'
|
||
el('decisionsBody').style.display = which === 'decisions' ? 'flex' : 'none'
|
||
}
|
||
document.querySelectorAll('.side-tab').forEach(t => t.addEventListener('click', () => switchSide(t.dataset.side)))
|
||
|
||
// ── DECISIONS: the reconcile-conflicting-answers surface ──
|
||
// A "decision" is a QUESTION where the group's answers diverged. Each card shows the question + a conflict/agreed
|
||
// status. Tapping it opens the divergent answers (who said what) so the group can come to an agreement. The agent
|
||
// re-emits a decision with status 'agreed' once the group converges; the card flips green (it never disappears —
|
||
// the resolved decisions are the record of what you agreed).
|
||
const decisions = new Map() // question -> {question, status, positions:[{name,answer}], suggestion}
|
||
// ★ DECISIONS PERSISTENCE (Driver bug: "decisions show up then DISAPPEAR"). The Map was in-memory only, so a RELOAD
|
||
// (which the moderator does constantly) wiped every decision — the agent only broadcasts a decision ONCE, so on reload
|
||
// they were gone forever. FIX: persist the decisions Map to localStorage per session; restore on load. Decisions now
|
||
// survive reloads + reconnects, and stay until resolved (they never auto-disappear).
|
||
function decisionsKey() { return `qf-decisions:${sessionId || 's'}` }
|
||
function saveDecisions() {
|
||
try { localStorage.setItem(decisionsKey(), JSON.stringify([...decisions.values()])) } catch (_) {}
|
||
}
|
||
function loadDecisions() {
|
||
try {
|
||
const raw = localStorage.getItem(decisionsKey()); if (!raw) return
|
||
JSON.parse(raw).forEach(d => { if (d && d.question) decisions.set(d.question, d) })
|
||
renderDecisions(); refreshDecisionsTab()
|
||
} catch (_) {}
|
||
}
|
||
function refreshDecisionsTab() {
|
||
const tab = el('tabDecisions'); if (!tab) return
|
||
const unresolved = [...decisions.values()].filter(x => x.status === 'conflict').length
|
||
tab.textContent = unresolved ? `Decisions (${unresolved})` : 'Decisions'
|
||
}
|
||
function upsertDecision(d) {
|
||
const key = d.question
|
||
const prev = decisions.get(key) || {}
|
||
decisions.set(key, {
|
||
question: d.question,
|
||
status: d.status || prev.status || 'conflict', // 'conflict' | 'agreed'
|
||
positions: d.positions || prev.positions || [], // [{name, answer}]
|
||
suggestion: d.suggestion || prev.suggestion || '', // the agent's proposed common ground
|
||
round: d.round || prev.round || roundNum + 1,
|
||
})
|
||
saveDecisions() // ★ persist so a reload doesn't lose it
|
||
renderDecisions()
|
||
refreshDecisionsTab() // badge the tab with the unresolved count
|
||
}
|
||
function renderDecisions() {
|
||
const list = el('decisionsList'); if (!list) return
|
||
const rows = [...decisions.values()].sort((a, b) => (a.status === b.status ? 0 : a.status === 'conflict' ? -1 : 1))
|
||
if (!rows.length) {
|
||
list.innerHTML = '<div class="waiting">No decisions yet. When your answers to the same question disagree, it shows up here as a decision to resolve.</div>'
|
||
return
|
||
}
|
||
list.innerHTML = ''
|
||
rows.forEach((d) => {
|
||
const row = document.createElement('div'); row.className = 'decision-row'
|
||
const q = document.createElement('span'); q.textContent = d.question
|
||
// ★ WORD-WRAP FIX (Driver: "it's word-wrapping every letter, each entry is 20 chars tall"). In a flex row a child
|
||
// needs min-width:0 to be allowed to shrink+ellipsize; without it (and with a non-shrinking badge) the browser
|
||
// wraps char-by-char. Give the question flex:1 + min-width:0 + single-line ellipsis; the badge never shrinks.
|
||
q.style.cssText = 'flex:1 1 auto;min-width:0;margin-right:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||
const st = document.createElement('span')
|
||
st.className = 'decision-status ' + (d.status === 'agreed' ? 'solved' : 'open')
|
||
st.style.cssText = 'flex:0 0 auto;white-space:nowrap;'
|
||
st.textContent = d.status === 'agreed' ? 'agreed' : 'needs agreement'
|
||
row.appendChild(q); row.appendChild(st)
|
||
row.addEventListener('click', () => openDecision(d))
|
||
list.appendChild(row)
|
||
})
|
||
}
|
||
function openDecision(d) {
|
||
const ov = document.createElement('div'); ov.className = 'modal-overlay'
|
||
const card = document.createElement('div'); card.className = 'modal-card'
|
||
const h = document.createElement('div'); h.style.cssText = 'font-weight:600;margin-bottom:8px;'; h.textContent = d.question
|
||
card.appendChild(h)
|
||
const sub = document.createElement('div'); sub.style.cssText = 'font-size:.72rem;opacity:.7;margin-bottom:10px;'
|
||
sub.textContent = d.status === 'agreed' ? 'Resolved — the group agreed.' : 'The group gave different answers. Talk it out in Chat, then converge.'
|
||
card.appendChild(sub)
|
||
;(d.positions || []).forEach((p) => {
|
||
const pos = document.createElement('div'); pos.style.cssText = 'margin:6px 0;padding:6px 8px;background:var(--surface);border-radius:4px;font-size:.82rem;'
|
||
const nm = document.createElement('div'); nm.style.cssText = 'font-weight:600;font-size:.72rem;opacity:.8;'; nm.textContent = p.name
|
||
const an = document.createElement('div'); an.textContent = p.answer
|
||
pos.appendChild(nm); pos.appendChild(an); card.appendChild(pos)
|
||
})
|
||
if (d.suggestion) {
|
||
const sg = document.createElement('div'); sg.style.cssText = 'margin-top:10px;padding:8px;border-left:3px solid var(--accent);font-size:.82rem;'
|
||
sg.innerHTML = '<b>Suggested common ground:</b> ' + d.suggestion.replace(/</g, '<')
|
||
card.appendChild(sg)
|
||
}
|
||
const close = document.createElement('button'); close.textContent = 'Close'
|
||
close.style.cssText = 'margin-top:12px;width:100%;padding:8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--fg);cursor:pointer;'
|
||
close.addEventListener('click', () => ov.remove())
|
||
card.appendChild(close)
|
||
ov.appendChild(card); ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove() })
|
||
document.body.appendChild(ov)
|
||
}
|
||
|
||
// ★ ACTIVITY BANNER (Driver QF-23): a single-row toast at the bottom-right of the questions section that fades after
|
||
// a few seconds. Multiple stack into a feed — newest at the BOTTOM, older ones pushed up — like a popup stack.
|
||
function flashActivity(text) {
|
||
let stack = document.getElementById('activityStack')
|
||
if (!stack) {
|
||
stack = document.createElement('div'); stack.id = 'activityStack'
|
||
stack.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;flex-direction:column;gap:5px;pointer-events:none;'
|
||
const host = document.getElementById('questions') || document.getElementById('session') || document.body
|
||
if (getComputedStyle(host).position === 'static') host.style.position = 'relative'
|
||
host.appendChild(stack)
|
||
}
|
||
const b = document.createElement('div')
|
||
b.textContent = text
|
||
b.style.cssText = 'background:var(--header);color:var(--fg);border:1px solid var(--line);border-radius:14px;padding:4px 12px;font-size:.74rem;box-shadow:0 2px 8px rgba(0,0,0,.18);opacity:0;transform:translateY(6px);transition:opacity .2s,transform .2s;white-space:nowrap;'
|
||
stack.appendChild(b) // newest at the bottom (flex-column: appended = lowest)
|
||
requestAnimationFrame(() => { b.style.opacity = '1'; b.style.transform = 'translateY(0)' })
|
||
setTimeout(() => { b.style.opacity = '0'; b.style.transform = 'translateY(-6px)'; setTimeout(() => b.remove(), 250) }, 3200)
|
||
}
|
||
|
||
// ── in-session CHAT (so users — and the builder-agent — can talk live) ──
|
||
function addChatMsg(name, text, mine) {
|
||
const log = el('chatLog'); if (!log) return
|
||
const row = document.createElement('div'); row.className = 'chat-msg' + (mine ? ' mine' : '')
|
||
const nm = document.createElement('span'); nm.className = 'chat-name'; nm.textContent = name + ':'
|
||
const tx = document.createElement('span'); tx.textContent = text
|
||
row.appendChild(nm); row.appendChild(tx); log.appendChild(row)
|
||
log.scrollTop = log.scrollHeight
|
||
}
|
||
async function sendChat() {
|
||
const inp = el('chatInput'); const text = (inp.value || '').trim()
|
||
if (!text || !sessionId || !token) return
|
||
inp.value = ''
|
||
try {
|
||
await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/chat?token=${encodeURIComponent(token)}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text })
|
||
})
|
||
} catch (_) { toast('Chat failed') }
|
||
}
|
||
;(function wireChat() {
|
||
const send = el('chatSend'); const inp = el('chatInput')
|
||
if (send) send.addEventListener('click', sendChat)
|
||
if (inp) {
|
||
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendChat() } })
|
||
// ★ Fix (Driver): when the chat box is TAPPED/focused (keyboard opens), scroll the chat log to the bottom so the
|
||
// last messages aren't hidden behind the input/keyboard, and add a class so the app can shift the panels up.
|
||
inp.addEventListener('focus', () => {
|
||
document.getElementById('app')?.classList.add('kb-open')
|
||
const log = el('chatLog'); if (log) setTimeout(() => { log.scrollTop = log.scrollHeight }, 250)
|
||
})
|
||
inp.addEventListener('blur', () => { document.getElementById('app')?.classList.remove('kb-open') })
|
||
}
|
||
})()
|
||
|
||
const qs = new URLSearchParams(location.search)
|
||
if (qs.get('c')) el('code').value = qs.get('c')
|
||
// auto-generate a random username (editable) + preview its identicon — no login needed.
|
||
;(function initName() {
|
||
const n = el('name'); if (!n.value) n.value = randomName()
|
||
const prev = el('namePreview')
|
||
const paint = () => { if (prev) prev.innerHTML = identiconSVG(n.value || 'guest') }
|
||
n.addEventListener('input', paint); paint()
|
||
})()
|