diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f85b619..76b714aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.40.4] — 2026-10-05 + +The Coach's conversation panel docks to the right edge; model and tokens +move behind an info icon; no view chip for a result that already toggles. + +### Changed + +- **Coach panel.** Flush against the right edge, full height below the top + bar, its own scroll area; the shell renders `/coach` without its container + gutter. The toggle uses mirrored panel icons and moves with the panel. + Its buttons and rows go compact only beside a mouse or trackpad; with + touch they keep the 44 px target at any width, a tablet included. +- **Answer row.** Copy, read aloud, retry, an info icon and the time in one + left-aligned group; the info icon shows model and token count on hover, + focus or tap, and Enter or Space keeps it open. +- **Follow-ups.** No "show as chart/table" chip when the answer's result + already offers that toggle; also for stored answers. + ## [1.40.3] — 2026-10-05 Small fixes to the Today overview and the numbers behind it. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6033f1998..a35d16e6b 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.3 + version: 1.40.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts index b4aec3b52..fe13504f9 100644 --- a/e2e/coach-answer-layout.spec.ts +++ b/e2e/coach-answer-layout.spec.ts @@ -12,11 +12,12 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * * Contracts under test: * - * 1. Under an answer there is exactly one action row: the icon group and - * the time on the left, the tokens and model on the right, both halves - * on one line (their centres within 2 px, the row no taller than one - * 44 px target), the left group starting on the bubble's left edge and - * the meta ending inside the answer column. + * 1. Under an answer there is exactly one action row, left-aligned on the + * bubble's left edge and no taller than one 44 px target: copy, read + * aloud, try again, details, then the time. The model and the tokens + * are not text on the row; the details icon shows them in a tooltip on + * keyboard focus (desktop) and on a tap (phone); Enter and Space keep it + * open, and a second tap or Escape closes it. * 2. The follow-up chips sit in the answer's column: their left edge is * the bubble's, their text is the answer's size, and a chip is at most * 36 px tall beside a pointer and at least 44 px on a phone. @@ -246,28 +247,64 @@ test.describe("Coach answer layout", () => { const bubble = last.locator('[data-slot="coach-answer-bubble"]'); const column = last.locator('[data-slot="coach-answer-column"]'); const row = last.locator('[data-slot="coach-answer-actions"]'); - const left = row.locator('[data-slot="coach-answer-actions-left"]'); - const meta = row.locator('[data-slot="coach-answer-meta"]'); + const info = row.locator('[data-slot="coach-answer-info"]'); + const time = row.locator('[data-slot="coach-message-time"]'); await expect(row).toHaveCount(1); - await expect(meta).toContainText("1,234 tokens"); await expect(row).toHaveCSS("opacity", "1"); - const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all( - [bubble, column, row, left, meta].map(box), - ); - expect( - Math.abs( - leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2), - ), - "left group and meta share one line", - ).toBeLessThanOrEqual(2); + await expect(row).not.toContainText("tokens"); + await expect(row.locator('[data-slot="coach-answer-meta"]')).toHaveCount(0); + await expect(info).toHaveAttribute("aria-label", "Answer details"); + const [bubbleBox, columnBox, rowBox, answerCopyBox, infoBox, timeBox] = + await Promise.all( + [ + bubble, + column, + row, + row.locator('[data-slot="coach-copy-message"]'), + info, + time, + ].map(box), + ); expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44); - expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); - expect(metaBox.x + metaBox.width).toBeLessThanOrEqual( + expect(Math.abs(answerCopyBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); + expect(infoBox.x, "details follows the other icons").toBeGreaterThan( + answerCopyBox.x, + ); + expect(timeBox.x, "the time comes last").toBeGreaterThan(infoBox.x); + expect(timeBox.x + timeBox.width).toBeLessThanOrEqual( columnBox.x + columnBox.width + 1, ); - await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount( - 1, - ); + for (const [b, name] of [ + [infoBox, "details"], + [timeBox, "time"], + ] as const) { + expect( + Math.abs( + b.y + b.height / 2 - (answerCopyBox.y + answerCopyBox.height / 2), + ), + `${name} shares the row's line`, + ).toBeLessThanOrEqual(2); + } + + // The details tooltip: focus opens it on a desktop, a tap on a phone. + const tip = page.locator('[data-slot="coach-answer-info-content"]'); + await expect(tip).toHaveCount(0); + if (desktop) await info.focus(); + else await info.tap(); + await expect(tip).toBeVisible(); + await expect(tip).toContainText("Model: gpt-4o-mini-2024-07-18"); + await expect(tip).toContainText("1,234 tokens"); + await expect(info).toHaveAttribute("aria-describedby", /.+/); + await shoot(page, testInfo, `answer-info-${viewport.label}`); + if (desktop) { + // Enter and Space on the focused icon keep it open; Escape closes it. + for (const key of ["Enter", "Space"]) { + await page.keyboard.press(key); + await expect(tip).toBeVisible(); + } + await page.keyboard.press("Escape"); + } else await info.tap(); + await expect(tip).toHaveCount(0); // 2. The chips in the answer's column, at the answer's size. const chips = last.locator('[data-slot="coach-follow-up-chips"]'); diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index b933afd30..bf65e6ed7 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,59 @@ 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"]); +} + +/** Rendered heights of the docked panel's header buttons and first row. */ +async function panelControlHeights(page: Page) { + const height = async (slot: string) => + Math.round( + (await panel(page) + .locator(`[data-slot="${slot}"]`) + .first() + .boundingBox())!.height, + ); + return { + plans: await height("coach-panel-plans"), + gear: await height("coach-settings"), + row: await height("coach-history-select"), + }; +} + async function shot(page: Page, testInfo: TestInfo, name: string) { const path = testInfo.outputPath(`${name}.png`); await page.screenshot({ path }); @@ -318,6 +373,13 @@ 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); + // 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, + ); await expectNoSidewaysScroll(page); await shot(page, testInfo, `coach-frame-${width}-open`); @@ -326,6 +388,13 @@ 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. + await expect + .poll(async () => { + const box = (await toggle(page).boundingBox())!; + return Math.round(width - (box.x + box.width)); + }) + .toBeLessThanOrEqual(24); 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. @@ -349,6 +418,39 @@ test.describe("Coach page frame", () => { }); } + test("1280 fine pointer: compact header buttons and rows", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockCoach(page); + await openCoach(page); + expect(await panelControlHeights(page)).toEqual({ + plans: 28, + gear: 28, + row: 36, + }); + }); + + test.describe("touch", () => { + // A touch tablet in landscape docks the panel too; the controls keep the + // 44 px floor because the size follows the input, not the width. + test.use({ hasTouch: true }); + + test("1280 touch: header buttons and rows keep the 44 px floor", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockCoach(page); + await openCoach(page); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + expect(await panelControlHeights(page)).toEqual({ + plans: 44, + gear: 44, + row: 44, + }); + }); + }); + test("1440: picking a row opens it and New chat clears it, both in the URL", async ({ page, }) => { diff --git a/messages/de.json b/messages/de.json index c45a8287b..c5947cd3f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat", "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", "tokensUsed": "{count} Tokens", - "tokensUsedWithModel": "{count} Tokens · {model}", "askAboutThis": "Den Coach dazu fragen", "readStrip": { "label": "Üblicher Bereich", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date}, {time}", "areasDoneOne": "1 Bereich angesehen", "areasDoneFew": "{count} Bereiche angesehen", - "areasDoneOther": "{count} Bereiche angesehen" + "areasDoneOther": "{count} Bereiche angesehen", + "info": "Details zur Antwort", + "infoModel": "Modell: {model}" } }, "relativeJustNow": "gerade eben", diff --git a/messages/en.json b/messages/en.json index 6582a631e..ecb67bc15 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "Show what the Coach considered", "thinkingDetail": "Answered from your health snapshot.", "tokensUsed": "{count} tokens", - "tokensUsedWithModel": "{count} tokens · {model}", "askAboutThis": "Ask the coach about this", "readStrip": { "label": "Usual range", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date}, {time}", "areasDoneOne": "Looked at 1 area", "areasDoneFew": "Looked at {count} areas", - "areasDoneOther": "Looked at {count} areas" + "areasDoneOther": "Looked at {count} areas", + "info": "Answer details", + "infoModel": "Model: {model}" } }, "relativeJustNow": "just now", diff --git a/messages/es.json b/messages/es.json index d4996861e..9f192a8e5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta", "thinkingDetail": "Respondido a partir de tu resumen de salud.", "tokensUsed": "{count} tokens", - "tokensUsedWithModel": "{count} tokens · {model}", "askAboutThis": "Pregúntale al coach sobre esto", "readStrip": { "label": "Rango habitual", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date}, {time}", "areasDoneOne": "1 área consultada", "areasDoneFew": "{count} áreas consultadas", - "areasDoneOther": "{count} áreas consultadas" + "areasDoneOther": "{count} áreas consultadas", + "info": "Detalles de la respuesta", + "infoModel": "Modelo: {model}" } }, "relativeJustNow": "ahora mismo", diff --git a/messages/fr.json b/messages/fr.json index 8b11b0e67..8fe0245ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "Montrer ce que le Coach a pris en compte", "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", "tokensUsed": "{count} jetons", - "tokensUsedWithModel": "{count} jetons · {model}", "askAboutThis": "Interroger le coach à ce sujet", "readStrip": { "label": "Plage habituelle", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date}, {time}", "areasDoneOne": "1 domaine consulté", "areasDoneFew": "{count} domaines consultés", - "areasDoneOther": "{count} domaines consultés" + "areasDoneOther": "{count} domaines consultés", + "info": "Détails de la réponse", + "infoModel": "Modèle : {model}" } }, "relativeJustNow": "à l’instant", diff --git a/messages/it.json b/messages/it.json index 03b89d1a8..03989123d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "Mostra cosa ha considerato il Coach", "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", "tokensUsed": "{count} token", - "tokensUsedWithModel": "{count} token · {model}", "askAboutThis": "Chiedi al coach", "readStrip": { "label": "Intervallo abituale", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date}, {time}", "areasDoneOne": "1 area consultata", "areasDoneFew": "{count} aree consultate", - "areasDoneOther": "{count} aree consultate" + "areasDoneOther": "{count} aree consultate", + "info": "Dettagli della risposta", + "infoModel": "Modello: {model}" } }, "relativeJustNow": "proprio ora", diff --git a/messages/ko.json b/messages/ko.json index 9ba7aa1be..f569b77b4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "코치가 참고한 내용 보기", "thinkingDetail": "건강 스냅샷을 바탕으로 답했어요.", "tokensUsed": "{count} 토큰", - "tokensUsedWithModel": "{count} 토큰 · {model}", "askAboutThis": "코치에게 물어보기", "readStrip": { "label": "평소 범위", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date} {time}", "areasDoneOne": "영역 1개 확인", "areasDoneFew": "영역 {count}개 확인", - "areasDoneOther": "영역 {count}개 확인" + "areasDoneOther": "영역 {count}개 확인", + "info": "답변 정보", + "infoModel": "모델: {model}" } }, "relativeJustNow": "방금", diff --git a/messages/pl.json b/messages/pl.json index 3cca6e11c..0cc83a5e2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3023,7 +3023,6 @@ "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę", "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", "tokensUsed": "{count} tokenów", - "tokensUsedWithModel": "{count} tokenów · {model}", "askAboutThis": "Zapytaj trenera o to", "readStrip": { "label": "Typowy zakres", @@ -3143,7 +3142,9 @@ "timeEarlier": "{date}, {time}", "areasDoneOne": "Sprawdzono 1 obszar", "areasDoneFew": "Sprawdzono {count} obszary", - "areasDoneOther": "Sprawdzono {count} obszarów" + "areasDoneOther": "Sprawdzono {count} obszarów", + "info": "Szczegóły odpowiedzi", + "infoModel": "Model: {model}" } }, "relativeJustNow": "przed chwilą", diff --git a/package.json b/package.json index c78099e23..8b28e3063 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.40.3", + "version": "1.40.4", "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 b3a4d8c8f..50cd9111c 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.3"; + /* @sw-version-fallback */ "v1.40.4"; 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/app/api/insights/chat/__tests__/route-followups.test.ts b/src/app/api/insights/chat/__tests__/route-followups.test.ts index 8110489ee..fbfc92f84 100644 --- a/src/app/api/insights/chat/__tests__/route-followups.test.ts +++ b/src/app/api/insights/chat/__tests__/route-followups.test.ts @@ -212,17 +212,17 @@ describe("a reuse chip", () => { messageId: h.LAST_ASSISTANT_ID, ref: "r1", }); - const [chips] = framesOf<{ followUps: CoachFollowUp[] }>( - run.frames, - "followUps", - ); - expect(chips.followUps.map((c) => c.kind)).toEqual(["as_chart"]); + // The result renders with its own chart/table toggle, so no chip + // offers the chart again. + expect( + framesOf<{ followUps: CoachFollowUp[] }>(run.frames, "followUps"), + ).toEqual([]); expect(m.appendMessage).toHaveBeenCalledWith( expect.objectContaining({ role: "assistant", providerType: "reuse" }), ); }); - it("answers 'as a chart' with the stored chart and offers the table view", async () => { + it("answers 'as a chart' with the stored chart and leaves the table to its toggle", async () => { conversationWithChips(); const run = await tap("f3"); expect(providerCalls).toHaveLength(0); @@ -231,11 +231,9 @@ describe("a reuse chip", () => { "result", ).map((f) => f.result); expect(result.chart).toEqual(world.storedTables[0].chart); - const [chips] = framesOf<{ followUps: CoachFollowUp[] }>( - run.frames, - "followUps", - ); - expect(chips.followUps.map((c) => c.kind)).toEqual(["as_table"]); + expect( + framesOf<{ followUps: CoachFollowUp[] }>(run.frames, "followUps"), + ).toEqual([]); }); it("still passes the rate limit", async () => { 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/__tests__/message-actions.test.tsx b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx index 0d53a6e70..928dee229 100644 --- a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx @@ -2,9 +2,9 @@ * The one action row under a Coach message, and the follow-up chips that * live in the answer's column. * - * - Assistant: copy, read aloud, try again, then the time, in one left - * group; the tokens and model in the right group of the same row. No - * feedback thumbs, no evidence disclosure. + * - Assistant: copy, read aloud, try again, details, then the time, in one + * left-aligned row; the model and tokens live behind the details icon, + * not as text on the row. No feedback thumbs, no evidence disclosure. * - User: copy, remember, time in one row; remember is an icon. * - The time reads "14:32" today, "yesterday 14:32", or the date further * back. @@ -50,7 +50,11 @@ vi.mock("../read-aloud", async (importOriginal) => { }); import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble"; -import { answerMetaText, messageTimeText } from "../message-actions"; +import { + answerInfoLines, + answerInfoOpenAfterClick, + messageTimeText, +} from "../message-actions"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { return renderToStaticMarkup( @@ -100,26 +104,27 @@ const ANSWER = { describe("assistant action row", () => { const html = render(); const row = slot(html, "coach-answer-actions"); - const left = slot(row, "coach-answer-actions-left"); - it("lists copy, read aloud, try again and the time on the left, in that order", () => { + it("lists copy, read aloud, try again, details and the time, in that order", () => { const order = [ "coach-copy-message", "coach-read-aloud", "coach-try-again", + "coach-answer-info", "coach-message-time", - ].map((name) => left.indexOf(`data-slot="${name}"`)); + ].map((name) => row.indexOf(`data-slot="${name}"`)); expect(order.every((at) => at > -1)).toBe(true); expect([...order].sort((a, b) => a - b)).toEqual(order); }); - it("puts the tokens and the model on the right of the same row", () => { - const meta = slot(row, "coach-answer-meta"); - expect(meta).toContain("1,234 tokens · gpt-4o-mini"); - expect(meta).toContain('title="gpt-4o-mini"'); - expect(meta).toContain("truncate"); - expect(left).not.toContain("coach-answer-meta"); - expect(row).toContain("justify-between"); + it("keeps the model and tokens off the row, behind a named details button", () => { + expect(row).not.toContain("1,234"); + expect(row).not.toContain("gpt-4o-mini"); + expect(row).not.toContain("justify-between"); + expect(row).not.toContain("coach-answer-meta"); + const info = slot(row, "coach-answer-info"); + expect(info).toMatch(/^ {inSheet ? ( @@ -189,9 +200,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={PANEL_HEADER_BUTTON} > - ) : null} @@ -244,12 +255,27 @@ export function ConversationsPanel({ aria-label={toggleLabel} title={toggleLabel} data-slot="coach-panel-toggle" - className="text-muted-foreground hover:text-foreground size-11" + className={cn( + "text-muted-foreground hover:text-foreground size-11", + // Docked and open, the toggle keeps the panel's width as its + // right margin, so it stays beside the panel's leading edge and + // travels with it as the panel opens and shuts. + "transition-[margin] duration-200 ease-linear motion-reduce:transition-none", + docked && dockedOpen && "mr-72", + )} > + {/* Mirrored, so the chevron points the way the panel moves on a + click: right to shut it, left to open it. */} {expanded ? ( -