chore(agent): phase roadmap from TODO.md, 3 phases (34-36)
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# Phase 34 — One Navbar on Every Page
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."
|
||||||
|
**Story:** `.agent/user_stories/nav-consistency.md`
|
||||||
|
**Context:** The shared header (phase 19, `frontend/assets/header.js`) + the Tuning nav link (phase 29) already standardize nav + auth on chat / sources / tuning — but `document.html` still uses the separate `.doc-header` variant (back + title + actions, **no nav at all**), `login.html` misses the Tuning link, and two functional controls remain page-scoped: the Tuning steering toggle + panel (chat only, logic in `app.js`) and the Sync sources button (Sources only, logic in `sources.js`). Owner confirmation (2026-08-26): the bar must be identical on **all** pages — nav, Tuning toggle, Sync, New chat, and the auth pair all present everywhere; the locked A10 UI revision stays (admin-only controls hidden for anonymous, active for the admin on every tab).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Make the header bar **identical on all five pages** (chat, sources, document viewer, tuning, login): one shared markup block, one owner of all functional control behavior (`header.js`), the viewer's back link + title preserved in a second titlebar row, and the phase-12/19 height contract (64px desktop / 58px ≤640px) applied to the standard row on every page.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `19_shared_header` (complete) — the `header.js` module, the nav/auth markup + ids, the cached-one-whoami contract, the ship-hidden/reveal-for-admin pattern.
|
||||||
|
- `29_tuning_nav_link` (complete) — the admin-only `#nav-tuning` reveal pattern this phase completes on the remaining pages.
|
||||||
|
- `15_steering_notes` + `27_global_tuning` (complete) — the steering toggle/panel logic being moved into the shared module; chat-page behavior must not change.
|
||||||
|
- `32_admin_sync_button` (complete) — the sync button state machine + `GET/POST /api/sync` being moved into the shared module; Sources-page behavior (result line + error banner) must not change.
|
||||||
|
- `13_document_back_navigation` (complete) — the `#doc-back` target-resolution behavior the viewer titlebar must preserve.
|
||||||
|
- `16_admin_auth` (complete) — the whoami gate, the soft-gate pages, the sign-out binding.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_steering_moves_to_module.md` — the steering toggle + panel logic moves from `app.js` into `header.js` (exported `refreshSteering()`); the chat per-bubble Tune form keeps working.
|
||||||
|
2. `02_sync_and_chat_moves_to_module.md` — the sync state machine moves from `sources.js` into `header.js` (`bor:sync-status` event); one module-owned New chat binding; the sign-in `?next=` rewrite.
|
||||||
|
3. `03_full_header_all_pages.md` — all five pages ship the identical header block; `#steering-panel` exists on every page; the viewer becomes standard row + titlebar row; login gains the full header.
|
||||||
|
4. `04_viewer_titlebar_styles.md` — the two-row viewer header styles, the sync button's failed state on non-Sources pages, theme/contrast/focus preserved.
|
||||||
|
5. `05_e2e_and_contract_update.md` — the story E2E suite `test_nav_consistency.py`; `test_header_consistency.py` + `test_shared_header.py` updated to the new viewer contract; regression pass; commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: frontend-only — no new `app/` logic; the no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) must still pass (all new markup is same-origin, no new tags).
|
||||||
|
- Coverage: **>90%** on `app/` — unchanged by this phase (no Python change).
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_nav_consistency.py` — the story gate, run in isolation; plus the two contract suites updated in task 05 and the regression list below.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The same visible header controls appear on **all five pages** in the same order — brand, nav [Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New chat, exactly one of Sign in / Sign out — verified in `test_nav_consistency.py` for both the admin and the anonymous role.
|
||||||
|
- [ ] The document viewer shows the standard bar (row 1) + back link and title (row 2); `#doc-back` target resolution (phase 13) unchanged.
|
||||||
|
- [ ] The login page carries the full header (nav incl. Tuning, Tuning toggle, Sync, New chat, auth pair).
|
||||||
|
- [ ] Chat page: the steering panel + per-bubble Tune + inline form behave exactly as before; Sources page: the sync button state machine + `#sync-result` line + `#sync-error-banner` behave exactly as before.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (>90%).
|
||||||
|
- [ ] Regressions green in isolation: `test_header_consistency.py`, `test_shared_header.py`, `test_document_back_navigation.py`, `test_document_viewer.py`, `test_steering.py`, `test_global_tuning.py`, `test_sync_button.py`, `test_tuning_nav_link.py`, `test_smoke.py`, `test_chat_rag.py`, `test_admin_auth.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean (no Python change, but run the gate).
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5): landmarks / labels / contrast ≥4.5:1 / focus-visible preserved; no CDN (rule 6).
|
||||||
|
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/34_consistent_navbar/` moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **A10 UI revision preserved** — admin-only controls (Sources / Tuning nav links, Sync button) ship hidden and are revealed only for the signed-in admin; anonymous visitors get the reduced bar, identically on every page (owner confirmation 2026-08-26 — "hidden for anon, visible for admin").
|
||||||
|
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages.
|
||||||
|
- **Phase 19 module contract extended, not replaced** — `header.js` keeps the cached one-whoami-per-page promise; it gains ownership of the controls' behavior, not a second whoami.
|
||||||
|
- **Viewer bar superseded** — the phase-19 single-row viewer bar (PLAN.md §7.1 "the viewer bar = back + title + the same actions") is replaced by the two-row layout at the owner's request (this TODO). `PLAN.md` is not edited (Protocol B); this phase directory records the revision.
|
||||||
|
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Task 01 — Steering toggle + panel move into header.js
|
||||||
|
|
||||||
|
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
|
||||||
|
**Story:** `.agent/user_stories/nav-consistency.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Make `frontend/assets/header.js` the owner of the steering toggle + panel behavior (today in `frontend/assets/app.js`), so the toggle can sit in every page's header (task 03) with zero page-script duplication. The chat page's behavior — panel open/close, list, count badge, per-note delete, per-bubble Tune form — must be byte-for-byte the same from the user's perspective.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/header.js` — add the steering logic (runs at module import, like the existing sign-out binding):
|
||||||
|
- `loadSteering()` — `GET /api/steering`; non-2xx (the anonymous 403, unreachable API) → empty list (the current chat-page anonymous state); render via `renderSteeringPanel(notes)`.
|
||||||
|
- `renderSteeringPanel(notes)` — newest-first `<li class="steering-note">` rows: the note as `textContent` in a `span.steering-note-text` (XSS contract unchanged — never innerHTML for the note), a per-note Remove `button.steering-delete` with `aria-label="Delete tuning note: …"`; toggle `#steering-empty`'s `hidden` on `notes.length`; set the `#steering-count` badge text.
|
||||||
|
- `deleteSteeringNote(id, btn)` — disable the row button, `DELETE /api/steering/{id}`, re-load the list, announce through `#steering-announcer` (`role="status"`).
|
||||||
|
- The `#steering-toggle` click binding — open/close `#steering-panel`, flip `aria-expanded`, move focus into the panel on open (the chat-page a11y contract; read `app.js`'s current implementation first and mirror it exactly, including any close-on-Esc / outside-click behavior it has).
|
||||||
|
- **Export `refreshSteering()`** (fetch + render) — task 01's `app.js` change wires the per-bubble Tune form's success path to it.
|
||||||
|
- Update the file's header comment (it now owns the steering controls).
|
||||||
|
2. `frontend/assets/app.js` — remove the steering **panel** section (the `#steering-toggle` / `#steering-count` / `#steering-panel` / `#steering-list` / `#steering-empty` / `#steering-announcer` refs, `loadSteering`, `renderSteeringPanel`, `deleteSteeringNote`, `announceSteering`, the toggle binding) — **keep** the per-bubble `appendTuneButton` + `openTuneForm` (a chat-specific feature): the inline form's success path calls `refreshSteering()` imported from `./header.js` instead of the removed `loadSteering()`. Keep `TUNE_ICON` and the form's fetch/error handling untouched.
|
||||||
|
3. Update the comments that describe the panel as chat-page-owned (app.js header comment, index.html steering comments) — the panel now belongs to the shared module; index.html's markup stays for now (task 03 copies it to the other pages).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- All elements are looked up null-safe (`querySelector` + guard) — a page that (still) lacks the panel markup is a no-op, mirroring how `initSharedHeader()` already works. This keeps the app functional between tasks.
|
||||||
|
- Do not change the steering API (`app/api/steering.py`), the panel markup in `index.html`, or the `#steering-panel` styles.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No Python change; the no-CDN integration test is unaffected.
|
||||||
|
- Coverage: `app/` gate unaffected (no Python change).
|
||||||
|
- The moved logic is behavior-verified by the regression suites in task 05 (`test_steering.py`, `test_global_tuning.py`); until then `uv run pytest` (unit + integration) must stay green.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `header.js` exports `refreshSteering()` and owns the toggle binding, panel render, per-note delete, count badge, and announcer.
|
||||||
|
- [ ] `app.js` no longer contains the panel logic; the per-bubble Tune button + inline form remain and call `refreshSteering()` on save.
|
||||||
|
- [ ] The chat page (`/`) still loads, opens, lists, and deletes steering notes exactly as before (manual smoke via the dev server or the regression suites in task 05).
|
||||||
|
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 02 — Sync state machine + New chat + sign-in next move into header.js
|
||||||
|
|
||||||
|
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
|
||||||
|
**Story:** `.agent/user_stories/nav-consistency.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Make `header.js` the owner of the Sync button state machine (today in `sources.js`), the single New chat binding (today duplicated across `app.js` / `sources.js` / `tuning.js` / `document.js`), and the sign-in `?next=` derivation — so the same markup on any page (task 03) behaves identically.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/header.js` — add the sync state machine (read `sources.js`'s sync section first and mirror its contract exactly):
|
||||||
|
- **Boot (admin only):** `await fetchIsAdmin()` on the cached whoami — non-admins never poll (the status endpoint is admin-only). One `GET /api/sync/status`: `running` → enter the running state + start polling (the phase-32 reload-mid-sync re-attach); terminal → render the last result.
|
||||||
|
- **Click `#sync-btn`:** `POST /api/sync` → 202 enters running; 409 attaches to the running state (one sync at a time).
|
||||||
|
- **Poll** `GET /api/sync/status` every 2000 ms — one live timer, stopped on a terminal state. **No client-side hard timeout** (phase-32 locked decision — a sync can outlive the page; the state machine simply keeps polling).
|
||||||
|
- **Button states (§7.4 never-stale):** idle → label "Sync"; running → `disabled` + `aria-busy="true"` + spinner class (`.sync-icon.is-spinning`) + label "Syncing…"; success → label "Synced HH:MM"; failed → error state with the sanitized error string in the button's `title` + `aria-label` (on non-Sources pages that is where the failure is visible — the Sources page's own banner is driven by the event below).
|
||||||
|
- **On every state change** dispatch `window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: <status> }))` where `detail` is the `GET /api/sync/status` object — step 2 points the Sources page's banner/result line at it.
|
||||||
|
2. `frontend/assets/header.js` — **one** New chat binding (module scope, null-safe): if `#messages` exists (chat page) → `window.dispatchEvent(new CustomEvent("bor:new-chat"))` and let the page script act; otherwise `clearChatStorage()` + `location.href = "/"` (the existing non-chat behavior — "new chat" means go to the chat, fresh).
|
||||||
|
3. `frontend/assets/header.js` — **sign-in `?next=` rewrite:** in `initSharedHeader()` (or the module-scope boot), set `#sign-in-link`'s `href` to `/login.html?next=<current pathname>` (default `/`) — the admin lands back on the page they signed in from.
|
||||||
|
- ASSUMPTION: on the chat page this changes the static fallback `?next=/sources.html` to `/` at runtime — landing on the page you signed in from ("return to where you were"). The page markup keeps its current href as the no-JS fallback.
|
||||||
|
4. `frontend/assets/sources.js` — remove the sync state machine (the `#sync-btn` click handler, the 2 s poll loop, the button-state helpers, the boot re-attach). **Keep** `#sync-result` + `#sync-error-banner` rendering, now driven by a `window.addEventListener("bor:sync-status", …)` subscription: `running` → clear the result line, hide the banner; `success` → render the last-result counts in `#sync-result` (reuse the existing formatting, "added" always shown); `failed` → show `#sync-error-banner` with the error text; `idle` → hide the banner, clear the result.
|
||||||
|
5. `frontend/assets/app.js` — replace the direct `#new-chat-btn` click binding with `window.addEventListener("bor:new-chat", startNewChat)` (the `startNewChat` function itself is unchanged).
|
||||||
|
6. `frontend/assets/sources.js`, `frontend/assets/tuning.js`, `frontend/assets/document.js` — remove their `#new-chat-btn` click bindings (the module owns them). Update the file-header comments (document.js: the module now owns New chat; sources.js: sync is module-owned, the banner is event-driven).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Null-safe element lookups throughout (a page that doesn't (yet) have `#sync-btn` is a no-op — the app stays functional between tasks).
|
||||||
|
- The module must keep exactly **one** whoami per page load (the cached promise) — the sync boot may await it but must not add a fetch.
|
||||||
|
- Do not touch `app/api/sync.py` — the API contract is unchanged.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No Python change; the no-CDN integration test is unaffected.
|
||||||
|
- Coverage: `app/` gate unaffected.
|
||||||
|
- Behavior parity is verified by the regression suites in task 05 (`test_sync_button.py`, `test_shared_header.py`, `test_chat_rag.py`); until then `uv run pytest` must stay green.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `header.js` owns: the sync state machine (+ `bor:sync-status` event), the single New chat binding (`bor:new-chat` on chat, clear+navigate elsewhere), and the sign-in `next` rewrite.
|
||||||
|
- [ ] `sources.js` no longer contains the sync state machine — `#sync-result` / `#sync-error-banner` render off the event; no `#new-chat-btn` binding remains in any page script.
|
||||||
|
- [ ] On the Sources page the full phase-32 cycle (click → polling → success counts / failure banner, reload re-attach) still works — confirmed in task 05 via `test_sync_button.py`.
|
||||||
|
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task 03 — The identical full header on all five pages
|
||||||
|
|
||||||
|
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
|
||||||
|
**Story:** `.agent/user_stories/nav-consistency.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Ship the **identical header block** on all five pages — brand, nav [Chat, Sources, Tuning], Tuning toggle, Sync sources, New chat, Sign in / Sign out — and the `#steering-panel` section on every page; the document viewer keeps back + title in a second titlebar row; the login page finally carries the full header.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
The canonical block is `index.html`'s current header **plus** the `#sync-btn` copied verbatim from `sources.html` (hidden by default, `#sync-label` + `.sync-icon` inside). Place the Sync button **after** the Tuning toggle and **before** the New chat button on every page.
|
||||||
|
|
||||||
|
1. `frontend/index.html` (chat) — add the `#sync-btn` block to the header (the only missing control); everything else already ships. `#steering-panel` stays where it is (after `#kb-banner` in `<main>`).
|
||||||
|
2. `frontend/sources.html` — add the Tuning toggle block (copied from `index.html`: `#steering-toggle` + `#steering-count`) after the nav; add the `#steering-panel` section (copied from `index.html`, incl. the `#steering-announcer` paragraph) as the **first child of `<main>`**; keep the existing `#sync-btn` where it is.
|
||||||
|
3. `frontend/tuning.html` — add the Tuning toggle block + the `#sync-btn` block to the header (same order as chat); add the `#steering-panel` section as the first child of `<main>`.
|
||||||
|
4. `frontend/document.html` — restructure the header:
|
||||||
|
- **Row 1** becomes the standard `.app-header` / `.header-inner` bar, byte-for-byte the same block as the other pages: brand, `<nav class="app-nav">` with `Chat` + `#nav-sources` (hidden) + `#nav-tuning` (hidden), Tuning toggle, `#sync-btn` (hidden), New chat, Sign in (`?next=/document.html` static fallback) / Sign out.
|
||||||
|
- **Row 2** — a new `.doc-titlebar` container inside the same `<header>`, carrying the existing `#doc-back` link + `#doc-title` + `#doc-meta` (moved out of the old `.doc-header-inner` title block, markup otherwise unchanged — `renderDocument` addresses them by id, so `document.js` needs no render change).
|
||||||
|
- ASSUMPTION: **no nav link gets `is-active` / `aria-current` on the viewer** — a document is a detail view reachable from chat or Sources (phase 13's `back` param), so no single nav target is "current". The back link carries the navigation affordance.
|
||||||
|
- The old `.doc-header-actions` wrapper is dropped — its buttons now live in row 1's standard `.header-inner`.
|
||||||
|
5. `frontend/login.html` — full header: the nav gains the `#nav-tuning` link (after `#nav-sources`, same hidden-by-default markup as the other pages); add the Tuning toggle + `#sync-btn` + New chat + the Sign in / Sign out pair (sign-in static fallback `?next=/login.html`); add the `#steering-panel` section as the first child of `<main>`.
|
||||||
|
6. `frontend/assets/header.js` — comment updates only: the viewer now has a nav (its "the viewer has no nav" notes are stale); the module's "missing element is a no-op" contract still holds for any page missing an element. No behavior change — the reveal code already handles `#nav-sources` / `#nav-tuning` / `#sync-btn` / the auth pair wherever they exist.
|
||||||
|
7. Update the stale HTML comments in the touched headers (phase-19/29 comments describing the old page-specific layouts) to reference this phase + the owner confirmation (2026-08-26).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep every existing id / class / aria attribute exactly as it exists today (the E2E suites key off them); only ADD missing blocks and move the viewer's title elements.
|
||||||
|
- Ship-hidden stays ship-hidden: `#nav-sources`, `#nav-tuning`, `#sync-btn`, and exactly one of the auth pair are `hidden` in the markup on every page — `header.js` reveals at load (one whoami, cached).
|
||||||
|
- Preserve indentation/markup style so the five headers stay diff-identical (that identity is what task 05's E2E asserts).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No Python change; the no-CDN integration test must still pass (same-origin markup only).
|
||||||
|
- Coverage: `app/` gate unaffected.
|
||||||
|
- Manual smoke before task 05: with the dev server, as admin and as anonymous, each of the five pages shows the full bar (admin) / reduced bar (anonymous) with no console errors.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All five page headers contain the same control inventory in the same order: brand, nav [Chat, Sources, Tuning], `#steering-toggle`, `#sync-btn`, `#new-chat-btn`, `#sign-in-link` + `#sign-out-btn`.
|
||||||
|
- [ ] `#steering-panel` (+ `#steering-announcer`) exists on all five pages (chat: after `#kb-banner`; others: first child of `<main>`).
|
||||||
|
- [ ] `document.html` = standard row + `.doc-titlebar` row with `#doc-back` / `#doc-title` / `#doc-meta`; no nav link carries `is-active` there.
|
||||||
|
- [ ] `login.html` carries the full header incl. the `#nav-tuning` link and the auth pair.
|
||||||
|
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 04 — Two-row viewer header styles + sync failed state
|
||||||
|
|
||||||
|
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
|
||||||
|
**Story:** `.agent/user_stories/nav-consistency.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Style the document viewer's new two-row header (standard row + titlebar row) so row 1 is visually indistinguishable from the other pages' bars, and give the Sync button a visible failed state on pages that have no error banner (every page except Sources).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/styles.css` — **viewer header:**
|
||||||
|
- The viewer's `<header>` keeps the sticky app-frame behavior; **row 1** reuses the existing `.app-header` / `.header-inner` rules verbatim (64px desktop, 58px at ≤640px — the phase-12/19 pinned heights apply because row 1 *is* the standard bar).
|
||||||
|
- New `.doc-titlebar` rules for row 2: `.container`-width inner row with `#doc-back` + the title block; its own height (title line + meta line), `border-top` separator in the existing hairline color, same surface color (`#121a2e`/`#0a0e17` family per the phase-08 palette); `#doc-title` truncates with an ellipsis + `title` attribute instead of the old pill-clipping rule.
|
||||||
|
- The old `.doc-header` / `.doc-header-inner` / `.doc-header-actions` / title-clipping rules are removed or reduced to the two-row structure (keep class names that task 05's updated contract suites reference — check which selectors the suites use before deleting: `.doc-header` may remain as the header element's class wrapping both rows).
|
||||||
|
- The `.steering-panel` positioning rules must work from the new placement on the non-chat pages (first child of `<main>`) — the panel is an in-flow section, so this is expected to be a no-op; verify visually and in the E2E.
|
||||||
|
2. `frontend/assets/styles.css` — **sync failed state (non-Sources pages):** the failed `#sync-btn` gets an error treatment from the phase-08 palette (error ink `#fca5a5` on the error surface `#2d1318`, border `#f59e0b`-free — the error chip uses `#fca5a5`/`#2d1318`, ≈9.1:1) so a failed sync is visible on every page, complementing the `title`/`aria-label` error text set by `header.js`. Reuse the existing `.sync-btn` state classes/styles if phase 32 already defines a failed look; otherwise add it.
|
||||||
|
3. `frontend/assets/styles.css` — **login page:** the full header needs no new rules (it reuses `.app-header`), but confirm the login card layout still centers correctly with the full bar (no header-height regression at ≤640px).
|
||||||
|
4. Accessibility checks (AGENTS.md rule 5): `:focus-visible` 3px outline on the new titlebar back link (it already has the existing `.doc-back` styles — preserve); contrast ≥4.5:1 for title/meta text; `prefers-reduced-motion` still stills the sync spinner.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- CSS-only — no Python change; the no-CDN integration test is unaffected.
|
||||||
|
- Coverage: `app/` gate unaffected.
|
||||||
|
- Visual pass (dev server, admin + anonymous, desktop + 640px): all five pages, viewer row 1 identical to chat's bar; viewer row 2 shows back + title + meta; the sync button's failed state is visible and readable.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The viewer's row 1 renders pixel-consistent with the chat/sources bars (same height 64px / 58px, same paddings, same controls).
|
||||||
|
- [ ] `.doc-titlebar` renders back + title (ellipsis) + meta badges on one or two tidy lines; sticky with the header.
|
||||||
|
- [ ] A failed sync shows an error-colored `#sync-btn` on non-Sources pages with the sanitized error in `title` / `aria-label`.
|
||||||
|
- [ ] No console layout breakage on the login page (card still centered, 58px bar at ≤640px).
|
||||||
|
- [ ] `uv run pytest` green (no-CDN test included); `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Task 05 — Story E2E + contract-suite updates + regression pass
|
||||||
|
|
||||||
|
**Phase:** `34_consistent_navbar` · **Source:** `TODO.md:3 — "I want the navbar to be consistent between every page. I don't want buttons to pop in and out of existance. Just keep all those buttons active across all tabs."`
|
||||||
|
**Story:** `.agent/user_stories/nav-consistency.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the contract with the story's dedicated Playwright suite — identical visible header control inventory on all five pages for each role — update the two pre-existing contract suites that encoded the old viewer bar, and run the full regression list.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_nav_consistency.py` (NEW — the story gate, run in isolation). Fixtures: the standard E2E app + DB (see `tests/e2e/conftest.py`); a fixture document for the viewer URL (the `source=docs&path=homelab%2Fkubernetes.md` pattern from `test_shared_header.py`); admin session via `tests/e2e/auth_helpers.py`.
|
||||||
|
- **Admin inventory (all five pages):** `/`, `/sources.html`, `/document.html?source=…&path=…`, `/tuning.html`, `/login.html` — each header contains, visible: the three nav links (Chat, `#nav-sources`, `#nav-tuning`), `#steering-toggle`, `#sync-btn`, `#new-chat-btn`, and `#sign-out-btn` visible with `#sign-in-link` hidden. Assert the same **id + class inventory and DOM order** of the header controls on every page (normalize: the current-page `is-active` nav marker and the sign-in `?next=` value legitimately differ per page).
|
||||||
|
- **Anonymous inventory (all five pages):** nav present with Chat visible and `#nav-sources` / `#nav-tuning` hidden (locked A10 UI revision); `#sync-btn` hidden; `#sign-in-link` visible, `#sign-out-btn` hidden; `#steering-toggle` visible.
|
||||||
|
- **Viewer specifics:** row 1 height equals the chat page's header height (64px desktop / 58px ≤640px); the titlebar row is visible with `#doc-back` + `#doc-title` (rendered document title) + `#doc-meta` badges; clicking `#doc-back` honors the `back` param (phase 13 — one positive + one rejection case).
|
||||||
|
- **Steering works off-chat:** as admin, on `/tuning.html` — seed zero notes (truncate `steering_notes` via a `SessionLocal` like the other suites), click `#steering-toggle` → `#steering-panel` visible + `aria-expanded="true"` + empty state shown; add a note through the panel? (the panel has no add form — it lists notes; assert the toggle open/close cycle + the count badge reads 0) — keep this deterministic, no chat needed.
|
||||||
|
- **Sync present, not triggered:** as admin on `/tuning.html` assert `#sync-btn` is visible (do NOT click it — a real sync would clone real repos; the full state machine is `test_sync_button.py`'s job).
|
||||||
|
2. `tests/e2e/test_header_consistency.py` (UPDATE to the new contract): the viewer assertions change — `.doc-header` is now the two-row header; assert **row 1** (the standard bar) is 64px desktop / 58px ≤640px and identical to the chat/sources bars (the existing `_box_height(page, ".doc-header")` measurement must be pointed at the standard row — use the row-1 selector, e.g. `.app-header .header-inner` inside the viewer header), and assert the titlebar row is present (height > 0). The chat/sources assertions are unchanged.
|
||||||
|
3. `tests/e2e/test_shared_header.py` (UPDATE to the new contract): the "the viewer has no nav — no Sources link in the DOM" assertions flip — the viewer now carries the same nav contract (`.app-nav` with Chat + hidden `#nav-sources` + hidden `#nav-tuning`, revealed for admin). The auth-pair + New chat assertions for the viewer stay (they move from `.doc-header-actions` to the standard bar — update the selectors).
|
||||||
|
4. **Regression pass — each in isolation** (`uv run pytest tests/e2e/<file>.py -v --no-cov`): `test_header_consistency.py`, `test_shared_header.py`, `test_document_back_navigation.py`, `test_document_viewer.py`, `test_steering.py`, `test_global_tuning.py`, `test_sync_button.py`, `test_tuning_nav_link.py`, `test_smoke.py`, `test_chat_rag.py`, `test_admin_auth.py`. Fix fallout in the suites above where the old contract is encoded; fix app code where behavior genuinely changed.
|
||||||
|
5. Full gate: `uv run pytest` (unit + integration), `uv run pytest --cov=app --cov-report=term-missing` (TOTAL unchanged, >90%), `uv run ruff check . && uv run pyright`.
|
||||||
|
6. **UI Structure Check** (AGENTS.md rule 5) on the five headers + the new titlebar: landmarks (`<header>`, `<nav aria-label>`, `<main>`), labels, contrast ≥4.5:1, focus-visible, no CDN (rule 6 — the no-CDN integration test covers it).
|
||||||
|
7. **Commit** (A17): stage only this phase's files (`frontend/**`, `tests/e2e/**`), message `feat(ui): one consistent navbar on every page (TODO.md L3)`, always `--no-gpg-sign`. Move `.agent/phases/todo/34_consistent_navbar/` to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `tests/e2e/test_nav_consistency.py` green **in isolation** — the story gate (A16: one story, one file).
|
||||||
|
- Unit/integration: no new `app/` logic — the existing suite (incl. the no-CDN test) stays green.
|
||||||
|
- Coverage: **>90%** on `app/` — unchanged (no Python change).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_nav_consistency.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] `test_header_consistency.py` + `test_shared_header.py` updated and green; every suite in the task 05 regression list green in isolation.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (>90%).
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 35 — Admin Page to Add / Remove Git Sources
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L4 — "I need a page only the admin can access where I can add and remove git sources for docs"
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
**Context:** Phase 28 introduced git-based sources (`BOR_GIT_SOURCES` env var + `scripts/git_sync.clone_or_pull`) and phase 32 the one-click Sync button (`POST /api/sync`) — but the *list itself* can only be changed by editing `.env` and restarting. This phase makes the list admin-managed: a Postgres-backed table, an admin-only CRUD API, and a dedicated admin page, with the sync pipeline and `import_docs` resolving the effective list from the DB (env var demoted to an empty-table fallback).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Deliver a page **only the admin can access** (`/git-sources.html`, soft-gated like Sources) to **add and remove git sources**, stored in a new `git_sources` table; the Sync button (phase 32) and `import_docs` (phase 28) use the stored list, `BOR_GIT_SOURCES` remains a fallback while the table is empty, and phase 32's fail-loud "no git sources configured" is preserved when both are empty.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `28_git_based_sources` (complete) — `scripts/git_sync.clone_or_pull`, `repo_name`, the `BOR_GIT_SOURCES` settings + `git_source_list`, the import resolution order (`--source` wins).
|
||||||
|
- `32_admin_sync_button` (complete) — the `POST /api/sync` / `GET /api/sync/status` pipeline this phase re-points at the DB list; the Sync button the page's hint refers to.
|
||||||
|
- `16_admin_auth` (complete) — `require_admin` (the router-level pattern from `app/api/sync.py`), the soft-gate page pattern (`sources.html`), the `fetchIsAdmin()` frontend gate.
|
||||||
|
- `34_consistent_navbar` (todo) — the identical five-page header this phase's admin-only "Git sources" nav link plugs into (phase 29 pattern).
|
||||||
|
- `29_tuning_nav_link` (complete) — the admin-only ship-hidden nav-link pattern to copy.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_model_and_migration.md` — `GitSource` model + migration `0006_git_sources.py` (reversible).
|
||||||
|
2. `02_git_sources_api.md` — admin-only `GET/POST /api/git-sources` + `DELETE /api/git-sources/{id}` with validation, the env-fallback listing, and the integration suite.
|
||||||
|
3. `03_sync_and_importer_use_db.md` — `effective_git_sources()` shared by `app/api/sync.py` and `scripts/import_docs.py` (DB wins, env fallback, fail-loud unchanged) + test updates.
|
||||||
|
4. `04_admin_page.md` — `/git-sources.html` + `git-sources.js` (soft-gated, list / add / remove, env note, sync hint) + styles.
|
||||||
|
5. `05_nav_link.md` — the admin-only "Git sources" nav link on all five pages + the `header.js` reveal.
|
||||||
|
6. `06_e2e_and_docs.md` — the story E2E suite `test_git_sources_admin.py`, `test_nav_consistency.py` nav-inventory update, README/`.env.example` notes, regressions, commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: `tests/unit/` for `effective_git_sources` (DB-wins / env-fallback / both-empty); `tests/integration/test_git_sources_api.py` for the CRUD contract (403/201/409/422/404, env fallback); the migration up/down test following the 0004/0005 pattern; the existing `test_sync_api.py` + `test_import_docs_git.py` suites stay green with the resolution indirection.
|
||||||
|
- Coverage: **>90%** on `app/` for the new module + API.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_git_sources_admin.py` — the story gate, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Migration 0006 applied (`uv run alembic upgrade head`); `git_sources` table exists with `url` unique.
|
||||||
|
- [ ] `GET /api/git-sources` (admin) lists DB rows; while the table is empty it returns the env list with `from_env: true`; anonymous gets 403 on all three routes.
|
||||||
|
- [ ] `POST` creates (201, trimmed, shape-validated, 409 duplicate without echoing the URL); `DELETE` removes (204/404).
|
||||||
|
- [ ] `POST /api/sync` and `import_docs` resolve the list via `effective_git_sources` (origin logged `db|env`); both-empty still fails loudly; `--source` override unchanged.
|
||||||
|
- [ ] `/git-sources.html`: anonymous sees the sign-in gate; the admin sees list + add + remove with a never-stale button and inline errors; the admin-only "Git sources" nav link is visible on all five pages for the admin and hidden for anonymous.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` green in isolation; regressions (task 06 list) green.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6).
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **A3 / A13 honoured** — the list lives in Postgres via Alembic (no JSON file, no new store).
|
||||||
|
- **A10 extended per the phase-16 pattern** — a new admin-only router behind `require_admin`; the public API surface stays stateless; no new auth mechanism.
|
||||||
|
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages.
|
||||||
|
- **Env var demoted, not removed** — `BOR_GIT_SOURCES` keeps working exactly as today while the table is empty (the fallback); once the table has rows it is ignored (the UI is the source of truth). Phase 32's fail-loud empty-config behavior is preserved.
|
||||||
|
- **Scope boundary** — adding/removing a repo does NOT immediately clone, import, or prune: the existing Sync button performs that (removal prunes on the next sync, `prune=True`). The page's hint says so.
|
||||||
|
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Task 01 — GitSource model + migration 0006
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add the `git_sources` table (one row per admin-managed repo URL) via the model + a reversible Alembic migration, following the exact conventions of migrations 0003–0005.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/models.py` — add the model (next to `SteeringNote`, docstring citing this phase + the A13 convention):
|
||||||
|
```python
|
||||||
|
class GitSource(Base):
|
||||||
|
"""One admin-managed git source (phase 35).
|
||||||
|
|
||||||
|
The UI-maintained list of repo URLs the Sync button (phase 32) and
|
||||||
|
import_docs (phase 28) clone/pull. DB rows win over the
|
||||||
|
BOR_GIT_SOURCES env var, which is a fallback while this table is
|
||||||
|
empty (see app.rag.git_sources.effective_git_sources).
|
||||||
|
"""
|
||||||
|
__tablename__ = "git_sources"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
added_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
(Import `Text` — already imported in the file; verify.)
|
||||||
|
2. `alembic/versions/0006_git_sources.py` — new migration:
|
||||||
|
- Read `alembic/versions/0005_kb_overview.py` first and chain from its actual `revision` id (the filenames are not the revision ids).
|
||||||
|
- `upgrade()`: `CREATE TABLE git_sources (id UUID PRIMARY KEY, url TEXT NOT NULL, added_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL)` + `CREATE UNIQUE INDEX uq_git_sources_url ON git_sources (url)` (use `sa.Uuid` / the same column types the other migrations use — mirror their style, including `op.create_table` kwargs and the `UniqueConstraint`-vs-index choice 0003/0004 made).
|
||||||
|
- `downgrade()`: drop the index + table.
|
||||||
|
3. Apply it to the dev database: `podman compose up -d db` (if needed) then `uv run alembic upgrade head`.
|
||||||
|
4. Migration test — follow the existing pattern (see how 0004/0005 are integration-tested — `tests/integration/` migration suite): assert 0006 up creates the table + unique constraint and down drops it (round-trip on the test DB).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: the migration up/down test above; `uv run pytest` green overall.
|
||||||
|
- Coverage: model-only for now — the `app/` gate stays >90% (models are thin).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `alembic/versions/0006_git_sources.py` exists, chains off 0005's real revision id, and is reversible.
|
||||||
|
- [ ] `uv run alembic upgrade head` applies cleanly on the dev DB; `git_sources` visible (`\d git_sources` equivalent).
|
||||||
|
- [ ] The 0006 up/down integration test passes; full `uv run pytest` green.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task 02 — Admin-only git sources CRUD API
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The admin CRUD contract for the stored list: `GET /api/git-sources` (DB rows, or the env fallback while the table is empty), `POST /api/git-sources` (validated create), `DELETE /api/git-sources/{id}` — all behind `require_admin`, exactly like `app/api/sync.py`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/schemas.py` — add (mirroring the steering schemas' style):
|
||||||
|
- `GitSourceIn` — `url: str = Field(min_length=1, max_length=500)` + a `mode="before"` trim validator (whitespace-only → 422, same trick as `SteeringNoteIn`).
|
||||||
|
- `GitSourceOut` — `id: uuid.UUID | None`, `url: str`, `added_at: datetime | None` (both nullable: env-fallback rows carry neither).
|
||||||
|
- `GitSourceList` — `sources: list[GitSourceOut]`, `from_env: bool` (`True` only when the table is empty and the list comes from `BOR_GIT_SOURCES`).
|
||||||
|
2. `app/api/git_sources.py` (NEW) — `router = APIRouter(prefix="/git-sources", tags=["git-sources"], dependencies=[Depends(require_admin)])` (copy the sync.py pattern + docstring style):
|
||||||
|
- `GET ""` → `GitSourceList`: DB rows ordered by `(added_at, id)`; if the table is empty → the `get_settings().git_source_list` env URLs as rows with `id=None, added_at=None` and `from_env=True`; `from_env=False` whenever DB rows exist (the env var is then ignored — the phase's locked decision).
|
||||||
|
- `POST ""` (201) → create:
|
||||||
|
- Shape validation (module-level `URL_RE = re.compile(r"^(https?://|ssh://|git@)")` with a docstring): the trimmed URL must match — covers the phase-28 real URLs (HTTPS + `git@` SSH); scp-style `host:repo` is deliberately rejected (422).
|
||||||
|
- ASSUMPTION: the accepted shapes are exactly `http://`, `https://`, `ssh://`, `git@…`; the 422 detail is generic ("not a valid git URL (expected https://, ssh:// or git@…)") and never echoes the input.
|
||||||
|
- Duplicate (same trimmed URL already stored) → 409 with a generic detail ("a git source with this URL already exists") — **never echo the URL** (URLs may embed `user:pass@` credentials; phase 32's masking discipline).
|
||||||
|
- Success → insert, commit, return the created `GitSourceOut`.
|
||||||
|
- `DELETE "/{source_id}"` → 204; unknown id → 404 `git source not found`.
|
||||||
|
3. `app/main.py` — register the router alongside the existing `include_router` calls (check how `sync` is included and mirror it).
|
||||||
|
4. `tests/integration/test_git_sources_api.py` (NEW) — follow `tests/integration/test_sync_api.py`'s auth pattern (`_login` via `POST /api/login` with the fixture admin password, admin client as context manager):
|
||||||
|
- Anonymous → 403 `{"detail": "admin only"}` on GET, POST, and DELETE.
|
||||||
|
- Admin + empty table + env set (monkeypatch the settings `git_sources`) → GET returns the env rows, `from_env=True`, null ids.
|
||||||
|
- Admin + empty table + env empty → GET returns `sources=[]`, `from_env=True`.
|
||||||
|
- POST: valid `https://…` → 201 + the row appears in GET with `from_env` now `False`; a duplicate → 409 and the detail contains no URL; an invalid shape (`not a url`, `host:repo`) → 422; whitespace-only / >500 chars → 422; a `git@github.com:…` URL → 201 (accepted).
|
||||||
|
- DB rows win over env: seed a row AND set the env → GET returns only the DB rows, `from_env=False`.
|
||||||
|
- DELETE: known id → 204 + gone from GET (back to env fallback if the table is now empty); unknown id → 404.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: the suite above — the full CRUD contract, auth split, fallback semantics.
|
||||||
|
- Coverage: **>90%** on the new module (`app/api/git_sources.py` + schemas).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All three routes exist under `/api/git-sources`, admin-only (403 anonymous), registered in `app/main.py`.
|
||||||
|
- [ ] `tests/integration/test_git_sources_api.py` green; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||||
|
- [ ] No credential-echo path: 409/422 details never contain the submitted URL (a test asserts this).
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Task 03 — Sync + import_docs resolve the effective list (DB wins, env fallback)
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
One shared resolver — DB rows win, `BOR_GIT_SOURCES` is a fallback only while the table is empty, fail-loud unchanged when both are empty — used by **both** the in-app sync pipeline (`app/api/sync.py::_run_sync`) and the CLI (`scripts/import_docs.py`), so the admin page's list is what actually gets cloned and indexed.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/git_sources.py` (NEW) — the single resolver (importable by both the app and the CLI — `scripts` already imports `app.rag.*`):
|
||||||
|
```python
|
||||||
|
def effective_git_sources(db: Session) -> tuple[list[str], Literal["db", "env"]]:
|
||||||
|
"""(urls, origin) — DB rows in (added_at, id) order win; while the
|
||||||
|
table is empty the BOR_GIT_SOURCES env list is the fallback; both
|
||||||
|
empty → ([], "env")."""
|
||||||
|
```
|
||||||
|
- DB rows: `select(GitSource).order_by(GitSource.added_at, GitSource.id)`.
|
||||||
|
- Fallback: `get_settings().git_source_list` (the phase-28 CSV parse — reuse, don't re-implement).
|
||||||
|
2. `app/api/sync.py` — `_run_sync` replaces `settings.git_source_list` with the resolver:
|
||||||
|
- Open a `SessionLocal()` (close in `finally`) around the resolution — the background task has no request session.
|
||||||
|
- Log the origin: the existing `sync: started repos=N` line gains `origin=db|env`.
|
||||||
|
- Both-empty: keep the fail-loud `GitSyncError("no git sources configured …")` (extend the message to mention both origins — e.g. `(git_sources table empty and BOR_GIT_SOURCES unset)`; if `tests/integration/test_sync_api.py::test_no_git_sources_configured_fails_loudly` asserts the old text, update that expectation — it is this phase's file to update).
|
||||||
|
3. `scripts/import_docs.py` — the git-URL resolution branch (today `settings.git_source_list`) resolves via the same function: open a short `SessionLocal()` at resolution time (the import needs the DB anyway — no DB-down fallback to design). The `--source` override still wins (manual mode), the log line records the origin (`git sources: N repo(s) origin=db|env` before the clone loop).
|
||||||
|
4. **Test updates:**
|
||||||
|
- `tests/unit/` — NEW unit tests for `effective_git_sources`: DB rows win (seeded table + env set → DB list, origin `db`); env fallback (empty table + env set); both empty → `([], "env")`. (Use a test DB session or a stubbed session following the existing unit-test conventions.)
|
||||||
|
- `tests/integration/test_sync_api.py` — where it stubs `settings.git_source_list` to drive sync scenarios, keep those scenarios working through the new indirection: either seed the `git_sources` table or monkeypatch `effective_git_sources` (whichever the file's existing fixture style favors); add one scenario asserting a DB row is used over the env when both are set (clone/import mocked — the file already mocks them).
|
||||||
|
- `tests/integration/test_import_docs_git.py` — same treatment for the CLI path (`--source` override scenario untouched); add the DB-over-env scenario at the CLI level.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `effective_git_sources` — all three branches.
|
||||||
|
- Integration: sync + import_docs suites green with the resolver in the path, incl. the new DB-over-env scenarios and the updated fail-loud expectation.
|
||||||
|
- Coverage: **>90%** on the new module + the modified call sites.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `effective_git_sources` exists in `app/rag/git_sources.py` and is the ONLY place (besides the API's GET fallback, which may call it too) that combines DB + env.
|
||||||
|
- [ ] `_run_sync` and `import_docs` both resolve through it; origin visible in their logs.
|
||||||
|
- [ ] Both-empty still raises the fail-loud error (sync) / the CLI's existing no-sources behavior (import_docs) — assertions kept/updated.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Task 04 — The /git-sources.html admin page
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The page itself: a soft-gated (anonymous → sign-in gate, exactly like `sources.html`) full-width manager with an add form, a sources list with per-row Remove, an env-fallback note, and a hint pointing at the Sync button. Never-stale buttons, inline errors, WCAG 2.1 AA basics, dark tech theme.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/git-sources.html` (NEW) — modeled on `frontend/sources.html` (same frame, same gate pattern, the standard full header from phase 34):
|
||||||
|
- Head: same meta/favicon/stylesheet pattern; `<title>Git sources · Brain of Reese</title>`; skip-link; the identical header block (nav [Chat, Sources, Tuning] + Tuning toggle + `#sync-btn` + New chat + auth pair — the "Git sources" nav link itself arrives in task 05, so this file lands without it for now).
|
||||||
|
- `<main id="main">` — `#steering-panel` first (phase 34 contract), then:
|
||||||
|
- **Gate** `#git-sources-gate` — the `#sources-gate` soft-gate markup pattern (sign-in card + link `/login.html?next=/git-sources.html`), visible for anonymous, hidden for admin.
|
||||||
|
- **Content** `#git-sources-content` (hidden until admin):
|
||||||
|
- `.page-head` — `<h1>Git sources</h1>` + sub: "The repositories the Sync button clones and indexes. Add or remove them here — no `.env`, no restart."
|
||||||
|
- **Env note** `#git-sources-env-note` (hidden by default; shown when the API returns `from_env: true`): "These sources currently come from `BOR_GIT_SOURCES` in `.env` — adding or removing one here switches management to the database."
|
||||||
|
- **Add form** `#git-source-form` — visible label (or visually-hidden label per the tuning-page pattern — use a visible `<label for="git-source-url">Add a git source</label>`), input `#git-source-url` (type text, `maxlength="500"`, `autocomplete="off"`, placeholder `https://github.com/you/homelab.git`, `required`), submit button `#git-source-add` ("Add source"), error line `#git-source-error` (`role="alert"`, hidden) — §7.4 never-stale: the button disables + label changes while the POST is in flight, re-enables on success/failure (the form is kept on failure, same as the tuning forms).
|
||||||
|
- **List** `#git-sources-list` (a full-width table or list per §7.1 — **no skinny single-column list**: use the Sources-page table pattern — columns: URL (mono `<code>`), Added, actions) + empty state `#git-sources-empty` ("No git sources stored yet." — and, with `from_env`, the env note already explains where the active list comes from).
|
||||||
|
- **Hint box** (`role="note"`): "Use the **Sync sources** button in the header (or on the Sources page) to clone the repos and refresh the index — removing a repository prunes its documents from the index on the next sync."
|
||||||
|
2. `frontend/assets/git-sources.js` (NEW) — the page module (loaded `type="module"`, imports `./header.js` like its siblings):
|
||||||
|
- Boot: `const admin = await initSharedHeader()` (one cached whoami) — anonymous → show the gate, stop; admin → hide the gate, `loadSources()`.
|
||||||
|
- `loadSources()` — `GET /api/git-sources` → render the table rows (`textContent` only — URLs may contain credentials; never innerHTML the URL), the added date (localized, `—` for null), the per-row Remove button (`.git-source-remove`, `aria-label="Remove git source: <url>"`), the env note's `hidden` on `from_env`, the empty state. Non-2xx → the content area shows a `role="alert"` error state with a retry (never a stuck page).
|
||||||
|
- Add submit — client-side non-empty check; disable `#git-source-add` (label "Adding…"); `POST /api/git-sources` with `{url}`; success → clear the input, re-enable (label "Add source"), `loadSources()`, focus the new row (a11y); failure → `#git-source-error` with the server detail (422 shape-aware like the tuning forms), re-enable, input kept.
|
||||||
|
- Remove click — `window.confirm("Remove this git source from the list? Its documents stay indexed until the next sync prunes them.")` — cancel → nothing; ok → disable the row button, `DELETE /api/git-sources/{id}`, `loadSources()`; failure → row error state + re-enable.
|
||||||
|
- Focus management + keyboard: all controls focus-visible (theme CSS covers it), the list rows' buttons are real `<button>`s.
|
||||||
|
3. `frontend/assets/styles.css` — the `.git-source-*` rules + gate reuse:
|
||||||
|
- The table: full-width in the 72rem container (the Sources-page table styles are a good starting point — reuse classes where they fit), mono URL cells with horizontal scroll on overflow (long URLs with credentials), rows ≥44px touch targets, `:focus-visible` 3px outline.
|
||||||
|
- Env note: an info chip in the theme palette (brand-soft `#232b52` surface, brand-ink `#a5b4fc` text ≈6.9:1); hint box: the page-sub styling family; error/alert states reuse the existing `#fca5a5`/`#2d1318` error treatment.
|
||||||
|
- Gate: reuse the `#sources-gate` styles (the page is the same shape as Sources — one gate visual language).
|
||||||
|
4. `frontend/assets/header.js` — no change (the page's controls are the standard shared ones; `initSharedHeader` already handles everything that ships in the header).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Frontend-only — no Python change this task; the no-CDN integration test must still pass (same-origin markup; the E2E in task 06 exercises the page).
|
||||||
|
- Coverage: `app/` gate unaffected.
|
||||||
|
- Manual smoke (dev server, signed in): add a real-looking URL → row appears; remove → confirm → row gone; invalid URL → inline 422 error, button re-enabled; signed out → gate only.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `/git-sources.html` served at the route; anonymous sees only the gate (list/form absent or inert); admin sees list + form + env note + hint.
|
||||||
|
- [ ] Add / remove round-trip works against the task-02 API; every in-flight state disables its control and re-enables on resolution (never stale); errors are inline `role="alert"`.
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5): landmarks, labeled controls, contrast ≥4.5:1, focus-visible; full-width table (no skinny list); no CDN (rule 6).
|
||||||
|
- [ ] `uv run pytest` green (no-CDN test); `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 05 — Admin-only "Git sources" nav link on all five pages
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Make the new page reachable from everywhere: the admin-only **"Git sources"** nav link in all five identical headers (phase 34's contract), revealed for the admin by `header.js` — the exact phase-29 pattern.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. The five page headers — `frontend/index.html`, `frontend/sources.html`, `frontend/document.html`, `frontend/tuning.html`, `frontend/git-sources.html` — inside `<nav class="app-nav" aria-label="Primary">`, **immediately after** the `#nav-sources` link, add (mirroring the `#nav-tuning` markup, comment citing this phase + owner permission 2026-08-26):
|
||||||
|
```html
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
|
```
|
||||||
|
- Rules (phase-29 contract): `hidden` by default on every page (anonymous-safe ship-hidden); NO `is-active` / `aria-current` on the four pages that aren't the Git sources page.
|
||||||
|
- **Exception:** on `frontend/git-sources.html` the link carries `class="nav-link is-active"` + `aria-current="page"` (the current page, like Tuning on `tuning.html`).
|
||||||
|
- Nav order on every page becomes: Chat, Sources, **Git sources**, Tuning.
|
||||||
|
2. `frontend/assets/header.js` — next to the `navTuning` reveal block, add the same ship-hidden/reveal-for-admin contract:
|
||||||
|
```js
|
||||||
|
const navGitSources = document.querySelector("#nav-git-sources");
|
||||||
|
if (navGitSources) navGitSources.hidden = !admin;
|
||||||
|
```
|
||||||
|
Update the file-header comment (the admin-only link list now includes Git sources).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Frontend-only — no Python change; the no-CDN integration test is unaffected (same-origin `<a>`).
|
||||||
|
- Coverage: `app/` gate unaffected.
|
||||||
|
- Manual smoke: admin sees "Git sources" on all five pages → each navigates to `/git-sources.html` (with `is-active` there); anonymous never sees it (ships hidden, no flash).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All five headers contain `#nav-git-sources` after `#nav-sources`, `hidden` by default; `git-sources.html`'s carries `is-active` + `aria-current="page"`.
|
||||||
|
- [ ] `header.js` reveals it for the admin on the cached whoami (no extra request) and hides it for anonymous on every page.
|
||||||
|
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 06 — Story E2E + nav-inventory update + docs + commit
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **Source:** `TODO.md:4 — "I need a page only the admin can access where I can add and remove git sources for docs"`
|
||||||
|
**Story:** `.agent/user_stories/git-sources-admin.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the story end-to-end with its dedicated Playwright suite, keep phase 34's nav-consistency contract in sync with the new link, document the env-var demotion, and close the phase with the full gate + one commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_git_sources_admin.py` (NEW — the story gate, run in isolation). Fixtures: the standard E2E app + DB (`tests/e2e/conftest.py`); admin session via `tests/e2e/auth_helpers.py`; **no git, no network** — this suite is UI + API only (the clone/import path is mocked at the integration level, task 03).
|
||||||
|
- **Anonymous:** `/git-sources.html` shows the sign-in gate (list + add form absent/inert); `#nav-git-sources` hidden on the five pages; the API 403s (assert via the page context's `fetch` or `context.request` — follow `test_admin_auth.py`'s pattern for anonymous API assertions).
|
||||||
|
- **Admin — nav:** `#nav-git-sources` visible on all five pages; clicking it from `/` lands on `/git-sources.html` with the link `is-active`.
|
||||||
|
- **Admin — list:** seed two rows via the API (or `SessionLocal`) before load → both rows render (mono URL text, added date); the env note is hidden (DB rows exist).
|
||||||
|
- **Admin — add:** submit `https://example.com/reese/new-repo.git` → the row appears, the input clears, the button re-enables (never stale); submit a duplicate → inline `role="alert"` error, no new row, button re-enabled; submit `not a valid url` → inline 422 error, button re-enabled.
|
||||||
|
- **Admin — remove:** click a row's Remove → accept the confirm dialog (Playwright `page.on("dialog")`) → the row disappears; cancel a second removal → the row stays.
|
||||||
|
- **Admin — env fallback:** truncate `git_sources`, set the E2E app's `BOR_GIT_SOURCES` (follow how `test_sync_button.py` controls the env on the app fixture), reload → the env rows render + `#git-sources-env-note` visible.
|
||||||
|
- The sync-origin behavior (DB over env in the pipeline) is integration-level (task 03) — do not trigger a real sync in this suite.
|
||||||
|
2. `tests/e2e/test_nav_consistency.py` (phase 34 — UPDATE): the admin nav inventory now includes **"Git sources"** (four links, order Chat, Sources, Git sources, Tuning); the anonymous hidden set gains `#nav-git-sources`; the per-page inventory comparison stays order-sensitive.
|
||||||
|
3. **Docs:**
|
||||||
|
- `.env.example` — the `BOR_GIT_SOURCES` comment: now the **empty-table fallback**; the primary management UI is the admin Git sources page (phase 35).
|
||||||
|
- `README.md` — the import/update workflow section: the git-sources list is managed on the admin page (stored in Postgres); `BOR_GIT_SOURCES` only applies while that list is empty; `--source` still overrides for manual runs.
|
||||||
|
4. **Regression pass — each in isolation** (`uv run pytest tests/e2e/<file>.py -v --no-cov`): `test_git_sources_admin.py` (new), `test_nav_consistency.py` (updated), `test_sync_button.py`, `test_shared_header.py`, `test_header_consistency.py`, `test_tuning_nav_link.py`, `test_smoke.py`.
|
||||||
|
5. Full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`.
|
||||||
|
6. **UI Structure Check** (AGENTS.md rule 5) on the new page (full-width table, labels, contrast, focus-visible, aria-live on the list updates) + no CDN (rule 6).
|
||||||
|
7. **Commit** (A17): stage this phase's files (`app/**`, `alembic/**`, `frontend/**`, `tests/**`, `README.md`, `.env.example`), message `feat(sources): admin page to add and remove git sources (TODO.md L4)`, always `--no-gpg-sign`. Move `.agent/phases/todo/35_git_sources_admin/` to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `tests/e2e/test_git_sources_admin.py` green **in isolation** (A16: one story, one file).
|
||||||
|
- Unit/integration: from tasks 01–03 — all green under `uv run pytest`.
|
||||||
|
- Coverage: **>90%** on `app/` (new API module + resolver fully covered).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] `test_nav_consistency.py` updated for the fourth nav link and green; every suite in the task 06 regression list green in isolation.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] README + `.env.example` document the fallback semantics.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 36 — Document Summary Shown Together With the Original
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L5 — "When I click on a document with a summary I should be able to see the summary and the original document together."
|
||||||
|
**Story:** `.agent/user_stories/summary-in-viewer.md`
|
||||||
|
**Context:** Phase 30 stores a lite-model summary on `documents.summary` for every non-markdown document (plus an indexed `is_summary` chunk) — but the viewer never shows it: `GET /api/documents/content` omits the field and the shared renderer `renderDocument` (`frontend/assets/document.js`, used by BOTH the full-page viewer `document.html` and the chat/sources modal, phase 26) only renders the raw content. Markdown documents carry no summary (phase 30) and must render exactly as before.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
When a document **has** a summary, show it and the original content together — a labeled Summary panel above the content, on both viewer surfaces at once (shared renderer); documents without a summary are unchanged.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `30_document_summaries` (complete) — the `documents.summary` column (migration 0004), the summarizer, the `summary_kb` E2E fixture + the deterministic mock `SUMMARY_MODE` digest.
|
||||||
|
- `10_story_document_viewer` + `26_document_modal_viewer` (complete) — the viewer page, the modal, and the shared `renderDocument(doc, {titleEl, metaEl, contentEl})` core both surfaces render through.
|
||||||
|
- `16_admin_auth` (complete) — the soft rule this phase must not touch: the content endpoint stays public + stateless (catalog gated, viewer public).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_content_api_summary_field.md` — `DocContent.summary` + the endpoint returns it; integration tests.
|
||||||
|
2. `02_viewer_summary_panel.md` — the shared renderer draws the `.doc-summary` panel (both surfaces) + theme-matched styles.
|
||||||
|
3. `03_e2e_and_regression.md` — the story E2E suite `test_summary_in_viewer.py`; regressions; full gate; commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: the content endpoint returns the summary for a summarized non-markdown doc and `null` for a markdown doc; anonymous access unchanged.
|
||||||
|
- Coverage: **>90%** on `app/` (the touched endpoint stays covered).
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_summary_in_viewer.py` — the story gate, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `GET /api/documents/content` returns `summary` (string or null); no auth/shape change beyond the added nullable field; the endpoint is still public.
|
||||||
|
- [ ] A summarized document shows the labeled Summary panel **above** the original content in the full-page viewer AND the modal; the original content (including content the summary digest doesn't contain) is fully visible.
|
||||||
|
- [ ] A markdown document (no summary) renders exactly as before on both surfaces — no empty panel.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov` green in isolation; regressions (task 03 list) green.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5): the panel is a labeled section, contrast ≥4.5:1, no CDN (rule 6).
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **A7 / A15 untouched** — retrieval, context assembly, and the SSE contract are unchanged; this is a display + API-field phase.
|
||||||
|
- **Phase 16 soft rule untouched** — `GET /api/documents/content` stays public + stateless (anyone who can open a document sees its summary; the catalog stays admin-gated).
|
||||||
|
- **Phase 30 untouched** — summaries are still generated at import, still markdown-excluded, still fail-soft (NULL possible); this phase only surfaces the existing field.
|
||||||
|
- **Shared-renderer principle (phase 26)** — the panel is drawn in `renderDocument`, so the page and the modal can never drift.
|
||||||
|
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages; summary text rendered with `textContent` (XSS contract unchanged).
|
||||||
|
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 01 — The content endpoint returns the summary
|
||||||
|
|
||||||
|
**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
|
||||||
|
**Story:** `.agent/user_stories/summary-in-viewer.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Surface the existing `documents.summary` field (phase 30) on the viewer's data contract: `DocContent` gains a nullable `summary` and `GET /api/documents/content` returns it — endpoint stays public, stateless, and otherwise byte-identical.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/schemas.py` — `DocContent` gains:
|
||||||
|
```python
|
||||||
|
#: Lite-model summary (phase 30) — non-markdown A9 docs only; None for
|
||||||
|
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
|
||||||
|
#: summary generation failed but the document was still indexed.
|
||||||
|
summary: str | None = None
|
||||||
|
```
|
||||||
|
(Place it after `format` / before `content`, with the docstring mirroring `Document.summary`'s.)
|
||||||
|
2. `app/api/docs.py` — `get_document_content` returns `summary=doc.summary` in the `DocContent(...)` construction. Nothing else changes (no auth, no query change — `Document` is already selected in full).
|
||||||
|
3. Integration tests — extend the existing `/api/documents/content` test module (find it in `tests/integration/` — the phase-10 content-endpoint tests):
|
||||||
|
- A non-markdown document row seeded with `summary="…"` → response JSON carries `summary` verbatim.
|
||||||
|
- A markdown document row with `summary=None` → `"summary": null`.
|
||||||
|
- Anonymous (no admin cookie) still gets 200 (the phase-16 soft rule — public viewer) for both.
|
||||||
|
- The existing assertions (404 on unknown pair, content/format fields) stay green unmodified.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: the cases above; `uv run pytest` green overall.
|
||||||
|
- Coverage: **>90%** on `app/` — the touched endpoint stays covered (the new field is exercised by the new assertions).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `GET /api/documents/content?source=…&path=…` returns the summary for a summarized doc and `null` for a markdown doc; anonymous access unchanged (200).
|
||||||
|
- [ ] No other field, status code, or auth behavior of the endpoint changed.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Task 02 — The shared renderer draws the Summary panel (both surfaces)
|
||||||
|
|
||||||
|
**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
|
||||||
|
**Story:** `.agent/user_stories/summary-in-viewer.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`renderDocument` — the single rendering core both the full-page viewer (`document.html`) and the chat/sources modal (`document-modal.js`) go through (phase 26) — draws a labeled Summary panel above the original content whenever `doc.summary` is non-empty; `null`/empty renders nothing, so markdown documents and fail-soft rows are byte-for-byte unchanged.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/document.js` — in `renderDocument(doc, { titleEl, metaEl, contentEl })`, **after** the meta row is populated and **before** the content is built, append the summary section to `contentEl` (which is then filled with the usual `.doc-md` / `<pre class="doc-raw">` content after it):
|
||||||
|
```js
|
||||||
|
if (doc.summary && doc.summary.trim() !== "") {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "doc-summary";
|
||||||
|
section.setAttribute("aria-label", "Summary");
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.className = "doc-summary-title";
|
||||||
|
title.textContent = "Summary";
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.className = "doc-summary-text";
|
||||||
|
body.textContent = doc.summary; // text node — XSS contract unchanged
|
||||||
|
section.append(title, body);
|
||||||
|
contentEl.appendChild(section);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- The meta badge row (`#doc-meta` / `#doc-modal-meta`) is untouched; the panel sits between meta and content on **both** surfaces because both call this one function.
|
||||||
|
- Update the file-header comment (the renderer now also owns the optional summary panel) and the `renderDocument` doc comment.
|
||||||
|
2. `frontend/assets/styles.css` — `.doc-summary` (dark tech theme, phase-08 palette):
|
||||||
|
- A clearly-distinct "summary, not content" look: surface `#121a2e` with a 3px brand left border (`#6d78f2`) or a brand-soft (`#232b52`) header strip — pick one and keep it simple; `border-radius` matching the existing content cards; padding ~1rem; `margin-bottom` separating it from the content.
|
||||||
|
- `.doc-summary-title` — small-caps/label treatment: `#a5b4fc` (brand-ink on the brand-soft chip, ≈6.9:1) or `#a5b4fc` on surface (verify ≥4.5:1 — if short, use the chip).
|
||||||
|
- `.doc-summary-text` — `var(--ink)` (`#e8ebf4`) on the surface (≈14.5:1); wraps inside the same width the content uses (the ≤46rem centered column for md docs, the raw-content width otherwise — the panel is a child of `contentEl`, so it inherits the column; verify for the `<pre class="doc-raw">` case where the content is wider).
|
||||||
|
- No animation (nothing for `prefers-reduced-motion` to still); the section is static content — no focusability needed (it carries `aria-label` + heading).
|
||||||
|
3. Do NOT touch `document-modal.js` (it calls `renderDocument` — the panel comes for free), `markdown.js`, or the page scripts.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Frontend-only — no Python change; the no-CDN integration test is unaffected.
|
||||||
|
- Coverage: `app/` gate unaffected.
|
||||||
|
- Manual smoke (dev server with an imported non-md doc, e.g. a yaml from the fixture KB via the importer + mock, or a hand-seeded `documents.summary` row): modal from the Sources table AND the full page both show panel + content; a markdown doc shows no panel.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] A document with a summary shows the labeled panel above the original content in **both** the modal and the full-page viewer; the original content is fully visible (nothing hidden/collapsed).
|
||||||
|
- [ ] A document without a summary (`null` or empty/whitespace) renders exactly as before on both surfaces — no panel, no empty box.
|
||||||
|
- [ ] The summary text is written with `textContent` (XSS contract); the meta row is unchanged.
|
||||||
|
- [ ] `uv run pytest` green (no-CDN test); `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Task 03 — Story E2E + regression pass + commit
|
||||||
|
|
||||||
|
**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
|
||||||
|
**Story:** `.agent/user_stories/summary-in-viewer.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the story end-to-end with its dedicated Playwright suite — summary panel + original content visible together on both surfaces, no panel for markdown docs — then the full gate + one commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_summary_in_viewer.py` (NEW — the story gate, run in isolation). Reuse `tests/e2e/test_document_summaries.py`'s machinery as closely as possible (its fixture KB + import helpers are the reference):
|
||||||
|
- Fixtures: the standard E2E app + DB + the deterministic mock LLM (`tests/e2e/mock_llm.py` — its `SUMMARY_MODE` answer is a byte-stable 24-token digest); import `tests/fixtures/summary_kb/` so `quadlet/qwen-llamacpp.yaml` gets its stored summary + `notes/qwen-llamacpp-notes.md` stays summary-less (the existing tail sentinel `RESE-SUMMARY-SENTINEL-7f3a` sits on the yaml's LAST line — **outside** the 24-token digest, so it is a marker for "the original, not the summary").
|
||||||
|
- **Full page:** open `/document.html?source=summary_kb&path=quadlet%2Fqwen-llamacpp.yaml` → `.doc-summary` visible with the deterministic digest text AND the original content visible with the sentinel (`RESE-SUMMARY-SENTINEL-7f3a` present in the rendered content) — summary and original **together**.
|
||||||
|
- **Modal:** from the Sources table (admin session via `tests/e2e/auth_helpers.py`), click the yaml's row → the modal shows the same panel + content (sentinel present, digest present); then "Full page" still lands on the dedicated page with the panel (the two surfaces agree).
|
||||||
|
- **No-summary control:** the markdown doc (`notes/qwen-llamacpp-notes.md`) → no `.doc-summary` element on the full page and in the modal; the content renders as before.
|
||||||
|
- **API shape (cheap, via the page context's `fetch` or `context.request`):** `GET /api/documents/content` for the yaml carries `summary` (string), for the md doc `null`; anonymous fetch → 200 (soft rule unchanged).
|
||||||
|
- The E2E must not depend on a real LLM (the mock's digest is deterministic — the same pattern `test_document_summaries.py` relies on).
|
||||||
|
2. **Regression pass — each in isolation** (`uv run pytest tests/e2e/<file>.py -v --no-cov`): `test_summary_in_viewer.py` (new), `test_document_viewer.py`, `test_document_summaries.py`, `test_document_back_navigation.py`, `test_chat_rag.py` (the source-chip modal path), `test_smoke.py`.
|
||||||
|
3. Full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`.
|
||||||
|
4. **UI Structure Check** (AGENTS.md rule 5): the panel is a labeled section (`aria-label` + heading), theme contrast ≥4.5:1, it does not break the centered 46rem chat-column-width content layout, no CDN (rule 6 — the no-CDN integration test covers it).
|
||||||
|
5. **Commit** (A17): stage this phase's files (`app/schemas.py`, `app/api/docs.py`, `frontend/assets/document.js`, `frontend/assets/styles.css`, `tests/**`), message `feat(viewer): show document summary together with the original (TODO.md L5)`, always `--no-gpg-sign`. Move `.agent/phases/todo/36_summary_in_viewer/` to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `tests/e2e/test_summary_in_viewer.py` green **in isolation** (A16: one story, one file).
|
||||||
|
- Unit/integration: from task 01 — all green under `uv run pytest`.
|
||||||
|
- Coverage: **>90%** on `app/` (the endpoint change is covered).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] Every suite in the task 03 regression list green in isolation.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Story: Admin Page to Add / Remove Git Sources
|
||||||
|
|
||||||
|
**Phase:** `35_git_sources_admin` · **E2E:** `tests/e2e/test_git_sources_admin.py`
|
||||||
|
|
||||||
|
## Narrative
|
||||||
|
|
||||||
|
As **the admin (owner)**, today I manage the git repos that feed the
|
||||||
|
knowledge base by editing `BOR_GIT_SOURCES` in `.env` and restarting. I
|
||||||
|
want a page only I can access where I can **add and remove git
|
||||||
|
sources** — no env file, no restart — and have the Sync button
|
||||||
|
(phase 32) and `import_docs` (phase 28) use that stored list.
|
||||||
|
|
||||||
|
- **Given** I am signed in as admin
|
||||||
|
- **When** I open the Git sources page and add a repo URL (or remove
|
||||||
|
one)
|
||||||
|
- **Then** the change is stored in Postgres (`git_sources` table) and
|
||||||
|
the next sync / import uses the stored list — the
|
||||||
|
`BOR_GIT_SOURCES` env var remains only a fallback while the table is
|
||||||
|
empty, and phase 32's fail-loud "no git sources configured" is
|
||||||
|
preserved when both are empty.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. Migration 0006: `git_sources` (id UUID PK, url TEXT UNIQUE NOT
|
||||||
|
NULL, added_at TIMESTAMPTZ) — reversible, integration-tested
|
||||||
|
up/down (existing migration-test pattern).
|
||||||
|
2. Admin-only API (phase 16 pattern — 403 `admin only` anonymous):
|
||||||
|
`GET /api/git-sources` (DB rows in added order; while the table is
|
||||||
|
empty, the env list with `from_env: true`), `POST /api/git-sources`
|
||||||
|
{url} (trimmed, 1–500 chars, `https?://` / `ssh://` / `git@`
|
||||||
|
shape, 409 on duplicate with a detail that never echoes the URL),
|
||||||
|
`DELETE /api/git-sources/{id}` (204 / 404).
|
||||||
|
3. `effective_git_sources()` shared by `app/api/sync.py::_run_sync`
|
||||||
|
and `scripts/import_docs.py`: DB rows win, env fallback only while
|
||||||
|
the table is empty, fail-loud unchanged when both are empty; the
|
||||||
|
sync log line records the list origin (`origin=db|env`).
|
||||||
|
4. `/git-sources.html` — soft-gated like the Sources page (anonymous:
|
||||||
|
sign-in gate) with the standard header (phase 34) and an
|
||||||
|
admin-only "Git sources" nav link on all five pages (phase 29
|
||||||
|
pattern): sources list (mono URL + added date + per-row Remove),
|
||||||
|
an add form (labeled input + Add, inline error, never-stale button
|
||||||
|
per §7.4), an env-fallback note while the table is empty, and a
|
||||||
|
hint that the KB itself updates via the Sync button.
|
||||||
|
5. `.env.example` + README: `BOR_GIT_SOURCES` documented as the
|
||||||
|
empty-table fallback; the admin page is the primary management UI.
|
||||||
|
6. Unit + integration green, `app/` coverage >90%, story E2E green in
|
||||||
|
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
|
||||||
|
|
||||||
|
## Playwright Mapping Rule
|
||||||
|
`tests/e2e/test_git_sources_admin.py` — one story, one file, run in
|
||||||
|
isolation: anonymous gets the sign-in gate (and 403s on the API); the
|
||||||
|
admin adds/removes sources on the page (the list updates live, invalid
|
||||||
|
input gets an inline error) and reaches the page via the "Git
|
||||||
|
sources" nav link on every page.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Story: One Navbar on Every Page
|
||||||
|
|
||||||
|
**Phase:** `34_consistent_navbar` · **E2E:** `tests/e2e/test_nav_consistency.py`
|
||||||
|
|
||||||
|
## Narrative
|
||||||
|
|
||||||
|
As **any user (admin or anonymous)**, I move between the Chat, Sources,
|
||||||
|
Document viewer, Global Tuning, and Sign in pages. Today the header bar
|
||||||
|
is only similar between pages: the document viewer replaces it with a
|
||||||
|
back + title bar (every nav button vanishes), the login page misses the
|
||||||
|
Tuning link, and the functional controls are page-scoped — the Tuning
|
||||||
|
steering toggle lives on chat only, the Sync sources button on Sources
|
||||||
|
only — so buttons pop in and out as I switch tabs. I want ONE identical
|
||||||
|
bar on every page.
|
||||||
|
|
||||||
|
- **Given** any of the app's five pages
|
||||||
|
- **When** I look at the header
|
||||||
|
- **Then** I see the same controls in the same order — brand, nav
|
||||||
|
[Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New
|
||||||
|
chat, and exactly one of Sign in / Sign out — with none missing
|
||||||
|
because of which page I happen to be on.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. `frontend/assets/header.js` is the single owner of the functional
|
||||||
|
header controls: the steering toggle + panel logic (moved from
|
||||||
|
`app.js`, exported `refreshSteering()`), the Sync button state
|
||||||
|
machine (moved from `sources.js`, dispatching `bor:sync-status`),
|
||||||
|
one New chat binding (chat page: `bor:new-chat` event; other pages:
|
||||||
|
clear + navigate), and the sign-in `?next=` rewrite to the current
|
||||||
|
page. No control logic remains in the page scripts.
|
||||||
|
2. All five pages (`index.html`, `sources.html`, `document.html`,
|
||||||
|
`tuning.html`, `login.html`) ship the identical header markup; the
|
||||||
|
`#steering-panel` section exists on every page (chat: after
|
||||||
|
`#kb-banner` as today; other pages: first child of `<main>`).
|
||||||
|
3. The document viewer keeps its back link + title in a second
|
||||||
|
titlebar row below the standard bar (phase 13 back-target behavior
|
||||||
|
unchanged); the pinned 64px / 58px height contract applies to the
|
||||||
|
standard row on every page.
|
||||||
|
4. The locked A10 UI revision is preserved: admin-only controls
|
||||||
|
(Sources / Tuning nav links, Sync button) ship hidden and are
|
||||||
|
revealed only for a signed-in admin — for the admin every button is
|
||||||
|
active on every page; anonymous visitors get the reduced bar,
|
||||||
|
identically on every page (owner confirmation 2026-08-26).
|
||||||
|
5. The chat page's per-bubble Tune button + inline form (phases 15/27)
|
||||||
|
and the Sources page's sync result line + error banner (phase 32)
|
||||||
|
keep working exactly as before.
|
||||||
|
6. `tests/e2e/test_nav_consistency.py` green in isolation;
|
||||||
|
`test_header_consistency.py` / `test_shared_header.py` updated to the
|
||||||
|
new viewer contract; regressions green; `app/` coverage >90%
|
||||||
|
(frontend-only phase — unchanged); ruff + pyright clean; one
|
||||||
|
`--no-gpg-sign` commit.
|
||||||
|
|
||||||
|
## Playwright Mapping Rule
|
||||||
|
`tests/e2e/test_nav_consistency.py` — one story, one file, run in
|
||||||
|
isolation. It asserts, per role (admin, anonymous), that the visible
|
||||||
|
header control inventory is identical on all five pages, and that the
|
||||||
|
viewer's back link + titlebar row survive the change.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Story: Summary + Original Document Together in the Viewer
|
||||||
|
|
||||||
|
**Phase:** `36_summary_in_viewer` · **E2E:** `tests/e2e/test_summary_in_viewer.py`
|
||||||
|
|
||||||
|
## Narrative
|
||||||
|
|
||||||
|
As **any user**, phase 30 gives every non-markdown document a
|
||||||
|
lite-model summary (stored on `documents.summary`, indexed as an
|
||||||
|
`is_summary` chunk, used for retrieval). When I open such a document —
|
||||||
|
from the Sources table, a chat source chip, or a direct URL — I only
|
||||||
|
see the raw original. I want to see **the summary and the original
|
||||||
|
document together**.
|
||||||
|
|
||||||
|
- **Given** a document that has a summary (phase 30: non-markdown A9
|
||||||
|
documents)
|
||||||
|
- **When** I open it in the document viewer (the modal or the full
|
||||||
|
page)
|
||||||
|
- **Then** a labeled Summary panel sits above the original content —
|
||||||
|
both visible at once. Documents without a summary (markdown docs,
|
||||||
|
pre-phase-30 rows, fail-soft rows) render exactly as before.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. `DocContent` gains `summary: str | None`; `GET
|
||||||
|
/api/documents/content` returns `documents.summary`. The endpoint
|
||||||
|
stays public + stateless — the phase 16 soft rule (catalog gated,
|
||||||
|
viewer public) is unchanged.
|
||||||
|
2. The shared renderer `renderDocument` (`assets/document.js`)
|
||||||
|
renders the summary panel for **both** surfaces at once (the
|
||||||
|
full-page viewer + the chat/sources modal); null/empty summary →
|
||||||
|
no panel; the summary text is written with `textContent` (XSS
|
||||||
|
contract unchanged).
|
||||||
|
3. `.doc-summary` styling matches the dark tech theme (phase 08
|
||||||
|
palette, contrast ≥4.5:1) and reads as a summary, not as document
|
||||||
|
content.
|
||||||
|
4. Integration tests: a summarized non-markdown doc returns its
|
||||||
|
summary; a markdown doc returns null; anonymous access unchanged.
|
||||||
|
5. Story E2E green in isolation (the `summary_kb` fixture: the
|
||||||
|
deterministic digest panel + the full original with its tail
|
||||||
|
sentinel visible together; the markdown control doc → no panel);
|
||||||
|
regressions green; `app/` coverage >90%; ruff + pyright clean; one
|
||||||
|
`--no-gpg-sign` commit.
|
||||||
|
|
||||||
|
## Playwright Mapping Rule
|
||||||
|
`tests/e2e/test_summary_in_viewer.py` — one story, one file, run in
|
||||||
|
isolation.
|
||||||
Reference in New Issue
Block a user