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:
parent
7d6429aca9
commit
1a8c220ca3
@ -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/
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
40
frontend/js/running-status.js
Normal file
40
frontend/js/running-status.js
Normal 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;
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
|||||||
43
frontend/tests/running-status.test.js
Normal file
43
frontend/tests/running-status.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user