refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Phase 51 — Share a Chat by Link (Anonymous View)
|
||||
|
||||
**Source:** `TODO.md` L6 — "Need a way to share a chat with a link so others can see it anonymously."
|
||||
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-08-29)
|
||||
**Context:** Builds on phase 50's `saved_chats` rows: sharing is a token on a saved chat. The public surface is a **new anonymous page** `/shared/<token>` (a real route — the static mount cannot serve a dynamic path) rendering a read-only copy of the conversation through the same record shape (thinking block, tool lines, source chips, stopped note) — no composer, no controls. The documents API is admin-only (phase 16), so a guest's source chips are plain text (owner-locked). The cache-busting middleware treats known HTML paths (the `HTML_PAGES` tuple in `app/core/caching.py`) as revalidate + `?v=`-rewrite pages; the shared page joins that contract by path prefix.
|
||||
|
||||
## Objective
|
||||
The owner can turn a saved chat into a public link (`/shared/<token>`); anyone with the link sees the conversation read-only, anonymously; unsharing revokes it.
|
||||
|
||||
## Dependencies
|
||||
- `50_chat_history` (todo) — the `saved_chats` row, the Save flow, and the History table the share actions extend.
|
||||
|
||||
## Tasks
|
||||
1. `01_share_token.md` — migration `0009_saved_chat_share_token` + the share/unshare/public-read API + the `/shared/<token>` page route + the middleware prefix.
|
||||
2. `02_share_ui.md` — the Share button (chat page, save-then-share in one action) + the History table's share column (create/copy/unshare).
|
||||
3. `03_shared_page.md` — `shared.html` + `shared.js`: the anonymous read-only rendering.
|
||||
4. `04_e2e_share_chat.md` — the story Playwright suite + regressions + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: share/unshare/public-read contract (token shape, idempotent share, unshare revokes, a wrong token 404s, no admin needed to read, `updated_at` untouched by share/unshare) + `tests/integration/test_migration_0009.py`.
|
||||
- Coverage: **>90%** on `app/`.
|
||||
- E2E (mandatory, A16): `tests/e2e/test_share_chat.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Admin: the Share button on the chat page (saved or unsaved conversation) and the History row both produce/copy the `/shared/<token>` link; an unsaved conversation is saved + shared in one action.
|
||||
- [ ] A fresh anonymous context opening `/shared/<token>` sees the full conversation read-only (thinking collapsed, tool lines, the stopped note where present, source chips as plain text) with **no** composer, Save, Share, Tune, or Retry anywhere; a wrong/revoked token shows the "invalid or revoked" state.
|
||||
- [ ] Unshare revokes: the same URL shows the invalid state afterwards; the served shared page carries the cache-busting contract (no-cache + `?v=` rewrite).
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_share_chat.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_chat_history.py`, `test_chat_persistence.py`, `test_smoke.py`, `test_cache_busting.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Owner-locked (2026-08-29, roadmap confirmation):** share links are public by design (128-bit `uuid4` token; unshare revokes); the shared page renders the full conversation read-only (thinking collapsed) with **zero** interactive controls; source chips are plain text (guests cannot open documents — the documents API is admin-only); clipboard copy with an inline-link fallback (a homelab http origin may not be a secure context).
|
||||
- **The A10 extension (phase 50, owner permission 2026-08-29) unchanged** — sharing reuses the already-stored row; no new storage beyond the token column.
|
||||
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agents/ app/ alembic/versions/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare"
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 01 — The share token + public read API + page route
|
||||
|
||||
**Phase:** `51_share_chat` · **Source:** `TODO.md:6` — "Need a way to share a chat with a link so others can see it anonymously."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
A saved chat can be shared (a token), unshared (revoked), and read publicly by token; the `/shared/<token>` URL serves the shared page (a real route ahead of the static mount) with the cache-busting contract.
|
||||
|
||||
## Work
|
||||
1. `alembic/versions/0009_saved_chat_share_token.py` — `revision = "0009"`, `down_revision = "0008"` (verify with `uv run alembic heads`): `op.add_column("saved_chats", sa.Column("share_token", postgresql.UUID(as_uuid=True), nullable=True))` + `op.create_index("ix_saved_chats_share_token", "saved_chats", ["share_token"], unique=True)` (a unique index on a nullable column — Postgres treats NULLs as distinct, the `git_sources.path` house precedent, phase 38); the down reverses both. `app/models.py` — the `SavedChat.share_token: Mapped[uuid.UUID | None]` column (nullable unique, `index=True`… expressed as `unique=True, nullable=True` on the `mapped_column` to match the migration) + the docstring line (the phase-38 `path` column's comment style). Apply: `uv run alembic upgrade head`.
|
||||
2. `app/schemas.py` — `SharedChatOut` — `title: str`, `messages: list[ChatMessage]` (the **public** read shape: no id, no timestamps, no token — a shared chat is a content snapshot, not a handle).
|
||||
3. `app/api/chats.py`:
|
||||
- `POST "/{chat_id}/share"` (admin router) → `{"chat_id": …, "share_url": "/shared/<token>"}` — 200, **idempotent**: an existing token is returned unchanged; a new token is `uuid.uuid4()`, persisted, and `updated_at` is **not** bumped (sharing is not a content edit — write the token with a Core `session.execute(update(SavedChat).where(...).values(share_token=…))`, which skips the ORM `onupdate`; pin this in the tests); 404 on unknown chat.
|
||||
- `POST "/{chat_id}/unshare"` (admin router) → `{"chat_id": …, "shared": false}` — the token set NULL (the same Core-update pattern), idempotent (an unshared chat unshares cleanly); 404 on unknown.
|
||||
- `GET "/shared/{token}"` (public — **no** admin dependency; put it on a second module-level `public_router = APIRouter(tags=["chats"])` in the same file, registered in `main.py` with `prefix="/api"`, so the JSON endpoint is `GET /api/shared/<token>`) → `SharedChatOut`; 404 `{"detail": "unknown or revoked share link"}` for a wrong or revoked token (one message — no enumeration between the two cases).
|
||||
4. **The page route** — `app/main.py`: `GET /shared/{token}` (a small router in `app/api/chats.py` or an inline route, registered **without** a prefix and **before** the static mount — the API-routes-first convention; `/shared/<uuid>` is not a static file, so without this route the mount would 404 it) → `FileResponse(static_dir / "shared.html")` (the page lands in task 03 — guard the missing file with an explicit check returning the same 404 JSON as the API, so a stale deploy never 500s).
|
||||
5. `app/core/caching.py` — extend the middleware's known-page dispatch: a path starting with `"/shared/"` gets the same treatment as `HTML_PAGES` (no-cache + `?v=` asset-rewrite on the `text/html` body — the `FileResponse` body is drained by the existing `_read_body` path). Comment it (phase 51: the dynamic share page). `tests/unit/test_caching.py` — pins: a `/shared/<uuid>` path is treated as a known HTML page (no-cache + rewrite), an `/api/shared/<uuid>` path passes through untouched, and an unknown path is untouched.
|
||||
6. `tests/integration/test_chats_api.py` — extend the house file: share (200 + `share_url` matching `/^\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/`; a second call returns the **same** token), unshare (token NULL; the public read 404s; unshare on an unshared chat → 200 idempotent), the public read (a fresh anonymous client: 200 with title + messages round-tripping and **no** `id`/`created_at`/`updated_at`/`share_token` keys in the body; a wrong token 404s with the "unknown or revoked" detail; a revoked token 404s with the same detail), the `updated_at`-unchanged pin for share/unshare, and 404s for share/unshare on unknown ids.
|
||||
7. `tests/integration/test_migration_0009.py` (new) — the house pattern: upgrade/downgrade round-trip; the unique index exists; the NULLs-distinct behavior (two rows may both carry NULL; two identical non-NULL tokens are rejected).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: as above; full suite green.
|
||||
- Coverage: **>90%** on `app/`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `POST /api/chats/<id>/share` is idempotent and leaves `updated_at` alone; `unshare` revokes; `GET /api/shared/<token>` is public + 404-safe; `GET /shared/<token>` serves the page route (404-JSON guard when the file is missing).
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 02 — Share button (chat) + the History share column
|
||||
|
||||
**Phase:** `51_share_chat` · **Source:** `TODO.md:6` — "Need a way to share a chat with a link so others can see it anonymously."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Two share entry points for the admin: the **Share** button on the chat page (an unsaved conversation is saved + shared in one action) and a Share column on the History table (create link / copy / unshare) — both copy the link with a clipboard + inline-link fallback.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — beside `#save-chat-btn`, `#share-chat-btn` (the same ghost-pill family: an inline link/share SVG + the label "Share", `aria-label="Share chat"`, ships `hidden` — revealed for admin exactly like Save); a comment documenting the save-then-share contract (2026-08-29, `TODO.md` L6).
|
||||
2. `app/schemas.py` + `app/api/chats.py` (the small server side of this task): `SavedChatCreate` gains `share: bool = False`; the create route, when `share` is true, sets `share_token = uuid.uuid4()` in the same commit and the response carries `share_url`. `SavedChatOut` and `SavedChatRow` each gain `share_url: str | None` (`None` → absent from the JSON) — the list endpoint populates it, so the History column renders from `GET /api/chats` without a second fetch.
|
||||
3. `frontend/assets/app.js`:
|
||||
- **`shareCurrentChat()`** — the `#share-chat-btn` handler (the same empty-conversation no-op guard as Save): if `currentChatId` is set → `POST /api/chats/<id>/share`; else → `POST /api/chats` with `{ messages: conversation, share: true }` → `currentChatId = created.id` (one action saves **and** shares — owner-locked). On success: copy the absolute URL of `share_url` — `navigator.clipboard.writeText(...)` in a try; on success the live region reads "Share link copied."; on failure (a non-secure http origin rejects the clipboard) render the **inline fallback**: a transient link field (an `<a>` styled as a select-on-focus field, carrying the full URL) near the status line + the live region "Share link ready — copy it from the field." (owner-locked fallback). On 403/5xx/network: `showErrorBanner` with an actionable line.
|
||||
- **Reveal on boot:** `#share-chat-btn` joins the same admin-reveal block as `#save-chat-btn`.
|
||||
- **Header comment:** the share contract (2026-08-29, `TODO.md` L6).
|
||||
4. `frontend/history.html` + `frontend/assets/history.js` — the **Share column** between Updated and the Actions/Delete cell (the `th` + the per-row cell; the table stays full-width):
|
||||
- row already shared (`share_url` present): a **Copy** button (the same clipboard + fallback helper — the per-page duplication house style: `history.js` keeps its own ~10-line copy of the helper rather than a new shared module) and an **Unshare** button (inline two-step, the phase-50 Delete-confirm pattern: "Unshare? [Yes] [No]" → `POST /api/chats/<id>/unshare` → the cell re-renders to the unshared state + the live region).
|
||||
- row unshared: a **Create link** button → `POST /api/chats/<id>/share` → the cell re-renders to the shared state (Copy + Unshare) and the link is offered for copying (same fallback pattern).
|
||||
5. `frontend/assets/styles.css` — `.share-chat-btn` (the pill family), the `.history-share` cell buttons (the Tune/Retry-family ghost buttons, ≥44px comfortable, focus-visible), `.share-link-fallback` (the inline link field — input-like look, select-on-focus), reusing the phase-50 `.history-confirm` styles for the unshare two-step.
|
||||
6. `tests/integration/test_chats_api.py` — extend: create-with-share (the response carries `share_url` matching the token shape and the row is immediately publicly readable), create-without-share (no `share_url`), the list rows carry `share_url` when shared and absent otherwise, `GET /{chat_id}` carries it too.
|
||||
7. Frontend source pins (house pattern): the `shareCurrentChat` branches (linked → POST share; unlinked → create-with-share + `currentChatId` set); the clipboard try → fallback element on rejection; the History cell's three states (unshared / shared / confirming-unshare) + the two-step unshare; `#share-chat-btn` ships hidden and reveals only for admin.
|
||||
|
||||
- ASSUMPTION (owner-locked 2026-08-29): the Share button saves + shares an unsaved conversation in one action; the clipboard copy has the inline-link fallback; unshare is inline two-step.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: as above; full suite green.
|
||||
- Coverage: **>90%** on `app/` (the create-with-share branch covered).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Admin: the Share button works on a saved (linked) and an unsaved conversation; the History column creates / copies / unshares per row.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Task 03 — The shared page (anonymous read-only)
|
||||
|
||||
**Phase:** `51_share_chat` · **Source:** `TODO.md:6` — "Need a way to share a chat with a link so others can see it anonymously."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
`/shared/<token>` renders the shared conversation for **anyone** — read-only, zero controls, nothing the share shouldn't expose — through the same record shape the chat uses.
|
||||
|
||||
## Work
|
||||
1. `frontend/shared.html` (new) — the page scaffold as `history.html` does it: the head (charset, viewport, `assets/styles.css`), the skip link, the **identical** shared header (the nav with all the admin-only links hidden — a guest never sees them — and the auth pair with `?next=/` on the Sign-in link: a guest signing in from a shared page returns to the app root; note this in a comment), then `<main id="main">` carrying the `#steering-panel` section + `#steering-announcer` (the shared panel ships on every page — phase 34) and the page content:
|
||||
- an `<h1 id="shared-title">` (JS-filled with the shared chat's title; static fallback text "Shared conversation");
|
||||
- a `.shared-note` line — "Shared via Brain of Reese — read-only." (the brand resolves through the `window.BOR_BRAND` convention like the other pages);
|
||||
- the messages section — `<section class="messages" id="messages" aria-label="Shared conversation">` using the **same** `.msg`/`.bubble`/`.thinking`/`.tool-calls` structure as the chat page, so the existing CSS applies unchanged (the shell maps to the 46rem centered chat column — reuse the `.chat-shell` class or a `.shared-shell` that maps to the same width rule, per the PLAN §7 column contract);
|
||||
- the invalid-state block `#shared-invalid` (hidden by default): "This share link is invalid or was revoked."
|
||||
- the app-footer (version span, like the other pages).
|
||||
- Scripts: `<script src="assets/brand.js"></script>` (classic, first) + `<script src="assets/markdown.js"></script>` (the classic renderer, as `index.html` loads it) + `<script type="module" src="/assets/shared.js"></script>`. **No** `document-modal.js`, **no** composer, **no** Save/Share/Retry/Tune markup at all (owner-locked: zero controls). No-CDN rule holds (local assets only).
|
||||
2. `frontend/assets/shared.js` (new) — a module:
|
||||
- read the token from `location.pathname` (the last path segment of `/shared/<token>`; a malformed/missing token → show `#shared-invalid` immediately, **no fetch**).
|
||||
- `await initSharedHeader()` (the header works for guests — whoami anonymous, the admin links stay hidden), then `GET /api/shared/<token>`:
|
||||
- 200 → set the `h1` to the title; render every message through a local `renderSharedMessage(m)` reusing the chat's record shape: user → the `.msg.user` bubble; brain → the `.msg.brain` bubble with the optional thinking block (**collapsed** — the phase-17 restore convention), the tool lines, the `is-deflected` class, the stopped note (the ~8-line `appendStoppedNote` markup duplicated locally — the per-page duplication house style), the deflection's "Maybe try" chips as **plain `<span class="suggestion-chip">`** text (not buttons — a guest tapping a chip has nowhere to go; owner-locked zero controls), and the source chips as **plain text `<span>`** (owner-locked: guests cannot open documents — the documents API is admin-only; no `href`, no modal wiring).
|
||||
- 404/other → show `#shared-invalid` (the title keeps its fallback), no data rendered, no error banner.
|
||||
- markdown through the global `renderMarkdown` (escape-first — the stored payloads are raw text, so the renderer's XSS safety applies unchanged).
|
||||
3. `frontend/assets/styles.css` — `.shared-note` (the muted meta line under the h1); the static-chip treatment scoped to the shared page (e.g. `.shared-shell .suggestion-chip { pointer-events: none; cursor: default; }` — or a distinct `.chip-static` class if cleaner; the interactive chips' styles elsewhere stay untouched); the invalid-state styling (a centered muted block); the shared shell's 46rem column mapping; the ≤640px responsive behavior (the phase-07 contract).
|
||||
4. Frontend source pins (house pattern): the token parse (a malformed path → no fetch, the invalid state shows); the 404 → invalid state (no data render); **zero interactive controls** (pin: `renderSharedMessage` never calls `renderChips`/`appendTuneButton`/`appendRetryButton`, and the rendered shared messages contain no `<button`/`<form` — i.e. chips are spans, source chips carry no `href`); `shared.html` does not reference `document-modal.js` or a composer.
|
||||
|
||||
- ASSUMPTION (owner-locked 2026-08-29): the shared page shows the full conversation (thinking collapsed) read-only; zero interactive controls; the "Maybe try" chips are plain text; the source chips are plain text (no document access for guests).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: source pins as above; full suite green.
|
||||
- Coverage: **>90%** on `app/` (unchanged — frontend-only task).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `GET /shared/<token>` (the task-01 route) serves the page; a fresh anonymous browser renders the conversation read-only.
|
||||
- [ ] A wrong/revoked token shows the invalid state; no control exists anywhere on the page.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 04 — Share E2E + regressions + commit
|
||||
|
||||
**Phase:** `51_share_chat` · **Source:** `TODO.md:6` — "Need a way to share a chat with a link so others can see it anonymously."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Prove the share → anonymous view → revoke loop in the browser, run the regressions, and commit the phase.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_share_chat.py` (new) — mock-only, DB up; admin via `tests/e2e/auth_helpers.py`; the anonymous view in a **fresh context** (`browser.new_context()` — a separate session, no cookies):
|
||||
- `test_share_from_chat_page` — admin: ask an on-topic question → `done`; (the conversation is unsaved) click `#share-chat-btn` → assert the live region reads "Share link copied." when `navigator.clipboard` is available in the context, else the fallback link field is present with the `/shared/<uuid>` URL (branch the assertion on `page.evaluate(() => !!navigator.clipboard)`); the API agrees: `GET /api/chats` (admin cookie) has the new row with a non-null `share_url` matching `/^\/shared\/[0-9a-f-]{36}$/`.
|
||||
- `test_anonymous_shared_view` — open the `share_url` in the fresh anonymous context (use a "think out loud …" on-topic question so the thinking block exists): the title = the auto-title; the user question bubble + the brain answer are present (the same deterministic answer text the admin session saw); `details.thinking` exists and is **not** open (collapsed); the source chips are plain text (zero `a.source-chip` in the shared view); **no** `#composer`, no `#save-chat-btn`/`#share-chat-btn`, no `.tune-btn`, no `.retry-btn`, and the "Maybe try" chips (if deflected) are spans, not buttons; the nav admin-only links are hidden (a guest).
|
||||
- `test_share_from_history_and_unshare` — admin, History: a row without a link → the "Create link" button → click → the cell shows Copy + Unshare (or the fallback field); open the link in the fresh anonymous context → it renders; back in admin: Unshare → the two-step confirm → Yes → the cell returns to "Create link"; the anonymous view of the same URL now shows the invalid state ("invalid or was revoked"); `GET /api/chats/<id>` (admin cookie) → `share_url` null.
|
||||
- `test_bad_token_invalid_state` — a fresh context opens `/shared/00000000-0000-4000-8000-000000000000` → the invalid state, no crash, the guest header renders.
|
||||
- DB isolation: the same distinctive-question / cleanup-in-`finally` discipline as `test_chat_history.py`.
|
||||
2. `tests/e2e/test_cache_busting.py` — add the shared page to the walk: create + share a chat via the admin API for the test, then assert `GET /shared/<token>` carries `Cache-Control: no-cache` and the served HTML's asset refs are `?v=`-tagged (the task-01 prefix extension) — the minimal addition to the suite's page coverage.
|
||||
3. Regression pass (isolation runs): `test_chat_history.py` (the schemas + the History table changed), `test_chat_persistence.py` (the chat page's boot + buttons), `test_smoke.py`, `test_cache_busting.py` (after the shared-page addition).
|
||||
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
5. Commit (Conventional Commits, `--no-gpg-sign`) — the message from the phase overview's Commit section — staging this phase's files; move `.agents/phases/todo/51_share_chat/` → `.agents/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_share_chat.py -v --no-cov` green in isolation.
|
||||
- Coverage: **>90%** on `app/`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All four story tests pass in isolation; the four regression suites pass in isolation.
|
||||
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||
Reference in New Issue
Block a user