Files
brain-of-reese/.agents/phases/todo/132_image_upload_feedback/00_phase.md
T
ducoterra 0c8018971f 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
2026-09-27 12:34:16 -04:00

5.6 KiB
Raw Blame History

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)