phase: 87_big_read_progress
Build and Push Containers / build-and-push-app (push) Successful in 2m48s
Build and Push Containers / build-and-push-db (push) Successful in 19s

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:
2026-09-08 05:51:23 -04:00
parent 0f6b9ff7e6
commit 7cfe58fb21
24 changed files with 1650 additions and 0 deletions
+96
View File
@@ -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
+32
View File
@@ -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; }
}