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.
806 lines
26 KiB
JavaScript
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');
|
|
}
|