docs(planning): tick B2b complete, record phase B2b report

B2b drawer UI shipped in four atomic commits on develop:
- TanStack Query hooks + SSE streaming client
- Drawer shell + message thread + source chips
- Composer + history rail + sources rail
- /insights wires the drawer via the hero strip's "Ask the coach"
  button + suggested-prompt chips

Tests: 1781 → 1833 unit (+52). Integration unchanged at 78.
Six new test files cover the streaming parser, every drawer column,
the thread state machine, locale-aware labels, and the page wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 16:34:10 +02:00
1 parent e54179997b
commit 90d3a80267
2 files changed
+206 -7

No files matched your search

+35 -7
View File
@@ -101,9 +101,10 @@ ships behind a feature flag and unlocks independently.
### B2 — AI Coach panel + streaming chat + persistence (~5-7 days)
Split into B2a (backend, complete) + B2b (drawer UI, deferred). The
backend half landed under five atomic commits on `develop`; the
frontend dispatches separately once the wire contract has settled.
Split into B2a (backend, complete) + B2b (drawer UI, complete). Both
halves landed on `develop` under nine atomic commits total — five
for the backend wave, four for the drawer wave plus a state-tick
commit.
- [x] New `POST /api/insights/chat` SSE-streaming endpoint
- [x] New `GET /api/insights/chat` (list) + `GET/DELETE
@@ -114,11 +115,13 @@ frontend dispatches separately once the wire contract has settled.
- [x] Token-budget cap (25 000 tokens / user / UTC-day) + 20-turn
conversation cap with summarised-history fold
- [x] Prompt-injection + off-topic refusal pattern (EN+DE)
- [ ] Drawer overlay over `/insights`; full-page route deferred to
v1.5 — UI deferred to B2b dispatch
- [ ] New `src/components/insights/coach-panel/*` — B2b
- [x] Drawer overlay over `/insights`; full-page `/insights/coach`
route deferred to v1.5 paired with the iOS app
- [x] New `src/components/insights/coach-panel/*` — drawer shell,
message thread, source chips, composer, history rail,
sources rail, TanStack Query + SSE hooks
- Detailed report: `.planning/phase-B2a-report.md` (backend);
`.planning/phase-B2-report.md` reserved for B2b drawer UI
`.planning/phase-B2b-report.md` (drawer UI)
### B3 — Correlation discovery + Trends row with AI annotations (~3-5 days)
@@ -199,3 +202,28 @@ frontend dispatches separately once the wire contract has settled.
ownership boundary. Tests: 1753 → 1781 unit (+28),
67 → 78 integration (+11). UI drawer (B2b) deferred to a separate
dispatch.
## Status block — B2b (v1.4.20)
- 2026-05-10T16:30+02:00 — B2b complete. AI Coach drawer landed in
four atomic commits on `develop`: TanStack Query + SSE streaming
hooks (`useCoachConversations`, `useCoachConversation`,
`useDeleteCoachConversation`, `useSendCoachMessage`) with a
pure `parseSseChunk()` helper for unit-testable frame parsing;
drawer shell wrapped in `<Sheet side="right">` with three-column
layout on `lg+` (history rail · message thread · sources rail) and
full-screen single-column on mobile; `<MessageThread>` renders user
/ assistant bubbles + an in-flight streaming bubble with
pinned-to-bottom auto-scroll; `<SourceChips>` renders provenance as
labels-only chips (metric · window · n-count, never raw values);
`<CoachInput>` composer with Enter / Cmd+Enter / Shift+Enter
semantics, disabled mic placeholder ("voice arrives in v1.5"), and
the "Coach replies are generated. Clinical decisions belong with
your doctor." disclaimer; `<HistoryRail>` with substring filter
and confirm-then-go delete; `<SourcesRail>` with the five Coach
metric contracts; `/insights` page mounts the drawer with the
hero strip's "Ask the coach" button + suggested-prompt chips
driving open + prefill. Tests: 1781 → 1833 unit (+52,
six new test files: `use-coach`, `source-chips`, `message-thread`,
`coach-input`, `sources-rail`, `history-rail`). Integration count
unchanged at 78. typecheck + lint clean (12 baseline warnings).
+171
View File
@@ -0,0 +1,171 @@
# Phase B2b — AI Coach drawer UI
Date: 2026-05-10
Status: complete
Branch: `develop`
The B2b half of v1.4.20 wave B ships the drawer UI on top of the B2a
backend: TanStack Query data hooks, SSE streaming client, drawer
shell, message thread, source chips, composer, history rail, sources
rail, and the `/insights` wiring that opens the drawer from the hero
strip's "Ask the coach" button + suggested-prompt chips.
## Commit timeline (four atomic commits + this report on `develop`)
| SHA | Subject |
| --- | --- |
| `c42c0a5` | `feat(coach): TanStack Query hooks + SSE streaming client` |
| `195448a` | `feat(coach): drawer shell, message thread, source chips` |
| `8dac389` | `feat(coach): composer, history rail, sources rail` |
| `e541799` | `feat(insights): wire Coach drawer into /insights via hero strip` |
| _(this)_ | `docs(planning): tick B2b complete, record phase-B2b report` |
## Streaming implementation
`useSendCoachMessage()` POSTs the body to `/api/insights/chat` with
`Accept: text/event-stream` and reads the response via
`response.body.getReader()` + `TextDecoder`. Decoded chunks are
fed into a pure `parseSseChunk(buffer, chunk)` helper that splits on
the `\n\n` frame separator, strips the `data: ` prefix, JSON-parses
the payload, and returns `{ events, rest }`. The hook accumulates
tokens into `streaming.content`, attaches the `provenance` envelope
to `streaming.metricSource`, captures the resolved `conversationId` on
the `done` frame, and surfaces an `errorCode` on the `error` frame. On
success it invalidates the rail + the just-completed conversation
cache so the drawer's next mount sees the canonical persisted shape
(matching the queryKey pattern in `src/lib/query-keys.ts`).
`parseSseChunk` is exported as a separate symbol so the parser is
unit-testable without spinning up a live network stream — the test
suite covers single-frame, multi-frame, byte-by-byte interleaving,
malformed-payload tolerance, and the full token + provenance + done
round-trip.
## Component map
```
src/components/insights/coach-panel/
├── use-coach.ts ← TanStack Query hooks + SSE client +
│ parseSseChunk()
├── coach-drawer.tsx ← <Sheet side="right"> wrapper, 3-col grid
├── message-thread.tsx ← user / assistant bubble renderer +
│ pinned-to-bottom auto-scroll
├── source-chips.tsx ← provenance row (labels-only)
├── coach-input.tsx ← composer (textarea + mic + send +
│ disclaimer)
├── history-rail.tsx ← conversation list + filter + delete
├── sources-rail.tsx ← "What I can see" — 5 metric contracts
└── __tests__/
├── use-coach.test.ts (11)
├── source-chips.test.tsx (7)
├── message-thread.test.tsx (8)
├── coach-input.test.tsx (10)
├── history-rail.test.tsx (7)
└── sources-rail.test.tsx (5)
```
The drawer accepts slot props (`historyRail`, `sourcesRail`,
`composer`) so future tests / storybooks can swap individual columns
without coupling them to the shell. In production the drawer mounts
the defaults so callers only need to pass `open` + `onOpenChange` +
`prefill`.
## Wiring at `/insights`
`src/app/insights/page.tsx` owns `coachOpen` + `coachPrefill` state.
- `<HeroStrip>` gains an `onAskCoach` prop. When passed, the "Ask the
coach" button drops the disabled state + Coming-soon tooltip and
clicking sets `coachOpen=true` with `prefill=null`.
- `<HeroStrip>`'s `onPickPrompt` (already wired in B1) now sets
`coachPrefill=<chip>` and `coachOpen=true` so a chip click pops the
drawer with the localised prompt already in the composer.
- `<CoachDrawer>` mounts at the bottom with `key={prefill ?? "blank"}`
so the lazy `useState` initialiser fires fresh on every chip
transition and the composer surfaces the latest prefill — this
sidesteps the `react-hooks/set-state-in-effect` rule (no
`setState` inside `useEffect`).
## Test coverage
| Suite | Before | After | Δ |
| --- | --- | --- | --- |
| Unit (vitest) | 1781 | 1833 | +52 |
| Integration | 78 | 78 | 0 |
Six new test files cover the streaming parser, every drawer column,
the message thread state machine, locale-aware labels, and the
hero-strip handler wiring inside `/insights`. The integration suite
is unchanged because the drawer adds no new server-side behaviour.
## i18n
New strings under `insights.coach.*`:
- Drawer chrome: `tagline`, `newChat`, `settings`, `settingsTooltip`,
`send`, `thinking`, `composerPlaceholder`, `composerHint`,
`composerDisclaimer`, `voiceComingSoon`, `threadEmpty`
- History rail: `historyTitle`, `historySearchPlaceholder`,
`historyEmpty`, `historyDeleteAria`, `historyDeleteConfirm`
- Sources rail: `sourcesTitle`, `sourcesFresh`, `sourcesStale`,
`sourcesFooter`
- Provenance chip namespaces: `metric.{bp,weight,pulse,mood,
compliance,general}` and `window.{last7days,last30days,last90days,
allTime}`
Both `messages/en.json` and `messages/de.json` updated;
`i18n-locale-integrity.test.ts` keeps passing.
## Pieces deferred (handed off, not blocking)
- **Voice input** → v1.5 paired with the iOS app. The mic button
renders disabled with a tooltip pointing at the v1.5 release; the
PWA is the wrong shipper for voice.
- **Chart-in-message** → B3. Source chips today are visual-only; once
the trends row + correlation row land, a chip click can deeplink to
the relevant chart with the matching window pre-selected.
- **Full-page `/insights/coach` route** → v1.5. The drawer covers the
v1.4.20 use case; the dedicated route is a better fit once the iOS
app needs a deep-link target.
- **Mobile rail trays** → v1.4.21. On `<lg` the drawer collapses to a
single column with the message thread visible; the history +
sources rails are reachable via a dropdown menu in a follow-up.
- **`<IntegrationStatusPill>` integration in sources rail** →
v1.4.21. Today the rail shows static green dots; the next round
reuses the existing pill so the user sees fresh / stale state.
## UX decisions that may need maintainer review
- **Drawer width on `lg+` is `1080px`.** The artboard reads tighter
but the three-column layout (260 + auto + 280) gets cramped under
900 px. If this feels too wide the call is to drop the sources rail
on `lg` and only show it on `xl+`.
- **Plain Enter sends the message.** The artboard prototype shows
⌘↵ as the explicit affordance with `Enter` left for newlines. We
ship plain-Enter sends (Shift+Enter for newline) because the
composer is short by design (~80–220 word replies) and parity with
ChatGPT / Linear AI is the prevailing pattern. Easy to flip if
Marc disagrees.
- **Confirm-then-go delete.** The first click on the delete trash
flips the icon red; a second click commits the optimistic remove
with rollback. No modal. If the maintainer wants an explicit
`<AlertDialog>` we have one in `src/components/ui/`.
- **Streaming bubble pulse.** The in-flight assistant bubble pulses
via `animate-pulse` (opacity-only). The artboard shows shimmer; the
app's existing skeletons use pulse, so we follow the codebase
rather than the artboard for visual identity.
## Voice / hygiene
- No maintainer-name leaks in committed source.
- All UI-rendered strings live in `messages/{en,de}.json`.
- Co-Author trailer present on every commit; pre-commit hooks ran
green; no `--no-verify` or `--no-gpg-sign` used.
## Verification gate snapshot
```
pnpm typecheck 0 errors
pnpm lint 0 errors / 12 baseline warnings (unchanged)
pnpm test --run 223 files, 1833 passed
```