diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76b714aae..0017353d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [1.40.5] — 2026-10-05
+
+The Coach's conversation panel runs the full height of the window.
+
+### Changed
+
+- **Coach panel.** Docked, the panel runs from the top of the window to the
+ bottom, beside the top bar instead of below it, and its header line
+ continues the top bar's. Shut, nothing of it remains on screen.
+- **Top bar on the Coach.** It names the page and the open conversation, and
+ the panel toggle sits at its right end, against the panel.
+- **New chat.** A round button with a plus at the panel's bottom right.
+
## [1.40.4] — 2026-10-05
The Coach's conversation panel docks to the right edge; model and tokens
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index a35d16e6b..59e63e736 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.40.4
+ version: 1.40.5
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts
index bf65e6ed7..a4b432ab8 100644
--- a/e2e/coach-layout.spec.ts
+++ b/e2e/coach-layout.spec.ts
@@ -16,7 +16,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* and the reading column (thread and composer) is exactly as wide with
* the panel open as with it closed. The choice survives a reload. The
* panel is flush with the viewport's right edge (no scrollbar gutter
- * beside it) and runs from the top bar to the bottom edge.
+ * beside it) and runs from the top of the window to the bottom; the
+ * top bar ends at its left edge and the panel's header border is the
+ * top bar's. The toggle is the bar's last item, within 16 px of its
+ * right edge, open and shut; shut, no pixel of the panel shows and the
+ * bar runs to the window's edge. New chat is a round 48 px plus.
* 2. Escape inside the docked panel closes it and returns focus to the
* toggle.
* 3. A row's menu renames by keyboard (Escape cancels without closing the
@@ -28,7 +32,7 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* 5. axe finds nothing on the open panel or the open settings, in the
* light and the dark theme.
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
- * right edge; Escape closes it and focus returns to the toggle. The gear
+ * right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. Nothing
* scrolls sideways.
*
@@ -233,10 +237,10 @@ async function expectNoSidewaysScroll(page: Page) {
}
/**
- * The docked panel is flush with the viewport: its right edge is the
- * viewport's (no scrollbar gutter beside it), it runs from the top bar's
- * bottom edge to the bottom of the viewport, and its own border is the left
- * one only.
+ * The docked panel is flush with the window: its right edge is the
+ * viewport's (no scrollbar gutter beside it), it runs from the top of the
+ * window to the bottom, the top bar ends at its left edge, its header row's
+ * bottom border is the top bar's, and its own border is the left one only.
*/
async function expectPanelFlush(page: Page, width: number, height: number) {
const geometry = await page.evaluate(() => {
@@ -244,15 +248,22 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
'[data-slot="coach-conversations-panel"]',
)!;
const inner = aside.firstElementChild as HTMLElement;
+ const header = aside.querySelector(
+ '[data-slot="coach-conversations-panel-header"]',
+ )!;
const bar = document.querySelector('[data-slot="top-bar"]')!;
const main = document.getElementById("main-content")!;
const style = getComputedStyle(inner);
const box = aside.getBoundingClientRect();
+ const barBox = bar.getBoundingClientRect();
return {
right: box.right,
top: box.top,
bottom: box.bottom,
- barBottom: bar.getBoundingClientRect().bottom,
+ left: box.left,
+ headerBottom: header.getBoundingClientRect().bottom,
+ barBottom: barBox.bottom,
+ barRight: barBox.right,
gutter: main.offsetWidth - main.clientWidth,
borders: [
style.borderTopWidth,
@@ -263,12 +274,60 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
};
});
expect(geometry.right).toBe(width);
- expect(geometry.top).toBe(geometry.barBottom);
+ expect(geometry.top).toBe(0);
expect(geometry.bottom).toBe(height);
+ expect(geometry.barRight).toBe(geometry.left);
+ expect(geometry.headerBottom).toBe(geometry.barBottom);
expect(geometry.gutter).toBe(0);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
+/**
+ * The toggle is the last item of the top bar (the account menu that follows
+ * it on phones is not rendered from `md`), and its right edge is within
+ * 16 px of the bar's right edge.
+ */
+async function expectToggleAtBarEnd(page: Page) {
+ const at = await page.evaluate(() => {
+ const bar = document.querySelector('[data-slot="top-bar"]')!;
+ const shown = [...bar.querySelectorAll("*")].filter(
+ (el) => el.getClientRects().length > 0,
+ );
+ const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
+ const rightmost = Math.max(
+ ...shown.map((el) => el.getBoundingClientRect().right),
+ );
+ return {
+ gap:
+ bar.getBoundingClientRect().right -
+ toggle.getBoundingClientRect().right,
+ last: toggle.getBoundingClientRect().right >= rightmost,
+ lastChild:
+ toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
+ toggle,
+ };
+ });
+ expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
+ expect(at.lastChild).toBe(true);
+ expect(at.gap).toBeGreaterThanOrEqual(0);
+ expect(at.gap).toBeLessThanOrEqual(16);
+}
+
+/** New chat: a round 48 px button with the plus glyph. */
+async function expectFab(page: Page) {
+ const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
+ const box = (await fab.boundingBox())!;
+ expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
+ expect(
+ await fab.evaluate((el) =>
+ parseFloat(getComputedStyle(el).borderTopLeftRadius),
+ ),
+ "round",
+ ).toBeGreaterThanOrEqual(24);
+ await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
+ await expect(fab).toHaveAttribute("aria-label", "New chat");
+}
+
/** Rendered heights of the docked panel's header buttons and first row. */
async function panelControlHeights(page: Page) {
const height = async (slot: string) =>
@@ -374,12 +433,13 @@ test.describe("Coach page frame", () => {
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectPanelFlush(page, width, 900);
- // Open, the toggle stands beside the panel's leading edge.
- const openToggle = (await toggle(page).boundingBox())!;
- expect(openToggle.x + openToggle.width).toBeLessThanOrEqual(aside.x);
- expect(aside.x - (openToggle.x + openToggle.width)).toBeLessThanOrEqual(
- 24,
- );
+ // Open, the toggle is the top bar's last item, against the panel.
+ await expectToggleAtBarEnd(page);
+ await expectFab(page);
+ // The bar says where the reader is: the page, then the conversation.
+ await expect(
+ page.locator('[data-slot="coach-top-bar-trail"]'),
+ ).toHaveText(/Coach.*Blood pressure this week/);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
@@ -388,13 +448,26 @@ test.describe("Coach page frame", () => {
await waitForWidth(panel(page), 0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
const closed = await columnWidths(page);
- // Closed, it returns to the trailing edge of the top bar.
+ // Closed, the panel is entirely off-screen, the top bar runs to the
+ // window's edge and the toggle is still its last item.
await expect
- .poll(async () => {
- const box = (await toggle(page).boundingBox())!;
- return Math.round(width - (box.x + box.width));
- })
- .toBeLessThanOrEqual(24);
+ .poll(() =>
+ page.evaluate(
+ () =>
+ document
+ .querySelector('[data-slot="top-bar"]')!
+ .getBoundingClientRect().right,
+ ),
+ )
+ .toBe(width);
+ expect(
+ await panel(page).evaluate((el) => {
+ const box = el.getBoundingClientRect();
+ return box.width === 0 || box.left >= window.innerWidth;
+ }),
+ "no pixel of the shut panel shows",
+ ).toBe(true);
+ await expectToggleAtBarEnd(page);
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
// Closed, nothing inside the panel is reachable.
@@ -475,10 +548,14 @@ test.describe("Coach page frame", () => {
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
const asideBox = (await panel(page).boundingBox())!;
+ // 24 px in from the panel's corner from `md`, 16 px on a phone.
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
- 16,
+ 24,
0,
);
+ expect(
+ asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
+ ).toBeCloseTo(24, 0);
await fab.click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
@@ -705,9 +782,15 @@ test.describe("Coach page frame", () => {
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
- const fabBox = (await fab.boundingBox())!;
- expect(390 - (fabBox.x + fabBox.width)).toBeCloseTo(16, 0);
- expect(fabBox.width).toBeGreaterThanOrEqual(44);
+ // Measured once the sheet has slid in.
+ await expect
+ .poll(async () => {
+ const box = (await fab.boundingBox())!;
+ return Math.round(390 - (box.x + box.width));
+ })
+ .toBe(16);
+ expect((await fab.boundingBox())!.width).toBe(48);
+ await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
diff --git a/messages/de.json b/messages/de.json
index c5947cd3f..cc16bbd08 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -407,6 +407,7 @@
"mood": "Stimmung",
"coach": "Coach",
"skipToContent": "Zum Inhalt springen",
+ "breadcrumb": "Du bist hier",
"sidebar": "Sidebar",
"mainNavigation": "Hauptnavigation",
"mobileNavigation": "Mobile Navigation",
diff --git a/messages/en.json b/messages/en.json
index ecb67bc15..9fd033567 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -407,6 +407,7 @@
"mood": "Mood",
"coach": "Coach",
"skipToContent": "Skip to content",
+ "breadcrumb": "You are here",
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
diff --git a/messages/es.json b/messages/es.json
index 9f192a8e5..47605ac34 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -407,6 +407,7 @@
"mood": "Estado de ánimo",
"coach": "Coach",
"skipToContent": "Saltar al contenido",
+ "breadcrumb": "Estás aquí",
"sidebar": "Barra lateral",
"mainNavigation": "Navegación principal",
"mobileNavigation": "Navegación móvil",
diff --git a/messages/fr.json b/messages/fr.json
index 8fe0245ee..87a42569d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -407,6 +407,7 @@
"mood": "Humeur",
"coach": "Coach",
"skipToContent": "Aller au contenu",
+ "breadcrumb": "Vous êtes ici",
"sidebar": "Barre latérale",
"mainNavigation": "Navigation principale",
"mobileNavigation": "Navigation mobile",
diff --git a/messages/it.json b/messages/it.json
index 03989123d..8a150e8a1 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -407,6 +407,7 @@
"mood": "Umore",
"coach": "Coach",
"skipToContent": "Vai al contenuto",
+ "breadcrumb": "Sei qui",
"sidebar": "Barra laterale",
"mainNavigation": "Navigazione principale",
"mobileNavigation": "Navigazione mobile",
diff --git a/messages/ko.json b/messages/ko.json
index f569b77b4..7c6d3661f 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -407,6 +407,7 @@
"mood": "기분",
"coach": "코치",
"skipToContent": "본문으로 건너뛰기",
+ "breadcrumb": "현재 위치",
"sidebar": "사이드바",
"mainNavigation": "주 메뉴",
"mobileNavigation": "모바일 메뉴",
diff --git a/messages/pl.json b/messages/pl.json
index 0cc83a5e2..c96054e1a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -407,6 +407,7 @@
"mood": "Nastrój",
"coach": "Coach",
"skipToContent": "Przejdź do treści",
+ "breadcrumb": "Jesteś tutaj",
"sidebar": "Pasek boczny",
"mainNavigation": "Nawigacja główna",
"mobileNavigation": "Nawigacja mobilna",
diff --git a/package.json b/package.json
index 8b28e3063..e1a6e9449 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.40.4",
+ "version": "1.40.5",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index 50cd9111c..dea9a680e 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.40.4";
+ /* @sw-version-fallback */ "v1.40.5";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index ff6265743..131f88d11 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -55,6 +55,7 @@ import {
} from "./message-thread";
import { CoachClarificationCard } from "./clarification-card";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
+import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
@@ -1202,6 +1203,11 @@ export function CoachConversation({
className="flex min-h-0 min-w-0 flex-1 flex-col"
>
{docScopeBanner}
+