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(/^