From ffa6bda3e52b576d0ac46c5f526193bc38cc7180 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sat, 12 Sep 2026 10:52:51 -0400 Subject: [PATCH] add phases for fixing suggestion chips --- .../00_phase.md | 46 ++++++++ .../01_opener_extraction.md | 34 ++++++ .../02_openers_docs.md | 21 ++++ .../03_e2e_suite_commit.md | 33 ++++++ .../104_chip_sizing_question_cap/00_phase.md | 106 ++++++++++++++++++ .../01_chip_ellipsis_css.md | 33 ++++++ .../02_chip_tooltip_aria.md | 30 +++++ .../03_composer_question_cap.md | 91 +++++++++++++++ .../04_e2e_suite_commit.md | 41 +++++++ 9 files changed, 435 insertions(+) create mode 100644 .agents/phases/todo/103_suggestions_session_openers/00_phase.md create mode 100644 .agents/phases/todo/103_suggestions_session_openers/01_opener_extraction.md create mode 100644 .agents/phases/todo/103_suggestions_session_openers/02_openers_docs.md create mode 100644 .agents/phases/todo/103_suggestions_session_openers/03_e2e_suite_commit.md create mode 100644 .agents/phases/todo/104_chip_sizing_question_cap/00_phase.md create mode 100644 .agents/phases/todo/104_chip_sizing_question_cap/01_chip_ellipsis_css.md create mode 100644 .agents/phases/todo/104_chip_sizing_question_cap/02_chip_tooltip_aria.md create mode 100644 .agents/phases/todo/104_chip_sizing_question_cap/03_composer_question_cap.md create mode 100644 .agents/phases/todo/104_chip_sizing_question_cap/04_e2e_suite_commit.md diff --git a/.agents/phases/todo/103_suggestions_session_openers/00_phase.md b/.agents/phases/todo/103_suggestions_session_openers/00_phase.md new file mode 100644 index 0000000..4f291ec --- /dev/null +++ b/.agents/phases/todo/103_suggestions_session_openers/00_phase.md @@ -0,0 +1,46 @@ +# Phase 103 — Onboarding chips suggest only session-opening questions, never follow-ups + +**Source:** Owner request (chat, 2026-09-12) — "The suggested questions on the chat page should not include follow-up questions from a session. The problem is this: Users will ask 'What are the correct arguments for qwen 3.8 27b on llama.cpp?' and BOR will answer. Then, users will ask 'What about qwen 3.6 35b?'. That second question … shows up as a suggested question to *start* a conversation but that question makes no sense without the context behind it. The only questions that should show up as a suggested question are those at the very beginning of a session." +**Story:** n/a (owner request — tightens the onboarding-chips contract of `80_history_suggestion_chips`; the story file `suggestion-chips.md` cited by phases 05/80 no longer exists in `.agents/user_stories/`). +**Context:** `app/api/suggestions.py` — `last_questions(db, limit=3)` walks each saved chat's `messages` in REVERSE (newest-first) and collects every user question (the contract this phase replaces); `GET /api/suggestions` (the `require_user`-gated endpoint, phase 79) with the seed fallback (`get_settings().suggestions` — `BOR_SUGGESTIONS` / built-in) when the walk yields nothing. `app/models.py` — `SavedChat.messages` is the raw `bor.chat.v1` JSONB list in CONVERSATIONAL order (oldest→newest); `SavedChat.title` is the first user question truncated to 120 chars + whitespace-collapsed at save time (`app/api/chats.py` `_auto_title`, `_AUTO_TITLE_MAX = 120`) and user-editable on re-Save. `tests/integration/test_suggestions_api.py` — the phase-80 state matrix (REWRITTEN in task 01). `tests/e2e/test_suggestion_chips.py` — the phase-80 story suite (REWRITTEN in task 03, the phase-76/80 precedent). Docs carrying the "last 3 questions asked" wording: `app/config.py` ~L373-377 (the `suggestions` seed docstring), `.env.example` ~L60 (the `BOR_SUGGESTIONS` comment), `README.md` ~L74. + +## Objective +A suggested question must make sense on its own: the onboarding chips are the **first user question of each saved chat** (the session's opening question) — follow-up questions ("What about …?") can never appear, because they are unanswerable without the session behind them. Walk order, dedup, the cap of 3, and the seed fallback are unchanged. + +## Owner decisions (chat, 2026-09-12 — recorded per AGENTS.md rule 3) +- **A1 — openers only:** "The only questions that should show up as a suggested question are those at the very beginning of a session." Each saved chat contributes AT MOST ONE chip: its first user question. In the owner's example, "What are the correct arguments for qwen 3.8 27b on llama.cpp?" (the opener) may chip; "What about qwen 3.6 35b?" (the follow-up) may not. +- **A2 — everything else unchanged:** chats still walked newest-`updated_at` first (`created_at` tiebreak), EXACT (case-sensitive) de-dup, cap 3 applied AFTER dedup, seed fallback when the walk yields zero openers (`BOR_SUGGESTIONS` override or built-in default) — all phase-80 contracts survive. The deflection "Maybe try" chips (`app/rag/suggestions.py` `derive_suggestions`, carried in the chat response) are a separate contract and untouched. The FRONTEND is untouched — the chip row renders whatever the endpoint returns (chip sizing/truncation is phase 104's job). +- **A3 — the defensive opener rule:** a chat's opener is its first `who == "user"` message whose trimmed `text` is non-blank. A LEADING blank user entry (the UI cannot produce one — `handleSend` trims and guards `!text`) does not disqualify the chat; a record with no non-blank user message (brain-only, or blank-user-only) contributes nothing. +- **A4 — read `messages`, not `title`:** `SavedChat.title` is truncated to 120 chars + whitespace-collapsed at save time and is user-editable on re-Save — the chips must carry the EXACT full opener text from the raw `bor.chat.v1` record (the phase-80 precedent: no SQL JSON ops, the deserialized list). + +## Design (shared by all tasks — the executor reads this, not the chat) +- **`app/api/suggestions.py` — the ONLY file changed in `app/`:** + - `last_questions` is RENAMED `opening_questions` (the old name would lie about the semantics; the helper is module-internal — its only caller is the endpoint, the tests hit the endpoint). Signature unchanged: `(db: Session, limit: int = 3) -> list[str]`. + - **The new walk:** for each chat in `updated_at DESC, created_at DESC` order, walk `chat.messages or []` FORWARD (oldest→newest — `bor.chat.v1` conversational order), take the first entry with `who == "user"` whose trimmed `text` is non-blank (the opener, per A3); if found and not already `seen` (exact, case-sensitive), append it; stop once `limit` UNIQUE openers are collected. Result in encounter order (newest chat first). No other endpoint change: `qs = opening_questions(db)` → `SuggestionList(suggestions=qs if qs else get_settings().suggestions)`. + - **Docstrings (the house dense-docstring style):** the module docstring's phase-80 paragraph becomes the opener contract — WHY follow-ups are excluded (a follow-up like "What about X?" is meaningless as a conversation starter — the owner's llama.cpp/qwen example); the function docstring documents the forward walk, the A3 rule, the A4 why-not-title note, and that dedup/cap/order are the phase-80 contracts; the endpoint docstring says "the opening questions of the 3 most recent saved chats — or, before any question has ever been saved, the seed list". +- **Not touched:** schemas, models, migrations, `app/rag/suggestions.py` (deflection), all of `frontend/` (the chips render the endpoint's list — truncation of long chips is phase 104), the auth gate. +- **Docs (task 02):** `app/config.py` seed docstring, `.env.example` `BOR_SUGGESTIONS` comment, `README.md` chat-features line — "the last 3 questions asked" → "the opening questions of the 3 most recent saved chats (the session openers, newest first)". + +## Dependencies +- `80_history_suggestion_chips` (complete) — the endpoint, the seed fallback, the dedup/cap/order contracts, the story E2E suite this phase rewrites. +- `79_api_tokens` (complete) — the `require_user` gate; the tests sign in first (unchanged). +- `102_extensionless_filenames` (todo) — queue order only (numeric); no code dependency (different subsystem). + +## Tasks +1. `01_opener_extraction.md` — the `opening_questions` rewrite (rename + forward walk + docstrings) + the integration matrix rewrite. +2. `02_openers_docs.md` — the "last 3 questions" → "session openers" wording in config / `.env.example` / README. +3. `03_e2e_suite_commit.md` — the story-suite rewrite to the opener semantics + regression E2Es + full gate + atomic commit. + +## Testing & Quality +- Integration — REWRITTEN `tests/integration/test_suggestions_api.py`: the full opener matrix (one chat's follow-ups never surface; the cap now binds ACROSS chats; opener dedup; case variants; the A3 leading-blank rule; brain-only → seed; auth 401 — full detail in task 01). +- E2E — REWRITTEN `tests/e2e/test_suggestion_chips.py` (the phase-76/80 precedent: a semantic change rewrites the story suite in place), run in isolation: the opener-only core state (a 3-turn chat yields EXACTLY its opener as the single chip), the three-openers state, partial, seed, refetch-on-New-chat, plus the carried-over phase-05 behavior (one-tap submit, keyboard walk, the mobile single horizontal-scroll row). +- Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`) — the changed file is `app/api/suggestions.py`, every branch of the new walk covered by the matrix. + +## Completion Criteria +- [ ] One saved chat with 4 user questions → the chip row holds EXACTLY its first question; none of the 3 follow-ups appears (integration + E2E pins). +- [ ] Four saved chats (each multi-turn) → exactly the 3 newest chats' OPENERS; the oldest opener is dropped by the cap; no follow-up text anywhere. +- [ ] Seed fallback, dedup, case-variant, partial (2 chips), brain-only/blank (A3), and 401 pins all green. +- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation; `test_responsive_polish.py` + `test_chat_persistence.py` green in isolation (regressions). +- [ ] The deflection "Maybe try" chips are UNCHANGED (`derive_suggestions` + its suites green). +- [ ] `uv run pytest` green; coverage >90%; `uv run ruff check . && uv run pyright` clean. +- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `fix(chat): onboarding chips are the session-opening questions, never follow-ups`); phase dir moved to `.agents/phases/complete/`. diff --git a/.agents/phases/todo/103_suggestions_session_openers/01_opener_extraction.md b/.agents/phases/todo/103_suggestions_session_openers/01_opener_extraction.md new file mode 100644 index 0000000..2d3355b --- /dev/null +++ b/.agents/phases/todo/103_suggestions_session_openers/01_opener_extraction.md @@ -0,0 +1,34 @@ +# Task 01 — The `opening_questions` extraction + the integration matrix rewrite + +**Phase:** `103_suggestions_session_openers` · **Story:** n/a (owner request) + +## Objective +The endpoint's helper collects each saved chat's OPENING question (the first non-blank user message) instead of every user question — and the integration matrix pins the new semantics end to end. + +## Work +1. `app/api/suggestions.py`: + - Rename `last_questions` → `opening_questions` (update the endpoint's call site; the helper is module-internal — no other importers; verify with `grep -rn "last_questions" app/ tests/`). + - Replace the REVERSE walk with the FORWARD opener walk (the 00_phase.md Design): for each chat in `updated_at DESC, created_at DESC` order, scan `chat.messages or []` oldest→newest; the first entry with `who == "user"` and a non-blank trimmed `text` is the chat's opener (A3 — a leading blank user entry does NOT disqualify the chat; keep scanning until a non-blank user message is found or the list is exhausted). Append the opener if not in `seen` (EXACT, case-sensitive — the phase-80 contract, the docstring keeps the why: case-insensitive dedup would drop a legitimately differently-cased re-ask). Stop once `limit` UNIQUE openers are collected; result in encounter order (newest chat first). + - Docstrings (house dense style): the module docstring's phase-80 paragraph → the opener contract + WHY (a follow-up like "What about X?" is meaningless as a conversation starter — cite the owner's qwen/llama.cpp example); the function docstring → the forward walk, the A3 rule, the A4 why-not-`SavedChat.title` note (120-char truncation + whitespace collapse at save, user-editable on re-Save — `app/api/chats.py` `_auto_title`), dedup/cap/order unchanged from phase 80; the endpoint docstring → "the opening questions of the 3 most recent saved chats — or, before any question has ever been saved, the seed list". +2. `tests/integration/test_suggestions_api.py` — REWRITE the matrix (keep the module's fixture/helper/auth scaffolding: the `clean_chats` autouse truncate, `_user`/`_brain`/`_add_chat`/`_chips`, the fixed `Q_*` texts — extend with a follow-up-flavored text such as `FOLLOW_UP = "What about qwen 3.6 35b?"` for the core pin): + - Module docstring → the new contract (openers only; everything else phase 80). + - `test_empty_db_returns_seed` — unchanged. + - REPLACE `test_cap_three_and_newest_first_within_a_chat` → `test_a_chats_follow_ups_never_surface`: ONE chat with 4 user questions (brain replies between, the 4th a follow-up-flavored text) → the chips are EXACTLY `[Q_ONE]` — the chat's opener only; Q_TWO/Q_THREE/Q_FOUR (the follow-ups) absent. + - NEW `test_cap_three_across_chats`: FOUR chats, each a multi-turn conversation (opener + at least one follow-up), DISTINCT explicit `updated_at` stamps → the chips are exactly the 3 NEWEST chats' openers, newest first; the oldest chat's opener is dropped (the cap now binds across chats); none of the four chats' FOLLOW-UPS appears anywhere. + - REWRITE `test_newer_chat_walked_first` → two multi-turn chats with DISTINCT `updated_at` → `[newer chat's opener, older chat's opener]`; the older chat's LAST (newest-looking) question is NOT in the chips. + - REWRITE the dedup pair → opener-flavored: the SAME opener text as the first question of two chats → exactly once (a verbatim re-ask as a FOLLOW-UP in the newer chat stays deduped too); a differently-cased OPENER variant → both kept (exact dedup). + - Keep the partial pins (2 chats → 2 openers; 1 chat → 1 chip — the follow-ups in those chats do not pad the row). + - Keep `test_brain_and_blank_user_texts_contribute_nothing` (all-brain + blank user → nothing). + - NEW (A3) `test_leading_blank_user_entry_does_not_disqualify`: a chat whose messages start `[user " ", user Q_TWO, …]` → contributes `Q_TWO` (the first NON-BLANK user message is the opener). + - Keep `test_all_brain_deployment_returns_seed` and `test_anonymous_is_401` unchanged. +3. Run `uv run pytest tests/integration/test_suggestions_api.py -v` (DB up: `podman compose up -d db`) — green. + +## Testing & Quality +- Integration: the rewritten matrix IS this task's test layer — every branch of the new walk is exercised: zero chats (seed via the empty test), opener found / no non-blank user message (brain-only), leading-blank skip (A3), dedup hit, case variant kept, cap stop (across chats), encounter order. +- Coverage: **>90%** on this task's modified code — `app/api/suggestions.py` is fully covered by the matrix (the helper's every branch; the full-suite gate runs at the phase's end). + +## Completion Criteria +- [ ] `opening_questions` walks FORWARD, takes ONE opener per chat (first non-blank user message), keeps the phase-80 order/dedup/cap/seed contracts; the old `last_questions` name is gone from `app/` and `tests/` +- [ ] The docstrings state the WHY (follow-ups are context-free) and the A3/A4 rules +- [ ] `uv run pytest tests/integration/test_suggestions_api.py -v` green (DB up) +- [ ] No other `app/` or `tests/` file changed (the E2E rewrite is task 03; docs are task 02) diff --git a/.agents/phases/todo/103_suggestions_session_openers/02_openers_docs.md b/.agents/phases/todo/103_suggestions_session_openers/02_openers_docs.md new file mode 100644 index 0000000..8771dee --- /dev/null +++ b/.agents/phases/todo/103_suggestions_session_openers/02_openers_docs.md @@ -0,0 +1,21 @@ +# Task 02 — The "session openers" wording: config / `.env.example` / README + +**Phase:** `103_suggestions_session_openers` · **Story:** n/a (owner request) + +## Objective +The three doc surfaces that still say "the last 3 questions asked" (the phase-80 wording) describe the NEW contract — the chips are the session-OPENING questions; the seed's meaning ("shown only before any question has ever been saved") is unchanged. + +## Work +1. `app/config.py` (~L373-377, the `suggestions` field comment): the seed docstring currently reads "shown ONLY while no saved chat has ever asked a question — after that, ``GET /api/suggestions`` serves the last 3 questions asked (deployment-wide, newest first)" → "serves the opening questions of the 3 most recent saved chats (the session openers — a chat's first user question; follow-ups never chip — phase 103; deployment-wide, newest first)". Keep the `BOR_SUGGESTIONS` override sentence. +2. `.env.example` (~L60, the `BOR_SUGGESTIONS` comment): "…# JSON seed chips — shown only before any question has been saved (phase 80)" → append "; after that the chips are the 3 newest chats' opening questions (phase 103)". +3. `README.md` (~L74, the chat-features line): "follow the last 3 questions asked — on a fresh deployment they seed from …" → "follow the opening questions of the 3 most recent saved chats (each chat's first question — a follow-up never chips, phase 103) — on a fresh deployment they seed from …" (keep the surrounding sentence intact; match the README's voice). +4. Grep sweep: `grep -rn "last 3 questions" app/ .env.example README.md` → ZERO hits (the phase-80 wording is fully retired; the phase records in `.agents/phases/complete/` may keep the historical phrasing — they are read-only and out of scope). + +## Testing & Quality +- No new logic — doc/comment changes only; `uv run pytest tests/unit/test_config.py -q` green (the config docstring change is inert; `tests/unit/test_config.py` pins the seed list shape, untouched). +- Coverage: **>90%** on `app/` (untouched — the gate runs at the phase's end). + +## Completion Criteria +- [ ] All three surfaces say "opening questions / session openers" (the WHY — follow-ups are meaningless without their session — appears at least in the `config.py` docstring) +- [ ] `grep -rn "last 3 questions" app/ .env.example README.md` → zero hits +- [ ] `uv run pytest tests/unit/test_config.py -q` green diff --git a/.agents/phases/todo/103_suggestions_session_openers/03_e2e_suite_commit.md b/.agents/phases/todo/103_suggestions_session_openers/03_e2e_suite_commit.md new file mode 100644 index 0000000..7cccb18 --- /dev/null +++ b/.agents/phases/todo/103_suggestions_session_openers/03_e2e_suite_commit.md @@ -0,0 +1,33 @@ +# Task 03 — The story-suite rewrite to the opener semantics + full gate + atomic commit + +**Phase:** `103_suggestions_session_openers` · **Story:** n/a (owner request) + +## Objective +The dedicated Playwright suite proves the new contract in the browser (a multi-turn chat yields EXACTLY its opener as the single chip — the owner's "What about …?" follow-up can never surface), then the phase closes with every gate green and one atomic commit. + +## Work +1. `tests/e2e/test_suggestion_chips.py` — REWRITE in place (the phase-76/80 precedent: a semantic change rewrites the story suite in place). Keep the module scaffolding (the `SEED` literal pin, the `auth_helpers.login` sign-in, the autouse `saved_chats` TRUNCATE fixture, the fixture-KB import, the mock-LLM marker constant, the run-in-isolation header). New module docstring: the opener contract + the four states. The states: + - **seed** (unchanged): fresh DB (no saved chats) → the chip texts equal the built-in default seed list EXACTLY, rendered as accessible buttons in the `role="list"` group (the phase-05 component contract). + - **opener-only** (the NEW core state): ONE saved chat with a 3-turn conversation — Q1 (the opener, e.g. "What are the correct arguments for qwen 3.8 27b on llama.cpp?") → brain → Q2 (a follow-up, e.g. "What about qwen 3.6 35b?") → brain → Q3 → brain — saved via the API (`POST /api/chats`) → a fresh page load shows EXACTLY ONE chip: Q1. Assert the chip count == 1 AND the exact text Q1; Q2/Q3 are absent (the owner's scenario, pinned). + - **three-openers** (replaces the old "last-3" state): THREE saved chats, each multi-turn (opener + at least one follow-up), DISTINCT `updated_at` (the API stamps them on save — save oldest→newest) → exactly 3 chips = the three openers, newest `updated_at` first; none of the chats' FOLLOW-UPS appears. + - **partial** (kept, re-scoped): exactly 2 saved chats → exactly 2 chips (the two openers — NO seed top-up; the follow-ups in those chats do not pad the row). + - **refetch** (kept): boot with the seed chips → save a multi-turn chat (opener Q) via the API → click New chat (`#new-chat-btn`) → the chips now are exactly Q, and the request log shows a SECOND `GET /api/suggestions` (the boot fetch was the first). + - Carried-over story behavior (unchanged semantics from the phase-05/80 suites): one-tap submit (chip click → composer filled → submitted → the mock-LLM brain bubble with the `MOCK_ANSWER_MARKER`), Tab+Enter keyboard reachability of the chips (the keyboard-walk assertion), and the mobile single horizontal-scroll row (the ≤640px viewport assertion). +2. Regression E2Es — run EACH in isolation (`--no-cov`, DB up), must stay green WITHOUT edits: + - `tests/e2e/test_responsive_polish.py` (chip visibility + the chip AA-contrast pair — it waits on `#suggestions .suggestion-chip` and measures colors; the seed chips on a truncated DB are still rendered), + - `tests/e2e/test_chat_persistence.py` (the deflection "Maybe try" chips + the empty-state chips after New chat — the separate contract, untouched). + If one of them asserts the OLD chip CONTENT semantics (not visibility/contrast/flow), update ONLY that assertion to the opener contract and note it in the commit message. +3. Full gate: `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` **>90%** on `app/`; `uv run ruff check . && uv run pyright` clean. +4. `git diff --stat` — limited to `app/api/suggestions.py`, `tests/integration/test_suggestions_api.py`, `tests/e2e/test_suggestion_chips.py`, `app/config.py`, `.env.example`, `README.md`, and the phase files (no migrations, no `frontend/` diff, no other `app/` diff). +5. Move the phase dir to `.agents/phases/complete/` and make ONE atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `fix(chat): onboarding chips are the session-opening questions, never follow-ups`). + +## Testing & Quality +- E2E: the rewritten `tests/e2e/test_suggestion_chips.py` IS the phase's story suite — the opener-only state is the load-bearing pin (the owner's exact scenario); run in isolation per AGENTS.md rule 9. +- Coverage: **>90%** on `app/` — `app/api/suggestions.py` is the only `app/` delta and every branch of the new walk is covered (the integration matrix + the endpoint's seed fallback). + +## Completion Criteria +- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation (DB up) — including the opener-only state (1 chip for a 3-turn chat) and the three-openers state +- [ ] `test_responsive_polish.py` + `test_chat_persistence.py` green in isolation (with edits only if their assertions measured the old content semantics — noted in the commit) +- [ ] The deflection "Maybe try" chips are UNCHANGED (`app/rag/suggestions.py` untouched — `git diff` shows no delta there) +- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean +- [ ] `git diff --stat` scoped per Work step 4; phase dir moved to `.agents/phases/complete/`; one atomic `--no-gpg-sign` commit diff --git a/.agents/phases/todo/104_chip_sizing_question_cap/00_phase.md b/.agents/phases/todo/104_chip_sizing_question_cap/00_phase.md new file mode 100644 index 0000000..854e5d0 --- /dev/null +++ b/.agents/phases/todo/104_chip_sizing_question_cap/00_phase.md @@ -0,0 +1,106 @@ +# Phase 104 — Suggestion chips stay single-line (hover reveals the full text) + the composer enforces the 4,000-char question cap + +**Source:** Owner request (chat, 2026-09-12) — "…tweak the size of the suggestion chips on the chat page. Some users submit truly massive queries and the 'chips' become more like 'chonks'. Hovering over the chips should still show the full message so users still have a way to see it. In fact, I would suggest you put a character cap on the chat submission box, to prevent ultra-long context overflowing queries. Users should still be able to paste code example of a few dozen lines, but nothing much longer than that." +**Story:** n/a (owner request — extends the suggestion-chips component contract of `05_story_suggestion_chips` / `80_history_suggestion_chips`; the question-cap backstop pattern of `83_chat_save_payload_limits`). +**Context:** `frontend/assets/styles.css` — the base `.suggestion-chip` rule (~L1225: the pill, `min-height: 44px`, text WRAPS inside today — a 400-char question becomes a tall multi-line block), the `.maybe-try .suggestion-chip` override (~L702: `min-width: 0; max-width: 100%` — the phase-07 overflow fix), the mobile row (~L4331: `.suggestions { flex-wrap: nowrap; overflow-x: auto; }` + `.suggestion-chip { flex: 0 0 auto; }`). `frontend/assets/app.js` — `renderChips` (~L1003-1040: the shared chip component, onboarding row + "Maybe try" row; NO `title`/`aria-label` today), the source-chip `title` + `aria-label`-when-truncated pattern (~L1331-1338 — the house precedent to copy), `autoGrow` (~L1258, 192px max), the four `input.value` mutation sites (the `input` listener ~L2484, `submitSuggestion` ~L1013, the `handleSend` clear ~L2118, the `startNewChat` clear ~L2015), the out-of-turn `showErrorBanner` precedent (`saveAsDoc` ~L731). `frontend/index.html` — the composer (~L271-285: `#message-input` textarea with NO maxlength; the `.chat-bottom` sticky unit wraps the chat-actions row + the form, ~L205-215); the theme inputs' `maxlength`-mirrors-the-server comment precedent (~L1054). `app/schemas.py` — `ChatRequest.message: Field(min_length=1, max_length=4000)` (L75 — the server backstop ALREADY in force: a >4000-char question 422s today with zero UI feedback — the banner just shows "Brain's API answered with HTTP 422."); `HistoryTurn.text` stays 32_000 (HISTORY turns may be long answers — only the CURRENT question is capped at 4000). `tests/unit/test_schemas.py` — no 4000/4001 `message` boundary pin today. `tests/unit/test_pinned_composer.py` — pins `.chat-bottom` as the LAST CHILD of `.chat-shell` and the `.composer` sticky CSS (a new child INSIDE `.chat-bottom` breaks neither). + +## Objective +Two coupled fixes on the chat page. (1) Long questions no longer balloon the suggestion chips into multi-line "chonks": every chip is single-line, ellipsized at the row edge, and the FULL text is one hover away (native `title` tooltip) plus the accessible name. (2) The composer makes the question-length cap VISIBLE — the server already hard-caps the current question at 4,000 chars (`ChatRequest.message`); the textarea gains `maxlength="4000"`, a counter appears near the cap, and a guard covers the one path that bypasses `maxlength` (the chip one-tap fill) — so no user ever meets the 422 blind. + +## Owner decisions (chat, 2026-09-12 — recorded per AGENTS.md rule 3) +- **A1 — single-line chips, never chonks:** chips never wrap — `white-space: nowrap`, ellipsized at the row edge, never taller than the one-line 44px pill, at every viewport width. +- **A2 — hover shows the full text:** "Hovering over the chips should still show the full message so users still have a way to see it" — a native `title` tooltip carrying the FULL text on every chip (the house source-chip pattern), plus `aria-label` = full text when the chip is visually truncated (screen readers). +- **A3 — the question cap is 4,000 chars, mirroring the existing server cap:** the cap must allow "code example of a few dozen lines, but nothing much longer" — 4,000 chars ≈ a 50-line block at 80 chars/line, and the server ALREADY rejects >4,000 (`ChatRequest.message max_length=4000`, pre-existing, untouched). **NO schema change:** the server cap stays the backstop (this phase pins it at the boundary, task 03); the UI becomes the visible contract (`maxlength` + counter + guard). +- **A4 — counter behavior:** hidden while the RAW length < 3,200 (80% of the cap — no noise on normal use); shows `len/4000` from 3,200; at/over the cap shows `len/4000 — character limit` in the `--err-*` semantic family (B3: the COPY change carries the state — text + color, never color alone; the executor verifies + records the AA ratio of the chosen `--err-*` pairing in the CSS comment). Count the RAW value (no trim): raw ≤ 4,000 ⟹ trimmed ≤ 4,000, so a raw count is a safe superset of what the server validates. +- **A5 — the over-cap guard:** `maxlength` constrains typing + pastes, but a programmatic `input.value = …` bypasses it — the one reachable path is `submitSuggestion` (a chip >4,000 chars; possible only via an admin-authored `BOR_SUGGESTIONS` seed — history chips are ≤4,000 by construction, having passed the same cap when asked). `handleSend` guards: trimmed text > the cap → the out-of-turn error banner (the `saveAsDoc` precedent), NO turn, the input KEEPS the text (the user trims it) — never stale (PLAN §7.4). +- **A6 — shared page:** `frontend/assets/shared.js` renders "Maybe try" chips as plain non-interactive spans (`pointer-events: none`, scoped `.shared-shell` — owner-locked phase 51: a guest tapping a chip has nowhere to go; a tooltip could never show) — UNTOUCHED; the CSS sizing applies to those pills automatically (shared stylesheet). + +## Design (shared by all tasks — the executor reads this, not the chat) + +### Chip sizing (task 01) — `frontend/assets/styles.css` +- The base `.suggestion-chip` rule gains: `white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; min-width: 0;` + - `overflow: hidden` (≠ visible) zeroes the flex item's automatic minimum size, so `max-width: 100%` actually binds: in the desktop wrap row (`.suggestions`, the 46rem column) a long chip clips at the column edge; in the ≤640px row (`flex-wrap: nowrap; overflow-x: auto`) it clips at the VISIBLE width and the row scrolls (the phase-07 contract). + - One line per chip at every width — the 44px `min-height` pill stays a pill. +- The `.maybe-try .suggestion-chip` override (`min-width: 0; max-width: 100%`) is fully subsumed by the new base rule → **delete it**, folding its phase-07 overflow-fix provenance into the base rule's comment (house: provenance lives with the contract). +- The mobile rule `.suggestion-chip { flex: 0 0 auto; }` (≤640px block) stays. +- No other CSS in this task. + +### Tooltip + a11y (task 02) — `frontend/assets/app.js` `renderChips` +- After `btn.textContent = text;`: `btn.title = text;` — the FULL text, always (A2; the house source-chip pattern, ~L1331). +- After `container.appendChild(btn)`: `if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);` — the source-chip truncation pattern (~L1336-1338): the screen-reader name is the full text when the visible text is clipped; attribute absent when not clipped (textContent already carries the full text). +- No other `renderChips` change (one-tap submit, `role="listitem"`, the container contract). + +### The composer cap (task 03) — `frontend/index.html` + `frontend/assets/app.js` +- `index.html`: + - `#message-input` gains `maxlength="4000"`, with the provenance comment (house pattern — the theme inputs' "maxlength=300 mirrors the server's 300-char"): "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)". + - The counter element, INSIDE `.chat-bottom` between the chat-actions row and `
` (a new child of the sticky unit — the sticky contract + `test_pinned_composer.py`'s last-child-of-`.chat-shell` pin are untouched): + ```html + + ``` + with the phase-104 comment: hidden until 80% of the cap (app.js); the `.is-max` state uses the `--err-*` family + a copy change (B3); NOT a live region — per-keystroke feedback is decorative, the over-cap failure announces through the error banner (`role=alert`). +- `app.js`: + - Constants next to `autoGrow` (provenance comment: mirrors the schema cap; the threshold is 80% — owner 2026-09-12 A4): + ```js + const MAX_QUESTION_CHARS = 4000; + const CHAR_COUNT_SHOW_AT = 3200; + ``` + - Element grabber alongside the existing ones (`~L301`): `const charCountEl = document.querySelector("#char-count");` + - `updateCharCount()` (next to `autoGrow`): `len = input.value.length` (RAW); `len < CHAR_COUNT_SHOW_AT` → `hidden = true` + drop `.is-max`; else unhide, `textContent = "${len}/${MAX_QUESTION_CHARS}"` and, when `len >= MAX_QUESTION_CHARS`, append ` — character limit` + add `.is-max`. Over-cap (the chip path) shows the honest `len` (e.g. `5123/4000 — character limit`). + - Call `updateCharCount()` at the EXACT four `input.value` mutation sites (each already calls `autoGrow()` there): the `input` listener, `submitSuggestion`, the `handleSend` clear, the `startNewChat` clear. + - `handleSend` guard, immediately AFTER `if (!text || sendBtn.disabled) return;` (BEFORE the clear — the input keeps the text): + ```js + if (text.length > MAX_QUESTION_CHARS) { + showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again."); + return; + } + ``` + with the A5 comment (maxlength caps typing + pastes; the programmatic chip fill bypasses it — this guard is the never-stale backstop). +- `styles.css` (this task): `.char-count` — `margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft);` (the counter sits on the app background behind `.chat-bottom` — the executor verifies the chosen pairing is ≥4.5:1 there and records the ratio in the comment, house style); `.char-count.is-max { color: var(--err-ink); }` (verify + record the ratio on the same background; the copy change already carries the state — B3). +- `tests/unit/test_schemas.py`: the `ChatRequest.message` boundary pin — exactly 4,000 chars validates; 4,001 → a pydantic `ValidationError` naming `message` (the A2/A3 backstop — the cap the UI now mirrors). + +### Unit source pins (house pattern — one new file, `tests/unit/test_chip_sizing_question_cap.py`, extended per task) +- Task 01 (CSS): the `.suggestion-chip` rule block contains `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`, `max-width: 100%`, `min-width: 0`; `css.count(".maybe-try .suggestion-chip") == 0` (the subsumed override is gone); the ≤640px block keeps `.suggestion-chip { flex: 0 0 auto; }`. +- Task 02 (renderChips): the `renderChips` block contains `btn.title = text` and the `scrollWidth > btn.clientWidth` → `setAttribute("aria-label"` pattern. +- Task 03: `index.html` — the `#message-input` block carries `maxlength="4000"`; `#char-count` exists, is `hidden` by default, and sits inside `.chat-bottom` before `#composer` (source order). `app.js` — `MAX_QUESTION_CHARS = 4000` + `CHAR_COUNT_SHOW_AT = 3200`; the guard `text.length > MAX_QUESTION_CHARS` + the banner copy "4,000 characters"; `updateCharCount()` defined AND called at all four mutation sites (pin each site's context). **Single-source cross-file pin:** the HTML `maxlength` value == the JS `MAX_QUESTION_CHARS` value (regex-parse both files and compare — the cap lives in one place conceptually). + +### E2E (task 04) — new dedicated suite `tests/e2e/test_chip_sizing_question_cap.py` +House scaffolding (DB up, mock LLM, the fixture-KB module import, admin login — the chips are `require_user`, the phase-80 autouse `saved_chats` truncate): +1. **Truncated chip + tooltip (A1/A2 core):** save via the API a chat whose FIRST user question is LONG (300+ chars — a readable repeated phrase) with a short follow-up; reload → exactly ONE onboarding chip (phase-103 opener semantics): + - computed style `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`; + - `scrollWidth > clientWidth` (visually clipped); + - single line: `44 <= clientHeight <= 60` (a one-line pill is the 44px min-height; a wrapped two-liner is ≥ ~76px); + - `title` attribute == the full long text; `aria-label` == the full long text. + Fresh-DB contrast pin: a short SEED chip has `title` set and NO `aria-label` (not truncated). +2. **Counter threshold (A4):** `#char-count` hidden at boot; 100 chars typed → still hidden; exactly 3,500 chars in the box (a dispatched `input` event) → visible, text `3500/4000`, NO `.is-max`. +3. **Hard cap through the input path (A3):** `keyboard.insert_text("x" * 6000)` (CDP `Input.insertText` = the paste path — `maxlength` applies) → the textarea holds EXACTLY 4,000 chars; the counter reads `4000/4000 — character limit` + `.is-max`. Submit → the 4,000-char question passes the server cap (NO 422 error state) → the mock answer streams to `done` → the input is cleared and the counter hidden again. +4. **The over-cap guard (A5):** `page.evaluate` sets `#message-input.value = "x".repeat(5000)` + dispatches an `input` event (the programmatic path `maxlength` cannot stop) → counter `5000/4000 — character limit` + `.is-max` → click Send → the error banner shows the "4,000 characters" copy; NO brain bubble appended; the input STILL holds the 5,000 chars (kept for trimming — never stale). +5. **Short-flow regression:** a short question submits cleanly; the counter never becomes visible. + +**Regressions (run in isolation, must stay green):** `tests/e2e/test_suggestion_chips.py` (the chip contract — phase 103's rewrite), `tests/e2e/test_pinned_composer.py` (the sticky cluster now hosts the counter), `tests/e2e/test_responsive_polish.py` (the mobile chip row + the chip AA-contrast pairs — the chip colors are unchanged), `tests/e2e/test_chat_history.py` (the send flow). Unit: `tests/unit/test_pinned_composer.py` (the `.chat-bottom` pins — untouched structure) + any `tests/unit/` source pin that conflicts with the added `app.js` lines (the executor runs `uv run pytest tests/unit/ -q` and fixes only genuine conflicts — the additions live INSIDE existing functions, so substring/context pins should survive). + +## Dependencies +- `103_suggestions_session_openers` (todo) — the onboarding chips become session openers; this phase's long-chip E2E state (a 300+ char opener chip) builds on that contract and runs AFTER it. Queue order only at the code level (different files), but the E2E fixtures assume the phase-103 semantics. +- `80_history_suggestion_chips` (complete) — the chip component + the E2E fixture pattern. +- `83_chat_save_payload_limits` (complete) — the boundary-cap pin pattern (`tests/unit/test_schemas.py`). + +## Tasks +1. `01_chip_ellipsis_css.md` — the base chip rule (single-line ellipsis) + the subsumed-override deletion + the CSS unit pins. +2. `02_chip_tooltip_aria.md` — `renderChips` full-text `title` + `aria-label`-when-truncated + the unit pins. +3. `03_composer_question_cap.md` — `maxlength` + the counter (HTML/JS/CSS) + the `handleSend` guard + the unit pins + the `test_schemas.py` boundary pin. +4. `04_e2e_suite_commit.md` — the dedicated E2E suite + regression E2Es + full gate + atomic commit. + +## Testing & Quality +- Unit — `tests/unit/test_chip_sizing_question_cap.py` (new; per-task pins above) + the `ChatRequest.message` 4,000/4,001 boundary in `tests/unit/test_schemas.py`. +- E2E (mandatory, A16) — `tests/e2e/test_chip_sizing_question_cap.py` green in isolation (the five states above); the four named regression suites green in isolation. +- Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`) — the `app/` delta is nil-to-trivial (no `app/` code changes; the gate stays green). +- Lint/types: `uv run ruff check . && uv run pyright`. + +## Completion Criteria +- [ ] A 300+ char onboarding chip renders single-line (`clientHeight` ≤ 60px, ellipsized, `scrollWidth > clientWidth`) with `title` + `aria-label` == the full text (E2E). +- [ ] `#message-input` hard-caps at 4,000 through the paste path; submitting at the cap streams a mock answer (no 422); the counter is hidden again after send (E2E). +- [ ] The over-cap guard: a programmatic 5,000-char fill + Send → the cap banner, no turn, the text kept (E2E). +- [ ] The `ChatRequest.message` 4,000/4,001 boundary is pinned in `tests/unit/test_schemas.py`; the HTML `maxlength` == the JS constant (cross-file pin). +- [ ] `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` green in isolation; `test_suggestion_chips.py`, `test_pinned_composer.py`, `test_responsive_polish.py`, `test_chat_history.py` green in isolation. +- [ ] `uv run pytest` green; coverage >90%; `uv run ruff check . && uv run pyright` clean. +- [ ] `git diff --stat` limited to `frontend/`, the new unit file, `tests/unit/test_schemas.py`, the new E2E file, phase files (NO `app/` code diff, no migration, no `shared.js` diff). +- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): single-line suggestion chips with full-text tooltips + the visible 4,000-char question cap`); phase dir moved to `.agents/phases/complete/`. diff --git a/.agents/phases/todo/104_chip_sizing_question_cap/01_chip_ellipsis_css.md b/.agents/phases/todo/104_chip_sizing_question_cap/01_chip_ellipsis_css.md new file mode 100644 index 0000000..04df7f0 --- /dev/null +++ b/.agents/phases/todo/104_chip_sizing_question_cap/01_chip_ellipsis_css.md @@ -0,0 +1,33 @@ +# Task 01 — Single-line ellipsized chips: the base `.suggestion-chip` rule + the subsumed override deletion + +**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request) + +## Objective +A suggestion chip is ONE line at every viewport width — long text ellipsizes at the row edge instead of wrapping the pill into a multi-line "chonk" (owner A1). + +## Work +1. `frontend/assets/styles.css` — the base `.suggestion-chip` rule (~L1225) gains four declarations (keep the existing ones — font/weight/color/background/border/radius/padding/min-height/transition): + ```css + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + min-width: 0; + ``` + Extend the rule's comment (house provenance style): phase 104 (owner 2026-09-12) — chips never wrap; `overflow: hidden` zeroes the flex item's automatic minimum size so `max-width: 100%` binds (desktop wrap row: 100% = the 46rem column; the ≤640px row: 100% = the visible width, the row scrolls — the phase-07 overflow contract now lives here). +2. DELETE the `.maybe-try .suggestion-chip { min-width: 0; max-width: 100%; }` override (~L702) — fully subsumed by the new base rule; fold its phase-07 provenance sentence into the base rule's comment (the step-1 comment) so the history is not lost. +3. Leave the ≤640px block's `.suggestion-chip { flex: 0 0 auto; }` (~L4331) and `.suggestions` row rule untouched. +4. `tests/unit/test_chip_sizing_question_cap.py` — CREATE the phase's unit-pins file (module docstring: pins the phase-104 chip-sizing contract in the static frontend — the house source-pin pattern) with the CSS pins: + - the `.suggestion-chip` rule block contains `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`, `max-width: 100%`, `min-width: 0` (reuse the `_rule`-style block extraction from `tests/unit/test_pinned_composer.py` / `test_wide_column_css.py` — read one and match its helpers); + - `css.count(".maybe-try .suggestion-chip") == 0` (the subsumed override is gone); + - the ≤640px mobile block still contains `.suggestion-chip { flex: 0 0 auto; }`. +5. Run `uv run pytest tests/unit/ -q` — green (the new file + no existing pin broken). + +## Testing & Quality +- Unit: the new pins ARE this task's test layer (they guard the CSS bytes — the house source-pin pattern). +- Coverage: **>90%** on `app/` (untouched — the full-suite gate runs at the phase's end). + +## Completion Criteria +- [ ] `.suggestion-chip` is single-line + ellipsized + capped at the row edge (the five declarations pinned); the `.maybe-try .suggestion-chip` override deleted with its provenance folded into the base comment +- [ ] `tests/unit/test_chip_sizing_question_cap.py` created and green; `uv run pytest tests/unit/ -q` green +- [ ] no other file changed (JS/HTML/E2E are later tasks) diff --git a/.agents/phases/todo/104_chip_sizing_question_cap/02_chip_tooltip_aria.md b/.agents/phases/todo/104_chip_sizing_question_cap/02_chip_tooltip_aria.md new file mode 100644 index 0000000..cb9d76e --- /dev/null +++ b/.agents/phases/todo/104_chip_sizing_question_cap/02_chip_tooltip_aria.md @@ -0,0 +1,30 @@ +# Task 02 — Full-text hover tooltip + accessible name on every suggestion chip + +**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request) + +## Objective +The full text of a (possibly ellipsized) chip is always recoverable: a native `title` tooltip on hover (owner A2) and the full text as the accessible name when the visible text is clipped (the house source-chip pattern). + +## Work +1. `frontend/assets/app.js` — `renderChips` (~L1024-1040), inside the per-chip loop: + - after `btn.textContent = text;` add `btn.title = text;` — the FULL text, always (the hover contract; the source-chip precedent is `chip.title = label` at ~L1331). + - after `container.appendChild(btn);` add the truncation-aware accessible name (the source-chip precedent at ~L1336-1338): + ```js + if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text); + ``` + (When NOT clipped the attribute stays absent — the `textContent` already carries the full text, so screen readers read it; the attribute is belt-and-suspenders for the clipped case, exactly like the source chips.) + - One short comment on the pair: 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). + - Touch NOTHING else in `renderChips` (one-tap submit via `submitSuggestion`, `role="listitem"`, the container-replace contract, the `onSelect` hook). +2. `tests/unit/test_chip_sizing_question_cap.py` — extend with the `renderChips` pins (extract the `renderChips` function body from `app.js` the way the neighboring unit files do): + - the body contains `btn.title = text`; + - the body contains the `btn.scrollWidth > btn.clientWidth` guard setting `aria-label` (pin the `setAttribute("aria-label"` call inside that guard — a small slice of the function text, the house "pin the contract words" style). +3. Run `uv run pytest tests/unit/ -q` — green (including the existing `tests/unit/test_shared_page.py` pin that `renderChips` does NOT leak into `shared.js` — `shared.js` is untouched by this task). + +## Testing & Quality +- Unit: the new pins guard the `app.js` bytes; the full E2E hover/tooltip behavior is task 04. +- Coverage: **>90%** on `app/` (untouched). + +## Completion Criteria +- [ ] every chip rendered by `renderChips` (onboarding row AND "Maybe try" row) carries `title` = the full text, and `aria-label` = the full text when clipped +- [ ] the unit pins are green; `uv run pytest tests/unit/ -q` green +- [ ] no other file changed diff --git a/.agents/phases/todo/104_chip_sizing_question_cap/03_composer_question_cap.md b/.agents/phases/todo/104_chip_sizing_question_cap/03_composer_question_cap.md new file mode 100644 index 0000000..ee7848f --- /dev/null +++ b/.agents/phases/todo/104_chip_sizing_question_cap/03_composer_question_cap.md @@ -0,0 +1,91 @@ +# Task 03 — The visible 4,000-char question cap: `maxlength` + counter + the over-cap guard + the server-boundary pin + +**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request) + +## Objective +The question-length cap the server ALREADY enforces (`ChatRequest.message max_length=4000` — a >4,000-char question 422s today with zero UI feedback) becomes VISIBLE in the composer: the textarea hard-caps input/paste, a counter appears near the cap, and a guard covers the one path that bypasses `maxlength` (the chip one-tap fill). The server cap is untouched and pinned at the boundary. + +## Work +1. `frontend/index.html`: + - `#message-input` (~L273-279) gains `maxlength="4000"`, with the provenance comment (house pattern — the theme inputs' "maxlength=300 mirrors the server's 300-char", ~L1054): `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)`. + - The counter element — INSIDE `.chat-bottom` (~L215), between the closing `` of `.chat-actions` and the composer comment/``: + ```html + + + ``` + (A new child of the sticky unit — the unit stays the LAST child of `.chat-shell` and the `.composer` sticky CSS is untouched, so `tests/unit/test_pinned_composer.py` + `tests/e2e/test_pinned_composer.py` stay green; a `hidden` `

` adds zero height.) +2. `frontend/assets/app.js`: + - Constants directly above `autoGrow` (~L1258) with the provenance comment (mirrors the schema cap — the executor must NOT change `app/schemas.py`; the threshold is 80% of the cap, owner A4): + ```js + const MAX_QUESTION_CHARS = 4000; + const CHAR_COUNT_SHOW_AT = 3200; // 80% of the cap — visible only when it matters + ``` + - Element grabber alongside the existing ones (next to `const suggestionsEl = …` ~L301): `const charCountEl = document.querySelector("#char-count");` + - `updateCharCount()` next to `autoGrow`: + ```js + 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}`; + } + ``` + (Over-cap — reachable only via the programmatic chip-fill path — shows the honest `len`, e.g. `5123/4000 — character limit`.) + - Call `updateCharCount()` at the EXACT four `input.value` mutation sites (each already calls `autoGrow()` — add the call right after it, or fold both into the listener body): + a. the `input` listener (~L2484: `input.addEventListener("input", autoGrow)` — e.g. `input.addEventListener("input", () => { autoGrow(); updateCharCount(); })`); + b. `submitSuggestion` (~L1013-1015, after `input.value = text; autoGrow();`); + c. `handleSend` (~L2118-2119, after the post-send clear `input.value = ""; autoGrow();`); + d. `startNewChat` (~L2015-2016, after `input.value = ""; autoGrow();`). + - The `handleSend` guard — immediately AFTER `if (!text || sendBtn.disabled) return;` (~L2114) and BEFORE the clear (the input keeps the text for trimming): + ```js + // 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. + if (text.length > MAX_QUESTION_CHARS) { + showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again."); + return; + } + ``` + (Out-of-turn banner = the `saveAsDoc` precedent, ~L731; the banner is cleared by the next user action — the existing `clearErrorBanner` call sites.) +3. `frontend/assets/styles.css` — the counter rules (near the composer styles, ~L1242+): + ```css + /* Phase 104: the question-length counter — right-aligned above the + composer, hidden until 80% of the 4,000-char cap (app.js). + [executor: verify + record the ratio] --ink-soft on the app + background behind .chat-bottom is ≥4.5:1 (WCAG AA). */ + .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); } + ``` + Verify BOTH pairings against the actual background the counter sits on (the app `--bg` behind `.chat-bottom` — the chat column area) and record each ratio in the comment (house style). The `.is-max` state pairs the color with the "— character limit" COPY change (B3 — never color alone). +4. `tests/unit/test_chip_sizing_question_cap.py` — extend with: + - `index.html` pins: the `#message-input` textarea block carries `maxlength="4000"`; `#char-count` exists, is `hidden` by default, and appears INSIDE `.chat-bottom` before `#composer` (source order). + - `app.js` pins: `MAX_QUESTION_CHARS = 4000` and `CHAR_COUNT_SHOW_AT = 3200`; the guard `text.length > MAX_QUESTION_CHARS` with the banner copy "4,000 characters"; `updateCharCount` defined AND its call present in each of the four mutation-site contexts (the listener, `submitSuggestion`, `handleSend`, `startNewChat` — pin each site's slice). + - **Single-source cross-file pin:** regex-parse the HTML `maxlength="(\d+)"` on the `#message-input` block and the JS `MAX_QUESTION_CHARS = (\d+)` and assert they are EQUAL (the cap lives in one place conceptually — the schema is the source, both mirror it). +5. `tests/unit/test_schemas.py` — the `ChatRequest.message` boundary pin (currently unpinned): exactly 4,000 chars validates; 4,001 → a pydantic `ValidationError` naming `message` (the backstop the UI now mirrors — the phase-83 boundary-pin pattern). +6. Run `uv run pytest tests/unit/ -q` — green. + +## Testing & Quality +- Unit: the new pins (HTML/JS/CSS bytes + the cross-file constant match + the schema boundary) ARE this task's test layer; the behavioral E2E is task 04. +- Coverage: **>90%** on `app/` (no `app/` code changes — `app/schemas.py` is untouched, only test-pinned). + +## Completion Criteria +- [ ] `#message-input` has `maxlength="4000"` + the provenance comment; the counter element sits in `.chat-bottom` above the composer, hidden by default +- [ ] `updateCharCount` fires at all four mutation sites; the `handleSend` guard (banner + no turn + no clear) is in place; the counter CSS is AA-verified with recorded ratios +- [ ] The HTML `maxlength` == the JS `MAX_QUESTION_CHARS` (cross-file pin); the 4,000/4,001 `ChatRequest.message` boundary is pinned in `test_schemas.py` +- [ ] `uv run pytest tests/unit/ -q` green; no `app/` file changed diff --git a/.agents/phases/todo/104_chip_sizing_question_cap/04_e2e_suite_commit.md b/.agents/phases/todo/104_chip_sizing_question_cap/04_e2e_suite_commit.md new file mode 100644 index 0000000..b8e42e9 --- /dev/null +++ b/.agents/phases/todo/104_chip_sizing_question_cap/04_e2e_suite_commit.md @@ -0,0 +1,41 @@ +# Task 04 — The dedicated E2E suite + regression E2Es + full gate + atomic commit + +**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request) + +## Objective +The browser proves the whole contract — single-line ellipsized chips with full-text tooltips, the hard 4,000-char cap through the paste path, the counter states, and the over-cap guard — then the phase closes with every gate green and one atomic commit. + +## Work +1. `tests/e2e/test_chip_sizing_question_cap.py` — NEW dedicated suite (house scaffolding: module docstring stating the phase-104 contract + the run-in-isolation command; DB up `podman compose up -d db`; the deterministic mock LLM — the fixture-KB import pattern from a sibling chat suite, e.g. `tests/e2e/test_suggestion_chips.py`; admin login via `e2e.auth_helpers.login` — the chips are `require_user`; the phase-80 autouse `saved_chats` TRUNCATE fixture so each test starts from — and leaves — an empty deployment): + - **`test_long_chip_is_single_line_ellipsized_with_full_text_tooltip`** (A1/A2 core): save via the API a chat whose FIRST user question is LONG (300+ chars — a readable repeated phrase, e.g. `"What are the correct arguments for " * 20 + "qwen on llama.cpp?"`) with one short follow-up turn; reload the chat page → exactly ONE onboarding chip (the phase-103 opener semantics — the follow-up never surfaces): + - computed style: `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`; + - `scrollWidth > clientWidth` (visually clipped — 300+ chars of ~0.5rem/char far exceeds the 46rem column); + - single line: `44 <= clientHeight <= 60` (a one-line pill sits at the 44px `min-height`; a wrapped two-liner is ≥ ~76px — the chonk); + - `get_attribute("title")` == the full long text (the hover reveal); + - `get_attribute("aria-label")` == the full long text (the clipped-case accessible name). + - **`test_short_seed_chip_has_tooltip_but_no_aria_label`**: fresh DB (seed chips) → a short chip has `title` set AND no `aria-label` (not truncated — the attribute is absent by design). + - **`test_counter_hidden_below_threshold_and_visible_above`** (A4): on the empty-state chat page, `#char-count` is hidden; type 100 chars → still hidden; put exactly 3,500 chars in `#message-input` (a dispatched `input` event — `locator.fill` does this) → `#char-count` visible, text `3500/4000`, NO `.is-max` class. + - **`test_paste_path_hard_caps_at_the_cap_and_sends`** (A3): `page.keyboard.insert_text("x" * 6000)` (CDP `Input.insertText` = the paste path — `maxlength` applies) → `#message-input` holds EXACTLY 4,000 chars; the counter reads `4000/4000 — character limit` + `.is-max`. Click Send → NO 422 error state (the 4,000-char question passes the server cap) → the mock answer streams to `done` (the brain bubble + the "Deterministic mock answer for E2E" marker) → `#message-input` cleared and `#char-count` hidden again. + - *Executor note:* if `insert_text` proves not to respect `maxlength` on the pinned Chromium build (it goes through the browser's input pipeline, like a paste — expect it to work), fall back to pinning the attribute (`maxlength == "4000"`) + a `fill`-based counter check, and record the deviation in the phase record — the guard test below still covers the bypass path. + - **`test_over_cap_programmatic_fill_hits_the_guard`** (A5): `page.evaluate` sets `#message-input.value = "x".repeat(5000)` + dispatches an `input` event (the programmatic path `maxlength` cannot stop — the chip one-tap fill) → counter `5000/4000 — character limit` + `.is-max` → click Send → the error banner shows the "4,000 characters" cap copy; NO brain bubble appended; `#message-input` STILL holds the 5,000 chars (kept for trimming — never stale, PLAN §7.4). + - **`test_short_flow_never_shows_the_counter`**: type a short question → submit → the mock answer lands; `#char-count` never becomes visible during the turn. +2. Regression E2Es — run EACH in isolation (`--no-cov`, DB up), must stay green: + - `tests/e2e/test_suggestion_chips.py` (the chip contract — phase 103's rewrite; the onboarding row this phase restyles), + - `tests/e2e/test_pinned_composer.py` (the sticky cluster now hosts the counter), + - `tests/e2e/test_responsive_polish.py` (the mobile chip row + the chip AA-contrast pairs — the chip colors are unchanged by this phase), + - `tests/e2e/test_chat_history.py` (the send/save flow). + Fix ONLY a regression whose assertion measured the OLD chip wrapping (e.g. a pin that asserted a multi-line chip height) — the asserted BEHAVIOR (a chip exists, is clickable, AA contrast) must survive; note any such fix in the commit message. +3. Full gate: `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` **>90%** on `app/`; `uv run ruff check . && uv run pyright` clean. +4. `git diff --stat` — limited to `frontend/`, `tests/unit/test_chip_sizing_question_cap.py`, `tests/unit/test_schemas.py`, `tests/e2e/test_chip_sizing_question_cap.py`, and the phase files (NO `app/` code diff, no migration, no `shared.js` diff, no `pyproject.toml`/`uv.lock`). +5. Move the phase dir to `.agents/phases/complete/` and make ONE atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): single-line suggestion chips with full-text tooltips + the visible 4,000-char question cap`). + +## Testing & Quality +- E2E: the new suite (five tests above) IS the phase's story suite — run in isolation per AGENTS.md rule 9; the four regression suites re-prove the untouched contracts (chip component, sticky composer, contrast, send flow). +- Coverage: **>90%** on `app/` (the `app/` delta is nil — the gate must simply stay green). + +## Completion Criteria +- [ ] `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` green in isolation (DB up) +- [ ] `test_suggestion_chips.py`, `test_pinned_composer.py`, `test_responsive_polish.py`, `test_chat_history.py` each green in isolation +- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean +- [ ] `git diff --stat` scoped per Work step 4 (no `app/` code diff, no `shared.js` diff) +- [ ] Phase dir moved to `.agents/phases/complete/`; one atomic `--no-gpg-sign` commit