diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts
index b933afd30..07b36d83e 100644
--- a/e2e/coach-layout.spec.ts
+++ b/e2e/coach-layout.spec.ts
@@ -14,7 +14,9 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
*
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
* 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 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.
* 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
@@ -230,6 +232,43 @@ async function expectNoSidewaysScroll(page: Page) {
expect(overflow.main).toBeLessThanOrEqual(0);
}
+/**
+ * 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.
+ */
+async function expectPanelFlush(page: Page, width: number, height: number) {
+ const geometry = await page.evaluate(() => {
+ const aside = document.querySelector(
+ '[data-slot="coach-conversations-panel"]',
+ )!;
+ const inner = aside.firstElementChild as HTMLElement;
+ const bar = document.querySelector('[data-slot="top-bar"]')!;
+ const main = document.getElementById("main-content")!;
+ const style = getComputedStyle(inner);
+ const box = aside.getBoundingClientRect();
+ return {
+ right: box.right,
+ top: box.top,
+ bottom: box.bottom,
+ barBottom: bar.getBoundingClientRect().bottom,
+ gutter: main.offsetWidth - main.clientWidth,
+ borders: [
+ style.borderTopWidth,
+ style.borderRightWidth,
+ style.borderBottomWidth,
+ style.borderLeftWidth,
+ ],
+ };
+ });
+ expect(geometry.right).toBe(width);
+ expect(geometry.top).toBe(geometry.barBottom);
+ expect(geometry.bottom).toBe(height);
+ expect(geometry.gutter).toBe(0);
+ expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
+}
+
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
@@ -318,6 +357,7 @@ test.describe("Coach page frame", () => {
// The panel sits right of the column, never over it.
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
+ await expectPanelFlush(page, width, 900);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx
index f22b463d5..01b044e6e 100644
--- a/src/app/coach/page-client.tsx
+++ b/src/app/coach/page-client.tsx
@@ -263,22 +263,12 @@ export default function CoachPageClient() {
` paints directly onto the page so it reads as
- // one continuous conversation, not a window inside a window. The
- // separate bottom-right FAB/overlay drawer stays the compact surface.
- //
- // v1.18.10 (W10) — height is mobile-aware. The fixed ``
- // (64px + iOS home-indicator inset) is mobile-only; the desktop
- // full-bleed layout has no bottom nav. On mobile the page must subtract
- // the TopBar (`4rem`) AND the BottomNav band (`4rem` +
- // `env(safe-area-inset-bottom)`) so the docked composer / Stop control
- // always clears the nav instead of sitting under it. On `md+` the nav
- // is hidden, so the page reclaims the full height below the TopBar.
- className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
+ // Full-bleed conversation surface. The shell renders `/coach` without
+ // its centred container, padding or reserved scrollbar gutter, and its
+ // `` already clears the top bar and, on phones, the bottom nav,
+ // so the page simply fills what is left. No inner card: the
+ // conversation paints directly onto the page.
+ className="bg-background flex min-h-[32rem] flex-1 flex-col overflow-hidden"
>
{/* `useSearchParams` requires a Suspense boundary so the client-search
bailout never opts the whole route out of static optimisation. */}
diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx
index c3082381b..eda23e3df 100644
--- a/src/components/insights/coach-panel/conversations-panel.tsx
+++ b/src/components/insights/coach-panel/conversations-panel.tsx
@@ -146,14 +146,14 @@ export function ConversationsPanel({
const header = (inSheet: boolean) => (
)}
@@ -161,7 +161,7 @@ export function ConversationsPanel({
asChild
variant="ghost"
size="icon"
- className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
+ className="text-muted-foreground hover:text-foreground size-11 shrink-0 md:size-7"
>
-
+
{inSheet ? (
@@ -189,9 +190,9 @@ export function ConversationsPanel({
data-slot="coach-panel-close"
aria-label={t("common.close")}
title={t("common.close")}
- className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
+ className="text-muted-foreground hover:text-foreground size-11 shrink-0 md:size-7"
>
-
+
) : null}
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index d7756af6d..a3d9da2e5 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -294,7 +294,7 @@ function HistoryRow({
title={c.title}
data-slot="coach-history-select"
className={cn(
- "flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-lg py-2 pr-11 pl-2 text-left text-sm transition-colors",
+ "flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-md py-2 pr-11 pl-2 text-left text-sm transition-colors sm:min-h-9",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
// The open conversation reads like the open page in the left
// navigation: the same primary wash, so the two rails agree.
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index c10364e59..f03f531e4 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -27,12 +27,19 @@ import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
-import { ModulePageGate } from "@/components/layout/module-page-gate";
+import {
+ isModulePageOff,
+ ModulePageGate,
+} from "@/components/layout/module-page-gate";
+import { cn } from "@/lib/utils";
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
+/** The route that renders edge to edge inside the shell. */
+const COACH_FULL_BLEED_PATH = "/coach";
+
// v1.4.27 MB6 — `/about` joins the public-path list so the GeoLite2
// CC BY-SA 4.0 attribution stays reachable for unauthenticated
// visitors. The `/about` route is already registered in `proxy.ts`
@@ -141,6 +148,17 @@ export function AuthShell({
inSharedRecord &&
!isDestinationInSharedRecord(pathname, sections) &&
!isRecordSettingsPath;
+ // The Coach page is the one full-bleed route: its conversations panel sits
+ // flush against the right edge of the viewport and the page owns every
+ // scroll area inside it. The content box drops the centred container and
+ // `` drops its reserved scrollbar gutter, which would otherwise paint
+ // a dead strip to the right of the panel on classic-scrollbar platforms.
+ // A refusal (shared record, module off) renders a card, so it keeps the
+ // ordinary padded frame.
+ const fullBleed =
+ pathname === COACH_FULL_BLEED_PATH &&
+ !outsideSharedRecord &&
+ !isModulePageOff(pathname, user?.modules);
// The whole setup flow, not only its front door: every screen lives under
// `/onboarding/` (v1.39 C2), and an exact match here put the
// questions inside the full app chrome, sidebar and all.
@@ -461,7 +479,10 @@ export function AuthShell({
// window. Anchoring the scroll container closes the escape route
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
- className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
+ className={cn(
+ "relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0",
+ fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
+ )}
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
@@ -479,7 +500,12 @@ export function AuthShell({
*/}
{outsideSharedRecord ? (
diff --git a/src/components/layout/module-page-gate.tsx b/src/components/layout/module-page-gate.tsx
index 8cd75fc4f..6d75d4efd 100644
--- a/src/components/layout/module-page-gate.tsx
+++ b/src/components/layout/module-page-gate.tsx
@@ -74,14 +74,24 @@ export function moduleOwningPath(pathname: string): ModuleKey | undefined {
* only after the account payload resolved, so the map is never read on a
* hydration render.
*/
+/** True when the module that owns `pathname` is switched off. */
+export function isModulePageOff(
+ pathname: string,
+ modules: Partial> | null | undefined,
+): boolean {
+ const moduleKey = moduleOwningPath(pathname);
+ return moduleKey !== undefined && modules?.[moduleKey] === false;
+}
+
export function ModulePageGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
const { t } = useTranslations();
const moduleKey = moduleOwningPath(pathname);
- const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
- if (!off) return <>{children}>;
+ if (moduleKey === undefined || !isModulePageOff(pathname, user?.modules)) {
+ return <>{children}>;
+ }
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),