chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

Remove the blanket .agent/ gitignore so the phase roadmap, user
stories, reports, and PLAN.md are versioned with the code. Only
runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log)
remain ignored. Update AGENTS.md git protocol rule to match.
This commit is contained in:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
@@ -0,0 +1,54 @@
# 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"
```
@@ -0,0 +1,33 @@
# 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/`.