diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts
index 67b455d96..274702f7a 100644
--- a/e2e/coach-layout.spec.ts
+++ b/e2e/coach-layout.spec.ts
@@ -7,8 +7,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* The Coach page frame: the conversations panel on the right, its toggle in
- * the top bar, the settings gear in the panel header, and New chat at the
- * panel's bottom right.
+ * the top bar, the settings gear in the panel header, and New chat opening
+ * that header row.
*
* Contracts under test, at 1440, 1280 and 390 px:
*
@@ -20,7 +20,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* 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.
+ * bar runs to the window's edge. New chat is the header row's first
+ * control, left of the title, and never overlaps the list.
* 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
@@ -31,8 +32,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* can see" and drops the param from the URL.
* 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 (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
+ * 6. At 390 px the panel is a dialog sheet with New chat first in its
+ * header and the title beside it; 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.
*
@@ -316,19 +317,38 @@ async function expectToggleAtBarEnd(page: Page) {
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]);
+/**
+ * New chat opens the panel header: the row's first control, left of the
+ * title, a plus glyph with an accessible name. It sits above the list, so no
+ * row ever slides under it.
+ */
+async function expectNewChatInHeader(scope: Locator) {
+ const header = scope.locator(
+ '[data-slot="coach-conversations-panel-header"]',
+ );
+ const button = header.locator('[data-slot="coach-panel-new-chat"]');
+ await expect(button).toHaveCount(1);
+ await expect(button.locator("svg.lucide-plus")).toHaveCount(1);
+ await expect(button).toHaveAttribute("aria-label", "New chat");
+ const order = await header.evaluate((el) => {
+ const first = el.firstElementChild as HTMLElement;
+ const heading = el.querySelector("h2") as HTMLElement;
+ return {
+ first: first.dataset.slot,
+ buttonRight: first.getBoundingClientRect().right,
+ headingLeft: heading.getBoundingClientRect().left,
+ };
+ });
+ expect(order.first).toBe("coach-panel-new-chat");
+ expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft);
+ const buttonBox = (await button.boundingBox())!;
+ const listBox = (await scope
+ .locator('[data-slot="coach-history-list"]')
+ .boundingBox())!;
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");
+ buttonBox.y + buttonBox.height,
+ "the button ends above the list",
+ ).toBeLessThanOrEqual(listBox.y);
}
/** Rendered heights of the docked panel's header buttons and first row. */
@@ -341,6 +361,7 @@ async function panelControlHeights(page: Page) {
.boundingBox())!.height,
);
return {
+ newChat: await height("coach-panel-new-chat"),
plans: await height("coach-panel-plans"),
gear: await height("coach-settings"),
row: await height("coach-history-select"),
@@ -439,7 +460,7 @@ test.describe("Coach page frame", () => {
await expectPanelFlush(page, width, 900);
// Open, the toggle is the top bar's last item, against the panel.
await expectToggleAtBarEnd(page);
- await expectFab(page);
+ await expectNewChatInHeader(panel(page));
// The bar says where the reader is: the page, then the conversation.
await expect(
page.locator('[data-slot="coach-top-bar-trail"]'),
@@ -505,6 +526,7 @@ test.describe("Coach page frame", () => {
await mockCoach(page);
await openCoach(page);
expect(await panelControlHeights(page)).toEqual({
+ newChat: 28,
plans: 28,
gear: 28,
row: 36,
@@ -524,6 +546,7 @@ test.describe("Coach page frame", () => {
await openCoach(page);
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(await panelControlHeights(page)).toEqual({
+ newChat: 44,
plans: 44,
gear: 44,
row: 44,
@@ -552,18 +575,8 @@ test.describe("Coach page frame", () => {
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
- 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(
- 24,
- 0,
- );
- expect(
- asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
- ).toBeCloseTo(24, 0);
- await fab.click();
+ const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]');
+ await newChat.click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
page.locator('[data-slot="coach-input-textarea"]'),
@@ -788,16 +801,15 @@ test.describe("Coach page frame", () => {
const sheet = page.getByRole("dialog", { name: "Conversations" });
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
- const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
- // 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 expectNewChatInHeader(sheet);
+ // The title keeps its whole word beside four header controls.
+ const title = sheet.locator(
+ '[data-slot="coach-conversations-panel-header"] h2',
+ );
+ expect(
+ await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
+ "the sheet title is not truncated",
+ ).toBe(true);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx
index ec10a47c5..c8bb33cd3 100644
--- a/src/components/insights/coach-panel/conversations-panel.tsx
+++ b/src/components/insights/coach-panel/conversations-panel.tsx
@@ -46,8 +46,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* 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.
+ * Plans, the settings gear and, in the sheet, a close button. New chat
+ * opens the header row, left of the title: a fixed control that never sits
+ * over the list, so the last conversation needs no reserve below it.
*
* Keyboard: Escape closes the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
@@ -214,13 +215,41 @@ export function ConversationsPanel({
// app (`shell-safe-area` in `auth-shell.tsx`).
// Docked, the row is the top bar's band (height and bottom border
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
+ // Touch: 44 px buttons already carry their own air, so the row packs
+ // them tighter to leave the title room at 390 px.
className={cn(
- "flex shrink-0 items-center gap-2",
+ "flex shrink-0 items-center gap-1 pointer-fine:gap-2",
inSheet
? "border-border border-b p-3"
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
)}
>
+
+
+
+
+
+ {t("insights.coach.newChat")}
+
+
{inSheet ? (
{t("insights.coach.historyTitle")}
@@ -287,28 +316,6 @@ 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")}
-
-
>
);
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index 1590b56cc..3ecaa120f 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -149,11 +149,7 @@ export function HistoryRail({