chore(agent): add phases 20-23 from TODO.md — four owner-confirmed bug fixes; clear TODO.md

This commit is contained in:
2026-08-24 15:23:49 -04:00
parent 2afc77ee56
commit 824914ca3d
13 changed files with 1040 additions and 0 deletions
@@ -0,0 +1,122 @@
# Phase 20 — Sources Mid-Stream: an in-flight answer is not lost on navigation
**Source:** `TODO.md` L3 — *"Clicking "sources" while chat is generating
clears chat and result will never show up"*
**Story:** `.agent/user_stories/sources-midstream.md` (created by task 02)
**Context:** `frontend/assets/app.js` — the phase-14 persistence block
(`STORAGE_KEY = "bor.chat.v1"`, `conversation`, `saveConversation`,
`rememberBrainTurn`), the turn state machine (`UI_STATE.thinking` /
`.streaming`), the streaming accumulators (`acc` / `thinkingAcc` /
`sawThinking`), and the phase-14 restore path; `frontend/index.html`
(`#nav-sources` link); `frontend/assets/header.js` (`clearChatStorage` —
the deliberate New-Chat clear, NOT this bug).
## Objective
When the user leaves the chat page (the Sources nav link, the document
viewer, any link) while a turn is still in flight, the answer generated so
far must not vanish. Today the brain message is persisted only on `done`,
so navigating away aborts the stream and the partial answer is lost — the
user returns to their own question with no result, ever. After this phase,
returning to the chat shows the question **and** the partial answer that
had already streamed (rendered like any brain message, thinking block
restored if any).
## Owner-confirmed (2026-08-24, roadmap A1)
1. **A partial answer is persisted as a plain brain message** — no
"(partial)" marker, no sources/suggestions (the turn is dead; the user
can re-ask for the full answer).
2. Navigation **before the first answer token** (pure thinking) persists
nothing brain-side: the question is restored, no empty/partial bubble.
3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer pages)
is untouched — that clear is by design (phase 14/19).
4. No server-side resume (A10 stays stateless) and no
"leave page?" confirmation dialog.
## Design
- **`app.js` — one new `pagehide` handler** (`window.addEventListener(
"pagehide", …)` — fires on navigate-away and bfcache store):
- Guard: only when a turn is in flight (current `uiState` is
`UI_STATE.thinking` or `UI_STATE.streaming`) **and** `acc` is
non-empty.
- Action: `rememberBrainTurn(acc, { thinking: thinkingAcc ||
undefined })` — reuse the existing save-point helper, so the partial
text is stored raw (the restore path re-renders through the
escape-first markdown renderer; the phase-17 `thinking` field
restores the collapsed Thinking block).
- **Idempotency guard:** a turn-local `persistedOnLeave` flag so a
second `pagehide` (or bfcache store+restore churn) never appends the
same partial message twice. The `done` save point is unaffected
(navigation means the stream is dead; if the user returns via
bfcache the turn is already aborted by the unloading page).
- **Restore path:** unchanged — a stored partial message is a well-formed
brain message and renders exactly like a completed one (minus
sources/deflection, which it simply doesn't carry).
- **Non-goals:** no resume of the SSE stream, no API changes, no changes
to the New Chat buttons, sign-out, or the document-viewer back link.
## Dependencies
- `14_chat_persistence` (complete) — `bor.chat.v1` shape, save points,
restore, and the `rememberBrainTurn` helper this phase reuses.
- `17_thinking_display` (complete) — the `thinking` field on persisted
brain messages and the `thinkingAcc` accumulator.
- `19_shared_header` (complete) — the `#nav-sources` link (admin-only)
the bug report clicks.
- `18_follow_bottom_scroll` (complete) — no overlap (scroll gating only).
## Tasks
1. `01_persist_inflight_turn.md` — the `pagehide` partial-persistence
handler in `app.js` + source-level unit pins.
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_sources_midstream_bug.py`
(the story gate, isolated), regression suites, story file, final
validation, the single atomic commit, phase move to `complete/`.
## Locked decisions
- **A10 untouched** — API stays stateless; no resume. **A11 untouched** —
vanilla JS, no CDN. **A16 honored** — one new story E2E suite +
adapted regressions. No anchor changed.
## Testing & Quality
- **Unit (source-level, new `tests/unit/test_sources_midstream.py`,
following the repo's source-pin pattern):** `app.js` registers a
`pagehide` listener; the guard references the in-flight `uiState` and a
non-empty `acc`; the partial path calls `rememberBrainTurn` with
`thinking: thinkingAcc || undefined`; a turn-local idempotency flag
exists; `STORAGE_KEY`/save-point comments updated to list the new
save point.
- **Integration:** none (no `app/` changes) — the `uv run pytest
--cov=app` number must stay at today's.
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected,
re-run to prove it.
- **E2E:** `tests/e2e/test_sources_midstream_bug.py` (task 02), green
**in isolation** (prereq `podman compose up -d db`).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] Admin, mid-stream, clicks **Sources** → returns to `/`: the
question **and** the already-streamed partial answer are both
rendered; no error banner; `bor.chat.v1` holds the partial brain
message.
- [ ] Navigate away before the first token → back: question restored,
no empty/partial brain bubble.
- [ ] A completed turn is persisted exactly as before (sources,
deflection, suggestions intact).
- [ ] New Chat from the sources page still clears the conversation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov`
green in isolation; regressions green in isolation (one command
each): `test_chat_persistence.py`, `test_thinking_display.py`,
`test_shared_header.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): no new UI surface — the
restored partial renders through the existing bubble/thinking
contract.
- [ ] `.agent/user_stories/sources-midstream.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/20_sources_midstream_bug/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return"
```
@@ -0,0 +1,83 @@
# Task 01 — app.js: persist the partial answer on navigate-away (pagehide)
**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3 —
*"Clicking "sources" while chat is generating clears chat and result will
never show up"*
## Objective
`frontend/assets/app.js` gains exactly one new save point: on `pagehide`
(navigate-away / bfcache), if a turn is in flight and answer text has
already streamed, the partial answer is persisted via the existing
`rememberBrainTurn` helper — so returning to the chat restores the
question **and** what had been generated.
## Work
1. `frontend/assets/app.js`
- Next to the other turn-local state (around the `acc` /
`thinkingAcc` / `sawThinking` / `aborted` declarations, ~line 920+),
declare a turn-local flag:
```js
let persistedOnLeave = false; // pagehide partial-persist at most once
```
and reset it to `false` at the top of `runTurn` (where `acc`,
`thinkingAcc`, `sawThinking`, `wrap` are initialized), so it is
turn-scoped like the rest.
- Register one handler at module boot (next to the other
`window.addEventListener` calls):
```js
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
* answer — the brain message persists only on `done`, and
* navigation aborts the stream. On `pagehide`, if a turn is in
* flight and answer text has streamed, persist the partial raw text
* (reusing the save-point helper, so restore re-renders it exactly
* like a completed answer — no "(partial)" marker, no sources).
* Thinking-only (no answer tokens yet) persists nothing brain-side:
* the question is already saved on send and the user can re-ask.
* `persistedOnLeave` makes this idempotent across pagehide/bfcache
* churn. */
window.addEventListener("pagehide", () => {
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return;
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
});
```
**ASSUMPTION (owner-confirmed A1):** the partial is a plain brain
message (no marker, no sources); the variables `uiState`, `acc`,
`thinkingAcc` are the turn's existing ones — match the names actually
in scope (the file keeps `let acc = ""` / `let thinkingAcc = ""`
turn-locals inside `runTurn`; if the handler needs them outside that
scope, hoist the turn-locals to module scope *without* changing any
behavior — smallest diff wins).
- Update the phase-14 persistence block comment (~line 631): the
"Save points:" sentence now lists three — the user message on send,
the brain message on `done`, and the **partial** brain message on
navigate-away (`pagehide`, phase 20).
2. `tests/unit/test_sources_midstream.py` (new — follow the repo's
source-level pin pattern used by `tests/unit/test_shared_header.py`):
- `app.js` contains `addEventListener("pagehide"` exactly once.
- The pagehide body is guarded by the in-flight states (`thinking`
and `streaming`) and a non-empty `acc` check.
- The pagehide body calls `rememberBrainTurn(acc,` with
`thinking: thinkingAcc || undefined`.
- `persistedOnLeave` is declared and reset in `runTurn`.
- The persistence block comment lists the `pagehide` save point.
- `clearChatStorage` (header.js) is untouched — still the only
deliberate clear.
## Testing & Quality
- `uv run pytest tests/unit/test_sources_midstream.py -v` green.
- `uv run ruff check . && uv run pyright` clean.
- Manual smoke (dev server, DEBUGPY optional): send a question against
the real LLM (or any slow stream), click Sources mid-stream, return —
the partial answer is visible.
## Completion Criteria
- [ ] The `pagehide` handler exists, is turn-scoped and idempotent, and
reuses `rememberBrainTurn` (no duplicated storage code).
- [ ] No other save point changed: send + `done` behave byte-identically
to before (existing persistence unit pins still pass).
- [ ] Unit pins green; lint/types clean.
@@ -0,0 +1,76 @@
# Task 02 — E2E story suite, story file, validation, commit
**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3
## Objective
The story gate: `tests/e2e/test_sources_midstream_bug.py` proves the bug
is fixed end-to-end (navigate away mid-stream, come back, the partial
answer is there), plus the regression suites, the story file, final
validation, and the single atomic commit.
## Work
1. `tests/e2e/test_sources_midstream_bug.py` (new — mirror
`test_chat_persistence.py`'s scaffolding: fixture import via
`_import_fixtures`/`_run_in_thread`, mock LLM on a thread, `login`
from `e2e.auth_helpers`). The mock LLM must stream **slowly enough**
that the turn is still in flight when the test navigates (reuse the
streaming pattern from `test_thinking_display.py` / `mock_llm.py`;
tune the per-chunk delay until the navigation lands mid-stream).
Tests (Playwright Mapping Rule — one per numbered scenario):
1. `test_partial_answer_survives_sources_nav_midstream` — admin
(`login(page, app_url, next="/")`), send a question, wait for the
first streamed chunk to render (expect the first chunk's text in
the answer bubble), **click `#nav-sources`** (the actual nav link —
admin sees it), land on `/sources.html`, then `page.goto("/")`:
expect the question text AND the first-chunk text present, no
`role="alert"` banner. Read `localStorage` `bor.chat.v1`: the
messages contain a brain message whose text starts with the first
chunk.
2. `test_no_orphan_brain_message_when_navigated_before_first_token` —
mock streams a `thinking` event, then a long pre-token pause;
navigate (direct `page.goto("/sources.html")` is fine here) during
the pause, return to `/`: the question is present, exactly one
user message and **zero** brain messages in both the DOM and
`bor.chat.v1`.
3. `test_completed_turn_unaffected` — a turn that finishes normally
(`done`), then navigate to sources and back: full answer, sources
chips, and the done-metadata (sources array) intact in storage.
4. `test_new_chat_still_clears_conversation` — regression: completed
turn → `/sources.html` → click the sources-page New Chat button →
lands on `/` with the empty state and `bor.chat.v1` removed.
2. `.agent/user_stories/sources-midstream.md` (new) — the short story
file matching the repo's story format (goal, the bug report verbatim
from `TODO.md` L3, the owner-confirmed A1 decisions from
`00_phase.md`, the E2E mapping table test-name → scenario).
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
`uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov`.
4. Regressions, in isolation, one command each (all must stay green):
- `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov`
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
- `uv run pytest tests/e2e/test_shared_header.py -v --no-cov`
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number (>90% gate);
`uv run ruff check . && uv run pyright` clean.
6. **UI Structure Check** (AGENTS.md rule 5): the restored partial
renders through the existing bubble/thinking contract — no new
surface, no new ids, focus/contrast unchanged.
7. Write the phase report
(`.agent/reports/20_sources_midstream_bug/` — what changed, E2E
results, the manual-smoke note from task 01).
8. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ frontend/ tests/
git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return"
mv .agent/phases/todo/20_sources_midstream_bug .agent/phases/complete/
```
## Testing & Quality
- Story suite green **in isolation**; the three regression suites green
in isolation; full unit+integration suite green; `app/` coverage at or
above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_sources_midstream_bug.py` 4/4 in isolation.
- [ ] Regressions (persistence, thinking display, shared header) green.
- [ ] Story file + phase report exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
@@ -0,0 +1,104 @@
# Phase 21 — Thinking Window: No Scroll Back, Just Live
**Source:** `TODO.md` L4 — *"Disable scroll in the thinking window.
Users don't need to scroll back through thinking, just see it live."*
**Story:** `.agent/user_stories/thinking-no-scroll.md` (created by task 02)
**Context:** `frontend/assets/styles.css` —
`details.thinking .thinking-text` (today: `max-height: 320px;
overflow-y: auto;`, i.e. a user-scrollable 320px window, ~line 444);
`frontend/assets/app.js` — the phase-17 thinking block (`ensureThinkingBlock`,
the streaming `thinking` branch that appends chunks and already pins the
stream to the bottom: `textEl.scrollTop = textEl.scrollHeight`, ~line 886),
and the phase-14 restore path (stored `thinking` re-renders a
**collapsed** block).
## Objective
The live Thinking block is a scratchpad, not a transcript. The user must
not be able to scroll back through it — the 320px window always shows the
**live tail** of the reasoning stream (the existing per-chunk
bottom-pinning stays). Wheel, drag, and keyboard scrolling on
`.thinking-text` stop working; the stream itself keeps pinning to the
bottom as chunks arrive.
## 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.
## Design
- **CSS (the whole functional change):**
`details.thinking .thinking-text` — `overflow-y: auto` →
`overflow-y: hidden`; keep `max-height: 320px`.
`overflow: hidden` still permits **programmatic** scrolling
(`scrollTop`), so the phase-17 pin
(`textEl.scrollTop = textEl.scrollHeight` on every thinking chunk)
keeps the window glued to the live tail — no JS change needed.
Add a CSS 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*.
- **No JS change** — the pin already exists; nothing else touches
`.thinking-text` scroll.
- **Non-goals:** no change to the answer bubble, the collapsed restore
state, the summary/chevron, or the auto-collapse on first delta
(phase 17).
## Dependencies
- `17_thinking_display` (complete) — the block, the pin, the restore.
- `18_follow_bottom_scroll` (complete) — no overlap (chat-page scroll
gate only; the thinking window is a separate inner element).
- `11_long_answers` (complete) — the untouched answer-bubble behavior.
## Tasks
1. `01_disable_thinking_scroll.md` — the CSS change + source-level unit
pins.
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_thinking_no_scroll.py`
(the story gate, isolated), regression suites, story file, final
validation, the single atomic commit, phase move to `complete/`.
## Locked decisions
- **A11 untouched** — no CDN, pure CSS. **A16 honored** — one new story
E2E suite + adapted regressions. No anchor changed.
## Testing & Quality
- **Unit (source-level, new `tests/unit/test_thinking_no_scroll.py`,
repo source-pin pattern):** `styles.css` carries
`details.thinking .thinking-text` with `overflow-y: hidden` and
`max-height: 320px`; the phase-17 pin line
(`textEl.scrollTop = textEl.scrollHeight`) still present in `app.js`
(the live-tail mechanism must not be lost).
- **Integration:** none (no `app/` changes).
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected.
- **E2E:** `tests/e2e/test_thinking_no_scroll.py` (task 02), green
**in isolation** (prereq `podman compose up -d db`).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] With a long thinking stream, wheel/mouse-drag/keyboard on
`.thinking-text` do **not** move it; the visible content is always
the live tail (`scrollTop === scrollHeight` after each chunk,
within 1px).
- [ ] Computed style: `overflow-y: hidden`, `max-height: 320px`.
- [ ] A long **answer** bubble still scrolls normally; a restored
collapsed Thinking block still renders (phase 17 regression).
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov`
green in isolation; regressions green in isolation:
`test_thinking_display.py`, `test_long_answers.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): no new surface; the block
keeps its focus-visible summary, aria contract, and reduced-motion
behavior.
- [ ] `.agent/user_stories/thinking-no-scroll.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/21_thinking_no_scroll/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail"
```
@@ -0,0 +1,57 @@
# Task 01 — styles.css: `.thinking-text` overflow hidden (no user scroll)
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4 —
*"Disable scroll in the thinking window. Users don't need to scroll back
through thinking, just see it live."*
## Objective
One CSS property change makes the Thinking window a live-tail-only view:
`overflow-y: hidden` instead of `auto`, keeping the 320px clip. The
phase-17 JS bottom-pin (which keeps working under `overflow: hidden`) is
the sole scroller.
## Work
1. `frontend/assets/styles.css` — in the phase-17 thinking block
section (~line 444):
```css
details.thinking .thinking-text {
padding: 0 0.75rem 0.75rem;
color: var(--ink-soft); /* 6.9:1 on --surface */
font-size: 0.875rem;
line-height: 1.55;
max-height: 320px;
overflow-y: hidden; /* no scroll back (owner choice 2026-08-24):
the window is a live tail only — the phase-17
JS bottom-pin (scrollTop = scrollHeight per
chunk) is the sole scroller */
}
```
(Only the `overflow-y` value + comment change; every other declaration
stays byte-identical.)
2. `frontend/assets/app.js` — **no change expected.** Verify the pin is
intact: the streaming `thinking` branch still does
`textEl.scrollTop = textEl.scrollHeight` on every chunk (~line 886).
If (and only if) the pin were missing/broken, fix it — do not remove
or alter any other scrolling behavior.
3. `tests/unit/test_thinking_no_scroll.py` (new — repo source-pin
pattern):
- `styles.css`: the `details.thinking .thinking-text` rule contains
`overflow-y: hidden` and `max-height: 320px` (no `overflow-y: auto`
left in that rule).
- `app.js`: the bottom-pin line
`textEl.scrollTop = textEl.scrollHeight` is still present (the
live-tail mechanism).
4. Manual smoke (dev server): stream a long thinking turn; try to wheel /
drag / Tab+ArrowDown inside the Thinking block — it must not move;
the newest chunk is always the one visible at the bottom.
## Testing & Quality
- `uv run pytest tests/unit/test_thinking_no_scroll.py -v` green.
- `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] `.thinking-text` is `overflow-y: hidden`, `max-height: 320px`, with
the owner-choice comment.
- [ ] The JS bottom-pin is verified intact (no app.js diff unless the
pin was broken).
- [ ] Unit pins green; lint/types clean; manual smoke passed.
@@ -0,0 +1,70 @@
# Task 02 — E2E story suite, story file, validation, commit
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4
## Objective
The story gate: `tests/e2e/test_thinking_no_scroll.py` proves the window
can't be user-scrolled but always tracks the live tail, plus regressions,
story file, final validation, and the single atomic commit.
## Work
1. `tests/e2e/test_thinking_no_scroll.py` (new — reuse
`test_thinking_display.py`'s mock-LLM streaming scaffolding; the mock
must stream a **long** thinking body, in many chunks, so
`.thinking-text` overflow exceeds its 320px box). Tests:
1. `test_thinking_window_not_user_scrollable` — open the block, wait
until `scrollHeight > clientHeight`; focus `.thinking-text`
(`el.focus()`), dispatch mouse wheel over it
(`page.mouse.wheel(0, -200)` after moving the mouse over the
element) and press `Home`/`ArrowUp`: `scrollTop` must not decrease
(assert `scrollTop` unchanged within 1px between actions).
2. `test_thinking_window_tracks_live_tail` — while chunks stream,
after the 2nd-to-last and last chunk:
`scrollTop === scrollHeight` (within 1px) — the visible window is
the live tail; the **last** chunk's text is within the visible
rectangle (its offsetTop + scrollTop geometry check, or
`elementFromPoint` at the box's bottom).
3. `test_thinking_window_css_contract` — computed style of
`.thinking-text`: `overflow-y === "hidden"`,
`max-height === "320px"`.
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — a
long answer (use the long-answer mock from
`test_long_answers.py`): the answer bubble is still
user-scrollable (scrollTop moves on wheel) and
`overflow-y` is not `hidden` there.
5. `test_restored_collapsed_thinking_unaffected` (regression,
phase 17) — a turn with stored `thinking`, reload: the collapsed
Thinking block renders with its text (existing pin from
`test_thinking_display.py` — replicate, don't duplicate the file).
2. `.agent/user_stories/thinking-no-scroll.md` (new) — story file per
the repo format: goal, the bug report verbatim from `TODO.md` L4, the
owner-confirmed A2 decisions from `00_phase.md`, E2E mapping table.
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
`uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov`.
4. Regressions, in isolation, one command each:
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
- `uv run pytest tests/e2e/test_long_answers.py -v --no-cov`
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number (>90% gate);
`uv run ruff check . && uv run pyright` clean.
6. **UI Structure Check** (AGENTS.md rule 5): no new surface; the block
keeps its summary chevron, focus-visible ring, aria-live/label
contract, and the reduced-motion stillness (styles.css ~line 686).
7. Write the phase report (`.agent/reports/21_thinking_no_scroll/`).
8. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ frontend/ tests/
git commit --no-gpg-sign -m "fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail"
mv .agent/phases/todo/21_thinking_no_scroll .agent/phases/complete/
```
## Testing & Quality
- Story suite green **in isolation**; both regression suites green in
isolation; full unit+integration suite green; `app/` coverage at or
above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_thinking_no_scroll.py` 5/5 in isolation.
- [ ] Regressions (thinking display, long answers) green in isolation.
- [ ] Story file + phase report exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
@@ -0,0 +1,114 @@
# Phase 22 — Animated Background: Make It Actually Animate
**Source:** `TODO.md` L5 — *"Fix background animation not working, just
blinking"*
**Story:** `.agent/user_stories/background-animation.md` (created by task 02)
**Context:** `frontend/assets/styles.css` — the phase-08 animated
background block (~lines 60–100): `body::before` (44px drifting grid,
1px lines at ~35% `--line` alpha, radial mask
`radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%)`,
`animation: bg-grid-drift 60s linear infinite`) and `body::after` (two
soft radial glows, `animation: bg-glow-breathe 14s ease-in-out infinite
alternate`, opacity 0.65↔1 + scale 1↔1.05). Both layers are
`position: fixed; inset: 0; z-index: -1; pointer-events: none`. `html`
owns the `var(--bg)` canvas and `body` is `background: transparent`
(~lines 42–54) — if any later rule occludes that, the layers vanish.
The **phase-08 design comments are the spec** for what "working" means.
## Objective
Owner report 2026-08-24: the background "just blinks" — i.e. the motion
the phase-08 design promised (a slow, seamless grid drift + a gentle
glow breathe) is not perceived; at most a flicker/blink is visible.
Diagnose which layer(s) actually fail in a real Chromium viewport, fix
the CSS, and leave a background that visibly and smoothly animates as
designed — no blink, no static frame, no jank.
## Owner-confirmed (2026-08-24, roadmap A3)
1. **Intended effect = the phase-08 design comments:** seamless 60s grid
drift (one cell per loop) + 14s ease glow breathing. The fix serves
that design, not a redesign.
2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new
assets, no `filter: blur` (perf note in the block).
## Design / diagnostic plan
The fix is found, not guessed — work through this checklist in a real
Chromium window (dev server, full page, ~15s of observation):
1. **Per-layer visibility:** toggle each pseudo-element (DevTools
generated-content / a temp outline) and screenshot — is the grid
visible at all? Is only the glow (the "blink" the user perceives)
alive?
2. **Grid layer:** sample `background-position` on `body::before` at two
timestamps — is it actually moving? Is the radial mask fading the
visible region so small that the 44px/60s drift is imperceptible?
(If the drift is real but too faint: raise the grid line alpha and/or
the mask's visible radius — smallest change that reads as "smooth
drift".)
3. **Glow layer:** is the 14s breathe reading as a *blink*? (If the
opacity swing 0.65↔1 is perceived as pulsing: lengthen the period
and/or narrow the opacity delta so it reads as breathing.)
4. **Occlusion check:** confirm nothing later in `styles.css` (or in
`html`/`body` rules) paints an opaque background over the
`z-index: -1` layers — the phase-08 comment at ~line 42 is the
contract.
5. **Apply the fix in `styles.css`** — document the found root cause in
the phase report (screenshot before/after in
`.agent/screenshots/22_background_animation/`).
## Dependencies
- `08_story_dark_tech_theme` (complete) — owns the layers, the palette,
and the "pure CSS, zero JS" anchor this phase must respect.
- `07_story_responsive_polish` (complete) — no new overflow at 360px
(both layers are `fixed; inset: 0` — keep it that way).
## Tasks
1. `01_fix_background_animation.md` — diagnosis + the CSS fix +
source-level unit pins.
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_background_animation.py`
(the story gate, isolated), regression suites, story file, final
validation, the single atomic commit, phase move to `complete/`.
## Locked decisions
- **Phase-08 anchor honored** — pure CSS, zero JS, no `filter: blur`,
WCAG AA palette untouched (background layers carry no text).
**A11 untouched** — no CDN, no new assets. **A16 honored** — one new
story E2E suite + adapted regressions. No anchor changed.
## Testing & Quality
- **Unit (source-level, new `tests/unit/test_background_animation.py`,
repo source-pin pattern):** `styles.css` still defines
`@keyframes bg-grid-drift` and `@keyframes bg-glow-breathe`;
`body::before` animates `bg-grid-drift` with `linear infinite`;
`body::after` animates `bg-glow-breathe`; both layers remain
`position: fixed; z-index: -1; pointer-events: none`; `html` keeps
`background: var(--bg)` and `body` keeps `background: transparent`
(the no-occlusion contract). Pin the **final** values the fix lands
on (durations/opacities may move per the design plan).
- **Integration:** none (no `app/` changes).
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected.
- **E2E:** `tests/e2e/test_background_animation.py` (task 02), green
**in isolation** (prereq `podman compose up -d db`).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] In a real Chromium viewport, the background visibly and smoothly
animates (grid drift + glow breathe) — screenshot before/after in
the phase report; owner's "just blinking" perception gone.
- [ ] Root cause documented in `.agent/reports/22_background_animation/`.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_background_animation.py -v --no-cov`
green in isolation; regressions green in isolation:
`test_dark_tech_theme.py`, `test_responsive_polish.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): layers stay behind content
(`z-index: -1`, `pointer-events: none`), no text/contrast impact,
no 360px overflow.
- [ ] `.agent/user_stories/background-animation.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/22_background_animation/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design"
```
@@ -0,0 +1,60 @@
# Task 01 — Diagnose and fix the animated background (styles.css)
**Phase:** `22_background_animation` · **Source:** `TODO.md` L5 —
*"Fix background animation not working, just blinking"*
## Objective
Find why the phase-08 animated background reads as "just blinking" and
fix `styles.css` so the grid drift and the glow breathe are both visibly
and smoothly alive, per the phase-08 design comments (pure CSS, zero JS).
## Work
1. **Reproduce:** `uv run uvicorn app.main:app --reload` (db up), open `/`
in a visible Chromium window (Playwright or the interactive browser),
observe ≥15s. Note exactly what moves and what doesn't.
2. **Diagnose per the `00_phase.md` checklist** (per-layer visibility
toggles, `background-position` samples on `body::before`, mask
inspection, occlusion check against `html`/`body` rules, glow
opacity-swing perception). Record findings + before-screenshot in
`.agent/reports/22_background_animation/` and
`.agent/screenshots/22_background_animation/`.
**ASSUMPTION (to verify, not assume):** the likely culprits, in
order — (a) the masked grid drift is too faint/slow to perceive,
(b) only the glow opacity swing is visible and it reads as a blink,
(c) a later rule occludes the `z-index: -1` layers. Confirm which
one actually fires before touching CSS; the fix must match the found
cause.
3. **Fix in `frontend/assets/styles.css`** (smallest change that makes
the design read):
- grid: raise line alpha and/or the mask's visible radius and/or the
drift speed as needed for a clearly visible, seamless drift
(drift delta must still equal one 44px cell for a seamless loop —
if the speed changes, keep `background-position` 0→44px and only
move the duration);
- glow: if the breathe reads as a blink, narrow the opacity delta
(e.g. 0.8↔1) and/or lengthen the period — it must read as
breathing, not pulsing;
- keep: both layers `position: fixed; inset: 0; z-index: -1;
pointer-events: none`; no `filter: blur`; no JS; palette/contrast
untouched.
4. **After-screenshot** (same viewport, two frames a few seconds apart
showing motion) into the same screenshots dir.
5. `tests/unit/test_background_animation.py` (new — repo source-pin
pattern): pin the **final** `styles.css` values — both
`@keyframes` present, `body::before` → `bg-grid-drift linear
infinite`, `body::after` → `bg-glow-breathe`, both layers
`fixed`/`z-index: -1`/`pointer-events: none`, `html` keeps
`background: var(--bg)`, `body` keeps `background: transparent`.
6. Manual re-verify: the "just blinking" perception is gone — smooth
drift + gentle breathe, no jank, no static frame.
## Testing & Quality
- `uv run pytest tests/unit/test_background_animation.py -v` green.
- `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] Root cause documented (with before/after screenshots) in the
phase report dir.
- [ ] Both layers visibly animate as the phase-08 design describes;
pure CSS, zero JS, no blur.
- [ ] Unit pins green against the final values; lint/types clean.
@@ -0,0 +1,68 @@
# Task 02 — E2E story suite, story file, validation, commit
**Phase:** `22_background_animation` · **Source:** `TODO.md` L5
## Objective
The story gate: `tests/e2e/test_background_animation.py` proves both
background layers are actually running animations (not just declared),
plus regressions, story file, final validation, and the single atomic
commit.
## Work
1. `tests/e2e/test_background_animation.py` (new). The layers are CSS
pseudo-elements, so assert via computed style + the Web Animations
API (Chromium reports pseudo-element CSS animations through
`element.getAnimations()`):
1. `test_grid_layer_animation_running` —
`getComputedStyle(document.body, "::before").animationName` is the
grid-drift keyframe (final name from task 01), timing function
`linear`, iteration count `infinite`; and a matching entry in
`document.body.getAnimations()` with `playState === "running"`.
2. `test_glow_layer_animation_running` — same for `"::after"` with
the glow-breathe keyframe; `playState === "running"`.
3. `test_animations_advance` — sample `animation.currentTime` (or
the `getAnimations()` entry's `currentTime`) for both layers,
wait ~500ms (`page.wait_for_timeout`), assert both advanced —
the animations are truly running, not paused.
4. `test_background_layers_contracts` — both pseudo-elements:
`position: fixed`, `z-index: -1`, `pointer-events: none`;
`document.documentElement` computed `background-color` is the
palette bg (the canvas stays on `html`); `document.body` computed
`background-color` is `rgba(0, 0, 0, 0)` (no occlusion).
5. `test_no_horizontal_overflow_with_layers` (regression, 360px) —
viewport 360px: `document.documentElement.scrollWidth <=
clientWidth` (the phase-07 pin, replicated locally).
2. `.agent/user_stories/background-animation.md` (new) — story file per
the repo format: goal, the bug report verbatim from `TODO.md` L5, the
owner-confirmed A3 decisions + the found root cause (from task 01's
report), E2E mapping table.
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
`uv run pytest tests/e2e/test_background_animation.py -v --no-cov`.
4. Regressions, in isolation, one command each:
- `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov`
- `uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov`
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number (>90% gate);
`uv run ruff check . && uv run pyright` clean.
6. **UI Structure Check** (AGENTS.md rule 5): layers stay behind
content, no text/contrast impact, no overflow at 360px.
7. Finish the phase report (`.agent/reports/22_background_animation/` —
E2E results + the task-01 screenshots).
8. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ frontend/ tests/
git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design"
mv .agent/phases/todo/22_background_animation .agent/phases/complete/
```
## Testing & Quality
- Story suite green **in isolation**; both regression suites green in
isolation; full unit+integration suite green; `app/` coverage at or
above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_background_animation.py` 5/5 in isolation.
- [ ] Regressions (dark tech theme, responsive polish) green in
isolation.
- [ ] Story file + phase report (with screenshots) exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
@@ -0,0 +1,120 @@
# Phase 23 — Containerfile: Build the Whole App Image Again
**Source:** `TODO.md` L6 — *"Fix Containerfile build not working"*
**Story:** `.agent/user_stories/containerfile-build.md` (created by task 02)
**Context:** `Containerfile` (3 stages: node:22-alpine + esbuild
0.25.5 frontend bundle → uv/python deps → slim runtime serving
`/app/static`); `frontend/` (4 pages: `index.html`, `sources.html`,
`document.html`, `login.html`; assets: `styles.css`, `markdown.js`
(classic script), `header.js`/`app.js`/`sources.js`/`document.js`/
`login.js` (ES modules)); `scripts/entrypoint.sh`.
## Verified diagnosis (2026-08-24, this conversion — 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/`).
2. **Secondary gap (image would be broken even if it built):** stage 1
bundles only `app.js` + `sources.js` and copies 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. **Verified fix:** with relative imports (`from "./header.js"`) all
four page scripts bundle cleanly with esbuild 0.25.5.
4. **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.
## Objective
`podman build -f Containerfile .` succeeds, and the resulting image
serves the **whole app** — all four pages with their bundled, minified,
local-only assets (No CDN rule) — with `header.js` evaluated exactly
once per page.
## Owner-confirmed (2026-08-24, roadmap A4)
1. **Relative imports** (`./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.
## Dependencies
- `19_shared_header` (complete) — introduced the absolute imports (root
cause) and the direct `header.js` tags.
- `10_story_document_viewer` / `16_admin_auth` (complete) — the pages
missing from the image.
- `08_story_dark_tech_theme` (complete) — No CDN rule the image must
honor.
## Tasks
1. `01_fix_containerfile_build.md` — relative imports, tag removal,
stage-1 asset coverage, green `podman build`, image smoke test.
2. `02_integration_test_commit.md` — `tests/integration/
test_containerfile_assets.py` (hermetic coverage pin), regression
suites, story file, final validation, the single atomic commit,
phase move to `complete/`.
## Locked decisions
- **A11 honored** — vanilla JS, no CDN, static serving from FastAPI.
**A16 honored** — integration test for the new build coverage; story
file + report; no Playwright suite required (this phase is
build/infrastructure — the phase gate is the hermetic integration
test + the real `podman build` + image smoke recorded in the report,
plus the dev-server E2E regressions). No anchor changed.
## Testing & Quality
- **Integration (new `tests/integration/test_containerfile_assets.py`,
hermetic — no podman, no network):** every `frontend/*.html` is
copied into stage 1's `/out`; every local `src`/`href` asset
referenced by the four pages is produced by a stage-1 line (esbuild
`--outfile` or `cp`); the four page module scripts are the exact set
esbuild bundles; `markdown.js` is produced; no HTML references
`/assets/header.js` directly (single-evaluation design pin); the
esbuild version stays pinned.
- **Unit:** none (no `app/` changes).
- **Coverage:** the >90% `app/` gate is unaffected, re-run to prove it.
- **Build gate (manual, recorded in the report):** `podman build
-f Containerfile .` green; image smoke (task 01 step 6) results +
log excerpt in `.agent/reports/23_containerfile_build/`.
- **Dev regressions (E2E, isolated):** `test_smoke.py`,
`test_shared_header.py`, `test_chat_persistence.py` (the HTML tag
removal touches dev page load).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] Local esbuild 0.25.5 bundles all four page scripts cleanly.
- [ ] `podman build -f Containerfile .` green (log excerpt in the
report).
- [ ] 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).
- [ ] Dev server unchanged in behavior: the three regression E2E suites
green in isolation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] `.agent/user_stories/containerfile-build.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/23_containerfile_build/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ Containerfile frontend/ tests/ && git commit --no-gpg-sign -m "fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image"
```
@@ -0,0 +1,91 @@
# Task 01 — Fix the build: relative imports, tag removal, full stage-1 asset coverage
**Phase:** `23_containerfile_build` · **Source:** `TODO.md` L6 —
*"Fix Containerfile build not working"*
## Objective
Make `podman build -f Containerfile .` succeed and ship the **complete**
frontend in the image: all four pages, all four bundled page modules,
the classic `markdown.js`, and the minified `styles.css` — with
`header.js` evaluated exactly once per page.
## Work
1. **Reproduce the failure** and record it in
`.agent/reports/23_containerfile_build/` (log excerpt):
- fast: `npx -y esbuild@0.25.5` on a copy of `frontend/` → the
`Could not resolve "/assets/header.js"` error (root cause, already
reproduced during conversion);
- authoritative: `podman build -f Containerfile .` → stage 1 fails
at the same line.
2. **`frontend/assets/{app,sources,document,login}.js`** — change the
header import from absolute URL to relative (one line each; the
specifiers are currently `from "/assets/header.js"`):
```js
import { … } from "./header.js";
```
(owner-confirmed A4 — relative over esbuild alias; dev-server
behavior is unchanged since the files are side-by-side and the
module URL resolves to the same file.)
3. **Remove the four redundant direct `header.js` tags** (owner-confirmed
A4-2 — the single-evaluation design from `00_phase.md`):
- `frontend/index.html` (~line 119) —
`<script type="module" src="/assets/header.js"></script>`;
- `frontend/sources.html` (~line 125), `frontend/document.html`
(~line 80), `frontend/login.html` (~line 67) — same tag.
- Update the surrounding HTML comments that describe the
header-before-page-script load order (e.g. index.html ~lines
115–119): the order is now guaranteed by the page script's own
`import` (hoisted, evaluated before the page script body calls
`initSharedHeader()`).
4. **`Containerfile` stage 1** — cover the whole app (keep the pinned
`esbuild@0.25.5` and the existing flags):
```dockerfile
RUN mkdir -p /out/assets \
&& esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
&& esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
&& esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp ./index.html ./sources.html ./document.html ./login.html /out/
```
(`markdown.js` is a classic script — minify only, **no** `--bundle`;
it exposes globals used by the pages.)
5. **Verify locally (no podman):** with esbuild 0.25.5, all four module
bundles + the markdown minify succeed on the real `frontend/` (not a
copy — the copy was only for the diagnosis).
6. **`podman build -f Containerfile .`** → green.
7. **Image smoke test** (results + log excerpt into the report dir):
- throwaway Postgres 17 + pgvector (`podman compose up -d db` and
point the container at it, or a one-off container with the same
env as `compose.yaml`);
- run the built image (migrations run via the entrypoint);
- `GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
- `GET /assets/app.js` → 200, minified (single-line-ish), and
contains the header code (e.g. the `clearChatStorage` function
body); `GET /assets/markdown.js`, `/styles.css`, the other three
page modules → 200;
- No CDN rule: none of the four served pages contain an `http(s)://`
`src`/`href` asset reference.
- Teardown the throwaway containers when done.
8. **Dev-server regression check** (the tag removal touches dev page
load — confirm boot order still holds): `uv run uvicorn
app.main:app --reload`, load all four pages, check the sign-out
binding exists exactly once (DevTools: no duplicate listener — one
`POST /api/logout` per click) and `initSharedHeader()` ran. (The
isolated E2E regressions run in task 02.)
## Testing & Quality
- Steps 5–8 above; `uv run ruff check . && uv run pyright` clean
(no Python changes, but keep the gate green).
## Completion Criteria
- [ ] The recorded build failure is fixed at the root cause (relative
imports) — not masked by an alias/patch.
- [ ] All four direct `header.js` tags removed + comments updated; the
page scripts' `import "./header.js"` is the only header load.
- [ ] Stage 1 produces: 4 HTML pages, 4 bundled modules, minified
`markdown.js`, minified `styles.css`.
- [ ] `podman build` green; image smoke all-200 + No CDN + single
header evaluation; dev-server boot unchanged (step 8).
- [ ] Log/screenshot evidence in `.agent/reports/23_containerfile_build/`.
@@ -0,0 +1,70 @@
# Task 02 — Integration coverage test, story file, validation, commit
**Phase:** `23_containerfile_build` · **Source:** `TODO.md` L6
## Objective
Pin the stage-1 asset coverage so it can't silently rot again (a new
page/script/asset without a matching Containerfile line fails CI), plus
regressions, story file, final validation, and the single atomic commit.
## Work
1. `tests/integration/test_containerfile_assets.py` (new — **hermetic**:
parses `Containerfile` + `frontend/` as text, no podman, no network).
Tests:
1. `test_every_html_page_is_copied_into_stage1` — for each
`frontend/*.html` in the repo, a stage-1 line copies it into
`/out` (regex over the `cp` line; the set must be exactly the
four current pages — a new page added to `frontend/` fails this).
2. `test_every_local_asset_reference_is_produced` — collect every
local `src=`/`href=` under `assets/` or `/assets/` from the four
HTML files; each basename must be produced by a stage-1 line
(an `esbuild … --outfile=/out/assets/<name>` or a `cp` of it).
(This is what catches a missing `markdown.js`-style gap.)
3. `test_page_module_scripts_are_bundled` — the set of `type="module"`
page scripts referenced by the HTML (basenames) equals the set of
scripts esbuild bundles in stage 1 (`app.js`, `sources.js`,
`document.js`, `login.js`).
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
file contains a `<script … src="/assets/header.js">` (or
`assets/header.js`) tag (the single-evaluation design pin,
owner-confirmed A4-2); and each of the four page scripts imports
it relatively (`from "./header.js"`).
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js`
has a stage-1 minify line **without** `--bundle` (it is a classic
global script) and no `import`/`export` statements at its top
level (source pin of that assumption).
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
`esbuild@X.Y.Z` version (no floating version).
2. `.agent/user_stories/containerfile-build.md` (new) — story file per
the repo format: goal, the bug report verbatim from `TODO.md` L6, the
verified diagnosis (root cause + missing-asset gap + double-eval
trap), the owner-confirmed A4 decisions, and the test mapping table.
3. Regressions, in isolation, one command each (prereq
`podman compose up -d db`):
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov`
- `uv run pytest tests/e2e/test_shared_header.py -v --no-cov`
- `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov`
4. Final validation: `uv run pytest` green (includes the new
integration test); `uv run pytest --cov=app --cov-report=term-missing`
≥ today's number (>90% gate); `uv run ruff check . && uv run pyright`
clean.
5. Finish the phase report (`.agent/reports/23_containerfile_build/` —
build log excerpt, smoke results, regression results).
6. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ Containerfile frontend/ tests/
git commit --no-gpg-sign -m "fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image"
mv .agent/phases/todo/23_containerfile_build .agent/phases/complete/
```
## Testing & Quality
- New integration suite green within `uv run pytest`; the three
regression E2E suites green in isolation; full suite green; `app/`
coverage at or above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_containerfile_assets.py` 6/6 within the full suite.
- [ ] Regressions (smoke, shared header, chat persistence) green in
isolation.
- [ ] Story file + phase report (build log + smoke evidence) exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
+5
View File
@@ -0,0 +1,5 @@
# TODO
All items from this file were converted to the phased-execution roadmap
on 2026-08-24 — see `.agent/phases/todo/` (phases 20–23). Each phase
file carries a `Source:` line citing the original item and line.