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 `