From 61f065c6515145131347013d997ae45c06534e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 18:46:27 +0200 Subject: [PATCH 1/3] feat(coach): run the conversations panel the full height of the window The docked panel was part of the page, below the top bar. It is now a column of the shell beside the content column, portalled through a new side-panel slot: it runs from the top of the window to the bottom, flush right, and the top bar ends at its left edge. Shut, the panel is gone entirely and the top bar runs to the window's edge. The panel's header row is the top bar's band, so the two bottom borders draw one line; the panel takes the sidebar surface and border, like the rail on the left. The top bar says where the reader is on desktop: "Coach", then the open conversation's title, truncated. The panel toggle is its last item, 12 px from its trailing edge, so it stands against the open panel and at the window's edge when the panel is shut; it carries a tooltip and is 28 px beside a fine pointer, 44 px with touch. New chat is a round 48 px primary button with a plus, 24 px in from the panel's corner (16 px in the phone sheet), with a tooltip. The top bar's action slot and the new context and side-panel slots share one portal helper. --- e2e/coach-layout.spec.ts | 131 +++++++++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../coach-panel/coach-conversation.tsx | 6 + .../coach-panel/coach-top-bar-trail.tsx | 56 +++++ .../coach-panel/conversations-panel.tsx | 216 +++++++++++------- src/components/layout/auth-shell.tsx | 7 + src/components/layout/shell-side-panel.tsx | 19 ++ src/components/layout/shell-slot.tsx | 55 +++++ src/components/layout/top-bar-actions.tsx | 64 ++---- src/components/layout/top-bar.tsx | 16 +- 16 files changed, 420 insertions(+), 157 deletions(-) create mode 100644 src/components/insights/coach-panel/coach-top-bar-trail.tsx create mode 100644 src/components/layout/shell-side-panel.tsx create mode 100644 src/components/layout/shell-slot.tsx 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/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} + {!heroActive ?

{title}

: null} {heroActive ? ( diff --git a/src/components/insights/coach-panel/coach-top-bar-trail.tsx b/src/components/insights/coach-panel/coach-top-bar-trail.tsx new file mode 100644 index 000000000..a1e1c81e1 --- /dev/null +++ b/src/components/insights/coach-panel/coach-top-bar-trail.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; + +import { TopBarContext } from "@/components/layout/top-bar-actions"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Where the reader is, at the leading edge of the top bar on the Coach page + * (desktop): "Coach", and the open conversation's title after it. The page + * name is meta (muted); the current item is content (foreground, medium), + * truncated before it would push into the toggle. On the new-chat hero the + * trail is the page name alone. + * + * Not a heading: the page keeps its own (screen-reader) `

`. + */ +export function CoachTopBarTrail({ + conversationTitle, +}: { + /** The open conversation's title; null on the new-chat hero. */ + conversationTitle: string | null; +}) { + const { t } = useTranslations(); + const page = t("nav.coach"); + return ( + + + + ); +} diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index d43b951fa..fc5113ba8 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -2,13 +2,7 @@ import { useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; -import { - PanelRightClose, - PanelRightOpen, - SquarePen, - Target, - X, -} from "lucide-react"; +import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -18,10 +12,18 @@ import { SheetDescription, SheetTitle, } from "@/components/ui/sheet"; +import { ShellSidePanel } from "@/components/layout/shell-side-panel"; +import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; import { TopBarActions } from "@/components/layout/top-bar-actions"; import { useCoachPanelOpen } from "@/hooks/use-coach-panel-open"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { CoachSettingsOverlay } from "./coach-settings-overlay"; import { HistoryRail } from "./history-rail"; @@ -37,10 +39,15 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach"; * Below 1280 px it is a sheet from the right that opens when asked and * closes again when a conversation is picked or a new chat starts. * - * The toggle sits at the trailing edge of the top bar and, while the docked - * panel is open, beside the panel's leading edge. The panel header - * carries the title, a link to Plans, the settings gear and, in the sheet, - * a close button. The round New chat button sits at the bottom right. + * Docked, the panel is a column of the shell beside the content column + * (top bar and page): it runs the full height of the window, and the top bar + * ends at its left edge. The panel's header row is the top bar's band, so + * the two bottom borders draw one line. Shut, it is gone entirely (no rail) + * and the top bar runs to the window's edge. The toggle is the last item of + * the top bar, so it sits against the open panel and at the window's edge + * when the panel is shut. The panel header carries the title, a link to + * Plans, the settings gear and, in the sheet, a close button. The round New + * chat button sits at the panel's bottom right. * * Keyboard: Escape closes the docked panel when focus is inside it and * nothing inside (a row menu, the rename field, the settings popover) has @@ -88,6 +95,52 @@ export interface ConversationsPanelProps { openSettingsOnData?: boolean; } +/** + * The panel's open/shut toggle, the last item of the top bar. 28 px beside a + * fine pointer, the 44 px touch floor otherwise; the tooltip names it. + */ +function PanelToggle({ + ref, + expanded, + label, + onToggle, + className, +}: { + ref: React.Ref; + expanded: boolean; + label: string; + onToggle: () => void; + className?: string; +}) { + const Icon = expanded ? PanelRightOpen : PanelRightClose; + return ( + + + + + {label} + + ); +} + export function ConversationsPanel({ activeId, onSelect, @@ -156,7 +209,14 @@ export function ConversationsPanel({ const header = (inSheet: boolean) => (
{inSheet ? ( @@ -224,87 +284,73 @@ export function ConversationsPanel({ onUndoDeleteActive={(id) => onSelect(id)} className="min-h-0 flex-1" /> - + {/* New chat: a round filled button floating at the panel's bottom + right; the list pads its own scroll area so the last row clears it. */} + + + + + + {t("insights.coach.newChat")} + + ); return ( - <> + - + expanded={expanded} + label={toggleLabel} + onToggle={toggle} + /> {docked ? ( - + + + ) : ( )} - + ); } diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 213d0a2e1..19ddea110 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -26,6 +26,7 @@ import { BottomNav } from "./bottom-nav"; import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; +import { ShellSidePanelOutlet } from "./shell-side-panel"; import { SharedRecordUnavailable } from "./shared-record-unavailable"; import { ModulePageGate, @@ -507,6 +508,12 @@ export function AuthShell({
+ {/* A page's side panel (the Coach's docked conversations) is a + column of this row, beside the top bar and `
`: it runs + the row's full height, from the top of the window to the + bottom, and the top bar ends at its left edge. Empty and out of + the row on every other page. */} + diff --git a/src/components/layout/shell-side-panel.tsx b/src/components/layout/shell-side-panel.tsx new file mode 100644 index 000000000..761fbebda --- /dev/null +++ b/src/components/layout/shell-side-panel.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { createShellSlot } from "./shell-slot"; + +/** + * A page-owned column on the right of the shell, beside the top bar and + * `
` rather than inside `
`. A panel portalled here runs the full + * height of the content row, from the top of the window (below any banner + * strip) to the bottom, and the top bar ends at its left edge instead of + * running underneath it. The Coach's docked conversations panel is the one + * user. See `shell-slot.tsx`. + */ +const slot = createShellSlot("shell-side-panel"); + +/** Where the shell paints a page's side panel. Empty unless a page fills it. */ +export const ShellSidePanelOutlet = slot.Outlet; + +/** Render `children` into the shell's side-panel column. */ +export const ShellSidePanel = slot.Portal; diff --git a/src/components/layout/shell-slot.tsx b/src/components/layout/shell-slot.tsx new file mode 100644 index 000000000..fe54677eb --- /dev/null +++ b/src/components/layout/shell-slot.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; + +/** + * A page-owned slot in the shell's chrome. + * + * The shell knows nothing about pages; a page that needs to paint into the + * chrome (the Coach's panel toggle in the top bar, its docked conversations + * panel beside the content column) renders the slot's `Portal` anywhere in + * its own tree and the content is portalled into the slot's `Outlet`. + * Portalling keeps the content inside the page's React tree, so it reads + * the page's state and context directly and no node has to be handed across + * a context on every render. + * + * The slot empties itself when the page unmounts: the portal goes with the + * component that rendered it, so no other route ever shows stale content. + * + * The outlet's element is held in a module-level store rather than a context + * provider. The shell mounts each outlet exactly once, so a store keeps the + * shell's tree untouched. On the server, and before the outlet has mounted, + * there is no target and the portal renders nothing. + */ +export function createShellSlot(slot: string) { + let outlet: HTMLElement | null = null; + const listeners = new Set<() => void>(); + + function setOutlet(node: HTMLElement | null) { + if (outlet === node) return; + outlet = node; + for (const listener of listeners) listener(); + } + + function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + } + + function Outlet({ className }: { className?: string }) { + return
; + } + + function Portal({ children }: { children: React.ReactNode }) { + const target = useSyncExternalStore( + subscribe, + () => outlet, + () => null, + ); + if (!target) return null; + return createPortal(children, target); + } + + return { Outlet, Portal }; +} diff --git a/src/components/layout/top-bar-actions.tsx b/src/components/layout/top-bar-actions.tsx index c053846e3..9eb88ba5a 100644 --- a/src/components/layout/top-bar-actions.tsx +++ b/src/components/layout/top-bar-actions.tsx @@ -1,53 +1,29 @@ "use client"; -import { useSyncExternalStore } from "react"; -import { createPortal } from "react-dom"; +import { createShellSlot } from "./shell-slot"; /** - * A page-owned slot at the trailing edge of the top bar. - * - * The top bar is shell chrome and knows nothing about pages; a page that - * needs a control up there (the Coach's conversations-panel toggle) renders - * `` anywhere in its own tree and the content is portalled - * into ``. Portalling keeps the control inside the - * page's React tree, so it reads the page's state and context directly and - * no node has to be handed across a context on every render. - * - * The slot empties itself when the page unmounts: the portal goes with the - * component that rendered it, so no other route ever shows a stale control. - * - * The outlet's element is held in a module-level store rather than a context - * provider. The shell mounts exactly one top bar, so there is one outlet, and - * a store keeps the shell's tree untouched. + * A page-owned slot at the trailing edge of the top bar: the Coach's + * conversations-panel toggle renders `` anywhere in its own + * tree and lands in ``. See `shell-slot.tsx`. */ -let outlet: HTMLElement | null = null; -const listeners = new Set<() => void>(); - -function setOutlet(node: HTMLElement | null) { - if (outlet === node) return; - outlet = node; - for (const listener of listeners) listener(); -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} +const slot = createShellSlot("top-bar-actions"); /** Where the top bar paints page actions. Empty unless a page fills it. */ -export function TopBarActionsOutlet({ className }: { className?: string }) { - return ( -
- ); -} +export const TopBarActionsOutlet = slot.Outlet; /** Render `children` into the top bar's action slot. */ -export function TopBarActions({ children }: { children: React.ReactNode }) { - const target = useSyncExternalStore( - subscribe, - () => outlet, - () => null, - ); - if (!target) return null; - return createPortal(children, target); -} +export const TopBarActions = slot.Portal; + +/** + * A page-owned slot at the leading edge of the top bar on desktop: where + * the page says where the reader is (the Coach's "Coach › conversation"). + * Phones keep the logo there. + */ +const context = createShellSlot("top-bar-context"); + +/** Where the top bar paints the page's context. Empty unless a page fills it. */ +export const TopBarContextOutlet = context.Outlet; + +/** Render `children` into the top bar's context slot. */ +export const TopBarContext = context.Portal; diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 71436d8b5..2f06d89d9 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -15,7 +15,10 @@ import { visibleUtilityDestinations, } from "@/components/layout/nav-model"; import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; -import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions"; +import { + TopBarActionsOutlet, + TopBarContextOutlet, +} from "@/components/layout/top-bar-actions"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -104,14 +107,19 @@ export function TopBar() { HealthLog - {/* Desktop: empty spacer (user controls are in sidebar) */} -
+ {/* Desktop: the page's context, where a page gives one (the Coach's + trail); otherwise an empty spacer. User controls are in the + sidebar. */} + {/* Page-owned actions (the Coach's conversations toggle). `ml-auto` keeps them at the trailing edge, right before the mobile avatar menu; `empty:hidden` takes the slot out of the row on every page that leaves it empty, so the header lays out exactly as before. */} - + {/* On desktop the last action sits 12 px from the bar's trailing edge + (`md:-mr-3` against `md:px-6`), so a panel toggle stands against the + panel it controls. */} + {/* Mobile-only auth section (desktop uses sidebar user section) */}
From b01c193274ab2f008e30a6400bfc8e2298aea9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 18:46:44 +0200 Subject: [PATCH 2/3] chore(release): v1.40.5 --- CHANGELOG.md | 13 +++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) 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/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/*` From 315b653079151e20cf0b615e2dd9fcf1c8acb35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 19:47:50 +0200 Subject: [PATCH 3/3] fix(coach): keep the docked panel header and new-chat button clear of the safe areas A standalone PWA on a tablet reserves the status bar in the top bar but not in the docked panel's header row, which could tuck its title and icons under the status bar; the new-chat button sat without the home indicator inset. Both now reserve the same insets as the top bar. --- .../insights/coach-panel/conversations-panel.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index fc5113ba8..eb756f365 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -209,6 +209,12 @@ export function ConversationsPanel({ const header = (inSheet: boolean) => (