Submit joins the admission queue first and sends the PAT only once the reservation is held. Queued state shows the shaping orb, ordinal position with estimate, and an exit button whose label changes with the wait; running switches the orb to solving. Rejoin once on a lost reservation or ticket, best-effort leave on pagehide. Mock server gains the queue endpoints and five scenarios; contract, unit and e2e tests cover them.
773 lines
25 KiB
JavaScript
773 lines
25 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';
|
|
|
|
// 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
|
|
|
|
// 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) {
|
|
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() {
|
|
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, existing status text, Cancel button
|
|
* (spec §7.2). Also the optimistic default shown while the join request is in flight.
|
|
*/
|
|
function setLoadingRunningUI() {
|
|
if (thinkingOrb) thinkingOrb.setState('solving');
|
|
loadingStatus.textContent = 'Agent researching Confluence...';
|
|
exitQueueBtn.classList.add('hidden');
|
|
cancelBtn.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Switches the active view.
|
|
* @param {'prompt'|'loading'|'result'} viewName
|
|
*/
|
|
function switchView(viewName) {
|
|
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');
|
|
}
|