Artur Mukhamadiev 1a8c220ca3 frontend: time-based playful status line while a query runs
The running view showed one fixed line for up to 15 minutes, which reads as a
hang. js/running-status.js maps elapsed run time to a line, starting neutral
("Searching Confluence…") and getting more playful at 20 s, 1, 2, 4, 7 and 11
minutes, in the same register as the Exit queue button. app.js re-evaluates
the line every 5 s from wall-clock time (correct after background-tab
throttling), restarts the clock when the running state is entered after a
queue wait, and stops the ticker when the view leaves the running state. The
backend streams no progress, so no line claims a specific activity.
2026-09-15 14:06:39 +03:00

806 lines
26 KiB
JavaScript

/**
* 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');
}