remove .agent, it shouldn't be committed. Do not commit it again
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
# Phase 52 — Pinned Message Composer
|
||||
|
||||
**Source:** `TODO.md` L3 — "The message input text box needs to be pinned to the bottom of the screen so it doesn't \"run away\" from the user as they try to click \"stop\""
|
||||
**Story:** n/a (TODO-derived — owner instruction 2026-08-30: convert without confirmation)
|
||||
**Context:** The chat page (`frontend/index.html`) scrolls at the document level: `.chat-shell` (the centered 46rem column, PLAN §7) is a flex column — kb-banner, steering panel, New chat, Save/Share, `.messages`, and finally the `.composer` form (`#message-input` + `#send-btn`). The composer is NOT sticky — in a long conversation it sits below the fold, and since the page never auto-scrolls while a turn streams (phase 42), the Stop button (phase 48: `#send-btn` morphs into the enabled Stop control in flight) can be off-screen exactly when the user wants to click it. The sticky app header is the only sticky chrome (z-index 20, 2px hairline below); the document modal is the topmost layer (z-index 1000). House frontend testing: source pins (`tests/unit/test_frontend_feedback.py` style — `test_frontend_scroll.py` / `test_history_page.py` are the closest precedents) plus one isolated Playwright suite per story (A16).
|
||||
|
||||
## Objective
|
||||
The composer (input + Send/Stop button) sits at the bottom of the screen — on an EMPTY/short chat as its resting position and at every scroll position of an over-viewport conversation — so the Stop control is always reachable mid-turn without scrolling, and no new auto-scroll behaviour is introduced (the phase-42 contract stays intact).
|
||||
|
||||
## Revision (owner, 2026-08-30) — the first pass did NOT complete this phase
|
||||
The first pass shipped `position: sticky; bottom` on `.composer` only and
|
||||
called the phase done. The owner rejected it: *"The chat message-input
|
||||
textarea should be at the bottom of the screen. It's not right now."*
|
||||
Verified in the browser: on an empty chat the input rested just under the
|
||||
empty state (~57% of the viewport) with a dead band down to the footer.
|
||||
|
||||
Why sticky alone cannot satisfy the objective: **`position: sticky` can
|
||||
only pull a box UP toward the scrollport's bottom edge — it never pushes a
|
||||
box DOWN to meet it.** So it works only while the document overflows
|
||||
(which the old E2E suite tested, and which is why the suite went green on
|
||||
a half-fixed feature); on a page that does not scroll it is a no-op.
|
||||
The recorded assumption "the pin is CSS-only sticky, no other rule" was
|
||||
the wrong assumption — flagged and revised here, not silently deviated.
|
||||
|
||||
The pin is now TWO rules:
|
||||
1. `.messages { flex: 1 1 auto }` — absorbs the free space of a short page
|
||||
so the composer's resting (in-flow) position IS the bottom of the
|
||||
full-height column (`body{min-height:100dvh}` → `.app-main{flex:1}` →
|
||||
`.chat-shell{flex:1}` → grown message list).
|
||||
2. `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0) }`
|
||||
— takes over as soon as the conversation overflows, gluing the box (and
|
||||
Stop) to the viewport's bottom edge at every scroll position; the `0`
|
||||
fallback replaces the old `env()`-only offset, which degraded to
|
||||
`auto` (no pin at all) where `env()` is unsupported.
|
||||
|
||||
Both halves stay CSS-only: no DOM change, no JS, no new scroll call site,
|
||||
no z-index — so the phase-42 never-auto-scroll contract still holds. The
|
||||
E2E contract was corrected the same way: the empty-chat test is now
|
||||
`test_empty_chat_composer_sits_at_the_screen_bottom` (the old
|
||||
`sits_in_normal_flow` test asserted the buggy geometry as expected
|
||||
behaviour), and the phone suite checks the resting position too.
|
||||
|
||||
## Dependencies
|
||||
- `48_stop_generation` (complete) — the Send↔Stop morph; Stop is clicked FROM the pinned composer (the original "run away" scenario).
|
||||
- `42_no_reply_autoscroll` (complete) — the no-autoscroll-while-streaming contract the pin must not revise.
|
||||
- `07_story_responsive_polish` (complete) — the 46rem column / responsive rules the pinned composer must sit within.
|
||||
|
||||
## Tasks
|
||||
1. `01_sticky_composer.md` — the `position: sticky; bottom` pin on `.composer` + safe-area inset + the frontend source pins.
|
||||
2. `02_e2e_pinned_composer.md` — the story Playwright suite + regressions + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_pinned_composer.py` — source pins: `.composer` carries `position: sticky` with a `bottom` offset (safe-area inset) in `styles.css`; `app.js` gains NO new page-scroll call site (the phase-42 invariant — the one page scroll is still `scrollReveal`).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate — this phase makes no `app/` changes; the gate must stay green).
|
||||
- E2E (mandatory, A16): `tests/e2e/test_pinned_composer.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [x] On an EMPTY/short chat the composer's resting position is at the bottom of the screen — the only band below it is chrome (`.app-footer`, in flow, never overlapped); no dead wasted space.
|
||||
- [x] With an over-viewport conversation, scrolled to the top: the composer is fully visible (bounding box inside the viewport) at the bottom edge.
|
||||
- [x] In flight, scrolled up to read earlier content: the Stop button is visible and clickable; clicking it (no scrolling) stops the turn — partial kept + persisted with `stopped: true` (the phase-48 contract, unchanged), no error banner, no window scroll (phase 42).
|
||||
- [x] `uv run pytest` green (1019 passed); coverage TOTAL 99% (>90%).
|
||||
- [x] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (4 passed, DB up).
|
||||
- [x] Regression E2E suites green in isolation: `test_stop_generation.py` (3), `test_no_reply_autoscroll.py` (6), `test_chat_persistence.py` (4), `test_mobile_hamburger_nav.py` (7) — plus the layout/scroll neighbours `test_smoke.py` (3), `test_chat_rag.py` (3), `test_honest_deflection.py` (3), `test_suggestion_chips.py` (4), `test_loading_feedback.py` (5), `test_long_answers.py` (2), `test_markdown_tables.py` (6), `test_retry_answer.py` (4), `test_thinking_scroll.py` (8), `test_responsive_polish.py` (7), `test_share_chat.py` (4), `test_chat_history.py` (5), `test_dark_tech_theme.py` (6), `test_background_no_motion.py` (8).
|
||||
- [x] `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Locked decisions
|
||||
- **REVISION of assumption (1) (owner, 2026-08-30):** sticky alone was wrong — see **## Revision** above. The pin is `position: sticky; bottom: env(safe-area-inset-bottom, 0)` on `.composer` **plus** `flex: 1 1 auto` on `.messages`, so the resting position also lands at the bottom of the screen. Still CSS-only: no `index.html` DOM change, no JS.
|
||||
- **Recorded assumptions (TODO conversion, 2026-08-30 — owner asked for no confirmation):** (1) ~~the pin is CSS-only — `position: sticky; bottom: env(safe-area-inset-bottom)` on the existing `.composer` inside the existing `.chat-shell` column~~ **revised, see above**; no `index.html` DOM change, no JS; (2) the composer keeps its current solid `--surface` background + border + shadow (no glass/transparency), so scrolled messages never show through it; (3) NO z-index change — the composer already paints above `.messages` by DOM order, never overlaps the sticky header, and stays under the z-1000 document modal; (4) the phase-42 never-auto-scroll contract is strictly upheld — the pin adds zero scroll call sites.
|
||||
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): pin the composer to the viewport bottom — Stop is always reachable while reading"
|
||||
```
|
||||
@@ -1,46 +0,0 @@
|
||||
# Task 01 — Sticky Bottom Composer
|
||||
|
||||
**Phase:** `52_pinned_composer` · **Source:** `TODO.md:3` — "The message input text box needs to be pinned to the bottom of the screen so it doesn't \"run away\" from the user as they try to click \"stop\""
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The composer stays at the bottom of the screen — resting there on a short page and pinned there at every scroll position of an over-viewport page — with CSS-only changes inside the existing chat column.
|
||||
|
||||
## Revision (owner, 2026-08-30) — this task was NOT done the first time
|
||||
The first pass applied only `position: sticky; bottom: env(safe-area-inset-`
|
||||
`bottom)` to `.composer`, and the browser disproved it: on an empty/short
|
||||
chat the input rested just under the empty state (~57% of the viewport)
|
||||
with a dead band all the way down to the footer. `position: sticky` can
|
||||
only pull a box UP to the scrollport's bottom edge; it never pushes a box
|
||||
DOWN to meet it, so on a page that does not overflow it does nothing — the
|
||||
old E2E contract only ever exercised an overflowing conversation, which is
|
||||
why the half-fix passed. Both rules below are now in place and both are
|
||||
source-pinned in `tests/unit/test_pinned_composer.py`.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — TWO rules, both required:
|
||||
a. `.messages { flex: 1 1 auto; }` (Main-frame block) — the message list
|
||||
absorbs the free space of a short page, so the composer's resting
|
||||
in-flow position is the bottom of the full-height column
|
||||
(`body{min-height:100dvh}` → `.app-main{flex:1}` → `.chat-shell{flex:1}`).
|
||||
`flex-basis` stays `auto` (a `0` basis would size the list below its
|
||||
content once the conversation overflows and let bubbles overlap the
|
||||
box); no `height` cap, no `overflow` — the document stays the scroller.
|
||||
b. `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0); }`
|
||||
(`/* ---------- Composer ---------- */` block, ~L1133) — takes over the
|
||||
moment the conversation overflows. The page scrolls at the document level and `.chat-shell` is the composer's containing column, so the box sticks to the viewport's bottom edge (offset by the mobile safe-area inset) while `.messages` scrolls behind it; at the document bottom it settles back into its normal flow position above the footer. Keep the existing solid `background: var(--surface)`, border, radius and `box-shadow: var(--shadow)` — messages must never show through the pinned box.
|
||||
2. `frontend/index.html` — verify NO change needed: the composer is already the LAST child of `.chat-shell` (the sticky context), and the `#message-input` / `#send-btn` / `#send-status` markup is untouched.
|
||||
3. Do NOT touch `frontend/assets/app.js` — the pin must not add any scroll call site (phase-42 invariant; the one page scroll in the file stays `scrollReveal`).
|
||||
4. `tests/unit/test_pinned_composer.py` (new, house pin style — see `tests/unit/test_frontend_scroll.py`): assert `styles.css` declares `position: sticky` AND a `bottom:` offset on `.composer` (the sticky-bottom pair, matched inside the `.composer` rule); assert `app.js` is unchanged in its scroll surface (the phase-42 single-`scrollReveal` pin still holds — reuse the same assertion approach `test_no_reply_autoscroll.py`'s companion pins use).
|
||||
- ASSUMPTION (revised): `bottom: env(safe-area-inset-bottom, 0)` — the
|
||||
notch-aware inset with an explicit `0` fallback; `env()`-only (the first
|
||||
pass) degrades to `auto`, i.e. no pin, where the function is unsupported.
|
||||
- ASSUMPTION: no `z-index` added — DOM order already stacks the composer above `.messages`; the sticky header (z 20) and doc modal (z 1000) are unaffected.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_pinned_composer.py` (the pins above) — `uv run pytest tests/unit/test_pinned_composer.py -v` green.
|
||||
- Coverage: **>90%** — no `app/` change; the gate stays green.
|
||||
|
||||
## Completion Criteria
|
||||
- [x] `.messages` carries the `flex-grow` and `.composer` carries `position: sticky` + the `bottom` safe-area offset with the `0` fallback; the `frontend/` diff contains no JS and no DOM change.
|
||||
- [x] `uv run pytest` green (1019 passed, TOTAL 99%); `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Task 02 — E2E: Pinned Composer + Regressions + Commit
|
||||
|
||||
**Phase:** `52_pinned_composer` · **Source:** `TODO.md:3` — "The message input text box needs to be pinned to the bottom of the screen so it doesn't \"run away\" from the user as they try to click \"stop\"" (this task verifies it in the browser)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
One isolated Playwright story suite proving the composer never runs away from the user — including the original scenario: clicking **Stop** while reading a streaming answer from a scrolled-up position.
|
||||
|
||||
## Revision (owner, 2026-08-30) — the suite tested the half-fix
|
||||
The first pass wrote `test_empty_chat_composer_sits_in_normal_flow`, which
|
||||
asserted that on an empty chat the composer "renders in its normal flow
|
||||
position" — i.e. it pinned the BUG as the expected result, so the suite
|
||||
went green while the objective failed. It is now
|
||||
`test_empty_chat_composer_sits_at_the_screen_bottom` (helper
|
||||
`assert_rests_at_the_screen_bottom`): the band below the resting composer
|
||||
may be chrome only (the footer's measured height + the settled slot's flow
|
||||
padding, `BOTTOM_SLACK_PX`), and the composer must sit in the lower part of
|
||||
the viewport (`LOWER_PART`). The phone suite checks the resting position as
|
||||
well as the pinned one.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_pinned_composer.py` (new) — the story suite (isolated run; `mock_llm` deterministic; DB up per the e2e prerequisite):
|
||||
- **Pinned while reading:** build an over-viewport conversation — ask ~8 short questions through the UI (each turn adds user + brain bubbles with meta rows; at the house 1280×720 viewport this exceeds the fold; see the ASSUMPTION below for the fallback if it proves insufficient). `page.evaluate("window.scrollTo(0, 0)")` (a test scroll — the app never scrolls itself, phase 42). Assert `page.locator("#composer").bounding_box()` is fully inside the viewport (`y >= 0`, `y + height <= viewport height`) with its bottom edge at the viewport bottom (± a few px for the safe-area inset).
|
||||
- **The run-away scenario — Stop from scrolled-up, in flight:** submit one question; let the turn enter streaming (the mock LLM streams deltas; wait for the Send label to read "Stop" per the phase-48 contract); scroll the window to the top (the user reads earlier content — phase 42 leaves them there; record `window.scrollY`); assert the Stop button (`#send-btn`, `.is-stop`) is visible WITHOUT scrolling; click it; assert: the turn settled (no in-flight state), the partial answer is on screen with the `.stopped-note` rendered, no error banner, `window.scrollY` UNCHANGED by the click (the pin adds no scroll), and a fresh page load restores the `stopped` record (the phase-48 persistence contract through the normal `bor.chat.v1` path).
|
||||
- **Natural bottom:** a fresh empty chat — the composer RESTS at the bottom of the screen (no dead band under it) while the `.app-footer` stays in normal flow below it, un-overlapped; one short turn still rests there.
|
||||
2. Regressions, each in isolation (`uv run pytest tests/e2e/<file> -v --no-cov`): `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py` (the mobile nav sits in the sticky header — the pin must not break the header/dropdown stacking at ≤640px).
|
||||
3. One `--no-gpg-sign` commit staging `.agent/ frontend/ tests/` (message per the phase overview); move `.agent/phases/todo/52_pinned_composer/` to `.agent/phases/complete/`.
|
||||
- ASSUMPTION: over-viewport overflow is produced by ~8 UI questions against the mock LLM (short deterministic answers, but each turn adds two bubbles + meta rows). If the suite shows that is not enough to exceed 720px, fall back to a saved long conversation via the phase-50 path (Save a multi-turn chat, reload with `/?chat=<id>`); no new fixture or API surface.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (mandatory, A16): `tests/e2e/test_pinned_composer.py` green in isolation.
|
||||
- The four regression suites green in isolation (no assertion edits outside the scope the phase-48 revised contract already owns — if `test_stop_generation.py` needs a revision it must be the pinned-composer contract, nothing else).
|
||||
|
||||
## Completion Criteria
|
||||
- [x] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (4 passed, DB up).
|
||||
- [x] `test_stop_generation.py` (3), `test_no_reply_autoscroll.py` (6), `test_chat_persistence.py` (4), `test_mobile_hamburger_nav.py` (7) green in isolation — plus the layout/scroll neighbours (smoke 3, chat_rag 3, honest_deflection 3, suggestion_chips 4, loading_feedback 5, long_answers 2, markdown_tables 6, retry_answer 4, thinking_scroll 8, responsive_polish 7, share_chat 4, chat_history 5, dark_tech_theme 6, background_no_motion 8).
|
||||
- [x] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task 01 — Sources Version Table + `saved_chats.sources_version`
|
||||
|
||||
**Phase:** `53_stale_saved_chats` · **Source:** `TODO.md:4` — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
A durable record of "which generation of the KB was this chat saved against": a single-row `sources_meta` version counter plus a `sources_version` stamp on every `saved_chats` row.
|
||||
|
||||
## Work
|
||||
1. `app/models.py` — add `SourcesMeta` (single row, the `KbOverview` id=1 precedent): `id` Integer PK `server_default="1"`, `version` Integer NOT NULL `server_default="0"`, `updated_at` timestamptz (`server_default=func.now()`, `onupdate=func.now()`). Add `SavedChat.sources_version: Mapped[int]` — Integer, NOT NULL, `server_default="0"`, with the model docstring noting: existing rows stamp 0 = "the pre-counter KB" and become stale on the first bump.
|
||||
2. `alembic/versions/0010_sources_version.py` — ONE migration for the feature: `op.create_table("sources_meta", …)` + the seed row (id 1, version 0) + `op.add_column("saved_chats", sa.Column("sources_version", sa.Integer(), nullable=False, server_default="0"))`. Downgrade reverses in the safe order (drop column, drop table). `down_revision` = the 0009 revision (verify against `alembic/versions/0009_saved_chat_share_token.py`).
|
||||
3. `app/rag/sources_meta.py` (new module) — `current_sources_version(db: Session) -> int` (PK read of the seed row; returns 0 when the row is absent — defensive, never raises) and `bump_sources_version(db: Session) -> int` (upsert the row, `version += 1`, `db.flush()` — the CALLER commits, because the two sync paths each own their session; returns the new version).
|
||||
4. `tests/integration/test_migration_0010.py` — the house migration pattern (model from `tests/integration/test_migration_0009.py`): upgrade head → the seed row exists (version 0), `saved_chats.sources_version` is NOT NULL with default 0, an inserted row round-trips; downgrade → column + table gone.
|
||||
5. `tests/unit/test_sources_meta.py` — the helpers on a real/fixture session (house DB-test pattern): absent row → `current` 0; first bump → 1; second bump → 2; `current` after a bump reflects it; two bumps in two sessions don't race to the same value (flush-order check is out of scope — one writer at a time is the deployment reality).
|
||||
|
||||
- ASSUMPTION: both schema changes ship in ONE migration (0010) — one feature, one atomic schema change; the seed row is inserted in the migration (not lazily on first bump), so `current_sources_version` is a plain PK read.
|
||||
- ASSUMPTION: the helper module lives at `app/rag/sources_meta.py` (rag domain — both sync paths already import from `app.rag.*`); no new setting, no new dependency.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: `tests/integration/test_migration_0010.py` green.
|
||||
- Unit: `tests/unit/test_sources_meta.py` green.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run alembic upgrade head` applies 0010 cleanly on a fresh DB (and on a DB already at 0009); the seed row is present with version 0.
|
||||
- [ ] `uv run pytest tests/integration/test_migration_0010.py tests/unit/test_sources_meta.py -v` green.
|
||||
- [ ] `uv run pytest` green (no existing test broken by the new NOT NULL column); `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Task 02 — Bump the Version on Every KB-Changing Sync
|
||||
|
||||
**Phase:** `53_stale_saved_chats` · **Source:** `TODO.md:4` — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" (this task wires the bump into the two canonical sync paths)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Every sync path that changed the knowledge base bumps `sources_meta.version` exactly once — the in-app Sync button and the CLI — so the staleness marker tracks the index, not the click.
|
||||
|
||||
## Work
|
||||
1. `app/api/sync.py` — in `_run_sync`, after `summary = await import_sources(sources, llm, prune=True)` succeeds and the overview decision is made: when `summary.added + summary.updated + summary.pruned > 0`, open a short-lived `SessionLocal()` (the run's status is in-memory; the `effective_sources` call above sets the exact pattern — open, use, close in `finally`) and run `bump_sources_version(db)` + commit. Add `"sources_version": <new version>` to `_status.detail` (the never-stale status object gains the new generation number). Any exception before this point (model check, source resolution, clone/pull, import failure) aborts the run in the `failed` state — a FAILED sync never bumps, so it never invalidates chats.
|
||||
- Note the deliberate gate difference: the overview regenerates on `added + updated > 0`, but the version bump also covers `pruned > 0` — a pruned document can invalidate a saved answer that cited it. The two gates stay separate on purpose.
|
||||
2. `scripts/import_docs.py` — in `_run` (the `asyncio.run` coroutine), the same bump with the same gate: after the `--limit`/unchanged early-returns, when `summary.added + summary.updated + summary.pruned > 0`, bump via a short `SessionLocal()` (the file already opens sessions for `_overview_row_exists`). `--limit` debug runs NEVER bump (an incomplete walk is debug-only — mirrors the existing `--limit` overview skip); an unchanged re-run (`added + updated + pruned == 0`) NEVER bumps. Add `sources_version=<value or "skipped">` to the summary `print` line (the cron/quadlet log surface).
|
||||
3. Tests:
|
||||
- `tests/integration/test_sync_api.py` — extend (fakes pattern from `tests/fakes.py`): a sync whose import changed the KB bumps the version exactly once AND the `/api/sync/status` detail carries `sources_version`; an unchanged re-sync (all `unchanged`) does NOT bump; a failing sync (e.g. the phase-41 model-down path) does NOT bump.
|
||||
- `tests/integration/test_import_docs_overview.py` (or the existing `import_docs` gate test if the file that pins `--limit`/unchanged gating lives elsewhere — match the file that already asserts the overview gate) — extend with the bump asserts: changed run → bump + `sources_version` in the print line; `--limit` run → no bump, `skipped`; unchanged run → no bump, `skipped`.
|
||||
- ASSUMPTION: the bump commits independently of the best-effort overview regeneration — a failed `lite` overview never rolls back the version (the index did change), and the bump happens in its own short session so it survives an overview exception.
|
||||
- ASSUMPTION: `scripts/import_docs.py --source <manual dir>` runs DO bump when they change the KB — a manual import changes the index exactly like a git sync (the TODO says "if the docs are synced"; the CLI is the other canonical sync).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the extended sync + import_docs suites green (`uv run pytest tests/integration/test_sync_api.py tests/integration/test_import_docs_overview.py -v`).
|
||||
- Coverage: **>90%** on `app/` (the sync-path bump is in `app/api/sync.py`; the CLI bump is in `scripts/`, covered by its own tests).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] A changed sync (both paths) bumps exactly once; unchanged / `--limit` / failed syncs never bump.
|
||||
- [ ] No behavior change in completed phases (the sync pipeline's steps 1–5 and exit codes are untouched apart from the new bump + detail field / print token).
|
||||
@@ -1,23 +0,0 @@
|
||||
# Task 03 — Stamp-on-Save + `stale` on the Chats API
|
||||
|
||||
**Phase:** `53_stale_saved_chats` · **Source:** `TODO.md:4` — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" (this task exposes staleness on the admin chats API)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
A saved chat knows the KB generation it was saved against, and the API tells the client whether it is stale — no client-side staleness math.
|
||||
|
||||
## Work
|
||||
1. `app/api/chats.py` — `create_chat`: stamp `row.sources_version = current_sources_version(db)` on the PENDING row (ships in the same INSERT, the `share_token` precedent). `update_chat` (the Re-Save upsert): re-stamp `sources_version` to the current value — a Re-Save is the owner affirming this content against the current KB. Share/unshare are UNCHANGED (raw SQL touching only `share_token` — the version, like `updated_at`, is untouched).
|
||||
2. `app/schemas.py` — add `stale: bool` to `SavedChatRow` (list shape) and `SavedChatOut` (detail shape). `SharedChatOut` is UNCHANGED — the public snapshot is frozen by design (phase 51; the staleness surface is admin-only).
|
||||
3. `app/api/chats.py` — `list_chats` + `get_chat`: call `current_sources_version(db)` ONCE per request and compute `stale = row.sources_version < current` inside `_to_row` / `_to_out` (pass the current version in — the module functions stay pure).
|
||||
4. `tests/integration/test_chats_api.py` — extend: create stamps the current version (assert via the helper read); bump (`bump_sources_version` in the test) → `GET /api/chats` rows and `GET /api/chats/<id>` both report `stale: true`; a Re-Save (`PUT`) re-stamps → `stale: false` again; share/unshare leave `sources_version` untouched; the 403/anonymous contract is unchanged.
|
||||
- ASSUMPTION: Re-Save ALWAYS re-stamps to the current version (not "only when stale") — uniform semantics, one fewer code path; a re-Save is also the manual escape hatch for a false-positive stale row.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: `tests/integration/test_chats_api.py` green (extended).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `POST` / `PUT /api/chats` stamp `sources_version`; list + detail carry `stale`; anonymous 403s unchanged; `/api/shared/<token>` body unchanged.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] No behavior change in completed phases (existing `test_chats_api.py` / `test_share_chat.py` assertions still hold).
|
||||
@@ -1,23 +0,0 @@
|
||||
# Task 04 — Stale Column on the History Table
|
||||
|
||||
**Phase:** `53_stale_saved_chats` · **Source:** `TODO.md:4` — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" (this task surfaces staleness on the History page)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The History page shows at a glance which saved chats predate the last KB-changing sync.
|
||||
|
||||
## Work
|
||||
1. `frontend/history.html` — add a `Stale` `<th>` to the full-width table, between the `Updated` and `Actions` columns (house rule 5: a column in the full-width table — no skinny side list).
|
||||
2. `frontend/assets/history.js` — render the cell from `row.stale` (the phase-50 row shape, extended in task 03): a rose `Stale` pill when true, an em-dash when false; the pill carries `title="Sources have changed since this chat was saved — open the chat to Regenerate"` and the `<td>` an `aria-label` so the marker is conveyed without the visual (WCAG 2.1 AA). Non-stale rows render exactly as before.
|
||||
3. `frontend/assets/styles.css` — the `.stale-pill` treatment: rose family (the Stop-treatment tokens, so "stale" reads in the same visual language as the in-flight control), theme-token based (both themes), contrast ≥4.5:1 on `--surface`.
|
||||
4. Source pins (`tests/unit/test_history_page.py` extended, house pin style): the `history.js` stale-cell branch (pill class + em-dash fallback + the `aria-label`), the `Stale` `<th>` in `history.html`, and the `.stale-pill` rule in `styles.css`.
|
||||
- ASSUMPTION: the marker is a READ-ONLY badge — opening the row shows the chat-page banner (task 05) which carries the Regenerate action; no bulk "regenerate all" in v1, no per-row action button (the table's Actions column keeps its Open/Share/Delete trio).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the extended `tests/unit/test_history_page.py` pins green.
|
||||
- Coverage: **>90%** — no `app/` change; the gate stays green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The History table renders the Stale column; stale rows show the pill, fresh rows the em-dash; the table stays full-width (AGENTS.md rule 5).
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] No behavior change in completed phases (the Open/Share/Delete flows and the cache-busting contract are untouched).
|
||||
@@ -1,30 +0,0 @@
|
||||
# Task 05 — Stale Banner + Regenerate on the Chat Page
|
||||
|
||||
**Phase:** `53_stale_saved_chats` · **Source:** `TODO.md:4` — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" (this task delivers the regeneration on the chat page)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Opening a saved chat that predates the current KB shows a banner; **Regenerate** re-asks the last question against the new index, in place, and re-saves the row so the stored answer is current again.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — add a `#stale-banner` section directly after `#kb-banner` (the chat-shell top-of-column position, `role="status"`, `hidden` by default): the text "The sources have been updated since this chat was saved." + a `#stale-regenerate` button labeled "Regenerate" (the brand-pill treatment, matched to the Save/Share pair; icon = the redo glyph, reused from the phase-49 Retry asset in `app.js`).
|
||||
2. `frontend/assets/app.js`:
|
||||
- `retryLastTurn(wrap)` (phase 49, ~L1381): make it RETURN the `runTurn(text, { reask: true })` promise (today it is `void runTurn(...)`) — the existing Retry click handler ignores the return value, so phase-49 behavior is byte-identical; the Regenerate path needs the turn's completion to know when to persist.
|
||||
- The `?chat=<id>` boot-load block (the phase-50 code around `fetch(\`/api/chats/${chatId}\`)` → `currentChatId = chatId` → `saveConversation()` → `history.replaceState`): when the fetched payload has `stale: true`, reveal `#stale-banner` (remove `hidden`).
|
||||
- New handler for `#stale-regenerate`: call `retryLastTurn(wrap)` on the LAST brain bubble's rendered wrap (the phase-49 targeting — reuse the `markLastRetryable` / last-`.msg.brain`-wrap resolution; the guard `wrap !== lastBrainWrap` already protects against a stale click), AWAIT the returned turn promise; on completion WITHOUT an error banner, persist the linked row through the existing upsert path (linked → `PUT /api/chats/<id>` — the server re-stamps `sources_version`, see task 03), then hide the banner and announce the outcome in the existing `#send-status` live region (PLAN §7.4 never-stale). A 404 PUT (row deleted from History meanwhile) follows the `saveCurrentChat` stale-link rule: unlink + recreate — the owner is never left with an unsaved conversation.
|
||||
- Guard: a stale conversation with NO brain record (user-only / deflection-only) — the banner shows WITHOUT the Regenerate button (text only); `retryLastTurn` is never called in that state.
|
||||
3. `frontend/assets/styles.css` — `#stale-banner` styling (the kb-banner family, distinct redo icon) + the Regenerate button; theme tokens only, contrast ≥4.5:1 in both themes; banner stacks correctly above `#kb-banner` when both are visible (e.g. empty-KB banner + stale chat — kb-banner wins the top slot, stale-banner directly below).
|
||||
4. Source pins (house style, extend `tests/unit/test_save_chat_ui.py`): the banner reveal branch on `payload.stale`, the Regenerate → `retryLastTurn` wiring, the `retryLastTurn` return-promise change, the post-regenerate linked PUT, and the no-brain-record guard.
|
||||
- ASSUMPTION: Regenerate = the phase-49 redo-in-place of the LAST brain bubble ONLY — the full conversation context is kept, earlier answers are not re-run (re-asking every question is out of scope for v1; the TODO's "a new answer" is satisfied by the answer the user was reading going stale).
|
||||
- ASSUMPTION: the auto re-save after a successful regenerate is the linked `PUT` (the row's `updated_at` bumps — it IS a content edit, so the History "latest activity" order follows it); a regenerate that errors mid-stream (the error banner shows) leaves the row untouched — stale stays true; a regenerate STOPPED mid-stream (phase 48) persists the stopped partial via the same PUT (the owner engaged with the new index).
|
||||
- ASSUMPTION: localStorage-only (unsaved) conversations are never stale — staleness is a property of the saved row; the banner appears only on the `?chat=<id>` boot path, never on the phase-14 local restore.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the extended `tests/unit/test_save_chat_ui.py` pins green.
|
||||
- Coverage: **>90%** — no `app/` change in this task; the gate stays green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Opening a stale `?chat=<id>` shows the banner; Regenerate streams the fresh answer in place and clears the banner after the linked row is re-saved.
|
||||
- [ ] The phase-49 Retry button behaves exactly as before (pin-verified: the return-promise change is behavior-neutral for the existing click handler).
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] No behavior change in completed phases (phase-14 local restore, phase-48 stop, phase-50 save/load flows).
|
||||
@@ -1,28 +0,0 @@
|
||||
# Task 06 — E2E: Stale Saved Chats + Regressions + Commit
|
||||
|
||||
**Phase:** `53_stale_saved_chats` · **Source:** `TODO.md:4` — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" (this task verifies the full loop in the browser)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
One isolated Playwright story suite for the whole invalidation loop: save → KB-changing sync (version bump) → stale surfaced → Regenerate → current again.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_stale_saved_chats.py` (new) — the story suite (isolated run; `mock_llm` deterministic; real DB per the e2e prerequisite):
|
||||
- As admin: ask a question (mock LLM answers deterministically), Save via the chat-page button → `GET /api/chats` (admin cookie) reports the row with `stale: false`.
|
||||
- Produce the KB change the way a real sync does: the test process (which shares the app's environment) imports `bump_sources_version` from `app.rag.sources_meta` and bumps the seed row through a short `SessionLocal()` — deterministic, no dependency on configured git sources in the E2E environment (see the ASSUMPTION).
|
||||
- `/history.html`: the row now carries the Stale pill; `GET /api/chats` carries `stale: true`.
|
||||
- Open the row (`/?chat=<id>`, the same URL the History table links): the `#stale-banner` is visible with the Regenerate button; click it → the fresh answer streams in place (the mock LLM's deterministic text replaces the old last brain bubble, phase-49 contract), the banner clears once the row is re-saved; `GET /api/chats/<id>` reports `stale: false` and its last brain message is the fresh answer; the History pill is gone.
|
||||
- Anonymous: share the (now fresh) chat, open `/shared/<token>` without a session — the page renders the snapshot with NO staleness surface (phase 51 unchanged).
|
||||
2. Regressions, each in isolation (`uv run pytest tests/e2e/<file> -v --no-cov`): `test_chat_history.py`, `test_share_chat.py`, `test_sync_button.py`, `test_retry_answer.py` (the `retryLastTurn` return-promise change), `test_chat_persistence.py`.
|
||||
3. One `--no-gpg-sign` commit staging `.agent/ app/ alembic/versions/ scripts/ frontend/ tests/` (message per the phase overview); move `.agent/phases/todo/53_stale_saved_chats/` to `.agent/phases/complete/`.
|
||||
- ASSUMPTION: the E2E bumps the version via `bump_sources_version` directly (a test-only DB step) — the Sync button's end-to-end clone/import path stays covered by `test_sync_button.py`, and the bump GATES are covered by this phase's integration tests (task 02); the E2E proves the user-visible invalidation loop, not git plumbing.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (mandatory, A16): `tests/e2e/test_stale_saved_chats.py` green in isolation.
|
||||
- The five regression suites green in isolation; no assertion edits outside the new contract (the phase-49 retry pins, the phase-50/51 save/share pins stay intact).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `test_chat_history.py`, `test_share_chat.py`, `test_sync_button.py`, `test_retry_answer.py`, `test_chat_persistence.py` green in isolation.
|
||||
- [ ] `uv run pytest` + coverage TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task 01 — Anonymous save/share API surface
|
||||
|
||||
**Phase:** `55_save_share_ux` · **Source:** `TODO.md:3` — "Share chat should work anonymously without login"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The `/api/chats` save/share **write** surface works without a session: the router-wide `require_admin` (A1) moves off the router and onto the four management routes only (list, detail, delete, unshare).
|
||||
|
||||
## Work
|
||||
1. `app/api/chats.py` — remove `dependencies=[Depends(require_admin)]` from `router = APIRouter(...)` (line ~69; keep `prefix="/chats"` and `tags`). Add `dependencies=[Depends(require_admin)]` to exactly four route decorators: `GET ""` (list, line ~128), `GET /{chat_id}` (detail, line ~180), `DELETE /{chat_id}` (line ~219), `POST /{chat_id}/unshare` (line ~267). The write surface — `POST ""` (create, **including the save-then-share `share: true` branch** which mints the token in the same commit), `PUT /{chat_id}`, `POST /{chat_id}/share` — becomes public (no dependency).
|
||||
2. `app/api/chats.py` — module docstring: rewrite the phase-16 "router-wide admin" paragraph into the split contract and its WHY — the write surface is the visitor's own conversation (row id is an unguessable `uuid4`, same trust model as the phase-51 share token: the id/token IS the credential); the management surface (list/detail/delete/unshare) is the owner's History surface and stays admin-only. Update the per-endpoint docstrings where they still say "admin-only" (the create/update/share docs must now state "public — no session required").
|
||||
3. `tests/integration/test_chats_api.py` — extend (update the existing pins that assert 403 on the now-public write endpoints; the admin pins stay):
|
||||
- Guest (no session cookie): `POST /api/chats` with `messages` → 201 + id + auto-title; `PUT /api/chats/<id>` → 200 (messages replaced, `updated_at` moved); `POST /api/chats/<id>/share` → 200 + `share_url`, a second POST returns the **same** token (idempotent); `POST /api/chats` with `{ messages, share: true }` → 201 + `share_url` in one action (the save-then-share contract, now guest-reachable).
|
||||
- Guest 403s: `GET /api/chats`, `GET /api/chats/<id>`, `DELETE /api/chats/<id>`, `POST /api/chats/<id>/unshare` → all 403 (the router-wide gate moved, it did not disappear).
|
||||
- Revocation still end-to-end: after an admin unshares, the public `GET /api/shared/<token>` read 404s (guest or admin — the public read is unaffected by this task).
|
||||
- Admin session still fully works: the pre-existing admin create/list/detail/delete/unshare pins stay green unchanged.
|
||||
- Do NOT change: `public_router` (`GET /api/shared/<token>`) and `shared_page_router` (already public, phase 51), the token minting (128-bit `uuid4`, same commit as save-then-share), `app/core/auth.py`, the models, or any migration (no schema change).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the guest write surface (create, create+share, update, share, idempotent share), guest 403s on the management surface, admin flow byte-for-byte unchanged.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_chats_api.py -v` green with the new guest-surface pins.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] No migration; no change in `public_router` / `shared_page_router` / `app/core/auth.py`.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Task 02 — Auto-save every conversation (retire the Save pill)
|
||||
|
||||
**Phase:** `55_save_share_ux` · **Source:** `TODO.md:4` — "Save shouldn't be a button, every chat should be saved by default"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The Save pill is gone. Every conversation upserts itself into `saved_chats` at the existing persistence save points (create on the first user message, update on each brain-done and the pagehide partial), and the row link (`currentChatId`) survives a page reload so the same conversation never spawns a second row.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — remove the `#save-chat-btn` element and its Phase-50 comment block (the button currently sits between `#new-chat-btn` and `#share-chat-btn`). Do **not** touch `#new-chat-btn` or `#share-chat-btn` here (task 03/05 own those).
|
||||
2. `frontend/assets/app.js` — convert the on-demand `saveCurrentChat()` (line ~1121) into a **headless** `persistConversation()` helper (rename + strip the button UI):
|
||||
- Drop `saveBtn` references, the `saveBtn?.addEventListener(...)` binding (line ~1687), and the `if (saveBtn) saveBtn.hidden = !isAdmin;` boot line (line ~1723).
|
||||
- Keep the exact upsert semantics (they are load-bearing, phase 50): empty conversation → no-op; linked (`currentChatId` set) → `PUT /api/chats/<id>`, and a **404 on PUT unlinks and retries as a create** (`currentChatId = null` then `POST /api/chats`) so a row deleted from History can't wedge the conversation; unlinked → `POST /api/chats`; on `201` capture `currentChatId = String(created.id)`.
|
||||
- Change the failure feedback from the error banner to the **A2 quiet contract**: on a non-ok or network failure set `sendStatus.textContent` to a one-line note (e.g. "Couldn't save automatically — will try on the next message.") and return — **no** `showErrorBanner`, the turn never blocks. On success, stay silent (A2: no status text; the History page is the visible proof).
|
||||
- Keep the idempotent double-fire guard: the save points can overlap (pagehide during a stream), so a module-level `persisting` flag + the existing per-turn ordering prevents concurrent upserts of the same conversation.
|
||||
3. `frontend/assets/app.js` — wire the helper into the save points:
|
||||
- **Save point 1** (user send, inside `runTurn`'s `if (!reask)` block, line ~1443, right after `saveConversation()`): call `persistConversation()`. This is where an unlinked conversation **creates** its row (auto-title from the first question, server-side, unchanged) and a linked one refreshes.
|
||||
- **Save point 2** (`rememberBrainTurn`, line ~1271, after `saveConversation()`): call `persistConversation()` — updates the row with the new brain turn + metadata.
|
||||
- **Pagehide partial** (the `window.addEventListener("pagehide", ...)` handler, line ~1700): it already calls `rememberBrainTurn`, which now carries the `persistConversation()` — so the partial rides the same path with no extra wiring (do **not** add a second call there).
|
||||
4. `frontend/assets/app.js` — **persist the link** across reloads (the dedupe guarantee):
|
||||
- Extend the `bor.chat.v1` record shape (line ~915/932) to `{ v: 1, chatId: string | null, messages: [...] }`. `saveConversation()` (line ~973) must write the current `currentChatId` (null when unlinked). The restore reader (line ~940) must read it back.
|
||||
- At boot, **after** `restoreConversation()` (line ~1725) and **after** `restoreSavedChatFromUrl()` (which already sets `currentChatId`, line ~1101), hydrate `currentChatId` from the restored record. `restoreSavedChatFromUrl` (the `?chat=<id>` admin path, A3 stays admin-gated) keeps its `currentChatId = chatId` and now also persists it into the record.
|
||||
- `startNewChat()` (line ~1330) already sets `currentChatId = null` — keep it, so "New chat" unlinks and the next conversation creates a fresh row on its first message.
|
||||
- **Old-record safety:** a pre-existing `bor.chat.v1` record that lacks `chatId` reads as `null` (unlinked) — its first save point after upgrade creates a row; never throw on the missing field.
|
||||
5. `frontend/assets/styles.css` — remove the `.save-chat-btn` base block (line ~319–339), the ≤640px `.save-chat-btn`/`.save-chat-label` overrides (line ~2637–2639), the `.chat-shell .save-chat-label`/`.chat-shell .save-chat-btn svg` overrides (line ~2649–2650), and drop `.save-chat-btn` from the ≤900px combined padding rule (line ~2532: `.new-chat-btn, .save-chat-btn, .auth-link` → `.new-chat-btn, .auth-link`).
|
||||
6. `tests/e2e/test_chat_history.py` + `tests/e2e/test_share_chat.py` — adapt the **existing phase-50/51 pins** to the new contract (assertion edits limited to the Save-pill removal; every other pin stays byte-for-byte):
|
||||
- `test_chat_history.py` — the file-local `_save()` helper (click `#save-chat-btn` + wait for "Conversation saved.") becomes an **auto-save waiter**: poll `GET /api/chats` (admin cookies, the file's existing `_chats`/`_admin_cookies` helpers) until a row with the conversation's auto-title appears — auto-saves are silent (A2: no status text to wait on). `test_save_and_see_history`: drop the admin Save-pill visibility + click + status assertions — the row now exists right after the question + answer settle (auto-save at the save points). The anonymous-state block (the "phase-50 surface is absent for anonymous" section, line ~415): `#save-chat-btn` now asserts **absent** (`to_have_count(0)` — the element no longer exists in the DOM); `#nav-history` stays hidden (unchanged); the "NEVER calls `/api/chats`" History-page pin stays (list remains admin-only, task 01).
|
||||
- `test_share_chat.py` — `test_share_from_history_and_unshare`: replace the `#save-chat-btn` click + "Conversation saved." wait (line ~375) with the same auto-save row wait (the conversation is already saved by the time the answer settles); the rest of that test (History's Share column, Create link / Copy / Unshare) is untouched. The `/shared/<token>` zero-controls pin (line ~333: `#save-chat-btn, #share-chat-btn` count 0 on the **shared page**) stays valid — the shared page gets no pills.
|
||||
- Do NOT change: `restoreSavedChatFromUrl`'s **admin gate** (`!isAdmin` → `false`, line ~1069, A3), the localStorage `v: 1` key (shape extends in place, old records valid), `saveConversation()`'s localStorage mirror contract, the composer/`#send-status` markup, or the phase-53 stale Regenerate's auto re-save — it must keep working (if it calls `saveCurrentChat` by name, re-point it at the renamed `persistConversation()`; the stale-banner logic is untouched).
|
||||
|
||||
## Testing & Quality
|
||||
- Frontend source pins (house style, `tests/unit/test_save_chat_ui.py` / `test_history_page.py` pattern): `index.html` has **no** `#save-chat-btn`; `app.js` has no `saveBtn`/`saveCurrentChat` symbol, a `persistConversation` referenced from the user-send save point, `rememberBrainTurn`, and the record read/write of `chatId`; `styles.css` has no `.save-chat-btn`.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate) — the server is untouched here, so this task's gate is the frontend pins + a green suite.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] No `#save-chat-btn` in `frontend/index.html` and no `.save-chat-btn` in `frontend/assets/styles.css`; no `saveBtn`/`saveCurrentChat` symbol left in `app.js`.
|
||||
- [ ] A signed-out visitor who sends one question produces exactly one `saved_chats` row (auto-title, both messages); the next brain answer updates the **same** row (no second row).
|
||||
- [ ] Reloading `/` restores the conversation **and** the link — the next message updates the same row, never a duplicate.
|
||||
- [ ] "New chat" unlinks: the following conversation creates a fresh row on its first message.
|
||||
- [ ] A failed auto-save (5xx / network) leaves the conversation fully usable — one status-line note, no error banner, and the next save point retries.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] Regression E2E green in isolation: `test_chat_persistence.py`, `test_smoke.py`, and the two adapted suites `test_chat_history.py`, `test_share_chat.py`.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task 03 — Share pill visible to every visitor
|
||||
|
||||
**Phase:** `55_save_share_ux` · **Source:** `TODO.md:3` — "Share chat should work anonymously without login"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The Share pill ships **visible to all visitors** — the admin reveal gate goes away (the API it calls is public since task 01), and its error copy stops presuming a signed-in user.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — `#share-chat-btn` (line ~155): remove the `hidden` attribute. Rewrite the Phase-51 comment above it: no longer "Admin-only — ships HIDDEN exactly like Save (absent-not-hidden)" — since phase 55 the pill is static, always-visible markup (task 01 opened the save/share write surface); the save-then-share contract in the comment stays accurate (unsaved → `POST /api/chats` with `share: true`; linked → idempotent `POST /{id}/share`; clipboard with the inline-link fallback; unsharing lives on the admin's History page).
|
||||
2. `frontend/assets/app.js`:
|
||||
- Boot IIFE (line ~1724): **remove** `if (shareBtn) shareBtn.hidden = !isAdmin;` — no reveal step; the `shareBtn` const (line ~203) and the `shareBtn?.addEventListener("click", shareCurrentChat)` binding (line ~1690) stay.
|
||||
- `shareCurrentChat()` (line ~1222): update the two error-banner texts that say "check you're still signed in and try again" → neutral "try again" (with a public API the 403/5xx path is no longer a sign-in problem for a guest). Keep the network-failure banner ("…is the app reachable?").
|
||||
- Update the module-header Share contract comment (line ~158–177): drop "admin-only, SHIPS HIDDEN, revealed at boot" — now "visible to every visitor"; the empty-conversation no-op guard ("Nothing to share yet.") and the one-share-at-a-time double-click guard stay.
|
||||
3. `tests/e2e/test_chat_history.py` — extend the anonymous-state block (the same section task 02 adapted: "the phase-50 surface is absent for anonymous", line ~415) with the new-contract assertion: the anonymous visitor at `/` now sees the Share pill — `expect(page.locator("#share-chat-btn")).to_be_visible()` (alongside the existing `#save-chat-btn` count-0 and `#nav-history` hidden pins). No other pin in that file changes in this task.
|
||||
- Do NOT change: the save-then-share request contract, `copyShareLinkWithFallback` / `absoluteShareUrl`, the inline fallback field, unshare (History page, admin-only, unchanged), or the `#new-chat-btn` (module-owned by `header.js`).
|
||||
|
||||
## Testing & Quality
|
||||
- Frontend source pins: `index.html` — `#share-chat-btn` present **without** a `hidden` attribute; `app.js` — no `shareBtn.hidden` assignment anywhere; the neutral error copy present.
|
||||
- E2E: the extended anonymous-state block in `test_chat_history.py` green in isolation.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] A signed-out visitor at `/` sees the Share pill (next to New chat) without signing in; a signed-in admin sees the same pill (no admin-only divergence).
|
||||
- [ ] Clicking Share on an empty conversation still no-ops with the live-region line "Nothing to share yet."
|
||||
- [ ] A failed share shows the neutral error banner (no "signed in" wording).
|
||||
- [ ] `uv run pytest` green; `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` green in isolation (the extended anonymous block); `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Task 04 — Share-success toast
|
||||
|
||||
**Phase:** `55_save_share_ux` · **Source:** `TODO.md:5` — "Need feedback (probably dropdown notification toast) to show share worked"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
A top-right slide-down toast confirms a successful share (both success paths). It is **visual only** — the existing `#send-status` live region remains the accessibility announcer, so there is no double screen-reader read.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — add a `.toast` component near the banner/status rules:
|
||||
- Position: `position: fixed; top: <header height + small offset>; right: 1rem; z-index` above the header; a small `max-width` so long text wraps.
|
||||
- Look: theme-consistent with the app's surfaces — solid, high-contrast (text on fill ≥ 4.5:1, WCAG AA, matching the `.new-chat-btn` brand-fill family is the natural choice), rounded, a subtle shadow, one line of icon-optional text.
|
||||
- Entry: slide-down + fade (`transform: translateY(-8px) → 0` + `opacity 0 → 1`, ~200ms). Provide a `.toast.is-visible` (or equivalent) state class the JS toggles.
|
||||
- **Reduced motion:** inside the existing `@media (prefers-reduced-motion: reduce)` convention — drop the transform, keep the opacity fade (or make it instant).
|
||||
- Hidden by default (`opacity: 0; pointer-events: none;` or `display` toggle) so it never intercepts clicks when idle.
|
||||
2. `frontend/assets/app.js` — a `showToast(message: string)` helper (page-script-local, house style — do **not** create a cross-page module):
|
||||
- Lazy-create **one** `<div class="toast">` appended to `document.body`; set text via `textContent` (XSS-safe, never `innerHTML`); mark it `aria-hidden="true"` (A4: visual only — `#send-status` is the announcer).
|
||||
- Re-trigger the entry: clear any pending dismiss timer, force a reflow, toggle the visible state class.
|
||||
- **Single instance:** a new toast replaces a pending one (clear the prior timer; reuse the same node — no stacking).
|
||||
- **Auto-dismiss ~4000ms.**
|
||||
- Call it from `shareCurrentChat()`'s success path (line ~1222): after a successful copy → `showToast("Share link copied.")`; after the fallback field is offered → `showToast("Share link ready — copy it from the field.")`. **Leave the `sendStatus.textContent` updates exactly as they are** (they drive the live region — never stale). Do **not** call `showToast` on any failure branch (the error banner is the failure UI).
|
||||
- Do NOT change: the `#send-status` markup/live region, `copyShareLinkWithFallback` / the inline fallback field, `showErrorBanner`, or any other page (the toast is chat-page only for this phase).
|
||||
|
||||
## Testing & Quality
|
||||
- Frontend source pins: `app.js` — a `showToast` defined and called from the two `shareCurrentChat` success branches (and **not** from any failure branch); the toast node is `aria-hidden`. `styles.css` — a `.toast` rule with a reduced-motion override.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] A successful share (clipboard path) shows the toast top-right; it auto-dismisses in ~4s.
|
||||
- [ ] A share that falls back to the inline field shows its own toast text.
|
||||
- [ ] A second share while the first toast is still up replaces it (exactly one toast node in the DOM, no stacking).
|
||||
- [ ] A **failed** share shows the error banner and **no** toast.
|
||||
- [ ] The toast never blocks pointer interaction when idle; with `prefers-reduced-motion: reduce` it appears without a transform.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Task 05 — New chat / Share action row (horizontal on desktop, stacked on mobile)
|
||||
|
||||
**Phase:** `55_save_share_ux` · **Source:** `TODO.md:6` — "New Chat and Share buttons should only be vertically stacked when in mobile, otherwise they should be horizontally next to each other"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The New chat and Share pills sit **horizontally next to each other** on desktop and stack **vertically only at ≤640px** (mobile) — today they are direct children of the vertical `.chat-shell` column, so they stack at every width.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — wrap `#new-chat-btn` and `#share-chat-btn` in a single `<div class="chat-actions">` row element, keeping the current DOM order (**New chat first, then Share**). The Save pill is already gone (task 02). The wrapper replaces the two pills as direct children of `.chat-shell`; the steering announcer / kb-banner / messages structure around them is untouched.
|
||||
2. `frontend/assets/styles.css`:
|
||||
- **Base (desktop):** `.chat-actions { display: flex; flex-direction: row; align-items: center; gap: 0.6rem; }`. As a flex **item** of `.chat-shell` (a column), the row must NOT stretch the pills to full width — `align-items: center` (not the column default `stretch`) keeps each pill at its intrinsic content width, so they read as two pills side by side, left-aligned in the column.
|
||||
- **≤640px** (the existing mobile block, line ~2536): `.chat-actions { flex-direction: column; align-items: stretch; gap: 0.5rem; }` — the pills stack full-width, New chat above Share. The **existing** ≤640px pill rules (`.new-chat-btn`/`.share-chat-btn` padding line ~2632–2644, the icon/label handling, and the `.chat-shell` label overrides line ~2647–2652) stay and apply to the stacked pills unchanged.
|
||||
- **No horizontal overflow at 360px:** the two pills + gap must fit `360px − 2 × 0.9rem` container padding. Verify the intrinsic widths; if the pair is tight at 360px, reduce the base `gap` (0.6rem → 0.5rem) or the ≤900px padding rule (line ~2532) rather than shrinking the 44px touch floor.
|
||||
- Do NOT change: the pill styling (`.new-chat-btn` / `.share-chat-btn` declarations), the header/nav bar layout, or the `.chat-shell` centered-46rem column contract (PLAN §7 — the wrapper is a normal column child, the column width is untouched).
|
||||
|
||||
## Testing & Quality
|
||||
- Frontend source pins: `index.html` — `#new-chat-btn` and `#share-chat-btn` both inside a single `.chat-actions` wrapper (New chat before Share). `styles.css` — a `.chat-actions` base rule (row) and a ≤640px override (column).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Desktop (1280×800): both pills on **one horizontal row** — Share's bounding box is to the right of New chat's (same `y` band, `Share.x > New.x + New.width`), each at intrinsic width (not full-column).
|
||||
- [ ] Mobile (390×844): the pills **stack vertically** — Share below New chat (`Share.y > New.y + New.height`), full-width.
|
||||
- [ ] No horizontal page overflow at a 360px viewport.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Phase 55 — Save by Default, Share Anonymously
|
||||
|
||||
**Source:** `TODO.md` L3–L6 — "Share chat should work anonymously without login" / "Save shouldn't be a button, every chat should be saved by default" / "Need feedback (probably dropdown notification toast) to show share worked" / "New Chat and Share buttons should only be vertically stacked when in mobile, otherwise they should be horizontally next to each other"
|
||||
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-08-31)
|
||||
**Context:** Phase 50 stores conversations in `saved_chats` through an explicit Save pill — the `saved_chats` CRUD lives under `/api/chats` in `app/api/chats.py` with a **router-wide** `dependencies=[Depends(require_admin)]` (line ~72), and the pill ships hidden, revealed at boot only when whoami says admin (`frontend/assets/app.js` boot IIFE, line ~1712). Phase 51 added save-then-share (`POST /api/chats` with `share: true` mints the 128-bit `uuid4` token in the same commit; idempotent `POST /{id}/share`) and the public read-only `/shared/<token>` page (already anonymous). The conversation itself lives in localStorage under `bor.chat.v1` (`{ v: 1, messages: [...] }` — **no row link is persisted**; `currentChatId` is module-scope only, so a reload unlinks the conversation). The chat-page pills (`#new-chat-btn`, `#save-chat-btn`, `#share-chat-btn`) are direct children of `.chat-shell` (a vertical flex column, `styles.css` line ~424), so they stack at **every** width. Feedback today is status-line text only (`#send-status`, the live region inside the composer's send button). Phase 53 (todo, preceding) stamps `sources_version` on save and re-saves the linked row after a stale Regenerate — its re-save must keep working once the Save pill is gone.
|
||||
|
||||
## Objective
|
||||
Every conversation on the chat page saves itself (no Save button), **any** visitor can turn the current conversation into a public link without signing in (with a visible toast confirming the share), and the New chat / Share pills sit horizontally on desktop, stacking vertically only on mobile.
|
||||
|
||||
## Dependencies
|
||||
- `53_stale_saved_chats` (todo, preceding) — the save-point machinery and the linked-row re-save (stale Regenerate's auto re-save must keep working after the Save pill is removed; task 02 re-points it at the shared upsert helper if it landed as a direct call).
|
||||
- `54_asset_cache_bust_revalidation` (todo, preceding) — the static-bundle / cache-busting contract the frontend changes ride on (the `?v=` rewrite picks up the changed `styles.css`/`app.js` automatically).
|
||||
- `50_chat_history` + `51_share_chat` (complete) — the `saved_chats` rows, the `/api/chats` surface, the save-then-share contract, the History page, the public `/shared/<token>` page.
|
||||
|
||||
## Tasks
|
||||
1. `01_anonymous_save_share_api.md` — open the save/share write surface to anonymous visitors (list/detail/delete/unshare stay admin-only).
|
||||
2. `02_auto_save_default.md` — retire the Save pill; every conversation auto-upserts at the existing save points and the row link survives reloads (no duplicate rows).
|
||||
3. `03_share_for_everyone.md` — ship the Share pill visible to all visitors; neutral error copy.
|
||||
4. `04_share_toast.md` — the top-right slide-down toast confirming a successful share (both success paths).
|
||||
5. `05_chat_actions_layout.md` — the `.chat-actions` row: horizontal on desktop, stacked at ≤640px.
|
||||
6. `06_e2e_save_share_ux.md` — the story Playwright suite + regressions + the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: `tests/integration/test_chats_api.py` — anonymous create (incl. save-then-share), anonymous update, anonymous share (idempotent); guest 403s on list/detail/delete/unshare; the existing admin pins stay green.
|
||||
- Frontend source pins (house style, `tests/unit/test_save_chat_ui.py` / `test_history_page.py` pattern): `app.js` (headless upsert helper, save-point triggers, `chatId` in the `bor.chat.v1` record, no Save-pill wiring, no admin-gated Share reveal, toast helper), `index.html` (no `#save-chat-btn`, `#share-chat-btn` not `hidden`, the `.chat-actions` wrapper), `styles.css` (`.save-chat-btn` gone, `.chat-actions` base + ≤640px rules, `.toast` + reduced-motion).
|
||||
- **Existing E2E pin adaptation (contract change):** `tests/e2e/test_chat_history.py` (the file-local `_save()` helper + the admin Save-pill assertions + the anonymous "absent" block) and `tests/e2e/test_share_chat.py` (the Save-click step in `test_share_from_history_and_unshare`) — the Save pill is replaced by waiting for the auto-saved row via the admin API; assertion edits limited to the new contract (tasks 02/03).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate).
|
||||
- E2E (mandatory, A16): `tests/e2e/test_save_share_ux.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Without a session: `POST /api/chats`, `POST /api/chats` with `share: true`, `PUT /api/chats/<id>`, and `POST /api/chats/<id>/share` all succeed; the same guest gets 403 on `GET /api/chats`, `GET /api/chats/<id>`, `DELETE /api/chats/<id>`, and `POST /api/chats/<id>/unshare` (unshare still revokes — the public `/api/shared/<token>` read 404s afterwards).
|
||||
- [ ] The chat page has **no Save control** at any width; a signed-out visitor who sends one question produces exactly one `saved_chats` row (auto-title, both messages); the next brain answer updates the SAME row; a reload at `/` keeps the link (the next message does not create a second row); "New chat" unlinks (a fresh conversation creates a fresh row on its first message).
|
||||
- [ ] A signed-out visitor sees the Share pill; clicking it on a non-empty conversation yields the public link (clipboard or the inline fallback field) **and** a top-right toast; the link opens read-only in a fresh anonymous context; an admin unshare from the History page revokes it.
|
||||
- [ ] Desktop (>640px): the New chat and Share pills share one horizontal row (Share right of New chat); ≤640px: stacked vertically (New chat above Share); no horizontal overflow at 360px.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_share_chat.py`, `test_chat_history.py`, `test_chat_persistence.py`, `test_stale_saved_chats.py`, `test_smoke.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Owner-locked (2026-08-31, roadmap confirmation):**
|
||||
1. **A1 — the write surface is public.** `POST /api/chats` (create, incl. save-then-share), `PUT /api/chats/<id>`, and `POST /api/chats/<id>/share` require **no session**. Row ids stay unguessable `uuid4` — the same trust model as the share token (the token IS the credential, phase 51). The management surface stays admin-only: `GET /api/chats` (list), `GET /api/chats/<id>` (detail), `DELETE`, and `POST /<id>/unshare` — the owner's History surface. Guest chats appear in the admin's History (saved by default, per L4). **This supersedes the phase-50 owner lock "save/history is admin-only".**
|
||||
2. **A2 — auto-save contract.** Triggers: the first user message creates the row (auto-title as today); every brain-done save point and the pagehide partial update it. A failed auto-save **never blocks the conversation** — a one-line status note only (no error banner), retried at the next save point. Successful auto-saves are silent (the History page is the visible proof; the toast is reserved for share, per L5).
|
||||
3. **A3 — `/?chat=<id>` boot restore stays admin-only** (History "Open"); guests keep the localStorage restore exactly as today.
|
||||
4. **A4 — the toast.** Top-right, slides down, auto-dismisses ~4s, a single instance (a new toast replaces a pending one). **Visual only** (`aria-hidden`) — the existing `#send-status` live region remains the a11y announcer (no double screen-reader read). Shown on BOTH share-success paths (clipboard copied / fallback field). Never on failure (the error banner is the failure UI).
|
||||
5. **A5 — the layout.** A `.chat-actions` wrapper around New chat + Share; horizontal row on desktop, vertical stack at the existing 640px breakpoint; pill order New chat → Share in both orientations; the existing ≤640px pill rules (padding, icon/label handling, the `.chat-shell` label overrides) stay and apply to the stacked pills.
|
||||
- **A10 honoured** — `/api/chat` stays stateless; auto-save writes the `saved_chats` row, not the chat endpoint.
|
||||
- **A16/A17 honoured** — one dedicated story E2E suite, one atomic commit.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A app/ frontend/ tests/ && git add -f .agent/phases/todo/55_save_share_ux .agent/phases/complete && git commit --no-gpg-sign -m "feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row"
|
||||
```
|
||||
@@ -1,33 +0,0 @@
|
||||
# Task 06 — E2E: save-by-default + anonymous share + regressions + commit
|
||||
|
||||
**Phase:** `55_save_share_ux` · **Source:** `TODO.md:3–6` — all four items (this task verifies the full loop in the browser)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
One isolated Playwright story suite for the whole phase: auto-save without a button, share without login (with the toast), and the action-row layout at both breakpoints — plus the regression gate and the single atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_save_share_ux.py` (new) — the story suite (isolated run; `mock_llm` deterministic; real DB per the e2e prerequisite; DB isolation follows the `test_share_chat.py` header pattern — the shared e2e Postgres keeps `saved_chats` rows between tests, so every row lookup selects by auto-title via the file's own `_chats`/`_find_row`-style helpers, and each test `_reset_db`s first):
|
||||
- **Anonymous auto-save (L4):** fresh anonymous context (no login) → `/` → assert **no** `#save-chat-btn` in the DOM → ask a question (mock answer) → wait for the turn to settle → `GET /api/chats` (admin cookies, file-local helper) reports exactly one row with the question's auto-title carrying both messages. No button press anywhere in this test.
|
||||
- **No duplicate across reload (L4):** same anonymous context → `page.reload()` → the conversation is restored from localStorage (both bubbles visible) → ask a second question → the row count for the auto-title is still **one**, and its message count grew by two (the link survived the reload — task 02's `chatId` persistence).
|
||||
- **Anonymous share + toast (L3 + L5):** fresh anonymous context → `/` → the Share pill is **visible without login** → ask a question → grant clipboard (the file's `_grant_clipboard` pattern) → click `#share-chat-btn` → the `.toast` becomes visible with the success text and auto-dismisses within ~5s → read the link (clipboard via Playwright, or the `.share-link-fallback` field text if the origin rejects the clipboard) → a **fresh incognito context** opens the `/shared/<token>` link → the conversation renders read-only (title + both bubbles, no composer/pills — the phase-51 zero-controls surface) → from an **admin** session, unshare via the History page's Unshare button → the same URL now shows the "invalid or revoked" state.
|
||||
- **Layout (L6):** desktop viewport (1280×800): `#new-chat-btn` and `#share-chat-btn` bounding boxes on one row (overlapping `y` bands; Share's `x` > New chat's `x` + width; each at intrinsic width, not the full 46rem column). Mobile viewport (390×844, `page.set_viewport_size`): stacked (Share's `y` > New chat's `y` + height). At 360px wide: no horizontal overflow (`document.documentElement.scrollWidth` ≤ 360).
|
||||
- **Admin still works (A1 sanity):** admin login → `/` → ask a question → the auto-saved row appears for the admin too (same machinery, session or not).
|
||||
2. Regressions, each in isolation (`uv run pytest tests/e2e/<file> -v --no-cov`, DB up): `test_share_chat.py` (Save-click pins already adapted in task 02), `test_chat_history.py` (auto-save pins adapted in task 02 + the anonymous Share-pill pin from task 03), `test_chat_persistence.py`, `test_stale_saved_chats.py` (phase 53 — the stale Regenerate's auto re-save must survive task 02's helper rename; if it still references the removed Save pill or `saveCurrentChat` by name, adapt those pins to the auto-save contract, edits limited to the rename/removal), `test_smoke.py`.
|
||||
3. One `--no-gpg-sign` commit staging `app/ frontend/ tests/` + the phase dir force-added (`.agent/` is gitignored by design — AGENTS.md rule 8: `git add -f .agent/…`):
|
||||
```bash
|
||||
git add -A app/ frontend/ tests/ && git add -f .agent/phases/complete/55_save_share_ux && git commit --no-gpg-sign -m "feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row"
|
||||
```
|
||||
then move `.agent/phases/todo/55_save_share_ux/` → `.agent/phases/complete/55_save_share_ux/` (stage the move with `git add -f` on the new path before committing, so the tracked phase files land under `complete/`).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (mandatory, A16): `tests/e2e/test_save_share_ux.py` green in isolation.
|
||||
- The five regression suites green in isolation; no assertion edits outside the new contract (phase-48/49/51 behavior pins — streaming, retry, the shared page's zero controls, unshare revocation — stay intact).
|
||||
- Full gate: `uv run pytest` + `uv run pytest --cov=app --cov-report=term-missing` (TOTAL >90%) + `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `test_share_chat.py`, `test_chat_history.py`, `test_chat_persistence.py`, `test_stale_saved_chats.py`, `test_smoke.py` green in isolation.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
Reference in New Issue
Block a user