From 1a8c220ca3958c3ac8d5a50b222d8c2c75a3d571 Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Tue, 15 Sep 2026 14:06:39 +0300 Subject: [PATCH] frontend: time-based playful status line while a query runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/README.md | 1 + frontend/index.html | 2 +- frontend/js/app.js | 37 +++++++++++++++++++++-- frontend/js/running-status.js | 40 +++++++++++++++++++++++++ frontend/tests/e2e_runner.js | 2 +- frontend/tests/running-status.test.js | 43 +++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 frontend/js/running-status.js create mode 100644 frontend/tests/running-status.test.js diff --git a/frontend/README.md b/frontend/README.md index e543726..7029c87 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,6 +15,7 @@ frontend/ │ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning │ ├── history.js # Sources, lazy bounded history serialization, artifacts listing │ ├── 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) │ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover ├── assets/ diff --git a/frontend/index.html b/frontend/index.html index e8eefb4..9326146 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,7 +64,7 @@
-

Agent researching Confluence...

+

Searching Confluence…

diff --git a/frontend/js/app.js b/frontend/js/app.js index 3980dfa..e772ba9 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -9,6 +9,7 @@ import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './histo 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 @@ -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 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; @@ -708,6 +713,7 @@ function renderResultView(result) { * @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'); @@ -719,6 +725,7 @@ function setLoadingQueuedUI(status) { * 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'); @@ -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. + * 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'); - loadingStatus.textContent = 'Agent researching Confluence...'; 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; } /** @@ -741,6 +773,7 @@ function setLoadingRunningUI() { * @param {'prompt'|'loading'|'result'} viewName */ function switchView(viewName) { + if (viewName !== 'loading') stopRunningStatusTicker(); viewPrompt.classList.add('hidden'); viewLoading.classList.add('hidden'); viewResult.classList.add('hidden'); diff --git a/frontend/js/running-status.js b/frontend/js/running-status.js new file mode 100644 index 0000000..6d31be9 --- /dev/null +++ b/frontend/js/running-status.js @@ -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; +} diff --git a/frontend/tests/e2e_runner.js b/frontend/tests/e2e_runner.js index b0710c8..a8cd945 100644 --- a/frontend/tests/e2e_runner.js +++ b/frontend/tests/e2e_runner.js @@ -226,7 +226,7 @@ async function runTests() { assert.equal(isLoadingVisible, true, 'Loading view must be visible while query executes'); 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")'); assert.equal(orbPresent, true, 'Thinking orb canvas must be present'); diff --git a/frontend/tests/running-status.test.js b/frontend/tests/running-status.test.js new file mode 100644 index 0000000..c33213f --- /dev/null +++ b/frontend/tests/running-status.test.js @@ -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'); + }); +});