/** * Main application wiring and state management. * Credentials remain strictly in browser memory and are never persisted or logged. */ import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue } from './api.js'; import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js'; import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js'; import { mountThinkingOrb } from './orb.js'; import { initBookLogo } from './logo.js'; import { QUEUE_POLL_INTERVAL_MS, formatQueuedStatus, formatExitQueueLabel } from './queue.js'; import { RUNNING_STATUS_TICK_MS, formatRunningStatus } from './running-status.js'; // Application memory state let committedCredentials = null; // { url: string, pat: string } | null let draftCredentials = { url: '', pat: '' }; let activeAbortController = null; let currentRequestGeneration = 0; let activeVerifyAbortController = null; let currentVerifyGeneration = 0; let currentResult = null; let lastSubmittedPrompt = ''; let thinkingOrb = null; // Admission queue state (docs/QUEUE_SPECIFICATION.md §7) let queuePollTimerId = null; let queueWaitStartedAt = null; // Date.now() at the join response that first returned "queued" let queueRejoinedAfterBusy = false; // rejoin-once guard for 409 busy right after "ready" let queueRejoinedAfterLoss = false; // rejoin-once guard for 404 ticket_not_found while polling // Running status line (js/running-status.js): advances with time since the query was sent let runningStatusTimerId = null; let runningStartedAt = null; // Date.now() when the running sub-state was last entered // DOM Elements let viewPrompt, viewLoading, viewResult; let promptInput, submitBtn, promptError; let keyBtn, credIndicator, credStatusSr; let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm; let credUrlInput, credPatInput, togglePatBtn, modalFeedback; let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred; let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn; let outputContent, sectionNav, resultWarnings; let artifactsSection, artifactsList, artifactsError; let historySection, historyToggleBtn, historyToggleTitle, historyContent; let pagesAccessedList, toolHistoryList; let markdownRenderer = null; /** * Initializes DOM element references and event listeners. */ document.addEventListener('DOMContentLoaded', () => { // Views viewPrompt = document.getElementById('view-prompt'); viewLoading = document.getElementById('view-loading'); viewResult = document.getElementById('view-result'); // Decorative animations (page is a fixed light theme, so the orb ink is pinned to light). // Starts in the "solving" preset; switched to "shaping" while queued (see setLoadingQueuedUI). thinkingOrb = mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' }); initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo')); // Prompt View promptInput = document.getElementById('prompt-input'); submitBtn = document.getElementById('submit-btn'); promptError = document.getElementById('prompt-error'); // Header / Credentials keyBtn = document.getElementById('key-btn'); credIndicator = document.getElementById('cred-indicator'); credStatusSr = document.getElementById('cred-status-sr'); // Modal modalBackdrop = document.getElementById('modal-backdrop'); modalDialog = document.getElementById('modal-dialog'); modalCloseBtn = document.getElementById('modal-close-btn'); credentialsForm = document.getElementById('credentials-form'); credUrlInput = document.getElementById('cred-url'); credPatInput = document.getElementById('cred-pat'); togglePatBtn = document.getElementById('toggle-pat-btn'); modalFeedback = document.getElementById('modal-feedback'); btnTestCred = document.getElementById('btn-test-cred'); btnSaveCred = document.getElementById('btn-save-cred'); btnCancelCred = document.getElementById('btn-cancel-cred'); btnClearCred = document.getElementById('btn-clear-cred'); // Loading & Result Controls cancelBtn = document.getElementById('cancel-btn'); exitQueueBtn = document.getElementById('exit-queue-btn'); loadingStatus = document.getElementById('loading-status'); backBtn = document.getElementById('back-btn'); exportBtn = document.getElementById('export-btn'); // Result Area outputContent = document.getElementById('output-content'); sectionNav = document.getElementById('section-nav'); resultWarnings = document.getElementById('result-warnings'); // Artifacts Area artifactsSection = document.getElementById('artifacts-section'); artifactsList = document.getElementById('artifacts-list'); artifactsError = document.getElementById('artifacts-error'); // History Area historySection = document.getElementById('history-section'); historyToggleBtn = document.getElementById('history-toggle-btn'); historyToggleTitle = document.getElementById('history-toggle-title'); historyContent = document.getElementById('history-content'); pagesAccessedList = document.getElementById('pages-accessed-list'); toolHistoryList = document.getElementById('tool-history-list'); // Markdown renderer markdownRenderer = new MarkdownRenderer(outputContent, sectionNav); // Wire events setupPromptEvents(); setupModalEvents(); setupLoadingEvents(); setupResultEvents(); updateCredentialIndicator(); // Best-effort ticket release on tab close/navigation while a ticket may still be held // (queued, reserved, or running). A keepalive DELETE beats nothing; if it does not land, // the server's heartbeat/reservation timeout reclaims the ticket within 15s (spec §7.1). window.addEventListener('pagehide', () => { if (viewLoading && !viewLoading.classList.contains('hidden')) { leaveQueue({ keepalive: true }).catch(() => {}); } }); }); /** * Updates textarea height dynamically up to 280px. */ function autoResizeTextarea() { if (!promptInput) return; promptInput.style.height = 'auto'; const newHeight = Math.min(promptInput.scrollHeight, 280); promptInput.style.height = `${newHeight}px`; } /** * Sets up prompt input and submission listeners. */ function setupPromptEvents() { promptInput.addEventListener('input', autoResizeTextarea); // Enter to submit (outside IME), Shift+Enter for newline promptInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { if (e.isComposing || e.keyCode === 229) { return; // IME composition in progress } if (!e.shiftKey) { e.preventDefault(); handleSubmitQuery(); } } }); submitBtn.addEventListener('click', (e) => { e.preventDefault(); handleSubmitQuery(); }); } /** * Handles query submission: validates locally, then runs the admission queue flow * (join → queued/ready → send) per docs/QUEUE_SPECIFICATION.md §7.1. */ async function handleSubmitQuery() { const prompt = promptInput.value; if (!prompt || !prompt.trim()) { showPromptError('Please enter a research prompt.'); return; } // Ensure credentials are configured if (!committedCredentials || !committedCredentials.url || !committedCredentials.pat) { showPromptError('Confluence credentials required. Click the key icon to configure URL and PAT.'); openCredentialsModal(); return; } hidePromptError(); lastSubmittedPrompt = prompt; // Transition to loading view. Default optimistically to the running sub-state: most // submissions are admitted immediately (join answers "ready"), and this avoids a flash // of queued UI while the join request is still in flight. switchView('loading'); setLoadingRunningUI(); const generation = ++currentRequestGeneration; activeAbortController = new AbortController(); queueWaitStartedAt = null; queueRejoinedAfterBusy = false; queueRejoinedAfterLoss = false; await beginAdmission(generation, prompt); } /** * Joins the admission queue and dispatches to the queued or ready path. * Also used to rejoin once after a lost reservation (409 after ready) or a lost ticket * (404 while polling), per spec §7.1 steps 5-6. * @param {number} generation * @param {string} prompt */ async function beginAdmission(generation, prompt) { let status; try { status = await joinQueue({ signal: activeAbortController.signal }); } catch (err) { if (generation !== currentRequestGeneration) return; if (generation === currentRequestGeneration) activeAbortController = null; if (err.name === 'AbortError') { switchView('prompt'); return; } switchView('prompt'); showQueryError(err); return; } if (generation !== currentRequestGeneration) return; await handleAdmissionStatus(generation, prompt, status); } /** * Reacts to a join/status response: sends the query immediately when "ready", otherwise * enters/updates the queued sub-state and schedules the next poll. * @param {number} generation * @param {string} prompt * @param {{ status: 'ready'|'queued' }} status */ async function handleAdmissionStatus(generation, prompt, status) { if (status.status === 'ready') { stopPolling(); setLoadingSendingUI(); await sendQuery(generation, prompt); return; } // queued if (queueWaitStartedAt === null) { queueWaitStartedAt = Date.now(); } setLoadingQueuedUI(status); schedulePoll(generation, prompt); } /** * Polls ticket status every QUEUE_POLL_INTERVAL_MS while queued. * @param {number} generation * @param {string} prompt */ function schedulePoll(generation, prompt) { stopPolling(); queuePollTimerId = setTimeout(() => { queuePollTimerId = null; pollStatus(generation, prompt); }, QUEUE_POLL_INTERVAL_MS); } /** * Clears any pending poll timer. Safe to call when nothing is scheduled. */ function stopPolling() { if (queuePollTimerId !== null) { clearTimeout(queuePollTimerId); queuePollTimerId = null; } } /** * Fetches ticket status once and reacts to it, including the rejoin-once-then-give-up * handling for a lost ticket (spec §7.1 step 6, §8). * @param {number} generation * @param {string} prompt */ async function pollStatus(generation, prompt) { if (generation !== currentRequestGeneration) return; let status; try { status = await queueStatus({ signal: activeAbortController.signal }); } catch (err) { if (generation !== currentRequestGeneration) return; if (err.name === 'AbortError') return; if (err.code === 'ticket_not_found') { if (!queueRejoinedAfterLoss) { queueRejoinedAfterLoss = true; await beginAdmission(generation, prompt); return; } switchView('prompt'); showPromptError('Your place in the queue was lost. Please submit again.'); return; } switchView('prompt'); showQueryError(err); return; } if (generation !== currentRequestGeneration) return; await handleAdmissionStatus(generation, prompt, status); } /** * Sends the actual query once the reservation is held ("ready"). Handles the * rejoin-once-then-give-up path for a reservation lost right after "ready" (§7.1 step 5). * @param {number} generation * @param {string} prompt */ async function sendQuery(generation, prompt) { setLoadingRunningUI(); try { const result = await submitQuery( { prompt, credentials: committedCredentials }, { signal: activeAbortController.signal } ); // Stale check if (generation !== currentRequestGeneration) { return; } currentResult = result; renderResultView(result); switchView('result'); } catch (err) { // Stale check if (generation !== currentRequestGeneration) { return; } if (err.name === 'AbortError') { // User cancelled, smoothly return to prompt view switchView('prompt'); return; } if (err.code === 'busy') { if (!queueRejoinedAfterBusy) { queueRejoinedAfterBusy = true; await beginAdmission(generation, prompt); return; } switchView('prompt'); showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.'); return; } // Switch back to prompt view and display error switchView('prompt'); showQueryError(err); } finally { if (generation === currentRequestGeneration) { activeAbortController = null; } } } /** * Maps a query/queue API error to the prompt-view error message. * @param {Error & { code?: string }} err */ function showQueryError(err) { if (err.code === 'confluence_auth_failed') { showPromptError('Confluence authentication failed. Please verify your URL and PAT in the credentials modal.'); } else if (err.code === 'origin_denied') { showPromptError('Request forbidden: Origin denied by server policy.'); } else if (err.code === 'destination_denied') { showPromptError('Request forbidden: Confluence destination URL is not permitted by server policy.'); } else if (err.code === 'busy') { showPromptError('The server is currently busy processing another query or cleaning up. Please try again shortly.'); } else if (err.code === 'query_timeout') { showPromptError('The query timed out. The operation exceeded the allowed execution time.'); } else if (err.code === 'request_too_large') { showPromptError('The request exceeds the maximum allowed payload size.'); } else if (err.code === 'queue_full') { showPromptError('The queue is full, please try later.'); } else if (err.code === 'ticket_not_found') { showPromptError('Your place in the queue was lost. Please submit again.'); } else { showPromptError(`Query failed [${err.code || 'error'}]: ${err.message || 'An unexpected error occurred.'}`); } } /** * Sets up credentials modal and actions. */ function setupModalEvents() { keyBtn.addEventListener('click', openCredentialsModal); modalCloseBtn.addEventListener('click', closeCredentialsModal); btnCancelCred.addEventListener('click', closeCredentialsModal); // Close modal on Escape window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !modalBackdrop.classList.contains('hidden')) { closeCredentialsModal(); } }); // Close on backdrop click outside dialog modalBackdrop.addEventListener('click', (e) => { if (e.target === modalBackdrop) { closeCredentialsModal(); } }); // Focus trap inside modal dialog modalDialog.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; const focusable = modalDialog.querySelectorAll( 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); // Password visibility toggle togglePatBtn.addEventListener('click', () => { const isPassword = credPatInput.type === 'password'; credPatInput.type = isPassword ? 'text' : 'password'; togglePatBtn.setAttribute('aria-label', isPassword ? 'Hide token' : 'Show token'); }); // Typing in inputs invalidates any in-flight test connection credUrlInput.addEventListener('input', () => { abortActiveVerify(); btnTestCred.disabled = false; }); credPatInput.addEventListener('input', () => { abortActiveVerify(); btnTestCred.disabled = false; }); // Test connection button (draft credentials only, does NOT commit; guarded against staleness) btnTestCred.addEventListener('click', async () => { draftCredentials.url = credUrlInput.value.trim(); draftCredentials.pat = credPatInput.value.trim(); try { validateCredentials(draftCredentials); } catch (err) { showModalFeedback(err.message, 'error'); return; } const verifyGen = ++currentVerifyGeneration; if (activeVerifyAbortController) { activeVerifyAbortController.abort(); } activeVerifyAbortController = new AbortController(); btnTestCred.disabled = true; showModalFeedback('Testing connection...', 'info'); try { await verifyCredentials(draftCredentials, { signal: activeVerifyAbortController.signal }); if (verifyGen !== currentVerifyGeneration) return; showModalFeedback('Connection successful! Credentials are valid.', 'success'); } catch (err) { if (verifyGen !== currentVerifyGeneration) return; if (err.name === 'AbortError') return; showModalFeedback(`Connection failed [${err.code || 'error'}]: ${err.message}`, 'error'); } finally { if (verifyGen === currentVerifyGeneration) { btnTestCred.disabled = false; activeVerifyAbortController = null; } } }); // Save & Close form submit credentialsForm.addEventListener('submit', (e) => { e.preventDefault(); abortActiveVerify(); draftCredentials.url = credUrlInput.value.trim(); draftCredentials.pat = credPatInput.value.trim(); try { validateCredentials(draftCredentials); } catch (err) { showModalFeedback(err.message, 'error'); return; } // Commit credentials to memory committedCredentials = { url: draftCredentials.url, pat: draftCredentials.pat }; updateCredentialIndicator(); closeCredentialsModal(); hidePromptError(); }); // Clear credentials btnClearCred.addEventListener('click', () => { abortActiveVerify(); // If a query (or an admission wait) is active, abort it and release any held ticket if (activeAbortController) { currentRequestGeneration++; stopPolling(); activeAbortController.abort(); activeAbortController = null; leaveQueue().catch(() => {}); switchView('prompt'); } committedCredentials = null; draftCredentials = { url: '', pat: '' }; credUrlInput.value = ''; credPatInput.value = ''; updateCredentialIndicator(); showModalFeedback('Credentials cleared from browser memory.', 'info'); }); } /** * Aborts any pending verify connection request and increments generation counter. */ function abortActiveVerify() { currentVerifyGeneration++; if (activeVerifyAbortController) { activeVerifyAbortController.abort(); activeVerifyAbortController = null; } } /** * Opens credentials modal and restores draft from committed state. */ function openCredentialsModal() { modalFeedback.className = 'modal-feedback hidden'; modalFeedback.textContent = ''; if (committedCredentials) { credUrlInput.value = committedCredentials.url; credPatInput.value = committedCredentials.pat; } else { credUrlInput.value = draftCredentials.url || ''; credPatInput.value = draftCredentials.pat || ''; } modalBackdrop.classList.remove('hidden'); credUrlInput.focus(); } /** * Closes credentials modal and restores focus to key button. */ function closeCredentialsModal() { abortActiveVerify(); modalBackdrop.classList.add('hidden'); keyBtn.focus(); } /** * Displays modal feedback messages. * @param {string} msg * @param {'error'|'success'|'info'} type */ function showModalFeedback(msg, type) { modalFeedback.className = `modal-feedback ${type === 'error' ? 'error-box' : type === 'success' ? 'success-box' : 'warning-box'}`; modalFeedback.textContent = msg; } /** * Updates the key icon indicator state. */ function updateCredentialIndicator() { if (committedCredentials && committedCredentials.url && committedCredentials.pat) { credIndicator.classList.add('active'); credStatusSr.textContent = 'Credentials active in memory'; keyBtn.title = 'Credentials active (Click to edit)'; } else { credIndicator.classList.remove('active'); credStatusSr.textContent = 'Credentials not set'; keyBtn.title = 'Configure Confluence Credentials'; } } /** * Sets up the loading view's Cancel (running) and Exit queue (queued) buttons. */ function setupLoadingEvents() { // Cancel button: running sub-state only (spec §7.4, unchanged). The server observes the // aborted fetch, ends the run, and promotes the next ticket. cancelBtn.addEventListener('click', () => { currentRequestGeneration++; stopPolling(); if (activeAbortController) { activeAbortController.abort(); activeAbortController = null; } switchView('prompt'); }); // Exit queue button: queued sub-state only (spec §7.3). Sends leave, stops polling, and // returns to the prompt view with the prompt text preserved (the textarea is never cleared). exitQueueBtn.addEventListener('click', () => { currentRequestGeneration++; stopPolling(); if (activeAbortController) { activeAbortController.abort(); activeAbortController = null; } leaveQueue().catch(() => {}); switchView('prompt'); }); } /** * Sets up result view buttons and interactions. */ function setupResultEvents() { // Back to prompt button backBtn.addEventListener('click', () => { switchView('prompt'); promptInput.focus(); }); // Export to MD button exportBtn.addEventListener('click', () => { if (currentResult && typeof currentResult.markdown === 'string') { exportToMarkdown(currentResult.markdown, lastSubmittedPrompt); } }); // History toggle button historyToggleBtn.addEventListener('click', () => { const expanded = historyToggleBtn.getAttribute('aria-expanded') === 'true'; historyToggleBtn.setAttribute('aria-expanded', String(!expanded)); if (expanded) { historyContent.classList.add('hidden'); } else { historyContent.classList.remove('hidden'); } }); } /** * Renders the query result view. * @param {object} result */ function renderResultView(result) { // 1. Warnings renderWarnings(resultWarnings, result.warnings || []); // 2. Markdown output (with bounded section renderer) markdownRenderer.load(result.markdown || ''); // 3. Artifacts artifactsError.classList.add('hidden'); artifactsError.textContent = ''; if (Array.isArray(result.artifacts) && result.artifacts.length > 0) { artifactsSection.classList.remove('hidden'); renderArtifacts(artifactsList, result.artifacts, async (id, name) => { try { artifactsError.classList.add('hidden'); const { blob, filename } = await downloadArtifact(id); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { artifactsError.classList.remove('hidden'); artifactsError.textContent = `Download failed [${err.code || 'error'}]: ${err.message || 'Artifact not found or expired.'}`; } }); } else { artifactsSection.classList.add('hidden'); artifactsList.replaceChildren(); } // 4. Sources and Tool History const pages = result.pages_accessed || []; historyToggleTitle.textContent = `Sources & Request History (${pages.length} page${pages.length === 1 ? '' : 's'} accessed)`; renderPagesAccessed(pagesAccessedList, pages); renderToolHistory(toolHistoryList, result.tool_history || []); // Collapse history by default historyToggleBtn.setAttribute('aria-expanded', 'false'); historyContent.classList.add('hidden'); } /** * Applies the queued loading sub-state: shaping orb, position/estimate status line, and the * Exit queue button whose label depends on time waited (spec §7.2, §7.3). * @param {{ position: number, eta_seconds: number|null }} status */ function setLoadingQueuedUI(status) { stopRunningStatusTicker(); if (thinkingOrb) thinkingOrb.setState('shaping'); loadingStatus.textContent = formatQueuedStatus(status); cancelBtn.classList.add('hidden'); exitQueueBtn.classList.remove('hidden'); exitQueueBtn.textContent = formatExitQueueLabel(Date.now() - (queueWaitStartedAt ?? Date.now())); } /** * Applies the brief "ready → sending" loading sub-state: shaping orb, no button (spec §7.2). */ function setLoadingSendingUI() { stopRunningStatusTicker(); if (thinkingOrb) thinkingOrb.setState('shaping'); loadingStatus.textContent = "Your turn, starting…"; cancelBtn.classList.add('hidden'); exitQueueBtn.classList.add('hidden'); } /** * Applies the running loading sub-state: solving orb, time-based status line, Cancel button * (spec §7.2). Also the optimistic default shown while the join request is in flight. * Each entry restarts the elapsed clock, so a run that follows a queue wait starts from the * neutral first line again. */ function setLoadingRunningUI() { if (thinkingOrb) thinkingOrb.setState('solving'); exitQueueBtn.classList.add('hidden'); cancelBtn.classList.remove('hidden'); startRunningStatusTicker(); } /** * Starts (or restarts) the ticker that advances the running status line with elapsed time. * The line is derived from wall-clock time on every tick, so a background tab whose timers * are throttled still shows the right stage as soon as it fires. */ function startRunningStatusTicker() { stopRunningStatusTicker(); runningStartedAt = Date.now(); loadingStatus.textContent = formatRunningStatus(0); runningStatusTimerId = setInterval(() => { loadingStatus.textContent = formatRunningStatus(Date.now() - runningStartedAt); }, RUNNING_STATUS_TICK_MS); } /** Stops the running status ticker; safe to call when it is not running. */ function stopRunningStatusTicker() { if (runningStatusTimerId !== null) { clearInterval(runningStatusTimerId); runningStatusTimerId = null; } runningStartedAt = null; } /** * Switches the active view. * @param {'prompt'|'loading'|'result'} viewName */ function switchView(viewName) { if (viewName !== 'loading') stopRunningStatusTicker(); viewPrompt.classList.add('hidden'); viewLoading.classList.add('hidden'); viewResult.classList.add('hidden'); if (viewName === 'prompt') { viewPrompt.classList.remove('hidden'); } else if (viewName === 'loading') { viewLoading.classList.remove('hidden'); } else if (viewName === 'result') { viewResult.classList.remove('hidden'); } } /** * Shows prompt error message. * @param {string} msg */ function showPromptError(msg) { promptError.textContent = msg; promptError.classList.remove('hidden'); } /** * Hides prompt error message. */ function hidePromptError() { promptError.textContent = ''; promptError.classList.add('hidden'); }