| 1 | import { createEditor, playPattern } from './strudel.js'; |
| 2 | import * as Sessions from './sessions.js'; |
| 3 | import { authHeaders, initApiKeyUI } from './apikey.js'; |
| 4 | import { getModel, initModelUI } from './models.js'; |
| 5 | import { getSystemPrompt, initPromptUI } from './prompt.js'; |
| 6 | import * as Themes from './themes.js'; |
| 7 | import { bindPanel } from './panel.js'; |
| 8 | import { createBroadcaster } from './broadcast.js'; |
| 9 | |
| 10 | const STARTER = `// Press ▶ Play, or ask Claude on the right to make something. |
| 11 | // While it plays, watch the notes flash in the code. |
| 12 | stack( |
| 13 | s("bd*4").gain(0.9), |
| 14 | s("~ cp").room(0.3), |
| 15 | s("hh*8").gain(0.4), |
| 16 | note("<c2 c2 g1 a1>").s("sawtooth").lpf(600).gain(0.7) |
| 17 | ).slow(2)`; |
| 18 | |
| 19 | // ---------- Session store ---------- |
| 20 | Sessions.initStore(STARTER); |
| 21 | const active = Sessions.getActive(); |
| 22 | // The session this tab has loaded. Saves target this id, not whatever session |
| 23 | // is active in localStorage — another tab may have moved that. |
| 24 | let loadedId = active.id; |
| 25 | |
| 26 | // The API requires messages to alternate user/assistant, so a turn whose reply |
| 27 | // never arrived (network error, server restart) must not linger in the history. |
| 28 | const dropDanglingTurn = (h) => |
| 29 | h.length && h[h.length - 1].role === 'user' ? h.slice(0, -1) : h; |
| 30 | |
| 31 | // Conversation history for the active session (sent to the backend each turn). |
| 32 | let history_ = dropDanglingTurn(Array.isArray(active.history) ? [...active.history] : []); |
| 33 | |
| 34 | // ---------- Status line ---------- |
| 35 | const statusEl = document.getElementById('ide-status'); |
| 36 | // `action` appends a trailing link — used to offer the one-shot way back out of |
| 37 | // whatever the message just announced (undo a revert, leave a past state). |
| 38 | const setStatus = (msg, kind = '', action = null) => { |
| 39 | statusEl.className = kind; // 'ok' | 'err' | '' |
| 40 | statusEl.replaceChildren(document.createTextNode(msg)); |
| 41 | if (action) { |
| 42 | const btn = document.createElement('button'); |
| 43 | btn.type = 'button'; |
| 44 | btn.className = 'undo-link'; |
| 45 | btn.textContent = action.label; |
| 46 | btn.addEventListener('click', action.onClick); |
| 47 | statusEl.append(' · ', btn); |
| 48 | } |
| 49 | }; |
| 50 | |
| 51 | // ---------- Strudel editor (left pane, with flash highlighting) ---------- |
| 52 | const editor = createEditor(document.getElementById('editor'), active.code || STARTER, { |
| 53 | onEval: () => setStatus('playing…', 'ok'), |
| 54 | onError: (err) => setStatus(`error: ${err.message || err}`, 'err'), |
| 55 | }); |
| 56 | |
| 57 | // Mirrors the code, its event timings and the transcript to /ws, where the |
| 58 | // terminal client (curl <host>/tui.py) picks them up. A prompt typed there |
| 59 | // comes back through `onPrompt` and goes into sendMessage — the same path the |
| 60 | // composer below uses, so a turn started from the terminal reverts, saves and |
| 61 | // reads back exactly like one started in the page. |
| 62 | const broadcast = createBroadcaster(editor, { |
| 63 | getChat: () => history_, |
| 64 | onPrompt: (text) => { |
| 65 | // Dropping beats interleaving — the API rejects two user messages in a |
| 66 | // row — but say so, or the terminal is left waiting on a reply that is |
| 67 | // never coming. |
| 68 | if (chatBusy) return broadcast.notice('still finishing the previous prompt'); |
| 69 | sendMessage(text); |
| 70 | }, |
| 71 | }); |
| 72 | |
| 73 | const saveActive = () => |
| 74 | Sessions.saveSession(loadedId, { code: editor.code, history: history_ }); |
| 75 | |
| 76 | // ---------- Transport (one button that toggles) ---------- |
| 77 | const transportBtn = document.getElementById('playstop'); |
| 78 | const isPlaying = () => Boolean(editor.repl?.scheduler?.started); |
| 79 | |
| 80 | function syncTransport() { |
| 81 | const playing = isPlaying(); |
| 82 | transportBtn.classList.toggle('playing', playing); |
| 83 | // Drives the ambient haze in style.css — the room only lights up when a |
| 84 | // pattern is actually running. |
| 85 | document.body.classList.toggle('live', playing); |
| 86 | transportBtn.textContent = playing ? '■ Stop' : '▶ Play'; |
| 87 | transportBtn.title = playing ? 'Stop (Ctrl+.)' : 'Play (Ctrl+Enter)'; |
| 88 | } |
| 89 | |
| 90 | transportBtn.addEventListener('click', async () => { |
| 91 | if (isPlaying()) { |
| 92 | editor.stop(); |
| 93 | setStatus('stopped'); |
| 94 | } else { |
| 95 | await playPattern(editor); |
| 96 | saveActive(); |
| 97 | } |
| 98 | syncTransport(); |
| 99 | }); |
| 100 | |
| 101 | // The editor's own Ctrl+Enter / Ctrl+. shortcuts start and stop the scheduler |
| 102 | // behind our back, so keep the button honest by watching its state. |
| 103 | setInterval(syncTransport, 250); |
| 104 | syncTransport(); |
| 105 | |
| 106 | setInterval(saveActive, 4000); |
| 107 | window.addEventListener('beforeunload', saveActive); |
| 108 | // Persist manual edits as soon as focus leaves the editor. |
| 109 | editor.editor.contentDOM.addEventListener('blur', saveActive); |
| 110 | |
| 111 | // ---------- Chat (right pane) ---------- |
| 112 | const messagesEl = document.getElementById('messages'); |
| 113 | const form = document.getElementById('chat-form'); |
| 114 | const input = document.getElementById('chat-input'); |
| 115 | const sessionListEl = document.getElementById('session-list'); |
| 116 | const songTitleEl = document.getElementById('song-title'); |
| 117 | |
| 118 | // `index` is the message's position in history_; assistant bubbles that know it |
| 119 | // get a revert button that undoes their turn (see revertTo). |
| 120 | function addBubble(role, text, index = null) { |
| 121 | const el = document.createElement('div'); |
| 122 | el.className = `bubble ${role}`; |
| 123 | const body = document.createElement('span'); |
| 124 | body.textContent = text; |
| 125 | el.appendChild(body); |
| 126 | |
| 127 | // A turn starts at the user message this reply answers — that's where the |
| 128 | // "before" snapshot lives, so revert from the reply rewinds to there. |
| 129 | const turnStart = role === 'assistant' && index !== null ? userTurnStart(index) : -1; |
| 130 | if (turnStart >= 0) { |
| 131 | const btn = document.createElement('button'); |
| 132 | btn.type = 'button'; |
| 133 | btn.className = 'revert'; |
| 134 | btn.textContent = '↩'; |
| 135 | btn.title = 'Undo this reply — restore the pattern and chat from before it'; |
| 136 | btn.addEventListener('click', (e) => { |
| 137 | e.stopPropagation(); // the bubble itself time-travels; the button rewrites |
| 138 | revertTo(turnStart); |
| 139 | }); |
| 140 | el.appendChild(btn); |
| 141 | } |
| 142 | |
| 143 | // Clicking the bubble jumps the editor to the pattern from that moment. |
| 144 | if (index !== null && codeAtMessage(index) !== null) { |
| 145 | el.classList.add('travelable'); |
| 146 | el.tabIndex = 0; |
| 147 | el.setAttribute('role', 'button'); |
| 148 | el.title = 'Jump the editor to the pattern from this point'; |
| 149 | if (travel?.index === index) el.classList.add('travelled'); |
| 150 | el.addEventListener('click', () => { |
| 151 | // Don't hijack a click that was someone selecting text to copy. |
| 152 | if (window.getSelection()?.toString()) return; |
| 153 | timeTravelTo(index); |
| 154 | }); |
| 155 | el.addEventListener('keydown', (e) => { |
| 156 | if (e.key === 'Enter' || e.key === ' ') { |
| 157 | e.preventDefault(); |
| 158 | timeTravelTo(index); |
| 159 | } |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | messagesEl.appendChild(el); |
| 164 | messagesEl.scrollTop = messagesEl.scrollHeight; |
| 165 | return el; |
| 166 | } |
| 167 | |
| 168 | // Rebuild the whole transcript from history_ (indices shift after a revert). |
| 169 | function renderMessages() { |
| 170 | messagesEl.replaceChildren(); |
| 171 | history_.forEach((m, i) => addBubble(m.role, m.content, i)); |
| 172 | } |
| 173 | |
| 174 | function addTypingBubble() { |
| 175 | const el = document.createElement('div'); |
| 176 | el.className = 'bubble assistant typing'; |
| 177 | el.innerHTML = |
| 178 | '<span class="dot"></span><span class="dot"></span><span class="dot"></span>'; |
| 179 | messagesEl.appendChild(el); |
| 180 | messagesEl.scrollTop = messagesEl.scrollHeight; |
| 181 | return el; |
| 182 | } |
| 183 | |
| 184 | function resolveBubble(el, text, isError = false) { |
| 185 | el.classList.remove('typing'); |
| 186 | el.textContent = text; |
| 187 | if (isError) el.classList.add('err'); |
| 188 | messagesEl.scrollTop = messagesEl.scrollHeight; |
| 189 | } |
| 190 | |
| 191 | // The track's name doubles as the IDE header — the pane holds one song, so its |
| 192 | // title is the song's. Auto-naming moves it too, so it re-renders alongside the |
| 193 | // sidebar list rather than only when the name is typed. |
| 194 | function renderSongTitle() { |
| 195 | const name = Sessions.get(loadedId)?.name ?? 'Untitled'; |
| 196 | songTitleEl.textContent = name; |
| 197 | songTitleEl.title = `${name} — click to rename`; |
| 198 | } |
| 199 | |
| 200 | // Rebuild the sidebar list from the store. |
| 201 | function renderSessionList() { |
| 202 | renderSongTitle(); |
| 203 | sessionListEl.replaceChildren(); |
| 204 | for (const s of Sessions.list()) { |
| 205 | const row = document.createElement('div'); |
| 206 | row.className = `session-row${s.active ? ' active' : ''}`; |
| 207 | |
| 208 | const open = document.createElement('button'); |
| 209 | open.type = 'button'; |
| 210 | open.className = 'session-open'; |
| 211 | open.textContent = s.name; |
| 212 | open.title = s.name; |
| 213 | open.addEventListener('click', () => switchToSession(s.id)); |
| 214 | open.addEventListener('dblclick', () => startRename(row, s)); |
| 215 | row.appendChild(open); |
| 216 | |
| 217 | const actions = document.createElement('div'); |
| 218 | actions.className = 'session-actions'; |
| 219 | actions.append( |
| 220 | rowAction('✎', 'Rename', () => startRename(row, s)), |
| 221 | rowAction('🗑', 'Delete', () => deleteSession(s.id), 'danger'), |
| 222 | ); |
| 223 | row.appendChild(actions); |
| 224 | |
| 225 | sessionListEl.appendChild(row); |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | function rowAction(glyph, label, onClick, extra = '') { |
| 230 | const btn = document.createElement('button'); |
| 231 | btn.type = 'button'; |
| 232 | btn.className = `icon-btn ${extra}`.trim(); |
| 233 | btn.textContent = glyph; |
| 234 | btn.title = label; |
| 235 | btn.setAttribute('aria-label', label); |
| 236 | btn.addEventListener('click', (e) => { |
| 237 | e.stopPropagation(); |
| 238 | onClick(); |
| 239 | }); |
| 240 | return btn; |
| 241 | } |
| 242 | |
| 243 | // Rename in place: the row's label becomes a text field. |
| 244 | function startRename(row, session) { |
| 245 | const field = document.createElement('input'); |
| 246 | field.className = 'session-rename'; |
| 247 | field.value = session.name; |
| 248 | field.setAttribute('aria-label', 'Track name'); |
| 249 | |
| 250 | const commit = (save) => { |
| 251 | if (!field.isConnected) return; |
| 252 | if (save) Sessions.rename(session.id, field.value); |
| 253 | renderSessionList(); |
| 254 | }; |
| 255 | field.addEventListener('blur', () => commit(true)); |
| 256 | field.addEventListener('keydown', (e) => { |
| 257 | e.stopPropagation(); |
| 258 | if (e.key === 'Enter') commit(true); |
| 259 | else if (e.key === 'Escape') commit(false); |
| 260 | }); |
| 261 | |
| 262 | row.replaceChildren(field); |
| 263 | field.focus(); |
| 264 | field.select(); |
| 265 | } |
| 266 | |
| 267 | // Same rename, from the IDE header: the title swaps itself for a field in |
| 268 | // place. Clearing it hands the name back to auto-naming (see Sessions.rename). |
| 269 | songTitleEl.addEventListener('click', () => { |
| 270 | const session = Sessions.get(loadedId); |
| 271 | if (!session) return; |
| 272 | |
| 273 | const field = document.createElement('input'); |
| 274 | field.className = 'song-title-edit'; |
| 275 | field.value = session.name; |
| 276 | field.setAttribute('aria-label', 'Track name'); |
| 277 | |
| 278 | // `refocus` only on a keyboard finish — on blur the user has already clicked |
| 279 | // somewhere else, and pulling focus back would fight them. |
| 280 | const commit = (save, refocus = false) => { |
| 281 | if (!field.isConnected) return; |
| 282 | if (save) Sessions.rename(session.id, field.value); |
| 283 | field.replaceWith(songTitleEl); |
| 284 | renderSessionList(); |
| 285 | if (refocus) songTitleEl.focus(); |
| 286 | }; |
| 287 | field.addEventListener('blur', () => commit(true)); |
| 288 | field.addEventListener('keydown', (e) => { |
| 289 | // The editor and the panels both listen on document for keys; this field |
| 290 | // owns Enter and Escape while it's open. |
| 291 | e.stopPropagation(); |
| 292 | if (e.key === 'Enter') commit(true, true); |
| 293 | else if (e.key === 'Escape') commit(false, true); |
| 294 | }); |
| 295 | |
| 296 | songTitleEl.replaceWith(field); |
| 297 | field.focus(); |
| 298 | field.select(); |
| 299 | }); |
| 300 | |
| 301 | function switchToSession(id) { |
| 302 | // Compare against what this tab has loaded, not the store's activeId — if |
| 303 | // another tab moved that, clicking the row you're looking at must still work. |
| 304 | if (id === loadedId) return; |
| 305 | saveActive(); // persist the one we're leaving |
| 306 | Sessions.setActive(id); |
| 307 | loadSessionIntoUI(Sessions.getActive()); |
| 308 | } |
| 309 | |
| 310 | function deleteSession(id) { |
| 311 | const wasActive = id === loadedId; |
| 312 | const nextActive = Sessions.remove(id, STARTER); |
| 313 | if (wasActive) loadSessionIntoUI(nextActive); |
| 314 | else renderSessionList(); |
| 315 | setStatus('track deleted'); |
| 316 | } |
| 317 | |
| 318 | // Render a session's chat + code into the UI (does not itself change the store). |
| 319 | // Switching while the music runs hands the transport over to the new session's |
| 320 | // pattern rather than dropping to silence. |
| 321 | async function loadSessionIntoUI(session) { |
| 322 | const wasPlaying = isPlaying(); |
| 323 | editor.stop(); |
| 324 | loadedId = session.id; // saves from here on belong to this session |
| 325 | history_ = dropDanglingTurn(Array.isArray(session.history) ? [...session.history] : []); |
| 326 | undoState = null; |
| 327 | travel = null; |
| 328 | renderMessages(); |
| 329 | editor.setCode(session.code || STARTER); |
| 330 | renderSessionList(); |
| 331 | setStatus(`switched to “${session.name}”`); |
| 332 | if (wasPlaying) await playPattern(editor); |
| 333 | syncTransport(); |
| 334 | } |
| 335 | |
| 336 | // ---------- Time travel ---------- |
| 337 | // Every turn carries the pattern it belongs to: a user message stores the code |
| 338 | // as it stood before it was sent, an assistant reply stores the code it |
| 339 | // produced. That makes each message a point on the song's timeline, and |
| 340 | // clicking one moves the editor back to it. |
| 341 | // |
| 342 | // Unlike revert this destroys nothing — the chat is left whole and you can step |
| 343 | // back out again. While parked in the past the editor genuinely holds that old |
| 344 | // pattern, so sending a new message from there continues from that version. |
| 345 | // `null` means we're at the newest state. |
| 346 | let travel = null; // { index, latestCode } |
| 347 | |
| 348 | // The pattern this message represents, or null if the session predates the |
| 349 | // snapshot it needs. |
| 350 | function codeAtMessage(index) { |
| 351 | const m = history_[index]; |
| 352 | if (!m) return null; |
| 353 | const code = m.role === 'assistant' ? m.code : m.codeBefore; |
| 354 | return typeof code === 'string' && code.trim() ? code : null; |
| 355 | } |
| 356 | |
| 357 | async function timeTravelTo(index) { |
| 358 | const code = codeAtMessage(index); |
| 359 | if (code === null) { |
| 360 | setStatus('no pattern saved at that message', 'err'); |
| 361 | return; |
| 362 | } |
| 363 | if (travel?.index === index) return; |
| 364 | |
| 365 | // The newest message is "now", not a past state — landing there ends the trip |
| 366 | // rather than parking on a marker that says you are somewhere you aren't. |
| 367 | // Otherwise remember where "now" is the first time we leave it, so returning |
| 368 | // is exact even after hopping between several past states. |
| 369 | const atEnd = index >= history_.length - 1; |
| 370 | travel = atEnd ? null : { index, latestCode: travel ? travel.latestCode : editor.code }; |
| 371 | |
| 372 | await applyCode(code); |
| 373 | renderMessages(); |
| 374 | saveActive(); |
| 375 | |
| 376 | setStatus( |
| 377 | atEnd ? 'back at the latest pattern' : `viewing the pattern from message ${index + 1}`, |
| 378 | '', |
| 379 | atEnd ? null : { label: 'Back to latest', onClick: returnToLatest }, |
| 380 | ); |
| 381 | } |
| 382 | |
| 383 | async function returnToLatest() { |
| 384 | if (!travel) return; |
| 385 | const { latestCode } = travel; |
| 386 | travel = null; |
| 387 | await applyCode(latestCode); |
| 388 | renderMessages(); |
| 389 | saveActive(); |
| 390 | setStatus('back at the latest pattern'); |
| 391 | } |
| 392 | |
| 393 | // ---------- Revert ---------- |
| 394 | // Snapshot taken before the last revert, so it can be undone once. |
| 395 | let undoState = null; |
| 396 | |
| 397 | // Index of the user message that opens the turn containing `index`, or -1. |
| 398 | function userTurnStart(index) { |
| 399 | for (let i = index; i >= 0; i--) if (history_[i].role === 'user') return i; |
| 400 | return -1; |
| 401 | } |
| 402 | |
| 403 | // Drop message `index` and everything after it, and restore the code as it was |
| 404 | // before that message was sent. |
| 405 | async function revertTo(index) { |
| 406 | const msg = history_[index]; |
| 407 | if (!msg) return; |
| 408 | |
| 409 | undoState = { history: [...history_], code: editor.code }; |
| 410 | const dropped = history_.length - index; |
| 411 | history_ = history_.slice(0, index); |
| 412 | renderMessages(); |
| 413 | |
| 414 | // Sessions saved before this feature have no snapshot — rewind the chat only. |
| 415 | if (typeof msg.codeBefore === 'string') await applyCode(msg.codeBefore); |
| 416 | |
| 417 | saveActive(); |
| 418 | renderSessionList(); |
| 419 | setStatus(`reverted ${dropped} message${dropped === 1 ? '' : 's'}`, '', { |
| 420 | label: 'Undo', |
| 421 | onClick: undoRevert, |
| 422 | }); |
| 423 | } |
| 424 | |
| 425 | function undoRevert() { |
| 426 | if (!undoState) return; |
| 427 | const { history, code } = undoState; |
| 428 | undoState = null; |
| 429 | travel = null; |
| 430 | history_ = [...history]; |
| 431 | renderMessages(); |
| 432 | applyCode(code); |
| 433 | saveActive(); |
| 434 | renderSessionList(); |
| 435 | setStatus('revert undone'); |
| 436 | } |
| 437 | |
| 438 | // Set the editor's code, re-evaluating only if a pattern is already playing. |
| 439 | async function applyCode(code) { |
| 440 | editor.setCode(code); |
| 441 | if (editor.repl?.scheduler?.started) await playPattern(editor); |
| 442 | } |
| 443 | |
| 444 | // Initial render of restored session. |
| 445 | renderMessages(); |
| 446 | renderSessionList(); |
| 447 | setStatus(history_.length ? 'restored your last track' : 'ready'); |
| 448 | |
| 449 | // New session. |
| 450 | document.getElementById('new-session').addEventListener('click', () => { |
| 451 | saveActive(); |
| 452 | const sess = Sessions.create(STARTER); |
| 453 | loadSessionIntoUI(sess); |
| 454 | setStatus('new track'); |
| 455 | input.focus(); |
| 456 | }); |
| 457 | |
| 458 | // ---------- Theme picker ---------- |
| 459 | { |
| 460 | const button = document.getElementById('theme-btn'); |
| 461 | const panel = document.getElementById('theme-panel'); |
| 462 | const siteSel = document.getElementById('site-theme'); |
| 463 | const codeSel = document.getElementById('code-theme'); |
| 464 | |
| 465 | const fill = (sel, entries) => { |
| 466 | sel.replaceChildren(); |
| 467 | for (const [value, label] of entries) { |
| 468 | const opt = document.createElement('option'); |
| 469 | opt.value = value; |
| 470 | opt.textContent = label; |
| 471 | sel.appendChild(opt); |
| 472 | } |
| 473 | }; |
| 474 | fill(siteSel, Themes.SITE_THEMES.map((t) => [t.id, t.label])); |
| 475 | fill(codeSel, Themes.CODE_THEMES.map((c) => [c, c])); |
| 476 | |
| 477 | // Reflect state into the controls — the site picker also changes the code |
| 478 | // theme, so both are re-read after every change. |
| 479 | const sync = () => { |
| 480 | const { site, code } = Themes.currentTheme(); |
| 481 | siteSel.value = site; |
| 482 | codeSel.value = code; |
| 483 | document.getElementById('theme-state').textContent = |
| 484 | Themes.SITE_THEMES.find((t) => t.id === site)?.label ?? 'Theme'; |
| 485 | }; |
| 486 | |
| 487 | bindPanel(button, panel); |
| 488 | |
| 489 | siteSel.addEventListener('change', () => { |
| 490 | Themes.setSiteTheme(editor, siteSel.value); |
| 491 | sync(); |
| 492 | setStatus(`lighting: ${siteSel.selectedOptions[0].textContent}`); |
| 493 | }); |
| 494 | codeSel.addEventListener('change', () => { |
| 495 | Themes.setCodeTheme(editor, codeSel.value); |
| 496 | sync(); |
| 497 | setStatus(`code colors: ${codeSel.value}`); |
| 498 | }); |
| 499 | |
| 500 | Themes.initThemes(editor); |
| 501 | sync(); |
| 502 | } |
| 503 | |
| 504 | // ---------- Model picker ---------- |
| 505 | initModelUI(); |
| 506 | |
| 507 | // ---------- System prompt (view / edit) ---------- |
| 508 | initPromptUI(); |
| 509 | |
| 510 | // ---------- API key (bring your own) ---------- |
| 511 | const apiKeyUI = initApiKeyUI({ |
| 512 | onChange: (key) => setStatus(key ? 'API key saved' : 'API key removed'), |
| 513 | }); |
| 514 | |
| 515 | // ---------- Sidebar collapse ---------- |
| 516 | const SIDEBAR_KEY = 'strudel-claude-sidebar'; |
| 517 | const hideBtn = document.getElementById('sidebar-hide'); |
| 518 | const showBtn = document.getElementById('sidebar-show'); |
| 519 | |
| 520 | function setSidebar(collapsed) { |
| 521 | document.getElementById('app').classList.toggle('sidebar-collapsed', collapsed); |
| 522 | showBtn.hidden = !collapsed; |
| 523 | try { |
| 524 | localStorage.setItem(SIDEBAR_KEY, collapsed ? 'collapsed' : 'open'); |
| 525 | } catch { |
| 526 | /* storage blocked — the layout still works, it just won't persist */ |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | hideBtn.addEventListener('click', () => setSidebar(true)); |
| 531 | showBtn.addEventListener('click', () => setSidebar(false)); |
| 532 | setSidebar(localStorage.getItem(SIDEBAR_KEY) === 'collapsed'); |
| 533 | |
| 534 | // True while a generate request is in flight. The page prevents a second turn |
| 535 | // by clearing the composer; the terminal has no such interlock, so it needs an |
| 536 | // explicit flag to check. |
| 537 | let chatBusy = false; |
| 538 | |
| 539 | async function sendMessage(text) { |
| 540 | // Nothing can be generated without a key — say so instead of round-tripping. |
| 541 | if (apiKeyUI.needsKey()) { |
| 542 | addBubble('assistant', '⚠ add your Anthropic API key (🔑, bottom left) to start generating.').classList.add('err'); |
| 543 | input.value = text; |
| 544 | return; |
| 545 | } |
| 546 | |
| 547 | // Snapshot the code as it stands *before* this turn so it can be reverted to. |
| 548 | history_.push({ role: 'user', content: text, codeBefore: editor.code }); |
| 549 | addBubble('user', text, history_.length - 1); |
| 550 | undoState = null; |
| 551 | travel = null; |
| 552 | chatBusy = true; |
| 553 | const thinking = addTypingBubble(); |
| 554 | |
| 555 | try { |
| 556 | const res = await fetch('/api/generate', { |
| 557 | method: 'POST', |
| 558 | headers: { |
| 559 | 'Content-Type': 'application/json', |
| 560 | // Skip ngrok's free-tier browser-warning page so the API returns JSON, |
| 561 | // not HTML, when the app is served through an ngrok tunnel. Harmless |
| 562 | // when running locally. |
| 563 | 'ngrok-skip-browser-warning': 'true', |
| 564 | // Your own key, if you set one (see apikey.js). |
| 565 | ...authHeaders(), |
| 566 | }, |
| 567 | body: JSON.stringify({ |
| 568 | messages: history_, |
| 569 | currentCode: editor.code, |
| 570 | // The backend only accepts ids from its own whitelist; empty means |
| 571 | // "your default", which is what we send before /api/config answers. |
| 572 | model: getModel(), |
| 573 | // Only set if the user edited the system prompt; otherwise the server |
| 574 | // uses its own PROMPT.md (see prompt.js). |
| 575 | ...(getSystemPrompt() ? { system: getSystemPrompt() } : {}), |
| 576 | }), |
| 577 | }); |
| 578 | if (!res.ok) throw new Error((await res.json()).error || res.statusText); |
| 579 | const { message, code } = await res.json(); |
| 580 | |
| 581 | resolveBubble(thinking, message || 'Here you go.'); |
| 582 | // Keep the pattern next to the reply that introduced it. The chat only shows |
| 583 | // `content`, but the backend feeds `code` back to Claude so it can see what |
| 584 | // it actually wrote earlier in the session, not just what it said about it. |
| 585 | history_.push({ role: 'assistant', content: message || '', code: code || '' }); |
| 586 | renderMessages(); // re-render so the new reply gets its revert control |
| 587 | // The turn is complete as far as the conversation is concerned, so release |
| 588 | // the interlock here rather than in `finally`. What follows can block for |
| 589 | // a long time — playPattern waits on the AudioContext, which never resumes |
| 590 | // until someone clicks the page — and a terminal that had to wait for that |
| 591 | // would look broken with nothing to show for it. |
| 592 | chatBusy = false; |
| 593 | |
| 594 | if (code && code.trim()) { |
| 595 | const prev = editor.code; // keep the last working pattern |
| 596 | editor.setCode(code); |
| 597 | const ok = await playPattern(editor); |
| 598 | if (!ok) { |
| 599 | editor.setCode(prev); |
| 600 | await playPattern(editor); |
| 601 | addBubble('assistant', '⚠ that pattern had an error, so I kept your previous one. Try again?'); |
| 602 | } |
| 603 | } |
| 604 | saveActive(); |
| 605 | renderSessionList(); // reflect auto-naming from the first message |
| 606 | } catch (err) { |
| 607 | // Drop the turn that failed — leaving a user message with no reply would |
| 608 | // make the next request send two user messages in a row, which the API |
| 609 | // rejects. The text goes back in the composer so it can be retried. |
| 610 | if (history_[history_.length - 1]?.role === 'user') history_.pop(); |
| 611 | renderMessages(); |
| 612 | addBubble('assistant', `⚠ ${err.message}`, null).classList.add('err'); |
| 613 | if (!input.value.trim()) input.value = text; |
| 614 | } finally { |
| 615 | chatBusy = false; |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | form.addEventListener('submit', (e) => { |
| 620 | e.preventDefault(); |
| 621 | const text = input.value.trim(); |
| 622 | if (!text) return; |
| 623 | input.value = ''; |
| 624 | sendMessage(text); |
| 625 | }); |
| 626 | |
| 627 | // Enter sends; Shift+Enter inserts a newline. |
| 628 | input.addEventListener('keydown', (e) => { |
| 629 | if (e.key === 'Enter' && !e.shiftKey) { |
| 630 | e.preventDefault(); |
| 631 | form.requestSubmit(); |
| 632 | } |
| 633 | }); |