mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
Merge pull request #1168 from MBombeck/feat/coach-panel-polish
chore(release): v1.40.4
This commit is contained in:
33 files changed
+750
-206
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user