diff --git a/.planning/STATE.md b/.planning/STATE.md index c27815772..553d578b3 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 `` with three-column + layout on `lg+` (history rail · message thread · sources rail) and + full-screen single-column on mobile; `` renders user + / assistant bubbles + an in-flight streaming bubble with + pinned-to-bottom auto-scroll; `` renders provenance as + labels-only chips (metric · window · n-count, never raw values); + `` 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; `` with substring filter + and confirm-then-go delete; `` 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). diff --git a/.planning/phase-B2b-report.md b/.planning/phase-B2b-report.md new file mode 100644 index 000000000..479563073 --- /dev/null +++ b/.planning/phase-B2b-report.md @@ -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 ← 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. + +- `` 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`. +- ``'s `onPickPrompt` (already wired in B1) now sets + `coachPrefill=` and `coachOpen=true` so a chip click pops the + drawer with the localised prompt already in the composer. +- `` 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 `` 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 + `` 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 +```