chore(agent): phase roadmap from TODO.md, 7 phases (127-133)
Converts the TODO list into the phased-execution roadmap (Protocol B append): incremental folder summaries on sync (127), the mobile sync label with the pinned (n/m) count (128), per-chunk embed progress in the sync label (129), chat answer clusters with the bottom-right actions row (130), read(offset/limit) line ranges for huge documents (131), visible chat image-upload feedback (132), and the garbage-PDF text gate with the page-screenshot vision fallback (133 — includes the diagnosed root cause of the hnb guide's pre-phase-126 binary import). Each phase carries its dedicated in-isolation Playwright suite; TODO.md is cleared (items now live in .agents/phases/todo/). LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# Phase 132 — Chat image upload: visible "uploading" feedback
|
||||
|
||||
**Source:** `TODO.md` L8 — "Uploading an image in the chat provides no feedback to the user. The user clicks the paperclick, selects and image, presses 'send' and then nothing happens. No loading bar, no 'uploading image', no indication. This results in the user spamming 'send' over and over because they think it's frozen. We need to visually show the user's image is uploading."
|
||||
(A5, owner-confirmed 2026-09-27: the affordance = a spinner + "Uploading image…" in the preview strip AND the Send button disabled with the label "Uploading…" — the double-spam is blocked visually, not just by the internal guard.)
|
||||
**Story:** n/a (TODO-derived phase, Protocol B append).
|
||||
**Context:** `frontend/assets/app.js` — `handleSend` (~L2700: the `attachedImage` branch — the `attachUpload` double-fire guard, `await uploadAttachedImage(attachedImage.file)` with NO visual state between the await's start and its resolution, then `image = { path, src, alt }` + `runTurn`), `uploadAttachedImage` (~L2641: the `POST /api/chat-images` multipart — every failure path is an out-of-turn banner + null), `showAttachPreview` / `clearAttachedImage` (~L2680: the strip's reveal/clear — static markup, only `textContent`/`src` move), `setUiState` (the turn-state owner — NOT used for the upload: the upload is a PRE-turn operation, `uiState` stays `idle`, the turn loader must not spin during the upload); `frontend/index.html` L280–322 — the static attach markup (`#attach-preview` with `img` + `.attach-preview-name` + `#attach-remove`; `#send-btn` with `#send-label`); `frontend/assets/styles.css` — the `.attach-preview*` / `.send-btn` rules (the existing `:disabled` styles); `tests/unit/test_frontend_sync_upload.py` or a sibling — the pinned frontend JS unit pattern.
|
||||
|
||||
## Objective
|
||||
Between the user pressing Send and the upload resolving, the UI proves the image is moving: the preview strip shows a spinner + "Uploading image…" (the remove button disabled), and the Send button is disabled with the label "Uploading…". When the upload resolves — success proceeds to the turn exactly as today; failure clears the state and keeps the existing banner + the typed text + the attachment for the retry. No frozen-looking gap, no visible double-send.
|
||||
|
||||
## Dependencies
|
||||
- `131_read_line_ranges` (todo) — pipeline order only; code dependencies are the completed phase-123 attach/upload contract.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **State scope (task 01):** the uploading state is the `attachedImage` branch of `handleSend` — from just after the `attachUpload = true` guard until the `attachUpload = false` line (success AND failure paths — the helper never throws). `setUiState` is NOT touched (the upload is pre-turn; `uiState` stays `idle`; the turn's own thinking/streaming feedback starts only when `runTurn` does). The `attachUpload` guard stays exactly as is (the visual state reinforces it — the disabled Send is the user-facing half).
|
||||
- **Preview strip (task 01):** the static markup gains ONE status element inside `#attach-preview`: `<span class="attach-preview-status" hidden><svg spinner…></svg><span class="attach-preview-status-text">Uploading image…</span></span>`. The JS sets `attachPreview.classList.add("is-uploading")` + un-hides the status + `attachRemove.disabled = true` on entry; on exit it removes the class + hides the status + `attachRemove.disabled = false`. The thumbnail + filename stay visible (the user watches THEIR image uploading).
|
||||
- **Send button (task 01):** `sendBtn.disabled = true` + `sendLabel.textContent = "Uploading…"` on entry; on exit (both paths) `sendBtn.disabled = false` + `sendLabel.textContent = "Send"` — restored BEFORE the success path calls `runTurn` (the turn's `setUiState("thinking")` then takes over the button as usual) and BEFORE the failure path returns (the banner + editable input + the still-attached image).
|
||||
- **Reduced motion (task 02):** the spinner animation is still under `prefers-reduced-motion` (the house `@media (prefers-reduced-motion: reduce)` block pattern).
|
||||
|
||||
## Tasks
|
||||
1. `01_upload_state.md` — the static markup + the uploading state in `handleSend` (enter/exit on both paths).
|
||||
2. `02_css_upload_state.md` — the strip's uploading styles + the disabled Send + reduced motion.
|
||||
3. `03_e2e_upload_feedback.md` — dedicated Playwright E2E, run in isolation.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pinned frontend JS suite — the state transitions (the strip's class/status/remove-disabled, the Send's disabled/label) on entry, on success exit, on failure exit (the banner path).
|
||||
- E2E: one dedicated Playwright file, run in isolation, with a route-delayed upload (Playwright `page.route` delaying `POST /api/chat-images`) so the in-flight window is observable.
|
||||
- Coverage: **>90%** on new/modified code (frontend: unit pins + E2E; `app/` untouched this phase).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] while the upload is in flight: the strip shows the spinner + "Uploading image…", the remove button is disabled, the Send button is disabled with "Uploading…" (E2E-asserted mid-flight)
|
||||
- [ ] a second Send press during the flight does nothing (no second upload request — E2E request count)
|
||||
- [ ] success: the indicator clears and the turn proceeds exactly as pre-phase (the user bubble carries the image); failure: the indicator clears, the banner shows, the text + attachment remain
|
||||
- [ ] `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov` green in isolation
|
||||
- [ ] no behavior change in completed phases (the phase-123 upload contract, the A5 path-never-bytes rule, the out-of-turn banner)
|
||||
@@ -0,0 +1,49 @@
|
||||
# Task 01 — The uploading state: markup + `handleSend` enter/exit on both paths
|
||||
|
||||
**Phase:** `132_image_upload_feedback` · **Source:** `TODO.md:8` — "Uploading an image in the chat provides no feedback to the user. ... We need to visually show the user's image is uploading."
|
||||
|
||||
## Objective
|
||||
The `attachedImage` branch of `handleSend` enters a visible uploading state the moment the upload starts and clears it the moment the upload resolves — success or failure — so the user always sees whether the app is moving.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` (L280–284, the `#attach-preview` block) — add the status element INSIDE the strip (after the name span, before the remove button), static + hidden by default (the house "ships hidden" rule):
|
||||
```html
|
||||
<span class="attach-preview-status" hidden>
|
||||
<svg class="attach-preview-status-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>
|
||||
<span class="attach-preview-status-text">Uploading image…</span>
|
||||
</span>
|
||||
```
|
||||
(The spinner arc is the house loading glyph — check `#turn-loader`'s icon for the exact shape and reuse it; the spinner's spin animation is the CSS task. `aria-hidden` on the svg — the text carries the meaning; the strip's `role`/label are untouched.)
|
||||
2. `frontend/assets/app.js` — get the new elements at module scope with the existing attach-family consts (L313–316, the null-guard pattern): `attachStatus = document.querySelector(".attach-preview-status")`, `attachStatusText` (not needed — the text is static), alongside `attachPreview`/`attachRemove`.
|
||||
3. `frontend/assets/app.js` — two tiny helpers next to `showAttachPreview`/`clearAttachedImage`:
|
||||
```js
|
||||
function enterUploadState() {
|
||||
if (attachPreview) attachPreview.classList.add("is-uploading");
|
||||
if (attachStatus) attachStatus.hidden = false;
|
||||
if (attachRemove) attachRemove.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
sendLabel.textContent = "Uploading…";
|
||||
}
|
||||
function exitUploadState() {
|
||||
if (attachPreview) attachPreview.classList.remove("is-uploading");
|
||||
if (attachStatus) attachStatus.hidden = true;
|
||||
if (attachRemove) attachRemove.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
sendLabel.textContent = "Send";
|
||||
}
|
||||
```
|
||||
(The label literals "Send"/"Uploading…" — check the idle label's exact current text in the markup/`setUiState` and match it byte-for-byte; if `setUiState` owns the idle label restore, use its restore helper instead of a literal — inspect first.)
|
||||
4. `frontend/assets/app.js` — `handleSend`, the `attachedImage` branch (~L2700):
|
||||
- after the `attachUpload = true;` line, BEFORE `await uploadAttachedImage(...)`: `enterUploadState();`
|
||||
- on the `attachUpload = false;` line (the helper never throws — this is reached on BOTH success and failure): `exitUploadState();` — the success path then continues to `image = { path, src, alt }` + the rest of the send (the turn's `setUiState` takes the button over); the failure path (`path === null`) returns with the banner shown, the input text intact, and the attachment still attached (the strip is back in its normal preview state — the user can retry or remove).
|
||||
- Do NOT touch the `attachUpload` guard, the `uploadAttachedImage` helper, or `clearAttachedImage` (the New-chat reset already hides the strip — the uploading class is transient and never persists: if a New chat happened mid-upload, the strip is hidden; the class on a hidden element is harmless, and `exitUploadState` still clears it when the upload resolves).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pinned frontend JS suite (extend `tests/unit/test_frontend_sync_upload.py` or the chat-frontend pin file — inspect what exists): the state transitions — entry sets the class/status/unhides/disables remove/disables Send with "Uploading…"; exit (success) clears everything and restores "Send"; exit (failure) clears everything the same way (the banner is the failure's signal, not the strip's); a second `handleSend` during the flight is a no-op (the existing `attachUpload` guard pin).
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the uploading state is entered exactly once per upload (after the guard, before the await) and exited on BOTH resolution paths
|
||||
- [ ] `uiState`/`setUiState`/the turn loader are untouched (the upload is pre-turn — the loader spins only when the turn starts)
|
||||
- [ ] the idle label after exit matches the pre-phase idle label byte-for-byte
|
||||
- [ ] `uv run pytest tests/unit -q` green; no behavior change in completed work
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task 02 — CSS: the strip's uploading look, the disabled Send, reduced motion
|
||||
|
||||
**Phase:** `132_image_upload_feedback` · **Source:** `TODO.md:8` — "Uploading an image in the chat provides no feedback to the user. ... We need to visually show the user's image is uploading."
|
||||
|
||||
## Objective
|
||||
The uploading state reads as "in motion": the strip's thumbnail dims with a spinning arc + the "Uploading image…" text beside it, and the Send button shows the disabled affordance the app already has for a disabled Send — no new color, no new weight, the existing `--ink-soft`/opacity language.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — with the other `.attach-preview*` rules (grep the existing block first — read the strip's current layout: the thumbnail, the name, the remove button):
|
||||
```css
|
||||
/* Phase 132: the uploading state — the strip proves the image is
|
||||
moving (the TODO: no feedback → the user spams Send). The
|
||||
thumbnail dims, the spinner arc spins, the status text stands in
|
||||
the name's place. */
|
||||
.attach-preview.is-uploading .attach-preview-img {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.attach-preview.is-uploading .attach-preview-name {
|
||||
visibility: hidden; /* keep the layout; the status takes its spot */
|
||||
}
|
||||
.attach-preview-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem; /* the strip's small text (match the name's size) */
|
||||
min-width: 0;
|
||||
}
|
||||
.attach-preview-status-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
animation: spin 1s linear infinite; /* the .sync-icon spinner (the house spin keyframes) */
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.attach-preview-status-icon { animation: none; }
|
||||
}
|
||||
.attach-preview.is-uploading .attach-preview-remove {
|
||||
opacity: 0.5; /* the disabled attribute's visual half (the house :disabled language) */
|
||||
}
|
||||
```
|
||||
(Match the EXISTING values — the strip's font sizes, the `spin` keyframes' name (verify they exist — the `.sync-icon.is-spinning` rule uses `spin`), the disabled-button opacity. If the strip's name span has `overflow: hidden; text-overflow: ellipsis` (a long filename), the status span inherits the row's clamp — verify the "Uploading image…" text never clips at 320 px; if it does, let the status text wrap or ellipsize per the strip's existing pattern.)
|
||||
2. `frontend/assets/styles.css` — verify the `#send-btn:disabled` rule exists and reads as disabled (the Send is disabled via the `disabled` attribute — the existing rule applies; if the disabled Send currently looks identical to the enabled one, add the house `opacity`/`cursor: wait` there — inspect the current rule first, do not invent a new visual).
|
||||
3. Theme audit: every value used is a theme variable or a neutral (no raw hex/rgb — the B4 byte-identical built-in theme contract; the status text's `--ink-soft` on the strip's background meets 4.5:1 — the strip's bg is `--bg-soft` or similar, verify the pair's contrast the way the theme phases did).
|
||||
|
||||
## Testing & Quality
|
||||
- Visual gate: the E2E (task 03) — the status is visible mid-flight, the thumbnail is dimmed (an `opacity` computed-style assertion is optional — the visibility of the status text is the pin).
|
||||
- Coverage: **>90%** on new/modified code (CSS has no direct coverage — the E2E + unit pins carry it).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the uploading strip shows the spinning arc + "Uploading image…" with the dimmed thumbnail (E2E-asserted mid-flight)
|
||||
- [ ] `prefers-reduced-motion` stills the arc (the house block)
|
||||
- [ ] the disabled Send reads as disabled (the existing rule, verified)
|
||||
- [ ] `uv run pytest` green; no behavior change in completed phases
|
||||
@@ -0,0 +1,25 @@
|
||||
# Task 03 — E2E: the upload window shows "uploading", blocks the double-send, then the turn proceeds
|
||||
|
||||
**Phase:** `132_image_upload_feedback` · **Source:** `TODO.md:8` — "Uploading an image in the chat provides no feedback to the user. The user clicks the paperclick, selects and image, presses 'send' and then nothing happens. ... This results in the user spamming 'send' over and over because they think it's frozen. We need to visually show the user's image is uploading."
|
||||
|
||||
## Objective
|
||||
A dedicated, mock-only Playwright suite (A16 — one per phase, run in isolation) proving the owner-visible fix: while `POST /api/chat-images` is in flight (route-delayed so the window is observable), the strip shows the uploading state, the Send button is disabled with "Uploading…", a second press produces NO second upload request — and once the upload resolves, the state clears and the turn proceeds exactly as pre-phase.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_image_upload_feedback.py` (new) — house pattern (module app fixture over the shared E2E Postgres, autouse cleanup, mock-only). The app boots with `BOR_IMAGES=1` (the `/api/config` `images: true` gate reveals `#attach-btn` — follow `tests/e2e/test_chat_image_questions.py`'s app env). KB fixture: a minimal local source with ONE text doc (the mock's grounded answer for a plain question — the turn after the upload needs to complete normally).
|
||||
2. The upload window (Playwright's `page.route`): intercept `POST /api/chat-images` — `route.waitForRequest`-style: the handler DELAYS (e.g. `await asyncio.sleep(2.0)` in the route handler, or `page.route` + a fixture that holds the response) then `route.continue_()` — the server's real handler runs, the response is just late. Count the intercepted upload requests (a module counter) for the double-send assertion. A small PNG fixture (the house test image — reuse the existing one the image E2E suites use; do not commit a new binary if one exists).
|
||||
3. Test → observable mapping (Playwright Mapping Rule):
|
||||
- `test_upload_window_shows_uploading_and_blocks_resend` — admin or anonymous (chat is public — anonymous is the simpler context) page on the chat view; click `#attach-btn` → `#attach-file` set_input_files (the small PNG); the strip is revealed (the phase-123 preview — `#attach-preview` not hidden, the thumbnail's `src` set); type a question; click Send. MID-FLIGHT (poll for the state within the delayed window): `#attach-preview` has the `is-uploading` class; `.attach-preview-status` is visible (not hidden) with the text "Uploading image…"; `#attach-remove` is disabled; `#send-btn` is disabled with `#send-label` text "Uploading…". THEN press `#send-btn` twice more (the user's exact spam — the button is disabled so the presses are no-ops; also try an Enter-to-submit while disabled — the form submit's `sendBtn.disabled` guard) → after the window, the upload-request counter is EXACTLY 1 (no double upload, no second turn).
|
||||
- `test_upload_success_clears_and_turn_proceeds` — the SAME flow, past the window: the `is-uploading` class is gone, `.attach-preview-status` hidden, `#attach-remove` enabled, `#send-label` back to the idle "Send"; the strip HIDEs with the send (the phase-123 contract — the preview clears when the question goes out); the user bubble renders with the attached image (the phase-123 `attachBubbleImage` — the data-URL `img`), and the turn completes (the mock's grounded answer streams to `done` — the brain bubble is non-empty).
|
||||
- `test_upload_failure_clears_state_and_keeps_the_draft` — a SECOND app/route that FAILS the upload (the route aborts or the handler is pointed at a 413 shape — the simplest: `route.abort("failed")` → `uploadAttachedImage`'s network-drop banner path): mid-flight the uploading state is visible (same assertions); after resolution: the state is CLEARED (no `is-uploading`, Send re-enabled with "Send"), the out-of-turn banner is shown ("Couldn't attach the image — try again." — the network-drop copy), the input text is INTACT, and the strip is back in its normal preview (the attachment remains for the retry — `#attach-preview` not hidden, no `is-uploading`).
|
||||
4. Header docstring: the run-in-isolation command (`uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`), the mock-only gate, the route-delay rationale (the in-flight window is otherwise ~50 ms — unsamplable), the fixture notes, the test→observable table.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: this file IS the task's suite — run in isolation, green.
|
||||
- Coverage: the phase's >90% bar is carried by task 01's unit pins; this task adds the in-isolation Playwright gate.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov` green in isolation (DB up: `podman compose up -d db`)
|
||||
- [ ] the mid-flight assertions are deterministic (the route delay makes the window ≥ 2 s — the UI's no-poll direct state, asserted by waiting for the class, not a race)
|
||||
- [ ] exactly ONE upload request per Send-press sequence (the spam is visibly AND functionally dead)
|
||||
- [ ] no behavior change in completed phases (the phase-123 upload contract, the A5 path-never-bytes rule, the out-of-turn banner)
|
||||
Reference in New Issue
Block a user