phase: 87_big_read_progress
All criteria verified — no defects found, nothing to fix. Final report: **Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)** - Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes) - **Tests/lint (exact outcomes):** - `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓) - `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore) - `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated) - 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors - **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓ - **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`) - **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
This commit is contained in:
@@ -307,6 +307,13 @@ const brand = () => window.BOR_BRAND || "Brain of Reese";
|
||||
* test_frontend_feedback.py). */
|
||||
export const TURN_TIMEOUT_MS = 120_000;
|
||||
|
||||
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
|
||||
* threshold (A5): below 5s a frameless gap reads as normal latency;
|
||||
* at/above it the latest line proves the turn is still processing.
|
||||
* Pinned constant, not a magic number — the tool-line clock's tick
|
||||
* gates on it. */
|
||||
const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;
|
||||
|
||||
const UI_STATE = Object.freeze({
|
||||
idle: "idle",
|
||||
thinking: "thinking",
|
||||
@@ -1018,6 +1025,17 @@ let thinkingClock = 0; // setInterval id — elapsed-seconds hint
|
||||
let thinkingStart = 0; // Date.now() when "thinking" began
|
||||
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
|
||||
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
|
||||
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — one clock per
|
||||
* turn, re-armed per `tool` frame: the baseline (toolLineStart) resets on
|
||||
* every new line, so each line counts its OWN frameless silence, and the
|
||||
* "(Ns)" suffix targets that line's LATEST row only (older rows keep their
|
||||
* permanent record, no timers). toolLineWrap is the message wrap the live
|
||||
* lines render into — the tick's null-safe lookups make a New-Chat click
|
||||
* mid-gap a no-op (no container → no suffix). Settle (next frame) and
|
||||
* stop (every setUiState transition) own the teardown. */
|
||||
let toolLineTimer = 0; // setInterval id — the latest tool line's "(Ns)" suffix
|
||||
let toolLineStart = 0; // Date.now() when the latest tool line armed the clock
|
||||
let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
|
||||
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
|
||||
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
|
||||
* while a turn is still in flight; reset per turn at the top of
|
||||
@@ -1062,6 +1080,19 @@ function startThinkingClock() {
|
||||
const bubble = document.querySelector("#typing-indicator .bubble");
|
||||
if (bubble) {
|
||||
bubble.setAttribute("aria-label", `${brand()} is still thinking (${secs}s)`);
|
||||
// Phase 87 (TODO.md L5): the 10s hint was aria-label-only — invisible
|
||||
// to sighted users, who saw frozen dots on a big read. Now it ALSO
|
||||
// renders a visible mono "Ns" suffix as the bubble's last child (after
|
||||
// the three dot spans). The aria channel is kept byte-identical —
|
||||
// both users, same clock. textContent only: the bubble is
|
||||
// role="status", so the change is announced.
|
||||
let el = bubble.querySelector(".typing-elapsed");
|
||||
if (!el) {
|
||||
el = document.createElement("span");
|
||||
el.className = "typing-elapsed";
|
||||
bubble.appendChild(el);
|
||||
}
|
||||
el.textContent = secs + "s";
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -1080,6 +1111,53 @@ function clearTurnTimeout() {
|
||||
turnTimeoutCb = null;
|
||||
}
|
||||
|
||||
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — the latest
|
||||
* tool line's visible "processing" suffix. The suffix is a SIBLING
|
||||
* appended AFTER the line's existing children (the pinned template text
|
||||
* + the <code> argument) — never a rewrite: appendToolLine's exact
|
||||
* line.textContent literals stay byte-identical (the unit + emoji-guard
|
||||
* pins), and the restore path (phase 14) re-renders lines with no clock
|
||||
* at all (A6 — the indication is live-only). */
|
||||
function armToolLineClock(wrap) {
|
||||
toolLineWrap = wrap;
|
||||
toolLineStart = Date.now();
|
||||
if (!toolLineTimer) {
|
||||
toolLineTimer = setInterval(() => {
|
||||
const secs = Math.round((Date.now() - toolLineStart) / 1000);
|
||||
if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; // A5: below 5s the gap reads as normal latency
|
||||
const line =
|
||||
toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child");
|
||||
if (!line) return; // the wrap was reset mid-gap (New Chat) — no-op
|
||||
let el = line.querySelector(".tool-elapsed");
|
||||
if (!el) {
|
||||
el = document.createElement("span");
|
||||
el.className = "tool-elapsed";
|
||||
line.appendChild(el);
|
||||
}
|
||||
el.textContent = `(${secs}s)`;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* A frame arrived (thinking / retry / delta) or the turn is settling:
|
||||
* the latest line is no longer "processing" — drop the interval and
|
||||
* REMOVE the suffix (a frozen timestamp on a finished line is noise;
|
||||
* the line itself stays the permanent record). */
|
||||
function settleToolLine() {
|
||||
if (toolLineTimer) {
|
||||
clearInterval(toolLineTimer);
|
||||
toolLineTimer = 0;
|
||||
}
|
||||
toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
/* The state-machine entry (next to stopThinkingClock / clearTurnTimeout
|
||||
* in setUiState): settle + forget the wrap — no residue across turns. */
|
||||
function stopToolLineClock() {
|
||||
settleToolLine();
|
||||
toolLineWrap = null;
|
||||
}
|
||||
|
||||
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
|
||||
* never stop a turn, but the 120s pre-token guard is a plain setTimeout,
|
||||
* so hidden time counted toward it: a turn whose first frame lands while
|
||||
@@ -1109,6 +1187,7 @@ export function setUiState(state, errorDetail = "") {
|
||||
uiState = state;
|
||||
stopThinkingClock();
|
||||
clearTurnTimeout(); // the guard only owns the pre-token window
|
||||
stopToolLineClock(); // phase 87: a stuck timer is impossible — every transition stops/clears it
|
||||
|
||||
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
|
||||
sendBtn.disabled = false; // enabled in every state — Stop is a control
|
||||
@@ -2082,6 +2161,10 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
await readSSE(res, (ev) => {
|
||||
if (aborted) return;
|
||||
if (ev.type === "thinking") {
|
||||
// Phase 87 (TODO.md L5): a frame arrived — the latest tool line
|
||||
// is no longer "processing"; settle its "(Ns)" suffix (the
|
||||
// visible indication moves to the thinking block).
|
||||
settleToolLine();
|
||||
// Phase 17: model reasoning — stream it live into the collapsible
|
||||
// Thinking block. No setUiState here: the UI state stays
|
||||
// "thinking" (the button stays the enabled "Stop" control —
|
||||
@@ -2146,8 +2229,17 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
?.setAttribute("aria-label", toolStatus);
|
||||
}
|
||||
appendToolLine(wrap, name, argument);
|
||||
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
|
||||
// baseline resets per line, so each line counts its OWN
|
||||
// frameless silence. This live branch is the ONLY arm call site
|
||||
// (A6): the phase-14 restore path re-renders lines without it.
|
||||
armToolLineClock(wrap);
|
||||
// No page scroll (phase 42): tool lines never yank the viewport.
|
||||
} else if (ev.type === "retry") {
|
||||
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
|
||||
// tool line's "(Ns)" suffix (the retry status line takes over as
|
||||
// the visible feedback).
|
||||
settleToolLine();
|
||||
// Phase 67 (owner-locked A4): the server restarted the LLM
|
||||
// request before ANY frame of it reached the client (locked
|
||||
// A2) — say exactly what is happening on the existing status
|
||||
@@ -2169,6 +2261,10 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
?.setAttribute("aria-label", retryStatus);
|
||||
}
|
||||
} else if (ev.type === "delta") {
|
||||
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
|
||||
// tool line's "(Ns)" suffix (the answer bubble takes over as the
|
||||
// visible feedback).
|
||||
settleToolLine();
|
||||
acc += ev.text || "";
|
||||
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
||||
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
||||
|
||||
@@ -606,6 +606,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
border-radius: 5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
|
||||
suffix — ticking "(Ns)" while ≥5s of the turn's stream stay silent
|
||||
after the call; removed on the next frame (settle). ink-soft on the
|
||||
bubble surface (the AA pairing), small mono like every status line. */
|
||||
.tool-elapsed {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
margin-left: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
font-size: 0.75rem;
|
||||
@@ -1113,8 +1124,29 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
/* Phase 87 (TODO.md L5): the visible pre-token elapsed hint — the
|
||||
10s thinking clock promoted from aria-only to sighted users.
|
||||
ink-soft on the bubble surface (the AA pairing), small mono like
|
||||
every status line. Plain text: no animation, no motion opt-out.
|
||||
The span is a sibling of the dots inside the same .typing bubble, so
|
||||
it resets the dot geometry (.typing span) it would otherwise inherit. */
|
||||
.typing-elapsed {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typing span { animation: none; opacity: 0.7; }
|
||||
/* Phase 87 (TODO.md L5): the elapsed hint is plain text, not motion —
|
||||
it keeps full opacity (the AA pairing) under reduced motion. */
|
||||
.typing span.typing-elapsed { opacity: 1; }
|
||||
/* Phase 17 thinking block: the chevron stills (no rotation motion). */
|
||||
details.thinking summary::before { transition: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user