chore(agent): add phases 20-23 from TODO.md — four owner-confirmed bug fixes; clear TODO.md
This commit is contained in:
@@ -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/`.
|
||||
Reference in New Issue
Block a user