refactor(agents): migrate .agent/ planning tree to .agents/

Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
This commit is contained in:
2026-09-05 10:57:07 -04:00
parent 766702c750
commit dbf2af26c6
1118 changed files with 664 additions and 664 deletions
+85
View File
@@ -0,0 +1,85 @@
# Story: Admin Sign-In (Single-Admin Auth)
**Phase:** `16_admin_auth.md` · **E2E:** `tests/e2e/test_admin_auth.py`
## Narrative
As **the one admin (the owner)**, I want to **sign in with a password** so
that I — and only I — can **tune how Brain answers** (Phase 15 steering
notes) and **browse the entire Sources catalog**, while **anyone else stays
anonymous** and can still **chat** and **open the documents the chat cites**.
- **Given** I am not signed in
- **When** I open the app
- **Then** chat, suggestion chips, and the document viewer all work, but the
tuning UI is gone (no Tune button, no Tuning panel) and the Sources page
shows a "sign in to view" state; a **Sign in** link is in the header.
- **When** I sign in with the admin password
- **Then** the tuning UI and the full Sources page are unlocked, and the
header offers **Sign out**.
- Anonymous users may open **any indexed document by direct URL** (soft
rule — the catalog is what is gated, not the viewer).
## Acceptance criteria
1. **Config (fail-loud):** `BOR_ADMIN_PASSWORD` (plaintext, `.env`) and
`BOR_SESSION_SECRET` (random hex) — if either is unset the app **refuses
to start** with a message naming the missing variable(s).
2. `POST /api/login {password}` → **204** + signed session cookie
(Starlette `SessionMiddleware`, `same_site=lax`, 12h default max age);
wrong password → **401** `{"detail": "invalid password"}` (constant-time
compare, one generic message — no user enumeration).
3. `POST /api/logout` → **204** (session cleared, idempotent);
`GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"anonymous"}`.
4. **Admin-only:** `GET /api/docs` and `GET`/`POST`/`DELETE` `/api/steering`
→ **403** for anonymous. **Public (unchanged):** `/api/chat`,
`/api/documents/content`, `/api/suggestions`, `/api/health`, all static
pages.
5. **Login page** `/login.html` (no CDN, Phase-08 tokens, WCAG 2.1 AA):
app-frame header, labeled password field, submit ≥44px, `role=alert`
error region; on success redirects to `?next` (same-origin only, default
`/sources.html`); an already-signed-in visit redirects immediately.
6. **Chat page:** header shows **Sign in** when anonymous (→
`/login.html?next=/sources.html`) or **Sign out** when admin (logout →
reload); when anonymous the Tune button (including on localStorage
restored messages, Phase 14) and the Tuning panel/toggle are completely
hidden; the chat flow itself is unchanged.
7. **Sources page:** anonymous sees `#sources-gate` ("sign in to view the
full catalog" + sign-in link) with stat cards and `#docs-table` hidden and
no `/api/docs` call; admin sees the current full Sources page.
8. **Document viewer:** unchanged — anonymous can open any indexed document
by direct URL.
9. Non-goals: no rate limiting / account lockout, no HTTPS enforcement
(homelab HTTP), no multi-user, no per-user history, no schema change.
10. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, regression suites adapted + green in isolation, one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_admin_auth.py`** (the E2E app server is
started with `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` set in
`tests/e2e/conftest.py`; the shared `tests/e2e/auth_helpers.py::login`
performs the real form login):
1. `test_anonymous_chat_without_tuning` — question → streamed answer renders;
no `.tune-btn` under the brain bubble; `#steering-toggle` absent;
`#sign-in-link` visible, `#sign-out-btn` not.
2. `test_anonymous_sources_gated_viewer_open` — `/sources.html`:
`#sources-gate` visible, `#docs-table` hidden; then
`/document.html?source=…&path=…` (seeded fixture doc) renders its content
while anonymous.
3. `test_login_wrong_password_shows_error` — wrong password → `#login-error`
(role=alert) visible, no redirect, still anonymous.
4. `test_admin_login_unlocks_sources_and_tuning` — correct password →
redirect to `/sources.html` with stats + table visible; chat page shows
`#steering-toggle` (count badge) and `.tune-btn` under the answer;
`#sign-out-btn` visible, `#sign-in-link` not.
5. `test_logout_returns_to_anonymous` — Sign out → tuning hidden,
`#sign-in-link` back, `/sources.html` gated again.
6. `test_login_page_a11y` — password field labeled, error announced via
role=alert, targets ≥44px, focus-visible; signed-in visit to
`/login.html?next=/` redirects immediately.
**Regression adaptations (this phase makes their preconditions
admin-only):** `test_steering.py` (log in before tuning),
`test_import_documents.py` + `test_document_back_navigation.py` (log in for
Sources-table assertions), `test_header_consistency.py` (header gains the
auth link — extend presence assertions; heights unchanged).
@@ -0,0 +1,66 @@
# Story: Agent document tools (list + read)
**Phase:** `37_agent_document_tools` · **E2E:** `tests/e2e/test_agent_document_tools.py`
## Narrative
As **a user**, when my question depends on content the retriever did not put
in context — e.g. a document that *references* another file ("the exact JSON
shape is in example-record-file.json") — today I get a guess or an apology
("I don't want to invent it!"). I want the answer agent to have **two
tools**: list the available sources, and read the one extra document it
thinks is relevant — with the number of opportunities configured by
environment variables — and the UI to show a "calling tool" state in
addition to "thinking".
- **Given** a grounded (HIGH-gate) chat turn
- **When** the model calls `list_documents` (≤ `BOR_AGENT_LIST_CALLS`,
default 1) or `read_document(source, path)` (≤ `BOR_AGENT_READ_CALLS`,
default 1)
- **Then** the app executes the tool server-side (DB-only), streams a
`tool` SSE event the UI renders as a "calling tool" line + button state,
and — once both budgets are spent — the model is required to answer with
the augmented context; `done` sources include the read document.
## Acceptance criteria
1. Live probe (task 01): `scripts/llm_probe.py --tools` verifies
`turbo`'s tool-calling support (non-streaming + streaming) against the
aipi endpoint; the verdict is recorded in the `app/rag/agent.py`
docstring + the commit message (phase-17 "verified live" convention).
2. `app/rag/llm.py`: `chat_stream(messages, tools=None)` — `tools` passed
through to the API; streaming `tool_calls` deltas (partial `name` /
fragmented `arguments`, indexed) accumulated into
`ToolCallPiece(id, name, arguments)`; `tools=None` → byte-identical
request to today.
3. `app/rag/agent.py`: the loop — budgets from
`BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS` (default 1/1); the
`list_documents` tool returns the DB catalog
(`source: X | path: Y | title: Z` lines, `/api/docs` order);
`read_document`
appends the **full** document text (A7-revised: never truncated); after
both budgets are spent the tools are dropped and the model must answer;
safety cap on model rounds; the deflection (LOW) path is byte-identical.
4. `app/api/chat.py` + `app/schemas.py`: new SSE
`{"type":"tool","name":…,"argument":…}` event (PLAN §4 revision note,
owner permission 2026-08-26); `done.sources` + `query_log.sources`
include the read document (deduped); the per-turn log line gains
`tool_calls=N` (PLAN §9 note).
5. Frontend: "calling tool" button/label state + visible `.tool-call`
lines in the bubble (distinct from the Thinking block), aria-live,
tool lines persisted with the chat record (phase 14/17 convention);
UI Structure Check + no CDN.
6. `mock_llm.py` deterministic: marker-driven list → read → answer
sequence (the mock parses the catalog out of the tool result it was
given); the story E2E asserts the tool UI, the answer quoting the read
document, the read document as a source chip, and reload re-render;
existing suites stay green (no tool events without the marker).
7. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_agent_document_tools.py` — one story, one file, run in
isolation: the marker question triggers two tool events; the UI shows the
"calling tool" state while tools run; the final answer quotes the read
document; the source chips include it; after a reload the tool lines
re-render; a plain question and a deflected question produce no tool
events.
+45
View File
@@ -0,0 +1,45 @@
# Story: Chat Survives a Refresh (localStorage)
**Phase:** `14_chat_persistence.md` · **E2E:** `tests/e2e/test_chat_persistence.py`
## Narrative
As **a user**, I want my conversation to **survive a browser refresh**
and a trip to another page — the previous session should be waiting for
me when I come back.
- **Given** I have asked a few questions
- **When** I refresh the page (or go to Sources and come back)
- **Then** the whole conversation is restored: my questions, Brain's
answers (rendered markdown), the source chips, and deflected answers
with their "Maybe try" chips — amber style included.
## Acceptance criteria
1. Conversation state (user + brain messages, with sources/deflection/
suggestions metadata) is stored in `localStorage` under a versioned
key; user messages are saved when sent, brain answers when the turn
completes.
2. On load, stored messages are re-rendered (markdown, source chips,
deflected styling + maybe-try chips); the empty state is hidden when
a conversation exists.
3. "New chat" control (chat header) clears the stored conversation and
returns to the empty state with suggestions.
4. Storage failures (private mode / quota) degrade silently — chat still
works, nothing throws.
5. Size-bounded: oldest messages are dropped if the serialized
conversation outgrows the quota budget.
6. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_chat_persistence.py`** (mock LLM, seeded KB):
1. `test_conversation_survives_reload` — ask → answer with source chip →
`page.reload()` → both bubbles restored (text + chip href), empty
state hidden.
2. `test_deflected_turn_restores_styling` — off-topic question →
deflected answer → reload → amber bubble + "Maybe try" chips back.
3. `test_new_chat_clears_conversation` — with a conversation, "New
chat" → messages gone, empty state + suggestions back, storage key
cleared.
4. `test_persists_across_page_navigation` — ask → go to Sources → back
to `/` → conversation still there.
@@ -0,0 +1,51 @@
# Story: Configurable app name
**Phase:** `39_configurable_brand` · **E2E:** `tests/e2e/test_configurable_brand.py`
## Narrative
As **the owner**, the app is "Brain of Reese" because that's what I named
it — but the name is hard-coded across every template and JS string. I want
to be able to **rename the app with one env var** (`BOR_APP_NAME`) so every
page title, the header brand, the status labels, and the aria text carry
the new name without touching code.
- **Given** the app is running with `BOR_APP_NAME="Brain of Testy"`
- **When** I open any page (chat, sources, tuning, login, document viewer)
- **Then** the title, the header brand, the status labels
("Brain of Testy is thinking"), the empty-state greeting, and the
document-viewer titles all say "Brain of Testy" — and with the variable
unset the app is byte-identical to today ("Brain of Reese").
## Acceptance criteria
1. `GET /api/config` (public, stateless) → `{app_name, version}` from
settings — exactly those two keys (no secrets, no other settings);
unit + integration tested.
2. `frontend/assets/brand.js` (classic script, loaded first on all five
pages): sets `window.BOR_BRAND` synchronously (default "Brain of
Reese"), fetches `/api/config` (`cache: "no-store"`), and applies the
name — `document.title` (global replace of the literal), every
`.brand-text` (bold split: a name starting "Brain of " bolds the
remainder, else plain; the name is HTML-escaped), every text node and
the `aria-label` / `placeholder` / meta-`content` attributes containing
the literal; a fetch failure falls back to the default (console.warn,
the page never breaks).
3. The JS hard-codes move off the literal: `app.js` (status labels, the
typing label, the elapsed-hint aria) + `document.js` (page titles) read
`window.BOR_BRAND` (default "Brain of Reese" if unset).
4. `Containerfile`: an esbuild line for brand.js next to the others.
5. The story E2E boots its **own** app instance with `BOR_APP_NAME`
overridden (distinct port; the shared conftest server keeps the default
name so the existing title assertions stay green) and asserts
title/brand/greeting/aria/label.
6. `.env.example` + README document `BOR_APP_NAME` (what it affects, the
default, the bold-split rule).
7. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_configurable_brand.py` — one story, one file, run in
isolation: the overridden-name instance shows the new brand in the title,
the header `.brand-text` (with the bold split), the empty-state greeting,
the messages aria-label, and the chat status label; the default-name
shared server still renders "Brain of Reese" (the no-op regression).
+81
View File
@@ -0,0 +1,81 @@
# Story: Dark Tech Theme
**Phase:** `08_story_dark_tech_theme.md` · **E2E:** `tests/e2e/test_dark_tech_theme.py`
## Narrative
As **a user**, I want Brain of Reese to look like a serious piece of my
infrastructure — dark, technical, free of emojis, with a subtly animated
background — so the app feels at home next to my dashboards and is
comfortable to use at night, instead of a generic light-mode chat widget.
- **Given** the v1 UI (light palette, emoji avatars/brand/favicon, static background)
- **When** the theme phase is complete
- **Then** every page is dark with a tech aesthetic, no emoji appear anywhere
in the UI chrome, a subtle pure-CSS animated background (drifting grid +
breathing glows) is visible but never distracting, all layout metrics and
behavior are unchanged, and WCAG 2.1 AA still holds on the new palette.
## Acceptance criteria
1. Dark palette (tokens in the phase file) applied to **both** pages
(`/`, `/sources.html`); every text/background pair ≥4.5:1 — computed,
not eyeballed.
2. **Zero emoji in UI chrome** — HTML templates, JS-rendered chrome,
favicon, CSS — replaced by inline SVG glyphs. An integration test
enforces this as a permanent regression guard.
3. Subtly animated background: a fine drifting grid + two slow breathing
radial glows, **pure CSS, zero JS**, `pointer-events: none`, never
blocking or dimming content.
4. `prefers-reduced-motion: reduce` → background animations disabled
(static grid + glows remain).
5. No-CDN invariant holds on both pages (all script/link refs local or
`data:`).
6. Layout metrics unchanged (PLAN §7.1): 72rem frame, 46rem centered chat
column, full-width Sources table, mobile rules; behavior unchanged
(chat state machine, chips, banners).
7. WCAG 2.1 AA: landmarks, labels, skip link, `:focus-visible`, live
regions intact on the new palette.
8. LLM answer content is out of scope (the model may output anything; the
rule binds UI chrome only).
9. Whole pytest suite green, `app/` coverage >90%, story E2E green in
isolation.
## UI Visualization & Structure
- **Palette (all pairs computed, ≥4.5:1):** page `#0a0e17`, surface
`#121a2e`, ink `#e8ebf4` (14.5:1 on surface), ink-soft `#9aa4bd`
(6.9:1), line `#26304a`, brand `#6d78f2` with **dark** text `#0a0e17`
(5.2:1 — white on brand is 3.7:1, forbidden for text), brand-soft
`#232b52` + brand-ink `#a5b4fc` (6.9:1), deflection `#2b2110`/`#fbbf24`
(9.5:1) + border `#f59e0b`, error `#2d1318`/`#fca5a5` (9.1:1), ok
`#10241b`/`#6ee7a8` (10.6:1).
- **Busy button** keeps its `#a5b4fc` background (the existing unit test
asserts the token) with a **dark** arc (`#0a0e17`, 9.7:1).
- **Avatars:** brain → inline SVG circuit-node glyph; user → minimal
silhouette SVG; `aria-hidden` retained.
- **Favicon / brand mark:** tech SVG mark (hex + node), still an inline
`data:` URI (no CDN).
- **Tech details:** mono wordmark + stat values, 1px `--line` borders,
radii 10px/6px, a 2px brand→cyan gradient hairline under the sticky
header.
- **Background:** `body::before` grid (44px cells, ~35% alpha 1px lines,
radial mask fade, 60s seamless diagonal drift); `body::after` two radial
glows (indigo ~14% top-left, cyan ~10% bottom-right, 14s breathing).
Both `position: fixed; inset: 0; pointer-events: none`.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_dark_tech_theme.py`** (mock LLM, seeded KB):
1. `test_dark_palette_and_contrast` — body background is
`rgb(10, 14, 23)`; computed ink-on-surface and brand-button
text/background pairs ≥4.5:1 (same contrast helper as Phase 07).
2. `test_no_emoji_in_chrome` — neither page's `document.body.innerText`
nor raw `outerHTML` contains any emoji code point.
3. `test_animated_background` — computed style of `body::before` has a
non-none `background-image` and `animation-name`; `body::after`
animates too.
4. `test_reduced_motion_honored` — a browser context created with
`reduced_motion="reduce"` → `animation-name: none` on both layers.
5. `test_behavior_unchanged_smoke` — on-topic question streams an answer +
a source chip + the send button recovers (state machine intact under
the new skin).
6. `test_all_assets_local` — every `script[src]` / `link[href]` on both
pages is relative or `data:` (no CDN).
@@ -0,0 +1,41 @@
# Story: Document Back Button Returns to Where You Came From
**Phase:** `13_document_back_navigation.md` · **E2E:** `tests/e2e/test_document_back_navigation.py`
## Narrative
As **a user**, when I open a document **from the chat tab** (a source
chip), the viewer's back button must take me **back to the chat** — not
to Sources. When I open it from Sources, it takes me to Sources.
Today every chip/link opens the viewer in a **new tab**, where there is
no browser history, so the fallback always lands on `/sources.html` —
which is wrong for chat-originated visits.
- **Given** I open a document from chat (chip) or from Sources (table)
- **When** I click the viewer's back button
- **Then** I land on the page I opened it from — Chat or Sources.
## Acceptance criteria
1. Chat source chips carry `&back=%2F` (chat) in the viewer URL; Sources
table links keep the default (no `back` param → `/sources.html`).
2. The viewer resolves the back target: `back` param wins when it is a
same-origin relative URL (starts with `/`, not `//`); anything else
(missing, absolute, protocol-relative, garbage) falls back to
`/sources.html`. The back link's **label** matches (Chat / Sources).
3. Clicking back navigates to the resolved target (deterministic — no
`history.length` heuristics).
4. Phase-10 viewer behavior and a11y frame unchanged.
5. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_document_back_navigation.py`** (seeded KB):
1. `test_back_from_chat_returns_to_chat` — question → click source chip →
new tab at `/document.html?…&back=%2F` → back link href is `/` and
labeled "Chat" → click → URL is the chat page.
2. `test_back_from_sources_returns_to_sources` — Sources → click path
link → new tab (no `back` param) → back link href is `/sources.html`,
labeled "Sources" → click → URL is Sources.
3. `test_malicious_back_param_is_rejected` — `back=https%3A%2F%2Fevil.com`
and `back=%2F%2Fevil.com` → back link falls back to `/sources.html`.
+64
View File
@@ -0,0 +1,64 @@
# Story: Clickable Document Viewer
**Phase:** `10_story_document_viewer.md` · **E2E:** `tests/e2e/test_document_viewer.py`
## Narrative
As **a user**, I want to click any document Brain cites (the source
chips under an answer) or any row in the Sources table and have the
**actual document open in the browser** — in whatever format it is — so I
can read the full context and verify the answer against my real notes.
- **Given** an answer with source chips (`Homelab/active/container_gitlab/
gitlab.md`) or the Sources page table
- **When** I click a chip or a path
- **Then** a new tab opens the viewer: title, source/format/path meta, and
the full content — markdown rendered with the existing local
escape-first renderer, other formats as escaped monospace text.
## Acceptance criteria
1. `GET /api/documents/content?source=…&path=…` → 200
`{source, path, title, format, content, indexed_at, chunks}`; 404
`{detail}` when the pair is not indexed. Served **from the database
only** — no filesystem access, hence no path-traversal surface.
2. `GET /document.html?source=…&path=…` renders the document: back link,
title, meta row (source, format badge, mono path, indexed date, chunk
count), content — md via the shared local renderer; other formats as
escaped `<pre>` (mono, horizontal scroll).
3. Chat source chips link to the viewer **in a new tab**
(`target="_blank" rel="noopener"`), accessible name intact.
4. Sources table: the path cell is a link to the viewer (new tab).
5. XSS-safe: document content is never injected as raw HTML (renderer
escapes first; raw formats use text nodes).
6. The viewer inherits the dark tech theme; the no-CDN invariant is
extended to `/document.html`; landmarks/labels/`aria-live` on the new
page.
7. Missing doc → designed not-found state with a link to the Sources
page.
8. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## UI Visualization & Structure
- **Viewer page:** sticky header (back link + title + meta badges) +
`<main>`: markdown column centered at ≤46rem; raw content in a
full-width `pre` with `overflow-x: auto`. Dark tokens from Phase 08.
- **Chips/links:** source chips keep the mono pill; path links get
hover/focus underline; `:focus-visible` ring; ≥44px touch targets on
mobile.
- **Not-found state:** centered card (no emoji — SVG or plain text),
message + "Open Sources" link.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_document_viewer.py`** (mock LLM, seeded KB):
1. `test_source_chip_opens_document` — on-topic question → click the
`.source-chip` → a **new tab** at `/document.html?…` shows the fixture
doc's title + a known content string + the format badge.
2. `test_sources_row_links_to_viewer` — `/sources.html` → click a path
link for a `.yaml` fixture → viewer shows its raw content in a `pre`.
3. `test_markdown_renders_and_stays_xss_safe` — an md fixture containing
`<script>alert(1)</script>` renders as visible escaped text (no
execution).
4. `test_missing_doc_shows_not_found` — unknown path → not-found state +
Sources link; no console crash.
5. `test_viewer_theme_and_no_cdn` — dark background + every
`script[src]` / `link[href]` local or `data:`.
@@ -0,0 +1,107 @@
# Story: Follow-the-Bottom Scroll
**Phase:** `18_follow_bottom_scroll` · **E2E:** `tests/e2e/test_follow_bottom_scroll.py`
## Narrative
As **a user**, Brain's answers (and its thinking) stream for 10–30 seconds.
I want to scroll up and read earlier messages — or the top of a long
thinking block — without the page dragging me back to the bottom token by
token; but when I submit, I should still see my message and the reply
appear.
- **Given** a turn is streaming (thinking, or answering) and I scroll up
to read
- **When** the stream keeps going
- **Then** the viewport holds still for the rest of the turn — nothing
auto-scrolls — while submitting (I'm pinned at the composer) still
reveals my message, and the reply follows into view while I stay
pinned.
## Acceptance criteria
1. Submitting a question always reveals the user's own message (explicit
action, unconditional).
2. While the user is pinned to the bottom (within 200px of it — the
composer zone), the typing indicator, thinking chunks, and answer
deltas follow into view (smooth, or instant under
`prefers-reduced-motion`).
3. Once the user scrolls up (more than 200px from the bottom), nothing
auto-scrolls for the rest of the turn — thinking or answer; the
viewport position is unchanged at turn end.
4. The thinking block's *internal* text still bottom-pins itself while
open (that is the block's own overflow, not the page).
5. Restoring a stored conversation still lands on the latest message
(one-shot, non-smooth).
6. No new UI surface (no "new content" pill — owner chose the minimal
contract, 2026-08-23); no other page is affected.
## UI Visualization & Structure
- **Scroller:** the document itself — there is no inner overflow
container (`body` is `min-height: 100dvh`; the page scrolls on the
window). All measurements go through `window.scrollY`,
`document.documentElement.scrollHeight`, and `window.innerHeight`.
- **The gate:** `scrollReveal(wrap, behavior = SCROLL, force = false)` is
the **single** `scrollIntoView` call site in `app.js` — it fires only
when `force` is set or `isNearBottom()` is true. `NEAR_BOTTOM_PX = 200`
is exported and unit-pinned (same pattern as `TURN_TIMEOUT_MS`); the
200px band ≈ the composer zone (the textarea auto-grows to 192px plus
the button row), so "the composer is in view" counts as pinned —
exactly where the user sits when they submit. Scrolling up into the
conversation (≫200px from the bottom) leaves the band.
- **Call sites:** the user's own message on submit (the plain gated
default — no force; the user submits from the composer, i.e. pinned,
so the message reveals), the typing indicator, the streaming `delta`
branch, and the phase-17 `thinking` branch — all go through the gate.
The only `force`d callers are the two phase-14 restore landings
(`"auto", true` — one-shot, load-time, non-smooth).
- **Reused unchanged:** the `SCROLL` constant (smooth, or `auto` under
`prefers-reduced-motion` — "calm, don't remove") still controls the
*feel* of a follow scroll; reduced-motion handling is untouched. The
thinking block's internal `.thinking-text` bottom-pinning (scrollTop of
the block's own overflow) stays as-is — that is the block's element,
not the page.
- **No new UI surface:** no "↓ new content" pill (owner chose the minimal
contract, 2026-08-23 — a follow-up phase if ever wanted), no CSS or
HTML template changes, no new DOM nodes, no other page affected
(Sources, document viewer, and login have no vertical auto-scroll).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_follow_bottom_scroll.py`** (mock LLM —
`write a long answer` long-answer trigger, plus the phase-17
`think out loud` trigger for scenario 4; mock-only by design, since
`E2E_REAL_LLM=1` would make the scroll-away windows unpredictable):
1. `test_submit_reveals_new_message` — fresh chat page (pinned at the
bottom by default); submit `LONG_QUESTION`; once settled, the last
`.msg.brain` is inside the viewport (bounding box) and
`near_bottom(scroll_state(page))`.
2. `test_stream_follows_while_pinned_at_bottom` — fresh page; submit
`LONG_QUESTION`; ~2s into the stream (poll until the last brain
bubble's text length > 200), assert `near_bottom(scroll_state(page))`
— the follow behavior is alive, not accidentally removed; at settle,
still near bottom.
3. `test_no_yank_while_scrolled_up_during_answer_stream` — submit
`LONG_QUESTION` (fresh page, pinned — normal flow; once settled, the
document overflows the 800px viewport — assert `sh > ch`); submit a
second `LONG_QUESTION`; wait until the new brain bubble's text length
> 200 (streaming has started); `window.scrollTo(0, 0)` (the user goes
up to read); wait until the bubble's text length > 600 (the stream
kept running while the viewport was at the top); assert
`scroll_state(page)["y"] <= 5` (viewport held); wait for settle;
assert `y <= 5` again (no scroll happened for the rest of the turn —
the answer finished off-screen below, by design).
4. `test_no_yank_while_scrolled_up_during_thinking` — one settled turn
first (overflow exists); submit `THINK_LONG_QUESTION` (normal flow,
pinned); wait for `details.thinking` in the last `.msg.brain` to
attach (thinking is streaming — phase-17 behavior) and is open;
`window.scrollTo(0, 0)`; wait until the answer `.bubble` text is
non-empty (the whole thinking stream plus the answer's start happened
at the top); assert `y <= 5`; wait for settle; assert `y <= 5` and
that the thinking text contains `Step 2: Check my notes` and the
bubble is filled (all present but off-screen — the point of the
story).
5. `test_restore_lands_on_latest_message` — two settled turns (user +
brain × 2, overflow); `page.reload()`; after restore, the last
`.msg.brain` is inside the viewport and
`near_bottom(scroll_state(page))` (phase-14 one-shot landing
preserved — pinned so a future "remove all scrolling" change fails
loudly instead of silently).
@@ -0,0 +1,38 @@
# Story: One Header, Same Size Everywhere
**Phase:** `12_header_consistency.md` · **E2E:** `tests/e2e/test_header_consistency.py`
## Narrative
As **a user**, I want the top bar to be the **same height on every page**
so moving between Chat, Sources, and a document never feels like the
app "grew".
Today the document viewer (the page whose back button says "Sources")
uses a content-sized header (≈66px desktop / ≈63px+ mobile) while Chat
and Sources use the fixed 64px bar (58px mobile) — the perceived
"header changes size between sources and chat".
- **Given** any of the three pages at any viewport
- **When** I look at the sticky top bar
- **Then** it is exactly `var(--header-h)` tall on all of them.
## Acceptance criteria
1. `.doc-header` is exactly `var(--header-h)` tall (64px desktop,
58px ≤640px) — same box as `.app-header` on `/` and `/sources.html`.
2. Title + meta still fit: title stays single-line (ellipsis), meta row
clips instead of growing the header on narrow screens.
3. Existing phase-10 viewer behavior (back link, title, meta badges,
a11y frame) unchanged.
4. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_header_consistency.py`**:
1. `test_header_height_identical_across_pages_desktop` — measured
bounding boxes of `.app-header` (chat), `.app-header` (sources),
`.doc-header` (viewer) are all exactly 64px tall.
2. `test_header_height_identical_across_pages_mobile` — same at a 375px
viewport: all exactly 58px.
3. `test_viewer_header_content_still_fits` — title ellipsizes, meta row
visible, back link + badges present (regression guard for phase 10).
@@ -0,0 +1,49 @@
# Story: Local directory sources
**Phase:** `38_local_directory_sources` · **E2E:** `tests/e2e/test_local_directory_sources.py`
## Narrative
As **the admin (owner)**, some of my documents live in plain directories
that are **not git repos** — today the only way to index them is the CLI's
`--source` flag, which the web app's Sync path ignores. I want to **add an
existing local directory as a source** from the same admin page as the git
sources, and have the Sync button and `import_docs` import it alongside the
git checkouts.
- **Given** I am signed in as admin, and `~/Notes` is a plain (non-git)
directory
- **When** I add it on the sources page and click Sync
- **Then** the directory is walked and imported together with the git
checkouts in one run (pruning over the union), and a missing directory
fails the run loudly instead of importing a partial KB.
## Acceptance criteria
1. Migration 0007: `git_sources.kind TEXT NOT NULL DEFAULT 'git'` (check:
`git`|`local`) + `git_sources.path TEXT NULL` (unique; non-null only for
local) — reversible, up/down integration-tested (the 0004–0006 pattern).
2. The admin API (phase 35) accepts `kind`: `git` rows validate `url`
exactly as today; `local` rows require `path` (trimmed, expanded,
absolute, existing directory on the server → else 422 naming the path);
409 on duplicate (per kind); GET rows carry `kind` + `path` (env
fallback rows are git-only).
3. The sync pipeline and `import_docs` resolve DB **git + local** rows
together (git → clone/pull; local → direct directory walk; a missing
local dir aborts the run before importing, with the path named);
`--source` still wins; `BOR_GIT_SOURCES` stays a git-only fallback;
both-empty fails loudly ("no sources configured").
4. The page: a second "Local directory" add form (labeled path input,
inline error, never-stale button per §7.4); list rows show a kind badge
(Git/Local, distinguishable by more than color); the hint says Sync
imports git checkouts and local directories together (union prune).
5. README: the two source types + add-time validation + "the DB is the
local-source registry"; no new env var.
6. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_local_directory_sources.py` — one story, one file, run in
isolation: the admin adds a temp local directory → it appears with the
Local badge; a missing path gets an inline error; Sync imports its fixture
file (visible via `GET /api/docs`); deleting the file and syncing again
prunes it; removing the row removes it from the list.
+78
View File
@@ -0,0 +1,78 @@
# Story: Retrieval Quality — Multi-Format Ingestion + Hybrid Search
**Phase:** `09_story_retrieval_quality.md` · **E2E:** `tests/e2e/test_retrieval_quality.py`
## Narrative
As **a user**, I want to ask questions that name my tools ("How did I
install gitlab?") and get a grounded answer citing the right document —
even when the answer lives in a `.yaml`, `.py`, `.json` or `.txt` file —
so Brain actually covers my notes instead of deflecting around them.
- **Given** my notes are a mix of markdown write-ups and config/code
files, and today's index is polluted with vendored-cache docs
(`.esphome/.espressif/**`, merge-request templates, …) that outrank
real content, while retrieval is pure cosine top-4
- **When** the importer covers the full text-format set, skips hidden
directories, and retrieval fuses vector similarity with Postgres
full-text search
- **Then** "How did I install gitlab?" surfaces `active/container_gitlab/
gitlab.md` as the top document, the junk docs are pruned from the
index, and genuinely off-topic questions still get the honest
deflection.
## Acceptance criteria
1. **Ingestion scope (A9, revised with owner permission 2026-08-21):**
default extensions `md, markdown, txt, yaml, yml, json, py`,
overridable via `BOR_IMPORT_EXTENSIONS`; **hidden (dot) directories
skipped by default**; the A9 exclusion list stays.
2. **Format-aware chunking:** md unchanged (heading-aware); yaml/yml
split on top-level keys and `---` document separators (key kept as
chunk anchor); json pretty-printed and split on top-level keys; py
split on top-level defs/classes (stdlib `ast`); txt on paragraphs.
Every format honors the 1200-char hard cap (aipi ~1024-token request
cap).
3. **Hybrid retrieval (A7, revised with owner permission 2026-08-21):**
cosine top-30 + FTS top-30 (OR tsquery over the question's tokens,
`ts_rank`-ordered) fused with Reciprocal Rank Fusion (k=60);
document-level ranking by best fused chunk score; the LLM still
receives the **full top-2 documents** capped at 24k chars (contract
preserved).
4. **Honesty gate (A8, revised with owner permission 2026-08-21):**
deflect when `best cosine < BOR_RELEVANCE_THRESHOLD` **and** no
candidate chunk FTS-matches the question; threshold re-tuned for the
`embed` model's measured 0.41–0.84 score compression (default 0.62,
documented tuning procedure); `fts_hits` logged per turn and stored in
`query_log`.
5. **Live verification:** `scripts/eval_retrieval.py` run against the
imported KB shows `gitlab.md` as the top document for "How did I
install gitlab?"; a known off-topic question gates LOW.
6. **Index hygiene:** after a `--prune` re-import, **zero** documents
with a dot-prefixed path component exist in `documents`; prune also
drops docs that no longer match the format filter.
7. **No regressions:** off-topic deflection (story 04) and grounded
answers (story 03) still pass their E2E suites in isolation; the E2E
mock keeps its own threshold calibration via the app fixture.
8. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## UI Visualization & Structure
No UI change. The Sources page document count drops after the junk prune
(intended cleanup — noted in the README).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_retrieval_quality.py`** (mock LLM, seeded KB):
1. `test_multiformat_docs_indexed` — fixture tree with `.md`, `.yaml`,
`.py`, `.json`, `.txt` plus `.hidden/junk.md`: import in-process →
`/api/docs` lists every non-hidden doc and none of the hidden ones.
2. `test_named_tool_question_retrieves_right_doc` — "How did I install
gitlab?" → not deflected, streamed answer, a `.source-chip` containing
the gitlab doc, `query_log` row `deflected=false` with the gitlab doc
in sources.
3. `test_keyword_only_question_beats_vector_ranking` — a question whose
rare token exists in exactly one fixture doc (weak mock-embedding
overlap, e.g. "kafkabridge") → grounded answer citing that doc
(FTS-OR gate + lexical RRF lift, end to end).
4. `test_off_topic_still_deflects` — "sourdough starter" →
`.is-deflected` bubble matching /haven't done anything like that/i +
≥2 "Maybe try:" chips (story 04 behavior under the new gate).
+121
View File
@@ -0,0 +1,121 @@
# Story: Shared Header (Auth + New Chat on Every Page, Sources Link Admin-Only)
**Phase:** 19_shared_header · **E2E:** tests/e2e/test_shared_header.py
## Narrative
As **a user**, the top bar should feel like **one shared component**: when
I move to Sources or open a document, I should still see **Sign in** (or
**Sign out**) and **New Chat** — and the **Sources** link should not offer
me a page I can't use until I sign in.
- **Given** I am on any page (chat, sources, or the document viewer)
- **When** the bar renders
- **Then** I always see the same controls — New Chat plus exactly one of
Sign in (anonymous) / Sign out (admin) — and the "Sources" nav link is
visible only to the admin.
- **When** I am anonymous and I look at the nav
- **Then** the "Sources" link is hidden on every page that has a nav —
the soft-gated page is still reachable by direct URL (phase-16 gate
intact), but nothing in the UI points me at a page I can't use.
## Acceptance criteria
1. Chat, Sources, and the document viewer each show **New Chat** +
**Sign in** (anonymous) or **Sign out** (admin) in the header; the
login page shows neither (it is the auth page, not an app page).
2. The **"Sources" nav link is hidden for anonymous users on every page
that has a nav** (chat, sources, login), and visible for the admin
(chat, sources — the viewer has no nav).
3. Anonymous **direct-URL access to `/sources.html`** still shows the
phase-16 soft gate (link hidden, gate intact); the API split is
unchanged (`/api/docs` → **403** for anonymous).
4. **New Chat on chat: in-place reset (unchanged).** New Chat on
sources/viewer: clears the local conversation (`bor.chat.v1`) and
navigates to the chat page (the empty state).
5. **Sign out works from any page** (logout + reload → anonymous state
restored on that page).
6. The bar stays exactly **64px (desktop) / 58px (≤640px)** on all three
pages in both auth states, with **no horizontal overflow at 360px**
(phase-12 contract, phase-07 overflow guard).
7. **Exactly one `/api/whoami` request per page load** (shared cached
fetch — every gate and toggle on the page shares the same promise).
## UI Visualization & Structure
- **Shared module `frontend/assets/header.js`** (new, ES module — all
pages already load JS as `type="module"`), loaded before each page
script:
- `fetchIsAdmin()` — one `GET /api/whoami`, cached in a module-level
promise (`adminPromise`; anonymous-safe: non-2xx or network failure
→ `false`); the single whoami call site for the whole frontend, so a
page makes exactly one request no matter how many gates reuse it.
- `initSharedHeader()` — awaits `fetchIsAdmin()`, then toggles
**only the elements that exist on the page** (missing → no-op, which
is how the login page reuses the module without gaining chat
controls): `#sign-in-link` / `#sign-out-btn` (exactly one visible —
phase-16 semantics) and `#nav-sources` (shown for admin); returns
the admin flag so callers reuse it.
- `clearChatStorage()` — removes the `bor.chat.v1` key in a
try/catch (same key + fail-silence contract as app.js's
`clearStoredConversation`).
- The `#sign-out-btn` binding lives here (POST `/api/logout`, disable
during the call, `location.reload()`) — app.js deleted its copy, so
there is exactly one implementation.
- **Element ids** (existing patterns): `#nav-sources` ships
**hidden-by-default** in the HTML (anonymous-safe — revealed once
whoami says admin, never flashed for anonymous); `#sign-in-link` /
`#sign-out-btn` follow the phase-16 pattern (exactly one visible,
decided by whoami at load — both start hidden so the wrong state is
never shown for a frame).
- **Viewer bar:** a `.doc-header-actions` wrapper (New Chat + Sign in /
Sign out) appended to the right of the title block — `margin-left:
auto` flex row; the title block keeps its `min-width: 0` truncation
(phase-12 "clip, don't wrap") while the two pills fit. The bar height
and the back-link/title contract (phase 13) are untouched.
- **Mobile:** the new controls reuse `.new-chat-btn` / `.auth-link`, so
the phase-14/16 icon-only rules (labels hidden, 16px icon shown at
≤640px) apply automatically — the pills never grow the 58px bar and
never overflow at 360px.
- **Login page boundary:** `#nav-sources` (hidden) + `header.js` only —
no chat controls are added to its markup, so none appear (it is the
auth page, not an app page).
- **Non-chat New Chat:** on sources.js / document.js, click →
`clearChatStorage()` → `location.href = "/"` (a new chat means going
to the chat, fresh); chat keeps app.js's `startNewChat` (in-place
reset + focus + announce).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_shared_header.py`** (DB seeded with the
fixture docs for the viewer URL and the admin catalog; real form logins
via `tests/e2e/auth_helpers.py::login`; the `assert_shared_bar(admin,
page_kind)` helper waits for the settled whoami state — exactly one of
Sign in / Sign out visible — before asserting the bar):
1. `test_anonymous_bar_on_all_pages` — fresh (anonymous) page: the shared
bar on chat, sources (phase-16 `#sources-gate` still visible above
it), and the viewer, in the anonymous state (desktop viewport).
2. `test_admin_bar_on_all_pages` — `login(page, app_url, next="/")`: the
bar on all three pages in the signed-in state (Sign out + Sources
link where a nav exists) — the login → `next` flow still lands right.
3. `test_sources_nav_hidden_for_anonymous_everywhere` — anonymous:
`#nav-sources` hidden on chat, sources, and the login page; after a
real login on the chat page, `#nav-sources` is visible (the toggle
works, not just the initial state).
4. `test_new_chat_from_sources_clears_and_navigates` — a seeded
`bor.chat.v1` conversation; on sources, `#new-chat-btn` → navigation
to `/` with the empty state visible and the localStorage key removed.
5. `test_sign_out_from_viewer_returns_to_anonymous` — log in with
`next=/sources.html`; open the viewer (admin bar); `#sign-out-btn` →
after the reload the same page shows the anonymous bar.
6. `test_mobile_bar_fits_and_heights_held` — viewport 375×812,
anonymous: 58px bars on all three pages with
`documentElement.scrollWidth <= clientWidth` (no horizontal
overflow; the pills are icon-only); repeat after login (Sign out +
Sources link present) — the bar never grows.
**Regression suites (adapted / re-run in isolation by this phase):**
`test_header_consistency.py` (64/58px with the new pills on sources +
viewer), `test_responsive_polish.py` (no 360px overflow),
`test_admin_auth.py` (phase-16 flows with `#nav-sources` hidden for
anonymous), `test_document_back_navigation.py` (viewer back-link/title
with the actions wrapper), `test_chat_persistence.py` (chat New Chat
in-place; the "New chat is chat-page-only" pin adapted — the shared bar
now puts it on sources/viewer too, by owner permission).
+73
View File
@@ -0,0 +1,73 @@
# Story: Sources Mid-Stream — an in-flight answer is not lost on navigation
**Phase:** `20_sources_midstream_bug` · **E2E:** `tests/e2e/test_sources_midstream_bug.py`
## Bug report (TODO.md L3, verbatim)
> *"Clicking "sources" while chat is generating clears chat and result
> will never show up"*
## Narrative
As **a user with a question in flight**, I want to **click "Sources"
while Brain is still generating** without losing the answer — when I
come back to the chat, what had already streamed should be waiting for
me.
- **Given** I asked a question and Brain's answer is still streaming
- **When** I click the "Sources" nav link (or otherwise leave the chat
page) mid-stream
- **Then** the stream is aborted by the navigation (no server-side
resume — A10 stays stateless), but returning to `/` shows my question
**and** the partial answer that had already streamed, rendered exactly
like any brain message (thinking block included, if any had streamed).
## Owner-confirmed (2026-08-24, roadmap A1)
1. **A partial answer is persisted as a plain brain message** — no
"(partial)" marker, no sources/suggestions (the turn is dead; the
user can re-ask for the full answer).
2. Navigation **before the first answer token** (pure thinking) persists
nothing brain-side: the question is restored, no empty/partial
bubble.
3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer
pages) is untouched — that clear is by design (phase 14/19).
4. No server-side resume (A10 stays stateless) and no "leave page?"
confirmation dialog.
## Acceptance criteria
1. `pagehide` (navigate-away / bfcache store) with an in-flight turn
(`thinking` or `streaming`) and non-empty streamed text persists the
partial raw answer via the existing `rememberBrainTurn` save-point
helper (idempotent per turn — `persistedOnLeave`).
2. Returning to `/` restores the question and the partial brain message
through the existing restore path (escape-first markdown, optional
collapsed thinking block) — no new UI surface, no error banner.
3. Navigation during pure thinking (no answer tokens) persists nothing
brain-side: exactly the user message is restored.
4. A completed turn persists exactly as before (done metadata intact;
no duplicate brain message).
5. New Chat from the sources page still clears the conversation
(`clearChatStorage`, phase 14/19 — untouched).
6. Unit + integration green, `app/` coverage ≥ today's number (>90%
gate), story E2E green in isolation, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_sources_midstream_bug.py`** (mock LLM,
seeded KB):
1. `test_partial_answer_survives_sources_nav_midstream` — admin (login,
`next=/`), send the ~9s long-answer question, wait for the first
streamed frames to render, click `#nav-sources` (lands on
`/sources.html`), `page.goto("/")` → question + partial answer
rendered, no `role="alert"`, `bor.chat.v1` holds the partial brain
message (starts with the first streamed chunk, shorter than the full
answer, no done metadata).
2. `test_no_orphan_brain_message_when_navigated_before_first_token` —
the mock streams the phase-17 thinking scratchpad, then a 4s
pre-token pause; navigate during the pause, return to `/` → exactly
one user message, zero brain messages, in the DOM and in storage.
3. `test_completed_turn_unaffected` — a fully settled turn → trip to
Sources and back → full answer, source chip, and a byte-identical
`bor.chat.v1` payload (done metadata intact, no duplicate).
4. `test_new_chat_still_clears_conversation` — completed turn →
`/sources.html` → click the sources-page New Chat button → lands on
`/` with the empty state and `bor.chat.v1` removed.
+49
View File
@@ -0,0 +1,49 @@
# Story: Tune How Brain Answers (Steering Notes)
**Phase:** `15_steering_notes.md` · **E2E:** `tests/e2e/test_steering.py`
## Narrative
As **a user**, when an answer **isn't quite right** (too chatty, wrong
assumption, missing context), I want to **tune** Brain right there: a
short instruction ("be more concise", "assume I'm on NixOS") that is
**stored in the database** and **read into the system prompt** so every
future reply follows it.
- **Given** an answer I don't like
- **When** I click "Tune" under it and save an instruction
- **Then** the note is persisted in Postgres, shown in the tuning
panel (where it can be removed), and injected into the system prompt
of every subsequent turn — verifiable in the answer.
## Acceptance criteria
1. New `steering_notes` table (id, note, created_at) via Alembic
migration 0003.
2. Stateless API: `GET /api/steering` (newest first),
`POST /api/steering` (1–2000 chars, 201 / 422),
`DELETE /api/steering/{id}` (204 / 404).
3. System prompt: when notes exist, a `<tuning>` section (numbered notes,
char-budgeted) is appended to **both** HIGH and DEFLECT prompts; when
none exist, the prompt is byte-identical to today's.
4. Per-turn log line gains `tuning=N`.
5. UI: "Tune" button under every completed brain answer (deflected too)
→ inline form → save → confirmation; header "Tuning" toggle lists
notes with per-note delete; all labeled, live regions, ≥44px targets,
Phase-08 tokens.
6. Notes render as text (XSS-safe).
7. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_steering.py`** (mock LLM, seeded KB —
the mock echoes the first tuning note into its answer when `<tuning>` is
present, so prompt injection is observable in the UI):
1. `test_tune_under_answer_persists_and_steers` — answer → Tune → note
"STEEER-MARKER be concise" → saved → next answer contains the marker
(note reached the system prompt) → note visible in panel with count.
2. `test_delete_note_stops_steering` — delete from panel → count 0 →
next answer has no marker.
3. `test_note_rendered_as_text_xss_safe` — note with `<script>` renders
as visible escaped text, never executes.
4. `test_tuning_panel_a11y` — toggle aria-expanded, region label,
delete buttons labeled, live region announces save/delete.
+103
View File
@@ -0,0 +1,103 @@
# Story: Thinking Display
**Phase:** `17_thinking_display` · **E2E:** `tests/e2e/test_thinking_display.py`
## Narrative
As **a user**, my local reasoning model *thinks* before it answers — and
today those 10–30 seconds are pure silence. I want to *see* Brain think:
its reasoning streaming live, tucked away once the answer starts — so long
turns feel transparent instead of frozen.
- **Given** I submit a question the model reasons about
- **When** the turn streams reasoning ahead of (or around) the answer
- **Then** a collapsible "Thinking" block streams open above the answer
bubble, auto-collapses on the first answer token, and stays
user-toggleable afterwards — while turns that emit no reasoning render
exactly as before.
## Acceptance criteria
1. Turns whose model stream carries reasoning show a "Thinking" block
(collapsible, above the answer bubble) that streams open and
auto-collapses on the first answer token; always user-toggleable
afterwards.
2. Turns without reasoning render exactly as before (no block, no layout
shift).
3. Thinking-without-answer (reasoning exhausted the token budget) shows
the existing empty-answer fallback with the thinking block preserved.
4. Thinking persists with the message (phase 14) and restores collapsed
after reload; "New chat" clears it with everything else.
5. Deflected turns show the amber bubble + "Maybe try" chips alongside the
thinking block (honesty gate untouched).
6. A stream that dies mid-thinking/mid-answer ends in the error state
(retry hint) — never a silent half bubble.
7. `BOR_STREAM_THINKING=0` suppresses `thinking` events server-side (log
line still counts `thinking_chars`).
## UI Visualization & Structure
- **DOM contract** (built dynamically — `index.html` is unchanged): inside
`.msg-body`, **before** `.bubble`:
```html
<details class="thinking" open>
<summary>Thinking</summary>
<div class="thinking-text"></div>
</details>
```
The block is created **open** at the first `thinking` event and
auto-collapses at the first answer token; a late/interleaved `thinking`
event only appends to the block — it never reopens once the answer
started. Restored messages (phase 14) render the block **collapsed**.
- **Styling (Phase-08 tokens):** `details.thinking` — `--surface`
background, 1px `--line` border, 3px `--brand-soft` left accent,
`--radius-sm`; `summary` — `--brand-ink` on surface (≈**8.7:1**), 0.9rem,
**min-height 44px** touch target, CSS chevron `▸` rotating 90° when open
(0.15s transform, stills under `prefers-reduced-motion`),
`:focus-visible` 3px `--brand` outline; `.thinking-text` — `--ink-soft`
on surface (≈**6.9:1**), 0.875rem/1.55, **max-height 320px** with
`overflow-y: auto` (long scratchpads scroll instead of pushing the
answer off-screen) and tightened paragraph/list margins.
- **Accessibility:** native `<details>/<summary>` — a real,
keyboard-focusable control with native open/closed announcements. **No
live region on the scratchpad** (announcing every reasoning chunk would
be hostile to screen readers); the existing `#send-status` region
announces turn state as before.
- **Feedback contract:** while reasoning streams, the block *is* the
visible "thinking" feedback (the typing dots are removed); the UI state
stays `thinking` (button disabled with "Thinking…", status text
unchanged). The 120s pre-token guard clears on the first `thinking`
**or** `delta` event.
- **Stream-drop guard:** a stream that ends without a `done` event after
at least one thinking/delta frame settles into the error state ("The
stream ended before my answer finished — try again?") — never a silent
half bubble.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_thinking_display.py`** (mock LLM,
`"think out loud"` trigger):
1. `test_thinking_block_streams_open_then_collapses` — submit
`THINK_QUESTION`. Assert `details.thinking` inside the last
`.msg.brain` attaches within 10s (it appears at the first `thinking`
event); immediately after attach, assert it is open (the mock's
~800-char thinking stream gives a multi-second open window — see the
suite's determinism note) and `.thinking-text` is non-empty; once
`.bubble` text is non-empty, assert the block is **closed**; at
settle: `.thinking-text` contains `Step 2: Check my notes`, the bubble
contains the mock's deterministic answer sentence, `.source-chip`
count ≥ 1, send button re-enabled.
2. `test_thinking_toggle_after_done` — after a settled `THINK_QUESTION`
turn, the block is closed; click `summary` → `details[open]` and the
full thinking text is visible; click again → closed. (Real
keyboard-focusable control.)
3. `test_thinking_restored_after_reload` — settle a `THINK_QUESTION`
turn; capture the thinking text; `page.reload()`; the restored
conversation contains the brain message with a **closed**
`details.thinking` whose `.thinking-text` matches the captured text,
and the answer bubble + source chips are intact (phase-14 restore
path).
4. `test_no_thinking_block_without_trigger` — submit `PLAIN_QUESTION`; at
settle: `page.locator("details.thinking")` count is 0 (a model that
doesn't think renders exactly as before — no layout regression).
5. `test_thinking_with_deflection` — submit `THINK_DEFLECT_QUESTION`; at
settle: the brain message has `.is-deflected`, a `.maybe-try` group
with chips, and a closed `details.thinking` whose text contains
`Step 2: Check my notes` (thinking and the honesty gate coexist).