phase: 104_chip_sizing_question_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All completion criteria verified. Final report:

**Phase 104 — final verification pass: all green**
- Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite.
- E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed.
- `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅.
- Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**.
- Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree.
- No commit made (harness commits per the execution protocol override).
- Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 19:45:00 -04:00
parent 1f1c01c9f7
commit ecc921098a
31 changed files with 1874 additions and 36 deletions
+54 -1
View File
@@ -299,6 +299,7 @@ bindSharedHeaderControls();
const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state");
const suggestionsEl = document.querySelector("#suggestions");
const charCountEl = document.querySelector("#char-count"); // phase 104: the question-length counter (ships hidden)
const composer = document.querySelector("#composer");
const input = document.querySelector("#message-input");
const sendBtn = document.querySelector("#send-btn");
@@ -1012,6 +1013,7 @@ function appendTruncatedNote(wrap, argument, charsShown, charsTotal) {
function submitSuggestion(text) {
input.value = text;
autoGrow();
updateCharCount(); // phase 104: the chip fill bypasses maxlength — count what landed
input.focus();
composer.requestSubmit();
}
@@ -1028,11 +1030,16 @@ function renderChips(container, items, { onSelect } = {}) {
btn.className = "suggestion-chip";
btn.setAttribute("role", "listitem");
btn.textContent = text;
// phase 104 (owner 2026-09-12): the single-line chip clips long
// questions — `title` is the hover reveal, `aria-label` the
// clipped-case accessible name (the source-chip pattern).
btn.title = text;
btn.addEventListener("click", () => {
submitSuggestion(text);
if (onSelect) onSelect(text, btn);
});
container.appendChild(btn);
if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);
}
return container;
}
@@ -1255,11 +1262,42 @@ export function setUiState(state, errorDetail = "") {
if (state === UI_STATE.error) showErrorBanner(errorDetail);
}
/* Phase 104 (owner 2026-09-12, A3/A4): the visible question-length cap.
MAX_QUESTION_CHARS mirrors ChatRequest.message max_length=4000
(app/schemas.py — the source of truth; the server 422s beyond it) and
must stay equal to the #message-input maxlength (cross-file pin in
tests/unit/test_chip_sizing_question_cap.py). The counter shows only
from 80% of the cap — no noise on normal use (owner A4). */
const MAX_QUESTION_CHARS = 4000;
const CHAR_COUNT_SHOW_AT = 3200; // 80% of the cap — visible only when it matters
function autoGrow() {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
}
/* Phase 104 (owner 2026-09-12, A4): the counter state — hidden below the
80% threshold, plain `len/4000` above it, and `len/4000 — character
limit` + the .is-max (–err-*) treatment at/over the cap. The over-cap
reading is the HONEST length (the programmatic chip-fill path can
exceed maxlength — e.g. `5123/4000 — character limit`). */
function updateCharCount() {
// RAW length (no trim): raw ≤ cap ⟹ trimmed ≤ cap, so the raw
// count is a safe superset of what the server validates.
const len = input.value.length;
if (len < CHAR_COUNT_SHOW_AT) {
charCountEl.hidden = true;
charCountEl.classList.remove("is-max");
return;
}
charCountEl.hidden = false;
const atMax = len >= MAX_QUESTION_CHARS;
charCountEl.classList.toggle("is-max", atMax);
charCountEl.textContent = atMax
? `${len}/${MAX_QUESTION_CHARS} — character limit`
: `${len}/${MAX_QUESTION_CHARS}`;
}
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
/* Cancel a response body without leaking an unhandled rejection:
@@ -2014,6 +2052,7 @@ function startNewChat() {
setUiState(UI_STATE.idle);
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the cleared composer hides the counter again
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
@@ -2111,12 +2150,21 @@ async function handleSend(e) {
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
// Phase 104 (owner 2026-09-12, A5): maxlength caps typing + pastes, but
// a programmatic fill (the chip one-tap path) bypasses it — this guard
// is the never-stale backstop (PLAN §7.4): no turn, no clear, the user
// trims the kept text (out-of-turn banner, the saveAsDoc precedent).
if (text.length > MAX_QUESTION_CHARS) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false });
}
@@ -2481,7 +2529,12 @@ async function runTurn(text, { reask = false } = {}) {
}
}
input.addEventListener("input", autoGrow);
// Phase 104: every input-path change (keystroke, paste — maxlength
// caps both at 4,000) re-runs the counter alongside the auto-grow.
input.addEventListener("input", () => {
autoGrow();
updateCharCount();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();