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({
{isLoading && visible.length === 0 ? (