chore(agent): phase roadmap from TODO.md, 5 phases
Protocol B append (phases 141-145) for the five TODO items: the read-range dedupe false "Already in context" bug (141), the stale KB overview recency ordering + honest intro (142), the mobile keyboard pop after answer completion (143), the document modal back-button history entry (144), and the git original-source links in answers + the doc modal (145). TODO.md cleared (items now live in .agents/phases/todo/). LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Phase 143 — the mobile keyboard stays down after an answer completes
|
||||
|
||||
**Source:** `TODO.md` L5 — "mobile keyboard pops back up after answer is complete and that's annoying"
|
||||
**Story:** n/a (TODO-derived phase, Protocol B append).
|
||||
**Context:** `frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): `// done | error | stop → idle: always settle, always focus back.` … `input.focus({ preventScroll: true })` (L3492) — EVERY turn end (done, error, stop) re-focuses the composer; on a phone, focusing the textarea pops the virtual keyboard the moment the answer completes (the owner's annoyance). The existing `matchMedia` precedent in the file (L391: `reducedMotion` for `prefers-reduced-motion` — the module-level-constant shape); `tests/unit/test_frontend_scroll.py` — `test_turn_end_focus_does_not_scroll` (L213+: the source-level pin that the finally block contains the `preventScroll` focus line — must be EXTENDED, not broken); the composer is a ≥44px tap target on mobile (PLAN §7.1).
|
||||
**Locked (owner-confirmed 2026-09-30, chat):** desktop-only refocus — the post-turn focus-back is skipped on coarse-pointer devices (`matchMedia("(pointer: coarse)")`), kept on desktop so the next question can be typed immediately.
|
||||
|
||||
## Objective
|
||||
A phone user reads the completed answer with the keyboard DOWN: the composer is only re-focused at turn end on fine-pointer (desktop) devices; a coarse-pointer device stays unfocused (the next question starts with a tap on the ≥44px composer). Desktop behavior — including the never-scroll focus contract — is byte-identical.
|
||||
|
||||
## Dependencies
|
||||
- `142_kb_overview_freshness` (todo) — pipeline order only; no code dependency.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **The gate (task 01):** a module-level constant beside the existing `reducedMotion` one (app.js ~L391): `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` (the L391 shape — the `typeof` guard keeps the constant safe where `matchMedia` is absent, the house style for the reduced-motion line). The finally block's focus line becomes GATED: `if (!COARSE_POINTER) input.focus({ preventScroll: true });` with a phase-143 comment explaining WHY (the keyboard pop) and the §7.1 tap-target note. The `preventScroll` argument and the never-scroll contract (phase 42 / the `test_turn_end_focus_does_not_scroll` intent) are UNCHANGED on desktop; the error/stop paths take the SAME gate (one focus line, one gate — the finally block is the single settle).
|
||||
- **The pin (task 01):** `tests/unit/test_frontend_scroll.py::test_turn_end_focus_does_not_scroll` is EXTENDED: the finally block still contains the `preventScroll` focus call (now inside the `if (!COARSE_POINTER)` guard) and the `COARSE_POINTER` constant is defined from `matchMedia("(pointer: coarse)")` — the pin asserts the GATED shape (a bare ungated `input.focus(...)` line in the block is a failure, as today).
|
||||
- **The E2E (task 02):** dedicated file, two contexts — desktop (default Chromium, no touch) and mobile (a Playwright device descriptor with `isMobile` + `hasTouch` — the descriptor's touch emulation makes Chromium report `pointer: coarse`). Each context: a mock-LLM turn to completion (the house E2E fixture — a single on-topic question, the done state, the same assertions as the chat story files), then assert `document.activeElement` — desktop: the composer's textarea (the phase-06 "always focus back" contract, preserved); mobile: NOT the composer (the keyboard stays down). A sanity assert in the mobile test: `page.evaluate(() => matchMedia("(pointer: coarse)").matches)` is true (the emulation is doing its job — if a future Playwright/Chromium change breaks the emulation, the test fails LOUDLY on the sanity line, not mysteriously on the focus line).
|
||||
|
||||
## Tasks
|
||||
1. `01_coarse_pointer_focus_gate.md` — the constant + the gated focus line + the extended source pin.
|
||||
2. `02_e2e_mobile_focus.md` — the dedicated two-context E2E.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the extended source-level pin (the gated focus shape; the constant definition); every other `app.js` pin passes unchanged.
|
||||
- E2E: one dedicated Playwright file, run in isolation, mock-only (desktop + mobile contexts).
|
||||
- Coverage: **>90%** on new/modified code (this phase is frontend — the `app/` coverage gate is unaffected and must stay green).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] desktop: after a turn ends (done, error, stop), the composer is focused with `preventScroll` — byte-identical to pre-phase
|
||||
- [ ] mobile (coarse pointer): after a turn ends, the composer is NOT focused — the keyboard stays down; the user's tap starts the next question
|
||||
- [ ] `uv run pytest` green (the extended pin passes); `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] no behavior change in completed phases (the never-scroll focus contract on desktop, the stop path's single settle, the char counter, the send flow)
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 01 — the coarse-pointer gate on the post-turn focus-back
|
||||
|
||||
**Phase:** `143_mobile_keyboard_after_answer` · **Source:** `TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The turn's `finally` block re-focuses the composer only on fine-pointer devices: a coarse-pointer (mobile) turn end leaves the page unfocused, so the virtual keyboard does not pop back up over the completed answer.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — beside the existing reduced-motion constant (~L391: `const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;`), add the twin constant: `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` — a short phase-143 comment: the coarse-pointer state of THIS device (touch phones/tablets), the keyboard-pop gate for the turn-end focus-back.
|
||||
2. `frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): the last line `input.focus({ preventScroll: true });` becomes:
|
||||
```js
|
||||
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
|
||||
// coarse pointer (a phone/tablet) focusing the composer pops the
|
||||
// virtual keyboard over the completed answer, which is exactly what
|
||||
// the owner found annoying. Fine pointers keep the immediate
|
||||
// refocus (type the follow-up without moving a hand); a coarse
|
||||
// pointer stays unfocused — the composer is a ≥44px tap target
|
||||
// (PLAN §7.1), so the next question starts with a tap. The
|
||||
// never-scroll contract (phase 42) and the single-settle rule are
|
||||
// unchanged on every device — only the focus call is gated.
|
||||
if (!COARSE_POINTER) input.focus({ preventScroll: true });
|
||||
```
|
||||
The comment above the line ("Focus back for the next question, but never move the viewport…") is folded into the phase-143 comment (one comment block, no stale duplicate). The block's SETTLE lines (clearTurnTimeout / stopThinkingClock / cancelStream / setUiState / the stop status line) are UNTOUCHED — the gate wraps the focus call alone.
|
||||
3. `tests/unit/test_frontend_scroll.py` — EXTEND `test_turn_end_focus_does_not_scroll` (L213+):
|
||||
- the finally block (located by the existing `// done | error | stop → idle: always settle, always focus back` anchor — KEEP the anchor comment in app.js verbatim or update the anchor in lockstep) now asserts the GATED shape: `if (!COARSE_POINTER) input.focus({ preventScroll: true })` is in the block, and a bare ungated `input.focus(` occurrence (the pre-phase line) is NOT,
|
||||
- and the constant: `matchMedia("(pointer: coarse)")` appears in the file with the `COARSE_POINTER` binding (the L391-twin shape),
|
||||
- the docstring is updated to state the phase-143 gate (the pin's job is now: focus-back exists, is preventScroll, and is coarse-gated).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the extended pin + every other frontend source pin green (`uv run pytest tests/unit/ -k frontend`).
|
||||
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green: `uv run pytest --cov=app`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] desktop (fine pointer): the turn end focuses the composer with `preventScroll` — the phase-06/42 contract byte-preserved
|
||||
- [ ] coarse pointer: the turn end does NOT focus the composer (the keyboard stays down)
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] no behavior change in completed work (the settle lines, the error/stop paths, the char counter, the send flow — the gate wraps the focus call alone)
|
||||
@@ -0,0 +1,26 @@
|
||||
# Task 02 — the dedicated two-context E2E: the keyboard stays down on mobile
|
||||
|
||||
**Phase:** `143_mobile_keyboard_after_answer` · **Source:** `TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The browser-visible proof on BOTH devices: a completed turn leaves the composer focused on desktop (the preserved contract) and UNFOCUSED on a mobile-emulated context (the owner's fix) — deterministic, mock-only, in one dedicated file.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_mobile_keyboard_focus.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the SSE/done-state waits from the chat story files):
|
||||
- a small shared helper in the file: `turn_to_completion(page)` — asks one on-topic question (the fixture's marker question, the mock answers immediately), waits for the done state (the `aria-live` stream settled / the send button back to its idle contract — the same wait the chat story E2Es use).
|
||||
- test 1 (`test_desktop_turn_end_refocuses_composer`): the DEFAULT Chromium context (no touch — `pointer: fine`): `turn_to_completion` → assert `page.evaluate("document.activeElement")` IS the composer's textarea (locate by the composer's selector — the `input` the app.js module owns; identify it via the existing selector the E2E helpers already use for typing questions). This is the regression guard: the phase-06 "always focus back" contract on desktop.
|
||||
- test 2 (`test_mobile_turn_end_keeps_keyboard_down`): a MOBILE context — a Playwright device descriptor with `isMobile: true` + `hasTouch: true` (e.g. the built-in Pixel/iPhone descriptor, or an explicit `{ viewport, isMobile, hasTouch }` — the mobile ≤640px CSS breakpoint must also apply, so the descriptor's viewport is the deciding factor): FIRST a sanity assert `page.evaluate("matchMedia('(pointer: coarse)').matches")` is TRUE (the emulation flips the media feature — if a future Playwright/Chromium change breaks that, this line fails loudly and names why, instead of the focus assertion failing mysteriously), THEN `turn_to_completion` → assert `document.activeElement` is NOT the composer's textarea (the keyboard stays down — the owner's complaint is gone).
|
||||
- both contexts run against the SAME fixture KB and the SAME question — the only variable is the device.
|
||||
2. Run the regression set in isolation: this file + ONE chat story E2E that exercises the turn-end state (e.g. the phase-06 loading-feedback or the phase-48 stop-generation file — whichever asserts the post-turn UI) to prove the desktop contract survived.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
|
||||
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] desktop context: after a completed turn, the composer is the active element
|
||||
- [ ] mobile context: the `pointer: coarse` sanity assert passes AND after a completed turn the composer is NOT the active element
|
||||
- [ ] `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; the chosen chat story E2E still green in isolation
|
||||
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)
|
||||
Reference in New Issue
Block a user