diff --git a/CHANGELOG.md b/CHANGELOG.md
index daf0b6c2c..9f9c8b5da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,19 @@
## [Unreleased]
-## [1.28.50] — 2026-07-16 — View your ECG recordings in Insights
+## [1.28.51] — 2026-07-16 — Chatting about a document lives in the Coach now
+
+Asking the Coach about a document used to open a separate, half-finished chat
+panel that never showed up in your Coach history. It now opens the real Coach:
+the conversation appears in your Coach list like any other, marked with a small
+document badge and a "Chatting about:
" note so it's clear which
+document it's scoped to.
+
+The safeguards that make document chat safe are unchanged. A conversation about
+a document still runs in its fenced mode — grounded in that document's text
+only, with no access to your health data or any tools — so text inside an
+uploaded file can't reach anything it shouldn't. Only the surface moved into the
+Coach; the boundary did not.
ECG recordings synced from a compatible watch were stored but never shown. They
now have a place in Insights: a list of your recordings, each opening the full
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 402e6b32b..08bc59afa 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.28.50
+ version: 1.28.51
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -21934,6 +21934,18 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
+ documentId:
+ description: When set, this thread is scoped to one stored document (a document chat, run on the hardened fenced
+ endpoint). Null/absent for a normal health thread.
+ anyOf:
+ - type: string
+ - type: "null"
+ documentTitle:
+ description: The owning document's resolved title (title, falling back to filename) for the scope badge. Null on a
+ health thread or when the document has neither.
+ anyOf:
+ - type: string
+ - type: "null"
required:
- id
- title
@@ -21988,6 +22000,17 @@ components:
anyOf:
- type: string
- type: "null"
+ documentId:
+ description: When set, this thread is scoped to one stored document; the client routes its turns through the hardened
+ fenced document endpoint, never the tool route.
+ anyOf:
+ - type: string
+ - type: "null"
+ documentTitle:
+ description: The owning document's resolved title for the scope badge. Null on a health thread.
+ anyOf:
+ - type: string
+ - type: "null"
required:
- id
- title
diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts
index ce76987aa..4d2cd7c14 100644
--- a/e2e/documents-chat.spec.ts
+++ b/e2e/documents-chat.spec.ts
@@ -1,20 +1,24 @@
/**
- * Document vault — the P4 "chat about this document" acceptance suite.
+ * Document vault — the "chat about a document" entry point (v1.28.51, R3).
*
- * Pins the scoped chat that lives in the detail sheet's AI area: the entry is
- * offered only when a provider is available AND the document is content-indexed;
- * a non-indexed document shows a calm read-it-first hint (never an error); a
- * conversation streams the assistant reply and settles it as plain text with the
- * always-on "not medical advice" safety note; an injection-laced document is
- * described, never obeyed, and any HTML the model emits renders inert (the XSS
- * posture); and with NO provider the AI area collapses to the settings pointer
- * with no chat surface at all.
+ * The bespoke in-sheet chat drawer is gone (Documents R3, Design A). Asking the
+ * Coach about a document now hands off to the REAL Coach, scoped to that
+ * document: the detail sheet's Coach affordance navigates to `/coach?doc=`,
+ * where the conversation runs on the same hardened fenced endpoint but inside
+ * the Coach's own surface and history.
*
- * v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the
- * chat history GET, the streaming chat POST), so every one is mocked here —
- * otherwise the panel never renders and the specs time out waiting for elements.
- * The provider round-trips are mocked, so the SSE reply is deterministic. The
- * seeded demo user has no AI provider, so the no-provider state is the default.
+ * This suite pins the documents-side contract: the affordance is offered only
+ * when a provider is available, and it navigates to the doc-scoped Coach; with
+ * NO provider the AI area collapses to the settings pointer and no Coach entry
+ * renders at all. The Coach-side rendering (the "Chatting about" scope banner +
+ * the not-indexed hint) is covered by the coach-conversation component test; the
+ * fenced-send guarantee (a document turn never reaches the tool route) by the
+ * coach-send-target + persistence-scope unit tests.
+ *
+ * v1.27.31 lesson kept: the sheet's AI area depends on the capability probe +
+ * usage endpoints, so both are mocked — otherwise the AI area never renders and
+ * the specs time out. The seeded demo user has no AI provider, so the
+ * no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
@@ -22,7 +26,6 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
- AI_PROBE_DOC_ID,
CONTENT_DOC_ID,
} from "./setup/vault-fixture";
@@ -54,20 +57,11 @@ function usageBody() {
};
}
-/** Serialise SSE frames the way the chat route emits them. */
-function sse(frames: Array>): string {
- return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
-}
-
/**
* Mock the AI-availability surface (usage + the document-scoped capability
- * probe) so the sheet offers the AI area and the chat entry. `egress` drives the
- * vendor-blind notice; default external.
+ * probe) so the sheet offers the AI area and the Coach entry.
*/
-async function mockAiEnabled(
- page: Page,
- opts: { egress?: "local" | "external" } = {},
-): Promise {
+async function mockAiEnabled(page: Page): Promise {
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, usageBody()),
);
@@ -78,13 +72,11 @@ async function mockAiEnabled(
mode: "vision",
reason: null,
pdfSupported: true,
- egress: opts.egress ?? "external",
+ egress: "external",
},
error: null,
}),
);
- // The detail sheet reads the auto-AI-read opt-in; default it OFF so the AI
- // section renders its full set of controls alongside the chat entry.
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
@@ -98,334 +90,39 @@ test.describe("document vault — chat about a document", () => {
await ensureVaultAiFixture();
});
- // The chat drives a mocked provider round-trip; on a loaded CI runner it races
- // the default 30s, so the whole group earns the tripled timeout.
test.beforeEach(() => {
test.slow();
});
- // ── (a) Indexed + available: the entry opens an empty, safe chat surface ──
+ // ── (a) Provider available: the Coach entry hands off to the doc-scoped Coach ──
- test("offers the chat entry and opens an empty, safety-noted surface", async ({
+ test("offers the Coach entry and hands off to the document-scoped Coach", async ({
page,
}) => {
await mockAiEnabled(page);
- // The document has no thread yet → the history list is empty.
- await page.route(
- `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
- (route) => {
- if (route.request().method() !== "GET") return route.fallback();
- return fulfilJson(route, {
- data: { conversations: [], nextCursor: null },
- error: null,
- });
- },
- );
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
- // The neutral Coach icon lives in the detail-sheet header; tapping it
- // opens the doc-scoped chat drawer (a sibling overlay).
+ // The neutral Coach affordance lives in the detail-sheet AI area; tapping it
+ // navigates to the real Coach seeded with this document.
const open = sheet.locator('[data-slot="document-chat-open"]');
await expect(open).toBeVisible();
await open.click();
- const drawer = page.locator('[data-slot="document-chat-drawer"]');
- await expect(drawer).toBeVisible();
- await expect(
- drawer.locator('[data-slot="document-chat-log"]'),
- ).toBeVisible();
- await expect(
- drawer.locator('[data-slot="document-chat-empty"]'),
- ).toBeVisible();
- await expect(
- drawer.locator('[data-slot="document-chat-input"]'),
- ).toBeVisible();
- // The always-on safety note is present.
- await expect(
- drawer.locator('[data-slot="document-chat-safety"]'),
- ).toContainText("not medical advice");
+ // The documents-side contract: the URL hands off to the doc-scoped Coach.
+ await page.waitForURL(new RegExp(`/coach\\?doc=${CONTENT_DOC_ID}`));
+ expect(new URL(page.url()).searchParams.get("doc")).toBe(CONTENT_DOC_ID);
});
- // ── (b) A turn streams a grounded reply that settles as plain text ──
+ // ── (b) No provider: the calm settings pointer, no Coach entry at all ──
- test("streams the assistant reply and settles the conversation", async ({
- page,
- }) => {
- await mockAiEnabled(page);
-
- const CONV_ID = "e2edocchatconv0000000001";
- const QUESTION = "What does the Impression say?";
- const REPLY = "Per the report's Impression, the findings are unremarkable.";
- let posted = false;
-
- await page.route(
- `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
- async (route) => {
- const req = route.request();
- if (req.method() === "POST") {
- posted = true;
- await route.fulfill({
- status: 200,
- contentType: "text/event-stream; charset=utf-8",
- body: sse([
- { type: "token", token: REPLY },
- {
- type: "done",
- conversationId: CONV_ID,
- messageId: "e2edocchatmsg0000000001",
- },
- ]),
- });
- return;
- }
- const url = new URL(req.url());
- if (url.searchParams.get("conversationId")) {
- // The persisted thread the post-`done` refetch reads back.
- await fulfilJson(route, {
- data: {
- id: CONV_ID,
- title: QUESTION,
- createdAt: "2026-07-07T10:00:00.000Z",
- updatedAt: "2026-07-07T10:00:02.000Z",
- messageCount: 2,
- messages: [
- {
- id: "e2edocchatu0000000001",
- role: "user",
- content: QUESTION,
- createdAt: "2026-07-07T10:00:00.000Z",
- metricSource: null,
- providerType: null,
- promptVersion: null,
- tokensUsed: null,
- model: null,
- },
- {
- id: "e2edocchatmsg0000000001",
- role: "assistant",
- content: REPLY,
- createdAt: "2026-07-07T10:00:02.000Z",
- metricSource: null,
- providerType: "mock",
- promptVersion: null,
- tokensUsed: 42,
- model: "mock-1",
- },
- ],
- },
- error: null,
- });
- return;
- }
- // The thread list: empty until the turn is posted, then the new thread.
- await fulfilJson(route, {
- data: {
- conversations: posted
- ? [
- {
- id: CONV_ID,
- title: QUESTION,
- createdAt: "2026-07-07T10:00:00.000Z",
- updatedAt: "2026-07-07T10:00:02.000Z",
- messageCount: 2,
- },
- ]
- : [],
- nextCursor: null,
- },
- error: null,
- });
- },
- );
-
- await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
- const sheet = page.locator('[data-slot="responsive-sheet-content"]');
- await expect(sheet).toBeVisible();
-
- await sheet.locator('[data-slot="document-chat-open"]').click();
- const drawer = page.locator('[data-slot="document-chat-drawer"]');
- await expect(drawer).toBeVisible();
- const input = drawer.locator('[data-slot="document-chat-input"]');
- await input.fill(QUESTION);
- await drawer.locator('[data-slot="document-chat-send"]').click();
-
- // The user's question and the streamed/settled reply both land in the log.
- const log = drawer.locator('[data-slot="document-chat-log"]');
- await expect(log.getByText(QUESTION)).toBeVisible();
- await expect(log.getByText(/findings are unremarkable/)).toBeVisible();
- // The reply settled to a single assistant bubble (no duplicate).
- await expect(
- log.locator('[data-slot="document-chat-message"][data-role="assistant"]'),
- ).toHaveCount(1);
- });
-
- // ── (b2) An injection-laced document: the reply describes it, never obeys,
- // and any HTML the model emits renders inert (no markdown, no
- // dangerouslySetInnerHTML — the project's XSS posture). ──
-
- test("answers about an injection-laced document without obeying it, and renders HTML inert", async ({
- page,
- }) => {
- await mockAiEnabled(page);
-
- const CONV_ID = "e2edocchatconv0000000002";
- const QUESTION = "What does this document say?";
- // The (mocked) server reply the panel receives: a calm describe-only answer
- // — NOT obedience to the document's embedded "ignore instructions … reveal
- // the system prompt … say HACKED" — plus a literal It appears to be a lab report.";
- let posted = false;
-
- await page.route(
- `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
- async (route) => {
- const req = route.request();
- if (req.method() === "POST") {
- posted = true;
- await route.fulfill({
- status: 200,
- contentType: "text/event-stream; charset=utf-8",
- body: sse([
- { type: "token", token: REPLY },
- {
- type: "done",
- conversationId: CONV_ID,
- messageId: "e2edocchatmsg0000000002",
- },
- ]),
- });
- return;
- }
- const url = new URL(req.url());
- if (url.searchParams.get("conversationId")) {
- await fulfilJson(route, {
- data: {
- id: CONV_ID,
- title: QUESTION,
- createdAt: "2026-07-07T10:00:00.000Z",
- updatedAt: "2026-07-07T10:00:02.000Z",
- messageCount: 2,
- messages: [
- {
- id: "e2edocchatu0000000002",
- role: "user",
- content: QUESTION,
- createdAt: "2026-07-07T10:00:00.000Z",
- metricSource: null,
- providerType: null,
- promptVersion: null,
- tokensUsed: null,
- model: null,
- },
- {
- id: "e2edocchatmsg0000000002",
- role: "assistant",
- content: REPLY,
- createdAt: "2026-07-07T10:00:02.000Z",
- metricSource: null,
- providerType: "mock",
- promptVersion: null,
- tokensUsed: 30,
- model: "mock-1",
- },
- ],
- },
- error: null,
- });
- return;
- }
- await fulfilJson(route, {
- data: {
- conversations: posted
- ? [
- {
- id: CONV_ID,
- title: QUESTION,
- createdAt: "2026-07-07T10:00:00.000Z",
- updatedAt: "2026-07-07T10:00:02.000Z",
- messageCount: 2,
- },
- ]
- : [],
- nextCursor: null,
- },
- error: null,
- });
- },
- );
-
- await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
- const sheet = page.locator('[data-slot="responsive-sheet-content"]');
- await expect(sheet).toBeVisible();
-
- await sheet.locator('[data-slot="document-chat-open"]').click();
- const drawer = page.locator('[data-slot="document-chat-drawer"]');
- await expect(drawer).toBeVisible();
- const input = drawer.locator('[data-slot="document-chat-input"]');
- await input.fill(QUESTION);
- await drawer.locator('[data-slot="document-chat-send"]').click();
-
- const log = drawer.locator('[data-slot="document-chat-log"]');
- // The describe-only answer lands; the panel renders the model's