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.
This commit is contained in:
Artur Mukhamadiev 2026-09-15 14:06:39 +03:00
parent 7d6429aca9
commit 1a8c220ca3
6 changed files with 121 additions and 4 deletions

View File

@ -15,6 +15,7 @@ frontend/
│ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning │ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning
│ ├── history.js # Sources, lazy bounded history serialization, artifacts listing │ ├── history.js # Sources, lazy bounded history serialization, artifacts listing
│ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view) │ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view)
│ ├── running-status.js # Time-based status line for the loading view's running state
│ ├── queue.js # Pure admission-queue formatting helpers (ordinals, ETA, status line, Exit queue label) │ ├── queue.js # Pure admission-queue formatting helpers (ordinals, ETA, status line, Exit queue label)
│ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover │ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover
├── assets/ ├── assets/

View File

@ -64,7 +64,7 @@
<div class="spinner-wrapper"> <div class="spinner-wrapper">
<canvas id="thinking-orb" class="thinking-orb" width="64" height="64" aria-hidden="true"></canvas> <canvas id="thinking-orb" class="thinking-orb" width="64" height="64" aria-hidden="true"></canvas>
</div> </div>
<p id="loading-status" class="loading-status" aria-live="polite">Agent researching Confluence...</p> <p id="loading-status" class="loading-status" aria-live="polite">Searching Confluence…</p>
<button id="cancel-btn" class="cancel-btn" type="button">Cancel</button> <button id="cancel-btn" class="cancel-btn" type="button">Cancel</button>
<button id="exit-queue-btn" class="cancel-btn exit-queue-btn hidden" type="button" aria-label="Leave the queue">I will try next time</button> <button id="exit-queue-btn" class="cancel-btn exit-queue-btn hidden" type="button" aria-label="Leave the queue">I will try next time</button>
</section> </section>

View File

@ -9,6 +9,7 @@ import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './histo
import { mountThinkingOrb } from './orb.js'; import { mountThinkingOrb } from './orb.js';
import { initBookLogo } from './logo.js'; import { initBookLogo } from './logo.js';
import { QUEUE_POLL_INTERVAL_MS, formatQueuedStatus, formatExitQueueLabel } from './queue.js'; import { QUEUE_POLL_INTERVAL_MS, formatQueuedStatus, formatExitQueueLabel } from './queue.js';
import { RUNNING_STATUS_TICK_MS, formatRunningStatus } from './running-status.js';
// Application memory state // Application memory state
let committedCredentials = null; // { url: string, pat: string } | null let committedCredentials = null; // { url: string, pat: string } | null
@ -27,6 +28,10 @@ let queueWaitStartedAt = null; // Date.now() at the join response that first ret
let queueRejoinedAfterBusy = false; // rejoin-once guard for 409 busy right after "ready" 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 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 // DOM Elements
let viewPrompt, viewLoading, viewResult; let viewPrompt, viewLoading, viewResult;
let promptInput, submitBtn, promptError; let promptInput, submitBtn, promptError;
@ -708,6 +713,7 @@ function renderResultView(result) {
* @param {{ position: number, eta_seconds: number|null }} status * @param {{ position: number, eta_seconds: number|null }} status
*/ */
function setLoadingQueuedUI(status) { function setLoadingQueuedUI(status) {
stopRunningStatusTicker();
if (thinkingOrb) thinkingOrb.setState('shaping'); if (thinkingOrb) thinkingOrb.setState('shaping');
loadingStatus.textContent = formatQueuedStatus(status); loadingStatus.textContent = formatQueuedStatus(status);
cancelBtn.classList.add('hidden'); cancelBtn.classList.add('hidden');
@ -719,6 +725,7 @@ function setLoadingQueuedUI(status) {
* Applies the brief "ready → sending" loading sub-state: shaping orb, no button (spec §7.2). * Applies the brief "ready → sending" loading sub-state: shaping orb, no button (spec §7.2).
*/ */
function setLoadingSendingUI() { function setLoadingSendingUI() {
stopRunningStatusTicker();
if (thinkingOrb) thinkingOrb.setState('shaping'); if (thinkingOrb) thinkingOrb.setState('shaping');
loadingStatus.textContent = "Your turn, starting…"; loadingStatus.textContent = "Your turn, starting…";
cancelBtn.classList.add('hidden'); cancelBtn.classList.add('hidden');
@ -726,14 +733,39 @@ function setLoadingSendingUI() {
} }
/** /**
* Applies the running loading sub-state: solving orb, existing status text, Cancel button * 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. * (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() { function setLoadingRunningUI() {
if (thinkingOrb) thinkingOrb.setState('solving'); if (thinkingOrb) thinkingOrb.setState('solving');
loadingStatus.textContent = 'Agent researching Confluence...';
exitQueueBtn.classList.add('hidden'); exitQueueBtn.classList.add('hidden');
cancelBtn.classList.remove('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;
} }
/** /**
@ -741,6 +773,7 @@ function setLoadingRunningUI() {
* @param {'prompt'|'loading'|'result'} viewName * @param {'prompt'|'loading'|'result'} viewName
*/ */
function switchView(viewName) { function switchView(viewName) {
if (viewName !== 'loading') stopRunningStatusTicker();
viewPrompt.classList.add('hidden'); viewPrompt.classList.add('hidden');
viewLoading.classList.add('hidden'); viewLoading.classList.add('hidden');
viewResult.classList.add('hidden'); viewResult.classList.add('hidden');

View File

@ -0,0 +1,40 @@
/**
* Status line for the loading view's running sub-state.
*
* The backend does not stream agent progress, so the line is chosen from elapsed run time
* only and never claims a specific activity. It starts neutral and gets more playful the
* longer the run goes, in the same register as the Exit queue button (js/queue.js), so a
* multi-minute wait reads as patience rather than a hang.
*/
/** How often the running view re-evaluates the status line. */
export const RUNNING_STATUS_TICK_MS = 5000;
/**
* Stages ordered by the elapsed time (ms) at which each line takes over.
* @type {ReadonlyArray<{ fromMs: number, text: string }>}
*/
export const RUNNING_STATUS_STAGES = Object.freeze([
{ fromMs: 0, text: 'Searching Confluence…' },
{ fromMs: 20_000, text: 'Rummaging through the wiki…' },
{ fromMs: 60_000, text: "Reading everything so you don't have to…" },
{ fromMs: 120_000, text: 'Turning pages faster than a human could…' },
{ fromMs: 240_000, text: 'Lost in Confluence, found three more tabs…' },
{ fromMs: 420_000, text: 'Has anyone updated this page since 2019? Checking…' },
{ fromMs: 660_000, text: 'Still at it. If time runs out, it answers with what it found…' },
]);
/**
* Picks the running status line for the time elapsed since the query was sent.
* Non-finite or negative input reads as "just started".
* @param {number} elapsedMs
* @returns {string}
*/
export function formatRunningStatus(elapsedMs) {
const elapsed = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
let text = RUNNING_STATUS_STAGES[0].text;
for (const stage of RUNNING_STATUS_STAGES) {
if (elapsed >= stage.fromMs) text = stage.text;
}
return text;
}

View File

@ -226,7 +226,7 @@ async function runTests() {
assert.equal(isLoadingVisible, true, 'Loading view must be visible while query executes'); assert.equal(isLoadingVisible, true, 'Loading view must be visible while query executes');
const statusText = await cdp.eval('document.querySelector(".loading-status").textContent'); const statusText = await cdp.eval('document.querySelector(".loading-status").textContent');
assert.equal(statusText, 'Agent researching Confluence...'); assert.equal(statusText, 'Searching Confluence…');
const orbPresent = await cdp.eval('!!document.querySelector("canvas.thinking-orb")'); const orbPresent = await cdp.eval('!!document.querySelector("canvas.thinking-orb")');
assert.equal(orbPresent, true, 'Thinking orb canvas must be present'); assert.equal(orbPresent, true, 'Thinking orb canvas must be present');

View File

@ -0,0 +1,43 @@
/**
* Unit tests for the running-state status line helper (js/running-status.js).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { formatRunningStatus, RUNNING_STATUS_STAGES, RUNNING_STATUS_TICK_MS } from '../js/running-status.js';
describe('formatRunningStatus', () => {
test('starts with the neutral line shown at submit time', () => {
assert.equal(formatRunningStatus(0), 'Searching Confluence…');
assert.equal(formatRunningStatus(19_999), 'Searching Confluence…');
});
test('advances through the playful stages at their boundaries', () => {
assert.equal(formatRunningStatus(20_000), 'Rummaging through the wiki…');
assert.equal(formatRunningStatus(59_999), 'Rummaging through the wiki…');
assert.equal(formatRunningStatus(60_000), "Reading everything so you don't have to…");
assert.equal(formatRunningStatus(120_000), 'Turning pages faster than a human could…');
assert.equal(formatRunningStatus(240_000), 'Lost in Confluence, found three more tabs…');
assert.equal(formatRunningStatus(420_000), 'Has anyone updated this page since 2019? Checking…');
assert.equal(formatRunningStatus(660_000), 'Still at it. If time runs out, it answers with what it found…');
});
test('stays on the last line past the protocol deadline', () => {
assert.equal(formatRunningStatus(900_000), RUNNING_STATUS_STAGES.at(-1).text);
assert.equal(formatRunningStatus(3_600_000), RUNNING_STATUS_STAGES.at(-1).text);
});
test('treats negative, NaN and non-number input as just started', () => {
assert.equal(formatRunningStatus(-5), 'Searching Confluence…');
assert.equal(formatRunningStatus(NaN), 'Searching Confluence…');
assert.equal(formatRunningStatus(undefined), 'Searching Confluence…');
});
test('stages are strictly increasing and start at zero', () => {
assert.equal(RUNNING_STATUS_STAGES[0].fromMs, 0);
for (let i = 1; i < RUNNING_STATUS_STAGES.length; i++) {
assert.ok(RUNNING_STATUS_STAGES[i].fromMs > RUNNING_STATUS_STAGES[i - 1].fromMs);
}
assert.ok(RUNNING_STATUS_TICK_MS < RUNNING_STATUS_STAGES[1].fromMs, 'first change must be reachable by the ticker');
});
});