Compare commits
2
Commits
7baca3d289
...
894637108c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894637108c | ||
|
|
e2bed52751 |
@@ -0,0 +1,55 @@
|
||||
# Phase 85 — Keep the mobile menu reachable while the token gate is up
|
||||
|
||||
**Source:** `TODO.md` L3 — "Only on the chat page, and only when navigating there directly, does the hamburger menu on mobile not work. It works on every other page, but clicking it from the chat page does not expand the menu. This makes the menu inaccessible on mobile. Note that when logging in the hamburger menu works as expected, so it's something specifically about the chat page that breaks it."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L3, 2026-09-07)
|
||||
**Context:** `frontend/assets/token-gate.js` (phase 79 task 05 — `mountGate(lockRoot, onAuthed)`: anonymous → the gate is revealed and `lockRoot.inert = true` (`#main`); the phase-79 contract line "the gate is the only interactive surface; the header is locked out with the rest of the page"), `frontend/index.html` (the body-level `<section class="auth-gate" id="auth-gate" hidden inert>` after `#main` — the shell's ONE gate; the six navbar views all share it) + `frontend/document.html` (the same markup as `#doc-auth-gate`), `frontend/assets/styles.css` (`.auth-gate { position: fixed; inset: 0; z-index: 500; … }` — the stacking comment above the rule; `.app-header` is `position: sticky; z-index: 20` (global rule near the top of the file); the mobile dropdown `.app-nav.is-open` carries `z-index: 21` INSIDE the header's stacking context in the `@media (max-width: 640px)` block; the skip-link sits at `z-index: 100`, overlays at 1000 — none of those are affected), `frontend/assets/header.js` (the module-level hamburger binding — phase 46: `#nav-toggle` click toggles `#app-nav .is-open` + `aria-expanded`; Esc/link/resize close; the binding is independent of the gate and of `initSharedHeader`), `tests/e2e/test_mobile_hamburger_nav.py` (phase 46 suite — its `_js_open_menu` helper exists precisely because "a real click on #nav-toggle is intercepted by the gate" for anonymous visitors), `tests/e2e/test_api_tokens.py` (phase 79 suite — no hamburger pins; the gate contract pins stay).
|
||||
|
||||
## Bug basis (confirmed by live reproduction, 2026-09-07 — headless Chromium, 375×812)
|
||||
- **Anonymous visitor, any shell view** (`/`, `/tuning.html`, `/history.html`): the token gate is a full-viewport overlay (`position: fixed; inset: 0; z-index: 500`) that sits ABOVE the sticky header (`z-index: 20`). `document.elementFromPoint` at the `#nav-toggle` center returns `#auth-gate` in every case — a real tap on the hamburger is intercepted by the gate, and a Playwright real click times out (the click never reaches the button). The menu is therefore **inaccessible on mobile for every unauthenticated visitor**.
|
||||
- **Signed-in admin, same viewport:** the gate ships hidden and the hamburger works on every view — direct loads of `/`, `/tuning.html`, `/history.html` and the SPA switch tuning→chat all open the menu with a real click (verified). The owner's "only on the chat page" perception is the entry point: the chat page is where a visitor lands, meets the gate, and finds the dead hamburger.
|
||||
- **`login.html`** is a separate document with no gate (phase 79 left it untouched) — the toggle is tappable there. This matches the owner's "when logging in the hamburger menu works as expected".
|
||||
- The gate landed in phase 79 (2026-09-07 — the same day this TODO was written); before it, anonymous visitors met the soft-gated chat page and the hamburger worked everywhere.
|
||||
|
||||
## Objective
|
||||
An unauthenticated visitor who meets the token gate can still open the mobile menu: the gate stays the app's lock (`#main` inert, the APIs token-gated) but no longer physically covers the header — the hamburger and its dropdown remain tappable and render above the gate, and the anonymous menu shows exactly what it always showed (the Chat link + the sign-in copy — the whoami ship-hidden contract untouched). Nothing new is exposed: the only tappable header controls for an anonymous visitor are the hamburger (menu: Chat + sign in) and the brand.
|
||||
|
||||
## Owner decisions (chat, 2026-09-07 — confirmed with the roadmap, recorded per AGENTS.md rule 3)
|
||||
- **A1 — the fix is pure CSS stacking.** `.auth-gate` moves from `z-index: 500` to **`z-index: 15`** — below the header's 20, so the header (and the open dropdown's 21, inside the header's stacking context) paint above the gate and taps on the bar reach the bar. The gate keeps `position: fixed; inset: 0` (it still covers and locks the app content — `#main` stays `inert`; the token card stays the only CONTENT-level interactive surface). This **revises the phase-79 locked line** "the gate is the only interactive surface; the header is locked out with the rest of the page" — the header is no longer locked out, at the owner's request (the TODO item). No JS behavior in `token-gate.js` changes: mount, silent re-auth, role check, focus, lock/unlock are all byte-identical.
|
||||
- **A2 — the document viewer's gate gets the same treatment.** `#doc-auth-gate` (document.html) is the same `.auth-gate` class, so the one CSS rule covers it: the viewer's row-1 bar (the same `.app-header` class) stays tappable while its gate is up. No separate markup or rule.
|
||||
- **A3 — docstrings/tests follow the new contract.** Every docstring and test helper that asserts the gate "covers the header" / "intercepts the toggle" is updated to the revised contract (header tappable, menu reachable, `#main` still inert). The phase-46 E2E's `_js_open_menu` programmatic drive becomes a REAL click for anonymous visitors.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **`frontend/assets/styles.css`** — ONE rule changes: in the `.auth-gate` rule (the stacking comment above it says "z-index 500 — above the app content (the sticky header is …)") the value becomes `z-index: 15`, and the comment is rewritten: the gate (15) sits ABOVE the app content (static) but BELOW the sticky header (20) and its mobile dropdown (21 inside the header's stacking context) — the phase-85 revision of the phase-79 "gate covers the header" contract: an unauthenticated visitor keeps a reachable menu while `#main` stays inert-locked. The skip-link (100) and the 1000-family overlays (modals, doc-modal, sync backdrop) are unaffected (all above 15).
|
||||
- **`frontend/index.html` / `frontend/document.html`** — if the gate section's HTML comments assert the overlay covers the header, update the wording to the new stacking (comment-only — the markup, ids, classes, and the ship-hidden `hidden inert` pair are untouched).
|
||||
- **`frontend/assets/token-gate.js`** — docstring only: the module header's description of the anonymous state ("the gate is revealed … the locked app must not receive focus or keyboard traversal") keeps its `#main`-locking language; any line asserting the header is covered/locked is reworded (the lock root is and always was `#main` — the header was never `inert`; it was only visually covered).
|
||||
- **`tests/e2e/test_mobile_hamburger_nav.py`** — `_js_open_menu` becomes a real click (the anonymous contract: `page.click("#nav-toggle")` opens the menu exactly as for admin); the helper docstring and the four test docstrings that cite the interception are updated (phase-85: the gate no longer intercepts the toggle — the menu is the header's, the gate covers only `#main`); a NEW test pins the exact TODO regression: anonymous at 375px, `document.elementFromPoint` at the toggle center resolves to the toggle (or a child of it), never `#auth-gate`, and the real click opens the menu (`aria-expanded="true"`, `.is-open`, opacity 1) with the anonymous contents (exactly one visible nav link — "Chat" — the admin links ship-hidden inside the menu, the sign-in copy visible).
|
||||
- **`tests/e2e/test_api_tokens.py`** — no hamburger pins exist (verified by grep: no `nav-toggle` / `intercept` references); the gate contract pins (gate visible for anonymous, hidden + `#main` unlocked after a valid token, wrong-token error, revocation) must stay green UNCHANGED — this phase's E2E gate runs this suite in isolation as the regression proof that the stacking change broke nothing about the lock.
|
||||
- **Not touched:** `header.js` (the binding is correct — the button was never unbound; it was unreachable), the gate's markup/ids, the whoami ship-hidden contract, the `#main` inert contract, everything server-side.
|
||||
|
||||
## Dependencies
|
||||
- `84_docs_push_error_sanitization` (todo) — pipeline predecessor (execution order) only; NO code dependency: this phase touches frontend CSS/comments, two E2E suites, and nothing in `app/`.
|
||||
|
||||
## Tasks
|
||||
1. `01_gate_below_header.md` — the z-index restack + comment/docstring alignment + the source-level unit pin.
|
||||
2. `02_e2e_pin_updates.md` — the phase-46 suite's real-click conversion + the new TODO-regression test + the docstring sweep.
|
||||
3. `03_verify_and_commit.md` — full gate (unit + integration + coverage, BOTH E2E suites in isolation, smoke, ruff + pyright) + atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit — new source-level pin (house pattern — read the CSS text, no browser): the `.auth-gate` rule carries `z-index: 15` and the `.app-header` rule carries `z-index: 20` (the "gate below the header" contract — the regression that put the gate at 500 must not return). Lives in the new `tests/unit/test_gate_header_stacking.py` (or an existing CSS-reading unit module if the executor finds one — the `tests/unit/test_hamburger_nav.py` style).
|
||||
- E2E (isolation gates per AGENTS.md rule 9): `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green in isolation (the phase's own suite — real clicks for anonymous now) AND `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation (the phase-79 lock contract unchanged) AND `uv run pytest tests/e2e/test_smoke.py -v --no-cov` green.
|
||||
- Coverage: **>90%** on `app/` — no `app/` code changes in this phase (the floor is held by the untouched suite).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Anonymous at 375×812 on `/` (and `/history.html`): a REAL click on `#nav-toggle` opens the menu (the TODO item's exact scenario — "clicking it … does not expand the menu" is fixed); `elementFromPoint` at the toggle center never returns `#auth-gate`.
|
||||
- [ ] The open dropdown renders ABOVE the gate (z-order pin in the E2E: with the gate up and the menu open, the menu's computed z context sits above the gate's 15 — probed via the menu being visible + clickable link targets).
|
||||
- [ ] The anonymous menu contents are unchanged: exactly one visible nav link (Chat) + the sign-in copy; admin links ship-hidden; the gate card (token input + Sign in + "Sign in as admin") is untouched and still the only content-level interactive surface (`#main` `inert` — phase-79 pins green).
|
||||
- [ ] Signed-in admin behavior is byte-identical (phase-46 suite green — the admin tests already used real clicks).
|
||||
- [ ] The document viewer's gate (`#doc-auth-gate`) sits below the viewer's bar too (same rule — covered by the CSS pin; `tests/e2e/test_document_viewer.py` stays green).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; both E2E suites + smoke green in isolation; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `git diff --stat` limited to `frontend/assets/styles.css`, `frontend/index.html` (comments only), `frontend/document.html` (comments only), `frontend/assets/token-gate.js` (docstring only), the two E2E files, the new unit test, phase files — nothing in `app/`.
|
||||
- [ ] One atomic `--no-gpg-sign` commit (e.g. `fix(ui): keep the mobile menu reachable while the token gate is up`); phase dir moved to `.agents/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **`#main` stays inert while the gate is up** (A1) — the phase-79 WCAG lock contract (the locked app must not receive focus or keyboard traversal) is intact; only the header's VISUAL lock (the overlay covering it) is lifted.
|
||||
- **No gate markup/behavior change** — `token-gate.js` logic is byte-identical; the phase-79 E2E suite (token gate, revocation, wrong-token, silent re-auth) passes unchanged.
|
||||
- **One CSS value is the whole fix** — `z-index: 500 → 15` on `.auth-gate`; no new elements, no new classes, no JS.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Task 01 — Restack the gate below the header
|
||||
|
||||
**Phase:** `85_mobile_menu_gate_overlap` · **Source:** `TODO.md:3` — "Only on the chat page, and only when navigating there directly, does the hamburger menu on mobile not work. … This makes the menu inaccessible on mobile. Note that when logging in the hamburger menu works as expected, so it's something specifically about the chat page that breaks it."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L3)
|
||||
|
||||
## Objective
|
||||
The gate no longer physically covers the header: one z-index value moves it from above the sticky bar to below it, so the hamburger and its dropdown are tappable (and paint correctly) for every unauthenticated visitor, while the gate still covers and locks the app content.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — in the `.auth-gate` rule (find it via the stacking comment "z-index 500 — above the app content"; the rule sets `position: fixed; inset: 0`) change `z-index: 500` → `z-index: 15`. Rewrite that comment block to state the phase-85 stacking contract: **15** (gate) < **20** (`.app-header`, sticky) — the bar and the mobile dropdown (**21**, inside the header's stacking context) paint above the gate; the skip-link (**100**) and the **1000**-family overlays (doc-modal, sync backdrop, the `.auth-gate`'s former neighborhood) are unaffected; the gate still covers all app CONTENT (static) and `#main` stays `inert` (the lock is JS, not z-order — phase 79).
|
||||
2. `frontend/index.html` — the gate section's comment (the block above `<section class="auth-gate" id="auth-gate" hidden inert>`): if it asserts the overlay covers the header, reword to the new contract (the gate covers `#main`'s content region visually; the header stays reachable — phase 85, TODO.md L3). Comment-only; the markup, ids, classes, and the `hidden inert` ship-hidden pair are byte-identical.
|
||||
3. `frontend/document.html` — the same comment alignment for `#doc-auth-gate` (same class, same rule — A2). Comment-only.
|
||||
4. `frontend/assets/token-gate.js` — module docstring sweep: keep every `#main`-locking sentence (the inert contract is unchanged); reword any sentence asserting the header is covered/locked-out (it was only visually covered — the lock root was and is `#main`). Logic byte-identical.
|
||||
5. `tests/unit/test_gate_header_stacking.py` (new — house source-level pattern, the `tests/unit/test_hamburger_nav.py` style: read `frontend/assets/styles.css` as text, no browser):
|
||||
- the `.auth-gate` rule's declaration block carries `z-index: 15` (regex on the rule text between the selector and its closing brace);
|
||||
- the `.app-header` rule carries `z-index: 20` (the bar must stay above the gate);
|
||||
- the mobile dropdown rule (`.app-nav.is-open` in the `max-width: 640px` block) keeps `z-index: 21` (the open menu paints above the bar content).
|
||||
- A short module docstring cites the bug basis (the gate at 500 intercepted the toggle — TODO.md L3) so the pin's intent survives.
|
||||
6. `- ASSUMPTION: the value 15 is the choice (any integer below 20 and above the static app content works; 15 sits below the header's 20 with headroom and is pinned by the unit test — the executor does not pick a different number).`
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `uv run pytest tests/unit/test_gate_header_stacking.py -v` green (the three z-order pins).
|
||||
- No integration test needed (no `app/` change); the existing integration suite must stay green (`uv run pytest -q` as part of the phase gate in task 03).
|
||||
- Coverage: **>90%** on `app/` is unaffected (no `app/` lines change).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `grep -n "z-index: 15" frontend/assets/styles.css` shows the `.auth-gate` rule; `grep -n "z-index: 500"` no longer matches any `.auth-gate` declaration.
|
||||
- [ ] `uv run pytest tests/unit/test_gate_header_stacking.py -v` green.
|
||||
- [ ] `git diff --stat -- frontend/` limited to the four files above (CSS value + comments/docstrings only); the gate markup in both HTML files is byte-identical (`git diff frontend/index.html frontend/document.html` shows comment lines only); `token-gate.js` diff is docstring-only.
|
||||
- [ ] No behavior change in completed work (the phase-46/79 behavior pins are exercised in task 02/03).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 02 — Convert the E2E pins to the real-click contract
|
||||
|
||||
**Phase:** `85_mobile_menu_gate_overlap` · **Source:** `TODO.md:3` — "… clicking it from the chat page does not expand the menu. This makes the menu inaccessible on mobile. …"
|
||||
**Story:** n/a (owner bug report — `TODO.md` L3)
|
||||
|
||||
## Objective
|
||||
The phase-46 hamburger suite drives the anonymous menu with REAL clicks (the programmatic workaround existed only because the gate intercepted the toggle — it no longer does), and a new test pins the exact TODO regression: an anonymous visitor's toggle is tappable and the menu opens above the gate.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_mobile_hamburger_nav.py` —
|
||||
- `_js_open_menu`: convert the body to a REAL click — `page.click("#nav-toggle")` (keep the three assertions: `aria-expanded="true"`, `.is-open` class, opacity 1). Rewrite the helper docstring: phase 85 — the gate (z 15) sits BELOW the header (z 20), so a real tap reaches the toggle for anonymous visitors too; the phase-79 "intercepted by the gate" workaround is retired.
|
||||
- Every test that called `_js_open_menu` (`test_anonymous_menu_contents`, `test_esc_and_outside_close`, `test_animation_and_reduced_motion` — both contexts) switches to the real-click helper. Keep the assertions EXACTLY as they are (anonymous menu = exactly one visible link "Chat", admin links hidden inside the menu, Esc closes + refocuses the toggle, outside click does NOT close — the gate overlay at point (10, 780) is still the "outside" surface for anonymous, the menu stays open, Esc settles it; the 180ms transition pins unchanged).
|
||||
- The module docstring's contract paragraph (the phase-79 sentence about the full-viewport overlay covering the header) is updated to the phase-85 stacking.
|
||||
- **NEW test `test_anonymous_toggle_tappable_with_gate_up`** (the TODO regression pin, placed after `test_anonymous_menu_contents`): anonymous at 375×812 on `/` with the gate up (wait for the settled anonymous state — the existing `_wait_settled_anonymous`):
|
||||
- `document.elementFromPoint` at the `#nav-toggle` center resolves to the toggle itself or a descendant of it (the SVG path) — assert it is NOT `#auth-gate` (this is the exact probe that returned `#auth-gate` in the bug reproduction);
|
||||
- a real `page.click("#nav-toggle")` opens the menu (`aria-expanded="true"`, `.is-open`, computed opacity 1) — and the menu is ABOVE the gate: the "Chat" link is visible and `is_visible()` true with the gate still up (the dropdown's z context (21 in the header's 20) paints over the gate's 15);
|
||||
- close with Esc (the settled-closed state) so the test leaves the page clean.
|
||||
- Add the new test to the module docstring's Test → story mapping list (numbered entry, one line: anonymous toggle tappable with the gate up — elementFromPoint never `#auth-gate`, real click opens the menu above the gate).
|
||||
2. `tests/e2e/test_api_tokens.py` — verify-only task: the suite carries NO hamburger pins (grep confirms no `nav-toggle`/`intercept` references). Run it in isolation and confirm green UNCHANGED — it is the regression proof that the restack did not alter the gate's lock contract (gate visible for anonymous, valid token → gate hidden + `#main` unlocked, wrong token → `role="alert"`, revocation). If any assertion unexpectedly fails because of the z-order (e.g. a "header is covered" style probe), update THAT assertion to the phase-85 contract (document it in the diff comment) — but do not weaken the lock assertions (`#main` inert, token flow) under any circumstance.
|
||||
3. `- ASSUMPTION: no new conftest machinery — the new test reuses the existing session `browser` fixture + the 375×812 page pattern (`_mobile_page`) and `_wait_settled_anonymous`; the suite header's run-in-isolation line is unchanged.`
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (this task's gate): `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green **in isolation** (DB up — the conftest `db_ready` fixture; the suite needs the mock LLM only for the login tests, which the conftest provides).
|
||||
- E2E (regression proof): `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green **in isolation**, file unchanged (or with documented phase-85 assertion updates only, if item 2's verification demanded them).
|
||||
- Coverage: >90% on `app/` unaffected (test-only task).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `grep -n "querySelector('#nav-toggle').click()" tests/e2e/test_mobile_hamburger_nav.py` returns nothing (the JS-dispatched drive is gone); the helper uses `page.click("#nav-toggle")`.
|
||||
- [ ] `test_anonymous_toggle_tappable_with_gate_up` exists, pins the `elementFromPoint`-not-`#auth-gate` probe, and passes in isolation; the module docstring mapping lists it.
|
||||
- [ ] `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` and `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` both green in isolation.
|
||||
- [ ] No change to `app/`, `frontend/`, or conftest machinery.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 03 — Full gate + atomic commit
|
||||
|
||||
**Phase:** `85_mobile_menu_gate_overlap` · **Source:** `TODO.md:3` — the mobile-menu bug report (TODO.md L3)
|
||||
**Story:** n/a (owner bug report — `TODO.md` L3)
|
||||
|
||||
## Objective
|
||||
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
|
||||
|
||||
## Work
|
||||
1. **Full regression gate** (AGENTS.md rule 9):
|
||||
- `uv run pytest` — unit + integration green.
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%** (no `app/` change this phase — the floor is held by the untouched suite; confirm the report).
|
||||
- `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` — green **in isolation** (this phase's E2E contract — the anonymous real-click menu above the gate).
|
||||
- `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` — green **in isolation** (the phase-79 gate lock contract — the restack's regression proof).
|
||||
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green in isolation.
|
||||
- `uv run ruff check . && uv run pyright` — clean.
|
||||
2. **Manual live check** (keep the output in the session log): start the dev server (`uv run uvicorn app.main:app`), open `http://localhost:8000/` at a 375px viewport (browser devtools device mode or the suite's own probe), NOT signed in: tap the hamburger → the menu opens above the gate (Chat + sign-in visible, the token card behind/below the bar); type a wrong token in the gate → the error line shows and the menu state is independent (Esc closes the menu, the gate stays). Sign in as admin → the menu behaves exactly as before. (The automated pins in task 02 cover this; the live check confirms the real-device-feeling stacking.)
|
||||
3. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `frontend/assets/styles.css`, `frontend/index.html`, `frontend/document.html`, `frontend/assets/token-gate.js`, `tests/e2e/test_mobile_hamburger_nav.py`, `tests/e2e/test_api_tokens.py` (only if task 02 item 2 required documented assertion updates), `tests/unit/test_gate_header_stacking.py`, and the phase files:
|
||||
`fix(ui): keep the mobile menu reachable while the token gate is up`
|
||||
— body: TODO.md L3 — the phase-79 token gate (z 500, fixed full-viewport) sat above the sticky header (z 20), so an unauthenticated visitor's tap on the mobile hamburger hit the gate overlay and the menu was unreachable until login (login.html is gate-free, which is why "logging in" worked). The gate now sits at z 15 — below the header and its mobile dropdown (21) — while `#main` stays inert-locked and the gate's markup/behavior is byte-identical. Phase-46 suite converted to real clicks + a new pin (elementFromPoint at the toggle never resolves to the gate); phase-79 suite green unchanged.
|
||||
4. Move the phase directory: `mv .agents/phases/todo/85_mobile_menu_gate_overlap .agents/phases/complete/` and include the move in the same commit.
|
||||
|
||||
## Testing & Quality
|
||||
- This task IS the phase-level gate — the commands above are the completion evidence.
|
||||
- Coverage: >90% held.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All five gate commands green (unit + integration, coverage >90%, both E2E suites in isolation, smoke, ruff + pyright).
|
||||
- [ ] The live check shows the anonymous menu opening above the gate at 375px (output kept in the session log).
|
||||
- [ ] Exactly one new commit; `git show --stat HEAD` lists the staged files above + the phase files (todo → complete move) — nothing else (in particular `app/`, `pyproject.toml` / `uv.lock` untouched).
|
||||
- [ ] `.agents/phases/complete/85_mobile_menu_gate_overlap/` exists; `todo/` no longer contains it.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Phase 86 — Stop the table pages from stretching the document to the table's width
|
||||
|
||||
**Source:** `TODO.md` L4 — "The history page appears to be the width of the table despite the table being scrollable. On mobile this results in half the page being blank and awkwardly scrollable."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L4, 2026-09-07)
|
||||
**Context:** `frontend/index.html` — `#view-history` (the History shell view: `.container.history-shell > .table-wrap.history-table-wrap#history-table-wrap > table.history-table`, whose Actions column header is `<th scope="col"><span class="visually-hidden">Actions</span></th>` and whose `<caption class="visually-hidden">` sits first) and `#view-tokens` (the SAME pattern: `.table-wrap#tokens-table-wrap > table#tokens-table` with the identical visually-hidden Actions header + caption); the RAG view's `.docs-table` (visible text headers — NO hidden spans), `frontend/assets/styles.css` — `.visually-hidden` (the global a11y helper: `position: absolute !important; width: 1px; height: 1px; margin: -1px; … clip: rect(0 0 0 0); overflow: hidden`), `.table-wrap` (the shared scroll card: `overflow-x: auto` — the phase-07 responsive contract), `.history-table` / `.tokens-table` (`width: 100%; min-width: 640px`), `tests/e2e/test_chat_history.py` + `tests/e2e/test_api_tokens.py` (the existing History/Tokens behavior suites — this phase adds no behavior, only fixes the page-level overflow).
|
||||
|
||||
## Bug basis (confirmed by live reproduction, 2026-09-07 — headless Chromium, 375×812, signed-in admin)
|
||||
- **Direct load of `/history.html`:** `document.documentElement.scrollWidth` = **626** vs `innerWidth` 375 — the WHOLE PAGE pans ~250px to the right into a blank region (the owner's "half the page being blank and awkwardly scrollable"; `elementFromPoint` at the far right returns `null` — nothing is painted there). The scroll card itself is fine: `#history-table-wrap` is 346px wide with `overflow-x: auto` and its own `scrollWidth` 640 — the table scrolls INSIDE the card correctly.
|
||||
- **The culprit:** `<span class="visually-hidden">Actions</span>` in the table header. `.visually-hidden` is `position: absolute !important` and NO ancestor in the chain (th → table → `.table-wrap` → `.history-shell` → `.view` → `#main` → `body`) is positioned, so the span's containing block is the **initial containing block**. The span's 1px box sits at its static position — the right edge of the 640px table (measured left ≈ 625.75px) — and, as a positioned box whose containing block is the ICB, it contributes to the **document's** scrollable overflow, bypassing the card's scroll clipping. `document.scrollWidth` (626) equals the span's right edge to the pixel.
|
||||
- **Decisive bisection:** hiding that span → `scrollWidth` 375. Making `#history-table-wrap` `position: relative` → `scrollWidth` 375 (the span's containing block becomes the card; its box is contained in the card's scroll area). A plain 640px block inside the card never leaks (the leak is specific to the positioned hidden span).
|
||||
- **The Tokens view has the identical bug** (same visually-hidden Actions header + caption in `#tokens-table`): measured `scrollWidth` 618; the same one-property fix brings it to 375. The owner reported History; Tokens is folded in (owner-confirmed in the roadmap, A3).
|
||||
- **The RAG view is clean** (measured 375 — `.docs-table` has visible text headers, no positioned hidden spans).
|
||||
- A standalone minimal page with the app's exact nesting (flex columns, the container, the card, a 640px table) does NOT leak — the trigger is the positioned `.visually-hidden` span inside the table, which is why the fix is the containing block, not any flex/width property.
|
||||
|
||||
## Objective
|
||||
On mobile (and any viewport narrower than the tables' 640px min-width) the History and Tokens pages are viewport-width: the table scrolls inside its `.table-wrap` card (the phase-07 contract, AGENTS.md rule 5 — full-width tables stay) and the document itself no longer pans into a blank region.
|
||||
|
||||
## Owner decisions (chat, 2026-09-07 — confirmed with the roadmap, recorded per AGENTS.md rule 3)
|
||||
- **A3 — the fix targets the shared `.table-wrap` card, not just History.** One rule (`position: relative`) on `.table-wrap` fixes BOTH affected views (History, Tokens) and any future table that ships a hidden header span in the card. No markup change: the visually-hidden spans stay (they are the accessible column name / table caption — removing them would break the a11y contract); no JS change.
|
||||
- **Scope fold-in:** the owner's TODO item names the History page; the Tokens view carries the byte-identical defect (confirmed by measurement) and is fixed + pinned by the same phase — reported in this phase's docs, not as a separate phase (one coherent root cause, one rule).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **`frontend/assets/styles.css`** — in the `.table-wrap` rule (the shared scroll card, the rule that sets `overflow-x: auto` near the "pill language" comment block) add **`position: relative;`** with a root-cause comment: the card must be the containing block for the `position: absolute` `.visually-hidden` spans it hosts (the table caption + the Actions column header) — without it their 1px boxes are positioned against the initial containing block and leak into the DOCUMENT's scrollable overflow (the History page appeared "the width of the table", TODO.md L4; the Tokens view had the identical defect). Zero-offset positioning changes no layout; the spans stay clipped by their own `clip: rect(0 0 0 0)` + 1px box; every card that does NOT host such spans (the RAG `.docs-table`, the git-sources table) is visually unchanged.
|
||||
- **Not touched:** the markup of both views, `.visually-hidden` itself (used across the app — the chat labels, announcers, skip-link neighborhood), `.md-table-wrap` (chat answer tables — different class, different context), any JS, any server code.
|
||||
- **Unit pin (source-level, house pattern):** the `.table-wrap` rule carries `position: relative` (the containing-block contract — the regression that omitted it must not return).
|
||||
- **E2E story (new suite, one file per story per AGENTS.md rule 4):** `tests/e2e/test_history_page_width.py` — the page-width contract at 375px for History AND Tokens (direct loads + SPA switch), the in-card scroll preserved, the RAG view regression, and the desktop (1280×800) no-overflow regression.
|
||||
|
||||
## Dependencies
|
||||
- `85_mobile_menu_gate_overlap` (todo) — pipeline predecessor (execution order) only; NO code dependency (CSS + E2E only; touches no file that phase 85 owns except `frontend/assets/styles.css` — different rules, no overlap).
|
||||
|
||||
## Tasks
|
||||
1. `01_table_wrap_fix.md` — the one-rule CSS fix + the source-level unit pin.
|
||||
2. `02_e2e_story_suite.md` — `tests/e2e/test_history_page_width.py` (History + Tokens + RAG + desktop).
|
||||
3. `03_verify_and_commit.md` — full gate (suite + coverage + the new E2E story in isolation + the two adjacent suites in isolation, smoke, ruff + pyright) + atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit — `tests/unit/test_table_wrap_stacking.py` (new, source-level): the `.table-wrap` rule's declaration block contains `position: relative` (regex on the rule text); module docstring cites the mechanism (positioned `.visually-hidden` span + unpositioned scroll card → document-level scroll leak — TODO.md L4).
|
||||
- E2E — `tests/e2e/test_history_page_width.py` (new; isolation gate per AGENTS.md rule 9): admin at 375×812 — direct `/history.html` and `/tokens.html`: `document.documentElement.scrollWidth <= window.innerWidth` (no page-level pan); `#history-table-wrap` / `#tokens-table-wrap` still scroll inside (`scrollWidth > clientWidth` — the 640px table, AGENTS.md rule 5 full-width contract); SPA switch history→tokens keeps the invariant; `/sources.html` (RAG) regression: `scrollWidth <= innerWidth` AND its table still full-width; desktop 1280×800: no overflow on any of the three views and the tables render at container width.
|
||||
- Regression suites (run in isolation by task 03): `tests/e2e/test_chat_history.py` (History behavior) and `tests/e2e/test_api_tokens.py` (Tokens behavior) green unchanged.
|
||||
- Coverage: **>90%** on `app/` — no `app/` code changes (the floor is held by the untouched suite).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] At 375px, `/history.html` and `/tokens.html` (direct + SPA): `documentElement.scrollWidth <= innerWidth` (the E2E pins) — the owner's "half the page being blank" is gone.
|
||||
- [ ] The tables still scroll INSIDE their cards (`wrap.scrollWidth > wrap.clientWidth` — pinned) and stay full-width per AGENTS.md rule 5; the visually-hidden Actions header/caption remain in the DOM (a11y names intact).
|
||||
- [ ] `/sources.html` and the desktop viewports are byte-identical in behavior (regression pins green).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; the new E2E story + `test_chat_history.py` + `test_api_tokens.py` + `test_smoke.py` green in isolation; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `git diff --stat` limited to `frontend/assets/styles.css`, the new unit test, the new E2E file, phase files — no markup or JS changes.
|
||||
- [ ] One atomic `--no-gpg-sign` commit (e.g. `fix(ui): stop the History and Tokens tables from stretching the document width`); phase dir moved to `.agents/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **One CSS property is the whole fix** — `position: relative` on `.table-wrap` (A3); the containing-block mechanism is pinned by the unit test + the E2E scrollWidth contract.
|
||||
- **The a11y spans stay** — the visually-hidden caption + Actions header are the accessible names; the fix repositions their CONTAINING BLOCK, not their existence.
|
||||
- **No `app/` change** — pure frontend; the coverage floor is held by the untouched suite.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Task 01 — The one-rule fix: make the scroll card the containing block
|
||||
|
||||
**Phase:** `86_history_page_width` · **Source:** `TODO.md:4` — "The history page appears to be the width of the table despite the table being scrollable. On mobile this results in half the page being blank and awkwardly scrollable."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L4)
|
||||
|
||||
## Objective
|
||||
`document.documentElement.scrollWidth` on the History (and Tokens) page at 375px drops from 626/618 to the viewport width, because the card that hosts the tables becomes the containing block for the positioned `.visually-hidden` spans instead of the initial containing block.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — in the `.table-wrap` rule (the shared scroll card: the rule declaring `background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow-x: auto;`) add **`position: relative;`** as the first declaration, with a comment above it (house comment style, citing the phase):
|
||||
- the card is the containing block for the `position: absolute` `.visually-hidden` elements it hosts (the table `<caption>` and the Actions column header span in `#view-history` / `#view-tokens`);
|
||||
- without a positioned ancestor their 1px boxes are laid out against the initial containing block — at the 640px table's right edge — and leak into the DOCUMENT's scrollable overflow (the page appeared "the width of the table", TODO.md L4; the Tokens view carried the identical defect);
|
||||
- zero-offset positioning changes no layout; the spans stay clipped by their own `clip: rect(0 0 0 0)` + 1px box; cards without such spans (the RAG `.docs-table`, the git-sources table) render unchanged.
|
||||
2. `tests/unit/test_table_wrap_stacking.py` (new — house source-level pattern, the `tests/unit/test_hamburger_nav.py` style: read `frontend/assets/styles.css` as text, no browser):
|
||||
- extract the `.table-wrap` rule's declaration block (the block opened by the selector `.table-wrap` — NOT `.md-table-wrap` and NOT the `#git-sources-table-wrap` / `#tokens-table-wrap` id rules: match the exact selector, e.g. the selector text is exactly `.table-wrap`);
|
||||
- assert the block contains `position: relative` (regex `position:\s*relative`);
|
||||
- assert the same block still contains `overflow-x: auto` (the scroll contract the fix preserves);
|
||||
- module docstring: the mechanism + the measured bug basis (History 626 / Tokens 618 / RAG 375 at 375px, 2026-09-07) so the pin's intent survives.
|
||||
3. `- ASSUMPTION: the property is added to the shared .table-wrap rule (not to .history-table-wrap / #tokens-table-wrap individually) — owner-confirmed A3; if the executor finds the rule already position:relative (it is not, verified 2026-09-07), stop and flag instead of proceeding.`
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `uv run pytest tests/unit/test_table_wrap_stacking.py -v` green (the two pins).
|
||||
- No integration test needed (no `app/` change); the existing suite must stay green (phase gate in task 03).
|
||||
- Coverage: **>90%** on `app/` unaffected.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The `.table-wrap` rule reads `position: relative;` first, `overflow-x: auto;` still present; no other rule in the file changed.
|
||||
- [ ] `uv run pytest tests/unit/test_table_wrap_stacking.py -v` green.
|
||||
- [ ] `git diff --stat -- frontend/` shows ONLY `frontend/assets/styles.css` (one property + comment).
|
||||
- [ ] No behavior change in completed work (verified by the E2E gates in tasks 02/03).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 02 — The E2E story: page-width contract for the table views
|
||||
|
||||
**Phase:** `86_history_page_width` · **Source:** `TODO.md:4` — "The history page appears to be the width of the table despite the table being scrollable. On mobile this results in half the page being blank and awkwardly scrollable."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L4)
|
||||
|
||||
## Objective
|
||||
A dedicated Playwright suite (one file per story, run in isolation — AGENTS.md rule 4/9) pins the fixed contract: at 375px the History and Tokens pages are viewport-width (no document-level pan), their tables still scroll inside the card, the RAG view and the desktop layout are unchanged.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_history_page_width.py` (new) — house E2E conventions (module docstring with the Source line, the run-in-isolation command `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` — DB up, and the Test → story mapping list; a fresh 375×812 page via the session `browser` fixture, the `login` helper from `e2e.auth_helpers` for the admin, `DESKTOP = 1280×800` for the regression):
|
||||
- **`test_history_page_is_viewport_width`** — admin at 375×812, direct `page.goto(app_url + "/history.html")`, wait for the settled admin state (the existing pattern: a `wait_for_function` on the admin nav link reveal, copied from `test_mobile_hamburger_nav.py`'s `_wait_settled_admin`):
|
||||
- `document.documentElement.scrollWidth <= window.innerWidth` (THE pin — 626 → ≤375);
|
||||
- `#history-table-wrap`: `scrollWidth > clientWidth` (the 640px table still scrolls INSIDE the card — the phase-07 / AGENTS.md-rule-5 full-width contract preserved) and `clientWidth` equals the container's content width (≤ `innerWidth`);
|
||||
- the visually-hidden Actions header + caption are still in the DOM (`#history-table-wrap th .visually-hidden` and `caption.visually-hidden` present — the a11y names survived the fix).
|
||||
- **`test_tokens_page_is_viewport_width`** — the same two pins for `/tokens.html` (direct load; `#tokens-table-wrap`) — the folded-in identical defect (measured 618 pre-fix).
|
||||
- **`test_spa_switch_keeps_page_width`** — from `/history.html`, open the mobile menu (`page.click("#nav-toggle")` — real click; the phase-85 contract, or the desktop inline nav at 375px… NOTE: at 375px the nav links live in the dropdown, so open the menu first), click the "Chat" link to land on `/`, then click the History nav link back — hmm, simpler and sharper: at 375px open the menu on `/history.html`, click a nav link to `/tokens.html` (SPA — no document load: assert the URL changed WITHOUT a new document — `performance.getEntriesByType('navigation')` length unchanged or the router's pushState contract), and re-assert `scrollWidth <= innerWidth` on the Tokens view. (Keeps the invariant across the router, not just direct loads.)
|
||||
- **`test_rag_view_regression`** — `/sources.html` at 375px: `scrollWidth <= innerWidth` (was already clean — pinned so the shared-rule change cannot regress it) AND the RAG table is still full-width inside its card (the `.table-wrap` containing the `.docs-table`: `scrollWidth > clientWidth` on a 375px viewport — the 640px `min-width` still engages).
|
||||
- **`test_desktop_unchanged`** — 1280×800 admin: on `/history.html`, `/tokens.html`, `/sources.html` — `scrollWidth <= innerWidth` (no overflow at any width) and each table's `clientWidth` > the mobile 346px (the tables render at container width, no card-internal scroll needed — `wrap.scrollWidth <= wrap.clientWidth + 1`).
|
||||
- Shared helpers in the module: `_mobile_page(browser)`, `_wait_settled_admin(page)` (copied, not imported — house style keeps suites self-contained; `auth_helpers.login` IS imported, as every suite does).
|
||||
2. `- ASSUMPTION: the SPA-switch test asserts the router contract via the URL + a re-probe of scrollWidth (not via a navigation-timeline count) — the router's pushState leaves one document; if the executor finds a cleaner existing probe in the phase-76/77 E2E suites (e.g. test_nav_switch_keeps_stream.py), reuse that pattern verbatim.`
|
||||
3. `- ASSUMPTION: no data setup — the contract holds with an EMPTY table too (the `min-width: 640px` forces the 640px width regardless of rows); if a suite fixture provides saved chats/tokens, the test must not depend on them.`
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (this task's gate): `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` green **in isolation** (DB up — the conftest `db_ready`; mock LLM via conftest for the login helper).
|
||||
- The pre-fix measurement (History 626, Tokens 618) is the counterfactual: with the task-01 CSS change the pins pass; without it `test_history_page_is_viewport_width` fails (the executor verifies the test is live by temporarily reverting the CSS in a scratch branch/`git stash` run — then restores — and records the red→green in the session log).
|
||||
- Coverage: >90% on `app/` unaffected (test-only task).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The five tests exist, the module docstring maps each to the contract (the mapping list is the suite's README).
|
||||
- [ ] `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` green in isolation; the red→green verification (CSS reverted → History test fails at `scrollWidth <= innerWidth`; restored → green) is recorded.
|
||||
- [ ] No change to `app/`, `frontend/`, conftest, or other suites.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 03 — Full gate + atomic commit
|
||||
|
||||
**Phase:** `86_history_page_width` · **Source:** `TODO.md:4` — the History-page-width bug report (TODO.md L4)
|
||||
**Story:** n/a (owner bug report — `TODO.md` L4)
|
||||
|
||||
## Objective
|
||||
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
|
||||
|
||||
## Work
|
||||
1. **Full regression gate** (AGENTS.md rule 9):
|
||||
- `uv run pytest` — unit + integration green.
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%** (no `app/` change this phase — confirm the floor is held).
|
||||
- `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` — green **in isolation** (this phase's E2E story — the page-width contract).
|
||||
- `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` — green in isolation (the History BEHAVIOR suite — the fix must not have touched row actions, refresh, or the gate).
|
||||
- `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` — green in isolation (the Tokens BEHAVIOR suite — the folded-in view's regression proof).
|
||||
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green in isolation.
|
||||
- `uv run ruff check . && uv run pyright` — clean.
|
||||
2. **Manual live check** (keep the output in the session log): dev server up, 375px viewport, signed in: open `/history.html` — the page no longer pans horizontally (drag/scroll right at the edge — nothing moves past the viewport); the table card scrolls its columns internally (swipe the card, not the page); `/tokens.html` the same; `/sources.html` unchanged; 1280px — all three tables render at container width with no card-internal scroll.
|
||||
3. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `frontend/assets/styles.css`, `tests/unit/test_table_wrap_stacking.py`, `tests/e2e/test_history_page_width.py`, and the phase files:
|
||||
`fix(ui): stop the History and Tokens tables from stretching the document width`
|
||||
— body: TODO.md L4 — on mobile the History page panned ~250px into a blank region (document scrollWidth 626 at 375px) even though the table scrolled correctly inside its card. Root cause: the `.visually-hidden` Actions header span (and caption) are `position: absolute` with no positioned ancestor, so their 1px boxes are laid out against the initial containing block — at the 640px table's right edge — and leak into the document's scrollable overflow. `position: relative` on the shared `.table-wrap` card makes it the containing block (zero layout change; the spans stay, still clipped); the identical defect in the Tokens view (measured 618) is fixed by the same rule; the RAG view was clean and is regression-pinned. New source-level unit pin + dedicated E2E story (History + Tokens + RAG + desktop).
|
||||
4. Move the phase directory: `mv .agents/phases/todo/86_history_page_width .agents/phases/complete/` and include the move in the same commit.
|
||||
|
||||
## Testing & Quality
|
||||
- This task IS the phase-level gate — the commands above are the completion evidence.
|
||||
- Coverage: >90% held.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All seven gate commands green (unit + integration, coverage >90%, the new E2E story + `test_chat_history.py` + `test_api_tokens.py` + smoke in isolation, ruff + pyright).
|
||||
- [ ] The live check shows no page-level horizontal pan on the two table views at 375px (output kept in the session log).
|
||||
- [ ] Exactly one new commit; `git show --stat HEAD` lists the staged files above + the phase files (todo → complete move) — nothing else (in particular `app/` and the table markup untouched).
|
||||
- [ ] `.agents/phases/complete/86_history_page_width/` exists; `todo/` no longer contains it.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Phase 87 — Show progress while a big read is being processed
|
||||
|
||||
**Source:** `TODO.md` L5 — "Need indication that prompt processing is happening during a big read, it can look frozen."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L5, 2026-09-07)
|
||||
**Context:** `app/api/chat.py` (the SSE turn: a `tool` frame — `ChatToolEvent{name, argument}` — is emitted the moment the model's tool-call request arrives, i.e. BEFORE `app/rag/agent.py::_execute_tool` runs and the next model round starts; the next frame after that arrives only when the model has executed the read, prefilled the big context, and started generating — the frameless gap the owner sees), `frontend/assets/app.js` (the chat state machine — `UI_STATE.thinking` keeps the button as "Stop"; `appendToolLine` renders the STATIC "📄 Reading `<path>`" / "🔎 Searching for `<pattern>`" / "🔎 Listing documents…" line — no animation, no timing; the `tool` frame handler (the `ev.type === "tool"` branch of the turn's SSE loop) calls `clearTurnTimeout()` — "the stream is alive — a frame arrived"; `startThinkingClock` ticks 1s from turn start and after 10s updates ONLY the typing bubble's **aria-label** (`${brand()} is still thinking (${secs}s)`) — invisible to sighted users; `addTyping`/`removeTyping` own `#typing-indicator` — three animated dot spans in a `role="status"` bubble; `setUiState` is the single entry point — "timers belong to the state machine: every transition stops/clears them"), `tests/unit/test_frontend_tool_states.py` (pins the EXACT tool-line template literals — `line.textContent = "📄 Reading "` etc. — and the emoji-guard strip set; those literals must stay byte-identical), `tests/unit/test_frontend_feedback.py` + `tests/unit/test_frontend_brand.py` (pin the "still thinking" aria-label contract and its exact template literal — must stay green), `tests/e2e/mock_llm.py` (the deterministic "use your tools" flow: request 1 streams `ls` tool_calls, request 2 streams a `read` tool_call, request 3 streams the answer — no thinking frames) + `tests/e2e/slow_llm.py` (the delay-injecting reverse proxy, `SLOW_LLM_DELAY_S` per-request sleep — the deterministic-gap machinery).
|
||||
|
||||
## Bug basis (code-traced + reproduced, 2026-09-07)
|
||||
- **The gap:** for a big read, the `tool` frame lands when the model REQUESTS the read. The server then executes the tool (a DB fetch — fast) and starts the NEXT model round, whose prefill of the big read context takes tens of seconds on the self-hosted models. No SSE frame arrives during that window; the UI's only motion is the typing dots' CSS animation, and the only elapsed feedback is screen-reader-only (the 10s aria-label). To a sighted user the turn "looks frozen" — exactly TODO.md L5.
|
||||
- **The static line:** `appendToolLine` renders "📄 Reading `<path>`" once and never touches it again — no in-progress mark, no timing, no settle. The line sits exactly where the user's attention is (above the answer), which is why the frozen feeling is strongest there.
|
||||
- **Existing guard (noted, OUT OF SCOPE):** the 120s pre-token guard (`TURN_TIMEOUT_MS`) is cleared by the `tool` frame (the stream is alive) — so the backstop that errors a stuck turn does not re-arm after a tool frame. Changing guard semantics is a separate owner decision; this phase only ADDS the visible indication the owner asked for and leaves the guard byte-identical.
|
||||
- **The mock flow makes the gap deterministic for E2E:** "use your tools" → `ls` tool frame → (server executes, next round) → `read` tool frame → (server executes, next round) → answer deltas. With the slow-LLM proxy sleeping `SLOW_LLM_DELAY_S` seconds per request, each inter-frame gap is ≥ that sleep — a 6s sleep puts every gap past both new thresholds (5s tool-line, 10s typing hint is crossed during the second gap at t≈10s).
|
||||
|
||||
## Objective
|
||||
During any frameless gap of an in-flight turn — most visibly after a big read — the UI shows, to sighted AND screen-reader users, that processing is ongoing: a ticking elapsed-seconds suffix on the latest tool line (after 5s of silence, settling when the next frame arrives) and a visible elapsed hint on the typing indicator (the existing 10s clock promoted from aria-only to visible text). Both settle the instant content resumes; persisted/restored turns never show timers.
|
||||
|
||||
## Owner decisions (chat, 2026-09-07 — confirmed with the roadmap, recorded per AGENTS.md rule 3)
|
||||
- **A4 — frontend-only scope.** No new server frame (no `tool_done`): the client derives the indication from the existing SSE stream — a `tool` frame arms the line's clock (reset per line); ANY later frame (thinking / tool / retry / delta) settles it; turn-end transitions stop it via the state machine. The server is untouched — the 120s-guard observation above stays out of scope.
|
||||
- **A5 — thresholds.** Tool-line elapsed suffix appears after **5s** of frame silence (`TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, a named module constant); the typing-indicator hint reuses the EXISTING 10s pre-token clock (its `secs < 10` gate) — now also writing visible text. Both thresholds are pinned constants, not magic numbers.
|
||||
- **A6 — live-only indication.** The suffix/hint exist only while the turn streams. The restore path (phase 14 re-renders persisted lines through the SAME `appendToolLine`) never arms the clock — the arming call lives ONLY in the live `tool` frame branch. A restored line reads exactly as it did pre-phase (the permanent record, no stale timer).
|
||||
- **Settle = remove the suffix.** When a thinking/retry/delta frame arrives, the `.tool-elapsed` suffix is REMOVED from the line (the visible indication moves to the thinking block / answer bubble — a frozen timestamp on a finished line is noise; the line itself stays the permanent record). A new `tool` frame re-arms on the new line with a fresh baseline.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **`frontend/assets/app.js`** (all changes in the house comment style, citing this phase + TODO.md L5):
|
||||
- Constants (next to `TURN_TIMEOUT_MS`): `const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;` (A5) with a comment: the visible "processing" threshold for a tool line — below it the gap reads as normal latency, above it the user needs proof of life.
|
||||
- **Task 01 — the typing hint:** in `startThinkingClock`'s 1s interval, alongside the existing aria-label update (KEPT byte-identical — the unit pins), after `secs >= 10`: ensure a `<span class="typing-elapsed">` (createElement, `document.createElement`, never innerHTML) as the LAST child of the `#typing-indicator .bubble` (after the three dot spans) and set its `textContent = secs + "s"`. The bubble's `role="status"` announces the change; the aria-label pin stays (both channels). `removeTyping()` already removes the whole indicator on every non-thinking transition (the state machine owns it — no extra cleanup).
|
||||
- **Task 02 — the tool-line clock:** turn-scoped module state `let toolLineTimer = 0; let toolLineStart = 0; let toolLineWrap = null;` (documented next to `thinkingClock`):
|
||||
- `armToolLineClock(wrap)` — `toolLineWrap = wrap; toolLineStart = Date.now();` start the 1s interval if not running: `secs = Math.round((Date.now() - toolLineStart) / 1000); if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return;` then find the LATEST line (`toolLineWrap`'s `.tool-calls` container's last `.tool-call` child) and ensure/append its `<span class="tool-elapsed">` (createElement; `textContent = `(${secs}s)`` — the parenthesized suffix reads as the line's status; the line's own content — the pinned template literal + the `<code>` argument — is untouched: the suffix is a SIBLING appended after them).
|
||||
- `settleToolLine()` — clear the interval and REMOVE every `.tool-elapsed` from `toolLineWrap` (the A6/A-settle contract); null the wrap.
|
||||
- `stopToolLineClock()` — `settleToolLine()` + `toolLineWrap = null` (the state-machine entry: no residue across turns).
|
||||
- Call sites: `armToolLineClock(wrap)` in the `ev.type === "tool"` branch, right after the existing `appendToolLine(wrap, name, argument)` (the ONLY call site — A6); `settleToolLine()` at the TOP of the `ev.type === "thinking"`, `"retry"`, and `"delta"` branches (a frame arrived — the line is no longer "processing"); `stopToolLineClock()` inside `setUiState`, next to the existing `stopThinkingClock()` / `clearTurnTimeout()` (every transition stops/clears — the house invariant).
|
||||
- The restore path (`appendToolLine` called from the phase-14 restore) is untouched — it never arms the clock (A6).
|
||||
- **The exact tool-line template literals (`line.textContent = "📄 Reading "` etc.) are NOT modified** — `tests/unit/test_frontend_tool_states.py` pins them byte-for-byte and the emoji guard strips precisely those; the suffix is a separate element added by the clock only.
|
||||
- **`frontend/assets/styles.css`** — two new rules (house AA palette, the `.history-status` token pairing — `var(--ink-soft)` on the bubble/card surfaces is the documented ≥4.5:1 shape; small mono matches the status-line language):
|
||||
```css
|
||||
/* Phase 87 (TODO.md L5): the visible "processing" indications … */
|
||||
.tool-elapsed { font-family: var(--mono); font-size: 0.75rem; color: var(--ink-soft); margin-left: 0.5rem; white-space: nowrap; }
|
||||
.typing-elapsed { font-family: var(--mono); font-size: 0.75rem; color: var(--ink-soft); margin-left: 0.5rem; }
|
||||
```
|
||||
(plain text, no animation — `prefers-reduced-motion` needs no override; place them near the `.tool-call` / `.typing` rules.)
|
||||
- **`tests/unit/test_big_read_progress.py` (new — source-level house pattern):**
|
||||
- `app.js`: `TOOL_LINE_ELAPSED_AFTER_MS` defined with the 5s value (regex `TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000`); `armToolLineClock(` appears EXACTLY twice (the definition + the single live call site — the live-only/A6 contract); `settleToolLine(` appears at least 4× (definition + the three frame branches); `stopToolLineClock()` is called in `setUiState` (the call site sits inside the function body after `stopThinkingClock()`); the suffix text is built with `textContent` (pin the template `(${secs}s)` or equivalent — NO `innerHTML` assignment to the suffix element); the existing aria-label literal stays (the brand test enforces this too — but pin it here as well so THIS file is self-documenting).
|
||||
- `styles.css`: the `.tool-elapsed` and `.typing-elapsed` rules exist and use `var(--mono)` + `var(--ink-soft)` (the AA pairing pin).
|
||||
- **E2E story (new suite — `tests/e2e/test_big_read_progress.py`):** the mock "use your tools" flow behind the slow-LLM proxy at `SLOW_LLM_DELAY_S = 6.0` (deterministic ≥6s gaps — past the 5s threshold; the 10s typing hint is crossed during the second gap). Four tests: (1) the tool line grows a visible ticking `.tool-elapsed` suffix during the gap (two samples ≥1.5s apart, strictly increasing `\(\d+s\)` value); (2) the typing indicator shows the visible `.typing-elapsed` hint at t≈11–12s (≥10, increasing); (3) on the answer's arrival BOTH settle — `#typing-indicator` removed, every `.tool-elapsed` gone, no re-appearance 1.5s later, the answer bubble complete; (4) a RELOAD after the turn (same context — the phase-14/50 persisted conversation restores) renders the tool lines WITHOUT any `.tool-elapsed` (A6 end-to-end).
|
||||
- **Not touched:** `app/` (server byte-identical — A4), the 120s guard, the tool-line template literals, `mock_llm.py` / `slow_llm.py` (used as-is with the suite's own proxy fixture), persistence format.
|
||||
|
||||
## Dependencies
|
||||
- `86_history_page_width` (todo) — pipeline predecessor (execution order) only; NO code dependency (different files: `app.js` / `styles.css` / a new E2E suite — phase 86 touches only the `.table-wrap` rule and table-view E2E).
|
||||
|
||||
## Tasks
|
||||
1. `01_typing_elapsed_hint.md` — the visible typing-indicator elapsed hint (app.js + CSS + unit pins).
|
||||
2. `02_tool_line_elapsed.md` — the per-line clock, suffix, and settle (app.js + CSS + unit pins).
|
||||
3. `03_e2e_story_suite.md` — `tests/e2e/test_big_read_progress.py` (slow-proxy deterministic gaps).
|
||||
4. `04_verify_and_commit.md` — full gate (suite + coverage + the new E2E story in isolation + the adjacent chat/tool suites in isolation, smoke, ruff + pyright) + atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit — `tests/unit/test_big_read_progress.py` (new): the pins listed in the Design section (named 5s constant, single live arm site, settle-on-three-frame-types, state-machine stop, textContent-only suffix, the two CSS rules' AA pairing).
|
||||
- Existing unit suites MUST stay green UNCHANGED: `tests/unit/test_frontend_tool_states.py` (the exact tool-line literals + emoji guard), `tests/unit/test_frontend_feedback.py` (the "still thinking" aria contract), `tests/unit/test_frontend_brand.py` (the exact aria-label template literal).
|
||||
- E2E — `tests/e2e/test_big_read_progress.py` (new; isolation gate per AGENTS.md rule 9): the four tests above, mock LLM + slow proxy (`SLOW_LLM_DELAY_S = 6.0`) via the conftest app-server pattern.
|
||||
- Regression E2E (run in isolation by task 04): `tests/e2e/test_thinking_display.py` (the thinking block + the state machine's neighbors) and `tests/e2e/test_agent_document_tools.py` (the tool-line rendering behavior) green unchanged; `tests/e2e/test_smoke.py` green.
|
||||
- Coverage: **>90%** on `app/` — no `app/` code changes (the floor is held by the untouched suite).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Live turn, big-read gap ≥5s: the latest tool line shows a ticking "(Ns)" suffix (E2E pin 1); the typing indicator shows the visible "Ns" hint after 10s of pre-token silence (E2E pin 2) — the owner's "it can look frozen" is fixed for sighted users; screen-reader users keep the aria channels (existing pins green).
|
||||
- [ ] On the next frame / turn end: both indications settle (suffix removed, typing removed on state change) — no stale timers after the answer (E2E pin 3); the 120s guard and every existing state-machine behavior are byte-identical.
|
||||
- [ ] Reload after a tool turn: restored tool lines carry NO timer (E2E pin 4 — A6).
|
||||
- [ ] `uv run pytest` green (including the three untouched frontend unit suites); `uv run pytest --cov=app --cov-report=term-missing` >90%; the new E2E story + `test_thinking_display.py` + `test_agent_document_tools.py` + `test_smoke.py` green in isolation; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `git diff --stat` limited to `frontend/assets/app.js`, `frontend/assets/styles.css`, the new unit test, the new E2E file, phase files — nothing in `app/`.
|
||||
- [ ] One atomic `--no-gpg-sign` commit (e.g. `feat(ui): show elapsed progress during tool reads and pre-token gaps`); phase dir moved to `.agents/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Frontend-only (A4)** — the server, the SSE event set, and the 120s guard are byte-identical; the indication is derived client-side from the existing stream.
|
||||
- **The tool-line template literals stay (the emoji-guard + unit pins)** — the suffix is a separate element the clock appends; `appendToolLine` renders exactly as before (which is also what makes A6 fall out for free on restore).
|
||||
- **Named thresholds, state-machine-owned timers (A5 + house invariant)** — `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`; every `setUiState` transition stops/clears the new clock exactly like the existing ones ("a stuck button is impossible" applies to a stuck timer too).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Task 01 — Promote the 10s typing clock to a visible hint
|
||||
|
||||
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — "Need indication that prompt processing is happening during a big read, it can look frozen."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L5)
|
||||
|
||||
## Objective
|
||||
While a turn is pre-token (state `thinking`), after 10s of silence the typing indicator shows a VISIBLE ticking "Ns" hint next to the dots — the existing aria-only clock promoted to sighted users too — while the pinned aria-label contract stays byte-identical.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — in `startThinkingClock`'s 1s interval (the block that today, after `if (secs < 10) return;`, updates `#typing-indicator .bubble`'s aria-label with `` `${brand()} is still thinking (${secs}s)` ``):
|
||||
- KEEP the existing `bubble.setAttribute("aria-label", …)` line BYTE-IDENTICAL (pinned by `tests/unit/test_frontend_feedback.py` + `tests/unit/test_frontend_brand.py`);
|
||||
- add, after it: ensure a hint element — `let el = bubble.querySelector(".typing-elapsed"); if (!el) { el = document.createElement("span"); el.className = "typing-elapsed"; bubble.appendChild(el); }` (append AFTER the three dot spans — the bubble's innerHTML is the template in `addTyping`; the hint is the last child) — and `el.textContent = secs + "s";` (textContent only — the bubble is `role="status"`, so the change is announced; no innerHTML anywhere with turn data).
|
||||
- house comment above the new lines: phase 87 (TODO.md L5) — the 10s hint was aria-label-only (invisible to sighted users); now it ALSO renders a visible mono suffix; the aria channel is kept (both users, same clock).
|
||||
- No change to `addTyping` / `removeTyping` (the hint lives and dies with the indicator — the state machine already removes the whole indicator on every non-thinking transition).
|
||||
2. `frontend/assets/styles.css` — add the `.typing-elapsed` rule (the house AA pairing — `var(--ink-soft)` on the typing bubble surface, the documented ≥4.5:1 shape; small mono, the status-line language; plain text, no animation — no reduced-motion override needed):
|
||||
```css
|
||||
/* 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. */
|
||||
.typing-elapsed {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
```
|
||||
Place it next to the existing `.typing` (typing-dot bubble) rules.
|
||||
3. `tests/unit/test_big_read_progress.py` (new — start the module here; task 02 extends it) — house source-level pattern (read `frontend/assets/app.js` + `frontend/assets/styles.css` as text, no browser), the module docstring cites TODO.md L5 + the design:
|
||||
- `app.js`: the `.typing-elapsed` class string appears; the visible hint is created with `document.createElement` (pin the `el.className = "typing-elapsed"` or the `createElement("span")` + class-assignment pair) and written with `textContent` (pin `textContent = secs + "s"` or the equivalent assignment — assert NO `innerHTML` on the typing bubble after the change: the `bubble.innerHTML` occurrence count is UNCHANGED from the `addTyping` template only — the simplest robust pin: `"typing-elapsed" in js` AND `js.count('bubble.innerHTML') == 1` (the addTyping template));
|
||||
- the existing aria-label literal is still present (`` `${brand()} is still thinking (${secs}s)` `` — mirrors the brand pin so this module is self-documenting);
|
||||
- `styles.css`: the `.typing-elapsed` rule exists and uses `var(--mono)` and `var(--ink-soft)` (the AA pairing).
|
||||
4. `- ASSUMPTION: the hint text is plain "Ns" (e.g. "12s") with no brand prefix — the bubble's aria-label already carries the full sentence; the visible suffix is deliberately terse (A5). The executor does not add wording.`
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `uv run pytest tests/unit/test_big_read_progress.py -v` green (this task's pins); `uv run pytest tests/unit/test_frontend_feedback.py tests/unit/test_frontend_brand.py tests/unit/test_frontend_tool_states.py -v` green UNCHANGED (the untouched pins).
|
||||
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_big_read_progress.py -v` green; the three frontend unit suites green unchanged.
|
||||
- [ ] The aria-label line in `app.js` is byte-identical (`git diff frontend/assets/app.js` shows only additions around it).
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean (no `app/` impact, but the gate runs).
|
||||
- [ ] No behavior change in completed work (the E2E proof is task 03/04).
|
||||
@@ -0,0 +1,55 @@
|
||||
# Task 02 — The per-tool-line elapsed clock, suffix, and settle
|
||||
|
||||
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — "Need indication that prompt processing is happening during a big read, it can look frozen."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L5)
|
||||
|
||||
## Objective
|
||||
The latest tool line ("📄 Reading `<path>`") shows a ticking "(Ns)" suffix after 5s of frame silence — proof of life exactly where the user's attention is — and the suffix (and the clock) settle the instant the next frame arrives or the turn ends; restored turns never show a timer.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — the turn-scoped clock (module scope, next to `thinkingClock` / `turnTimeout`, house comment style citing phase 87 + TODO.md L5):
|
||||
- state: `let toolLineTimer = 0; let toolLineStart = 0; let toolLineWrap = null;` — one clock per turn, re-armed per `tool` frame (the baseline resets on every new line, so each line counts its OWN silence);
|
||||
- `const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;` — place it with the other constants (next to `TURN_TIMEOUT_MS`), comment: A5 — below it the gap reads as normal latency; at/above it the latest line proves it is still processing; pinned constant, not a magic number;
|
||||
- `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; const line = toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child"); if (!line) return; let el = line.querySelector(".tool-elapsed"); if (!el) { el = document.createElement("span"); el.className = "tool-elapsed"; line.appendChild(el); } el.textContent = \`(${secs}s)\`; }, 1000); } }`
|
||||
- the suffix is appended to the line AFTER its existing children (the pinned template text + the `<code>` argument) — a SIBLING, never a rewrite: the exact `line.textContent = "📄 Reading "` literals in `appendToolLine` stay byte-identical (the `test_frontend_tool_states.py` + emoji-guard pins);
|
||||
- `function settleToolLine() { if (toolLineTimer) { clearInterval(toolLineTimer); toolLineTimer = 0; } toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove()); }`
|
||||
- `function stopToolLineClock() { settleToolLine(); toolLineWrap = null; }` — the state-machine entry (no residue across turns).
|
||||
- call sites (the ONLY ones — the live-only/A6 contract):
|
||||
- the `ev.type === "tool"` branch (the turn's SSE loop, the branch that calls `appendToolLine(wrap, name, argument)`): add `armToolLineClock(wrap);` immediately after the `appendToolLine(…)` call;
|
||||
- the TOP of the `ev.type === "thinking"`, `ev.type === "retry"`, and `ev.type === "delta"` branches: `settleToolLine();` (a frame arrived — the line is no longer "processing"; the visible indication moves to the thinking block / answer bubble);
|
||||
- inside `export function setUiState(state, …)`: add `stopToolLineClock();` on the line next to the existing `stopThinkingClock();` / `clearTurnTimeout();` (every transition stops/clears — the house invariant "a stuck button is impossible" applied to a stuck timer).
|
||||
- the restore path (the phase-14 code that re-renders persisted lines through `appendToolLine`) is UNTOUCHED — it never calls `armToolLineClock` (A6).
|
||||
2. `frontend/assets/styles.css` — add the `.tool-elapsed` rule next to the existing `.tool-call` rules (same AA pairing as task 01; `white-space: nowrap` so "(12s)" never wraps):
|
||||
```css
|
||||
/* 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;
|
||||
}
|
||||
```
|
||||
3. `tests/unit/test_big_read_progress.py` (extend the module from task 01) — the source-level pins:
|
||||
- `TOOL_LINE_ELAPSED_AFTER_MS` defined with the 5s value (regex `TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000`);
|
||||
- `armToolLineClock(` appears EXACTLY twice in `app.js` (definition + the single live call site — the A6 live-only contract: the restore path has no arm call);
|
||||
- `settleToolLine(` appears at least 4× (definition + the thinking/retry/delta branches) and `stopToolLineClock()` is called inside `setUiState` (pin: the call occurs after the `stopThinkingClock()` call and before the function's `sendBtn.disabled` line — or more simply: it sits in the same statement block, i.e. between the `stopThinkingClock();` and `clearTurnTimeout();` lines — pick the pin that is stable and document it);
|
||||
- the suffix text is built with `textContent` (pin the template containing `(${secs}s)`) and the element is created with `createElement` (no innerHTML for the suffix);
|
||||
- the exact tool-line template literals are still present (the three `line.textContent = "…"` strings — mirrors `test_frontend_tool_states.py` so this module is self-documenting);
|
||||
- `styles.css`: the `.tool-elapsed` rule exists with `var(--mono)` + `var(--ink-soft)`.
|
||||
4. `- ASSUMPTION: ":last-child" targets the LATEST line only (older lines of the same turn keep their permanent record without timers); if the `.tool-calls` container is absent when the tick fires (the wrap was reset by a New-Chat click mid-gap), the tick is a no-op — the null-safe `?.` chain is the guard, pinned implicitly by the settle/stop contract.`
|
||||
5. `- ASSUMPTION: the suffix format is parenthesized "(Ns)" — reads as the line's status suffix (e.g. "📄 Reading src/app.py (12s)"); the typing hint stays bare "Ns" (task 01). No owner-visible wording debate — pinned here so the executor does not guess.`
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `uv run pytest tests/unit/test_big_read_progress.py -v` green (all pins, task 01 + this task); `uv run pytest tests/unit/test_frontend_tool_states.py tests/unit/test_frontend_feedback.py tests/unit/test_frontend_brand.py -v` green UNCHANGED.
|
||||
- Quick live sanity (session log): dev server, ask a question that triggers a tool call (the "use your tools" phrase against the dev LLM if configured, else the phase-37 flow) — after ~5s of silence the latest tool line gains "(5s)" and it ticks; the next frame removes it. (The deterministic E2E is task 03 — this is a smoke of the wiring only.)
|
||||
- Coverage: **>90%** on `app/` unaffected.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `git diff frontend/assets/app.js` shows: the constant, the three clock functions, the ONE arm call (in the `tool` branch), the three settle calls (thinking/retry/delta), the ONE stop call (in `setUiState`) — and NOTHING else; the `appendToolLine` body and its template literals byte-identical.
|
||||
- [ ] `uv run pytest tests/unit/test_big_read_progress.py -v` green; the three frontend unit suites green unchanged.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] No behavior change in completed work (E2E proof in task 03/04).
|
||||
@@ -0,0 +1,44 @@
|
||||
# Task 03 — The E2E story: progress indication during a deterministic big-read gap
|
||||
|
||||
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — "Need indication that prompt processing is happening during a big read, it can look frozen."
|
||||
**Story:** n/a (owner bug report — `TODO.md` L5)
|
||||
|
||||
## Objective
|
||||
A dedicated Playwright suite (one file per story, run in isolation — AGENTS.md rule 4/9) drives the mock LLM's deterministic tool flow behind the slow-LLM proxy so every inter-frame gap is ≥6s — past both thresholds — and pins: the tool line's ticking suffix, the visible typing hint, the settle on the answer, and the no-timer restore contract.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_big_read_progress.py` (new) — house E2E conventions (module docstring: the Source line, the run-in-isolation command `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — DB up + mock LLM, the mechanism paragraph (mock "use your tools" flow → ls tool frame → read tool frame → answer deltas; the slow proxy's per-request sleep makes every gap deterministic), the Test → story mapping list):
|
||||
- **Suite fixtures:** a module-level `app_server`-style fixture pair following the phase-64 pattern (the conftest already spawns the mock LLM; this suite additionally spawns the proxy): `uv run python -m uvicorn tests.e2e.slow_llm:app --port <proxy port>` with `E2E_MOCK_PORT` = the conftest mock port and **`SLOW_LLM_DELAY_S = 6.0`** (the ≥6s gaps — past the 5s tool-line threshold; the 10s typing hint is crossed during the SECOND gap at t≈10s, before the answer's first delta). Reuse the conftest's subprocess-management helpers verbatim (the phase-64 suite is the reference implementation — mirror its fixture shape; do not re-implement process handling differently).
|
||||
- **`test_tool_line_shows_ticking_elapsed`** — admin (or anonymous — the mock flow is public; use the anonymous context, no login needed, like the phase-37 suite: confirm which the phase-37 E2E uses and mirror it) at the conftest 1280×800 viewport: `page.goto(app_url)`, type the EXACT mock trigger phrase "use your tools" into `#message-input`, submit:
|
||||
- wait for the FIRST `.tool-call` line (the "Listing documents" line — the mock flow's first call) to appear in `#messages`;
|
||||
- from the line's appearance, wait until a `.tool-elapsed` child is visible (poll; it must appear by ~7s — 5s threshold + tick granularity — assert with a generous timeout of 12s so the suite is not flaky);
|
||||
- sample its text twice, ≥1.5s apart (regex `\((\d+)s\)`): the second value is STRICTLY greater (the clock ticks while the gap holds);
|
||||
- note the turn is still in flight (the button is the enabled "Stop" — `#send-label` text "Stop" — the phase-48 contract, a cheap invariant check).
|
||||
- **`test_typing_indicator_shows_visible_elapsed`** — a fresh page + a fresh turn with the same trigger:
|
||||
- at t≈11–12s after submit (during the second gap — pre-token: no thinking/delta frame has arrived in the mock flow), `#typing-indicator .typing-elapsed` is visible and its value (regex `(\d+)s`) is ≥10;
|
||||
- a second sample ≥1.5s later is strictly greater;
|
||||
- the aria-label channel still works (the bubble's `aria-label` contains "still thinking" at the same moment — the kept contract, E2E-observable).
|
||||
- **`test_indicators_settle_when_the_answer_arrives`** — a fresh turn with the same trigger:
|
||||
- first assert the ticking state (a `.tool-elapsed` visible — reuse the test-1 wait helper);
|
||||
- wait for the answer: the mock flow's final answer renders in a `.bubble` under the brain msg (the phase-37 suite's answer-wait pattern — mirror it: wait for non-empty answer text / the `done` state — the button back to "Send");
|
||||
- then: `#typing-indicator` is absent (the state machine removed it); NO `.tool-elapsed` element exists anywhere in `#messages` (the settle removed them);
|
||||
- wait 1.5s more: still none (no re-appearance — the turn is over, the clock is stopped by the transition);
|
||||
- the tool lines themselves remain (the permanent record — both the "Listing documents" and "Reading …" lines still present, byte-identical text).
|
||||
- **`test_restored_turn_has_no_timer`** — a fresh turn with the same trigger, let it COMPLETE (answer rendered — the auto-save persisted it, the phase-50/55 headless auto-save contract): `page.reload()` (same context — `bor.chat.v1` survives):
|
||||
- the restored conversation renders the tool lines (`.tool-call` elements present — the phase-14 restore path);
|
||||
- NO `.tool-elapsed` element exists (A6 — the restore never arms the clock);
|
||||
- the page is otherwise settled (no `#typing-indicator`, button "Send").
|
||||
- Shared helpers in the module: `_submit_tools_turn(page, app_url)` (type the trigger + submit + wait for the first `.tool-call`), `_elapsed_value(el)` (the regex parse), `_sample_twice(page, selector)` (two values ≥1.5s apart) — small, local, documented.
|
||||
2. `- ASSUMPTION: the anonymous context is used (no login) IF the phase-37 agent-tools E2E (tests/e2e/test_agent_document_tools.py) runs anonymous — mirror its auth choice exactly so the suite matches the house pattern for this flow; if that suite logs in, mirror the login instead. The mock flow + proxy are identical either way.`
|
||||
3. `- ASSUMPTION: `SLOW_LLM_DELAY_S = 6.0` is the suite's value (the phase-64 default 0.15 is far below the 5s threshold — the suite must pass its own, like phase 64 did); the whole turn then runs ~18s + overhead — acceptable for an isolated story suite (the phase-64 suite has the same shape). Timeouts are set with ≥2× headroom on every wait so CI variance cannot flake the suite.`
|
||||
4. `- ASSUMPTION: no changes to mock_llm.py / slow_llm.py / conftest.py — the suite reuses them as-is (the proxy is spawned per-suite the phase-64 way); if the conftest's mock port is occupied by the phase-64 pattern's choice, the proxy port follows the same allocation rule that suite uses.`
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (this task's gate): `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` green **in isolation** (DB up; mock LLM + the suite's slow proxy).
|
||||
- Flakiness discipline: run the suite THREE times in isolation (`for i in 1 2 3; do uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov || break; done`) — all three green before the task is done (the timing assertions are the flake risk; the ≥2× timeout headroom + the strictly-increasing (not exact-value) assertions are the countermeasures).
|
||||
- Coverage: >90% on `app/` unaffected (test-only task).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The four tests exist; the module docstring maps each to the contract (the suite's README).
|
||||
- [ ] `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` green in isolation — three consecutive runs.
|
||||
- [ ] No change to `app/`, `frontend/`, `conftest.py`, `mock_llm.py`, `slow_llm.py`, or other suites.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 04 — Full gate + atomic commit
|
||||
|
||||
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — the big-read indication bug report (TODO.md L5)
|
||||
**Story:** n/a (owner bug report — `TODO.md` L5)
|
||||
|
||||
## Objective
|
||||
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
|
||||
|
||||
## Work
|
||||
1. **Full regression gate** (AGENTS.md rule 9):
|
||||
- `uv run pytest` — unit + integration green (including the three untouched frontend unit suites: `test_frontend_tool_states.py`, `test_frontend_feedback.py`, `test_frontend_brand.py` — the byte-identical-literal proof).
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%** (no `app/` change this phase — confirm the floor is held).
|
||||
- `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — green **in isolation** (this phase's E2E story — ticking suffix, visible hint, settle, no-timer restore).
|
||||
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` — green in isolation (the thinking block + the state machine's neighborhood — the task-01 clock change's regression proof).
|
||||
- `uv run pytest tests/e2e/test_agent_document_tools.py -v --no-cov` — green in isolation (the tool-line rendering behavior — the task-02 change's regression proof).
|
||||
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green in isolation.
|
||||
- `uv run ruff check . && uv run pyright` — clean.
|
||||
2. **Manual live check** (keep the output in the session log): dev server with the REAL LLM (or the mock via the dev env if that is the dev setup), ask a question that makes the model read a large document: while the read's follow-up round is in flight, the latest "📄 Reading …" line ticks "(5s), (6s), …" and the typing indicator shows the visible hint after 10s of silence; when the answer starts, both settle and the turn completes normally; Stop mid-gap → no residue (reload: no timer, clean state).
|
||||
3. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `frontend/assets/app.js`, `frontend/assets/styles.css`, `tests/unit/test_big_read_progress.py`, `tests/e2e/test_big_read_progress.py`, and the phase files:
|
||||
`feat(ui): show elapsed progress during tool reads and pre-token gaps`
|
||||
— body: TODO.md L5 — after a tool `read` the UI sat on a static "Reading <path>" line while the model prefilled the big context (tens of seconds, no frames): the turn looked frozen. Two visible, state-machine-owned indications: (1) the latest tool line grows a ticking "(Ns)" suffix after 5s of frame silence (`TOOL_LINE_ELAPSED_AFTER_MS`, armed ONLY from the live `tool` frame — restored lines stay timer-free) and it is removed on the next thinking/retry/delta frame; (2) the existing 10s pre-token thinking clock — previously aria-label-only — now also renders a visible "Ns" hint on the typing indicator (the aria channel kept). Frontend-only: the SSE event set, the 120s guard, the server, the pinned tool-line template literals, and the persistence format are byte-identical. New source-level unit pins + a dedicated slow-proxy E2E story (deterministic ≥6s gaps).
|
||||
4. Move the phase directory: `mv .agents/phases/todo/87_big_read_progress .agents/phases/complete/` and include the move in the same commit.
|
||||
|
||||
## Testing & Quality
|
||||
- This task IS the phase-level gate — the commands above are the completion evidence.
|
||||
- Coverage: >90% held.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All seven gate commands green (unit + integration, coverage >90%, the new E2E story + `test_thinking_display.py` + `test_agent_document_tools.py` + smoke in isolation, ruff + pyright).
|
||||
- [ ] The live check shows the ticking tool-line suffix + the visible typing hint during a real big read, settling on the answer (output kept in the session log).
|
||||
- [ ] `git show --stat HEAD` lists the staged files above + the phase files (todo → complete move) — nothing else (in particular `app/`, `pyproject.toml` / `uv.lock`, `conftest.py`, `mock_llm.py`, `slow_llm.py` untouched).
|
||||
- [ ] `.agents/phases/complete/87_big_read_progress/` exists; `todo/` no longer contains it.
|
||||
@@ -19,6 +19,7 @@ BOR_LLM_BASE_URL=https://aipi.reeseapps.com/v1
|
||||
BOR_LLM_API_KEY= # falls back to $AIPI_KEY, then "not-needed"
|
||||
BOR_LLM_CHAT_MODEL=turbo
|
||||
# BOR_LLM_RETRIES=3 # retry a dead LLM request before the first token lands (phase 67); 0 = off
|
||||
# BOR_LLM_TIMEOUT=300 # HTTP timeout for LLM API calls, seconds (default 120)
|
||||
# BOR_LLM_RETRY_DELAY=5 # seconds between LLM retries (phase 67)
|
||||
BOR_LLM_EMBED_MODEL=embed
|
||||
BOR_LLM_SUMMARY_MODEL=lite # one-shot completions: document summaries (phase 30), KB overview (phase 31)
|
||||
|
||||
@@ -80,6 +80,11 @@ class Settings(BaseSettings):
|
||||
#: Flat seconds to wait between attempts (phase 67,
|
||||
#: ``BOR_LLM_RETRY_DELAY``); the TODO-locked 5 s, no backoff.
|
||||
llm_retry_delay: float = 5.0
|
||||
#: HTTP timeout in seconds for LLM API calls (chat + embeddings).
|
||||
#: Increase when long prompt processing or slow models exceed the
|
||||
#: default 120 s (``BOR_LLM_TIMEOUT``; ``0`` = use the OpenAI SDK
|
||||
#: default, which is platform-dependent).
|
||||
llm_timeout: float = 120.0
|
||||
# --- Chat history (phase 74, TODO L4: prior turns + prior thinking) ---
|
||||
#: Newest client-provided history turns kept per ``POST /api/chat``
|
||||
#: (phase 74, ``BOR_HISTORY_MAX_TURNS``): the request's ``history``
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ class LLMClient:
|
||||
self._client = AsyncOpenAI(
|
||||
base_url=self.settings.llm_base_url,
|
||||
api_key=self.settings.effective_api_key,
|
||||
timeout=120.0,
|
||||
timeout=self.settings.llm_timeout,
|
||||
)
|
||||
|
||||
async def _post_embeddings(self, texts: list[str]) -> list[list[float]]:
|
||||
|
||||
Reference in New Issue
Block a user