phase: 104_chip_sizing_question_cap
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:
+54
-1
@@ -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();
|
||||
|
||||
@@ -703,14 +703,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
gap: 0.45rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
/* As flex items these chips must be allowed to shrink (min-width:auto
|
||||
would let a long title-derived chip exceed the column on phones —
|
||||
phase 07 overflow fix); the label text then wraps inside the pill. */
|
||||
.maybe-try .suggestion-chip {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Steering notes (phase 15) ---------- */
|
||||
/* "Tune" button in the meta row of every completed brain bubble: ghost
|
||||
pill, ≥44px, right-aligned after the source chips. ink-soft on surface
|
||||
@@ -1230,6 +1222,21 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* The shared chip pill — both chip rows (the onboarding row in the
|
||||
empty state and the "Maybe try" row under a deflected bubble) reuse
|
||||
it. Phase 104 (owner 2026-09-12, A1): a chip is ONE line at every
|
||||
viewport width — `white-space: nowrap` stops a 400-char question
|
||||
from wrapping the pill into a multi-line "chonk"; `overflow: hidden`
|
||||
zeroes the flex item's automatic minimum size (min-width:auto would
|
||||
pin the item to the full text width), so `max-width: 100%` binds and
|
||||
`text-overflow: ellipsis` clips the text at the row edge — in the
|
||||
desktop wrap row 100% is the chat column, in the ≤640px row
|
||||
(nowrap + overflow-x: auto) 100% is the VISIBLE width and the row
|
||||
scrolls. That clipping is the phase-07 overflow fix the old
|
||||
"Maybe try" chip override used to carry (min-width:0 + max-width:
|
||||
100%) — now fully subsumed here and deleted, provenance folded into
|
||||
this comment so the history lives with the contract. The full text
|
||||
stays one hover away (the title tooltip, phase 104 task 02). */
|
||||
.suggestion-chip {
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
@@ -1242,6 +1249,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.05s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.suggestion-chip:hover { background: var(--brand-soft); }
|
||||
.suggestion-chip:active { transform: scale(0.98); }
|
||||
@@ -1321,6 +1333,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#view-chat:not(.chat-booted) .composer {
|
||||
position: static;
|
||||
}
|
||||
/* Phase 104 (owner 2026-09-12, A4): the question-length counter —
|
||||
right-aligned above the composer, a child of the .chat-bottom sticky
|
||||
unit (between the actions row and the form), hidden until 80% of the
|
||||
4,000-char cap (app.js updateCharCount). It sits on the APP
|
||||
background behind the transparent .chat-bottom unit, so both pairings
|
||||
are verified against --bg: --ink-soft on --bg = 8.6:1 (≥4.5:1, WCAG
|
||||
AA) and the .is-max state's --err-ink on --bg = 10.4:1 (AA). The
|
||||
.is-max state ALSO changes the copy ("— character limit") — text +
|
||||
color, never color alone (B3). */
|
||||
.char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); }
|
||||
.char-count.is-max { color: var(--err-ink); }
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -252,6 +252,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 104 (owner 2026-09-12): the question-length counter —
|
||||
hidden until 80% of the 4,000-char cap (app.js
|
||||
updateCharCount); the .is-max state uses the --err-* family
|
||||
PLUS a copy change (B3: text + color, never color alone). NOT
|
||||
a live region: per-keystroke feedback is decorative — the
|
||||
over-cap failure path announces through the error banner
|
||||
(role=alert). -->
|
||||
<p class="char-count" id="char-count" hidden></p>
|
||||
|
||||
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
||||
roles — #send-btn reads "Send" when idle and morphs into the
|
||||
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
||||
@@ -270,12 +279,16 @@
|
||||
precedent as the tuning form's noValidate). -->
|
||||
<form class="composer" id="composer" novalidate>
|
||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
|
||||
(app/schemas.py) — the server 422s beyond; the #char-count
|
||||
line makes the cap visible (app.js updateCharCount). -->
|
||||
<textarea
|
||||
id="message-input"
|
||||
name="message"
|
||||
rows="1"
|
||||
placeholder="Ask me anything…"
|
||||
autocomplete="off"
|
||||
maxlength="4000"
|
||||
></textarea>
|
||||
<button type="submit" class="send-btn" id="send-btn">
|
||||
<span class="spinner" aria-hidden="true" hidden></span>
|
||||
|
||||
Reference in New Issue
Block a user