feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down
This commit is contained in:
@@ -1,92 +0,0 @@
|
||||
# Story: Admin Sync Button (One-Click Doc Import Sync)
|
||||
|
||||
**Phase:** `32_admin_sync_button.md` · **E2E:** `tests/e2e/test_sync_button.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, I don't want to SSH in and hand-run the import
|
||||
script every time my notes repos move. I want **a button that only I can
|
||||
see** — on the Sources page — that **triggers a doc import sync by
|
||||
cloning the relevant repos and then running the import script**, with
|
||||
live feedback so I always know whether it is running, what it changed,
|
||||
or why it failed.
|
||||
|
||||
- **Given** `BOR_GIT_SOURCES` is set (the git repos) and I am signed in
|
||||
as the admin
|
||||
- **When** I click **Sync sources** on the Sources page
|
||||
- **Then** the app clones/pulls each repo, re-imports with prune (the
|
||||
`--prune` equivalent — the canonical "mirror the repos" action), and
|
||||
refreshes the KB overview when anything changed — while the button
|
||||
reports the whole lifecycle (**Syncing…** → **Synced HH:MM** + counts,
|
||||
or an error banner naming the failure) and never sits stale or stuck.
|
||||
Anonymous visitors never see the button, and the sync endpoints answer
|
||||
them with 403.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Admin-only visibility.** The button ships `hidden` in
|
||||
`sources.html` (anonymous-safe) and `header.js` reveals it for the
|
||||
admin on the SAME cached whoami that reveals `#nav-sources` /
|
||||
`#nav-tuning` (one fetch, no extra whoami call); anonymous users
|
||||
never see it.
|
||||
2. **The sync API (A10 extended, A12 in-process).** `POST /api/sync`
|
||||
(admin only) starts one background task — `clone_or_pull` each
|
||||
`BOR_GIT_SOURCES` repo (phase 28, reused) → `import_sources(prune=True)`
|
||||
→ `regenerate_overview` when the KB changed (phase 31) — and returns
|
||||
`202`. A second trigger while a run is in flight returns
|
||||
`409 {"detail": "a sync is already running"}` (one sync at a time).
|
||||
`GET /api/sync/status` (admin only) reports `idle | running |
|
||||
success | failed` with ISO-8601 timestamps and the run's `detail` /
|
||||
`error`. Both endpoints answer anonymous callers with 403.
|
||||
3. **Loud failure.** An unset/empty `BOR_GIT_SOURCES` fails the sync with
|
||||
"no git sources configured (BOR_GIT_SOURCES)" (manual `--source` dirs
|
||||
have no repo to clone); a `GitSyncError` fails the run with git's
|
||||
stderr (the repo named, credentials masked) before any import.
|
||||
4. **The §7.4 "never stale" lifecycle.** Click → `202` → button disabled
|
||||
with **Syncing…** (spinning icon, `aria-busy`) + a 2 s poll of the
|
||||
status endpoint — the ONLY feedback timer; there is no client-side
|
||||
hard timeout (a sync can legitimately run for minutes; the server
|
||||
state is authoritative). Success → enabled, **Synced HH:MM** (local
|
||||
time of `finished_at`) + the last result in `#sync-result`
|
||||
(`role="status"` / `aria-live="polite"`; "added" always announced,
|
||||
zero terms omitted, a no-op run reads `0 added · 1 unchanged`).
|
||||
Failure → enabled, retry-ready **Sync sources** + the `role="alert"`
|
||||
banner naming the error. A `409` adopts the in-flight run (never a
|
||||
second poll loop); a reload mid-sync re-attaches to the running run;
|
||||
a `403` hides the button (defense in depth).
|
||||
5. **Idempotent.** Re-syncing an unchanged repo is a fast-forward pull +
|
||||
sha256 hash skip — nothing re-embedded, the overview left alone
|
||||
(change-gated), the result `0 added · 1 unchanged`.
|
||||
6. **Quality gates.** Integration (`tests/integration/test_sync_api.py`):
|
||||
anonymous 403s; idle → running → success/failed transitions with git +
|
||||
import + overview mocked; 409 double trigger; `GitSyncError` →
|
||||
`failed` with the repo named and the import never called; `prune=True`
|
||||
asserted. Unit (`tests/unit/test_sync_button.py`, frontend-assertion):
|
||||
the ship-hidden markup, the header reveal, the state machine (2 s
|
||||
poll, 202/409/403 branches, terminal labels, single-poll guard, no
|
||||
client timeout), the CSS states (spin + reduced-motion opt-out,
|
||||
disabled, focus-visible, contrast ≥ 4.5:1, 44 px touch floor).
|
||||
Coverage `app/` > 90 % (`app/api/sync.py` fully covered).
|
||||
7. **E2E (this story's gate).** `tests/e2e/test_sync_button.py`, run in
|
||||
isolation: a real local `file://` git fixture repo (deterministic, no
|
||||
network — git is a documented environment prerequisite, phase 28) with
|
||||
the mock LLM proves the admin-only visibility, the full lifecycle
|
||||
against the REAL clone → import → overview path (including the
|
||||
idempotent second run and the fresh `kb_overview` row), and the 409
|
||||
double trigger.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_sync_button.py` — run in isolation (Chromium +
|
||||
`podman compose up -d db` + git on PATH; mock LLM, no live aipi). The
|
||||
module overrides the session app fixture with per-module env
|
||||
(`BOR_GIT_SOURCES=file://<fixture repo>`, its own `BOR_SOURCES_DIR`) and
|
||||
truncates the KB tables before each test (the E2E isolation pattern):
|
||||
|
||||
1. `test_anonymous_sees_no_button` → AC 1 + 2 (the button never leaves
|
||||
`hidden`; both endpoints 403).
|
||||
2. `test_admin_sync_lifecycle` → AC 2 + 4 + 5 (button visible for the
|
||||
admin; click → **Syncing…** (disabled) → **Synced HH:MM** +
|
||||
`1 added`; the fixture path `notes/sync-fixture.md` in the Sources
|
||||
table; the `kb_overview` row fresh and non-empty (DB check); the
|
||||
idempotent second run → `0 added · 1 unchanged`).
|
||||
3. `test_double_trigger_409` → AC 2 (second trigger while running → 409
|
||||
with the exact detail; the single in-flight run still completes).
|
||||
@@ -1,103 +0,0 @@
|
||||
# Story: Agent makes as many tool calls as it wants
|
||||
|
||||
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md` L8 ·
|
||||
**E2E:** `tests/e2e/test_agent_unlimited_tools.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L8)
|
||||
|
||||
> "Allow the LLM to make as many tool calls as it wants, remove the
|
||||
> restrictions, they're causing problems getting correct answers"
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, the phase-37 per-turn budgets — one `list_documents`
|
||||
call and one `read_document` call — cap answers: complex questions need
|
||||
several documents, and the "No reading budget left" refusal is where
|
||||
correct answers die. The per-tool restrictions are being **removed**:
|
||||
the model may call the tools as many times as it needs, within a single
|
||||
configurable **round cap** that exists only to stop a pathological
|
||||
infinite loop (and doubles as the no-tools kill switch).
|
||||
|
||||
- **Given** a grounded chat turn (retrieval found relevant docs)
|
||||
- **When** the model calls `list_documents` / `read_document`
|
||||
- **Then** every valid call is executed — re-lists included — until the
|
||||
model answers or the round cap is reached, at which point a final
|
||||
no-tools answer is forced.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **No per-tool budgets:** `BOR_AGENT_LIST_CALLS` /
|
||||
`BOR_AGENT_READ_CALLS` are gone from `app/config.py`,
|
||||
`.env.example`, and the agent loop; the `LIST_EXHAUSTED` /
|
||||
`READ_EXHAUSTED` refusals no longer exist.
|
||||
2. **Round cap only:** new `agent_max_rounds`
|
||||
(`BOR_AGENT_MAX_ROUNDS`, default **10**) counts tool rounds; at the
|
||||
cap the loop forces one final `tools=None` answer. `0` disables the
|
||||
tools entirely — the request goes out with `tools=None`,
|
||||
byte-identical to the pre-phase-37 path (the kill switch survives,
|
||||
per the owner-locked revision).
|
||||
3. **Non-budget rejections kept:** unknown tool → `"Unknown tool."`,
|
||||
missing args → the `MISSING_READ_ARGS` refusal, already-in-context
|
||||
document → `"Already in your context."` — none of these consume a
|
||||
round's *budget* (there is none) but the round cap still bounds a
|
||||
stream that keeps emitting rejected calls.
|
||||
4. **Everything downstream unchanged:** the `tool` SSE event shape, the
|
||||
per-turn `tool_calls=N` log field (budget-consuming → now: executed),
|
||||
`done.sources` extension, and the UI tool lines are untouched.
|
||||
5. **PLAN recorded:** the phase-37 locked decision
|
||||
("budgets-as-kill-switch") is revised in `.agent/PLAN.md` with an
|
||||
owner-permission note (2026-08-27, `TODO.md` L8), following the
|
||||
established revision-note pattern (phases 16/19/24/37).
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap R2)
|
||||
1. **Both budget env vars removed.** New single guard
|
||||
`BOR_AGENT_MAX_ROUNDS` (default **10** tool rounds, then forced final
|
||||
answer); **`0` = no-tools kill switch** (pre-phase-37 behavior).
|
||||
2. **Re-listing is allowed** (a second `list_documents` is a normal
|
||||
executed call — it even counts in `tool_calls=N`).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Config** (`app/config.py`): delete `agent_list_calls` /
|
||||
`agent_read_calls`; add `agent_max_rounds: int = 10` with a docstring
|
||||
(0 disables the tools entirely — the loop makes exactly one request
|
||||
with `tools=None`). `.env.example`: the two `BOR_AGENT_*_CALLS` lines
|
||||
are replaced by `BOR_AGENT_MAX_ROUNDS=10` with an updated comment.
|
||||
- **Agent loop** (`app/rag/agent.py`): `run_agent` —
|
||||
`max_rounds = settings.agent_max_rounds`; `tools = AGENT_TOOLS if
|
||||
max_rounds > 0 else None`; the loop drops `list_left` / `read_left`
|
||||
and the budget-driven `tools = None` transition; after each executed
|
||||
call `rounds += 1` and at `rounds >= max_rounds` the existing forced
|
||||
final answer path runs (now the *only* exit besides "no calls").
|
||||
`_execute_tool` loses its budget parameters + the two exhaustion
|
||||
constants; `AGENT_TOOLS`' `read_document` description drops "exactly
|
||||
one more"; module/docstrings and the probe reference updated.
|
||||
`AgentHolder` unchanged (`tool_calls` still counts executed calls).
|
||||
- **Tests:** `tests/unit/test_agent.py` rewritten around the round cap
|
||||
(always-listing mock LLM: N rounds then forced answer; `max_rounds=0`
|
||||
→ single `tools=None` request; rejected-call spam bounded by the cap);
|
||||
`tests/unit/test_config.py` (default 10, env override, 0);
|
||||
`tests/integration/test_chat_api.py` budget fixtures →
|
||||
`agent_max_rounds`; `tests/e2e/mock_llm.py` `_tool_flow` extended: the
|
||||
existing deterministic 3-step flow stays, plus a multi-read variant
|
||||
triggered by a marker (e.g. the user message containing
|
||||
`"read two documents"`) — stateless classification by counting
|
||||
`Document …:` tool messages (list → read #1 → read #2 → answer).
|
||||
- **Non-goals:** no per-call cost cap, no streaming change, no new
|
||||
endpoint.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_agent_unlimited_tools.py`** (mock
|
||||
LLM; DB up):
|
||||
1. `test_multi_read_turn` — a grounded question carrying both
|
||||
`TOOLS_TRIGGER` and the multi-read marker: the turn streams tool
|
||||
lines for `list_documents` **and two** `read_document` calls, then a
|
||||
final answer; the bubble is not deflected.
|
||||
2. `test_done_sources_include_reads` — the final `done` (observed via
|
||||
the source chips) lists the retrieved doc(s) plus **both** read
|
||||
documents, deduped.
|
||||
3. `test_relist_allowed` (unit-level via integration, plus E2E
|
||||
observable state) — a re-listed catalog does not produce a refusal
|
||||
line; the UI shows a tool line per executed call.
|
||||
4. `test_single_tool_flow_regression` (phase 37) — the original
|
||||
3-step flow still answers with exactly one read (runs against the
|
||||
unchanged `tests/e2e/test_agent_document_tools.py` in the
|
||||
regression pass, not duplicated here).
|
||||
@@ -1,75 +0,0 @@
|
||||
# Story: Archive upload sources
|
||||
|
||||
**Phase:** `49_archive_upload_sources` · **E2E:** `tests/e2e/test_archive_upload_sources.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, the "Add a local directory" form makes me type
|
||||
server paths — but the directories I want to index often live on *another
|
||||
machine*. I want to **upload a tarball or zipfile** on the Sources
|
||||
(`/git-sources.html`) page: the server **unpacks it and scans it** (the
|
||||
content is indexed, visible in the RAG catalog immediately after the
|
||||
upload settles). Re-uploading the **same filename** must **replace that
|
||||
source in place** — same folder, same list row, previous content
|
||||
overwritten — never a second folder or a duplicate row.
|
||||
|
||||
- **Given** I am signed in as admin, on the Sources page, and I have
|
||||
`homelab.tar.gz` (containing `k3s.md`, `gitlab.md`)
|
||||
- **When** I upload it
|
||||
- **Then** it is unpacked to a server folder named after the archive
|
||||
(`homelab`), imported (added/updated/pruned counts shown), appears in
|
||||
the source list with the Local badge, and its documents show up in the
|
||||
RAG catalog (`/sources.html`).
|
||||
- **Given** I later edit the tarball (drop `gitlab.md`, add `caddy.md`)
|
||||
and re-upload **`homelab.tar.gz`** (same name)
|
||||
- **When** the upload settles
|
||||
- **Then** there is still exactly one `homelab` folder and one list row;
|
||||
`gitlab.md` is pruned from the index, `caddy.md` is indexed, `k3s.md`
|
||||
is unchanged.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `POST /api/git-sources/upload` (multipart `file`, admin-only —
|
||||
anonymous 403 like the rest of the router): accepts
|
||||
`.tar`, `.tar.gz`, `.tgz`, `.zip` (else 422 naming the accepted set);
|
||||
derives the source name from the filename minus the archive suffix
|
||||
(`homelab.tar.gz` → `homelab`); rejects empty/unsafe names (422);
|
||||
caps compressed upload AND extracted bytes at `BOR_UPLOAD_MAX_MB`
|
||||
(default 512 — new settings `BOR_UPLOAD_DIR`, default
|
||||
`~/bor-sources/uploads`, and `BOR_UPLOAD_MAX_MB`); blocks zip-slip /
|
||||
tar-slip (absolute members, `..`, symlink/hardlink escapes, device
|
||||
files) with a 422 and **no partial state** — a failed upload never
|
||||
touches an existing folder, row, or the KB.
|
||||
2. Successful upload: unpack → atomic swap-in of the folder → the
|
||||
`git_sources` row is upserted by path (`kind=local`, no new row when
|
||||
the path exists) → fail-fast model check (phase 41) →
|
||||
`import_sources([folder], prune=True)` (single source) →
|
||||
`regenerate_overview` when the KB changed → **200 with the same count
|
||||
keys as the sync detail** (`files, added, updated, unchanged, pruned,
|
||||
errors, chunks, overview`); one upload at a time (409 while a run is
|
||||
in flight — the phase-32 pattern); a per-upload log line (PLAN §9).
|
||||
3. Re-upload of the same filename replaces the folder's content in place
|
||||
(temp unpack + rename swap — no missing window) and prunes files that
|
||||
left the archive; no duplicate folder, no duplicate row.
|
||||
4. The page: the "Add a local directory" form is **removed**; a labeled
|
||||
archive upload form (file input `accept=".tar,.tar.gz,.tgz,.zip"`,
|
||||
inline error `role=alert`, result line `role=status`, never-stale
|
||||
button per §7.4 showing the counts) takes its place; the hint and
|
||||
table caption mention upload+scan and in-place replace; existing
|
||||
Local rows (incl. pre-existing hand-added dirs) still list/remove.
|
||||
The `POST /api/git-sources` `kind=local` API contract is unchanged
|
||||
(the capability survives via the API — no regression).
|
||||
5. The phase-38 story E2E (`test_local_directory_sources.py`) is
|
||||
rewritten to add local sources via the API (`page.request.post`)
|
||||
instead of the removed form — its other assertions (Local badge,
|
||||
sync-import, prune-remove, 422 naming the path) stand.
|
||||
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_archive_upload_sources.py` — one story, one file, run in
|
||||
isolation: the admin uploads a real (test-built) tarball through the
|
||||
page's file input → counts shown + Local-badged row named after the
|
||||
archive stem + documents visible via the catalog; re-upload of the same
|
||||
filename (modified archive) → still one row/folder, dropped file pruned,
|
||||
new file indexed; a non-archive file gets an inline error; the
|
||||
local-directory form is absent; anonymous still gets the gate.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Story: Animated Background That Actually Animates
|
||||
|
||||
> **SUPERSEDED (owner direction 2026-08-25):** the phase-22 motion
|
||||
> design (grid drift + whole-layer breathe) is superseded by the
|
||||
> owner's "no movement, only fading light" direction — see
|
||||
> `.agent/user_stories/background-no-motion.md` (phase
|
||||
> `25_background_no_motion`). The phase-22 history below is preserved
|
||||
> as-is; the old E2E suite now pins the phase-25 contract as a
|
||||
> regression.
|
||||
|
||||
**Phase:** `22_background_animation` · **E2E:** `tests/e2e/test_background_animation.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, I reported (2026-08-24) that the Brain of Reese
|
||||
background "just blinks". I want the background the phase-08 design
|
||||
promised — a slow, seamless grid drift plus a gentle glow breathe — to
|
||||
be *perceived* as smooth motion in a real browser, so the app feels
|
||||
alive but calm, not like a broken flicker.
|
||||
|
||||
- **Given** the phase-08 pure-CSS background (a 60s grid drift masked
|
||||
into a small top band + a 14s glow opacity swing)
|
||||
- **When** the page is observed in a real Chromium viewport
|
||||
- **Then** both layers visibly and smoothly animate — the grid drift
|
||||
reads as continuous motion and the glow reads as breathing — with no
|
||||
blink, no static frame, no jank, no new overflow at 360px, and no
|
||||
impact on text contrast or interactivity.
|
||||
|
||||
## Bug report (verbatim, TODO.md L5)
|
||||
|
||||
> Fix background animation not working, just blinking
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A3)
|
||||
|
||||
1. **Intended effect = the phase-08 design comments:** a seamless 60s
|
||||
grid drift (one cell per loop) + a 14s eased glow breathing. The fix
|
||||
serves that design — it is not a redesign.
|
||||
2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new
|
||||
assets, no `filter: blur`.
|
||||
|
||||
## Root cause (found in task 01 — `.agent/reports/22_background_animation/`)
|
||||
|
||||
Both layers *were* animating and nothing occluded them (occlusion
|
||||
disproven with a red-line probe) — but:
|
||||
|
||||
1. **The grid drift was invisible.** 1px lines at 35% `--line` alpha
|
||||
blend to a ≈10/12/18-level delta over the page bg, and the radial
|
||||
mask faded the layer to ~zero past the top ~25% of the viewport. A
|
||||
0.73px/s drift of that faint texture sits below the perception
|
||||
threshold (measured ≈1.4/765 mean pixel change over 5s in the grid
|
||||
zone; ≈0 in the bottom half — no grid there at all).
|
||||
2. **The glow swing was the only visible motion.** The 0.65↔1.0 opacity
|
||||
swing (35% amplitude, frame-diff concentrated exactly on the two
|
||||
glow centers) was 100% of the perceived animation → it read as a
|
||||
blink/pulse, not breathing.
|
||||
|
||||
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
|
||||
|
||||
| property | phase 08 | phase 22 |
|
||||
|---|---|---|
|
||||
| grid line alpha | 35% `--line` ×2 | **60% `--line` ×2** |
|
||||
| grid mask | `120% 90% at 50% 0%, black 25%, transparent 78%` | **`140% 110% at 50% 0%, black 40%, transparent 90%`** |
|
||||
| grid drift | 60s, 0→44px | **unchanged** (one-cell seamless loop) |
|
||||
| glow opacity swing | 0.65↔1 | **0.85↔1** |
|
||||
| glow period/scale | 14s ease-in-out alternate, 1↔1.05 | **unchanged** |
|
||||
|
||||
Both layers keep `position: fixed; inset: 0; z-index: -1;
|
||||
pointer-events: none`; `<html>` keeps the `var(--bg)` canvas and
|
||||
`<body>` stays transparent (the no-occlusion contract); the
|
||||
`prefers-reduced-motion` stilling block is untouched.
|
||||
|
||||
## Acceptance criteria
|
||||
1. In a real Chromium viewport, both background layers run their
|
||||
animations (grid drift + glow breathe) and the animation timelines
|
||||
advance — the background is not a static or paused frame.
|
||||
2. No blink: the glow reads as breathing (0.85↔1), not a pulse
|
||||
(0.65↔1); the grid drift reads as smooth continuous motion.
|
||||
3. Layers stay behind content: `position: fixed`, `z-index: -1`,
|
||||
`pointer-events: none`, full-viewport `inset: 0`; `<html>` keeps
|
||||
the page background and `<body>` stays transparent (no occlusion).
|
||||
4. No new horizontal overflow at 360px (the phase-07 pin).
|
||||
5. Pure CSS, zero JS, no `filter: blur`, no new assets (phase-08
|
||||
anchor); WCAG AA palette untouched (the layers carry no text).
|
||||
6. Regressions green in isolation: `test_dark_tech_theme.py` (pins the
|
||||
live 60s/14s durations + reduced-motion stilling) and
|
||||
`test_responsive_polish.py`.
|
||||
7. Unit + integration green, `app/` coverage ≥90%, story E2E green in
|
||||
isolation, ruff + pyright clean.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Grid layer (`body::before`):** 44px cells, 1px lines at 60% of
|
||||
`--line` (`rgb(38 48 74 / 0.6)`), radial mask fully visible to 40% of
|
||||
the radius and faded out by 90%, 60s linear infinite drift of
|
||||
exactly one cell (0→44px) — seamless loop.
|
||||
- **Glow layer (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
|
||||
56rem circle at 12%/8% + cyan `rgb(34 211 238 / 0.10)` 60rem circle
|
||||
at 88%/92%; 14s ease-in-out infinite alternate, opacity 0.85↔1 +
|
||||
scale 1↔1.05.
|
||||
- **Stacking:** the `<html>` canvas (`var(--bg)` = `#0a0e17`) sits
|
||||
under both `z-index: -1` layers; `<body>` stays transparent and
|
||||
non-stacking, so nothing can paint over the layers.
|
||||
- **Motion:** compositor-friendly `background-position` / `opacity` /
|
||||
`transform` only; no blur filter; `prefers-reduced-motion` stills
|
||||
both layers (phase-08 guard, unchanged).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_background_animation.py`** (the
|
||||
layers are CSS pseudo-elements — asserted via computed style + the Web
|
||||
Animations API; Chromium enumerates pseudo-element CSS animations in
|
||||
`document.getAnimations()`, not `document.body.getAnimations()`):
|
||||
1. `test_grid_layer_animation_running` — computed style of
|
||||
`body::before`: `animationName` = `bg-grid-drift`, timing function
|
||||
`linear`, iteration count `infinite`; a matching entry in the
|
||||
document animation list with `playState === "running"`.
|
||||
2. `test_glow_layer_animation_running` — same for `body::after` with
|
||||
`bg-glow-breathe`; `playState === "running"`.
|
||||
3. `test_animations_advance` — sample `currentTime` of both layers,
|
||||
wait ~500ms, assert both advanced — truly running, not paused
|
||||
(the headless document animation timeline starts ~1s after load, so
|
||||
the sample polls until the timeline is alive first).
|
||||
4. `test_background_layers_contracts` — both pseudo-elements:
|
||||
`position: fixed`, `z-index: -1`, `pointer-events: none`,
|
||||
`inset: 0`; `document.documentElement` computed background is the
|
||||
palette bg (canvas stays on `html`); `document.body` computed
|
||||
background is `rgba(0, 0, 0, 0)` (no occlusion).
|
||||
5. `test_no_horizontal_overflow_with_layers` — 360px viewport:
|
||||
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin,
|
||||
replicated locally).
|
||||
@@ -1,181 +0,0 @@
|
||||
# Story: A Background That No Longer Moves — Only Fading Light
|
||||
|
||||
**Phase:** `25_background_no_motion` · **E2E:**
|
||||
`tests/e2e/test_background_no_motion.py`
|
||||
**Supersedes:** `.agent/user_stories/background-animation.md`
|
||||
(phase-22 motion design — history preserved there)
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, I reported (2026-08-25, chat) that the phase-22
|
||||
background "jitters down and to the right every second and it slowly
|
||||
blinks brighter and darker". I want the background to be smooth,
|
||||
fluxuating, dimming and brightening — but **not moving** — with
|
||||
**different bright spots** that slowly fade in and out.
|
||||
|
||||
- **Given** the phase-22 animated background (a 60s one-cell grid drift
|
||||
at 0.73px/s diagonally down-right + a 14s whole-layer glow breathe of
|
||||
opacity 0.85↔1 with scale 1↔1.05)
|
||||
- **When** the page is observed in a real Chromium viewport
|
||||
- **Then** nothing moves — the grid is a static texture and no
|
||||
background keyframe animates anything but `opacity` — while three
|
||||
independent bright spots each fade in and out on their own slow,
|
||||
out-of-phase cycles (26s/34s/42s), so the total light fluxuates
|
||||
smoothly and irregularly: no jitter, no blink, no static frame, no
|
||||
new overflow at 360px, and no impact on text contrast or
|
||||
interactivity.
|
||||
|
||||
## Owner report (verbatim, 2026-08-25, chat)
|
||||
|
||||
> It should be smooth, fluxuating, dimming and brightening, but not
|
||||
> moving. Different bright spots should slowly fade in and out.
|
||||
|
||||
## Owner direction (2026-08-25)
|
||||
|
||||
1. **No movement** — no grid drift, no `scale`/`transform`, no
|
||||
`background-position` animation, anywhere in the background.
|
||||
2. **Fluxuating brightness** — overall page brightness varies smoothly
|
||||
and irregularly (not one synchronized pulse).
|
||||
3. **Different bright spots** — multiple glow spots, each fading in
|
||||
and out on its own slow cycle.
|
||||
4. **The static grid stays** — the owner rejected the grid's *motion*,
|
||||
not the grid; it remains as a still texture. (If the owner later
|
||||
wants the grid gone, that is a follow-up, not this phase.)
|
||||
|
||||
## Root cause (found from the code + phase-22 measurements)
|
||||
|
||||
1. **"Jitters down and to the right every second" = the grid drift.**
|
||||
`bg-grid-drift` moved the 44px grid 44px per 60s (≈0.73px/s)
|
||||
diagonally down-right — exactly the reported direction. A 1px grid
|
||||
line translated sub-pixel by sub-pixel is rasterized with per-frame
|
||||
stepping/shimmer, not smooth motion: it reads as a once-per-second
|
||||
jitter. Phase 22 had made that drift *visible* (60% line alpha,
|
||||
wider mask — measured 1.82/765 mean pixel change over 5s in the
|
||||
grid zone); that is precisely why it now reads as jitter.
|
||||
2. **"Slowly blinks brighter and darker" = the whole-layer breathe.**
|
||||
`bg-glow-breathe` swung the ENTIRE glow layer's opacity 0.85↔1 over
|
||||
14s (alternate) plus `scale(1)↔scale(1.05)` (a faint zoom). One
|
||||
synchronized pulse of the whole background reads as a blink; the
|
||||
owner wants independent spots instead.
|
||||
|
||||
Phase 22 served the phase-08 design intent (grid drift + whole-layer
|
||||
breathe). The owner's 2026-08-25 direction supersedes that **design
|
||||
intent** — no LOCKED anchor changed (A11 stays pure CSS / zero JS / no
|
||||
CDN / no new assets; the no-`filter: blur` perf anchor is honored).
|
||||
|
||||
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
|
||||
|
||||
| property | phase 22 | phase 25 |
|
||||
|---|---|---|
|
||||
| grid (`body::before`) | `bg-grid-drift 60s linear infinite` (0→44px) | **static** — no animation, `bg-grid-drift` deleted (44px cells, 60% `--line` 1px lines, widened mask kept) |
|
||||
| glow layer count | one whole-layer breathe | **three independent spot layers** |
|
||||
| spot A (`body::after`) | indigo + cyan spots, 14s opacity 0.85↔1 + scale 1↔1.05 | **indigo `rgb(109 120 242 / 0.14)` 56rem at 12%/8%**, `bg-glow-a` **26s** ease-in-out infinite, low opacity **0.25** |
|
||||
| spot B (`html::before`) | — (cyan shared body::after) | **cyan `rgb(34 211 238 / 0.10)` 60rem at 88%/92%**, `bg-glow-b` **34s** ease-in-out **−12s** infinite, low **0.20** |
|
||||
| spot C (`html::after`) | — | **indigo `rgb(109 120 242 / 0.09)` 52rem at 14%/86%**, `bg-glow-c` **42s** ease-in-out **−23s** infinite, low **0.15** |
|
||||
| keyframes | `bg-grid-drift` (background-position), `bg-glow-breathe` (opacity + transform) | **`bg-glow-a/b/c` — opacity only** (0%/100% low → 50% 1) |
|
||||
| reduced motion | stills `body::before/::after` | stills **all four** layers |
|
||||
|
||||
All four layers keep `content: ""; position: fixed; inset: 0;
|
||||
z-index: -1; pointer-events: none`. `<html>` keeps the `var(--bg)`
|
||||
canvas and `<body>` stays transparent (the no-occlusion contract):
|
||||
`html` is the root stacking context, so its `z-index: -1`
|
||||
pseudo-elements paint above the canvas and below the transparent,
|
||||
non-stacking `<body>`'s content. The 26/34/42s periods with negative
|
||||
delays (LCM 4641s) keep the cycles out of phase — the composite
|
||||
pattern effectively never repeats within a viewing session.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **No movement:** the grid is static (`body::before` computed
|
||||
`animationName: none`; no `bg-grid-drift` in
|
||||
`document.getAnimations()`; grid texture still painted) and —
|
||||
audited in real Chromium via `document.styleSheets` — **no `bg-*`
|
||||
keyframe animates anything but `opacity`** (the deterministic
|
||||
no-movement proof).
|
||||
2. **Three distinct bright spots** (`body::after`, `html::before`,
|
||||
`html::after`) run distinct slow opacity fades (26s/34s/42s,
|
||||
ease-in-out, infinite, pairwise distinct, out of phase); all three
|
||||
timelines advance; the layer's computed opacity AND a clipped
|
||||
screenshot of the bottom-left glow region measurably change within
|
||||
a few seconds (a real fade, not a frozen frame).
|
||||
3. **Contracts hold:** all four layers `position: fixed`, `z-index:
|
||||
-1`, `pointer-events: none`, full-viewport `inset: 0`; `<html>`
|
||||
keeps the `var(--bg)` canvas (`rgb(10, 14, 23)`) and `<body>` stays
|
||||
transparent (`rgba(0, 0, 0, 0)`) — no occlusion.
|
||||
4. **Reduced motion** stills all four layers
|
||||
(`animationName: none`), the static grid + spot images remain.
|
||||
5. No new horizontal overflow at 360px (the phase-07 pin).
|
||||
6. Pure CSS, zero JS, no `filter: blur`, no new assets (A11 + phase-08
|
||||
perf anchor); WCAG AA palette untouched (the layers carry no text).
|
||||
7. Regressions green in isolation:
|
||||
`tests/e2e/test_background_animation.py` (adapted to the phase-25
|
||||
contract), `tests/e2e/test_dark_tech_theme.py` (grid static +
|
||||
26/34/42s spots; reduced motion across all four layers),
|
||||
`tests/e2e/test_responsive_polish.py`.
|
||||
8. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, ruff + pyright clean.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Grid layer (`body::before`):** 44px cells, 1px lines at 60% of
|
||||
`--line` (`rgb(38 48 74 / 0.6)`), widened radial mask
|
||||
(`140% 110% at 50% 0%, black 40%, transparent 90%`) — a STATIC
|
||||
texture, no animation.
|
||||
- **Glow spot A (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
|
||||
56rem circle at 12%/8% (phase-08 position/color); opacity-only fade
|
||||
0.25↔1 over 26s ease-in-out.
|
||||
- **Glow spot B (`html::before`):** cyan `rgb(34 211 238 / 0.10)` 60rem
|
||||
circle at 88%/92% (phase-08 position/color); fade 0.20↔1 over 34s,
|
||||
−12s delay.
|
||||
- **Glow spot C (`html::after`):** indigo `rgb(109 120 242 / 0.09)`
|
||||
52rem circle at 14%/86%; fade 0.15↔1 over 42s, −23s delay.
|
||||
- **Stacking / no occlusion:** the `<html>` canvas
|
||||
(`var(--bg)` = `#0a0e17`) sits under all four `z-index: -1` layers;
|
||||
`<body>` stays transparent and non-stacking, so nothing can paint
|
||||
over the layers — verified live, not assumed.
|
||||
- **Motion:** opacity-only keyframes (compositor-friendly); no
|
||||
`transform`, no `background-position`, no `filter` anywhere in the
|
||||
background; `prefers-reduced-motion: reduce` stills all four layers
|
||||
(the static background remains visible).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_background_no_motion.py`** (the
|
||||
layers are CSS pseudo-elements — asserted via computed style + the Web
|
||||
Animations API + a live `document.styleSheets` audit; Chromium
|
||||
enumerates pseudo-element CSS animations in `document.getAnimations()`,
|
||||
not `document.body.getAnimations()`, and the `html` pseudo-layers'
|
||||
computed styles come from
|
||||
`getComputedStyle(document.documentElement, "::before"/"::after")`):
|
||||
1. `test_grid_layer_is_static` — computed `animationName` of
|
||||
`body::before` is `"none"`; no `bg-grid-drift` entry in
|
||||
`document.getAnimations()`; the grid `backgroundImage` is still
|
||||
present (the static texture survives).
|
||||
2. `test_three_glow_layers_run_distinct_fades` — `body::after` →
|
||||
`bg-glow-a` (26s), `documentElement::before` → `bg-glow-b` (34s),
|
||||
`documentElement::after` → `bg-glow-c` (42s); each `ease-in-out` +
|
||||
`infinite`, with a matching `playState === "running"` entry in the
|
||||
document animation list; the three durations are pairwise distinct.
|
||||
3. `test_no_motion_properties_in_background_keyframes` — walk
|
||||
`document.styleSheets`; for every `CSSRule.KEYFRAMES_RULE` whose
|
||||
name starts with `bg-`, collect the declared property names of
|
||||
every keyframe frame; the set across all frames is exactly
|
||||
`{"opacity"}` — the deterministic no-movement proof.
|
||||
4. `test_glow_timelines_advance` — poll until all three timelines
|
||||
report `currentTime > 0` (headless Chromium starts the document
|
||||
timeline ~1s after load), sample all three, wait ~500ms, each
|
||||
advanced ≥ 200ms.
|
||||
5. `test_background_light_actually_changes` — (a) the computed opacity
|
||||
of `body::after` changes by ≥ 0.05 within ~8s (a real fade, not a
|
||||
frozen frame); (b) two clipped screenshots ~4s apart of the
|
||||
bottom-left glow region (the `html::after` spot at 14%/86%) differ
|
||||
in bytes — the light visibly changes while nothing moves.
|
||||
6. `test_background_layers_contracts` — all four pseudo-layers:
|
||||
`position: fixed`, `z-index: -1`, `pointer-events: none`,
|
||||
top/right/bottom/left all `0px`; `documentElement` computed
|
||||
background is `rgb(10, 14, 23)` (canvas stays on `html`);
|
||||
`document.body` computed background is `rgba(0, 0, 0, 0)` (no
|
||||
occlusion).
|
||||
7. `test_reduced_motion_stills_all_layers` —
|
||||
`reduced_motion="reduce"` context: all four pseudo-layers report
|
||||
computed `animationName` `"none"` and still carry a
|
||||
`backgroundImage`.
|
||||
8. `test_no_horizontal_overflow_with_layers` — 360px viewport:
|
||||
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin).
|
||||
@@ -1,91 +0,0 @@
|
||||
# Story: Cache Busting (Un-stick the Pages)
|
||||
|
||||
**Phase:** `33_cache_busting/` · **E2E:** `tests/e2e/test_cache_busting.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **anyone using Brain of Reese**, I want a new deploy to be **visible
|
||||
without a hard refresh**. Today the five HTML pages reference their CSS/JS
|
||||
with no version at all, so the browser keeps serving stale assets long
|
||||
after the app has moved on — the "the pages are too sticky" report. I want
|
||||
a deploy (a commit) to change what the browser fetches, automatically,
|
||||
with no new services and no CDN.
|
||||
|
||||
- **Given** the app has been redeployed (a new commit)
|
||||
- **When** I open — or revisit — any page
|
||||
- **Then** the page itself is always revalidated (never served from cache
|
||||
unchecked), its assets are fetched from **versioned URLs**
|
||||
(`?v=<token>` — the git short SHA of the deploy, so each commit changes
|
||||
them), and the API — the SSE chat stream in particular — is left
|
||||
byte-for-byte alone.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **HTML always revalidates.** All five pages (`/`, `/index.html`,
|
||||
`/sources.html`, `/document.html`, `/login.html`, `/tuning.html`) ship
|
||||
`Cache-Control: no-cache` (the page is the cheap redirector to the
|
||||
long-cached assets).
|
||||
2. **Assets are versioned.** Every local `href`/`src` asset reference on
|
||||
those pages carries `?v=<token>` — rewritten in flight by one
|
||||
middleware (a small regex over `href`/`src` `assets/…` refs, leading
|
||||
slash optional, idempotent: an existing query string or fragment is
|
||||
never double-tagged, and non-asset references pass through). The token
|
||||
is non-empty and **stable across requests** within a process (one token
|
||||
per process, computed once).
|
||||
3. **Immutable assets.** `/assets/*` responses ship
|
||||
`Cache-Control: public, max-age=31536000, immutable` — safe precisely
|
||||
because the URL carries the token (a deploy changes the token, hence
|
||||
the URL). The unversioned path keeps resolving (the static mount
|
||||
ignores the query string), so old tabs and direct links still work.
|
||||
4. **The token is the deploy.** `app/core/caching.py::asset_version()`:
|
||||
a git checkout (the project root next to `frontend/` has a `.git` —
|
||||
the homelab reality) → `git rev-parse --short HEAD` with a 5 s timeout
|
||||
(a deploy must not hang a boot); no `.git`, git missing, non-zero
|
||||
exit, timeout, or empty output → the first **12 hex chars** of SHA-256
|
||||
over the sorted `relpath:mtime_ns:size` of every regular file under
|
||||
`frontend/`; a missing/empty static dir → `"dev"`. Cached with
|
||||
`functools.cache` (== `lru_cache(maxsize=None)`) — zero per-request
|
||||
git/file cost. A new commit or a frontend content change flips the
|
||||
token on the next process start.
|
||||
5. **The API is untouched.** The middleware touches exactly two response
|
||||
shapes (the page paths: body rewrite + `no-cache`; `/assets/*`:
|
||||
header only). Everything else — all of `/api/*`, including the SSE
|
||||
chat stream — passes through with no header changes and no body read
|
||||
(SSE keeps the `no-cache` its endpoint sets itself). The no-CDN
|
||||
integration test stays green (the rewritten references are
|
||||
same-origin).
|
||||
6. **Quality gates.** Unit (`tests/unit/test_caching.py`): the token's
|
||||
git path, fallback path (stable across an unchanged tree, flips on a
|
||||
touch/modify + `cache_clear()`), failure path (git raising → content
|
||||
hash, no exception), empty-dir `"dev"`; the pure `rewrite_asset_refs`
|
||||
(versioned `href`/`src`, no-leading-slash refs, module scripts,
|
||||
idempotency, `#fragment`/existing-query left alone, non-asset refs
|
||||
untouched). Integration (`tests/integration/test_api.py`): every page
|
||||
`no-cache` + versioned references, asset headers, no `cache-control`
|
||||
injected on `/api/health`, SSE chat tests unchanged and green.
|
||||
Coverage: `app/core/caching.py` 100 %, `app/` > 90 % (TOTAL ≥
|
||||
pre-change).
|
||||
7. **E2E (this story's gate).** `tests/e2e/test_cache_busting.py`, run in
|
||||
isolation: a real (fresh-profile) Chromium asserts the **wire truth**
|
||||
— the document responses are `no-cache`, the CSS/JS request URLs the
|
||||
browser actually makes carry one shared token (matching this
|
||||
checkout's `asset_version()`), the asset responses are immutable for a
|
||||
year, and `/api/*` — including a live SSE chat turn that streams
|
||||
deltas and completes with `done` — is unaffected (mock LLM, no live
|
||||
aipi; `podman compose up -d db` for the chat check).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_cache_busting.py` — run in isolation (Chromium +
|
||||
`podman compose up -d db`; mock LLM via the shared `conftest.py` session
|
||||
app; no per-module overrides needed):
|
||||
|
||||
1. `test_html_pages_are_no_cache_and_versioned` → AC 1 + 2 + 3 (the `/`
|
||||
document response is `no-cache`; the `styles.css` request URL carries
|
||||
`?v=<token>` and its response is `immutable` + `max-age=31536000`;
|
||||
the `app.js` request URL carries the **same** token; the served HTML
|
||||
carries no unversioned `"/assets/styles.css"` reference).
|
||||
2. `test_other_pages_share_the_token` → AC 1 + 2 (the `/sources.html` and
|
||||
`/login.html` document responses are `no-cache`; both pages'
|
||||
stylesheet requests carry the same token).
|
||||
3. `test_api_responses_unaffected` → AC 5 (`/api/health` has no
|
||||
`cache-control` injected — the endpoint's baseline headers only; the
|
||||
SSE chat POST still streams deltas and completes with `done`).
|
||||
@@ -1,131 +0,0 @@
|
||||
# Story: Containerfile Builds the Whole App Again
|
||||
|
||||
**Phase:** `23_containerfile_build` · **Gate:** hermetic integration
|
||||
`tests/integration/test_containerfile_assets.py` + real `podman build`
|
||||
+ image smoke (no Playwright suite — build/infrastructure phase, per
|
||||
the phase plan's A16 note)
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, I reported (2026-08-24, TODO.md L6) that the
|
||||
Containerfile build "is not working". I want
|
||||
`podman build -f Containerfile .` to succeed again — and, more
|
||||
importantly, for the resulting image to serve the **whole app**: all
|
||||
four pages (chat, sources, document viewer, login) with their bundled,
|
||||
minified, local-only assets (No CDN rule, A11), the shared header
|
||||
module evaluated exactly once per page, and a build contract that
|
||||
fails loudly when a future page/script/asset outgrows the Containerfile.
|
||||
|
||||
- **Given** the 3-stage `Containerfile` (esbuild frontend bundle → uv
|
||||
python deps → slim runtime serving `/app/static`)
|
||||
- **When** `podman build -f Containerfile .` runs
|
||||
- **Then** the build is green and the image serves `/`,
|
||||
`/sources.html`, `/document.html`, `/login.html` with 200 and every
|
||||
referenced asset local and minified — with `header.js` inlined into
|
||||
each page bundle exactly once.
|
||||
|
||||
## Bug report (verbatim, TODO.md L6)
|
||||
|
||||
> Fix Containerfile build not working
|
||||
|
||||
## Verified diagnosis (2026-08-24, phase 23 — not a guess)
|
||||
|
||||
1. **Root cause of the build failure:** phase 19 switched the page
|
||||
scripts to `import … from "/assets/header.js"` (an absolute URL).
|
||||
esbuild resolves that as the *filesystem* path `/assets/header.js`
|
||||
and the stage-1 bundle dies:
|
||||
`✘ [ERROR] Could not resolve "/assets/header.js"`
|
||||
(reproduced with esbuild **0.25.5**, the exact pinned version, on a
|
||||
copy of `frontend/` — `repro_esbuild_0.25.5.log`).
|
||||
2. **Secondary gap (image would be broken even if it built):** stage 1
|
||||
bundled only `app.js` + `sources.js` and copied only `index.html` +
|
||||
`sources.html`. Missing from the image: `document.html` +
|
||||
`login.html` (phases 10/16), `document.js` + `login.js`, and
|
||||
`markdown.js` (classic script loaded by `index.html` +
|
||||
`document.html`).
|
||||
3. **Latent double-evaluation trap:** all four HTML pages also load
|
||||
`<script type="module" src="/assets/header.js">` directly while the
|
||||
page script imports it. In dev the browser dedupes (same module
|
||||
URL) — but in the image the bundled page script already contains the
|
||||
header code, so shipping a raw `header.js` too would evaluate the
|
||||
module **twice** (duplicate sign-out listener, double init). The
|
||||
direct tags are redundant: the page script's `import` is hoisted and
|
||||
guarantees `header.js` evaluates before the page script's body calls
|
||||
`initSharedHeader()`, in dev and in the bundle alike.
|
||||
4. **Second build blocker (found in task 01):** a *local* `npm install`
|
||||
of esbuild left the binary in `node_modules/.bin`, invisible to the
|
||||
next `RUN` — `esbuild: not found`. The stage must install globally.
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A4)
|
||||
|
||||
1. **Relative imports** (`from "./header.js"`) over an esbuild alias —
|
||||
simpler, verified working, dev-server behavior unchanged (files are
|
||||
side-by-side).
|
||||
2. **Remove the four redundant direct `header.js` script tags** (the
|
||||
design above) rather than ship a raw `header.js` into the image —
|
||||
single module evaluation, no duplicate listeners.
|
||||
3. The image must cover **all four pages + all local assets** they
|
||||
reference — the integration test (task 02) enforces this coverage so
|
||||
the gap cannot silently reappear.
|
||||
|
||||
## Fix
|
||||
|
||||
| piece | before | after |
|
||||
|---|---|---|
|
||||
| page-script header import | `from "/assets/header.js"` (absolute — esbuild fatal) | **`from "./header.js"`** (relative, bundles cleanly) |
|
||||
| direct header.js `<script>` tags | 4 (one per page — double-eval trap) | **0** (the hoisted import covers evaluation order) |
|
||||
| esbuild install | local (binary off the next RUN's PATH) | **`npm install -g esbuild@0.25.5`** (pinned, on PATH) |
|
||||
| stage-1 bundles | `app.js`, `sources.js` | **`app.js`, `sources.js`, `document.js`, `login.js`** |
|
||||
| stage-1 minifies | `styles.css` | **`styles.css`**, **`markdown.js`** (no `--bundle` — classic script) |
|
||||
| stage-1 copies | `index.html`, `sources.html` | **all four pages** into `/out` |
|
||||
|
||||
## Acceptance criteria
|
||||
1. Local esbuild 0.25.5 bundles all four page scripts cleanly.
|
||||
2. `podman build -f Containerfile .` green (log excerpt in
|
||||
`.agent/reports/23_containerfile_build/`).
|
||||
3. Image smoke: container runs (throwaway Postgres 17 + pgvector);
|
||||
`GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
|
||||
`/assets/app.js` minified and contains the header code;
|
||||
`/assets/markdown.js` 200; no `http(s)://` asset reference in any
|
||||
served page (No CDN rule).
|
||||
4. Dev server unchanged in behavior: `test_smoke.py`,
|
||||
`test_shared_header.py`, `test_chat_persistence.py` green in
|
||||
isolation.
|
||||
5. `test_containerfile_assets.py` 6/6 within the full suite;
|
||||
`uv run pytest` green; `app/` coverage ≥ today's number (>90% gate);
|
||||
ruff + pyright clean.
|
||||
|
||||
## Test mapping table
|
||||
|
||||
**Test Scenario → `tests/integration/test_containerfile_assets.py`**
|
||||
(hermetic — parses `Containerfile` + `frontend/` as text; no podman,
|
||||
no network, no DB):
|
||||
|
||||
1. `test_every_html_page_is_copied_into_stage1` — every
|
||||
`frontend/*.html` is copied by a stage-1 `cp` into `/out`, and the
|
||||
copied set is exactly the pages on disk (new page without cp entry
|
||||
→ fail; cp of a deleted page → fail).
|
||||
2. `test_every_local_asset_reference_is_produced` — every local
|
||||
`assets/`/`/assets/` `src=`/`href=` in the pages is produced by a
|
||||
stage-1 line (esbuild `--outfile=/out/assets/<name>` or a `cp`) —
|
||||
the missing-`markdown.js`-style gap cannot reappear.
|
||||
3. `test_page_module_scripts_are_bundled` — the `type="module"` page
|
||||
scripts referenced by the HTML (basenames) equal the esbuild
|
||||
`--bundle` inputs in stage 1 (today: `app.js`, `sources.js`,
|
||||
`document.js`, `login.js`).
|
||||
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
|
||||
carries a direct `<script … src="…/header.js">` tag (single-evaluation
|
||||
design pin, owner-confirmed A4-2) and every page script imports it
|
||||
relatively (`from "./header.js"` — no absolute form).
|
||||
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js` has
|
||||
a stage-1 minify line **without** `--bundle` and no top-level
|
||||
`import`/`export` in the source (both sides of the classic-script
|
||||
assumption pinned).
|
||||
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
|
||||
`esbuild@X.Y.Z` (no `latest`/`^`/`~`/bare spec — the exact pinned
|
||||
0.25.5 is what the diagnosis reproduced against).
|
||||
|
||||
Complementary source pins live in `tests/unit/test_shared_header.py::
|
||||
test_header_module_loads_before_the_page_script` (unit) and the real
|
||||
build + image smoke are recorded in
|
||||
`.agent/reports/23_containerfile_build/` (task 01).
|
||||
@@ -1,54 +0,0 @@
|
||||
# Story: Open Documents in a Modal (Almost-Fullscreen)
|
||||
|
||||
**Phase:** `26_document_modal_viewer.md` · **E2E:** `tests/e2e/test_document_viewer.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, when I click a document source chip (in the chat) or a
|
||||
document path link (in the Sources table), I want the document to open
|
||||
**right where I am** — in an almost-fullscreen modal overlay — instead of
|
||||
navigating away to a new page/tab. I want to read it, then close the modal
|
||||
and keep going.
|
||||
|
||||
- **Given** a document cited in a chat answer (or listed in Sources)
|
||||
- **When** I click its chip / path link
|
||||
- **Then** the document opens in an almost-fullscreen modal on the same
|
||||
page, rendered exactly as the `/document.html` viewer renders it
|
||||
(markdown in a centered column, other formats in a mono `pre`), with a
|
||||
close button, Escape-to-close, and backdrop-to-close; and the dedicated
|
||||
`/document.html` page still works for direct links / no-JS fallback.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Clicking a source chip or a Sources-table path link opens the document
|
||||
in a modal (`#doc-modal`, `.doc-modal-panel`, ~`96vw × 92vh`) on the
|
||||
current page — **no new tab, no navigation**.
|
||||
2. The modal renders the same content as `/document.html`: md/markdown via
|
||||
the shared escape-first renderer (`.doc-md`), other formats in
|
||||
`<pre class="doc-raw">`, source/format/path/indexed/chunks meta.
|
||||
3. The modal closes on the close button, on `Escape`, and on backdrop
|
||||
click; focus moves into the panel on open and returns on close.
|
||||
4. The modal honours the dark theme, `prefers-reduced-motion`, no-CDN, and
|
||||
the ≥4.5:1 contrast / focus-visible a11y rules.
|
||||
5. The standalone `/document.html` page is unchanged (direct link, back
|
||||
button, not-found state, XSS-safe rendering).
|
||||
6. No new packages; vanilla HTML/CSS/JS; the modal reuses the existing
|
||||
stateless `GET /api/documents/content` endpoint (no backend change).
|
||||
7. Story E2E green in isolation, no regressions in the document-navigation
|
||||
/ header / smoke suites, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_document_viewer.py`** (mock LLM, seeded
|
||||
KB — same harness as the phase-10 suite):
|
||||
1. `test_source_chip_opens_modal` — chip → modal opens in-page (no popup),
|
||||
title + `.doc-md` content present, page URL unchanged.
|
||||
2. `test_sources_row_opens_modal` — Sources path link → modal, yaml in
|
||||
`<pre.doc-raw>`, mono font.
|
||||
3. `test_modal_closes_on_button_escape_and_backdrop` — close via button,
|
||||
backdrop, and `Escape`.
|
||||
4. `test_modal_focus_and_a11y` — `role="dialog"` + `aria-modal`, focus
|
||||
inside the panel, close button has an `aria-label`.
|
||||
5. `test_modal_xss_safe` — hostile md renders escaped, no dialog fires.
|
||||
6. `test_standalone_page_still_works` — `/document.html` still renders,
|
||||
not-found, dark theme, no-CDN, a11y frame, ≤736px md column.
|
||||
7. `test_modal_theme_and_no_cdn` — dark surface; all assets same-origin /
|
||||
`data:`.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Story: Document Summaries (lite-model summaries for non-markdown docs)
|
||||
|
||||
**Phase:** `30_document_summaries.md` · **E2E:** `tests/e2e/test_document_summaries.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **any user of Reese**, I ask questions about notes that live in
|
||||
machine-formatted files (yaml, json, py, txt). Raw flags and keys embed
|
||||
badly, so retrieval misses those documents. I want a small model to
|
||||
**analyze every non-markdown document at import time** and store a
|
||||
natural-language summary with a pointer back to the source. When
|
||||
retrieval hits a summary, Reese follows the pointer and answers from the
|
||||
**full source document** — the summary is a retrieval target, never the
|
||||
answer.
|
||||
|
||||
- **Given** a knowledge base containing non-markdown A9 documents (yaml,
|
||||
yml, json, py, txt)
|
||||
- **When** I run `import_docs` and then ask a question those documents
|
||||
answer
|
||||
- **Then** each non-markdown document carries a `lite`-model summary
|
||||
(`documents.summary` plus one embedded `is_summary` chunk at position
|
||||
−1), markdown documents carry neither, and a question whose best match
|
||||
is a summary chunk gets an answer grounded in the **full source
|
||||
document**, with the hit counted in the per-turn log line
|
||||
(`summary_hits=N`).
|
||||
|
||||
## Acceptance criteria
|
||||
1. `BOR_LLM_SUMMARY_MODEL` (default `lite`) + non-streaming
|
||||
`LLMClient.chat()` — A5 extended (same OpenAI-compatible endpoint),
|
||||
empty/missing content raises `LLMError` (no silent summaries).
|
||||
2. Migration 0004: `documents.summary TEXT NULL` +
|
||||
`chunks.is_summary BOOLEAN NOT NULL DEFAULT FALSE` (reversible,
|
||||
integration-tested up/down).
|
||||
3. `app/rag/summarizer.py`: the `SUMMARY_MODE` prompt (document content
|
||||
capped at `BOR_SUMMARY_MAX_CHARS`, overflow marked with the shared
|
||||
truncation marker), the lite call, output validation, and the
|
||||
**code-deterministic** `Source: <source>/<path>` pointer line (never
|
||||
model-generated).
|
||||
4. Importer: every non-markdown file gets its summary stored + indexed
|
||||
(best-effort — a lite failure logs, counts in `summary_errors`, and
|
||||
still leaves the document fully indexed); markdown files get neither;
|
||||
re-import replaces the old summary chunk (exactly one at a time).
|
||||
5. `is_summary` flows through the retriever's vector and lexical
|
||||
candidate lists and `fuse`; `TurnPlan.summary_hits` counts hit chunks
|
||||
with `is_summary` whose parent document made the selected top-N
|
||||
context; the per-turn log line records `summary_hits=N` after
|
||||
`fts_hits=N` (PLAN §9 extension).
|
||||
6. A summary hit resolves to its parent through the unchanged
|
||||
chunk→document mapping — the LLM receives the full source document
|
||||
(A7 revised: never truncated).
|
||||
7. `.env.example` + README document `BOR_LLM_SUMMARY_MODEL` /
|
||||
`BOR_SUMMARY_MAX_CHARS` and the fail-soft behavior; the deterministic
|
||||
mock LLM answers `SUMMARY_MODE` with a byte-stable 24-token digest.
|
||||
8. 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_document_summaries.py` — one story, one file, run in
|
||||
isolation. It imports the story-dedicated fixture KB
|
||||
(`tests/fixtures/summary_kb/`: one yaml document with a tail sentinel +
|
||||
one markdown control document) through the real importer against the
|
||||
deterministic mock LLM, asserts the yaml document's import state (one
|
||||
embedded `is_summary` chunk at position −1 whose text is the byte-stable
|
||||
mock digest + the deterministic `Source:` pointer), asserts the summary
|
||||
chunk is the yaml document's best fused chunk, then asks the question
|
||||
whose best yaml match is that summary chunk and asserts the rendered
|
||||
answer quotes the document's tail sentinel `RESE-SUMMARY-SENTINEL-7f3a`
|
||||
(only possible if the full source document — not the summary digest —
|
||||
reached the LLM), the source chip cites the yaml path, the turn is not
|
||||
deflected, and the markdown control document carries no summary chunk.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Story: Admin Page to Add / Remove Git Sources
|
||||
|
||||
**Phase:** `35_git_sources_admin` · **E2E:** `tests/e2e/test_git_sources_admin.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, today I manage the git repos that feed the
|
||||
knowledge base by editing `BOR_GIT_SOURCES` in `.env` and restarting. I
|
||||
want a page only I can access where I can **add and remove git
|
||||
sources** — no env file, no restart — and have the Sync button
|
||||
(phase 32) and `import_docs` (phase 28) use that stored list.
|
||||
|
||||
- **Given** I am signed in as admin
|
||||
- **When** I open the Git sources page and add a repo URL (or remove
|
||||
one)
|
||||
- **Then** the change is stored in Postgres (`git_sources` table) and
|
||||
the next sync / import uses the stored list — the
|
||||
`BOR_GIT_SOURCES` env var remains only a fallback while the table is
|
||||
empty, and phase 32's fail-loud "no git sources configured" is
|
||||
preserved when both are empty.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Migration 0006: `git_sources` (id UUID PK, url TEXT UNIQUE NOT
|
||||
NULL, added_at TIMESTAMPTZ) — reversible, integration-tested
|
||||
up/down (existing migration-test pattern).
|
||||
2. Admin-only API (phase 16 pattern — 403 `admin only` anonymous):
|
||||
`GET /api/git-sources` (DB rows in added order; while the table is
|
||||
empty, the env list with `from_env: true`), `POST /api/git-sources`
|
||||
{url} (trimmed, 1–500 chars, `https?://` / `ssh://` / `git@`
|
||||
shape, 409 on duplicate with a detail that never echoes the URL),
|
||||
`DELETE /api/git-sources/{id}` (204 / 404).
|
||||
3. `effective_git_sources()` shared by `app/api/sync.py::_run_sync`
|
||||
and `scripts/import_docs.py`: DB rows win, env fallback only while
|
||||
the table is empty, fail-loud unchanged when both are empty; the
|
||||
sync log line records the list origin (`origin=db|env`).
|
||||
4. `/git-sources.html` — soft-gated like the Sources page (anonymous:
|
||||
sign-in gate) with the standard header (phase 34) and an
|
||||
admin-only "Git sources" nav link on all five pages (phase 29
|
||||
pattern): sources list (mono URL + added date + per-row Remove),
|
||||
an add form (labeled input + Add, inline error, never-stale button
|
||||
per §7.4), an env-fallback note while the table is empty, and a
|
||||
hint that the KB itself updates via the Sync button.
|
||||
5. `.env.example` + README: `BOR_GIT_SOURCES` documented as the
|
||||
empty-table fallback; the admin page is the primary management UI.
|
||||
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_git_sources_admin.py` — one story, one file, run in
|
||||
isolation: anonymous gets the sign-in gate (and 403s on the API); the
|
||||
admin adds/removes sources on the page (the list updates live, invalid
|
||||
input gets an inline error) and reaches the page via the "Git
|
||||
sources" nav link on every page.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Story: Git-Based Sources (No Hardcoded Directories)
|
||||
|
||||
**Phase:** `28_git_based_sources.md` · **E2E:** none (no UI/API change; unit + integration cover the git flow)
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, I don't want the import sources hard-coded to
|
||||
`~/Homelab` and `~/Deployments`. I want to **specify a list of git
|
||||
repository URLs** and have `import_docs` **clone or pull** each one into a
|
||||
dedicated local location and then **index all the code** in them.
|
||||
|
||||
- **Given** a list of git URLs and a destination directory
|
||||
- **When** I run `import_docs`
|
||||
- **Then** each repo is cloned (first run) or pulled to `ff` (subsequent
|
||||
runs) into the dedicated directory, and the resulting files are indexed
|
||||
exactly like any other A9-format directory.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `BOR_GIT_SOURCES` (comma-separated git URLs) + `BOR_SOURCES_DIR`
|
||||
(default `~/bor-sources`) settings; `git_source_list` parses the CSV.
|
||||
2. `scripts/git_sync.py::clone_or_pull(url, dest)` — shallow clone when the
|
||||
dest has no `.git`, `--ff-only` pull otherwise; raises `GitSyncError`
|
||||
(with stderr) on failure and on missing `git`.
|
||||
3. `scripts.import_docs` resolves `BOR_GIT_SOURCES` → cloned/pulled local
|
||||
dirs and passes them to `import_sources`; `--source <path>` overrides
|
||||
(manual mode wins); a failed sync aborts the run (non-zero exit, no
|
||||
partial import).
|
||||
4. The A9 format filter / hidden-dir skip / exclusion list still apply to
|
||||
the cloned content (the importer's walk is unchanged).
|
||||
5. `documents.source` = the repo directory name (e.g. `homelab`).
|
||||
6. `.env.example` + README document both source modes.
|
||||
7. Unit (git_sync dispatch/errors) + integration (import_docs resolution
|
||||
with mocked git + import) green; `app/` coverage >90%; one
|
||||
`--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
No story E2E suite (no `app/` UI/API change). The git flow is covered by
|
||||
unit tests (`tests/unit/test_git_sync.py`) and an integration test
|
||||
(`tests/integration/test_import_docs_git.py`) with `clone_or_pull` and
|
||||
`import_sources` mocked — no network, no `git`, no real embed endpoint.
|
||||
@@ -1,51 +0,0 @@
|
||||
# Story: Global Tuning Manager (No Chat Required, Editable)
|
||||
|
||||
**Phase:** `27_global_tuning.md` · **E2E:** `tests/e2e/test_global_tuning.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, I want to **manage my global tuning notes**
|
||||
without having to have a chat conversation first, and I want to be able to
|
||||
**edit** a note I've already saved — not just create and delete.
|
||||
|
||||
- **Given** I'm the admin and I have no chat open yet
|
||||
- **When** I open the "Tuning" page (from the header)
|
||||
- **Then** I can add a new note, edit any existing note inline, list all
|
||||
notes, and delete them — and every note is stored in Postgres and read
|
||||
into the system prompt of every future turn.
|
||||
|
||||
## Acceptance criteria
|
||||
1. New endpoint `PUT /api/steering/{id}` (200 updated note; 404 unknown;
|
||||
422 empty/whitespace/over-2000; 403 anonymous) — the router keeps its
|
||||
existing `require_admin` gate.
|
||||
2. New page `/tuning.html` (admin-only nav link): a "Add a global tuning
|
||||
note" form + a list of notes, each with inline **Edit** (textarea +
|
||||
Save/Cancel) and **Delete**.
|
||||
3. A note created on `/tuning.html` is persisted and steers future answers
|
||||
(no chat turn needed to create it; observable in a later answer).
|
||||
4. Editing a note updates its text in the list and in the `<tuning>`
|
||||
prompt.
|
||||
5. Delete removes the note from the list and the prompt.
|
||||
6. The chat-page "Tune" button and header "Tuning" panel (phase 15) are
|
||||
unchanged — create + delete still work there.
|
||||
7. Anonymous users get 403 on the data and on `PUT`; the header "Tuning"
|
||||
link is hidden for anonymous.
|
||||
8. UI: landmarks, labeled controls, ≥44px targets, `:focus-visible`,
|
||||
contrast ≥4.5:1, live-region announcements, Phase-08 tokens, no-CDN.
|
||||
9. Notes render as text (XSS-safe).
|
||||
10. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||
isolation, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_global_tuning.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_create_note_without_chat` — `/tuning.html`, add a note, it
|
||||
appears in the list + announcer.
|
||||
2. `test_edit_note_inline` — Edit → change text → Save → list updated.
|
||||
3. `test_delete_note` — Delete → row removed, empty state shows.
|
||||
4. `test_edit_note_steers_answer` — create via `/tuning.html`, then a chat
|
||||
turn shows the note's marker in the answer.
|
||||
5. `test_tuning_page_a11y_and_no_cdn` — landmarks, labels, ≥44px,
|
||||
focus-visible, dark theme, all assets local.
|
||||
6. `test_anonymous_cannot_manage` — anonymous list empty; `PUT` → 403.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Story: KB Overview in the System Prompt (lite-generated knowledge-base outline)
|
||||
|
||||
**Phase:** `31_kb_overview_prompt.md` · **E2E:** `tests/e2e/test_kb_overview.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **any user of Reese**, I ask questions without knowing what the
|
||||
knowledge base actually covers. Retrieval only reveals the answer
|
||||
documents after it has run, and when it finds nothing the deflection has
|
||||
no picture of what Reese DOES know. I want the system prompt to carry,
|
||||
on every turn, a plain-text outline of the **basic categories** of
|
||||
everything that has been read — so the agent knows roughly what its
|
||||
knowledge base contains before retrieval returns documents. The outline
|
||||
is **generated by the `lite` model** from the document catalogue
|
||||
(source, path, title, first summary line per document) and **stored in
|
||||
a single row**, so it is regenerated whenever an import changes the
|
||||
knowledge base — never per chat turn.
|
||||
|
||||
- **Given** a knowledge base that was imported (with at least one
|
||||
document added or updated, or no outline stored yet)
|
||||
- **When** I ask any question
|
||||
- **Then** the system prompt of that turn — grounded (HIGH) or
|
||||
deflected (LOW) — contains a `<knowledge_base>` section with the
|
||||
stored outline (budgeted by `BOR_KB_OVERVIEW_MAX_CHARS`, overflow
|
||||
marked with the shared `[…truncated…]` marker), and the per-turn log
|
||||
line records `kb_chars=N`; when no outline is stored, both prompts
|
||||
are byte-identical to the pre-phase text.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Migration 0005: single-row `kb_overview` table (`id INTEGER PK
|
||||
DEFAULT 1`, `content TEXT NOT NULL DEFAULT ''`,
|
||||
`updated_at TIMESTAMPTZ`) + `KbOverview` model — no other schema
|
||||
change (reversible, integration-tested up/down).
|
||||
2. `app/rag/overview.py`: the `KB_OVERVIEW_MODE` prompt builder
|
||||
(document list capped at `BOR_OVERVIEW_INPUT_MAX_CHARS`, overflow
|
||||
cut at the cap with the shared truncation marker), `load_kb_overview`
|
||||
(one indexed PK lookup, `""` when the row is missing/empty), and
|
||||
`regenerate_overview` (best-effort upsert into the single row with a
|
||||
fresh UTC `updated_at`, `overview: regenerated docs=… chars=…` log
|
||||
line; a `lite` failure logs and leaves the previous outline intact;
|
||||
zero documents → no model call, any existing row untouched).
|
||||
3. Prompt injection (phase 15 convention): the `<knowledge_base>`
|
||||
section is budgeted by `BOR_KB_OVERVIEW_MAX_CHARS` (default 4000)
|
||||
with the shared `[…truncated…]` overflow, ordered
|
||||
`<relevance>` → `<knowledge_base>` → `<tuning>` → mode body in
|
||||
**both** the HIGH and LOW prompts; a missing/empty row keeps both
|
||||
prompts **byte-identical** to the pre-phase text (unit-asserted).
|
||||
4. Chat turn: `plan_turn` reads the stored row per turn (no per-turn
|
||||
LLM call) and records `TurnPlan.kb_chars`; the per-turn log line
|
||||
records `kb_chars=N` after `tuning=N` (PLAN §9 extension).
|
||||
5. Import trigger: after a run that added/updated at least one document
|
||||
(or no row exists yet), `import_docs` regenerates the outline with
|
||||
the same `lite` model (`BOR_LLM_SUMMARY_MODEL` — A5 extended);
|
||||
unchanged re-imports and `--limit` debug runs never burn a `lite`
|
||||
call; the summary line ends `overview=updated|skipped|failed` and a
|
||||
`lite` failure never changes the import's exit code.
|
||||
6. `.env.example` + README document `BOR_KB_OVERVIEW_MAX_CHARS` /
|
||||
`BOR_OVERVIEW_INPUT_MAX_CHARS` and the regeneration behavior; the
|
||||
deterministic mock LLM answers `KB_OVERVIEW_MODE` with a byte-stable
|
||||
outline (first 8 tokens of the document list) and echoes the
|
||||
`<knowledge_base>` section's first bullet as `(kb: …)` — the
|
||||
`(tuning: …)` steering-echo precedent.
|
||||
7. Unit + integration green, `app/` coverage >90% (TOTAL ≥ pre-change),
|
||||
story E2E green in isolation, `ruff` + `pyright` clean, one
|
||||
`--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_kb_overview.py` — one story, one file, run in
|
||||
isolation. It seeds the `kb_overview` row directly in the DB (the
|
||||
import-trigger path is integration-tested) with a recognizable
|
||||
multi-bullet outline, imports the shared fixture KB, asks an on-topic
|
||||
question and asserts the rendered brain answer ends with
|
||||
`(kb: Kubernetes cluster and node maintenance notes)` (the mock's echo
|
||||
of the injected section's first bullet — only possible if the
|
||||
`<knowledge_base>` section reached the LLM prompt), asks an off-topic
|
||||
question and asserts the deflected answer carries the same echo (the
|
||||
section is in the LOW prompt too), deletes the row and asserts a fresh
|
||||
answer has no `(kb: …)` suffix (absence end-to-end), and runs the real
|
||||
`regenerate_overview` against the mock to assert the `KB_OVERVIEW_MODE`
|
||||
branch's byte-stable outline is stored and echoed by a subsequent turn.
|
||||
@@ -1,100 +0,0 @@
|
||||
# Story: Markdown tables in chat (and everywhere the renderer runs)
|
||||
|
||||
**Phase:** `44_markdown_tables` · **Source:** `TODO.md` L6 ·
|
||||
**E2E:** `tests/e2e/test_markdown_tables.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L6)
|
||||
|
||||
> "Certain markdown formatting isn't working - tables for example don't
|
||||
> get rendered as tables in the chat response."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, when Brain answers with a markdown table (services and
|
||||
ports, versions, schedules — the concrete specifics the persona is built
|
||||
around), I expect a real table: aligned columns, borders, readable.
|
||||
Today the shared renderer (`frontend/assets/markdown.js`, ~60 lines,
|
||||
no-CDN by A11) has no table support — a pipe table renders as one
|
||||
paragraph of raw `|` text.
|
||||
|
||||
- **Given** Brain's answer (or a document / thinking block) contains a
|
||||
GFM pipe table
|
||||
- **When** the renderer runs
|
||||
- **Then** it renders a semantic `<table>` with a `<thead>` header row
|
||||
and `<tbody>` body rows, XSS-safe (escape-first, as the rest of the
|
||||
renderer).
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Pipe tables render:** header row + `|---|` separator row + body
|
||||
rows → `<table class="md-table">` with `<th scope="col">` header
|
||||
cells; leading/trailing pipes and in-cell whitespace are handled;
|
||||
cells keep their inline markdown (bold/em/code).
|
||||
2. **XSS-safe:** cell content is escaped before any transform — a cell
|
||||
containing `<img onerror=…>` renders inert (the escape-first
|
||||
guarantee, same as all other content).
|
||||
3. **Code fences win:** a `|`-heavy block inside a ``` fence is never
|
||||
parsed as a table (fence protection runs first, as today).
|
||||
4. **Non-tables stay put:** a single `|` in prose, a lone separator
|
||||
without a header, or a 1-line "table" is left as text.
|
||||
5. **Wide tables:** the table sits in an `overflow-x: auto` wrapper so a
|
||||
wide table scrolls horizontally instead of breaking the 46rem chat
|
||||
column (PLAN §7.1).
|
||||
6. **Shared everywhere:** the same renderer serves the chat answer, the
|
||||
document viewer, and the thinking block — all three render tables.
|
||||
7. **Style:** `.md-table` uses the existing dark-tech palette
|
||||
(PLAN §7.2 tokens, contrast ≥4.5:1); alignment colons in the
|
||||
separator are parsed but all cells render left-aligned (owner
|
||||
decision).
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap A3)
|
||||
1. **Scope = GFM pipe tables** (header + separator + body rows). Links,
|
||||
blockquotes, and hr are **not** in scope for this story.
|
||||
2. **Alignment colons parsed, rendered left.**
|
||||
3. **Wide tables get a horizontal scroll wrapper** inside the bubble.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Renderer** (`frontend/assets/markdown.js`): a table-protection pass
|
||||
between the existing fence pass and the escape pass — consecutive
|
||||
lines forming a table (every line contains `|`; line 2 matches the
|
||||
separator `^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$`-style
|
||||
rule with ≥1 cell) are pulled out, each cell is escaped + inline-
|
||||
transformed, and the block is reinserted as protected HTML (same
|
||||
`\u0000CODE…\u0000` placeholder mechanism as code fences, or a
|
||||
sibling placeholder — the existing restore step is the only place
|
||||
placeholders are re-expanded). Output shape:
|
||||
`<div class="md-table-wrap"><table class="md-table"><thead><tr>
|
||||
<th scope="col">…` / `</table></div>`.
|
||||
- **CSS** (`frontend/assets/styles.css`): `.md-table-wrap
|
||||
{ overflow-x: auto; }` (the wrapper is the scroller — the table keeps
|
||||
natural width); `.md-table` border-collapse, `th`/`td` borders from
|
||||
`--line`, padding ≈0.4rem 0.6rem, `thead` tinted from the surface
|
||||
palette; fits the 46rem column without a new container.
|
||||
- **Mock** (`tests/e2e/mock_llm.py`): new `TABLE_TRIGGER` (a substring
|
||||
like `"show me a table"`) → `compose_answer` returns a fixed
|
||||
deterministic GFM table answer (checked before the default tail-echo
|
||||
branch), including one deliberately wide table for the overflow
|
||||
assertion.
|
||||
- **Non-goals:** no new library (A11), no CDN, no change to the escape-
|
||||
first architecture, no table editing in the steering/tuning UI.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_markdown_tables.py`** (mock LLM; DB
|
||||
up):
|
||||
1. `test_chat_table_renders` — ask a question containing `TABLE_TRIGGER`;
|
||||
the brain bubble contains `<table class="md-table">` with a
|
||||
`<thead>` (header cells as `<th scope="col">`) and the expected
|
||||
body-cell texts; no raw `|---|` separator text in the bubble.
|
||||
2. `test_wide_table_scrolls` — the mock's wide table: the bubble's
|
||||
`.md-table-wrap` has `scrollWidth > clientWidth` and horizontal
|
||||
wheel/scroll moves it; the 46rem column itself does not overflow the
|
||||
page.
|
||||
3. `test_table_xss_safe` — a table whose cell contains an HTML tag
|
||||
(mock variant or document content) renders the tag as text
|
||||
(no injected element).
|
||||
4. `test_viewer_table_renders` (shared renderer) — an indexed fixture
|
||||
document containing a pipe table opens in the document modal and
|
||||
renders the same `<table class="md-table">`.
|
||||
5. `test_fence_not_a_table` (regression) — a fenced code block full of
|
||||
`|` pipes renders as `<pre><code>`, no `<table>`.
|
||||
6. `test_plain_pipe_stays_text` (regression) — a prose answer with a
|
||||
single `|` renders as text, no `<table>`.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Story: Mobile hamburger nav
|
||||
|
||||
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md` L9 ·
|
||||
**E2E:** `tests/e2e/test_mobile_hamburger_nav.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L9)
|
||||
|
||||
> "The navbar on mobile is way too squished. Make it a hamburger
|
||||
> dropdown menu with a nice animation"
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a mobile user**, the 58px header currently crams up to four text
|
||||
nav pills (Chat / Sources / Git sources / Tuning) next to the brand and
|
||||
four icon action pills — the phase-34/35 squeeze at 360–375px leaves
|
||||
0.72rem-font pills that are hard to hit and hard to read. The nav links
|
||||
move into a **hamburger dropdown menu** on small screens: one
|
||||
`#nav-toggle` button in the bar, and the links open as an animated
|
||||
panel below the header with comfortable touch targets.
|
||||
|
||||
- **Given** a viewport ≤640px
|
||||
- **When** I tap the hamburger
|
||||
- **Then** the nav menu drops down with a short slide+fade animation and
|
||||
full-size links; tapping a link navigates and closes the menu.
|
||||
- **Given** a viewport >640px
|
||||
- **When** the page loads
|
||||
- **Then** nothing changes — the inline nav pills render exactly as
|
||||
today.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Mobile (≤640px):** the inline nav pills are hidden from the bar; a
|
||||
hamburger button (`#nav-toggle`, `aria-label="Menu"`,
|
||||
`aria-controls="app-nav"`, `aria-expanded`) appears — 44px touch
|
||||
target, icon-only.
|
||||
2. **Menu:** `.app-nav` (now `id="app-nav"`) becomes a dropdown panel
|
||||
below the header — vertical full-width rows, ≥44px targets, readable
|
||||
font size; the **auth visibility contract is preserved inside the
|
||||
menu** (anonymous: only "Chat"; admin: Chat / Sources / Git sources /
|
||||
Tuning — the same ship-hidden `hidden` attributes the whoami gate
|
||||
already drives).
|
||||
3. **Animation:** opening/closing animates (slide-down + fade, ≈180ms);
|
||||
`prefers-reduced-motion: reduce` stills it (no transition).
|
||||
4. **Behavior:** toggle flips `aria-expanded`; `Esc` closes while open;
|
||||
a link click navigates **and** closes the menu; resizing back to
|
||||
>640px closes it (the inline nav reappears, no stale state).
|
||||
5. **Bar layout:** the action pills (Tuning toggle, Sync, New chat,
|
||||
Sign in/out) stay in the bar icon-only — the phase-35 tightest
|
||||
squeeze rules on `.nav-link` / `.app-nav` gaps are replaced by the
|
||||
roomier menu; the bar fits 360px with the brand intact or clipped as
|
||||
today.
|
||||
6. **All six pages** get the identical toggle + menu (the phase-34
|
||||
"same bar on every page" contract).
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap A5)
|
||||
1. **The hamburger contains the nav links only** (Chat / Sources / Git
|
||||
sources / Tuning). The action pills stay in the bar.
|
||||
2. **Animation:** slide-down + fade, 180ms; `prefers-reduced-motion`
|
||||
stills it.
|
||||
3. **Breakpoint:** the existing ≤640px mobile block (no new breakpoint).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Markup (all six pages — `index.html`, `sources.html`,
|
||||
`document.html`, `git-sources.html`, `login.html`, `tuning.html`):**
|
||||
a `#nav-toggle` button inserted before `<nav class="app-nav"
|
||||
aria-label="Primary">` (which gains `id="app-nav"`); the nav keeps
|
||||
its existing links and `hidden` attributes byte-identically.
|
||||
- **CSS** (`frontend/assets/styles.css`, the `@media (max-width: 640px)`
|
||||
block): `.nav-toggle { display: none }` outside, `display:
|
||||
inline-flex` + 44px target inside; `.app-nav` becomes the dropdown:
|
||||
absolute below the header, `flex-direction: column`, surface background
|
||||
+ bottom border/shadow, full-width row links; closed state
|
||||
(`visibility: hidden; opacity: 0; transform: translateY(-8px);
|
||||
pointer-events: none`) → `.is-open` (`visible; opacity: 1; transform:
|
||||
none`), `transition: opacity/transform 180ms ease`; a
|
||||
`prefers-reduced-motion` override kills the transition. The old
|
||||
nav-pill squeeze rules (`.nav-link` 0.72rem, `.app-nav` gap 0.05rem)
|
||||
are superseded for the menu rows.
|
||||
- **JS** (`frontend/assets/header.js`, module-import binding like
|
||||
sign-out): null-safe `#nav-toggle` / `#app-nav` — click toggles
|
||||
`.is-open` + `aria-expanded`; delegated click on nav links closes it;
|
||||
`document` keydown `Esc` closes while open; a
|
||||
`matchMedia("(max-width: 640px)")` change listener closes on
|
||||
desktop. No other header.js behavior touched.
|
||||
- **Non-goals:** no change to the 900px tablet rules, the action pills,
|
||||
the viewer's title bar height, or the no-CDN/A11 constraints.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_mobile_hamburger_nav.py`** (mock
|
||||
LLM; DB up):
|
||||
1. `test_mobile_hamburger_visible_and_bar_roomy` — 375×812: `#nav-toggle`
|
||||
visible with `aria-expanded="false"`; the inline nav links are not
|
||||
visible in the bar (menu closed); the header has no horizontal
|
||||
overflow.
|
||||
2. `test_anonymous_menu_contents` — anonymous: open the menu → exactly
|
||||
"Chat" is visible; open/close flips `aria-expanded`.
|
||||
3. `test_admin_menu_contents` — login: open the menu → Chat / Sources /
|
||||
Git sources / Tuning all visible (auth contract inside the menu).
|
||||
4. `test_link_click_navigates_and_closes` — open, click "Sources"
|
||||
(admin): navigates to `/sources.html`, and the menu on the arrival
|
||||
page is closed.
|
||||
5. `test_esc_and_backdrop_close` — open, `Esc` closes (aria-expanded
|
||||
false); open again, click outside the panel closes.
|
||||
6. `test_animation_and_reduced_motion` — with motion allowed, the menu
|
||||
has a transition (computed `transition-duration` ≈180ms on the
|
||||
opacity/transform pair); with `reducedMotion: "reduce"` emulated,
|
||||
the transition is none/0s and the menu still opens/closes.
|
||||
7. `test_desktop_unchanged` (regression) — 1280×800: no hamburger,
|
||||
inline nav pills exactly as before (the phase-34/35 bar contract,
|
||||
`test_nav_consistency` / `test_header_consistency` pass in the
|
||||
regression pass).
|
||||
@@ -1,57 +0,0 @@
|
||||
# Story: One Navbar on Every Page
|
||||
|
||||
**Phase:** `34_consistent_navbar` · **E2E:** `tests/e2e/test_nav_consistency.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **any user (admin or anonymous)**, I move between the Chat, Sources,
|
||||
Document viewer, Global Tuning, and Sign in pages. Today the header bar
|
||||
is only similar between pages: the document viewer replaces it with a
|
||||
back + title bar (every nav button vanishes), the login page misses the
|
||||
Tuning link, and the functional controls are page-scoped — the Tuning
|
||||
steering toggle lives on chat only, the Sync sources button on Sources
|
||||
only — so buttons pop in and out as I switch tabs. I want ONE identical
|
||||
bar on every page.
|
||||
|
||||
- **Given** any of the app's five pages
|
||||
- **When** I look at the header
|
||||
- **Then** I see the same controls in the same order — brand, nav
|
||||
[Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New
|
||||
chat, and exactly one of Sign in / Sign out — with none missing
|
||||
because of which page I happen to be on.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `frontend/assets/header.js` is the single owner of the functional
|
||||
header controls: the steering toggle + panel logic (moved from
|
||||
`app.js`, exported `refreshSteering()`), the Sync button state
|
||||
machine (moved from `sources.js`, dispatching `bor:sync-status`),
|
||||
one New chat binding (chat page: `bor:new-chat` event; other pages:
|
||||
clear + navigate), and the sign-in `?next=` rewrite to the current
|
||||
page. No control logic remains in the page scripts.
|
||||
2. All five pages (`index.html`, `sources.html`, `document.html`,
|
||||
`tuning.html`, `login.html`) ship the identical header markup; the
|
||||
`#steering-panel` section exists on every page (chat: after
|
||||
`#kb-banner` as today; other pages: first child of `<main>`).
|
||||
3. The document viewer keeps its back link + title in a second
|
||||
titlebar row below the standard bar (phase 13 back-target behavior
|
||||
unchanged); the pinned 64px / 58px height contract applies to the
|
||||
standard row on every page.
|
||||
4. The locked A10 UI revision is preserved: admin-only controls
|
||||
(Sources / Tuning nav links, Sync button) ship hidden and are
|
||||
revealed only for a signed-in admin — for the admin every button is
|
||||
active on every page; anonymous visitors get the reduced bar,
|
||||
identically on every page (owner confirmation 2026-08-26).
|
||||
5. The chat page's per-bubble Tune button + inline form (phases 15/27)
|
||||
and the Sources page's sync result line + error banner (phase 32)
|
||||
keep working exactly as before.
|
||||
6. `tests/e2e/test_nav_consistency.py` green in isolation;
|
||||
`test_header_consistency.py` / `test_shared_header.py` updated to the
|
||||
new viewer contract; regressions green; `app/` coverage >90%
|
||||
(frontend-only phase — unchanged); ruff + pyright clean; one
|
||||
`--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_nav_consistency.py` — one story, one file, run in
|
||||
isolation. It asserts, per role (admin, anonymous), that the visible
|
||||
header control inventory is identical on all five pages, and that the
|
||||
viewer's back link + titlebar row survive the change.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Story: Nav rename — "Sources" becomes "RAG", "Git sources" becomes "Sources"
|
||||
|
||||
**Phase:** `48_nav_rename_sources` · **E2E:** `tests/e2e/test_nav_rename_sources.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin (owner)**, the two admin-only nav items are confusing:
|
||||
"Sources" (the document catalog) and "Git sources" (the source manager)
|
||||
read like the same thing. I want the navbar to say **"RAG"** for the
|
||||
document catalog page and **"Sources"** for the source manager page, so
|
||||
the labels say what each page *is*: RAG = the indexed knowledge base you
|
||||
chat against; Sources = where that knowledge comes from.
|
||||
|
||||
- **Given** I am signed in as admin, on any of the six pages (chat,
|
||||
sources, git-sources, tuning, document viewer, login)
|
||||
- **When** I look at the primary nav
|
||||
- **Then** the catalog link (`#nav-sources`, → `/sources.html`) is
|
||||
labeled **"RAG"** and the manager link (`#nav-git-sources`, →
|
||||
`/git-sources.html`) is labeled **"Sources"** — same ids, same hrefs,
|
||||
same order, same admin-only ship-hidden/reveal contract as before.
|
||||
|
||||
## Acceptance criteria
|
||||
1. All six pages (`frontend/index.html`, `sources.html`,
|
||||
`git-sources.html`, `tuning.html`, `document.html`, `login.html`)
|
||||
carry the swapped label text on `#nav-sources` ("RAG") and
|
||||
`#nav-git-sources` ("Sources"). Element ids, `href`s, `hidden`
|
||||
defaults, `is-active`/`aria-current` markers, and the nav order
|
||||
(Chat, RAG, Sources, Tuning) are unchanged — this is a label-only
|
||||
rename, and `header.js` logic (reveal by id) is untouched.
|
||||
2. Anonymous visitors still see neither link (ship-hidden contract
|
||||
unchanged); the admin sees both, as before.
|
||||
3. Existing test assertions on the old label text are updated
|
||||
(`test_git_sources_admin.py` nav-link text,
|
||||
`test_mobile_hamburger_nav.py` `LINK_TEXTS`); assertions that are
|
||||
NOT about these two nav items stay untouched (the document viewer's
|
||||
"Sources" back button, the "Sync sources" button label, the
|
||||
`/git-sources.html` page-`<h1>`/`<title>` marker).
|
||||
4. Story E2E `tests/e2e/test_nav_rename_sources.py` proves: admin sees
|
||||
"RAG" (→ `/sources.html`) and "Sources" (→ `/git-sources.html`) on
|
||||
all six pages; clicking each lands on the right page with
|
||||
`is-active`; anonymous sees both hidden.
|
||||
5. Frontend-only: unit + integration green, `app/` coverage
|
||||
unaffected, ruff + pyright clean, no CDN, one `--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_nav_rename_sources.py` — one story, one file, run in
|
||||
isolation: the two relabeled nav items' full lifecycle (labels, hrefs,
|
||||
admin reveal, anonymous hide, click-navigation with the current-page
|
||||
marker) on the shared header, desktop viewport.
|
||||
@@ -1,91 +0,0 @@
|
||||
# Story: No reply autoscroll
|
||||
|
||||
**Phase:** `42_no_reply_autoscroll` · **Source:** `TODO.md` L5 ·
|
||||
**E2E:** `tests/e2e/test_no_reply_autoscroll.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L5)
|
||||
|
||||
> "Get rid of the chat reply autoscroll, it's breaking things like
|
||||
> making it impossible for the user to scroll while a reply generates."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user reading a long answer**, I want full control of the
|
||||
viewport while Brain replies. Today the page auto-scrolls to follow the
|
||||
stream (phase 18 "follow-the-bottom"): while I'm in the 200px
|
||||
near-bottom band the page is yanked down on every thinking / tool /
|
||||
delta frame, which fights my own scrolling mid-answer. The reply
|
||||
autoscroll is being **removed** — the page only scrolls when I
|
||||
explicitly cause it.
|
||||
|
||||
- **Given** a reply is streaming (thinking, tool calls, or answer text)
|
||||
- **When** I scroll up to read earlier context
|
||||
- **Then** the viewport stays exactly where I put it for the rest of the
|
||||
turn — no frame yanks it back.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **No streaming autoscroll:** during a long thinking stream, a tool
|
||||
call, and a long answer, the page never auto-scrolls — sampled
|
||||
`window.scrollY` is stable (within 1px) across frames while the
|
||||
viewport is away from the bottom.
|
||||
2. **Submit reveals my message:** sending a question while scrolled up
|
||||
still scrolls the viewport down so my own message is visible
|
||||
(user-initiated — kept by owner decision).
|
||||
3. **Restore landing kept:** reloading a persisted conversation
|
||||
(phase 14) still lands one-shot on the latest message.
|
||||
4. **The phase-18 gate is gone:** `NEAR_BOTTOM_PX` /
|
||||
`isNearBottom()` and the per-frame `scrollReveal` calls in the
|
||||
thinking / tool / delta handlers are removed from `app.js`; the unit
|
||||
pin (`tests/unit/test_frontend_scroll.py`) is rewritten for the new
|
||||
contract (scrolls happen only on submit + restore landing).
|
||||
5. **Everything else unchanged:** the thinking window's *internal*
|
||||
bottom-pin (phase 17 — `textEl.scrollTop`, not the page) is untouched
|
||||
in this phase (phase 43 reworks it separately); message rendering,
|
||||
persistence, UI states, and the 120 s guard are unchanged.
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap A1)
|
||||
1. **"Reply autoscroll" = the phase-18 follow-the-bottom auto-follow on
|
||||
thinking / tool / delta frames.** Removed.
|
||||
2. **Kept:** scroll-on-submit (reveal the user's own message) and the
|
||||
one-shot restore landing on page load.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **The functional change is in `frontend/assets/app.js` only:**
|
||||
- delete `export const NEAR_BOTTOM_PX = 200`, `isNearBottom()`, and
|
||||
the `force`-optional gating in `scrollReveal` — the helper becomes an
|
||||
unconditional `scrollIntoView` (still smooth, still still under
|
||||
`prefers-reduced-motion` via the existing `SCROLL` constant);
|
||||
- `addMessage(...)` gains an explicit "scroll" intent: the **user
|
||||
submit** path scrolls (shows my message), brain bubble creation and
|
||||
the typing indicator do **not**;
|
||||
- the thinking / tool / delta handlers drop their `scrollReveal(wrap)`
|
||||
calls (the thinking handler keeps its `textEl.scrollTop` window-pin —
|
||||
phase 17, reworked in phase 43);
|
||||
- the phase-14 restore landing keeps its one-shot forced scroll;
|
||||
- module docstrings updated (the phase-18 contract block is replaced
|
||||
by the new "no reply autoscroll (owner direction 2026-08-27)"
|
||||
contract).
|
||||
- **Non-goals:** no new UI element, no "↓ new content" pill (the owner
|
||||
wants silence, not a substitute affordance), no change to the
|
||||
composer / safe-area layout.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_no_reply_autoscroll.py`** (mock LLM;
|
||||
DB up; the phase-18 suite `tests/e2e/test_follow_bottom_scroll.py` is
|
||||
**deleted** in this phase — its behavior is intentionally removed):
|
||||
1. `test_no_autoscroll_during_long_answer` — `LONG_ANSWER_TRIGGER`
|
||||
question; once the answer starts, scroll the viewport up ~2× the
|
||||
answer height; sample `window.scrollY` across ≥10 streaming frames:
|
||||
stable within 1px; after `done` the viewport is still where it was.
|
||||
2. `test_no_autoscroll_during_thinking` — `THINKING_TRIGGER` question;
|
||||
scroll up during the ~4.5 s thinking stream; the viewport stays
|
||||
pinned (no per-chunk page follow).
|
||||
3. `test_submit_reveals_user_message` — scroll to the very top of a
|
||||
populated conversation, send a question; the viewport ends with the
|
||||
user's message visible (bottom in view).
|
||||
4. `test_restore_landing_one_shot` (phase 14 regression) — settle a
|
||||
conversation, reload; the page lands on the latest message one-shot
|
||||
and stays there while no stream is active.
|
||||
5. `test_answer_content_intact` (regression) — the long answer streams
|
||||
to completion with sources and (for thinking) the collapsed block,
|
||||
persisted and restorable.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Story: Import quadlet + jinja files
|
||||
|
||||
**Phase:** `47_quadlet_jinja_import` · **Source:** `TODO.md` L10–L11 ·
|
||||
**E2E:** `tests/e2e/test_quadlet_jinja_import.py`
|
||||
|
||||
## Bug reports (verbatim, `TODO.md` L10–L11)
|
||||
|
||||
> "Add \".container\", \".network\", \".volume\" and other quadlet files
|
||||
> to the list of allowed/parsed files"
|
||||
> "Add \".j2\" jinja files to the list of allowed/parsed files"
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, my homelab notes increasingly live in Podman quadlet
|
||||
unit files (`.container`, `.network`, `.volume`, …) and Jinja templates
|
||||
(`.j2`). Neither is in the A9 import format list, so the KB is blind to
|
||||
exactly the config files I ask questions about. Both families join the
|
||||
allowed + default import formats and are parsed (chunked) by the
|
||||
importer.
|
||||
|
||||
- **Given** a source directory containing quadlet and/or `.j2` files
|
||||
- **When** the importer (CLI or sync) runs
|
||||
- **Then** those files are indexed (chunked, embedded, upserted) like any
|
||||
other A9-format file, and their content is retrievable.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Allowed set:** `_ALLOWED_IMPORT_EXTENSIONS` in `app/config.py`
|
||||
gains `container, network, volume, image, pod, kube, swap, os,
|
||||
endpoint` (the full Podman quadlet family) and `j2`; a
|
||||
`BOR_IMPORT_EXTENSIONS` env value may name any of them (the
|
||||
never-widen validator keeps rejecting truly unknown extensions).
|
||||
2. **Default set:** the default `import_extensions` CSV includes all ten
|
||||
new formats after the existing seven — a default import now picks
|
||||
them up with no env configuration.
|
||||
3. **Parsing:** `chunker.py` dispatches every new suffix to
|
||||
plain-text paragraph packing (`chunk_text`) — quadlet files are TOML
|
||||
unit files and `.j2` files are templates; no format-specific
|
||||
splitter (owner decision). Every chunk still honors
|
||||
`HARD_MAX_CHARS` (1200).
|
||||
4. **Title:** no H1 → `extract_title` falls back to the file stem, as
|
||||
with other non-markdown formats (no change needed, verified).
|
||||
5. **Behavior parity:** hidden (dot) directories are still skipped, the
|
||||
exclusion list is unchanged, sha256 delta detection / prune work
|
||||
unchanged for the new formats.
|
||||
6. **Docs:** `.env.example`'s `BOR_IMPORT_EXTENSIONS` comment, the
|
||||
README's format list, and an **A9 revision note** in
|
||||
`.agent/PLAN.md` (owner permission 2026-08-27, `TODO.md` L10–L11)
|
||||
record the extended set.
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap R1)
|
||||
1. **Full quadlet family:** `container, network, volume, image, pod,
|
||||
kube, swap, os, endpoint` — plus `j2`.
|
||||
2. **Chunked as plain text** — no TOML/Jinja-aware splitting.
|
||||
3. **A9 is revised** with a PLAN.md revision note (the established
|
||||
owner-permission pattern).
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Config** (`app/config.py`): extend the `_ALLOWED_IMPORT_EXTENSIONS`
|
||||
frozenset + the `import_extensions` default string (comment cites the
|
||||
A9 revision 2026-08-27). No validator change — it already
|
||||
normalizes/dedups and rejects unknowns.
|
||||
- **Chunker** (`app/rag/chunker.py`): ten new `_FORMAT_CHUNKERS` entries
|
||||
→ `chunk_text`; module docstring's format list updated.
|
||||
- **Fixtures:** `tests/fixtures/docs/homelab/quadlet/compose.container`
|
||||
(realistic quadlet TOML: `[Unit]` / `[Service]` / `[Container]`
|
||||
sections, one unique sentinel token, >1200 chars to exercise
|
||||
sub-splitting), `…/quadlet/lan.network`, `…/quadlet/cache.volume`,
|
||||
and `tests/fixtures/docs/homelab/templates/deploy.j2` (Jinja snippet
|
||||
with `{{ … }}` / `{% … %}` tags + its own sentinel).
|
||||
- **Tests:** unit (`test_config.py` allowed-set/default/validator;
|
||||
`test_chunker.py` dispatch for every new suffix + fixture chunking;
|
||||
`test_importer.py` directory walk picks the new files up);
|
||||
integration (import_sources over a temp dir with quadlet+j2 files →
|
||||
documents + chunks rows).
|
||||
- **Non-goals:** no new DB column, no format badge change (the viewer
|
||||
shows the extension it already shows), no summary-model changes.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_quadlet_jinja_import.py`** (mock
|
||||
LLM; DB up):
|
||||
1. `test_quadlet_and_jinja_indexed` — truncate + import the fixture
|
||||
tree (the `test_import_documents.py` pattern): `GET /api/docs` lists
|
||||
the `.container` / `.network` / `.volume` / `.j2` files with
|
||||
non-zero chunk counts.
|
||||
2. `test_sources_table_shows_them` (admin) — the Sources table renders
|
||||
rows for the new files; their path links open the document modal.
|
||||
3. `test_container_content_viewable` — the viewer modal shows the
|
||||
`.container` file's TOML content (sentinel token present) with its
|
||||
stem as the title.
|
||||
4. `test_jinja_retrievable_not_deflected` — ask a question containing
|
||||
the `.j2` file's sentinel word: the FTS hit keeps the honesty gate
|
||||
honest-positive (A8) — the answer bubble is **not**
|
||||
`.is-deflected` and the source chip names the `.j2` document.
|
||||
5. `test_default_walk_includes_new_formats` (regression, unit-backed) —
|
||||
a default-extensions walk over a temp tree with all ten new
|
||||
extensions indexes every file; hidden directories + the exclusion
|
||||
list still filter (covered by `test_importer.py` in the regression
|
||||
pass).
|
||||
@@ -1,45 +0,0 @@
|
||||
# Story: Summary + Original Document Together in the Viewer
|
||||
|
||||
**Phase:** `36_summary_in_viewer` · **E2E:** `tests/e2e/test_summary_in_viewer.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **any user**, phase 30 gives every non-markdown document a
|
||||
lite-model summary (stored on `documents.summary`, indexed as an
|
||||
`is_summary` chunk, used for retrieval). When I open such a document —
|
||||
from the Sources table, a chat source chip, or a direct URL — I only
|
||||
see the raw original. I want to see **the summary and the original
|
||||
document together**.
|
||||
|
||||
- **Given** a document that has a summary (phase 30: non-markdown A9
|
||||
documents)
|
||||
- **When** I open it in the document viewer (the modal or the full
|
||||
page)
|
||||
- **Then** a labeled Summary panel sits above the original content —
|
||||
both visible at once. Documents without a summary (markdown docs,
|
||||
pre-phase-30 rows, fail-soft rows) render exactly as before.
|
||||
|
||||
## Acceptance criteria
|
||||
1. `DocContent` gains `summary: str | None`; `GET
|
||||
/api/documents/content` returns `documents.summary`. The endpoint
|
||||
stays public + stateless — the phase 16 soft rule (catalog gated,
|
||||
viewer public) is unchanged.
|
||||
2. The shared renderer `renderDocument` (`assets/document.js`)
|
||||
renders the summary panel for **both** surfaces at once (the
|
||||
full-page viewer + the chat/sources modal); null/empty summary →
|
||||
no panel; the summary text is written with `textContent` (XSS
|
||||
contract unchanged).
|
||||
3. `.doc-summary` styling matches the dark tech theme (phase 08
|
||||
palette, contrast ≥4.5:1) and reads as a summary, not as document
|
||||
content.
|
||||
4. Integration tests: a summarized non-markdown doc returns its
|
||||
summary; a markdown doc returns null; anonymous access unchanged.
|
||||
5. Story E2E green in isolation (the `summary_kb` fixture: the
|
||||
deterministic digest panel + the full original with its tail
|
||||
sentinel visible together; the markdown control doc → no panel);
|
||||
regressions green; `app/` coverage >90%; ruff + pyright clean; one
|
||||
`--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
`tests/e2e/test_summary_in_viewer.py` — one story, one file, run in
|
||||
isolation.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Story: Sync fails fast + modal when a model is down
|
||||
|
||||
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md` L4 ·
|
||||
**E2E:** `tests/e2e/test_sync_model_down.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L4)
|
||||
|
||||
> "If the embedding or lite model is not accessible the sync button
|
||||
> should fail fast and there should be a modal error popup explaining
|
||||
> that the model isn't available."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the admin**, when I press "Sync sources" with the aipi models
|
||||
(`embed` or `lite`) unreachable, I don't want to wait through git clones
|
||||
and a partial import to discover the KB can't be updated — and a tooltip
|
||||
on a button is not a readable error. The sync should **fail fast**
|
||||
(before any expensive work) with a clear "the model isn't available"
|
||||
message, shown in a **modal dialog** I can read and dismiss.
|
||||
|
||||
- **Given** the `embed` or `lite` model endpoint is unreachable
|
||||
- **When** I press "Sync sources"
|
||||
- **Then** the run fails within a couple of seconds (before any git
|
||||
clone), the button settles retry-ready, and a modal dialog explains
|
||||
which model isn't available.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Server fail-fast:** `POST /api/sync` with a dead LLM endpoint
|
||||
reaches `state: "failed"` with a message naming the unavailable model
|
||||
(embedding first, then summary/lite) **without** cloning any source —
|
||||
the probe (one small embedding + one tiny completion against
|
||||
`BOR_LLM_SUMMARY_MODEL`) runs before source resolution and before any
|
||||
`clone_or_pull`.
|
||||
2. **Modal:** on a failed sync the page shows a modal error dialog
|
||||
(`role="alertdialog"`, `aria-modal="true"`) with a title, the
|
||||
sanitized error text (rendered via `textContent` — XSS-safe), and a
|
||||
close control; it closes on the close button, `Esc`, or backdrop
|
||||
click; focus moves into the dialog on open and returns to `#sync-btn`
|
||||
on close.
|
||||
3. **Every page:** the modal is built by the shared header module
|
||||
(`frontend/assets/header.js`), which owns the sync state machine — so
|
||||
it appears wherever `#sync-btn` exists (all six pages from phase 34).
|
||||
4. **Existing surfaces kept:** the button's failed-state `title` /
|
||||
`aria-label` / `.is-error` affordance and the Sources page's
|
||||
`#sync-error-banner` (via `bor:sync-status`) are unchanged — the modal
|
||||
is the primary, readable surface.
|
||||
5. **Success path unchanged:** a healthy model still runs clone → import
|
||||
→ overview exactly as phase 32/35/38 define it (regression).
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap A4)
|
||||
1. **The probe runs before git clones** — the fastest possible failure;
|
||||
it costs one small embedding request and one ~1-token completion.
|
||||
2. **The modal is the primary failure surface on every page;** the
|
||||
button-title affordance and the Sources banner stay as secondary
|
||||
surfaces.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **Server** (`app/rag/llm.py`, `app/api/sync.py`): a new
|
||||
`ModelUnavailableError` (subclass of `LLMError`) + an `async
|
||||
check_models(llm)` probe: `embed_one("sync model check")` then a tiny
|
||||
`chat([...])` against the summary model; each failure mode maps to a
|
||||
message naming the model and that it isn't available (the sync
|
||||
sanitizer's credential masking still applies downstream). `_run_sync`
|
||||
calls it first, after `LLMClient()` construction — before
|
||||
`effective_sources`, before any clone.
|
||||
- **UI** (`frontend/assets/header.js`, `frontend/assets/styles.css`):
|
||||
`applySyncFailure(status)` additionally opens `showSyncModal(status)`:
|
||||
a lazily-created backdrop + `role="alertdialog"` panel appended to
|
||||
`<body>` (so no page markup changes), error text via `textContent`,
|
||||
close button + `Esc` + backdrop-click dismissal, focus management as
|
||||
in AC 2. Styled with the existing dark-theme error palette
|
||||
(PLAN §7.2: `#fca5a5` on `#2d1318` class, error border), `:focus-visible`
|
||||
per the global rule, no motion under `prefers-reduced-motion`.
|
||||
- **Non-goals:** no new endpoint, no retry-from-modal button (the button
|
||||
itself is retry-ready), no change to the 2 s poll lifecycle.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_sync_model_down.py`** (mock LLM; DB
|
||||
up). The suite boots its **own module-scoped app** on a distinct port
|
||||
(conftest pattern used by `test_sync_button.py`) with
|
||||
`BOR_LLM_BASE_URL=http://127.0.0.1:9/v1` (dead port — connection
|
||||
refused) and a local `file://` fixture repo as the configured source, so
|
||||
a (regressed, non-fail-fast) run would spend time cloning before failing:
|
||||
1. `test_model_down_fails_fast_with_modal` — admin login, click
|
||||
`#sync-btn`; within a short wall-clock budget (≤ ~10 s, vs the 60 s
|
||||
generous budget of the healthy-run suite) the button settles
|
||||
retry-ready **and** the modal is visible with an error naming the
|
||||
model; assert the dialog role/aria contract.
|
||||
2. `test_modal_dismissal` — close via button, `Esc`, and backdrop click
|
||||
(one fresh failure per path); focus returns to `#sync-btn` each time.
|
||||
3. `test_sync_error_surfaces_unaffected` (phase 32 regression) — after
|
||||
the failure the button keeps its `title` / `.is-error` affordance; on
|
||||
`/sources.html` the `#sync-error-banner` still renders off
|
||||
`bor:sync-status`.
|
||||
4. `test_healthy_sync_still_succeeds` (phase 32/35 regression) — the
|
||||
session mock-backed app (or a second healthy module app) still runs
|
||||
the full clone → import → overview pipeline to "Synced HH:MM".
|
||||
@@ -1,99 +0,0 @@
|
||||
# Story: Thinking No Scroll (live-tail Thinking window)
|
||||
|
||||
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4 ·
|
||||
**E2E:** `tests/e2e/test_thinking_no_scroll.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L4)
|
||||
|
||||
> "Disable scroll in the thinking window. Users don't need to scroll back
|
||||
> through thinking, just see it live."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, the Thinking block is a *scratchpad, not a transcript*:
|
||||
while Brain reasons I just want to watch the newest lines appear — not
|
||||
scroll back through everything it already thought. The block should
|
||||
always show the **live tail** of the reasoning stream, in its fixed
|
||||
320px window, with no way to scroll it back.
|
||||
|
||||
- **Given** a turn streams a long reasoning body (longer than the 320px
|
||||
window)
|
||||
- **When** I try to scroll back through the window — mouse wheel, mouse
|
||||
drag, or keyboard — while it streams (or while it sits open before the
|
||||
first token)
|
||||
- **Then** the window does not move: it stays glued to the live tail,
|
||||
pinned there by the phase-17 per-chunk bottom-pin (the sole scroller).
|
||||
|
||||
## Acceptance criteria
|
||||
1. With a long thinking stream, wheel / mouse-drag / keyboard on
|
||||
`.thinking-text` do **not** move it; the visible content is always the
|
||||
live tail (pinned at the bottom, within 1px, after each chunk).
|
||||
2. Computed style of `.thinking-text`: `overflow-y: hidden`,
|
||||
`max-height: 320px` — the 320px clip stays (owner-confirmed: no
|
||||
auto-height growth, no "↓ more" affordance).
|
||||
3. A long **answer** bubble is untouched: it keeps its existing scroll
|
||||
behavior (phase 11) — phase 21 only changed `.thinking-text`.
|
||||
4. Restored (collapsed) Thinking blocks are untouched (phase 14/17
|
||||
restore path): they render collapsed with their text, where overflow
|
||||
is moot.
|
||||
5. No new surface: the block keeps its summary chevron, focus-visible
|
||||
ring, aria contract, and the reduced-motion stillness (styles.css
|
||||
`prefers-reduced-motion` block).
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A2)
|
||||
1. **Keep the 320px clip** — "just see it live" means the window stays a
|
||||
fixed 320px viewport showing the newest lines; no auto-height growth,
|
||||
no "↓ more" affordance.
|
||||
2. **The answer bubble is untouched** — final answers keep their existing
|
||||
scroll behavior (phase 11 long answers).
|
||||
3. **Restored (collapsed) Thinking blocks are untouched** — the phase-14
|
||||
restore renders them collapsed, where overflow is moot.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **The whole functional change is one CSS property**
|
||||
(`frontend/assets/styles.css`, phase-17 thinking section):
|
||||
`details.thinking .thinking-text` — `overflow-y: auto` →
|
||||
`overflow-y: hidden` (with the owner-choice comment: *no user scroll
|
||||
back (owner choice 2026-08-24): the window is a live tail only — the
|
||||
JS bottom-pin is the sole scroller*). `max-height: 320px` and every
|
||||
other declaration stay byte-identical.
|
||||
- **Why this works:** `overflow: hidden` disables *user* scrolling
|
||||
(wheel/drag/keyboard) but still permits *programmatic* scrolling — so
|
||||
the phase-17 bottom-pin (`textEl.scrollTop = textEl.scrollHeight` on
|
||||
every `thinking` chunk, `frontend/assets/app.js`) keeps the window
|
||||
glued to the live tail with **no JS change**.
|
||||
- **Non-goals:** no change to the answer bubble, the collapsed restore
|
||||
state, the summary/chevron, or the auto-collapse on the first delta
|
||||
(phase 17).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_thinking_no_scroll.py`** (mock LLM;
|
||||
phase 21 lengthened `mock_llm.compose_thinking` to ~2 700 chars ≈ 4.5s
|
||||
of paced `reasoning_content` frames so the scratchpad overflows the
|
||||
320px window; tests 1–2 use the phase-20 `think out loud then hesitate`
|
||||
trigger — after the stream ends there is a deterministic 4s pre-content
|
||||
pause with the block still open and no further pin frames, a frozen live
|
||||
tail where any (regressed) user scroll would persist and be observable):
|
||||
1. `test_thinking_window_not_user_scrollable` — wait until the window
|
||||
overflows and the thinking stream ends (text stable; block still
|
||||
open, pre-token). Assert the pin left it at the tail; then focus
|
||||
`.thinking-text`, wheel up ×2, `Home` + `ArrowUp`, mouse-drag up:
|
||||
`scrollTop` must not move (within 1px) and the window must still show
|
||||
the live tail.
|
||||
2. `test_thinking_window_tracks_live_tail` — while the stream is live:
|
||||
once the 2nd-to-last chunk has landed, the window is pinned to the
|
||||
tail; after the last chunk (held by the 4s pause) it is still pinned,
|
||||
the last chunk's text (the final text node) renders inside the
|
||||
visible rectangle, and a hit-test at the box's bottom lands inside
|
||||
`.thinking-text`.
|
||||
3. `test_thinking_window_css_contract` — computed style: `overflow-y` is
|
||||
`hidden`, `max-height` is `320px`, and the clip is real
|
||||
(`scrollHeight > clientHeight` for the long scratchpad).
|
||||
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — the
|
||||
long-answer mock question streams to completion; the answer bubble's
|
||||
computed `overflow-y` is not `hidden`, the page is scrollable, and
|
||||
wheel up/down over the answer moves the page scroll both ways.
|
||||
5. `test_restored_collapsed_thinking_unaffected` (regression, phase 17)
|
||||
— a settled thinking turn is reloaded; the restored conversation
|
||||
renders the Thinking block **collapsed** with its full text (matching
|
||||
the persisted raw text), and the answer bubble is intact.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Story: Thinking scroll back (user scroll + generate-time autoscroll)
|
||||
|
||||
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md` L7 ·
|
||||
**E2E:** `tests/e2e/test_thinking_scroll.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L7)
|
||||
|
||||
> "Add scrolling back to the thinking block, but have it autoscroll
|
||||
> while thinking content is generating."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user watching Brain reason**, the Thinking block should work like
|
||||
a well-behaved live console: it **follows the tail while the reasoning
|
||||
is generating** — but the moment I scroll up to re-read an earlier line,
|
||||
it must **stop yanking me down**, and it must let me scroll the window
|
||||
freely (phase 21's no-scroll clip is being reversed by owner direction).
|
||||
|
||||
- **Given** the Thinking block is streaming reasoning content
|
||||
- **When** I'm at the bottom of the 320px window
|
||||
- **Then** each new chunk keeps the window pinned to the live tail.
|
||||
- **When** I scroll up to read earlier reasoning
|
||||
- **Then** the window stays where I put it (no more re-pinning);
|
||||
- **When** I return to the bottom
|
||||
- **Then** tail-following resumes on the next chunk.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **User scroll restored:** computed `overflow-y` of
|
||||
`.thinking-text` is `auto`; wheel / mouse-drag / keyboard move the
|
||||
window (phase 21's `overflow-y: hidden` is gone).
|
||||
2. **Follow while generating:** with the user pinned at the window's
|
||||
bottom (within a small near-bottom band — the phase-18 pattern,
|
||||
now applied to the *window* instead of the page, exported constant
|
||||
`THINKING_NEAR_BOTTOM_PX = 32`), each streamed chunk re-pins the
|
||||
window to the tail (within 1px).
|
||||
3. **Paused on scroll-up:** scrolled up, the window stops being re-pinned
|
||||
— `scrollTop` stays stable across subsequent chunks (within 1px).
|
||||
4. **Resumes on return:** scrolling back to the bottom (within the band)
|
||||
resumes tail-following on the next chunk.
|
||||
5. **Kept from phases 17/21:** the fixed 320px `max-height` window, the
|
||||
auto-collapse on the first answer token, the reduced-motion stillness,
|
||||
and the answer-bubble scroll behavior (phase 11) are all unchanged.
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap A2)
|
||||
1. **The 320px window stays** — only the overflow mode and the pinning
|
||||
logic change.
|
||||
2. **Follow-the-bottom for the window:** autoscroll only while the user
|
||||
is pinned near the window's bottom (≈32px band); scroll-up pauses,
|
||||
return-to-bottom resumes. (The phase-18 page-level band is removed in
|
||||
phase 42; this is its window-level successor.)
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **CSS** (`frontend/assets/styles.css`): `details.thinking
|
||||
.thinking-text` — `overflow-y: hidden` → `overflow-y: auto`; the
|
||||
owner-choice comment is replaced with the 2026-08-27 direction
|
||||
(user-scrollable window; JS follows the tail only while pinned).
|
||||
`max-height: 320px` and all other declarations untouched.
|
||||
- **JS** (`frontend/assets/app.js`, the `thinking` SSE handler):
|
||||
- new exported `const THINKING_NEAR_BOTTOM_PX = 32` +
|
||||
`isThinkingNearBottom(textEl)` (`scrollHeight - scrollTop -
|
||||
clientHeight <= band`);
|
||||
- the phase-17 unconditional pin
|
||||
(`textEl.scrollTop = textEl.scrollHeight`) becomes gated:
|
||||
`if (block.open && isThinkingNearBottom(textEl)) { textEl.scrollTop
|
||||
= textEl.scrollHeight; }` — a scrolled-up user is never re-pinned,
|
||||
and returning to the bottom re-arms the pin automatically (the check
|
||||
runs on every chunk).
|
||||
- **Non-goals:** no "↓ more" affordance, no auto-height growth, no change
|
||||
to the summary/chevron, the tool-call lines, or the answer bubble.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_thinking_scroll.py`** (mock LLM; DB
|
||||
up; phase 21 lengthened `mock_llm.compose_thinking` to ~2 700 chars ≈
|
||||
4.5 s of paced frames so the scratchpad overflows the 320px window by
|
||||
~2×, and the phase-20 hesitation trigger gives a deterministic 4 s
|
||||
frozen-tail state with the block open). The phase-21 suite
|
||||
`tests/e2e/test_thinking_no_scroll.py` is **deleted** in this phase:
|
||||
1. `test_thinking_window_user_scrollable` — frozen live tail (4 s
|
||||
hesitation): focus `.thinking-text`, wheel up, `Home`, mouse-drag up
|
||||
— `scrollTop` moves; the window shows earlier content.
|
||||
2. `test_thinking_window_follows_while_pinned` — during the live stream:
|
||||
with the user at the bottom, after the 2nd-to-last and the last chunk
|
||||
the window is pinned to the tail (within 1px) and the last chunk's
|
||||
text renders inside the visible rectangle.
|
||||
3. `test_thinking_window_stops_on_scroll_up` — mid-stream: scroll up
|
||||
~half the window; over the next ≥5 chunks `scrollTop` stays stable
|
||||
(within 1px) — no re-pin.
|
||||
4. `test_thinking_window_resumes_on_return` — from the paused state,
|
||||
scroll the window back to its bottom; on the next chunk the window is
|
||||
re-pinned to the tail (within 1px).
|
||||
5. `test_thinking_window_css_contract` — computed `overflow-y: auto`,
|
||||
`max-height: 320px`, and the clip is real (`scrollHeight >
|
||||
clientHeight` for the long scratchpad).
|
||||
6. `test_answer_bubble_still_scrollable` (phase 11 regression) — a long
|
||||
answer: the page scrolls, the bubble's overflow is untouched.
|
||||
7. `test_restored_collapsed_thinking_unaffected` (phase 17 regression) —
|
||||
a settled thinking turn reloads as a collapsed block with its full
|
||||
text.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Story: Tuning toggle anonymous flash
|
||||
|
||||
**Phase:** `40_tuning_toggle_flash` · **Source:** `TODO.md` L3 ·
|
||||
**E2E:** `tests/e2e/test_tuning_toggle_flash.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L3)
|
||||
|
||||
> "Loading the page briefly shows the 'Tuning' button in the header even
|
||||
> when the user isn't authenticated. Only show that if the user is
|
||||
> authenticated."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **an anonymous visitor**, the header must never show admin-only
|
||||
controls — not even for a frame. Today the tuning-notes toggle
|
||||
(`#steering-toggle`, the header button labeled **"Tuning"**) ships
|
||||
*visible* in all six pages' markup and is only removed from the DOM after
|
||||
`/api/whoami` resolves — so every anonymous page load flashes the button
|
||||
for the length of the whoami round-trip. The admin-only *nav links*
|
||||
(`#nav-sources`, `#nav-git-sources`, `#nav-tuning`) already ship `hidden`
|
||||
(phase-19 "absent, not hidden" contract) and are not the issue.
|
||||
|
||||
- **Given** an anonymous visitor loads any page
|
||||
- **When** the page renders (before `/api/whoami` resolves)
|
||||
- **Then** no "Tuning" control is ever visible — not for a single frame.
|
||||
|
||||
- **Given** a signed-in admin loads any page
|
||||
- **When** whoami resolves
|
||||
- **Then** the toggle is revealed (and the note list refreshes, as today).
|
||||
|
||||
## Acceptance criteria
|
||||
1. Anonymous load of **every** page: `#steering-toggle` is never
|
||||
attached-visible — a MutationObserver installed via `addInitScript`
|
||||
records zero visible frames of the toggle from first paint to settled
|
||||
state; after load the toggle is absent from the DOM (the existing
|
||||
remove-from-DOM behavior).
|
||||
2. Admin load: the toggle is visible after whoami, `aria-expanded`
|
||||
works, the count badge refreshes — identical to today's admin
|
||||
behavior (phase 15/34 contract).
|
||||
3. No-JS visitors: the toggle is hidden (the control is JS-gated by
|
||||
design — whoami decides).
|
||||
4. No regression to the shared-header contract (phase 19/34): nav links
|
||||
ship hidden, sign-in/sign-out pair, sync button, new-chat binding
|
||||
unchanged.
|
||||
|
||||
## Owner-confirmed (2026-08-27, roadmap A — confirmed with the
|
||||
conversion interview)
|
||||
1. **The flashing control is the steering toggle**, not the admin
|
||||
"Tuning" nav link (which already ships `hidden`) — confirmed by code
|
||||
inspection: `#steering-toggle` ships visible in all six pages
|
||||
(`index.html`, `sources.html`, `document.html`, `git-sources.html`,
|
||||
`login.html`, `tuning.html`) and is removed post-whoami.
|
||||
2. **Fix = ship `hidden`, reveal for admin, keep anonymous removal** —
|
||||
the same ship-hidden / reveal-for-admin contract the admin-only nav
|
||||
links already use; anonymous still gets "absent, not hidden".
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **The whole functional change is one attribute + one JS line:**
|
||||
- `#steering-toggle` gains `hidden` in all six pages' header markup
|
||||
(the button element only — the `#steering-panel` region already ships
|
||||
`hidden`).
|
||||
- `frontend/assets/header.js` `initSharedHeader()`: in the admin branch,
|
||||
unhide the toggle (`steeringToggle.hidden = false`) before
|
||||
`refreshSteering()`; the anonymous branch (`steeringToggle?.remove()`)
|
||||
is unchanged.
|
||||
- **Non-goals:** no change to the nav links, the panel, the steering
|
||||
API, or any other shared-header control.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_tuning_toggle_flash.py`** (mock LLM;
|
||||
DB up):
|
||||
1. `test_anonymous_never_sees_toggle` — `addInitScript` a MutationObserver
|
||||
that records every frame in which `#steering-toggle` exists in the
|
||||
DOM and is not `[hidden]`; load `/` anonymously; after load assert the
|
||||
observer recorded **zero** such frames, and the toggle is absent from
|
||||
the DOM (removed, per the phase-16 contract).
|
||||
2. `test_anonymous_other_pages_never_flash` — same observer assertion on
|
||||
`/sources.html`, `/tuning.html`, `/login.html` (the page set the
|
||||
contract must hold on).
|
||||
3. `test_admin_toggle_revealed_and_working` — login via
|
||||
`e2e.auth_helpers.login`; reload `/`; the toggle is visible
|
||||
(`hidden` removed), clicking opens `#steering-panel`
|
||||
(`aria-expanded="true"`), and the count badge matches the panel.
|
||||
4. `test_nav_contract_regression` (phase 19/34) — anonymous: nav links
|
||||
`#nav-sources` / `#nav-git-sources` / `#nav-tuning` stay hidden and
|
||||
absent from the visible header; admin: they are revealed — the
|
||||
ship-hidden contract this phase relies on is intact.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Story: Whole-Document Context — a matched document is never truncated
|
||||
|
||||
**Phase:** `24_whole_document_context` · **E2E:** `tests/e2e/test_whole_document_context.py`
|
||||
|
||||
## Bug report (TODO.md L3–L4, verbatim)
|
||||
|
||||
> *"Documents are truncated for some reason? This should never happen"*
|
||||
>
|
||||
> *"When the LLM matches a chunk it should get the entire document placed in its context so it can see the whole thing before answering the question"*
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user asking about an indexed note**, I want **the entire document
|
||||
in the LLM's context** whenever retrieval matches one of its chunks — so
|
||||
the answer is grounded in the whole note, not a silently cut-down version
|
||||
of it.
|
||||
|
||||
- **Given** a question whose chunk matches a document
|
||||
- **When** the turn assembles context
|
||||
- **Then** the LLM receives the **entire** parent document — never a
|
||||
`[…truncated…]`-cut version — so the answer is grounded in the whole
|
||||
note.
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap D1–D5)
|
||||
1. **D1 — no cap at all (revises LOCKED A7):** `select_documents` returns
|
||||
the full top-N document texts, always. The `max_context_chars` setting
|
||||
and `BOR_MAX_CONTEXT_CHARS` env var are removed. If a future KB ever
|
||||
makes the prompt too large for the model, the existing `LLMError` → SSE
|
||||
`error` path surfaces it loudly — no silent partial context. The
|
||||
emergency-valve variant (raised cap + warning log) was **explicitly
|
||||
rejected**.
|
||||
2. **D2 — `top_n_docs = 2` unchanged** (the TODO is about truncation, not
|
||||
about how many documents).
|
||||
3. **D3 — no viewer/import changes** — both already serve full content
|
||||
(verified diagnosis above).
|
||||
4. **D4 — E2E evidence via a deterministic mock tail-echo** (repo pattern,
|
||||
cf. the phase-15 tuning-note echo); the big documents are seeded
|
||||
directly in the DB inside the E2E test — `tests/fixtures/docs/` must
|
||||
not grow, because other suites pin `summary.added == 8`.
|
||||
5. **D5 — no `query_log` schema change** (no new columns, no migration).
|
||||
|
||||
## Acceptance criteria
|
||||
1. No budget parameter in `select_documents` — it never truncates
|
||||
(`TRUNCATION_MARKER` remains for the steering section only).
|
||||
2. `BOR_MAX_CONTEXT_CHARS` gone from settings/env/README
|
||||
(`app/config.py`, `.env.example`, `README.md`).
|
||||
3. The story E2E's three tests green in isolation
|
||||
(`uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`).
|
||||
4. Steering-note truncation (phase 15, `BOR_STEERING_MAX_CHARS` + shared
|
||||
marker) unchanged.
|
||||
5. Unit + integration green, `app/` coverage >90%, one
|
||||
`--no-gpg-sign` commit.
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_whole_document_context.py`** (mock LLM
|
||||
tail-echo, oversized documents seeded directly in the DB):
|
||||
1. `test_whole_document_over_old_cap_reaches_llm` — a 30 000-char document
|
||||
(past the old 24 000 cap): its tail sentinel (last line) is echoed in
|
||||
the rendered answer, `[…truncated…]` is absent, the source chip renders,
|
||||
`query_log` row `deflected == False`.
|
||||
2. `test_second_document_of_over_cap_pair_reaches_llm` — two ~16 000-char
|
||||
documents (32 000 combined — the exact case the old budget cut): the
|
||||
second, lower-ranked document's tail sentinel is echoed (doc 1's is not
|
||||
— it pins the rank order), both source chips render.
|
||||
3. `test_small_document_path_unchanged` — regression: standard fixtures via
|
||||
the real importer → grounded answer with the `kubernetes.md` chip, no
|
||||
`[…truncated…]` (the under-cap path is byte-identical to before).
|
||||
Reference in New Issue
Block a user