phase: 153_document_viewer_context
That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified. ## Phase 153 — final verification pass report **Verified (all tasks 01–04 complete in `complete/`):** - Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true - Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor - Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched **Test / lint / coverage results:** - `uv run pytest` → **3544 passed**, 0 failed (full unit + integration) - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate) - `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM) - `uv run ruff check . && uv run pyright` → clean (0 errors/warnings) **Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness. **Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed. **Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work. **Next pending phase:** `154_sync_status_freshness`.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Phase 154 — sync + upload stamps say WHEN (date-qualified freshness)
|
||||
|
||||
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "RAG 'Synced 16:06' badge is time-only — ambiguous the next day" + "Sources 'Uploaded — press Sync sources to import it.' has no timestamp — it's server-persisted and re-rendered on every mount; after that archive is imported by a sync, the line can sit there indefinitely and mislead."
|
||||
**Story:** n/a (audit-derived phase)
|
||||
**Context:** `sources.js::fmtSyncTime(iso)` (L814–819) — `HH:MM` only; its consumer `applySyncSuccess` (L1195–1196) settles the RAG sync button as `Synced ${time}` (or bare "Synced" when the ISO is unparseable — the app-restart case: the sync status is in-memory, A12). The label parts writer (L821 onward) carries the same text in the button `title` + the running `aria-label` — the qualified stamp rides all three (the phase-128 parts-writer idiom: write once, three surfaces). `git-sources.js::fmtUploadResult(detail, name)` (L1128–1133) — the no-count contract (phase 90): `Uploaded <name> — press Sync sources to import it.` (fresh 202 settle, the page-local `lastUploadName`) or the nameless `Uploaded — press Sync sources to import it.` (the boot re-render, `initUploadStatus` L1279–1304, where the name is unknown after a reload). `GET /api/git-sources/upload/status` (git_sources.py L704–729) ALREADY exposes `started_at` / `finished_at` (ISO) + `detail` — the server change is ZERO; this phase is frontend-only. The per-page duplication idiom is established (history.js L301's copyText, sources.js vs app.js) — the date-qualifier helper is duplicated into the two modules, not shared.
|
||||
|
||||
## Objective
|
||||
Both freshness stamps tell the owner *when*: a same-day stamp stays `HH:MM` (byte-identical to today's behavior); a non-same-day stamp carries the date (`M/D, h:MM AM/PM` — the table-date family); the Sources upload line carries its completion stamp on every render path (fresh settle AND boot re-render), so a stale "Uploaded" line is self-dating.
|
||||
|
||||
## Dependencies
|
||||
- `150_drop_sync_result` (complete) — the settled sync button is the sole "Synced" surface (the phase-149 box is the running label; the button settles it).
|
||||
- `64_sync_upload_progress` (complete) — the upload 202/poll/re-attach state machine this phase's line change rides on.
|
||||
- `90_upload_no_scan` (complete) — the no-count result-line contract (phase 90, A2) this phase EXTENDS (a stamp is added; no counts return).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **The date-qualifier rule (ASSUMPTION A1, owner-locked):** the stamp is computed from the ISO's LOCAL date (the page's clock — the table dates already use local time). Same local year as now AND same calendar day → `HH:MM` (byte-identical to the current `fmtSyncTime` output — a same-day badge is unchanged). Otherwise, same year → `M/D, h:MM AM/PM` (e.g. `10/2, 3:14 PM`); different year → `M/D/YYYY, h:MM AM/PM` (e.g. `1/3/2025, 9:12 PM`). An unparseable/missing ISO → the existing fallback (sync: bare "Synced"; upload: no stamp — the pre-phase strings). `h:MM` uses the locale 12-hour form WITHOUT leading zero on the hour (matching the table dates' `5:34:06 PM` family — drop the seconds), month/day without leading zero (the tables' `M/D/YYYY` shape).
|
||||
- **RAG sync badge (task 01):** `fmtSyncTime` becomes the date-qualifier (keep the name — its contract widens; or add `fmtStamp` and delegate — the executor's choice, the pins fix the output). `applySyncSuccess` is unchanged in shape (`settleSyncButton(time ? \`Synced ${time}\` : "Synced")` — the qualifier lives inside the formatter). The parts writer's comment block (L821 onward) names the qualified stamp in the label parts where the `HH:MM` contract is cited (cite, don't restate).
|
||||
- **Sources upload line (task 02):** `fmtUploadResult(detail, name, when)` — `when` is the precomputed stamp string (the caller computes it from `status.finished_at`, falling back to `status.started_at`; the success state always has a `finished_at` on the server, the fallback is the defensive guard for a legacy/partial payload). Shapes (the phase-90 contract extended, name present/absent): `Uploaded <name> (<stamp>) — press Sync sources to import it.` / `Uploaded <stamp> — press Sync sources to import it.`; `when` empty (running state, legacy payload) → BYTE-IDENTICAL to the current strings (the no-stamp fallback). Call sites: the poll settle (L1239–1244, the page-local `lastUploadName`) and `initUploadStatus`'s success branch (L1279–1304, nameless) — both read `status.finished_at` from the same `/upload/status` payload. The L78–96 and L1125–1133 comment blocks name the stamp in the contract (cite, don't restate).
|
||||
- **A11y:** the stamps are text in existing `role="status"` / button-label surfaces — no new nodes, no color-alone state (B5).
|
||||
|
||||
## Tasks
|
||||
1. `01_rag_sync_stamp.md` — the date-qualifier in `sources.js` (`fmtSyncTime`'s contract widens; same-day byte-identical).
|
||||
2. `02_upload_line_stamp.md` — the `when` param on `fmtUploadResult` + both call sites reading `finished_at`/`started_at`.
|
||||
3. `03_unit_pins.md` — the qualifier's boundary pins + both stamp shapes (with/without name, with/without stamp).
|
||||
4. `04_e2e_status_freshness.md` — the dedicated Playwright suite.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: source-level pins — the qualifier (same-day `HH:MM` byte-identical; yesterday `M/D, h:MM AM/PM`; last-year `M/D/YYYY, h:MM AM/PM`; garbage/missing ISO → the pre-phase fallbacks — a fixed clock in the test, not wall-clock math on `new Date()`); `fmtUploadResult`'s four shapes (name×stamp, name×no-stamp byte-identical, no-name×stamp, no-name×no-stamp byte-identical); the `applySyncSuccess` settle string for a non-same-day `finished_at`.
|
||||
- Integration: ZERO server change — the `/upload/status` payload is untouched (regression guard: the full-dict pin, if one exists, stays byte-identical).
|
||||
- Coverage: **>90%** on the modified JS (source-level pins + E2E).
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
|
||||
- E2E: `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the stamp format is a visible UI change — name it where the sync/upload surfaces are described).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a same-day sync settles as `Synced HH:MM` — byte-identical to pre-phase (the pin + the E2E)
|
||||
- [ ] a non-same-day `finished_at` (the unit pins; the E2E asserts the same-day shape on a real mock sync — the date branch is unit-pinned with a fixed clock, the E2E never depends on the wall clock)
|
||||
- [ ] the upload line carries the stamp on BOTH render paths (the fresh 202 settle with the name; the boot re-render without it) — the E2E seeds a prior upload via the API and reloads the page
|
||||
- [ ] a running/legacy upload payload renders the pre-phase strings byte-identically (no partial-state regression)
|
||||
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
|
||||
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 01 — the RAG sync badge is date-qualified
|
||||
|
||||
**Phase:** `154_sync_status_freshness` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The settled RAG sync button reads `Synced HH:MM` when the run finished today and `Synced M/D, h:MM AM/PM` (or `M/D/YYYY, …` across years) otherwise — a badge that no longer lies about its age.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/sources.js`:
|
||||
- `fmtSyncTime(iso)` (L814–819): the contract widens to the date-qualifier (the phase design's A1 rule). Keep the function name if the delegation is clean; otherwise add `fmtStamp(iso)` and let `fmtSyncTime` delegate — the pins fix the OUTPUT, the executor's choice is internal. The parse guard (`Number.isNaN(d.getTime()) → ""`) is UNCHANGED — an unparseable ISO still yields the bare "Synced" settle.
|
||||
- the qualifier compares LOCAL calendar dates (the page's clock — the tables' `Updated` columns already render local dates; the idiom is there in `history.js`'s updated-cell formatter — mirror its `toLocaleDateString`/manual shape, the `M/D(/YYYY), h:MM AM/PM` family with no leading zeros).
|
||||
- `applySyncSuccess` (L1195–1196) is UNCHANGED in shape — the qualifier lives inside the formatter; the settle string for a same-day run is byte-identical to pre-phase.
|
||||
- the parts-writer comment block (L821 onward) names the qualified stamp where the `HH:MM` label contract is cited (cite, don't restate — the block already lists the label parts; the badge is one of them).
|
||||
2. No CSS change (the label part ellipsizes; the longer stamp is just more text in the same part).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the source-level pins gain the qualifier's boundary set with a FIXED clock (the test's fake clock — never `new Date()` in the test body): same-day → `HH:MM` (byte-identical to the pre-phase pin); yesterday (same year) → `M/D, h:MM AM/PM`; last-year same M/D → `M/D/YYYY, h:MM AM/PM`; hour formatting (`3:14 PM` — no leading zero on the hour; `12:05 AM` edge); garbage ISO → `""` (unchanged).
|
||||
- Coverage: **>90%** on the modified JS.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] same-day settle is byte-identical to pre-phase (`Synced HH:MM`)
|
||||
- [ ] the boundary pins pass on a fixed clock; the wall clock never appears in a test assertion
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 02 — the Sources upload line carries its completion stamp on every render path
|
||||
|
||||
**Phase:** `154_sync_status_freshness` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
`Uploaded — press Sync sources to import it.` becomes self-dating — `Uploaded 21:14 — …` (or `Uploaded 10/2, 3:14 PM — …`) on BOTH render paths (the fresh 202 settle with the page-local name, the boot re-render without it) — so a line that outlives its upload can no longer masquerade as fresh.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/git-sources.js`:
|
||||
- `fmtUploadResult(detail, name, when)` (L1128–1133): the third param is the PRECOMPUTED stamp string (the phase design's A1 format, computed by a page-local `fmtUploadStamp(status)` helper — the per-page duplication idiom; it reads `status.finished_at` first, falls back to `status.started_at`, and yields `""` when both are missing/absent). Shapes:
|
||||
- `detail.message === "uploaded"` + `name` + `when`: `Uploaded ${name} (${when}) — press Sync sources to import it.`
|
||||
- `detail.message === "uploaded"` + no `name` + `when`: `Uploaded ${when} — press Sync sources to import it.`
|
||||
- `when === ""` (any name state): BYTE-IDENTICAL to the current strings (the legacy/partial-payload guard — the phase-90 contract for a payload without timestamps).
|
||||
- the non-`uploaded` branch (`"The upload finished."`) is unchanged.
|
||||
- the poll settle (L1239–1244): `fmtUploadResult(detail, lastUploadName, fmtUploadStamp(status))` — the poll tick already holds the full status object.
|
||||
- `initUploadStatus`'s success branch (L1279–1304): the same third arg from the boot-fetched status (the name stays null — the page-local `lastUploadName` is null after a reload; the stamp comes from the SERVER payload, so the nameless line is still self-dating after a reload — that is the point).
|
||||
- the L78–96 and L1125–1133 comment blocks name the stamp in the no-count contract (cite, don't restate — the blocks already document the nameless re-render; add the stamp to the described string shape).
|
||||
2. NO server change: `GET /api/git-sources/upload/status` already exposes `started_at` / `finished_at` (git_sources.py L704–729) — do not touch `app/` in this task.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the source-level pins gain the four `fmtUploadResult` shapes (name×stamp, name×no-stamp → byte-identical to the pre-phase string, no-name×stamp, no-name×no-stamp → byte-identical) + `fmtUploadStamp` (finished wins over started; started when finished is missing; `""` when both absent; a garbage ISO → `""`).
|
||||
- Coverage: **>90%** on the modified JS.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the four shape pins pass; the two no-stamp shapes are byte-identical to pre-phase
|
||||
- [ ] `initUploadStatus`'s success branch passes the stamp (a boot re-render of a prior upload shows the stamp even with the name unknown)
|
||||
- [ ] `app/` is untouched in this task (a `git diff --name-only` scoped check)
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,22 @@
|
||||
# Task 03 — unit pins for the qualifier and the four stamp shapes
|
||||
|
||||
**Phase:** `154_sync_status_freshness` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The date-qualifier rule (A1) and both stamp line shapes are pinned at the source level on a fixed clock — a later refactor cannot silently revert to time-only or break the byte-identity fallbacks.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/` — locate the existing `sources.js` / `git-sources.js` source-level pin suite (the phase-64/90/150/152 suites are the precedent — read `test_frontend_sync_upload.py` and its siblings for the idiom) and extend it:
|
||||
- the qualifier (task 01's boundary set — fixed clock; the byte-identity of the same-day output against the PRE-PHASE pinned string — pin the old string verbatim in the test so the byte-identity is assertable, not assumed);
|
||||
- `fmtUploadResult`'s four shapes (task 02 — including the two byte-identity fallbacks pinned against the pre-phase strings);
|
||||
- `fmtUploadStamp` (finished/started/missing/garbage).
|
||||
2. The `GET /api/git-sources/upload/status` payload pin (if the suite pins the full dict — the phase-64 precedent): UNCHANGED — this phase adds no server keys; the pin stays byte-identical (a regression guard that task 02 really touched nothing in `app/`).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; every pre-existing pin stays green (additions only — no pin deleted or loosened).
|
||||
- Coverage: the pins exercise the modified branches.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
|
||||
- [ ] the two byte-identity fallback pins assert against the verbatim pre-phase strings
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,22 @@
|
||||
# Task 04 — the dedicated Playwright suite: date-qualified stamps
|
||||
|
||||
**Phase:** `154_sync_status_freshness` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_sync_status_freshness.py`)
|
||||
|
||||
## Objective
|
||||
An isolated E2E proves the two visible contracts on the live UI: a fresh mock sync settles the RAG button as `Synced HH:MM` (same-day shape), and the Sources upload line carries its stamp on BOTH render paths — the fresh settle (with the file name) and the boot re-render after a reload (nameless, still stamped from the server payload).
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_sync_status_freshness.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the neighboring sync suites — `test_sync_image_progress.py` (phase 152) and the phase-90/64 suites are the precedents; read one and mirror its boot, do not invent a new fixture):
|
||||
- **test_fresh_sync_settles_same_day_shape:** a mock sync run (the suite's sync fixture — a small fixture source; the mock-LLM makes the run fast) → the RAG sync button settles; assert the label matches `Synced \d{2}:\d{2}` (the same-day shape — the test runs "now", so the qualifier must yield `HH:MM`; a regex pin, NOT a wall-clock comparison — the suite never asserts on the actual time value).
|
||||
- **test_upload_line_fresh_settle_carrys_name_and_stamp:** a real small fixture archive is uploaded through the UI (the phase-90 suite's upload idiom — a generated zip in `tmp_path`) → the 202 toast → the poll settles → the ready-for-sync line reads `Uploaded <name> (<stamp>) — press Sync sources to import it.` (the name is the fixture's safe name; the stamp matches the `(\d{2}:\d{2}|\d{1,2}/\d{1,2}(?:/\d{4})?, \d{1,2}:\d{2} [AP]M)` shape pin — same-day in practice, the shape pin covers both).
|
||||
- **test_upload_line_boot_rerender_stamped_nameless:** continue from the previous state (or re-seed): RELOAD the Sources view/page → the boot re-render (`initUploadStatus`'s success branch) shows the NAMELESS line WITH the stamp: `Uploaded <stamp> — press Sync sources to import it.` (the name is gone after the reload — the phase-90 contract — but the stamp survives because it comes from the server payload: THIS is the regression the phase fixes).
|
||||
- **test_running_upload_line_unchanged (regression guard):** a running upload (the poll's processing state) never renders the ready-for-sync line (the phase-90 contract — the line appears only on settle; assert the bare "Processing…" label during the run with the suite's slow-LLM/delay idiom if a deterministic in-flight observation is needed, otherwise the settle-only assertion).
|
||||
2. The suite is DETERMINISTIC: mock-LLM only, no `E2E_REAL_LLM` dependency; time assertions are SHAPE pins (regex), never value comparisons against the wall clock.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` green in isolation
|
||||
- [ ] the boot-re-render test proves the nameless line carries the stamp (the phase's actual fix)
|
||||
- [ ] the suite asserts shapes, not wall-clock values; no real-LLM dependency
|
||||
@@ -0,0 +1,42 @@
|
||||
# Phase 155 — folder summaries in varied, natural language
|
||||
|
||||
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "All `lite` folder descriptions open with 'This folder contains…' — formulaic; a prompt nudge for variety would help."
|
||||
**Story:** n/a (audit-derived phase; the folder-summary machinery is phase 94's — `94_ls_tree_drilldown` (complete))
|
||||
**Context:** `app/rag/folder_summaries.py::FOLDER_SUMMARY_INSTRUCTION` (L102–108) — the current neutral instruction: "From the document list below, write a 1-3 sentence plain-text summary of what this folder's documents cover, in natural language. Do not use markdown. Do not invent anything that is not in the list." The `FOLDER_SUMMARY_MODE` marker (L91) and the `FOLDER_HEADER_PREFIX` (`"Folder: "`) are the E2E mock's keys — `tests/e2e/mock_llm.py` keys on the marker + the header line (a canned reply that is a pure function of the request), NOT on the instruction text — an instruction change is mock-safe as long as the marker + header prefix stay byte-identical. `generate_folder_summaries(db, llm, *, skip, only_missing, changed_docs, on_progress)` (L440) — `changed_docs=None` is the FULL regeneration (every candidate folder; the `scripts/import_docs.py` CLI's default); the sync path is change-gated (phase 127). PLAN's "persona text changes through the plan, not in code" does NOT bind this constant — it is a one-shot prompt constant (the phase-94/127 idiom), not the chat persona; the marker contract is the load-bearing piece and it is untouched. LLM calls are free (A21) — a full regeneration's cost is not a constraint.
|
||||
|
||||
## Objective
|
||||
New and regenerated folder summaries open in varied, natural language — no more catalogue of "This folder contains…" — via an instruction nudge, plus a one-shot `scripts/regen_folder_summaries.py` that refreshes every stored summary against the new instruction (the live RAG catalogue's 15+ source-root rows currently all carry the formulaic opener).
|
||||
|
||||
## Dependencies
|
||||
- `94_ls_tree_drilldown` (complete) — the folder-summary machinery (grouping, prompt, upsert, prune).
|
||||
- `127_sync_incremental_folder_summaries` (complete) — the `changed_docs` mode contract the new script's FULL mode (`changed_docs=None`) must not disturb.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **The nudge (task 01):** `FOLDER_SUMMARY_INSTRUCTION` gains the variety direction: keep the 1–3 sentence / plain-text / no-markdown / no-invention constraints UNCHANGED; add: "Open in a varied, natural way — state what the folder is for, the way a person would describe it. Do NOT begin with 'This folder contains' or 'The <folder name> folder contains'." (ASSUMPTION A1, owner-locked — the exact wording above; the executor may polish grammar but not the two forbidden openers, which are the pin.) `FOLDER_SUMMARY_MODE` and `FOLDER_HEADER_PREFIX` stay byte-identical (the mock contract — a unit pin asserts it).
|
||||
- **The regen script (task 02):** `scripts/regen_folder_summaries.py` — the `scripts/import_docs.py` entrypoint idiom (argparse; the app settings/`get_settings()` fail-loud boot; the DB session construction; the `LLMClient` construction — read `scripts/import_docs.py`'s main path and mirror its exact setup, the `--limit N` debug passthrough included if import_docs has one — check and match). Body: `await generate_folder_summaries(session, llm)` — FULL mode (`changed_docs=None` the default), `only_missing=False` (a REGEN — every stored row is refreshed, not just gaps), `skip=False`. End: the importer's cron-safe single summary log line idiom (one line: counts out of the returned stats dict) + the per-`uv run` exit code 0 only on success. NO new API surface, NO schema change, NO change to the sync path's change-gated behavior (phase 127's contract is untouched — the script is a standalone maintenance entrypoint).
|
||||
- **The mock (task 04's suite prerequisite):** `tests/e2e/mock_llm.py`'s CANNED folder-summary reply may evolve (it is a pure function of the request — the marker/header keys are unchanged); its canned text for a folder is updated to the new voice (e.g. "Documentation for <folder>, covering <first-doc-theme> …" — a natural opener, never the forbidden ones) so the E2E can assert the stored text's shape. The mock's OTHER markers/contracts are untouched.
|
||||
|
||||
## Tasks
|
||||
1. `01_prompt_nudge.md` — the instruction update + the marker/header byte-identity pins.
|
||||
2. `02_regen_script.md` — `scripts/regen_folder_summaries.py` (full mode, the import_docs idiom).
|
||||
3. `03_unit_pins.md` — the instruction pin, the mock-contract guard, the script wiring.
|
||||
4. `04_e2e_folder_voice.md` — the dedicated Playwright suite (the mock's new canned voice + the RAG table rendering it).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `FOLDER_SUMMARY_INSTRUCTION` pinned (the two forbidden openers present as the constraint; the 1–3 sentence/plain-text/no-markdown/no-invention words preserved); `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX` byte-identical (the mock contract); the script's wiring (a fake LLM + a fixture catalogue: `generate_folder_summaries` called with `changed_docs=None`, `only_missing=False`, `skip=False` — the stats dict surfaces in the summary line; a bad settings boot fails loud — the app's validator idiom).
|
||||
- Integration: a full-mode regeneration over a fixture catalogue upserts every candidate row (the phase-94 idiom) and prunes nothing (no catalogue change — the prune rule is count-driven; a pure regen touches only `updated_at` + text).
|
||||
- Coverage: **>90%** on new/modified code (`app/rag/folder_summaries.py`, the script).
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
|
||||
- E2E: `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` in isolation (DB up: `podman compose up -d db`; the deterministic `tests/e2e/mock_llm.py`).
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the new script is a user-visible command — it joins the README's import/update workflow list beside `import_docs`; the summary voice is a visible content change).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX` are byte-identical to pre-phase (the mock contract pin)
|
||||
- [ ] `uv run python -m scripts.regen_folder_summaries` runs a FULL regeneration (the stats summary line logs; exit 0; every candidate row's `updated_at` advances, unchanged folders' text is the mock's new voice)
|
||||
- [ ] the sync path's change-gated behavior is byte-identical (phase 127's pins stay green — no `app/api/sync.py` change)
|
||||
- [ ] the E2E: a mock-LLM folder summary is stored + rendered in the RAG table WITHOUT the forbidden openers
|
||||
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
|
||||
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Task 01 — the instruction nudge (varied openers, marker untouched)
|
||||
|
||||
**Phase:** `155_folder_summary_voice` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
`lite` stops opening every folder summary with "This folder contains…" — the instruction gains the variety direction while the E2E mock's keys (the `FOLDER_SUMMARY_MODE` marker + the `FOLDER_HEADER_PREFIX`) stay byte-identical.
|
||||
|
||||
## Work
|
||||
1. `app/rag/folder_summaries.py` — `FOLDER_SUMMARY_INSTRUCTION` (L102–108): keep the existing four constraints (1–3 sentence; plain text; no markdown; no invention) and ADD the variety direction (the phase design's A1 wording — the two forbidden openers are the pin): "Open in a varied, natural way — state what the folder is for, the way a person would describe it. Do NOT begin with 'This folder contains' or 'The <folder name> folder contains'." (grammar may be polished; the two forbidden openers and the "varied, natural way" direction must survive).
|
||||
2. `FOLDER_SUMMARY_MODE` (L91) and `FOLDER_HEADER_PREFIX` (`"Folder: "`) — UNTOUCHED (byte-identical — the mock's keys; a unit pin asserts it).
|
||||
3. `app/rag/folder_summaries.py` module docstring — where the instruction's contract is described, name the nudge (cite the phase, don't restate the whole contract).
|
||||
4. `tests/e2e/mock_llm.py` — the CANNED folder-summary reply (the pure function of the request): update its text to the new voice (a natural opener naming the folder — e.g. "Documentation for <folder> …" — NEVER the two forbidden openers). The marker + header keys are untouched; the reply stays a pure function of the request (the same inputs → the same output — the determinism contract).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the instruction pin (the two forbidden openers present as the constraint; the four preserved constraint words present); the marker + header prefix byte-identical pins; the mock's canned-reply shape (a folder's reply names the folder, avoids the forbidden openers, is a pure function of the request — two identical requests → identical replies).
|
||||
- Coverage: **>90%** on the modified module (the pins exercise the changed constant).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the marker + header prefix pins are byte-identical to pre-phase
|
||||
- [ ] the mock's canned folder reply avoids the forbidden openers and stays deterministic
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 02 — `scripts/regen_folder_summaries.py` (one-shot full regeneration)
|
||||
|
||||
**Phase:** `155_folder_summary_voice` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
A standalone maintenance entrypoint refreshes EVERY stored folder summary against the (new) instruction in one `uv run` — the live catalogue's 15+ source-root rows all currently carry the formulaic opener, and the sync path's change-gated mode (phase 127) never re-touches unchanged folders, so a one-shot is the only way to re-voice the whole catalogue.
|
||||
|
||||
## Work
|
||||
1. `scripts/regen_folder_summaries.py` — the `scripts/import_docs.py` entrypoint idiom (READ that file first and mirror its exact setup — do not invent a second pattern):
|
||||
- `build_parser()` (argparse) — `--limit N` (the debug passthrough, same meaning as import_docs': cap the walk/generation; the summary line names it) if import_docs' parser has it — match import_docs' actual flags, do not guess;
|
||||
- `main(argv: list[str] | None = None) -> int` + the `if __name__ == "__main__": raise SystemExit(main())` tail (match import_docs' tail);
|
||||
- `get_settings()` + `configure_logging(settings.log_level)` + `configure_debugging()` (the phase-8 debug idiom — `DEBUGPY=1` honored, the AGENTS.md rule 7 contract);
|
||||
- `llm = LLMClient()`; the short-lived DB session (the phase-53 convention — the script's own session commits; the generator only flushes — read import_docs' session handling and mirror it);
|
||||
- the body: `stats = await generate_folder_summaries(session, llm)` — FULL mode (`changed_docs=None` is the default — do NOT pass it), `only_missing=False` (a REGEN — every stored row is refreshed, not just gaps; do NOT pass it either — the defaults), `skip=False` (do not pass);
|
||||
- the cron-safe SINGLE summary line (the importer's idiom — one greppable line: `regen_folder_summaries: folders=<n> …` out of the returned stats dict) on the logger;
|
||||
- exit 0 on success; non-zero + a named stderr line on failure (the fail-loud idiom — a missing admin secret / DB outage names the failure, never a silent partial).
|
||||
2. NO new API surface; NO schema change; NO change to `app/api/sync.py` or the importer (phase 127's change-gated contract is untouched — the script is a consumer of the existing `changed_docs=None` full mode).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the script's wiring (a fake LLM + a fixture catalogue: `generate_folder_summaries` is called with the FULL-mode defaults — assert the call's kwargs; the stats dict surfaces in the summary line; a settings/boot failure exits non-zero with the named line — the app validator idiom).
|
||||
- Integration: a full-mode regeneration over a fixture catalogue upserts EVERY candidate row (text + `updated_at` advance for all of them — a pure regen, no prune: the catalogue is unchanged, so the count-driven prune rule fires for nothing).
|
||||
- Coverage: **>90%** on the script (the wiring pins exercise main()'s paths).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -m scripts.regen_folder_summaries` runs the full regeneration against a fixture DB: every candidate row's `updated_at` advances, the single summary line logs, exit 0
|
||||
- [ ] `DEBUGPY=1 uv run python -m scripts.regen_folder_summaries` honors the listener (the debugging idiom — the unit test for `configure_debugging`'s contract, the phase-8 precedent)
|
||||
- [ ] `app/api/sync.py` + `app/rag/importer.py` are untouched (`git diff --name-only` scoped check)
|
||||
- [ ] `uv run pytest -q` green (the script's pins); `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,22 @@
|
||||
# Task 03 — unit pins: the instruction, the mock contract, the script wiring
|
||||
|
||||
**Phase:** `155_folder_summary_voice` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The load-bearing pieces are pinned: the instruction carries the two forbidden openers as its constraint (the four old constraints preserved), the mock's keys are byte-identical, the mock's canned reply is deterministic and opener-free, and the script calls the generator in full mode.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/` — the folder-summaries pin suite (the phase-94/96/127 suites are the precedent — locate `test_folder_summaries.py` or its equivalent and extend it):
|
||||
- `FOLDER_SUMMARY_INSTRUCTION`: the two forbidden openers present as the constraint; the four preserved constraint words (1–3 sentence; plain text; no markdown; no invention) present;
|
||||
- `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX`: byte-identical to the pre-phase values (pin the verbatim strings — the mock contract);
|
||||
- the mock's canned folder reply (in `test_mock_llm.py` or the mock's own suite — the phase's idiom): names the folder, avoids the two forbidden openers, is a pure function of the request (two identical requests → identical replies; different folders → different replies keyed on the header line).
|
||||
2. The script's wiring pins (task 02's list — a new test module beside the import_docs' script pins, mirroring that idiom).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; every pre-existing pin stays green (additions only).
|
||||
- Coverage: the pins exercise the changed constant + the script's paths.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
|
||||
- [ ] the marker/header pins assert the verbatim pre-phase strings
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,21 @@
|
||||
# Task 04 — the dedicated Playwright suite: the new voice, end to end
|
||||
|
||||
**Phase:** `155_folder_summary_voice` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_folder_summary_voice.py`)
|
||||
|
||||
## Objective
|
||||
An isolated E2E proves the voice change end to end: a KB change triggers the folder regeneration under the mock LLM, the STORED summary carries the mock's new-voice canned text (no forbidden opener), and the RAG catalogue renders it.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_folder_summary_voice.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the phase-127/94 suites — read `test_incremental_sync.py` / the folder-summary E2E for the boot + the KB-change fixture, mirror it, do not invent a new one):
|
||||
- **test_regen_summary_avoids_forbidden_openers:** the fixture KB change (a new document under a fixture source folder — the suites' idiom) triggers the sync's folder regeneration (the change-gated path, phase 127) → the stored `folder_summaries` row for that folder equals the mock's canned reply for that folder (a pure function — assert the EXACT canned string) AND does not start with "This folder contains" / "The <folder> folder contains" (the shape pin);
|
||||
- **test_rag_table_renders_the_stored_voice:** the RAG view's folder table (the phase-97 catalogue) shows the stored text in the DESCRIPTION cell for the regenerated folder (the text is user-derived — `textContent` only, the house rule — assert the visible cell text equals the canned reply, truncated display aside: the cell's `title`/full text attribute carries the full string if the display ellipsizes — check the kb-table cell's idiom in the phase-97 suite and assert accordingly);
|
||||
- **test_unchanged_folders_keep_their_rows (phase-127 regression guard):** a KB change in folder A does not touch folder B's stored row (text + `updated_at` unchanged — the phase-127 contract; assert via the API/DB the way the phase-127 suite does).
|
||||
2. The suite is DETERMINISTIC: mock-LLM only (the canned reply is a pure function — no `E2E_REAL_LLM` dependency).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` green in isolation
|
||||
- [ ] the stored-text assertion is an exact match against the mock's canned reply (deterministic)
|
||||
- [ ] the phase-127 regression guard is asserted (unchanged folders keep their rows)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Phase 156 — tables: sticky headers + ellipsis on clipped labels
|
||||
|
||||
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "sticky table headers (RAG/Sources/History — the tables are long; the header scrolls away)" + "At ~1,009px window width the rightmost table columns start clipped with no ellipsis hint on the 'Hidden'/'Edit' labels (the wrapper scrolls fine — just subtle)".
|
||||
**Story:** n/a (audit-derived phase; the table machinery is phase 97's `97_kb_tree_catalog` + phase 86's `86_history_page_width`, both complete)
|
||||
**Context:** `styles.css:: .table-wrap` (L2528–2534: `position: relative; background; border; radius; box-shadow; overflow-x: auto`) wraps `.docs-table` (`width: 100%; min-width: 640px`) — the RAG folder table (the `kb-folders-table` class), the Sources table (`#git-sources-table-wrap`, L3390), and the History table (the phase-50/86 width contract, L5583). The thead already has a TINTED background (L530 comment: "the thead tinted") — the sticky design needs it OPAQUE (rows must not show through on scroll). The clipped cells: the Sources table's `Hidden` checkbox label + `Ignore paths`/`Remove` buttons, the RAG table's `Edit` button, the History table's `Copy`/`Unshare`/`Create link`/`Delete` buttons — today a clipped label hard-cuts (no `text-overflow: ellipsis`). **The sticky constraint (the design's load-bearing fact):** a `position: sticky` `th` sticks to its NEAREST SCROLLING ANCESTOR — with `overflow-x: auto`, the wrapper's computed `overflow-y` is `auto` too, so the wrapper IS the scroll container; but it has no height bound, so it never scrolls vertically — the PAGE does. A sticky header in an unbounded wrapper therefore scrolls away with the page. The header sticks only if the wrapper itself is the vertical scroll region (a bounded `max-height`) — the standard scrollable-table-with-sticky-header pattern. That is the UX trade this phase makes (ASSUMPTION A1): the tables become bounded internal-scroll panels; the page no longer carries their height.
|
||||
|
||||
## Objective
|
||||
On the three catalogue tables (RAG folders, Sources, History): the header row stays pinned while the user scrolls the table's own (now bounded) vertical scroll, and a clipped cell label shows `…` instead of a hard cut — the full text remains reachable (title/aria, the established idiom).
|
||||
|
||||
## Dependencies
|
||||
- `97_kb_tree_catalog` (complete) — the RAG folder table + the `.table-wrap` card.
|
||||
- `86_history_page_width` (complete) — the History table's width contract (the sticky design must keep it — the table stays full-width inside the bounded panel).
|
||||
- `63_unambiguous_document_listing` (complete) — the Sources table's column idiom (the `Hidden` label + per-row actions).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **A1 (owner-locked):** the bounded-panel design — `.table-wrap` gains `max-height` (ASSUMPTION A2: `min(70vh, 64rem)` — the phase picks this; it must keep a 15-row table scrolling while a 3-row one fits without a scrollbar: the `min()` of a viewport fraction and a fixed cap does exactly that) + `overflow: auto` (superseding `overflow-x: auto` — the horizontal scroll contract is UNCHANGED: the table's `min-width: 640px` still triggers the wrapper's horizontal scroll at narrow widths; `overflow: auto` keeps both axes). The `th` sticky: `position: sticky; top: 0; z-index: 1;` on `.table-wrap table thead th` with an OPAQUE background (the existing tint made opaque — `background: var(--surface)` or the existing tint token, whichever the L530 comment names; check the current value and make it non-transparent; do NOT add a new palette variable — B3: the 17-variable contract is untouched, and an all-default theme stays byte-identical in the served CSS — the rule changes apply to BOTH themes equally).
|
||||
- **A3 (owner-locked):** the ellipsis hint — the clipped cell LABELS get `max-width` + `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;` (a `.cell-clip` class or a direct selector on the action-cell text nodes — the executor's choice; the `docs-table`'s existing cell padding is untouched). Coverage: the Sources `Hidden` checkbox label + `Ignore paths` + `Remove`; the RAG `Edit`; the History action buttons (`Copy`/`Unshare`/`Create link`/`Delete`). The FULL text stays reachable: every clipped label's control already carries its accessible name (the `Remove` button's `aria-label` names the full source — the phase-89 idiom; CHECK each of the other controls and add a `title`/`aria-label` where one is missing — the audit: the RAG `Edit` button, the History action buttons, the `Ignore paths` button — add the missing labels in the same task).
|
||||
- **Scope guard:** the CHAT view's `.md-table-wrap` (L539 — the markdown-rendered tables) is a DIFFERENT class — UNTOUCHED (it has no card, no height bound; the design is for the catalogue tables only). The mobile ≤640px contract (PLAN §7.1) is re-verified in the E2E (the hamburger nav + the tables' horizontal scroll at ≤640px still work — the `max-height` must not break the mobile scroll model).
|
||||
|
||||
## Tasks
|
||||
1. `01_sticky_header.md` — the bounded-panel `max-height`/`overflow: auto` + the opaque sticky `th` (all three catalogue tables share the class — one rule).
|
||||
2. `02_ellipsis_hints.md` — the clipped-label ellipsis + the missing title/aria audit (the five control families).
|
||||
3. `03_unit_pins.md` — the CSS contract pins (the existing styles-pinning idiom — the `:root`-drift test parses `styles.css`; extend the same suite).
|
||||
4. `04_e2e_table_polish.md` — the dedicated Playwright suite (narrow viewport: the sticky offset + the computed ellipsis style + the mobile contract).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the CSS pins — `.table-wrap` carries `max-height: min(70vh, 64rem)` + `overflow: auto`; `.table-wrap table thead th` carries `position: sticky; top: 0; z-index: 1` + an opaque `background` (parse the stylesheet — the styles-pinning idiom, the `:root`-drift test's precedent); the `.md-table-wrap` rule is byte-UNCHANGED (the scope guard).
|
||||
- Integration: none (CSS-only phase — the server pins stay byte-identical as regression guards).
|
||||
- Coverage: **>90%** on modified code (CSS pinned by the source-level suite + the E2E).
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
|
||||
- E2E: `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the bounded-panel scroll is a visible UX change — name it where the pages' layout is described, if anywhere).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] all three catalogue tables: after scrolling the wrapper's vertical scroll, the `thead` stays at the panel top (the E2E asserts the `th`'s offsetTop ≈ 0 while the wrapper's scrollTop > 0)
|
||||
- [ ] the horizontal-scroll contract is unchanged (narrow viewport: the table's `min-width: 640px` still scrolls inside the wrapper — the E2E)
|
||||
- [ ] a clipped action label shows `text-overflow: ellipsis` (the E2E asserts the COMPUTED style on a forced-narrow cell — deterministic, no pixel assertions) and its full text is reachable (title/aria present on every clipped control)
|
||||
- [ ] the mobile ≤640px contract holds (the hamburger nav + the table scroll — the E2E)
|
||||
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
|
||||
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Task 01 — the bounded-panel sticky header (one rule, three tables)
|
||||
|
||||
**Phase:** `156_table_ux_polish` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The catalogue tables' header rows stay pinned while the table's own vertical scroll moves — the standard scrollable-table-with-sticky-header pattern, shared by all three tables through the one `.table-wrap` class.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css`:
|
||||
- `.table-wrap` (L2528–2534): `overflow-x: auto` → `overflow: auto` (both axes — the horizontal contract via the table's `min-width: 640px` is unchanged) + `max-height: min(70vh, 64rem)` (the phase design's A2 value). Keep `position: relative` (the sticky's containing context) — the rest of the block (background/border/radius/shadow) is untouched.
|
||||
- `#git-sources-table-wrap` (L3390) — check whether it OVERRIDES the base `.table-wrap` (a height/overflow of its own): if it does, harmonize it (the same `max-height`/`overflow: auto`; do not leave a divergent rule that would make the Sources table unbounded while the others are bounded — the one-rule contract).
|
||||
- the sticky rule: `.table-wrap table thead th { position: sticky; top: 0; z-index: 1; }` + an OPAQUE background. Check the existing thead background (L530 comment: "the thead tinted" — the `.docs-table thead th` rule at L542 is the MARKDOWN table (`md-table-wrap`), a DIFFERENT class — the catalogue tables' thead background is set elsewhere; locate the catalogue thead's current background and make it opaque (the tint token as-is if it is already opaque; otherwise the surface token). NO new palette variable (B3); the rule applies equally under a themed page (B4 — a themed page gets the same sticky behavior, the `#bor-theme` override of the background token handles the color).
|
||||
- the `.md-table-wrap` block (L539) is UNTOUCHED (the scope guard — a pin asserts it).
|
||||
2. Comment the rule in the file's established voice (the phase-97/86/50 comment blocks that describe the scrollable `.table-wrap` — name the sticky + the bounded panel, cite the phase, don't restate the whole contract).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the CSS pins (task 03's suite) — the `max-height` value, `overflow: auto`, the sticky th rule, the opaque background, the `.md-table-wrap` byte-unchanged.
|
||||
- Coverage: **>90%** (CSS pinned by the source-level suite).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the CSS pins pass; the `#git-sources-table-wrap` override is harmonized (no divergent unbounded rule)
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,24 @@
|
||||
# Task 02 — ellipsis on clipped labels + the title/aria audit
|
||||
|
||||
**Phase:** `156_table_ux_polish` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
A clipped cell label shows `…` instead of a hard cut, and every clipped label's full text stays reachable (title/aria) — the five control families across the three catalogue tables.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — the ellipsis rule (the phase design's A3): the clipped cell LABELS get `max-width` + `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`. The executor's choice of selector shape (a `.cell-clip` class added to the label nodes, or direct selectors) — the constraint: the `docs-table` cell padding is untouched, and a cell that FITS shows its full text (the `max-width` must be generous enough that `Edit`/`Hidden`/`Remove` at full width are never clipped — the ellipsis is for the CLIPPED case only; the `max-width` is on the label span, not the cell).
|
||||
2. The label nodes (markup — `frontend/index.html` + `frontend/assets/git-sources.js` + `frontend/assets/history.js` + `frontend/assets/sources.js` — the tables' renderers build their cells with the DOM APIs, the house rule): wrap the clipped labels in the `.cell-clip` span (or the chosen selector's class) — the five families:
|
||||
- the Sources table: the `Hidden` checkbox LABEL (the visible text — the checkbox's own `aria-label` already names the full source, the phase-105 idiom), the `Ignore paths` button text, the `Remove` button text (its `aria-label` names the full source — KEEP it; the visible text clips);
|
||||
- the RAG folder table: the `Edit` button text;
|
||||
- the History table: the `Copy`/`Unshare`/`Create link`/`Delete` button texts (the `Delete` button's `aria-label` already names the chat title, the phase-66 idiom — CHECK the other three and add the missing `aria-label`s: `Copy the share link for <title>` / `Unshare <title>` / `Create a share link for <title>` — the audit's additions).
|
||||
3. The missing title/aria (the audit, one task): for every clipped control that has NO accessible name beyond the visible text, add the `aria-label`/`title` — the `Remove` (Sources) and `Delete` (History) precedents are the idiom; the additions must name the ROW's identity (the source URL / the chat title — the values the renderers already have in hand).
|
||||
4. Comment the change in the file's established voice (cite the phase; the B5 "text + color, never color alone" idiom is already satisfied — the labels are text).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the source-level pins — the renderers' cell builders carry the `.cell-clip` span (or the chosen class) on the five label families; the added `aria-label`s are present on the audited controls (assert the attributes the renderers set — the source-pinning idiom).
|
||||
- Coverage: **>90%** on the modified JS/markup (the pins).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the five control families carry the ellipsis class (the pin) and the computed style yields `text-overflow: ellipsis` on a forced-narrow viewport (the E2E, task 04)
|
||||
- [ ] every clipped control's full text is reachable: the audit's `aria-label` additions are present (the pin + the E2E)
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,24 @@
|
||||
# Task 03 — CSS contract pins (the styles-pinning idiom)
|
||||
|
||||
**Phase:** `156_table_ux_polish` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The design's load-bearing CSS facts are pinned at the source level — a later "cleanup" cannot silently unstick the header, unbound the panel, or transparent the thead.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/` — the styles-pinning suite (the `:root`-drift test that PARSES `styles.css` is the idiom — the phase-91/92 theme suites extended it; locate the stylesheet-parsing test module and extend it):
|
||||
- `.table-wrap`: `max-height: min(70vh, 64rem)` (the exact value pinned) + `overflow: auto`;
|
||||
- `.table-wrap table thead th`: `position: sticky` + `top: 0` + `z-index: 1` + an OPAQUE `background` (the parse asserts the background is a non-`transparent` token — the exact token is the phase's choice, the OPACITY is the pin);
|
||||
- `#git-sources-table-wrap`: no divergent unbounded override (if the rule exists, it carries the same `max-height`/`overflow: auto` — the pin asserts harmony);
|
||||
- `.md-table-wrap`: byte-UNCHANGED (the scope guard — pin the rule's exact pre-phase text);
|
||||
- the ellipsis rule: the clipped-label selector carries `text-overflow: ellipsis` + `overflow: hidden` + `white-space: nowrap`.
|
||||
2. The `:root`-drift test itself is UNCHANGED (the theme contract is untouched — this phase adds no palette variable; a pin asserts the 17-variable set is byte-identical).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; every pre-existing pin stays green (additions only).
|
||||
- Coverage: the pins exercise the changed CSS.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
|
||||
- [ ] the `.md-table-wrap` pin asserts the verbatim pre-phase rule
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 04 — the dedicated Playwright suite: sticky + ellipsis + mobile
|
||||
|
||||
**Phase:** `156_table_ux_polish` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_table_ux_polish.py`)
|
||||
|
||||
## Objective
|
||||
An isolated E2E proves the visible contracts on the real DOM: the header stays pinned while the table's own scroll moves, a forced-narrow viewport shows the ellipsis computed style on a clipped label with its full text reachable, and the mobile ≤640px contract (hamburger nav + horizontal table scroll) survives the bounded panel.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_table_ux_polish.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the phase-97/86 suites — read one of their E2E files for the boot + the table fixtures, mirror it, do not invent a new one):
|
||||
- **test_sticky_header_on_vertical_scroll (×3 tables, parameterized — RAG folders / Sources / History):** a tall fixture (the suites' idiom — enough rows to exceed the panel's `max-height`); scroll the wrapper (`el.scrollTop = <a large value>`); assert `wrapper.scrollTop > 0` AND the `thead th`'s `offsetTop ≈ 0` relative to the panel (the offset assertion — the Playwright `boundingBox` delta, the standard sticky assertion; NO pixel-tolerance games: the sticky offset is exactly 0 while scrolling).
|
||||
- **test_horizontal_scroll_contract_unchanged:** a narrow viewport (the E2E browser resizes — the suite's idiom) → the table's `min-width: 640px` still scrolls horizontally inside the wrapper (`wrapper.scrollWidth > wrapper.clientWidth` with a table wider than the panel) — the phase-97/86 contract.
|
||||
- **test_ellipsis_on_clipped_label:** force the action column's clip (a narrow viewport that clips the rightmost cells — the audit's ~1,009px case, or a tighter fixture width): assert the clipped label's COMPUTED style `text-overflow === "ellipsis"` (deterministic — the computed style, not a pixel), AND `el.scrollWidth > el.clientWidth` (the label is actually clipped), AND its full text is reachable: the control's `aria-label`/`title` (the audit's additions) contains the row identity (the source URL / the chat title).
|
||||
- **test_mobile_contract:** ≤640px viewport (the PLAN §7.1 contract — the phase-88 suite's idiom): the hamburger nav opens + the catalogue table's horizontal scroll works inside the bounded panel + the `thead` still pins (the mobile regression guard — the `max-height` must not break the mobile scroll model).
|
||||
2. The suite is DETERMINISTIC: fixed fixture row counts (the suites' idiom), computed-style assertions (never pixel/offset tolerance beyond the exact-0 sticky offset), no real-LLM dependency.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` green in isolation
|
||||
- [ ] the sticky assertion is the exact-0 offset while `scrollTop > 0` (all three tables)
|
||||
- [ ] the ellipsis assertion is the computed style + the actual-clip check + the reachable full text
|
||||
- [ ] the mobile contract test passes (the phase-88 idiom)
|
||||
@@ -0,0 +1,41 @@
|
||||
# Phase 157 — hero suggestion chips cap their display at 90 characters
|
||||
|
||||
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "hero suggestion chips — consider a char cap — long opening questions get hard-truncated mid-word… the chips stretch near full container width before clipping."
|
||||
**Story:** n/a (audit-derived phase; the chip machinery is phase 80's `80_history_suggestion_chips` + phase 104's `104_chip_sizing_question_cap`, both complete)
|
||||
**Context:** `app.js::renderChips(container, items, onSelect)` (L1272–1315) — the ONE shared chip component (the hero `#suggestions` + the deflection "Maybe try" chips). Phase 104's contract (owner-locked 2026-09-12, in-code): the single-line chip CLIPS long questions (CSS), `title` is the hover reveal, `aria-label` is the clipped-case accessible name (the source-chip pattern), and the click submits the FULL `text` (`submitSuggestion(text)` + `onSelect(text, btn)`). Today there is NO display-length cap — a 300-char opening question stretches its chip to near full container width before the CSS clip engages (the audit's observation: the hero's third chip ran the full reading-column width). Phase 104 also capped the COMPOSER (the input) — a different surface, untouched here. The chips' content is the 3 newest saved chats' opening questions (phase 80/103) — owner-derived, `textContent`-only (the house rule), so a display cap is a pure presentation change: the click payload stays full.
|
||||
|
||||
## Objective
|
||||
The chip's DISPLAYED text caps at 90 characters with a clean `…` (the chip stays compact — no more full-width hero chips), while the `title` hover reveal, the clipped-case `aria-label`, and the click payload all keep the FULL question (phase 104's contract, extended).
|
||||
|
||||
## Dependencies
|
||||
- `80_history_suggestion_chips` (complete) — the hero chips' content contract (the 3 newest opening questions).
|
||||
- `104_chip_sizing_question_cap` (complete) — the single-line CSS clip + the `title`/`aria-label` idiom this phase EXTENDS (the cap makes the CSS clip a secondary guard, not the primary one).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **The cap (ASSUMPTION A1, owner-locked):** `CHIP_CAP = 90` — a named constant in `app.js` (the module's top, beside the other chip constants). Applied to the DISPLAYED text ONLY in `renderChips`: `btn.textContent = text.length > CHIP_CAP ? text.slice(0, CHIP_CAP).trimEnd() + "…" : text`. `slice(0, 90).trimEnd()` avoids the trailing-space-before-`…` artifact when char 90 is mid-word (the `trimEnd` is the pin). The `title` is UNCHANGED (the full `text` — the hover reveal, phase 104). The `aria-label` logic is UNCHANGED (the clipped-case accessible name is the full `text` — with the cap, a long chip is ALWAYS "clipped" in the visual sense, but phase 104's `scrollWidth > clientWidth` check still governs the aria-label: a 91-char display still overflows its single-line box at typical widths, so the aria-label lands; a SHORT string (< 90) is byte-identical to pre-phase in every respect — the no-cap case is unchanged).
|
||||
- **The click payload (the load-bearing invariant):** `submitSuggestion(text)` + `onSelect(text, btn)` take the FULL `text` (the un-capped value) — UNCHANGED. The cap is display-only: a capped chip sends the full question (the user gets their full question submitted — the phase-80 contract). A unit pin asserts the handler receives the FULL string for a >90-char item.
|
||||
- **Scope:** `renderChips` is shared (hero + Maybe-try) — the cap applies to both (a single code path; the Maybe-try chips are title-derived and short — the cap is a no-op there, and the byte-identity of short strings is the guard). NO other file changes (the CSS clip stays as the secondary guard; the composer's phase-104 cap is a different surface — untouched).
|
||||
|
||||
## Tasks
|
||||
1. `01_chip_cap.md` — the `CHIP_CAP` constant + the display-only cap in `renderChips` + the comment in the phase-104 voice.
|
||||
2. `02_unit_pins.md` — the boundary pins (89/90/91), the full-text invariant, the short-string byte-identity.
|
||||
3. `03_e2e_chip_cap.md` — the dedicated Playwright suite.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: source-level pins — the 90-char boundary (an 89-char display is unchanged; a 90-char display is unchanged (the cap is `>`); a 91-char display is `slice(0,90).trimEnd() + "…"` — the exact string pinned); the `title` is the full text in all three cases; the click/`onSelect` payload is the FULL `text` for a >90-char item (the load-bearing invariant); a <90-char item is byte-identical to pre-phase in textContent/title/aria-label (the short-string guard).
|
||||
- Integration: the `/api/suggestions` contract is untouched (no server change — a regression guard only).
|
||||
- Coverage: **>90%** on the modified JS (the pins).
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
|
||||
- E2E: `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` in isolation (DB up: `podman compose up -d db`; the deterministic `tests/e2e/mock_llm.py`).
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the cap is a visible UI change — name it where the onboarding chips are described, if anywhere).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a >90-char opening question renders a ≤91-char display (the cap + the `…`) — the E2E
|
||||
- [ ] clicking the capped chip submits the FULL question (the user bubble shows the full text — the E2E's load-bearing assertion)
|
||||
- [ ] the `title` hover reveal + the clipped-case `aria-label` carry the full text (the E2E)
|
||||
- [ ] a <90-char question is byte-identical to pre-phase (the pin)
|
||||
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
|
||||
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Task 01 — the `CHIP_CAP` display cap in `renderChips`
|
||||
|
||||
**Phase:** `157_chat_chip_cap` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The shared chip component caps its displayed text at 90 characters with a clean `…` — a long opening question no longer stretches its chip to full container width — while the `title`, the `aria-label`, and the click payload keep the full text.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js`:
|
||||
- `const CHIP_CAP = 90;` — the named constant at the module top, beside the other chip constants (the module's established placement — the `suggestions` section header, L1272, is the anchor; place it just above `renderChips`).
|
||||
- `renderChips` (L1296–1315): the display line becomes the cap (the phase design's A1 shape): `btn.textContent = text.length > CHIP_CAP ? text.slice(0, CHIP_CAP).trimEnd() + "…" : text;` — the `title` line (`btn.title = text`) is UNCHANGED (the full text — the hover reveal, phase 104); the `aria-label` logic (L1314: the `scrollWidth > clientWidth` check → `aria-label = text`) is UNCHANGED; the click handler (`submitSuggestion(text)` + `onSelect(text, btn)`) is UNCHANGED (the full `text` — the load-bearing invariant).
|
||||
- the phase-104 comment block (L1298–1301, "the single-line chip clips long questions — `title` is the hover reveal, `aria-label` the clipped-case accessible name") gains the cap sentence in the same voice (name the phase-157 cap as the PRIMARY guard, the CSS clip as the secondary — cite, don't restate the whole contract).
|
||||
2. NO other file changes: the CSS clip rule stays (the secondary guard); the composer's phase-104 cap is a different surface (untouched); the "Maybe try" path reuses `renderChips` automatically (a single code path).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: task 02's pins.
|
||||
- Coverage: **>90%** on the modified JS (the pins).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the constant + the cap line land exactly as designed (the source pin)
|
||||
- [ ] the `title` / `aria-label` / click-handler lines are byte-unchanged (a `git diff` scoped check)
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 02 — unit pins: the 90-char boundary, the full-text invariant, the short-string guard
|
||||
|
||||
**Phase:** `157_chat_chip_cap` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
The cap's three contracts are pinned at the source level: the 90-char boundary (display), the full-text invariant (title/aria/click), and the short-string byte-identity (the no-cap case).
|
||||
|
||||
## Work
|
||||
1. `tests/unit/` — the `app.js` source-level pin suite (the phase-80/104 suites are the precedent — locate the chip pin module and extend it):
|
||||
- **the boundary:** an 89-char item → `textContent` byte-identical to the input (no cap); a 90-char item → byte-identical (the cap is `>`, not `≥`); a 91-char item → `slice(0, 90).trimEnd() + "…"` — the EXACT string pinned (pin a 91-char fixture where char 90 is a letter AND one where it is a space — the `trimEnd` case is the artifact the cap avoids);
|
||||
- **the full-text invariant:** for a >90-char item: `title` === the full text; the click handler's payload (the `onSelect` spy) === the full text; `submitSuggestion`'s argument === the full text (the load-bearing assertion — a capped chip sends the FULL question);
|
||||
- **the short-string guard:** a <90-char item → `textContent`/`title` byte-identical to pre-phase + the `aria-label` logic unchanged (the `scrollWidth > clientWidth` check — the DOM-simulation idiom the phase-104 suite uses);
|
||||
- **the Maybe-try path (the shared-component guard):** the cap applies identically through `appendMaybeTry` (a >90-char suggestion there renders capped — a single code path; a short one is byte-identical).
|
||||
2. The `/api/suggestions` payload pin (if the suite pins it — the phase-80 idiom): UNCHANGED (no server change in this phase).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; every pre-existing pin stays green (additions only).
|
||||
- Coverage: the pins exercise the changed branch.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
|
||||
- [ ] the full-text invariant pin asserts the full string (not a length) in `title`, `onSelect`, and `submitSuggestion`
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 03 — the dedicated Playwright suite: the capped chip, the full submit
|
||||
|
||||
**Phase:** `157_chat_chip_cap` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_chat_chip_cap.py`)
|
||||
|
||||
## Objective
|
||||
An isolated E2E proves the user-facing contract: a >90-char opening question renders a compact capped chip (display ≤ 91 chars ending in `…`), the `title` hover reveal + the clipped-case `aria-label` carry the full text, and clicking the capped chip submits the FULL question (the user bubble shows the full text).
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_chat_chip_cap.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the phase-80 suite — read `test_suggestion_chips.py` (or its actual name in `tests/e2e/`) for the saved-chat-seeding idiom, mirror it, do not invent a new one):
|
||||
- **the seed:** a saved chat whose opening question is a >90-char string (the suite builds it — a fixed >90-char question, e.g. 120 chars, so the cap engages deterministically; the saved-chat API the phase-80 suite uses is the seeding idiom) → the hero renders the 3-chip contract (phase 80/103) with the seeded question as a chip.
|
||||
- **test_capped_display:** the chip's `textContent` length ≤ 91 AND ends with `…` AND equals the pinned `slice(0, 90).trimEnd() + "…"` of the seeded question (the exact-string pin — the E2E asserts the byte-for-byte display, not just a length);
|
||||
- **test_full_text_reachable:** the chip's `title` attribute === the full seeded question; the chip's `aria-label` (the clipped case — the 91-char display overflows its single-line box at the E2E viewport) === the full seeded question (the phase-104 idiom);
|
||||
- **test_click_submits_full (the load-bearing assertion):** the chip is clicked → the mock-LLM turn runs → the USER bubble's text === the FULL seeded question (not the capped display — the phase-80 contract: the chip sends the full question; the cap is display-only);
|
||||
- **test_short_chip_unchanged (the regression guard):** a seeded <90-char opening question renders byte-identical to pre-phase (the chip's `textContent` === the question, no `…` appended) — the short-string guard on the live DOM.
|
||||
2. The suite is DETERMINISTIC: the mock-LLM answer (no `E2E_REAL_LLM` dependency); the seeded questions are fixed strings (the pins, not generated lengths).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` green in isolation
|
||||
- [ ] the full-text assertion is byte-for-byte (the `title`, the `aria-label`, and the user bubble all equal the FULL seeded question)
|
||||
- [ ] the short-chip guard passes (a <90-char question is byte-identical to pre-phase)
|
||||
@@ -0,0 +1,427 @@
|
||||
# Phase 158 — Chronological thinking / tool-call timeline
|
||||
|
||||
**Source:** owner report (live interactive-browser session, 2026-10-04, shared page
|
||||
`https://brain.reeseapps.com/shared/95dd7a2a-8cda-478b-b694-53936070b9fb` — a real
|
||||
`turbo` turn with 6 tool calls): "the thinking block happens first but LLM sent a
|
||||
normal response, not a thinking response, and then went back to thinking. This is
|
||||
awkward. Then the tool calling happens, and the thinking block reads strangely
|
||||
because the tool calls are shown below the thinking block. … show the thinking /
|
||||
tool call / responses in order, rather than in single big blocks each."
|
||||
**Story:** n/a (owner bug report — live site, 2026-10-04)
|
||||
**Context:** `app/rag/agent.py::run_agent` (yields `thinking` / `content` /
|
||||
`ToolCallPiece` / `ToolResultPiece` in TRUE chronological order per round —
|
||||
thinking → tool call → thinking → … → answer; the SSE mapping in
|
||||
`app/api/chat.py` preserves that order, so NO backend stream change is needed);
|
||||
`frontend/assets/app.js` — `ensureThinkingBlock` (~L1053, ONE `details.thinking`
|
||||
per message, idempotent; phase 109 D15 re-opens it on every later thinking frame),
|
||||
`closeThinkingBlock` (~L1075), `closeToolCalls` (~L1086), `appendToolLine`
|
||||
(~L1136, ONE `details.tool-calls-disclosure` per message — phase 117 D3
|
||||
open-live/fold-at-rest), `armToolLineClock` (~L1479, the phase-87 clock queries
|
||||
`.tool-calls .tool-call:last-child`), the turn handler in `runTurn`
|
||||
(thinking ~L3148 / tool ~L3193 / delta ~L3300 / done ~L3313 branches;
|
||||
`thinkingAcc` ~L3071, `toolAcc` ~L3080, `segRuns` — NEW — module scope beside
|
||||
them; the done save point ~L3355, the `tool_result` branch ~L3389, the
|
||||
stream-drop `finalizeFailedTurn` call ~L3417), `rememberBrainTurn` (L2589,
|
||||
`meta` spread — the pagehide partial save reuses it), `finalizeFailedTurn`
|
||||
(L2983); `renderStoredMessage` (~L2089: the `m.thinking` block + the `m.tools`
|
||||
loop, both rendered collapsed); `frontend/assets/shared.js` — `addThinkingBlock`
|
||||
(L126) + `addToolLines` (L175, the byte-parity copy, rendered closed);
|
||||
`app/schemas.py` — `ChatMessage` (L1103, `extra="forbid"`, phase-83 value caps,
|
||||
the wrap serializer that omits absent image keys), `ToolCall` (L1017),
|
||||
`HistoryTurn` (L58 — the history wire, UNCHANGED); `tests/e2e/mock_llm.py`
|
||||
(`_tool_flow` L1820, `_tool_call_stream` L3813, `_sse_stream` L3727,
|
||||
`compose_thinking` L3589, the trigger-dispatch order in `chat_completions`
|
||||
L3872 — the more-specific-phrase-wins convention); the E2E suites whose pins
|
||||
this phase updates (task 05): `test_turn_progress_loader.py` (the phase-109 D15
|
||||
re-open pin), plus the unit source pins in `test_frontend_feedback.py`,
|
||||
`test_frontend_turn_loader.py`, `test_frontend_tool_states.py`,
|
||||
`test_chat_persistence.py`, `test_shared_page.py`, `test_tool_call_compact.py`,
|
||||
`test_thinking_scroll.py`, `test_big_read_progress.py`, `test_read_range_tool_lines.py`,
|
||||
`test_read_truncation_frontend.py`, `test_search_tool_scope.py`,
|
||||
`test_frontend_failed_turn.py` (each pins `ensureThinkingBlock` / `appendToolLine`
|
||||
/ the clock in different depths — inspect each; ONLY the pins the redesign breaks
|
||||
change, the rest stay green untouched).
|
||||
|
||||
## Bug basis (code-traced, 2026-10-04)
|
||||
The server streams the turn in true chronological order (`run_agent` yields per
|
||||
round: thinking → tool call → thinking → … → answer), but the client LUMPS:
|
||||
`app.js` funnels every thinking frame of the turn into ONE `details.thinking`
|
||||
block (`ensureThinkingBlock` is idempotent — the phase-109 D15 re-open re-opens
|
||||
that same block for each later agent round) and every tool frame into ONE
|
||||
`details.tool-calls-disclosure`. DOM order is therefore always
|
||||
`[ALL thinking] → [ALL tool calls] → [answer]` regardless of the actual order —
|
||||
the owner's "thought everything, then called all the tools, then answered"
|
||||
reading. The "thinking → answer → back to thinking" awkwardness is the D15
|
||||
re-open live: the model emits a short mid-round reply alongside a tool call
|
||||
(React style), the first `delta` closes the one thinking block and the answer
|
||||
starts, then the next round's thinking frame re-opens the SAME block above the
|
||||
tool lines — the answer begins, then thinking resumes on top of it. The persisted
|
||||
`bor.chat.v1` record only stores `thinking` (one concatenated string) + `tools`
|
||||
(one array) — the per-run order is never captured, so the shared page
|
||||
(`shared.js`) renders the same lumped layout, collapsed.
|
||||
|
||||
## Objective
|
||||
Render each agent turn as a chronological timeline — each CONTIGUOUS run of
|
||||
thinking frames in its own `details.thinking` block, each CONTIGUOUS run of tool
|
||||
calls in its own `details.tool-calls-disclosure` (`Tool call(s) (N)` per run),
|
||||
segments in arrival order — live, on restore, and on the shared page; persist the
|
||||
run order in a new optional `segments` array on the brain record (additive;
|
||||
records without it — all pre-phase — restore byte-identically through the
|
||||
untouched legacy path).
|
||||
|
||||
## Owner decisions (chat, 2026-10-04 — recorded per AGENTS.md rule 3)
|
||||
- **D1 — Per-run timeline.** Each contiguous thinking run = its own Thinking
|
||||
block; each contiguous tool-call run = its own disclosure. Consecutive frames
|
||||
of the same kind extend the current run; the first frame of a new kind closes
|
||||
the previous run and opens the new one.
|
||||
- **D2 — Option B mid-round placement (owner-chosen, shown as a live DOM demo in
|
||||
the owner's browser 2026-10-04).** ALL segments render ABOVE the answer
|
||||
bubble, in arrival order — including runs that start AFTER the first `delta`
|
||||
(the mid-round reply case). The bubble stays ONE element; its content
|
||||
accumulates in arrival order (the mid-round text and the final answer are one
|
||||
streamed text). Rejected: Option A (post-answer segments below the bubble —
|
||||
the bubble grows above its own scratchpad).
|
||||
- **D3 — Plain `Thinking` label on every block (owner-chosen).** The phase-17
|
||||
pinned summary literal stays byte-identical across runs (zero new literals;
|
||||
position distinguishes the runs). The disclosure summary stays the phase-117
|
||||
`Tool call(s) (N)` — now per run.
|
||||
- **D4 — Additive persistence.** The brain record gains an OPTIONAL `segments`
|
||||
array; `thinking` (full concatenated text — feeds the history wire + the
|
||||
per-turn log) and `tools` (all call records) are KEPT (old-client graceful
|
||||
degradation; the phase-113 lesson applied up front). `segments` is omitted when
|
||||
absent (the `undefined`-drops-the-key house pattern) — a no-thinking,
|
||||
no-tools record stays byte-identical to pre-phase.
|
||||
- **D5 — Open live, fold at rest** (extends phase 117 D3 + the phase-17
|
||||
open-while-thinking contract): the active run's block is OPEN while its frames
|
||||
stream; a new run's first frame closes the previous run; the first `delta`,
|
||||
`done`, and the stop/abort settle fold the open runs; the restore path and
|
||||
the shared page render everything COLLAPSED.
|
||||
- **D6 — Server: a validated `segments` field on `ChatMessage`.**
|
||||
`extra="forbid"` means the new key would 422 every saved-chat write without a
|
||||
schema field (the phase-113 lesson: a missing key silently UNSAVED every
|
||||
grounded brain record). The field is a discriminated union (`kind` =
|
||||
`"thinking"` | `"tool"`) with phase-83-style value caps, omitted-when-`None`
|
||||
in the serializer (the phase-50 round-trip contract). No migration (the record
|
||||
is opaque JSONB), no SSE contract change, no history-wire change.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
|
||||
### The `segments` entry shape (D4)
|
||||
```
|
||||
segments: [
|
||||
{ kind: "thinking", text: "<that run's full text>" },
|
||||
{ kind: "tool", name: "…", argument?: "…", offset?: int, limit?: int,
|
||||
path?: "…", truncated?: true, chars_shown?: int, chars_total?: int },
|
||||
…
|
||||
]
|
||||
```
|
||||
- One `thinking` entry per RUN (the run's accumulated text — the same text that
|
||||
streams live into that run's block). One `tool` entry per TOOL CALL (the exact
|
||||
`toolAcc` record shape — the phase-37 `{name, argument}` plus the phase-139
|
||||
`offset/limit` and phase-146 `path` additive fields, and the phase-95
|
||||
`truncated/chars_shown/chars_total` stamp when present).
|
||||
- Built at save time from the run tracker: an EMPTY thinking run stores NOTHING
|
||||
(a zero-char run — e.g. a `BOR_STREAM_THINKING`-suppressed turn — is absent);
|
||||
a turn with no thinking and no tools stores no `segments` key at all.
|
||||
- `thinking` (the full concatenation, for the history wire + the log) and `tools`
|
||||
(every call record, for old clients) are UNCHANGED in the record.
|
||||
|
||||
### `app/schemas.py` (task 01)
|
||||
- `ChatThinkingSegment`: `kind: Literal["thinking"]`, `text: str`
|
||||
(`min_length=1`, `max_length=32_000` — `HistoryTurn`'s cap, phase 83).
|
||||
- `ChatToolSegment`: `kind: Literal["tool"]` + the EXACT `ToolCall` field set
|
||||
(name/argument/offset/limit/path/truncated/chars_shown/chars_total with their
|
||||
existing caps) — one model, no duplication of the field definitions where
|
||||
practical (the docstring cross-references `ToolCall`).
|
||||
- `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment],
|
||||
Field(discriminator="kind")]`.
|
||||
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None,
|
||||
max_length=50)` (the phase-83 cap style — far past any realistic turn: the
|
||||
round cap is 10 → ≤ 20 entries; 50 matches the `tools` cap).
|
||||
- The EXISTING wrap serializer (`_drop_image_when_absent`) gains the rule:
|
||||
`segments is None → pop "segments"` (ABSENT, never `null` — the phase-50
|
||||
contract; a record WITH segments keeps them). `HistoryTurn` and the
|
||||
`history_to_messages` mapper are UNTOUCHED (segments never travel the wire —
|
||||
the concatenated `thinking` travels exactly as today).
|
||||
|
||||
### `frontend/assets/app.js` — the live turn (task 02)
|
||||
- Turn state: `segRuns = []` (module scope, reset in `runTurn` beside
|
||||
`thinkingAcc`/`toolAcc` — an array of run objects
|
||||
`{ kind, text, records }`), `currentRun = null` (turn-local).
|
||||
- **Insertion anchor (D2):** every run element is inserted `before`
|
||||
`body.querySelector(".bubble")` (get-or-create the bubble first, as today).
|
||||
There is no other anchor — segments ALWAYS sit above the bubble, in arrival
|
||||
order. `ensureThinkingBlock`'s old `.tool-calls-disclosure ?? .tool-calls ??
|
||||
.bubble` anchor is REPLACED by this.
|
||||
- **`thinking` frame (per frame):** `settleToolLine()` (unchanged);
|
||||
`thinkingAcc += ev.text` + `sawThinking` + `clearTurnTimeout()` + `if (!wrap)
|
||||
wrap = addMessage("brain","")` + `removeTyping()` (all unchanged). Then the
|
||||
run logic: if `currentRun?.kind === "thinking"` → extend it (re-render
|
||||
`renderMarkdown(run.text)` into THAT block's `.thinking-text` — the existing
|
||||
pre-update pin-state / bottom-follow logic moves verbatim into this branch,
|
||||
now per-block); else → close `currentRun` (if any, `details.open = false`),
|
||||
create `details.thinking` (`open = true`, `<summary>Thinking</summary>` +
|
||||
`.thinking-text`, inserted before the bubble), push `{kind:"thinking",text:"",
|
||||
records:[]}` to `segRuns`, `currentRun` = it, extend.
|
||||
- **`tool` frame (per frame):** the existing guards stay byte-identical
|
||||
(the phase-139 `frameOffset`/`frameLimit`, the phase-146 `framePath`
|
||||
well-formedness; `toolAcc.push(toolRecord)`; `clearTurnTimeout()`; the
|
||||
`toolStatus` label logic; the phase-70 legacy-name mapping). Then the run
|
||||
logic: if `currentRun?.kind === "tool"` → extend that disclosure; else → close
|
||||
`currentRun` (if any), create `details.tool-calls-disclosure` (`open = true`,
|
||||
`summary.tool-calls-summary` + the `div.tool-calls` list `role=list`
|
||||
`aria-label="Tool calls"`, inserted before the bubble), push
|
||||
`{kind:"tool",text:"",records:[]}` to `segRuns`, `currentRun` = it; extend =
|
||||
build the line EXACTLY as today (`appendToolLine` — now with the container
|
||||
passed in: `appendToolLine(container, name, argument, offset, limit, scope)`,
|
||||
the line bytes + the four pinned `line.textContent` label literals + the
|
||||
phase-139 range suffix + the phase-146 scope span UNCHANGED, the summary
|
||||
count updated per append — `Tool call(s) (N)` per run), then
|
||||
`armToolLineClock(wrap, lineEl)` (the NEW signature — task 02's clock fix).
|
||||
- **`armToolLineClock(wrap, lineEl)` (the phase-87 multi-disclosure fix):**
|
||||
store the `lineEl` reference (module scope, reset with the wrap); the interval
|
||||
appends the `.tool-elapsed` suffix to `lineEl` instead of querying
|
||||
`.tool-calls .tool-call:last-child` (that query breaks with multiple
|
||||
disclosures — it would find the last line of the FIRST list). The visible
|
||||
behavior is identical (the suffix on the newest line; `settleToolLine` still
|
||||
removes every `.tool-elapsed` in the wrap + resets the reference).
|
||||
- **`delta` handler:** `settleToolLine()` + `acc += ev.text` + the
|
||||
`thinking→streaming` transition UNCHANGED; replace
|
||||
`closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with `closeCurrentRun()`
|
||||
(folds `currentRun` if open; idempotent; no-op without a run).
|
||||
- **The other settle-pair sites — `finalizeFailedTurn`'s `if (wrap)` block
|
||||
(L2990–L2991), the STOP path (L3477–L3478), and every remaining
|
||||
`closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep — the
|
||||
redesign removes both functions):** `closeAllRuns(wrap)` (closes EVERY
|
||||
open `details.thinking` + `details.tool-calls-disclosure` in the wrap —
|
||||
defensive; by construction at most the last run is open, but a late
|
||||
double-close must be a no-op). The rest of `done` is unchanged.
|
||||
- **Save payloads (the FIVE sites):** `buildSegments(segRuns)` (a helper —
|
||||
maps runs: thinking run → `{kind:"thinking",text}` when non-empty; tool run
|
||||
→ one `{kind:"tool",…record}` per record, in order) — the `done` save point
|
||||
(~L3355); the `finalizeFailedTurn` parameter + its `if (wrap)` saved record
|
||||
(the two call sites — stream-drop ~L3431, network-error ~L3519 — pass
|
||||
`buildSegments(segRuns)`); the STOP path's save meta (L3487–L3491,
|
||||
`stopped: true`); the pagehide partial (L3642–L3650 — `segRuns` is module
|
||||
scope, readable there). All include `segments` non-empty only. The
|
||||
zero-frame fallback save needs nothing (zero frames → no runs → no key).
|
||||
The `thinking`/`tools` keys are UNCHANGED at every site.
|
||||
- The UI state machine, `#send-status` copy, the 300 s pre-token guard, the
|
||||
stream-drop guard, and the `thinking-without-answer` fallback are ALL
|
||||
UNCHANGED (§7.4 never-stale intact). `BOR_STREAM_THINKING=0` needs no client
|
||||
change (no thinking frames arrive → no thinking runs).
|
||||
|
||||
### `frontend/assets/app.js` — the restore path (task 03)
|
||||
- `renderStoredMessage`: after the bubble exists, IF
|
||||
`Array.isArray(m.segments) && m.segments.length` → `restoreTimeline(wrap,
|
||||
m.segments)` and SKIP the legacy `m.thinking` block + the `m.tools` loop
|
||||
(segments take precedence — they carry the same data in order); ELSE the
|
||||
legacy path is BYTE-IDENTICAL (a pre-phase record — or a `BOR_STREAM_THINKING=0`
|
||||
turn saved with the old client — restores exactly as today).
|
||||
- `restoreTimeline`: iterate `m.segments` in order; group CONSECUTIVE
|
||||
`kind:"tool"` entries into ONE `details.tool-calls-disclosure` (created
|
||||
CLOSED — D5; summary `Tool call(s) (N)`; each line through the SAME
|
||||
`appendToolLine` container-signature path with the EXISTING tOffset/tLimit/tPath
|
||||
guards + the existing `appendTruncatedNote` call for `t.truncated` records —
|
||||
the current loop's guards move verbatim inside the grouping); a `kind:"thinking"`
|
||||
entry → its OWN `details.thinking` (created CLOSED; `.thinking-text` =
|
||||
`renderMarkdown(entry.text)` — the restore convention stays escape-first);
|
||||
unknown `kind` values are SKIPPED (never render from storage — textContent
|
||||
only, the house rule). All elements inserted before the bubble.
|
||||
|
||||
### `frontend/assets/shared.js` — the shared page (task 03)
|
||||
- `addSegments(wrap, segments)` — the shared-page parity copy of
|
||||
`restoreTimeline` (pure render, always COLLAPSED — D5): the same grouping,
|
||||
the same line bytes (the four label literals + the phase-95 truncation note
|
||||
+ the phase-139/146 suffixes, already parity-pinned in `addToolLines`), the
|
||||
same summary count; the `addThinkingBlock`/`addToolLines` legacy path is
|
||||
BYTE-IDENTICAL when the record has no `segments`. The record-dispatch site
|
||||
(~L456): `if (Array.isArray(m.segments) && m.segments.length)
|
||||
addSegments(wrap, m.segments); else { the existing addThinkingBlock +
|
||||
addToolLines }`.
|
||||
|
||||
### `tests/e2e/mock_llm.py` (task 04)
|
||||
- `THINK_TOOLS_TRIGGER = "think out loud and use your tools"` — checked in the
|
||||
streaming branch BEFORE the `_tool_flow` dispatch (the more-specific-phrase-wins
|
||||
convention — the phrase contains both `think out loud` and `use your tools`,
|
||||
so it must not fall through to the plain answer path; no other existing
|
||||
trigger phrase is a substring of it, and no existing E2E question carries it —
|
||||
verify both before wiring).
|
||||
- `_thinking_tools_flow(body)` — stateless, shaped like `_tool_flow` (gated on
|
||||
the `tools` parameter + the `<tools>` section, like the other marker flows):
|
||||
- request 1 (tools offered, no tool results yet): `thinking` =
|
||||
`"Step 1: list the documents to find the reference server config."` →
|
||||
`_tool_call_stream("ls", {}, "call_0", thinking=thinking)`;
|
||||
- request 2 (a `tool`-role top-level source listing — the agent's `ls` tree
|
||||
format, no file lines yet): `"Step 2: drill into the first source."` → the
|
||||
`_drill_target` scoped `ls` (id `call_1`), with thinking;
|
||||
- request 3 (a `tool`-role folder listing with file lines — parse the FIRST
|
||||
file line's labeled `source:` / `path:` fields, the phase-63/70 format):
|
||||
`"Step 3: read the file line's document."` → `read` on the JOINED
|
||||
combined `source/path` (id `call_2`), with thinking;
|
||||
- request 4 (a `tool`-role read result — content starting with the agent's
|
||||
`"Document <source/path>:"` header): `"Step 4: compose the answer from the
|
||||
read document."` → the phase-37 answer convention: `Read <source/path>.
|
||||
<first 80 chars of the read document's content>` (content stream, with
|
||||
thinking ahead of it).
|
||||
- `_tool_call_stream(name, arguments, call_id, thinking="")` — the optional
|
||||
`thinking` param: when non-empty, yield `reasoning_content` chunks FIRST
|
||||
(the 12-char split — the `_sse_stream` convention), then the existing
|
||||
`tool_calls` frames + `finish_reason: "tool_calls"` (unchanged). Default
|
||||
`""` → every pre-phase 3-arg caller streams BYTE-IDENTICAL.
|
||||
- Docstring: the new trigger entry (the phase-158 flow) — the mock stays
|
||||
mock-only by design (`E2E_REAL_LLM` note unchanged).
|
||||
|
||||
### `tests/e2e/test_thinking_timeline.py` (task 04 — dedicated suite, run in isolation)
|
||||
The conftest `app_server`/`page`/`seeded_kb` fixtures + the phase-37 admin
|
||||
login + the trigger question (the KB must be seeded so request 4's read result
|
||||
exists — mirror `test_agent_document_tools.py`). Six tests:
|
||||
1. **`test_timeline_order_after_completed_turn`** — after `done`: the brain
|
||||
wrap's `.msg-body` children before the bubble are EXACTLY
|
||||
`[details.thinking, details.tool-calls-disclosure, details.thinking,
|
||||
details.tool-calls-disclosure, details.thinking, details.tool-calls-disclosure,
|
||||
details.thinking]` (7 elements); the four `.thinking-text`s carry DISTINCT
|
||||
`Step 1`…`Step 4` markers (distinctness = the runs are separate blocks, not
|
||||
one lump); each disclosure summary is `Tool call (1)`; the answer bubble
|
||||
carries the `Read <path>` echo.
|
||||
2. **`test_runs_fold_at_rest`** — after `done`: NO `.thinking` /
|
||||
`.tool-calls-disclosure` has the `open` attribute; the summaries are
|
||||
visible (D5).
|
||||
3. **`test_live_runs_open_while_streaming`** — during the turn (an
|
||||
`expect.poll` predicate over the mock's 0.1 s pacing window): at least one
|
||||
`.thinking` or `.tool-calls-disclosure` is `open` while the answer bubble is
|
||||
still empty (the D5 live-open contract — the live feedback the owner keeps).
|
||||
4. **`test_restored_timeline_and_record`** — RELOAD (same context): the
|
||||
restored message renders the same 7-element timeline, all closed; the
|
||||
localStorage `bor.chat.v1` record for that brain turn carries a `segments`
|
||||
array of EXACTLY 7 entries in the pattern
|
||||
`thinking, tool, thinking, tool, thinking, tool, thinking` (4 thinking runs
|
||||
+ 3 tool entries).
|
||||
5. **`test_shared_page_timeline`** — the phase-51 save + share flow, a FRESH
|
||||
context on `/shared/<token>`: the same 7-element timeline renders, all
|
||||
collapsed (the `shared.js` parity — `addSegments`).
|
||||
6. **`test_no_thinking_turn_single_disclosure`** — a plain `use your tools`
|
||||
question (NO thinking trigger): the turn renders EXACTLY ONE
|
||||
`.tool-calls-disclosure` (3 lines, `Tool calls (3)`) and ZERO `.thinking`
|
||||
(the run-grouping contract: no thinking frames → one tool run — the
|
||||
phase-117 behavior for no-thinking turns is preserved).
|
||||
|
||||
## Tasks
|
||||
1. `01_schema_segments.md` — the discriminated `ChatSegment` models + the
|
||||
`ChatMessage.segments` field + the serializer omission + the integration
|
||||
contract (accept / caps / byte-identical round-trip / shared read).
|
||||
2. `02_live_timeline.md` — the per-run live rendering (run tracker, D2 anchor,
|
||||
the clock re-target, the settle sites, the save payloads) + the JS source
|
||||
unit pins.
|
||||
3. `03_restore_and_shared.md` — the `restoreTimeline` dispatch in
|
||||
`renderStoredMessage` + the `shared.js` `addSegments` parity + the unit pins.
|
||||
4. `04_mock_and_e2e.md` — the mock `THINK_TOOLS_TRIGGER` flow + the dedicated
|
||||
`test_thinking_timeline.py` story suite, run in isolation.
|
||||
5. `05_regression_update_and_commit.md` — the phase-109 suite pin update + the
|
||||
remaining unit-pin updates, the full gate, the documentation pass, the
|
||||
atomic commit, the phase move.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (new): `tests/unit/test_thinking_timeline.py` — the source-level pins:
|
||||
the per-run creation (a NEW `details.thinking` when the run kind changes,
|
||||
insert-before-bubble anchor — D2), the plain `Thinking` literal (D3), the
|
||||
per-run disclosure + the `Tool call(s) (N)` per-run count, the
|
||||
`armToolLineClock` reference (the `:last-child` query is GONE), the
|
||||
`segRuns` → `buildSegments` mapping (empty-run skip, the tool-record shape),
|
||||
the `delta` `closeCurrentRun` + the settle-pair sites → `closeAllRuns`, the
|
||||
FIVE save-payload sites (done / finalizeFailedTurn + its 2 call sites /
|
||||
stop path / pagehide), the restore dispatch (segments vs legacy — the
|
||||
legacy path byte-identical), the `shared.js` `addSegments` parity, and
|
||||
`innerHTML` ABSENT from the restore/shared builders (the house rule).
|
||||
- Integration: `tests/integration/test_chats_api.py` (extended) — a record WITH
|
||||
segments (both kinds, every additive tool field) is accepted and round-trips
|
||||
through create/get; `segments` > 50 → 422; a thinking-segment text > 32 000 →
|
||||
422; an unknown segment `kind` → 422 (`extra="forbid"` intact — a stray
|
||||
`<b>`-ish key still 422s); a record WITHOUT `segments` round-trips with the
|
||||
key ABSENT (never `null` — the phase-50 byte-identical contract, pinned);
|
||||
the shared read (`/api/chats/<id>/shared` or the public read path) carries
|
||||
segments (the public shape).
|
||||
- Existing unit suites MUST stay green: every pin NOT broken by the redesign
|
||||
(the tool-line label literals, the phase-117 deboxed CSS, the phase-87
|
||||
`.tool-elapsed` behavior, the phase-139/146 suffix pins, the emoji guard,
|
||||
the `test_tool_call_compact.py` pins — a no-thinking turn is one run, so the
|
||||
phase-117 E2E contract is UNCHANGED).
|
||||
- E2E: `tests/e2e/test_thinking_timeline.py` (new — the six tests above), green
|
||||
**in isolation** (`uv run pytest tests/e2e/test_thinking_timeline.py -v
|
||||
--no-cov`), mock-only.
|
||||
- Regression E2E (task 05, in isolation): `test_thinking_display.py` (a
|
||||
no-tools thinking turn is ONE run — the suite's contract is unchanged),
|
||||
`test_tool_call_compact.py` (unchanged — no-thinking single-run),
|
||||
`test_turn_progress_loader.py` (UPDATED in task 05 — the D15 pin → the
|
||||
per-run contract), `test_big_read_progress.py` (the clock's visible behavior
|
||||
is identical — must stay green UNCHANGED), `test_agent_document_tools.py`,
|
||||
`test_share_chat.py`, `test_chat_persistence.py`, `test_smoke.py`.
|
||||
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` **>90%** on
|
||||
`app/` (task 01's schema code is unit/integration-covered; the rest is held
|
||||
by the suite).
|
||||
- Lint/types: `uv run ruff check . && uv run pyright` clean.
|
||||
- The phase is "Complete" only when the **entire** project test suite passes
|
||||
100% (every test in the project, not just this phase's).
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the
|
||||
project as it stands after this phase — fixing every inconsistency found,
|
||||
**including pre-existing ones not caused by the phase**.
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
|
||||
user-visible (the timeline is a visible UI change — name it where the chat
|
||||
thinking/tool-call display is described, if anywhere).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] A completed tool+thinking turn renders `Thinking → Tool call (1) →
|
||||
Thinking → Tool call (1) → … → answer` — the E2E's 7-element order pin
|
||||
(task 04 test 1); the runs fold at rest (test 2) and stay open while
|
||||
streaming (test 3).
|
||||
- [ ] A reload restores the same timeline + the record's `segments` array
|
||||
(test 4); a fresh context on `/shared/<token>` renders the same timeline
|
||||
(test 5).
|
||||
- [ ] A no-thinking tool turn is ONE disclosure, 3 lines (test 6) — the
|
||||
phase-117 contract is preserved; `test_tool_call_compact.py` +
|
||||
`test_thinking_display.py` + `test_big_read_progress.py` green in
|
||||
isolation UNCHANGED.
|
||||
- [ ] A pre-phase saved record (no `segments`) restores byte-identically
|
||||
(the integration pin + the E2E regression suites).
|
||||
- [ ] `uv run pytest` green (100% of the project suite);
|
||||
`uv run pytest --cov=app --cov-report=term-missing` > 90%;
|
||||
`tests/e2e/test_thinking_timeline.py` green in isolation;
|
||||
`uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `git diff --stat` limited to `app/schemas.py`, `frontend/assets/app.js`,
|
||||
`frontend/assets/shared.js`, `tests/`, and the phase files.
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole
|
||||
project (verified in the final pass).
|
||||
- [ ] ONE owner-directed exception to "no behavior change in completed phases":
|
||||
the phase-109 D15 re-open contract is REPLACED by the per-run blocks
|
||||
(owner direction 2026-10-04 — recorded above); `test_turn_progress_loader.py`'
|
||||
pin is updated in task 05 to the per-run contract (two thinking blocks,
|
||||
each carrying its own round, open in its live window, folded at rest).
|
||||
- [ ] One atomic `--no-gpg-sign` commit (below); the phase dir moved to
|
||||
`.agents/phases/complete/`.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agents/ app/schemas.py frontend/ tests/ README.md && git commit --no-gpg-sign -m "feat(ui): render thinking and tool runs as a chronological timeline (per-run blocks, segments persistence)"
|
||||
```
|
||||
|
||||
## Locked decisions
|
||||
- **The SSE contract is UNCHANGED (A15 intact).** The frames already arrive in
|
||||
true chronological order (`run_agent`'s yield order — verified in code); this
|
||||
phase is client-grouping + record persistence only. `app/rag/` is untouched.
|
||||
- **D1–D6 (owner, 2026-10-04)** — the per-run timeline, Option B placement,
|
||||
the plain `Thinking` label, the additive `segments` record, open-live/
|
||||
fold-at-rest, and the validated `ChatMessage.segments` field (the
|
||||
`extra="forbid"` boundary preserved with phase-83-style caps).
|
||||
- **A10 history wire UNCHANGED** — segments never travel to the LLM (the
|
||||
concatenated `thinking` travels as `reasoning_content` exactly as today;
|
||||
`HistoryTurn` + the mapper are untouched).
|
||||
- **A13 UNCHANGED** — no migration (the record is opaque JSONB; the schema
|
||||
field is validation at the boundary only).
|
||||
- **A16 honoured** — one new story E2E suite + the task-05 regression updates;
|
||||
mock-only by design. **A17 honoured** — one atomic `--no-gpg-sign` commit.
|
||||
- **The phase-87 clock re-target** (the `:last-child` query → the tracked
|
||||
reference) is a bug fix in service of D1; its visible behavior (the suffix on
|
||||
the newest line) is byte-identical — `test_big_read_progress.py` stays green
|
||||
unchanged. No other anchor changed.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Task 01 — the validated `ChatMessage.segments` field (server)
|
||||
|
||||
**Phase:** `158_thinking_timeline` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
Saved-chat writes accept the new optional `segments` array (a discriminated
|
||||
`thinking`/`tool` union with phase-83-style caps), omit it when absent so
|
||||
pre-phase records round-trip byte-identically, and reject malformed values at
|
||||
the `extra="forbid"` boundary.
|
||||
|
||||
## Work
|
||||
1. `app/schemas.py`:
|
||||
- `ChatThinkingSegment` (a `BaseModel`, beside `ToolCall`): `kind:
|
||||
Literal["thinking"]`, `text: str = Field(min_length=1, max_length=32_000)`
|
||||
(`HistoryTurn`'s cap, phase 83 — docstring cross-reference).
|
||||
- `ChatToolSegment` (a `BaseModel`): `kind: Literal["tool"]` + the EXACT
|
||||
`ToolCall` field set (name / argument / offset / limit / path / truncated /
|
||||
chars_shown / chars_total with their existing types and caps — mirror, do
|
||||
not weaken; docstring cross-references `ToolCall` as the shape source).
|
||||
- `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment],
|
||||
Field(discriminator="kind")]`.
|
||||
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None,
|
||||
max_length=50)` — with the phase-83 voice comment (the cap sizing: far
|
||||
past any realistic turn — the round cap is 10 → ≤ 20 entries; 50 matches
|
||||
the `tools` cap).
|
||||
- The EXISTING wrap serializer (`_drop_image_when_absent`) gains the
|
||||
omission rule: after the two image pops, `if self.segments is None:
|
||||
data.pop("segments", None)` — ABSENT, never `null` (the phase-50
|
||||
round-trip contract; the phase-122 `SourceRef.image_url` omission
|
||||
precedent, the phase-113 lesson applied up front: the field EXISTS so
|
||||
the boundary does NOT 422 the done-time auto-save carrying it).
|
||||
- The `ChatMessage` docstring gains the phase-158 paragraph (the shape,
|
||||
the omission rule, the `extra="forbid"` boundary) in the established
|
||||
voice; `HistoryTurn` and everything in `app/rag/` is UNTOUCHED (the
|
||||
history wire stays `{who, text, thinking}`).
|
||||
2. NO migration, NO router change (`app/api/chats.py` validates through
|
||||
`ChatMessage` — `m.model_dump()` + `ChatMessage.model_validate(m)` already
|
||||
carry the new field end to end, including the public shared read
|
||||
`SharedChatOut.messages`).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration (extend `tests/integration/test_chats_api.py` — the file already
|
||||
pins the brain-record contract, phase 83/113/120/123/124):
|
||||
- a record WITH `segments` (one `thinking` entry + two `tool` entries
|
||||
carrying every additive field: `offset/limit`, `path`,
|
||||
`truncated/chars_shown/chars_total`) is accepted by `POST /api/chats` and
|
||||
round-trips through `GET` (the shape preserved, entry order preserved);
|
||||
- `segments` with 51 entries → 422; a thinking-segment `text` of 32 001
|
||||
chars → 422; a segment with `kind: "bogus"` → 422; a record with a stray
|
||||
top-level key (e.g. `"foo": 1`) still → 422 (the `extra="forbid"`
|
||||
boundary intact);
|
||||
- a record WITHOUT `segments` (a pre-phase brain record, the full phase-113
|
||||
shape) round-trips with the key ABSENT from the served JSON (never
|
||||
`null` — the byte-identity pin, the phase-50 contract);
|
||||
- the shared read path (the public read endpoint's `messages`) carries the
|
||||
segments of a saved record with them (the public shape, the phase-51
|
||||
contract).
|
||||
- Coverage: **>90%** on this task's new/modified code (the schema code is
|
||||
exercised by the integration tests).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the three models + the field + the serializer rule land exactly as
|
||||
designed (a source check)
|
||||
- [ ] the new integration tests are green; the EXISTING
|
||||
`test_chats_api.py` tests stay green unchanged
|
||||
- [ ] `uv run pytest tests/integration -q` green; `uv run ruff check . && uv
|
||||
run pyright` clean
|
||||
- [ ] no change in `app/rag/`, no migration, no router change (a `git diff
|
||||
--stat` scoped check)
|
||||
@@ -0,0 +1,195 @@
|
||||
# Task 02 — the per-run live timeline in `app.js`
|
||||
|
||||
**Phase:** `158_thinking_timeline` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
Replace the single-block/single-disclosure turn rendering with the per-run
|
||||
timeline: each contiguous thinking run streams into its own `details.thinking`
|
||||
block, each contiguous tool-call run into its own
|
||||
`details.tool-calls-disclosure`, all segments ABOVE the answer bubble in
|
||||
arrival order (D2); the runs open live and fold at rest (D5); the phase-87
|
||||
clock is re-targeted to a tracked line reference; the save payloads persist
|
||||
the `segments` array (D4).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — turn state (the `runTurn` locals / module scope,
|
||||
~L3065–L3080):
|
||||
- `segRuns = []` — MODULE scope (beside `thinkingAcc`, which the pagehide
|
||||
partial reads), reset to `[]` in `runTurn` with the other turn locals.
|
||||
Entries: `{ kind: "thinking", text: "", records: [] }` /
|
||||
`{ kind: "tool", text: "", records: [] }` (the tool entry's `records`
|
||||
holds the `toolAcc` record objects — the SAME objects `toolAcc` holds,
|
||||
so the phase-95 `tool_result` stamp (`t.truncated = true; t.chars_shown
|
||||
= …; t.chars_total = …`, ~L3398–L3404) flows into both automatically —
|
||||
NO new wiring).
|
||||
- `currentRun = null` — turn-local (the active run's element + kind).
|
||||
2. `frontend/assets/app.js` — the run builders (replace `ensureThinkingBlock`
|
||||
+ the single-container logic in `appendToolLine`):
|
||||
- `ensureThinkingRun(wrap)` — returns the CURRENT thinking run's
|
||||
`details.thinking` if `currentRun?.kind === "thinking"`; else closes
|
||||
`currentRun` (if any — `open = false`), creates a NEW
|
||||
`details.thinking` (`open = true`, `<summary>Thinking</summary>` — the
|
||||
phase-17 literal, D3 — built via the existing `innerHTML` template that
|
||||
`ensureThinkingBlock` uses, OR createElement/textContent — either is fine,
|
||||
but the RESTORE path's builder stays createElement/textContent (the house
|
||||
rule pins `innerHTML` ABSENT from the restore/shared builders — see task
|
||||
03)), inserts it `before body.querySelector(".bubble")` (the bubble
|
||||
get-or-created first), pushes a fresh `{kind:"thinking",…}` to `segRuns`,
|
||||
sets `currentRun`.
|
||||
- `ensureToolRun(wrap)` — the same, for
|
||||
`details.tool-calls-disclosure` (`open = true`,
|
||||
`summary.tool-calls-summary` + the `div.tool-calls` list `role=list`
|
||||
`aria-label="Tool calls"` — the phase-117 structure, now per run).
|
||||
- `appendToolLine` — the signature becomes
|
||||
`appendToolLine(container, name, argument, offset = null, limit = null,
|
||||
scope = null)` (the CALLER passes the run's disclosure — no more
|
||||
`body.querySelector(".tool-calls-disclosure")` single-container
|
||||
derivation). EVERYTHING else in the function is BYTE-IDENTICAL: the
|
||||
`.tool-calls` list get-or-create, the line bytes, the four pinned
|
||||
`line.textContent` label literals, the phase-139 range suffix, the
|
||||
phase-146 scope span, the `Tool call(s) (N)` count.
|
||||
- `closeCurrentRun()` — folds `currentRun` (if any, `open = false`);
|
||||
idempotent; no-op without a run.
|
||||
- `closeAllRuns(wrap)` — closes EVERY open `details.thinking` +
|
||||
`details.tool-calls-disclosure` in the wrap (defensive; the `done` +
|
||||
stop/abort settle sites).
|
||||
3. `frontend/assets/app.js` — the turn-handler branches:
|
||||
- the `thinking` branch (~L3148): keep `settleToolLine()`,
|
||||
`thinkingAcc += ev.text || ""`, `sawThinking`, `clearTurnTimeout()`,
|
||||
`if (!wrap) wrap = addMessage("brain", "")`, `removeTyping()` — all
|
||||
unchanged. REPLACE the `ensureThinkingBlock` + `block.open = true`
|
||||
(phase-109 D15 re-open — SUPERSEDED by D1) with
|
||||
`const block = ensureThinkingRun(wrap);` + the run's text extension:
|
||||
the run's `text` accumulates `ev.text`; the pre-update pin-state /
|
||||
bottom-follow logic (the `isThinkingNearBottom` capture +
|
||||
`scrollTop = scrollHeight`, ~L3166–L3189) moves VERBATIM into this
|
||||
branch, now operating on THIS block (per-block — the existing comment
|
||||
block stays, updated to name the per-run placement).
|
||||
- the `tool` branch (~L3193): keep the phase-139/146 guards,
|
||||
`toolAcc.push(toolRecord)`, `clearTurnTimeout()`, the wrap
|
||||
get-or-create, and the `toolStatus` label logic UNCHANGED. REPLACE
|
||||
`appendToolLine(wrap, …)` + `armToolLineClock(wrap)` with:
|
||||
`const disc = ensureToolRun(wrap); const line =
|
||||
appendToolLine(disc, name, argument, frameOffset, frameLimit, framePath);`
|
||||
(the function returns the appended `line` element — a one-line change,
|
||||
the function already has it in scope) +
|
||||
`armToolLineClock(wrap, line)`. The run record is the SAME `toolRecord`
|
||||
object pushed to `toolAcc` (push it into the run's `records` too — or
|
||||
derive the run's `records` from `segRuns` at save time via the call
|
||||
order — the executor picks ONE, but the phase-95 stamp must reach the
|
||||
`segments` payload: the simplest is the run's `records.push(toolRecord)`
|
||||
alongside the `toolAcc.push(toolRecord)`).
|
||||
- the `delta` branch (~L3300): `settleToolLine()` + `acc += …` + the
|
||||
`thinking→streaming` transition UNCHANGED; replace
|
||||
`closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with
|
||||
`closeCurrentRun()`.
|
||||
- the THREE remaining settle-pair sites — `finalizeFailedTurn`'s
|
||||
`if (wrap)` block (L2990–L2991), the STOP path (L3477–L3478), and any
|
||||
other `closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep for
|
||||
the pair — the redesign removes both functions): replace the pair with
|
||||
`closeAllRuns(wrap);`.
|
||||
- `removeTyping()` / the typing-indicator / the `#send-status` copy / the
|
||||
UI state machine / the 300 s guard / the stream-drop guard / the
|
||||
`thinking-without-answer` fallback: ALL UNCHANGED (§7.4 intact).
|
||||
4. `frontend/assets/app.js` — the clock (the phase-87 re-target, ~L1479):
|
||||
- `armToolLineClock(wrap, lineEl)` — the NEW second param: store `lineEl`
|
||||
in a module-scope `toolLineEl` (reset to `null` with `toolLineWrap` in
|
||||
`stopToolLineClock` and on re-arm); the interval body uses `toolLineEl`
|
||||
(null-guarded — the wrap-reset no-op stays) instead of
|
||||
`toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child")`.
|
||||
The suffix element + the `(Ns)` text + the `TOOL_LINE_ELAPSED_AFTER_MS`
|
||||
gate are UNCHANGED.
|
||||
- `settleToolLine()` — the `querySelectorAll(".tool-elapsed")` removal stays
|
||||
(it clears every line in the wrap — correct with multiple disclosures);
|
||||
add `toolLineEl = null` alongside.
|
||||
- the comment block updated to name the re-target (the multi-disclosure
|
||||
fix, phase 158 D1).
|
||||
5. `frontend/assets/app.js` — the save payloads (D4) — the FIVE sites:
|
||||
- `buildSegments(segRuns)` — a helper (beside the turn state): maps the
|
||||
runs IN ORDER — a thinking run → `{ kind: "thinking", text: run.text }`
|
||||
ONLY when `run.text` is non-empty (an empty run stores nothing); a tool
|
||||
run → one `{ kind: "tool", …record }` per record in order (spread the
|
||||
record object — the additive `offset/limit/path` + the phase-95 stamp
|
||||
fields ride through; NO re-validation on the client — the server's
|
||||
`ChatMessage` boundary validates).
|
||||
- the `done` save point (~L3355, the `rememberBrainTurn` meta):
|
||||
`segments: buildSegments(segRuns).length ? buildSegments(segRuns) :
|
||||
undefined` (the `undefined`-drops-the-key house pattern, D4) — the
|
||||
`thinking: thinkingAcc || undefined` + `tools: toolAcc.length ? toolAcc :
|
||||
undefined` lines are UNCHANGED.
|
||||
- `finalizeFailedTurn` (L2983): the parameter object gains `segments` (the
|
||||
TWO call sites — the stream-drop site ~L3431 and the network-error
|
||||
site ~L3519 — pass `buildSegments(segRuns)`); the function's `if
|
||||
(wrap)` saved record includes `segments` ONLY when the passed value is
|
||||
a non-empty array (`segments: segments?.length ? segments :
|
||||
undefined`); the existing `acc`/`thinking`/`tools` fields are
|
||||
UNCHANGED.
|
||||
- the STOP path's save point (L3487–L3491, the `stopped: true` meta):
|
||||
gains the same `segments` field (non-empty only) — a stopped partial
|
||||
restores its timeline.
|
||||
- the pagehide partial (L3642–L3650, the module-scope read — `segRuns`
|
||||
is module scope, so it is readable there): gains the same `segments`
|
||||
field (non-empty only). The zero-frame fallback save (L3456–L3462)
|
||||
needs NOTHING (zero frames → no runs → no key).
|
||||
- the `thinking`/`tools` keys are UNCHANGED at every site.
|
||||
6. `tests/unit/test_thinking_timeline.py` (NEW — the source-level house
|
||||
pattern, the `js[fn : js.find("\n}\n", fn)]` body-extraction the existing
|
||||
pins use):
|
||||
- `ensureThinkingRun` body: creates `details` + `className = "thinking"` +
|
||||
`open = true` + the plain `Thinking` summary literal (D3 — the phase-17
|
||||
literal, NOT a numbered variant) + inserts before `.bubble` (D2 — the
|
||||
anchor is the bubble, NOT the old `.tool-calls-disclosure ?? …` chain);
|
||||
`ensureToolRun` body: `className = "tool-calls-disclosure"` + `open =
|
||||
true` + the summary/list structure.
|
||||
- `appendToolLine` signature: the first param is the container (no
|
||||
`body.querySelector(".tool-calls-disclosure")` in the body — the
|
||||
single-container derivation is GONE); the four label literals STILL
|
||||
present (mirror the guard); returns the line.
|
||||
- `armToolLineClock`: the `:last-child` query is GONE; a `toolLineEl`
|
||||
reference is used; `settleToolLine` resets it.
|
||||
- `closeCurrentRun` / `closeAllRuns` defined; the `delta` branch calls
|
||||
`closeCurrentRun` (pin: appears in the delta-branch region); `done` +
|
||||
the stop/abort settle call `closeAllRuns`.
|
||||
- `buildSegments` present; the `done` save + the `finalizeFailedTurn`
|
||||
call site + the pagehide partial all reference it (≥ 3 call sites).
|
||||
- `innerHTML` NOT in `ensureThinkingRun`… — pin per the builder choice
|
||||
made in Work item 2 (if the live builder reuses the phase-17 innerHTML
|
||||
template, pin that; the RESTORE/SHARED builders (task 03) are pinned
|
||||
innerHTML-free there).
|
||||
- `segRuns` is module scope + reset in `runTurn`.
|
||||
- Builder style: MIRROR the function it replaces (the live thinking
|
||||
builder keeps the phase-17 static-structure convention — a static
|
||||
template with NO data — the data flows through `renderMarkdown` /
|
||||
`textContent` exactly as today; the live tool builder keeps the
|
||||
phase-117 createElement/textContent style — `innerHTML` ABSENT from
|
||||
`appendToolLine`, as pinned today). Pin the STRUCTURE + the no-data-
|
||||
through-innerHTML rule, not the mechanism.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_thinking_timeline.py` (this task's pins) + UPDATE the
|
||||
existing source pins THIS task's change breaks (inspect, in order:
|
||||
`test_frontend_feedback.py`, `test_frontend_turn_loader.py` — the phase-109
|
||||
D15 re-open pin becomes the per-run pin (a NEW block, not a re-open),
|
||||
`test_frontend_tool_states.py` — the `appendToolLine` single-container pin,
|
||||
`test_big_read_progress.py` — the clock-query pin, `test_frontend_failed_turn.py`
|
||||
— the `finalizeFailedTurn` payload pin, `test_thinking_scroll.py`,
|
||||
`test_chat_persistence.py` — ONLY the pins this task touches; the
|
||||
restore-path pins are task 03's, the shared-page pins are task 03's). Every
|
||||
pin this task does NOT break (the tool-line label literals, the phase-117
|
||||
deboxed CSS, the phase-139/146 suffix pins, the emoji guard) stays green
|
||||
UNCHANGED.
|
||||
- Coverage: **>90%** on this task's modified code (the pins).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a live turn renders per-run blocks in arrival order (the code paths
|
||||
land exactly as designed — the unit pins)
|
||||
- [ ] the clock targets the tracked line reference (the `:last-child` query
|
||||
is gone — the unit pin)
|
||||
- [ ] the `done` / failed / pagehide save payloads carry `segments`
|
||||
(non-empty only) — the unit pins
|
||||
- [ ] `uv run pytest tests/unit -q` green (every pin this task breaks is
|
||||
updated; every pin it doesn't touch stays green); `uv run ruff check .
|
||||
&& uv run pyright` clean
|
||||
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
|
||||
`tests/unit/test_thinking_timeline.py`, and the unit pin files updated
|
||||
in this task
|
||||
@@ -0,0 +1,82 @@
|
||||
# Task 03 — the restore path + the shared-page timeline
|
||||
|
||||
**Phase:** `158_thinking_timeline` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
A restored brain record renders its `segments` as the collapsed timeline (the
|
||||
grouping mirrors the live run structure); a record WITHOUT `segments` — every
|
||||
pre-phase record — restores byte-identically through the untouched legacy
|
||||
path; the shared page renders the same timeline from the same record shape.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — `renderStoredMessage` (~L2089):
|
||||
- AFTER the bubble exists: `if (Array.isArray(m.segments) &&
|
||||
m.segments.length) { restoreTimeline(wrap, m.segments); } else { THE
|
||||
EXISTING `m.thinking` BLOCK + THE EXISTING `m.tools` LOOP,
|
||||
BYTE-IDENTICAL (including the `closeToolCalls(wrap)` call — RENAME it
|
||||
to `closeAllRuns(wrap)` — task 02 removed `closeToolCalls` — the
|
||||
restore's folded-at-rest result is the same) }`.
|
||||
- `restoreTimeline(wrap, segments)` (a new function, the house style —
|
||||
createElement/textContent, `renderMarkdown` for the thinking text,
|
||||
`innerHTML` ABSENT for the structure): iterate `segments` in order:
|
||||
- a `kind: "thinking"` entry (guard: `typeof entry.text === "string"`)
|
||||
→ its OWN `details.thinking` (created CLOSED — D5;
|
||||
`<summary>Thinking</summary>` — the phase-17 literal, D3;
|
||||
`.thinking-text` = `renderMarkdown(entry.text)` — the existing
|
||||
restore convention, escape-first), inserted before the bubble;
|
||||
- CONSECUTIVE `kind: "tool"` entries → ONE
|
||||
`details.tool-calls-disclosure` (created CLOSED — D5;
|
||||
`summary.tool-calls-summary` + the `div.tool-calls` list
|
||||
`role=list` `aria-label="Tool calls"` — the phase-117 structure),
|
||||
each entry's line through `appendToolLine` (task 02's container
|
||||
signature) with the EXISTING tOffset/tLimit/tPath guards
|
||||
(`Number.isInteger(x) && x >= 1` / the string guard — byte-identical,
|
||||
the phase-139/146 restore precedent) + the EXISTING
|
||||
`appendTruncatedNote(wrap, arg, …)` call for `entry.truncated`
|
||||
records (the phase-95 marker re-render — moved verbatim inside the
|
||||
grouping); the summary count `Tool call(s) (N)` set once per
|
||||
disclosure after its entries;
|
||||
- an entry with an unknown `kind` (or a malformed shape) → SKIPPED
|
||||
(never render from storage — textContent only, the house rule;
|
||||
a guard comment naming the skip).
|
||||
2. `frontend/assets/shared.js` — `addSegments(wrap, segments)` (new, the
|
||||
shared-page parity copy of `restoreTimeline` — pure render, always
|
||||
COLLAPSED — D5): the same grouping, the same line bytes (the four label
|
||||
literals + the phase-95 truncation note + the phase-139/146 suffixes —
|
||||
the parity `addToolLines` already carries; mirror it), the same summary
|
||||
count, the same unknown-`kind` skip. The record-dispatch site (~L456):
|
||||
`if (Array.isArray(m.segments) && m.segments.length) {
|
||||
addSegments(wrap, m.segments); } else { THE EXISTING addThinkingBlock +
|
||||
addToolLines, BYTE-IDENTICAL }`. The `addThinkingBlock` / `addToolLines`
|
||||
functions themselves are UNCHANGED (the legacy path is intact).
|
||||
3. `tests/unit/test_thinking_timeline.py` — ADD the pins:
|
||||
- the `renderStoredMessage` dispatch (segments take precedence; the
|
||||
legacy path intact when `segments` is absent — pin the
|
||||
`Array.isArray(m.segments) && m.segments.length` guard);
|
||||
- `restoreTimeline` body: `document.createElement("details")` +
|
||||
`className = "thinking"` (NO `open = true` — collapsed, D5) + the plain
|
||||
`Thinking` literal + the consecutive-tool grouping (one disclosure per
|
||||
run — pin the grouping loop) + the `appendTruncatedNote` call + the
|
||||
unknown-`kind` skip; `innerHTML` ABSENT (structure built via
|
||||
createElement/textContent).
|
||||
- the `shared.js` `addSegments` body: the same structure pins (collapsed,
|
||||
the parity) + `innerHTML` ABSENT; the dispatch guard in `shared.js`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_thinking_timeline.py` (this task's pins) + UPDATE the
|
||||
existing source pins THIS task's change breaks (inspect:
|
||||
`test_chat_persistence.py` — the restore-path pins, `test_shared_page.py` —
|
||||
the `addToolLines`/dispatch pins; every pin this task does NOT break stays
|
||||
green UNCHANGED).
|
||||
- Coverage: **>90%** on this task's modified code (the pins).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the `restoreTimeline` + `addSegments` builders land exactly as designed
|
||||
(the unit pins)
|
||||
- [ ] the legacy restore / shared paths are byte-unchanged (a `git diff`
|
||||
scoped check — only the dispatch guard is added)
|
||||
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run
|
||||
pyright` clean
|
||||
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
|
||||
`frontend/assets/shared.js`, `tests/unit/test_thinking_timeline.py`,
|
||||
and the unit pin files updated in this task
|
||||
@@ -0,0 +1,115 @@
|
||||
# Task 04 — the mock interleaved flow + the dedicated E2E suite
|
||||
|
||||
**Phase:** `158_thinking_timeline` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
A deterministic mock trigger that interleaves a DISTINCT reasoning run before
|
||||
every tool call and before the final answer (the phase-158 flow), and the
|
||||
dedicated `test_thinking_timeline.py` Playwright suite that pins the
|
||||
timeline contract (order / fold-at-rest / live-open / restore + record /
|
||||
shared-page parity / the no-thinking single-run grouping).
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/mock_llm.py`:
|
||||
- `THINK_TOOLS_TRIGGER = "think out loud and use your tools"` (beside
|
||||
`THINKING_TRIGGER`, L992) + the docstring entry for the flow (the
|
||||
four-request stateless shape, the `Step N` markers, the phase-37
|
||||
answer convention). BEFORE wiring, VERIFY (and note in the docstring):
|
||||
no existing trigger phrase is a substring of it, and NO existing E2E
|
||||
question contains it (grep `tests/e2e/*.py` for the phrase — the
|
||||
convention that keeps the other suites byte-unaffected).
|
||||
- `_thinking_tools_flow(body) -> tuple[str, ...] | None` (shaped like
|
||||
`_tool_flow`, L1820 — the same stateless discrimination, the same
|
||||
`<tools>` + `tools`-parameter gating, reusing `_drill_target` /
|
||||
`_first_file_line` / the `read`-result header check):
|
||||
- request 1 (tools offered, no tool results yet):
|
||||
`thinking = "Step 1: list the documents to find the reference server
|
||||
config."` → `_tool_call_stream("ls", {}, "call_0", thinking)`;
|
||||
- request 2 (a `tool`-role top-level source listing — no file lines
|
||||
yet): `thinking = "Step 2: drill into the first source."` → the
|
||||
`_drill_target` scoped `ls` (id `call_1`), with thinking;
|
||||
- request 3 (a `tool`-role folder listing WITH file lines — the
|
||||
phase-63/70 labeled `source:` / `path:` format): `thinking = "Step 3:
|
||||
read the file line's document."` → `read` on the JOINED combined
|
||||
`source/path` (id `call_2`), with thinking;
|
||||
- request 4 (a `tool`-role read result — content starting with the
|
||||
agent's `"Document <source/path>:"` header): `thinking = "Step 4:
|
||||
compose the answer from the read document."` → the phase-37 answer
|
||||
convention: `Read <source/path>. <first 80 chars of the read
|
||||
document's content>` (the `_sse_stream` content path — the
|
||||
`thinking=` param of `_sse_stream` already exists — pass it; the
|
||||
final answer's thinking streams ahead of the content, exactly like
|
||||
the `think out loud` answer path).
|
||||
- `_tool_call_stream(name, arguments, call_id, thinking="")` (L3813):
|
||||
the optional fourth param — when non-empty, yield
|
||||
`reasoning_content` chunks FIRST (the 12-char split — the
|
||||
`_sse_stream` convention), then the EXISTING `tool_calls` frames +
|
||||
`finish_reason: "tool_calls"` UNCHANGED. Default `""` → every
|
||||
pre-phase 3-arg caller (the ~10 call sites) streams BYTE-IDENTICAL
|
||||
(verify with the existing tool-flow E2E suites in the regression
|
||||
pass). The docstring updated (the param, the phase-158 flow).
|
||||
- the `chat_completions` streaming dispatch: check
|
||||
`_thinking_tools_flow(body)` BEFORE the `_tool_flow(body)` dispatch
|
||||
(L4453 region) — the more-specific-phrase-wins convention (the phrase
|
||||
contains `use your tools`, so the plain `_tool_flow` must NOT see
|
||||
it); on a hit, route each flow stage through the same
|
||||
`StreamingResponse` wrapper the `_tool_flow` stages use.
|
||||
2. `tests/e2e/test_thinking_timeline.py` (NEW — the six tests, the phase
|
||||
design's contract):
|
||||
- fixtures: the conftest `app_server` / `page` / `seeded_kb` (mirror
|
||||
`test_agent_document_tools.py` — the admin login, the seeded KB so
|
||||
request 4's read result exists);
|
||||
- 1. `test_timeline_order_after_completed_turn` — the trigger
|
||||
question; after the `done` state: the brain wrap's `.msg-body`
|
||||
children before the bubble are EXACTLY `[details.thinking,
|
||||
details.tool-calls-disclosure, details.thinking,
|
||||
details.tool-calls-disclosure, details.thinking,
|
||||
details.tool-calls-disclosure, details.thinking]` (assert the
|
||||
tag+class sequence — 7 elements); the four `.thinking-text`s carry
|
||||
DISTINCT `Step 1`…`Step 4` markers (a substring assert each —
|
||||
distinctness proves the runs are separate blocks, not one lump); each
|
||||
disclosure summary is `Tool call (1)`; the answer bubble carries the
|
||||
`Read ` echo.
|
||||
- 2. `test_runs_fold_at_rest` — after `done`: NO `.thinking` /
|
||||
`.tool-calls-disclosure` has the `open` attribute (a `count` assert
|
||||
on `[open]`); the summaries are visible.
|
||||
- 3. `test_live_runs_open_while_streaming` — an `expect.poll` predicate
|
||||
over the mock's pacing window (the tool frames' 0.1 s + the thinking
|
||||
stream): a `.thinking` or `.tool-calls-disclosure` is `open` while
|
||||
the answer bubble is still EMPTY (the D5 live-open contract).
|
||||
- 4. `test_restored_timeline_and_record` — RELOAD (same context): the
|
||||
restored message renders the same 7-element sequence, all closed; the
|
||||
localStorage record (read via `page.evaluate` on the `bor.chat.v1`
|
||||
storage key — the phase-14 key) for that brain turn carries a
|
||||
`segments` array of EXACTLY 7 entries in the pattern
|
||||
`thinking, tool, thinking, tool, thinking, tool, thinking` (assert the
|
||||
`kind` sequence + the `Step N` text on the thinking entries + the
|
||||
`name` on the tool entries); the `thinking` (full) + `tools` (3
|
||||
entries) keys are STILL present (D4 — the legacy fallback fields).
|
||||
- 5. `test_shared_page_timeline` — the phase-51 save + share flow (the
|
||||
`test_share_chat.py` pattern), a FRESH context on `/shared/<token>`:
|
||||
the same 7-element sequence renders, all collapsed, the `Step N`
|
||||
markers visible when expanded (expand ONE — the click on a summary —
|
||||
then assert), the answer bubble carries the `Read ` echo.
|
||||
- 6. `test_no_thinking_turn_single_disclosure` — a plain `use your
|
||||
tools` question (NO thinking trigger): EXACTLY ONE
|
||||
`.tool-calls-disclosure` (3 `.tool-call` lines, summary `Tool calls
|
||||
(3)`) and ZERO `.thinking` (the run-grouping contract — no thinking
|
||||
frames → one tool run — the phase-117 behavior preserved).
|
||||
|
||||
## Testing & Quality
|
||||
- The mock change is exercised by the suite (the trigger flow) + the
|
||||
existing tool-flow suites stay byte-unaffected (the `thinking=""` default
|
||||
— verified in the regression pass, task 05).
|
||||
- E2E: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov`
|
||||
green **in isolation** (prereq: `podman compose up -d db`), mock-only.
|
||||
- Coverage: the mock is test code (no `app/` change — the coverage floor is
|
||||
held by the untouched suite; task 01's schema code is covered there).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] the mock trigger + flow + the `_tool_call_stream` param land exactly
|
||||
as designed; the pre-phase callers are byte-unchanged (a `git diff`
|
||||
scoped check)
|
||||
- [ ] all six tests pass in isolation
|
||||
- [ ] `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` green
|
||||
in isolation; `uv run ruff check . && uv run pyright` clean
|
||||
@@ -0,0 +1,85 @@
|
||||
# Task 05 — the phase-109 suite pin update, the full gate, docs pass, commit
|
||||
|
||||
**Phase:** `158_thinking_timeline` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
Update `test_turn_progress_loader.py` from the phase-109 D15 re-open pin to
|
||||
the per-run contract (the ONE owner-directed behavior change — recorded in
|
||||
the phase design), run the full completion gate (unit + integration,
|
||||
coverage >90%, the phase E2E + the regression E2E suites in isolation,
|
||||
lint + types), run the documentation & consistency pass, and cut the single
|
||||
atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_turn_progress_loader.py` — the D15 pin → the per-run
|
||||
contract (the mock's turn-progress flow is `delta → tool → thinking →
|
||||
delta → thinking → done` — the first thinking run starts AFTER the
|
||||
first delta):
|
||||
- the re-open pin (`test_thinking_block_reopens_after_delta_with_visible_loader`
|
||||
— "the thinking block re-opens after the answer has started … the
|
||||
scratchpad carries both thinking rounds") becomes the PER-RUN pin:
|
||||
after the turn, the brain wrap holds TWO `.thinking` blocks (the
|
||||
`count` assert), EACH carrying its OWN round's text (the flow's two
|
||||
distinct reasoning texts — assert a marker on each, the way the mock's
|
||||
two reasoning streams are already distinct); both are FOLDED at rest
|
||||
(no `[open]`); the visible-during-streaming half of the pin (the
|
||||
loader visible, the thinking text non-empty and open while its run
|
||||
streams) is kept — a run is open in its live window (D5 replaces D15's
|
||||
re-open: a new block instead of a re-opened one — the user sees the
|
||||
new thinking either way; the D15 symptom — "the frozen chat" — stays
|
||||
fixed). The module docstring updated (the phase-158 supersession,
|
||||
owner direction 2026-10-04, recorded).
|
||||
- the other tests in the file (the loader visibility pins) stay green
|
||||
UNCHANGED — verify.
|
||||
2. The full gate (the phase's Testing & Quality section):
|
||||
- `uv run pytest` — the ENTIRE project suite, 100% green;
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on
|
||||
`app/`;
|
||||
- `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` — the
|
||||
phase E2E, green in isolation (prereq: `podman compose up -d db`);
|
||||
- the regression E2E suites, each green in isolation
|
||||
(`-v --no-cov`): `test_thinking_display.py`,
|
||||
`test_tool_call_compact.py`, `test_turn_progress_loader.py` (the
|
||||
UPDATED pins), `test_big_read_progress.py` (UNCHANGED — the clock's
|
||||
visible behavior is identical), `test_agent_document_tools.py`,
|
||||
`test_share_chat.py`, `test_chat_persistence.py`, `test_smoke.py`;
|
||||
- `uv run ruff check . && uv run pyright` clean.
|
||||
- If a regression suite fails on a pin the redesign broke that tasks
|
||||
02–04 missed: update THAT pin to the per-run contract (the same
|
||||
supersession logic as item 1 — note it in the commit body), and
|
||||
re-run the suite in isolation.
|
||||
3. The documentation & consistency pass (the phase's Documentation &
|
||||
Consistency section):
|
||||
- `README.md` + `AGENTS.md` reviewed in full against the project as it
|
||||
stands after this phase; every inconsistency fixed, **including
|
||||
pre-existing ones** (the README's chat thinking/tool-call display
|
||||
description, if any, gains the timeline sentence).
|
||||
- the `app/schemas.py` docstring voice (task 01) + the
|
||||
`frontend/assets/app.js` / `shared.js` comment blocks (tasks 02–03)
|
||||
read in the established phase-citation voice (spot-check).
|
||||
4. The commit + the phase move:
|
||||
- `git add -A .agents/ app/schemas.py frontend/ tests/ README.md && git
|
||||
commit --no-gpg-sign -m "feat(ui): render thinking and tool runs as a
|
||||
chronological timeline (per-run blocks, segments persistence)"`
|
||||
(the AGENTS.md rule 8 + A17; the LLM-Generated trailer per the
|
||||
git-commit skill's convention for this repo's commits — check the
|
||||
last few commits' bodies for the trailer form and match);
|
||||
- `mv .agents/phases/todo/158_thinking_timeline
|
||||
.agents/phases/complete/158_thinking_timeline`.
|
||||
|
||||
## Testing & Quality
|
||||
- The gate above IS this task's testing; no new code.
|
||||
- Coverage: the >90% floor (the gate).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `test_turn_progress_loader.py` updated to the per-run contract +
|
||||
green in isolation (item 1)
|
||||
- [ ] the FULL gate green (item 2 — every line)
|
||||
- [ ] README.md + AGENTS.md accurate and consistent with the whole project
|
||||
(item 3 — the final pass)
|
||||
- [ ] `git diff --stat` of the phase's total footprint limited to
|
||||
`app/schemas.py`, `frontend/assets/app.js`,
|
||||
`frontend/assets/shared.js`, `tests/`, the docs, and the phase files
|
||||
(the phase design's scope check)
|
||||
- [ ] one atomic `--no-gpg-sign` commit; the phase dir moved to
|
||||
`.agents/phases/complete/`
|
||||
Reference in New Issue
Block a user