Merge pull request #1168 from MBombeck/feat/coach-panel-polish

chore(release): v1.40.4
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-05 18:23:00 +02:00
commit 4909a94485
33 files changed
+750 -206

No files matched your search

+18
View File
@@ -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.
+1 -1
View File
@@ -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.
+59 -22
View File
@@ -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"]');
+103 -1
View File
@@ -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,
}) => {
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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": "방금",
+3 -2
View File
@@ -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ą",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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 () => {
+6 -16
View File
@@ -263,22 +263,12 @@ export default function CoachPageClient() {
<div
data-slot="coach-page"
data-tour-id="coach-hero"
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
// full viewport height minus the top bar (`4rem`). No inner card:
// `<CoachConversation>` 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 `<BottomNav>`
// (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
// `<main>` 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. */}
@@ -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(<ChatBubble {...ANSWER} />);
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(/^<button type="button"/);
expect(info).toContain('aria-label="Answer details"');
});
it("has no thumbs, no evidence disclosure and no separate token line", () => {
@@ -129,19 +134,18 @@ describe("assistant action row", () => {
});
it("shows the time as text, the full date for screen readers", () => {
const time = slot(left, "coach-message-time");
const time = slot(row, "coach-message-time");
expect(time).toMatch(/^<time[^>]*dateTime="/);
expect(time).toContain("Message sent");
expect(time).toMatch(/<span aria-hidden="true">[^<]*\d:\d{2}[^<]*<\/span>/);
});
it("keeps an empty right group when the answer has no count", () => {
it("offers no details button when neither the model nor the count is known", () => {
const bare = render(
<ChatBubble {...ANSWER} tokensUsed={null} model={null} />,
);
expect(slot(bare, "coach-answer-meta")).toMatch(
/<p data-slot="coach-answer-meta"[^>]*><\/p>/,
);
expect(bare).toContain("coach-answer-actions");
expect(bare).not.toContain("coach-answer-info");
});
it("is absent on a refusal and while the answer is in flight", () => {
@@ -276,17 +280,41 @@ describe("messageTimeText", () => {
});
});
describe("answerMetaText", () => {
describe("answerInfoLines", () => {
const { t } = getServerTranslator("en");
it("formats the count for the locale and adds the model", () => {
expect(answerMetaText(1234, "gpt-4o", "en-US", t)).toBe(
"1,234 tokens · gpt-4o",
);
expect(answerMetaText(1234, null, "de-DE", t)).toBe("1.234 tokens");
it("names the model, then the count formatted for the locale", () => {
expect(answerInfoLines(1234, "gpt-4o", "en-US", t)).toEqual([
"Model: gpt-4o",
"1,234 tokens",
]);
expect(answerInfoLines(1234, null, "de-DE", t)).toEqual(["1.234 tokens"]);
});
it("is nothing without a count", () => {
expect(answerMetaText(null, "gpt-4o", "en-US", t)).toBeNull();
it("keeps the model without a count, and is empty without either", () => {
expect(answerInfoLines(null, "gpt-4o", "en-US", t)).toEqual([
"Model: gpt-4o",
]);
expect(answerInfoLines(null, null, "en-US", t)).toEqual([]);
});
it("speaks the reader's language", () => {
const de = getServerTranslator("de").t;
expect(answerInfoLines(1234, "gpt-4o", "de-DE", de)).toEqual([
"Modell: gpt-4o",
"1.234 Tokens",
]);
});
});
describe("answerInfoOpenAfterClick", () => {
it("opens on Enter or Space, never closes, though focus opened it already", () => {
// A keyboard activation fires no pointer-down.
expect(answerInfoOpenAfterClick(null)).toBe(true);
});
it("toggles on a tap or a click from the state at pointer-down", () => {
expect(answerInfoOpenAfterClick(false)).toBe(true);
expect(answerInfoOpenAfterClick(true)).toBe(false);
});
});
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { PANEL_HEADER_BUTTON } from "../conversations-panel";
/**
* The conversations panel's controls shrink below the 44 px touch floor only
* beside a fine pointer. A width breakpoint (`sm:` / `md:`) cannot tell a
* tablet from a laptop: a touch tablet at 1280 px docks the panel and got
* 28 px header buttons and 36 px rows from one.
*/
const FILES = [
"conversations-panel.tsx",
"history-rail.tsx",
"coach-settings-overlay.tsx",
].map((name) => ({
name,
source: readFileSync(
join(process.cwd(), "src/components/insights/coach-panel", name),
"utf8",
),
}));
/** A control size set by a width breakpoint: `md:size-7`, `sm:min-h-9`. */
const WIDTH_SIZED = /\b(?:sm|md|lg|xl):(?:size|min-h|h)-(?:\d|\[)/g;
describe("conversations panel touch targets", () => {
it.each(FILES)("$name sizes no control by viewport width", ({ source }) => {
expect(source.match(WIDTH_SIZED) ?? []).toEqual([]);
});
it.each(FILES)(
"$name shrinks its controls only beside a fine pointer",
({ source }) => {
expect(source).toMatch(/pointer-fine:(?:size|min-h)-\d/);
},
);
it("keeps the header buttons at 44 px and 28 px only with a fine pointer", () => {
const classes = PANEL_HEADER_BUTTON.split(/\s+/);
expect(classes).toContain("size-11");
expect(classes).toContain("pointer-fine:size-7");
});
});
@@ -78,7 +78,7 @@ export function CoachSettingsOverlay({
aria-expanded={open}
onClick={isPhone ? () => onOpenChange(true) : undefined}
className={cn(
"text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9",
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9",
className,
)}
>
@@ -37,7 +37,8 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* Below 1280 px it is a sheet from the right that opens when asked and
* closes again when a conversation is picked or a new chat starts.
*
* The toggle sits at the trailing edge of the top bar. The panel header
* The toggle sits at the trailing edge of the top bar and, while the docked
* panel is open, beside the panel's leading edge. 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 bottom right.
*
@@ -48,6 +49,15 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
/**
* The panel header's icon buttons: 28 px beside a fine pointer, the 44 px
* touch floor otherwise. Keyed on the input, not the viewport width, so a
* tablet in landscape (touch, docked panel) keeps the touch size.
*/
export const PANEL_HEADER_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4";
const noSubscription = () => () => {};
/**
@@ -146,14 +156,14 @@ export function ConversationsPanel({
const header = (inSheet: boolean) => (
<div
data-slot="coach-conversations-panel-header"
className="border-border flex h-14 shrink-0 items-center gap-1 border-b pr-2 pl-3"
className="border-border flex shrink-0 items-center gap-2 border-b p-3"
>
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-sm font-semibold">
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
</SheetTitle>
) : (
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold">
<h2 className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
</h2>
)}
@@ -161,7 +171,7 @@ export function ConversationsPanel({
asChild
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
className={PANEL_HEADER_BUTTON}
>
<Link
href="/coach/plans"
@@ -169,7 +179,7 @@ export function ConversationsPanel({
aria-label={t("coach.plans.title")}
title={t("coach.plans.title")}
>
<Target className="size-4" aria-hidden="true" />
<Target className={PANEL_HEADER_ICON} aria-hidden="true" />
</Link>
</Button>
<CoachSettingsOverlay
@@ -179,6 +189,7 @@ export function ConversationsPanel({
if (!next) setSettingsOnData(false);
}}
focusData={settingsOnData}
className="pointer-fine:size-7 [&_svg]:size-5 pointer-fine:[&_svg]:size-4"
/>
{inSheet ? (
<SheetClose asChild>
@@ -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}
>
<X className="size-4" aria-hidden="true" />
<X className={PANEL_HEADER_ICON} aria-hidden="true" />
</Button>
</SheetClose>
) : 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 ? (
<PanelRightClose className="size-5" aria-hidden="true" />
<PanelRightOpen
className="size-5 -scale-x-100"
aria-hidden="true"
/>
) : (
<PanelRightOpen className="size-5" aria-hidden="true" />
<PanelRightClose
className="size-5 -scale-x-100"
aria-hidden="true"
/>
)}
</Button>
</TopBarActions>
@@ -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 pointer-fine: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.
@@ -334,7 +334,7 @@ function HistoryRow({
})}
data-slot="coach-history-row-menu"
className={cn(
"text-muted-foreground hover:text-foreground absolute top-1/2 right-0 size-11 -translate-y-1/2 sm:right-1 sm:size-9",
"text-muted-foreground hover:text-foreground absolute top-1/2 right-0 size-11 -translate-y-1/2 pointer-fine:right-1 pointer-fine:size-9",
// Pointer devices reveal it on hover or focus; touch shows it
// always, since there is no hover to reveal it with.
"[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/row:opacity-100 [@media(hover:hover)]:group-hover/row:opacity-100 [@media(hover:hover)]:data-[state=open]:opacity-100",
@@ -3,10 +3,10 @@
/**
* The one action row under a Coach message, for both roles.
*
* Assistant: a single line. Left, the icon actions in reading order (copy,
* read aloud, try again) followed by the time the answer was written; right,
* the tokens and model the answer cost, as meta. The right group truncates
* before the row would wrap, so the row is one line at 390 px too.
* Assistant: a single line, left-aligned. The icon actions in reading order
* (copy, read aloud, try again, details) followed by the time the answer was
* written. The details icon holds the model and the tokens the answer cost
* in a tooltip that opens on hover, on keyboard focus and on a tap.
*
* User: copy, remember, time, flush with the bubble's right edge.
*
@@ -15,7 +15,7 @@
* `opacity-0` rather than `invisible` keeps every control focusable, and
* focus reveals the row.
*/
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -23,11 +23,18 @@ import {
BookmarkPlus,
Check,
Copy,
Info,
Loader2,
RotateCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Formatters } from "@/lib/format-locale";
@@ -85,27 +92,30 @@ export function messageTimeText(
}
/**
* The answer's cost as one meta string: tokens with the model when both are
* known, tokens alone otherwise, nothing without a count. The count is the
* server's (`done.usage` live, `tokensUsed` on reload), formatted for the
* reader's locale.
* What the details tooltip says about an answer: the model that wrote it and
* the tokens it cost, one line each, in that order, each only when known. The
* count is the server's (`done.usage` live, `tokensUsed` on reload),
* formatted for the reader's locale. Empty when neither is known, and then
* the row offers no details icon at all.
*/
export function answerMetaText(
export function answerInfoLines(
tokens: number | null | undefined,
model: string | null | undefined,
locale: string,
t: Translate,
): string | null {
if (tokens == null) return null;
let count: string;
try {
count = new Intl.NumberFormat(locale).format(tokens);
} catch {
count = String(tokens);
): string[] {
const lines: string[] = [];
if (model) lines.push(t("insights.coach.answer.infoModel", { model }));
if (tokens != null) {
let count: string;
try {
count = new Intl.NumberFormat(locale).format(tokens);
} catch {
count = String(tokens);
}
lines.push(t("insights.coach.tokensUsed", { count }));
}
return model
? t("insights.coach.tokensUsedWithModel", { count, model })
: t("insights.coach.tokensUsed", { count });
return lines;
}
function CopyMessageButton({
@@ -170,6 +180,70 @@ function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
);
}
/**
* Whether the details tooltip is open after a click on its icon, given its
* state when the pointer went down (`null`: no pointer went down, so Enter
* or Space activated the button). A tap or a mouse click toggles from that
* state, before the primitive's own pointer-down close ran. A keyboard
* activation always opens: focus has already opened the tooltip, and a
* toggle would close it on Enter. Escape is the keyboard's way out.
*/
export function answerInfoOpenAfterClick(
openAtPointerDown: boolean | null,
): boolean {
return openAtPointerDown === null ? true : !openAtPointerDown;
}
/**
* The model and token count of an answer, behind an info icon. A tooltip,
* opened the three ways a reader can reach it: hovering the icon, focusing
* it from the keyboard, and tapping it on a touch screen, where there is no
* hover. A second tap, a tap elsewhere or Escape closes it.
*
* The click is handled here because the tooltip primitive closes on every
* click (`answerInfoOpenAfterClick` decides instead).
*/
function AnswerInfoButton({ lines }: { lines: string[] }) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const openAtPointerDown = useRef<boolean | null>(null);
const label = t("insights.coach.answer.info");
return (
<TooltipProvider delayDuration={150}>
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
type="button"
data-slot="coach-answer-info"
aria-label={label}
className={COACH_ICON_BUTTON}
onPointerDown={() => {
openAtPointerDown.current = open;
}}
onClick={(event) => {
// Keep the primitive from closing it again on this click.
event.preventDefault();
setOpen(answerInfoOpenAfterClick(openAtPointerDown.current));
openAtPointerDown.current = null;
}}
>
<Info className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
data-slot="coach-answer-info-content"
className="flex flex-col gap-0.5 tabular-nums"
>
{lines.map((line) => (
<span key={line}>{line}</span>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
/**
* When the message was written, as text. The visible run is short ("14:32");
* the full date and time ride the `title` and the screen-reader text.
@@ -293,34 +367,23 @@ export function AssistantMessageActions({
model,
}: AssistantMessageActionsProps) {
const { t, locale } = useTranslations();
const meta = answerMetaText(tokens, model, resolveIntlLocale(locale), t);
const info = answerInfoLines(tokens, model, resolveIntlLocale(locale), t);
return (
<div
data-slot="coach-answer-actions"
className={cn(
"flex w-full min-w-0 items-center justify-between gap-2 self-stretch",
"flex w-full min-w-0 items-center gap-0.5 self-stretch",
REVEAL.assistant,
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<div
data-slot="coach-answer-actions-left"
className="flex shrink-0 items-center gap-0.5"
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <MessageTime iso={createdAt} />}
</div>
<p
data-slot="coach-answer-meta"
title={model ?? undefined}
className="text-muted-foreground min-w-0 truncate text-right text-xs tabular-nums"
>
{meta}
</p>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{!streaming && info.length > 0 && <AnswerInfoButton lines={info} />}
{createdAt && <MessageTime iso={createdAt} />}
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { isFullBleedPage } from "../full-bleed";
const open = { outsideSharedRecord: false, moduleOff: false };
describe("isFullBleedPage", () => {
it("renders /coach edge to edge", () => {
expect(isFullBleedPage({ pathname: "/coach", ...open })).toBe(true);
});
it.each([
"/coach/plans",
"/coach/conversations",
"/coach/",
"/coaching",
"/",
"/insights",
"/insights/coach",
"/settings/ai",
"/documents",
])("keeps %s in the padded frame", (pathname) => {
expect(isFullBleedPage({ pathname, ...open })).toBe(false);
});
it("keeps the padded frame for a refusal on /coach", () => {
expect(
isFullBleedPage({
pathname: "/coach",
outsideSharedRecord: true,
moduleOff: false,
}),
).toBe(false);
expect(
isFullBleedPage({
pathname: "/coach",
outsideSharedRecord: false,
moduleOff: true,
}),
).toBe(false);
});
});
+21 -3
View File
@@ -27,7 +27,12 @@ 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 {
ModulePageGate,
switchedOffModule,
} from "@/components/layout/module-page-gate";
import { cn } from "@/lib/utils";
import { isFullBleedPage } from "./full-bleed";
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
@@ -141,6 +146,11 @@ export function AuthShell({
inSharedRecord &&
!isDestinationInSharedRecord(pathname, sections) &&
!isRecordSettingsPath;
const fullBleed = isFullBleedPage({
pathname,
outsideSharedRecord,
moduleOff: switchedOffModule(pathname, user?.modules) !== undefined,
});
// The whole setup flow, not only its front door: every screen lives under
// `/onboarding/<step>` (v1.39 C2), and an exact match here put the
// questions inside the full app chrome, sidebar and all.
@@ -461,7 +471,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 +492,12 @@ export function AuthShell({
*/}
<div
data-slot="main-content-wrapper"
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
data-full-bleed={fullBleed ? "true" : undefined}
className={
fullBleed
? "flex min-h-0 flex-1 flex-col"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{outsideSharedRecord ? (
<SharedRecordUnavailable />
+27
View File
@@ -0,0 +1,27 @@
/**
* Whether the shell renders a page edge to edge.
*
* 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 shell then drops its centred container and
* padding, and `<main>` drops its reserved scrollbar gutter, which would
* otherwise paint a dead strip to the right of the panel on
* classic-scrollbar platforms.
*
* Exactly `/coach`: its sub-pages (plans, conversations) are ordinary pages
* in the padded frame. A refusal on `/coach` (outside a shared record, or
* the module switched off) renders a card, so it keeps the padded frame too.
*/
const FULL_BLEED_PATH = "/coach";
export function isFullBleedPage(args: {
pathname: string;
outsideSharedRecord: boolean;
moduleOff: boolean;
}): boolean {
return (
args.pathname === FULL_BLEED_PATH &&
!args.outsideSharedRecord &&
!args.moduleOff
);
}
+17 -3
View File
@@ -56,6 +56,21 @@ export function moduleOwningPath(pathname: string): ModuleKey | undefined {
return destination ? navDestinationModule(destination) : undefined;
}
/**
* The module that owns `pathname` when it is switched off, otherwise
* undefined. The shell reads it too: a switched-off page renders the
* notice in the ordinary padded frame.
*/
export function switchedOffModule(
pathname: string,
modules: Partial<Record<ModuleKey, boolean>> | null | undefined,
): ModuleKey | undefined {
const moduleKey = moduleOwningPath(pathname);
return moduleKey !== undefined && modules?.[moduleKey] === false
? moduleKey
: undefined;
}
/**
* A page of a module that is switched off answers with an inline notice that
* names the reason, in one place for every module page: top-level pages
@@ -79,9 +94,8 @@ export function ModulePageGate({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { t } = useTranslations();
const moduleKey = moduleOwningPath(pathname);
const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
if (!off) return <>{children}</>;
const moduleKey = switchedOffModule(pathname, user?.modules);
if (moduleKey === undefined) return <>{children}</>;
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
@@ -201,6 +201,60 @@ describe("appendMessage — the dialog fields", () => {
});
});
it("reads a stored view chip back only where the answer's panel has no toggle for it", async () => {
// Stored before the rule: "as a table" for the shown chart (r1), whose
// panel already toggles, and "as a chart" for a table only used (r2).
const view = (id: string, kind: "as_chart" | "as_table", ref: string) => ({
...FULL.followUps![0],
id,
kind,
labelKey: `coach.followUp.${kind === "as_chart" ? "asChart" : "asTable"}`,
label: kind === "as_chart" ? "Show as a chart" : "Show as a table",
anchor: { ref, domain: "bp" as const, window: "last30days" as const },
reuse: true,
});
const used = { ...SLEEP_META, displayed: false };
stubEchoCreate(
JSON.stringify({
windows: [],
metrics: [],
results: [META, used],
followUps: [
view("f0", "as_table", "r1"),
FULL.followUps![0],
view("f3", "as_chart", "r2"),
],
}),
);
const out = await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
metricSource: { windows: [], metrics: [] },
});
expect(out.metricSource?.followUps?.map((c) => [c.id, c.kind])).toEqual([
["f1", "previous_period"],
["f3", "as_chart"],
]);
// Only the redundant chip stored: the list reads back absent, not empty.
stubEchoCreate(
JSON.stringify({
windows: [],
metrics: [],
results: [META],
followUps: [view("f1", "as_table", "r1")],
}),
);
const bare = await appendMessage({
conversationId: "c1",
role: "assistant",
content: "reply",
metricSource: { windows: [], metrics: [] },
});
expect(bare.metricSource).not.toHaveProperty("followUps");
});
it("strips a key the stored blob carries that the contract does not know", async () => {
stubEchoCreate(
JSON.stringify({
@@ -165,20 +165,26 @@ describe("deriveFollowUps — the rules", () => {
expect(all).toEqual([]);
});
it("offers the chart back for a table shown as a table", () => {
const chips = derive({
results: [
table("bp", { chartKind: "line", displayed: true, view: "table" }),
],
});
expect(chips[0]).toMatchObject({ kind: "as_chart", reuse: true });
it("offers no view chip for a table the answer shows with its chart", () => {
// The result panel renders its own chart/table toggle; a chip asking for
// either view would repeat it, whichever view the panel opens on.
for (const view of [undefined, "table" as const]) {
const chips = derive({
results: [
table("bp", {
chartKind: "line",
displayed: true,
...(view ? { view } : {}),
}),
],
});
expect(chips.some((c) => c.reuse)).toBe(false);
}
});
it("offers the other view of a table that has a chart, as a reuse chip", () => {
const shown = derive({
results: [table("bp", { chartKind: "line", displayed: true })],
});
expect(shown[0]).toMatchObject({ kind: "as_table", reuse: true });
it("offers the chart of a table the answer only used, as a reuse chip", () => {
// Folded under "Data used" as a table, its toggle is out of sight; the
// chip brings it into the answer.
const tucked = derive({
results: [table("bp", { chartKind: "line", displayed: false })],
});
@@ -404,6 +410,11 @@ describe("deriveFollowUps — property: every chip's domain was read this turn",
}
if (!chip.reuse) {
expect(held.has(domain!), `seed ${seed}`).toBe(true);
} else {
// A view chip never repeats the toggle of a table the answer
// shows with its chart.
const meta = results.find((m) => m.ref === chip.anchor?.ref);
expect(meta?.displayed && meta.chartKind, `seed ${seed}`).toBeFalsy();
}
}
}
@@ -17,6 +17,7 @@ vi.mock("@/lib/logging/context", () => ({
import type { CoachFollowUp } from "@/lib/ai/coach/types";
import { followUpContextHint, resolveFollowUp } from "../resolve";
import { isRedundantViewChip } from "../view-chip";
const PREVIOUS: CoachFollowUp = {
id: "f1",
@@ -145,6 +146,37 @@ describe("resolveFollowUp", () => {
);
});
it("still resolves a stored view chip the read path now hides", async () => {
// A reply stored before view chips were withheld for a table the answer
// shows with its own toggle. Reading the reply back drops the chip, so a
// current client never offers it; an older client that still shows it
// gets an answer on a tap, because the resolver reads the raw stored
// chips. Intended: the chip was a valid offer when it was stored.
const shown = {
...PRIOR[0].results[0],
displayed: true,
};
for (const kind of ["as_table", "as_chart"] as const) {
const chip: CoachFollowUp = {
...AS_CHART,
kind,
labelKey: `coach.followUp.${kind === "as_chart" ? "asChart" : "asTable"}`,
};
expect(isRedundantViewChip(chip, [shown])).toBe(true);
findMany.mockResolvedValue([
assistant("m-last", [PREVIOUS, chip], { results: [shown] }),
]);
const out = await resolveFollowUp({
userId: "u1",
conversationId: "c1",
followUp: { messageId: "m-last", id: "f2" },
});
expect(out?.followUp).toEqual(chip);
expect(out?.followUp.reuse).toBe(true);
}
expect(staleReasons()).toEqual([]);
});
it("looks past an interrupted turn's marker", async () => {
findMany.mockResolvedValue([
{
+2 -6
View File
@@ -22,6 +22,8 @@ import {
} from "@/lib/ai/coach/dialog-keys";
import { METRIC_TABLE_EXCLUDED_SOURCES } from "@/lib/ai/coach/results/metric-table-tool";
import { REUSE_FOLLOW_UP_KINDS } from "./view-chip";
/** At most this many chips under one reply. */
export const MAX_FOLLOW_UPS = 3;
@@ -36,12 +38,6 @@ export const FOLLOW_UP_KINDS: readonly CoachFollowUpKind[] = [
"continue",
];
/** Kinds answered from a table the conversation already holds. */
export const REUSE_FOLLOW_UP_KINDS: ReadonlySet<CoachFollowUpKind> = new Set([
"as_chart",
"as_table",
]);
/**
* Kinds the model may propose in a `---FOLLOWUPS---` block. `continue` is
* the server's alone: it follows from the loop hitting its round cap, which
+13 -14
View File
@@ -6,8 +6,10 @@
*
* A chip is offered only when what it asks for is grounded in this turn:
*
* - `as_chart` / `as_table`: a table this turn produced (or copied) that
* has a chart; the chip names the view it is not shown in yet.
* - `as_chart`: a table this turn produced that has a chart and that the
* answer only used, so it sits folded under "Data used" as a table. A
* table the answer displays with a chart carries its own chart/table
* toggle, so it gets no view chip (`isRedundantViewChip`).
* - `previous_period`: a time series table of the current period, for a
* metric the table tool reads and the record holds.
* - `year_ago`: the same, over a window of at most 90 days, when the
@@ -59,6 +61,7 @@ import {
widerWindow,
} from "./catalog";
import type { FollowUpProposal } from "./parse-sentinel";
import { isRedundantViewChip } from "./view-chip";
import {
exceedsReach,
fitsReach,
@@ -195,18 +198,14 @@ export function deriveFollowUps(args: {
...(source.granularity ? { granularity: source.granularity } : {}),
period: source.period,
};
if (meta.chartKind !== null) {
// The other view of what the answer shows: a table shown as a table
// offers its chart, a shown chart offers its table.
const showsChart = meta.displayed && meta.view !== "table";
candidates.push(
buildFollowUp({
kind: showsChart ? "as_table" : "as_chart",
anchor,
origin: "server",
locale,
}),
);
const viewChip = buildFollowUp({
kind: "as_chart",
anchor,
origin: "server",
locale,
});
if (meta.chartKind !== null && !isRedundantViewChip(viewChip, [meta])) {
candidates.push(viewChip);
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* The view chips ("as a chart", "as a table") and when one would repeat a
* control the answer already shows.
*
* A leaf module on purpose: the stored-reply reader (`persistence.ts`) uses
* it, and that reader sits on the fenced document chat's module graph, which
* must not reach the Coach tool registry the rest of the chip catalog
* imports.
*/
import type {
CoachFollowUp,
CoachFollowUpKind,
CoachResultMeta,
} from "@/lib/ai/coach/types";
/** Kinds answered from a table the conversation already holds. */
export const REUSE_FOLLOW_UP_KINDS: ReadonlySet<CoachFollowUpKind> = new Set([
"as_chart",
"as_table",
]);
/**
* True when a view chip ("as a chart", "as a table") would repeat a control
* the answer already shows: a table the answer displays with a chart renders
* its own chart/table toggle right under the prose, so the chip adds
* nothing. A view chip still counts for a table the answer only used (it
* sits folded under "Data used", and the chip brings it into the answer)
* and every other kind is never redundant this way. One definition for the
* chips a turn derives and the chips a stored reply is read back with, so a
* reply stored before the rule offers the same as a new one, on every
* client.
*/
export function isRedundantViewChip(
chip: Pick<CoachFollowUp, "kind" | "anchor">,
results: readonly Pick<CoachResultMeta, "ref" | "displayed" | "chartKind">[],
): boolean {
if (!REUSE_FOLLOW_UP_KINDS.has(chip.kind)) return false;
const ref = chip.anchor?.ref;
return results.some(
(meta) => meta.ref === ref && meta.displayed && meta.chartKind !== null,
);
}
+7 -2
View File
@@ -23,6 +23,7 @@ import {
coachStepSchema,
} from "./stream-events";
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
import { isRedundantViewChip } from "./follow-ups/view-chip";
import { RESULTS_MAX_BYTES, fitResultsToStorage } from "./results/refs";
import {
isCheckupIntervalId,
@@ -323,7 +324,11 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
const steps = restoreSteps(parsed.steps);
const method = restoreMethod(parsed.method);
const results = restoreResultMetas(parsed.results);
const followUps = restoreFollowUps(parsed.followUps);
// A reply stored before view chips were withheld for a table the answer
// already shows with its own toggle reads back without them.
const followUps = restoreFollowUps(parsed.followUps)?.filter(
(chip) => !isRedundantViewChip(chip, results ?? []),
);
const clarification = restoreClarification(parsed.clarification);
const forcedFinal = parsed.forcedFinal === true;
const continuationOf =
@@ -345,7 +350,7 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
...(steps ? { steps } : {}),
...(method ? { method } : {}),
...(results ? { results } : {}),
...(followUps ? { followUps } : {}),
...(followUps && followUps.length > 0 ? { followUps } : {}),
...(clarification ? { clarification } : {}),
...(forcedFinal ? { forcedFinal: true as const } : {}),
...(continuationOf ? { continuationOf } : {}),
@@ -290,14 +290,7 @@ describe("a reuse chip", () => {
const order = out
.map((f) => f.type)
.filter((type, i, all) => type !== "token" || all[i - 1] !== "token");
expect(order).toEqual([
"step",
"token",
"provenance",
"result",
"followUps",
"done",
]);
expect(order).toEqual(["step", "token", "provenance", "result", "done"]);
expect(
out.flatMap((f) => (f.type === "token" ? [f.token] : [])).join(""),
).toBe("Here are the same figures from my last answer, unchanged.");
@@ -310,11 +303,9 @@ describe("a reuse chip", () => {
chart: STORED.chart,
reusedFrom: { messageId: "m-last", ref: "r1" },
});
const followUps = out.find((f) => f.type === "followUps");
// Shown as a chart now, so the other view is what is offered.
expect(
followUps?.type === "followUps" && followUps.followUps.map((c) => c.kind),
).toEqual(["as_table"]);
// The table is shown with its chart, and the panel carries its own
// chart/table toggle, so no chip offers the other view again.
expect(out.some((f) => f.type === "followUps")).toBe(false);
const done = out.at(-1);
expect(done).toMatchObject({
type: "done",
@@ -327,7 +318,7 @@ describe("a reuse chip", () => {
expect(annotated("coach.followUp.reused")).toHaveLength(1);
});
it("shows the table first on 'as a table' and offers the chart back", async () => {
it("shows the table first on 'as a table', leaving the chart to the panel's toggle", async () => {
const asTable = { ...AS_CHART, kind: "as_table" as const };
m.findMany.mockResolvedValue(latestReply([asTable]));
m.readMessageResults.mockResolvedValue([STORED]);
@@ -338,10 +329,9 @@ describe("a reuse chip", () => {
chart: STORED.chart,
rows: STORED.rows,
});
const chips = out.find((f) => f.type === "followUps");
expect(
chips?.type === "followUps" && chips.followUps.map((c) => c.kind),
).toEqual(["as_chart"]);
// The chart stays on the result, one tap away on its toggle; a chip
// for it would repeat that toggle.
expect(out.some((f) => f.type === "followUps")).toBe(false);
});
it("goes to the model with the chip's hint when the table is withheld", async () => {