mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the current step in the answer's text size with a shimmer passing through it and fading dots, and a chevron once there are steps to open. There is no running clock any more, and the interim table preview with its sparkline no longer renders under the row; interim tables stay in state until the answer's own results show them. When the answer is done the row reads "Thought process · N steps" with proper plurals, and "Answer needed" while a clarifying question waits. It never opens by itself, and a row opened during the run closes when the turn ends. Opened, the steps form a vertical list with an icon per kind of work on a thin connector line, and a reasoning round shows its summary under its title. The shimmer and the dots are a `text-shimmer` utility and a reusable WaitingText component, both still under reduced motion. The demo's write block now carries `meta.errorCode: "demo.readOnly"`, listed in the error-code catalogue, and the Coach answers it with an honest line instead of the provider-outage copy. Removes the interim preview component, the elapsed-seconds and summary helpers, and their message keys in all seven locales.
This commit is contained in:
1 parent
3df5754ab1
commit
dd72634791
32 files changed
+1069
-1312
No files matched your search
@@ -159,6 +159,18 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses the Coach chat with a code a client can name", async () => {
|
||||
// The chat POST is not on the allowlist. The refusal carries a stable
|
||||
// code, so the Coach says "not in the demo" instead of reading the 403
|
||||
// as a provider outage.
|
||||
const res = proxy(makeRequest("/api/insights/chat", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as {
|
||||
meta?: { demo?: boolean; errorCode?: string };
|
||||
};
|
||||
expect(body.meta).toEqual({ demo: true, errorCode: "demo.readOnly" });
|
||||
});
|
||||
|
||||
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
|
||||
// The allowlist pins method per path; admitting the widgets PUT
|
||||
// must not open DELETE (which wipes the user's dashboard layout).
|
||||
|
||||
@@ -1032,6 +1032,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Live status text ────────────────────────────────────────────────
|
||||
* `text-shimmer`: a band of foreground light sweeps through muted text, for a
|
||||
* line that says something is still in progress (the Coach's thinking row).
|
||||
* The gradient is clipped to the glyphs; the band travels left to right once
|
||||
* every two seconds. Under reduced motion the text is plain muted text.
|
||||
*
|
||||
* `waiting-dot`: one of three trailing dots that fade in turn (the caller
|
||||
* staggers them by 0.2 s). Under reduced motion they stand still at full
|
||||
* opacity, which the global backstop below would not give them: it pins the
|
||||
* last frame, and the last frame is dim. */
|
||||
@keyframes text-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
}
|
||||
@utility text-shimmer {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--muted-foreground) 40%,
|
||||
var(--foreground) 50%,
|
||||
var(--muted-foreground) 60%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
background-repeat: no-repeat;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: text-shimmer 2s linear infinite;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
@keyframes waiting-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@utility waiting-dot {
|
||||
animation: waiting-dot 1.4s ease-in-out infinite;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global reduced-motion backstop ──────────────────────────────────
|
||||
* Defensive catch-all so any animation/transition we forget to gate
|
||||
* per-component still collapses to near-instant when the user has asked
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* v1.41 — the tables a turn has read so far, one quiet line each, before the
|
||||
* first token; taken down when the turn is blocked.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { CoachResultTable } from "@/lib/ai/coach/types";
|
||||
|
||||
vi.mock("../coach-results", () => ({ CoachResultView: () => null }));
|
||||
|
||||
import {
|
||||
CoachInterimResults,
|
||||
interimLabel,
|
||||
interimPreviews,
|
||||
interimReadingCount,
|
||||
sparklinePoints,
|
||||
} from "../interim-results";
|
||||
import {
|
||||
upsertActivity,
|
||||
upsertResult,
|
||||
withoutWithheldResults,
|
||||
} from "../use-coach";
|
||||
|
||||
function bp(ref: string): CoachResultTable {
|
||||
return {
|
||||
ref,
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byDay",
|
||||
title: "Blood pressure by day",
|
||||
rowCount: 3,
|
||||
chartKind: "line",
|
||||
displayed: false,
|
||||
columns: [
|
||||
{ key: "day", kind: "period", labelKey: "k", label: "Day" },
|
||||
{ key: "systolic", kind: "number", labelKey: "k", label: "Systolic" },
|
||||
{ key: "readings", kind: "count", labelKey: "k", label: "Readings" },
|
||||
],
|
||||
rows: [
|
||||
["2026-09-01", 128, 2],
|
||||
["2026-09-02", 124, 3],
|
||||
["2026-09-03", 131, 1],
|
||||
],
|
||||
truncated: false,
|
||||
chart: { kind: "line", x: "day", series: ["systolic"] },
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("interim previews", () => {
|
||||
it("one line per interim table: title, window, readings, a sparkline", () => {
|
||||
const html = render(
|
||||
<CoachInterimResults
|
||||
results={[bp("r1"), bp("r2")]}
|
||||
interimRefs={["r1"]}
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/data-slot="coach-interim-result"/g)).toHaveLength(1);
|
||||
expect(html).toContain("Blood pressure by day, last 30 days, 6 readings");
|
||||
expect(html).toContain('stroke="var(--chart-1)"');
|
||||
// Closed until tapped, and the arrival fades in only with motion allowed.
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain("motion-safe:animate-in");
|
||||
});
|
||||
|
||||
it("shows at most three, and nothing without interim tables", () => {
|
||||
const four = ["r1", "r2", "r3", "r4"];
|
||||
expect(interimPreviews(four.map(bp), four)).toHaveLength(3);
|
||||
expect(
|
||||
render(<CoachInterimResults results={[bp("r1")]} interimRefs={[]} />),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("counts readings from the count column, else rows", () => {
|
||||
expect(interimReadingCount(bp("r1"))).toBe(6);
|
||||
const noCount = { ...bp("r1"), columns: bp("r1").columns.slice(0, 2) };
|
||||
expect(interimReadingCount(noCount)).toBe(3);
|
||||
// The title is the server's, already in the request locale.
|
||||
expect(interimLabel(bp("r1"), getServerTranslator("de").t, "de")).toBe(
|
||||
"Blood pressure by day, letzte 30 Tage, 6 Werte",
|
||||
);
|
||||
});
|
||||
|
||||
it("draws the sparkline inside its box", () => {
|
||||
const points = sparklinePoints([1, 3, 2], 48, 16).split(" ");
|
||||
expect(points).toHaveLength(3);
|
||||
for (const point of points) {
|
||||
const [x, y] = point.split(",").map(Number);
|
||||
expect(x).toBeGreaterThanOrEqual(0);
|
||||
expect(x).toBeLessThanOrEqual(48);
|
||||
expect(y).toBeGreaterThanOrEqual(0);
|
||||
expect(y).toBeLessThanOrEqual(16);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the stream's bookkeeping", () => {
|
||||
it("replaces an interim table with its final frame by ref", () => {
|
||||
const final = { ...bp("r1"), displayed: true };
|
||||
const next = upsertResult([bp("r1"), bp("r2")], final);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[0].displayed).toBe(true);
|
||||
});
|
||||
|
||||
it("takes the interim tables down when the turn was blocked", () => {
|
||||
expect(
|
||||
withoutWithheldResults([bp("r1"), bp("r2"), bp("r3")], ["r1", "r3"]).map(
|
||||
(r) => r.ref,
|
||||
),
|
||||
).toEqual(["r2"]);
|
||||
});
|
||||
|
||||
it("keeps an entry's title when a later frame leaves it out", () => {
|
||||
const first = {
|
||||
id: "a1",
|
||||
phase: "thinking" as const,
|
||||
status: "running" as const,
|
||||
round: 1,
|
||||
labelKey: "k",
|
||||
label: "Thinking…",
|
||||
title: "Comparing months",
|
||||
};
|
||||
const done = { ...first, status: "done" as const, durationMs: 900 };
|
||||
delete (done as { title?: string }).title;
|
||||
const [entry] = upsertActivity([first], done);
|
||||
expect(entry.title).toBe("Comparing months");
|
||||
expect(entry.status).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -101,12 +101,9 @@ function streaming(
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
interimRefs: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
@@ -255,10 +252,10 @@ describe("<MessageThread>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("folds an older message's provenance into the areas header, never a raw key", () => {
|
||||
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-done"');
|
||||
expect(html).toContain("Looked at 1 area");
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
expect(html).not.toContain('data-slot="coach-source-chips"');
|
||||
expect(html).not.toContain("insights.coach.metric.");
|
||||
expect(html).not.toContain("n=26");
|
||||
@@ -481,16 +478,16 @@ describe("<MessageThread>", () => {
|
||||
expect(html).not.toContain('data-slot="coach-evidence"');
|
||||
expect(html).not.toContain("What I'm looking at");
|
||||
expect(html).not.toContain("138 mmHg");
|
||||
// The areas the answer drew on stay reachable from the header.
|
||||
expect(html).toContain("Looked at 1 area");
|
||||
// The areas the answer drew on stay reachable from the row.
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
});
|
||||
|
||||
it("names the areas header in German under 'de'", () => {
|
||||
it("names the thought-process row in German under 'de'", () => {
|
||||
const html = render(
|
||||
<MessageThread conversation={baseConversation} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("1 Bereich angesehen");
|
||||
expect(html).toContain("Denkprozess · 1 Schritt");
|
||||
expect(html).not.toContain("Worauf bezieht sich das?");
|
||||
});
|
||||
|
||||
@@ -797,6 +794,12 @@ describe("errorCodeToI18nKey", () => {
|
||||
|
||||
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
|
||||
// generic provider copy; the user needs the actionable offline hint.
|
||||
it("maps the demo's refusal to its own honest line, not the outage copy", () => {
|
||||
expect(errorCodeToI18nKey("demo.readOnly")).toBe(
|
||||
"insights.coach.errorDemo",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps coach.network to the dedicated offline key", () => {
|
||||
expect(errorCodeToI18nKey("coach.network")).toBe(
|
||||
"insights.coach.errorNetwork",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -8,18 +10,18 @@ import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import { Brain, ChartScatter, Database, Lightbulb, Table2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
CoachTurnActivity,
|
||||
CoachTurnStepList,
|
||||
activityLineLabel,
|
||||
activitySeconds,
|
||||
activitySummary,
|
||||
countLookups,
|
||||
countSources,
|
||||
currentActivity,
|
||||
currentStep,
|
||||
describeStep,
|
||||
inProgressLabel,
|
||||
legacyAreaLabels,
|
||||
openAfter,
|
||||
stepIcon,
|
||||
stepLabel,
|
||||
} from "../turn-activity";
|
||||
|
||||
@@ -218,7 +220,133 @@ const SETTLED = [
|
||||
ANSWER_DONE,
|
||||
];
|
||||
|
||||
describe("the running line", () => {
|
||||
/** The row's element (button or plain row) and everything inside it. */
|
||||
function rowHtml(html: string): string {
|
||||
const match = html.match(
|
||||
/<(button|div)[^>]*data-slot="coach-turn-steps-(?:toggle|row)"[\s\S]*?<\/\1>/,
|
||||
);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** The visible text of a fragment, tags removed. */
|
||||
function textOf(html: string): string {
|
||||
let text = html.replace(/<span class="sr-only select-none">, <\/span>/g, "");
|
||||
for (let prev = ""; prev !== text;) {
|
||||
prev = text;
|
||||
text = text.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
describe("one row while the Coach works", () => {
|
||||
it("walks thinking → fetching → summarising on one shimmering row", () => {
|
||||
const frames = [
|
||||
[THINKING_RUNNING],
|
||||
[
|
||||
{ ...THINKING_RUNNING, status: "done" as const },
|
||||
{ ...FETCH_BP, status: "running" as const },
|
||||
],
|
||||
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
|
||||
];
|
||||
const htmls = frames.map((activity) =>
|
||||
render(<CoachTurnActivity activity={activity} steps={[]} active />),
|
||||
);
|
||||
expect(htmls[0]).toContain('data-text="Thinking…"');
|
||||
expect(htmls[1]).toContain(
|
||||
'data-text="Fetching blood pressure, last 90 days…"',
|
||||
);
|
||||
expect(htmls[2]).toContain('data-text="Summarising 214 readings…"');
|
||||
for (const html of htmls) {
|
||||
// Exactly one row, and nothing under it while closed.
|
||||
expect(
|
||||
html.match(/data-slot="coach-turn-steps-(toggle|row)"/g),
|
||||
).toHaveLength(1);
|
||||
expect(html).toContain('data-state="running"');
|
||||
expect(html).not.toContain("coach-interim");
|
||||
expect(html).not.toContain('<svg viewBox="0 0 48 16"');
|
||||
// The words shimmer; the trailing dots fade on their own.
|
||||
expect(html).toMatch(/<span class="text-shimmer">[^<]+<\/span>/);
|
||||
expect(html).toContain('data-slot="waiting-dots"');
|
||||
expect(html.match(/class="waiting-dot /g)).toHaveLength(3);
|
||||
expect(html).toContain("[animation-delay:0.2s]");
|
||||
expect(html).toContain("[animation-delay:0.4s]");
|
||||
// No clock while it runs, in any form.
|
||||
expect(html).not.toMatch(/\d+\s?s</);
|
||||
expect(html).not.toContain("coach-turn-steps-seconds");
|
||||
expect(html).not.toContain("tabular-nums");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads brain, text, chevron, in the answer's text size and the muted tone", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
const row = rowHtml(html);
|
||||
expect(row.startsWith("<button")).toBe(true);
|
||||
const order = [
|
||||
row.indexOf("lucide-brain"),
|
||||
row.indexOf('data-slot="coach-turn-steps-active"'),
|
||||
row.indexOf('data-slot="coach-turn-steps-chevron"'),
|
||||
];
|
||||
expect(order.every((at) => at > -1)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
expect(row).toContain("lucide-chevron-down");
|
||||
expect(row).toMatch(/class="[^"]*\bw-fit\b/);
|
||||
expect(row).toMatch(/class="[^"]*\btext-muted-foreground\b/);
|
||||
expect(row).toMatch(/class="[^"]*\bhover:text-foreground\b/);
|
||||
expect(row).toContain("focus-visible:ring-input-focus");
|
||||
expect(row).not.toMatch(/ring-ring|ring-primary|text-primary/);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-sm\b/,
|
||||
);
|
||||
expect(html).not.toMatch(
|
||||
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-xs\b/,
|
||||
);
|
||||
// A stable name while the text keeps changing.
|
||||
expect(row).toContain('aria-label="Show what the Coach looked at"');
|
||||
expect(row).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
it("says Thinking… before the first frame, with no chevron and nothing to open", () => {
|
||||
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
|
||||
expect(html).toContain('data-text="Thinking…"');
|
||||
expect(html).toContain("text-shimmer");
|
||||
expect(html).not.toContain("coach-turn-steps-chevron");
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
expect(rowHtml(html).startsWith("<div")).toBe(true);
|
||||
});
|
||||
|
||||
it("names a reasoning title as still going, and a step-only server's step", () => {
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[{ ...THINKING_RUNNING, title: "Checking the trend" }]}
|
||||
steps={[]}
|
||||
active
|
||||
/>,
|
||||
),
|
||||
).toContain('data-text="Checking the trend…"');
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
|
||||
),
|
||||
).toContain('data-text="Checking: Lab results, last 12 months…"');
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={[]} steps={[]} active />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain('data-text="Denkt nach…"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the labels", () => {
|
||||
it("names the latest running entry, else the latest one", () => {
|
||||
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
|
||||
"a4",
|
||||
@@ -242,59 +370,134 @@ describe("the running line", () => {
|
||||
).toBe("Fetching blood pressure, last 90 days…");
|
||||
});
|
||||
|
||||
it("walks thinking → fetching → summarising, one quiet line", () => {
|
||||
const frames = [
|
||||
[THINKING_RUNNING],
|
||||
[
|
||||
{ ...THINKING_RUNNING, status: "done" as const },
|
||||
{ ...FETCH_BP, status: "running" as const },
|
||||
],
|
||||
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
|
||||
];
|
||||
const lines = frames.map((activity) =>
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={activity}
|
||||
steps={[]}
|
||||
active
|
||||
startedAt={Date.now() - 12_000}
|
||||
/>,
|
||||
),
|
||||
it("ends a running line in exactly one ellipsis", () => {
|
||||
expect(inProgressLabel("Writing the answer")).toBe("Writing the answer…");
|
||||
expect(inProgressLabel("Writing the answer…")).toBe("Writing the answer…");
|
||||
expect(inProgressLabel("Writing the answer...")).toBe(
|
||||
"Writing the answer…",
|
||||
);
|
||||
expect(lines[0]).toContain(">Thinking…<");
|
||||
expect(lines[1]).toContain(">Fetching blood pressure, last 90 days…<");
|
||||
expect(lines[2]).toContain(">Summarising 214 readings…<");
|
||||
for (const html of lines) {
|
||||
expect(html.match(/data-slot="coach-turn-steps-toggle"/g)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(html).toContain('data-state="running"');
|
||||
expect(html).toContain('aria-label="Show what the Coach looked at"');
|
||||
// The seconds so far follow the text directly, in the line's own
|
||||
// muted tone, with the summary's spacing ("12 s"), never pushed to
|
||||
// the far edge of the column.
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-active"[^>]*>[^<]*<\/span><span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s</,
|
||||
);
|
||||
const seconds = html.match(
|
||||
/<span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(seconds).toContain("tabular-nums");
|
||||
expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/);
|
||||
});
|
||||
|
||||
it("picks an icon by the kind of work", () => {
|
||||
expect(stepIcon("thinking", undefined)).toBe(Lightbulb);
|
||||
expect(stepIcon("fetch", "get_metric_series")).toBe(Database);
|
||||
expect(stepIcon("fetch", "get_metric_table")).toBe(Table2);
|
||||
expect(stepIcon("fetch", "get_correlations")).toBe(ChartScatter);
|
||||
expect(stepIcon(null, "get_sleep")).toBe(Database);
|
||||
// The brain belongs to the row, never to a step.
|
||||
for (const phase of [
|
||||
"thinking",
|
||||
"memory",
|
||||
"fetch",
|
||||
"digest",
|
||||
"checkpoint",
|
||||
"remember",
|
||||
"plan",
|
||||
"asking",
|
||||
"stop",
|
||||
"answer",
|
||||
] as const) {
|
||||
expect(stepIcon(phase, undefined)).not.toBe(Brain);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("says Thinking… before the first frame arrives", () => {
|
||||
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
|
||||
expect(html).toContain(">Thinking…<");
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
describe("after the answer", () => {
|
||||
it("settles into the thought process and its step count, without shimmer", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={SETTLED}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="done"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
|
||||
);
|
||||
expect(html).not.toContain("text-shimmer");
|
||||
expect(html).not.toContain("waiting-dot");
|
||||
expect(html).toContain('data-slot="coach-turn-steps-chevron"');
|
||||
expect(html).not.toMatch(/\d+\s?s</);
|
||||
// The settled row names itself by its text.
|
||||
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
|
||||
});
|
||||
|
||||
it("falls back to the step label on a server that sends only steps", () => {
|
||||
it("counts in the reader's language, with proper plurals", () => {
|
||||
const de = render(
|
||||
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
|
||||
"de",
|
||||
);
|
||||
expect(de).toContain("Denkprozess · 1 Schritt<");
|
||||
const pl = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
/>,
|
||||
"pl",
|
||||
);
|
||||
expect(pl).toContain("Tok myślenia · 3 kroki<");
|
||||
});
|
||||
|
||||
it("an older message counts its steps, or the areas it drew on", () => {
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
|
||||
),
|
||||
).toContain("Thought process · 2 steps<");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
areas={["bp"]}
|
||||
/>,
|
||||
),
|
||||
).toContain("Thought process · 1 step<");
|
||||
});
|
||||
|
||||
it("says an answer is needed while a question waits, without shimmer", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
awaitingAnswer
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="awaiting"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Answer needed</,
|
||||
);
|
||||
expect(html).not.toContain("text-shimmer");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
awaitingAnswer
|
||||
/>,
|
||||
"de",
|
||||
),
|
||||
).toContain(">Antwort nötig<");
|
||||
});
|
||||
|
||||
it("renders nothing for a settled message with nothing to show", () => {
|
||||
expect(
|
||||
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
|
||||
).not.toContain("coach-turn-steps");
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
expect(html).toContain("Checking: Lab results, last 12 months");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,6 +523,7 @@ describe("nothing opens by itself", () => {
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
awaitingAnswer
|
||||
/>,
|
||||
],
|
||||
[
|
||||
@@ -370,109 +574,78 @@ describe("nothing opens by itself", () => {
|
||||
for (const [name, node] of states) {
|
||||
it(`is closed when ${name}`, () => {
|
||||
const html = render(node);
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).not.toContain('aria-expanded="true"');
|
||||
expect(html).not.toContain('rotate-180"');
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-panel"');
|
||||
expect(html).not.toContain('data-slot="coach-turn-step-detail"');
|
||||
expect(html).not.toContain("The second half of the month runs higher.");
|
||||
});
|
||||
}
|
||||
|
||||
it("a row opened while the turn ran closes when it ends, and nothing opens it", () => {
|
||||
// Opened during the run, the turn ends: closed.
|
||||
expect(openAfter({ open: true, wasActive: true, active: false })).toBe(
|
||||
false,
|
||||
);
|
||||
// Closed stays closed through every move.
|
||||
for (const [wasActive, active] of [
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
[false, false],
|
||||
] as const) {
|
||||
expect(openAfter({ open: false, wasActive, active })).toBe(false);
|
||||
}
|
||||
// A tap on a settled row is the person's, and stays.
|
||||
expect(openAfter({ open: true, wasActive: false, active: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("after the answer", () => {
|
||||
const { t } = translators("en");
|
||||
|
||||
it("collapses to one calm summary of lookups and seconds", () => {
|
||||
describe("tokens and motion", () => {
|
||||
it("uses theme tokens only, and every motion is guarded or turned off by reduced motion", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={SETTLED}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="done"');
|
||||
expect(html).toContain(
|
||||
'data-slot="coach-turn-steps-done" class="min-w-0 truncate">Thought it through, 2 lookups, 12 s<',
|
||||
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
|
||||
expect(html).not.toMatch(/text-muted-foreground\/\d/);
|
||||
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
|
||||
m[1].split(/\s+/),
|
||||
);
|
||||
// No spinner, no running seconds, no stable-name override once settled.
|
||||
expect(html).not.toContain("coach-turn-steps-spinner");
|
||||
expect(html).not.toContain("coach-turn-steps-seconds");
|
||||
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
|
||||
for (const cls of classes) {
|
||||
if (cls.startsWith("transition") || cls.startsWith("animate-")) {
|
||||
throw new Error(`unguarded motion: ${cls}`);
|
||||
}
|
||||
}
|
||||
// A 44 px tap target on phones.
|
||||
expect(html).toContain("min-h-11");
|
||||
});
|
||||
|
||||
it("counts fetches as lookups and runs a round's fetches side by side", () => {
|
||||
expect(countLookups(SETTLED)).toBe(2);
|
||||
// 4 s thinking + max(0.9, 1.4) s fetching + 0.6 s digest + 6 s answer.
|
||||
expect(activitySeconds(SETTLED)).toBe(12);
|
||||
expect(activitySeconds([THINKING_RUNNING])).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the live turn's clock when the trail has no durations", () => {
|
||||
expect(
|
||||
activitySummary({
|
||||
activity: [{ ...ANSWER_DONE, durationMs: undefined }],
|
||||
startedAt: 1_000,
|
||||
endedAt: 19_000,
|
||||
t,
|
||||
locale: "en",
|
||||
}),
|
||||
).toBe("Thought it through, 18 s");
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
|
||||
"de",
|
||||
it("the shimmer and the dots stop under reduced motion, in the stylesheet", () => {
|
||||
const css = readFileSync(
|
||||
join(__dirname, "../../../../app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
expect(html).toContain("Nachgedacht, 2 Abfragen, 12 s");
|
||||
});
|
||||
|
||||
it("an older message still folds into its sources", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
|
||||
const shimmer =
|
||||
css.match(/@utility text-shimmer \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
expect(shimmer).toContain("background-clip: text");
|
||||
expect(shimmer).toContain("background-size: 250% 100%");
|
||||
expect(shimmer).toContain("animation: text-shimmer 2s linear infinite");
|
||||
expect(shimmer).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*background-image: none;\s*color: var\(--muted-foreground\);/,
|
||||
);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
expect(
|
||||
countSources([BP, { ...BP, id: "s9", period: "previous" }, SLEEP]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("an answer saved before steps folds into its areas", () => {
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
areas={["bp", "sleep"]}
|
||||
/>,
|
||||
),
|
||||
).toContain("Looked at 2 areas");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
areas={["bp"]}
|
||||
/>,
|
||||
"de",
|
||||
),
|
||||
).toContain("1 Bereich angesehen");
|
||||
});
|
||||
|
||||
it("renders nothing for a settled message with nothing to show", () => {
|
||||
expect(
|
||||
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
|
||||
).not.toContain("coach-turn-steps");
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
|
||||
expect(css).toMatch(
|
||||
/@keyframes text-shimmer \{\s*from \{\s*background-position: 100% 0;\s*\}\s*to \{\s*background-position: 0% 0;/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
const dot = css.match(/@utility waiting-dot \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
expect(dot).toContain("animation: waiting-dot 1.4s ease-in-out infinite");
|
||||
expect(dot).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*opacity: 1;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -485,72 +658,6 @@ describe("currentStep", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokens and motion", () => {
|
||||
it("uses theme tokens only, and every motion stops under reduced motion", () => {
|
||||
const html =
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
) +
|
||||
render(
|
||||
<CoachTurnStepList
|
||||
steps={[
|
||||
BP,
|
||||
SLEEP,
|
||||
LABS_RUNNING,
|
||||
{
|
||||
...SLEEP,
|
||||
id: "s4",
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
},
|
||||
]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
|
||||
expect(html).not.toMatch(/text-muted-foreground\/\d/);
|
||||
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
|
||||
m[1].split(/\s+/),
|
||||
);
|
||||
for (const cls of classes) {
|
||||
if (cls.startsWith("transition")) {
|
||||
throw new Error(`unguarded transition: ${cls}`);
|
||||
}
|
||||
}
|
||||
expect(html).toContain("animate-spin motion-reduce:animate-none");
|
||||
// A 44 px tap target on phones.
|
||||
expect(html).toContain("min-h-11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnStepList>", () => {
|
||||
it("one row per step: domain · window · count or reason, with its status", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
|
||||
);
|
||||
expect(html).toContain('aria-label="What the Coach looked at"');
|
||||
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
|
||||
expect(html).toContain('data-status="done"');
|
||||
expect(html).toContain('data-status="empty"');
|
||||
expect(html).toContain('data-status="running"');
|
||||
let text = html.replace(
|
||||
/<span class="sr-only select-none">, <\/span>/g,
|
||||
"",
|
||||
);
|
||||
for (let prev = ""; prev !== text;) {
|
||||
prev = text;
|
||||
text = text.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
|
||||
expect(text).toContain("Sleep · last 30 days · no readings");
|
||||
expect(text).toContain("Lab results · last 12 months");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacyAreaLabels", () => {
|
||||
const { t } = translators("en");
|
||||
|
||||
@@ -563,3 +670,22 @@ describe("legacyAreaLabels", () => {
|
||||
).toEqual(["Blood pressure", "Sleep"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the visible row text", () => {
|
||||
it("is the status alone", () => {
|
||||
expect(
|
||||
textOf(
|
||||
rowHtml(
|
||||
render(
|
||||
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
|
||||
),
|
||||
),
|
||||
),
|
||||
).toBe("Thought process · 5 steps");
|
||||
expect(
|
||||
textOf(
|
||||
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
|
||||
),
|
||||
).toBe("Thinking...");
|
||||
});
|
||||
});
|
||||
@@ -56,12 +56,9 @@ function streaming(
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
interimRefs: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { DEMO_READ_ONLY_CODE } from "@/lib/demo-refusal";
|
||||
import {
|
||||
parseChartTokens,
|
||||
tokenToMetric,
|
||||
@@ -20,7 +21,6 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
import { SuggestedActionCard } from "./suggested-action-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
import { CoachTurnActivity } from "./turn-activity";
|
||||
import { CoachInterimResults } from "./interim-results";
|
||||
import {
|
||||
CoachResults,
|
||||
countResultsInSection,
|
||||
@@ -100,6 +100,10 @@ export function errorCodeToI18nKey(code: string): string {
|
||||
return "insights.coach.errorNoProvider";
|
||||
case "module.disabled":
|
||||
return "insights.coach.errorCoachOff";
|
||||
// A demo deploy refuses every write, the chat POST included. That is
|
||||
// the demo's nature, not an outage, and the line says so.
|
||||
case DEMO_READ_ONLY_CODE:
|
||||
return "insights.coach.errorDemo";
|
||||
default:
|
||||
// The operator switched the Coach (or all AI) off on this server.
|
||||
if (code.startsWith("assistant.disabled.")) {
|
||||
@@ -337,11 +341,6 @@ interface ChatBubbleProps {
|
||||
* its metadata on `metricSource.activity`; the bubble falls back to that.
|
||||
*/
|
||||
activity?: CoachActivity[];
|
||||
/** v1.41 — the refs of the tables that arrived while the turn ran. */
|
||||
interimRefs?: string[];
|
||||
/** v1.41 — when the live turn started and ended on this device. */
|
||||
startedAt?: number | null;
|
||||
endedAt?: number | null;
|
||||
/** v1.41 — the live `memoryNote` frame, with the fact's words. */
|
||||
memoryNote?: CoachMemoryNoteFrame | null;
|
||||
}
|
||||
@@ -416,9 +415,6 @@ export function areChatBubblePropsEqual(
|
||||
prev.onFollowUp === next.onFollowUp &&
|
||||
prev.replies === next.replies &&
|
||||
prev.activity === next.activity &&
|
||||
prev.interimRefs === next.interimRefs &&
|
||||
prev.startedAt === next.startedAt &&
|
||||
prev.endedAt === next.endedAt &&
|
||||
prev.memoryNote === next.memoryNote &&
|
||||
// onRegenerate is a per-render closure — compare only whether it is present.
|
||||
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
|
||||
@@ -449,9 +445,6 @@ function ChatBubbleImpl({
|
||||
onFollowUp,
|
||||
replies,
|
||||
activity,
|
||||
interimRefs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
memoryNote,
|
||||
}: ChatBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -645,18 +638,22 @@ function ChatBubbleImpl({
|
||||
data-slot="coach-answer-column"
|
||||
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
|
||||
>
|
||||
{/* v1.41 — what the Coach is doing, one quiet line at the top of the
|
||||
answer: live from the activity frames while the turn runs, one
|
||||
calm summary once it is done. Nothing opens by itself; a tap on
|
||||
the line opens the trail, which also says how the answer was
|
||||
worked out and holds the tables the answer read. A message saved
|
||||
before v1.41 falls back to its steps, or the areas it drew on. */}
|
||||
{/* v1.41 — what the Coach is doing, one quiet row at the top of the
|
||||
answer and the only thing in the column until the answer starts:
|
||||
live from the activity frames while the turn runs, "Thought
|
||||
process · N steps" once it is done, "Answer needed" while a
|
||||
question waits. Nothing opens by itself; a tap on the row opens
|
||||
the steps, which also say how the answer was worked out and hold
|
||||
the tables the answer read. A message saved before v1.41 falls
|
||||
back to its steps, or the areas it drew on. */}
|
||||
<CoachTurnActivity
|
||||
activity={activity ?? metricSource?.activity ?? []}
|
||||
steps={steps ?? metricSource?.steps ?? []}
|
||||
active={!!inProgress}
|
||||
startedAt={startedAt}
|
||||
endedAt={endedAt}
|
||||
awaitingAnswer={
|
||||
!inProgress &&
|
||||
!!replies?.replies.some((reply) => reply.kind === "clarification")
|
||||
}
|
||||
areas={metricSource?.metrics}
|
||||
method={inProgress || errorCode ? null : method}
|
||||
conversationId={conversationId}
|
||||
@@ -673,11 +670,6 @@ function ChatBubbleImpl({
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* v1.41 — the tables read so far, one line each, while the turn
|
||||
still runs. They give way to the answer's own charts. */}
|
||||
{inProgress && results && interimRefs && interimRefs.length > 0 ? (
|
||||
<CoachInterimResults results={results} interimRefs={interimRefs} />
|
||||
) : null}
|
||||
{/* The prose bubble, once there is prose or an error. While the turn
|
||||
runs without prose the status line above says what is happening;
|
||||
the bubble arrives with the first token. */}
|
||||
|
||||
@@ -124,7 +124,7 @@ function displayedByRef(
|
||||
|
||||
/**
|
||||
* The live tables with `displayed` taken from the provenance where it names
|
||||
* them: an interim table is sent before the answer decides.
|
||||
* them: a table read while the turn ran is sent before the answer decides.
|
||||
*/
|
||||
export function liveWithProvenance<
|
||||
T extends Pick<CoachResultMeta, "ref" | "displayed">,
|
||||
@@ -265,7 +265,7 @@ export function CoachResults({
|
||||
|
||||
type ResultView = "chart" | "table";
|
||||
|
||||
export function CoachResultView({
|
||||
function CoachResultView({
|
||||
result,
|
||||
chartFirst,
|
||||
}: {
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — the tables a turn has read so far, while it still runs.
|
||||
*
|
||||
* Under the status line, one quiet line per table as soon as the call that
|
||||
* read it settles: its title, window and how many readings it holds, with a
|
||||
* small sparkline in the first chart colour and no axes. At most three. A
|
||||
* tap opens the full chart (or table) right there; nothing opens by itself.
|
||||
* Once the answer settles the previews go: the tables the answer points at
|
||||
* become its charts, the rest sit under "Data used" in the closed trail.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
coachWindowLabelKey,
|
||||
interimLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachResultTable } from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachResultView } from "./coach-results";
|
||||
|
||||
/** At most this many previews show, whatever the turn has read. */
|
||||
export const MAX_INTERIM_PREVIEWS = 3;
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** The interim tables to preview, in the order they arrived. */
|
||||
export function interimPreviews(
|
||||
results: readonly CoachResultTable[],
|
||||
interimRefs: readonly string[],
|
||||
): CoachResultTable[] {
|
||||
const refs = new Set(interimRefs);
|
||||
return results
|
||||
.filter((result) => refs.has(result.ref))
|
||||
.slice(0, MAX_INTERIM_PREVIEWS);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many readings a table holds: the sum of its count column when it has
|
||||
* one (readings per day or week), else its rows.
|
||||
*/
|
||||
export function interimReadingCount(result: CoachResultTable): number {
|
||||
const countIndex = result.columns.findIndex((c) => c.kind === "count");
|
||||
if (countIndex === -1) return result.rowCount;
|
||||
let sum = 0;
|
||||
for (const row of result.rows) {
|
||||
const cell = row[countIndex];
|
||||
if (typeof cell === "number") sum += cell;
|
||||
}
|
||||
return sum > 0 ? sum : result.rowCount;
|
||||
}
|
||||
|
||||
/** "Blood pressure by day, last 30 days, 214 readings". */
|
||||
export function interimLabel(
|
||||
result: CoachResultTable,
|
||||
t: Translate,
|
||||
locale: Locale,
|
||||
): string {
|
||||
const count = interimReadingCount(result);
|
||||
return t(interimLabelKey(count, locale), {
|
||||
title: result.title,
|
||||
window: t(coachWindowLabelKey(result.source.window)),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
/** The values the sparkline draws: the first charted series, in row order. */
|
||||
export function sparklineValues(result: CoachResultTable): number[] {
|
||||
const spec = result.chart;
|
||||
const key =
|
||||
spec?.kind === "line" || spec?.kind === "bar"
|
||||
? spec.series[0]
|
||||
: spec?.kind === "compare"
|
||||
? spec.a
|
||||
: result.columns.find((c) => c.kind === "number")?.key;
|
||||
const index = result.columns.findIndex((c) => c.key === key);
|
||||
if (index === -1) return [];
|
||||
return result.rows
|
||||
.map((row) => row[index])
|
||||
.filter((cell): cell is number => typeof cell === "number");
|
||||
}
|
||||
|
||||
/** An SVG polyline path through the values, inside `width × height`. */
|
||||
export function sparklinePoints(
|
||||
values: readonly number[],
|
||||
width: number,
|
||||
height: number,
|
||||
): string {
|
||||
if (values.length === 0) return "";
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min || 1;
|
||||
const step = values.length > 1 ? width / (values.length - 1) : 0;
|
||||
return values
|
||||
.map((value, i) => {
|
||||
const x = values.length > 1 ? i * step : width / 2;
|
||||
const y = height - 1 - ((value - min) / span) * (height - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
if (values.length < 2) return null;
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-slot="coach-interim-sparkline"
|
||||
viewBox="0 0 48 16"
|
||||
className="h-4 w-12 shrink-0 overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polyline
|
||||
points={sparklinePoints(values, 48, 16)}
|
||||
fill="none"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InterimPreview({ result }: { result: CoachResultTable }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const detailId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<li
|
||||
data-slot="coach-interim-result"
|
||||
data-ref={result.ref}
|
||||
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 flex min-w-0 flex-col gap-2 motion-safe:duration-300"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? detailId : undefined}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus -my-1 flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded text-left text-xs outline-none focus-visible:ring-2 sm:my-0 sm:min-h-6"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-3 shrink-0 motion-safe:transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{interimLabel(result, t, locale)}
|
||||
</span>
|
||||
<Sparkline values={sparklineValues(result)} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div id={detailId} className="w-full min-w-0">
|
||||
<CoachResultView result={result} chartFirst />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachInterimResults({
|
||||
results,
|
||||
interimRefs,
|
||||
}: {
|
||||
results: readonly CoachResultTable[];
|
||||
interimRefs: readonly string[];
|
||||
}) {
|
||||
const previews = interimPreviews(results, interimRefs);
|
||||
if (previews.length === 0) return null;
|
||||
return (
|
||||
<ul
|
||||
data-slot="coach-interim-results"
|
||||
className="flex w-full min-w-0 flex-col gap-0.5 self-stretch"
|
||||
>
|
||||
{previews.map((result) => (
|
||||
<InterimPreview key={result.ref} result={result} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -716,15 +716,13 @@ export function MessageThread({
|
||||
? { results: streaming.results }
|
||||
: {})}
|
||||
// v1.41 — and its live trail (with the titles and texts
|
||||
// the persisted copy only has behind `…/trail`), its clock
|
||||
// and the words of the fact it kept.
|
||||
// the persisted copy only has behind `…/trail`) and the
|
||||
// words of the fact it kept.
|
||||
{...(m.id === streaming?.messageId
|
||||
? {
|
||||
...(streaming.activity.length > 0
|
||||
? { activity: streaming.activity }
|
||||
: {}),
|
||||
startedAt: streaming.startedAt,
|
||||
endedAt: streaming.endedAt,
|
||||
memoryNote: streaming.memoryNote,
|
||||
}
|
||||
: {})}
|
||||
@@ -782,9 +780,6 @@ export function MessageThread({
|
||||
steps={streaming.steps}
|
||||
results={streaming.results}
|
||||
activity={streaming.activity}
|
||||
interimRefs={streaming.interimRefs}
|
||||
startedAt={streaming.startedAt}
|
||||
endedAt={streaming.endedAt}
|
||||
memoryNote={streaming.memoryNote}
|
||||
providerType={streaming.inProgress ? "streaming" : null}
|
||||
inProgress={streaming.inProgress}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -568,25 +568,12 @@ export interface CoachStreamingMessage {
|
||||
* `metricSource.activity` and the model text behind `…/trail`.
|
||||
*/
|
||||
activity: CoachActivity[];
|
||||
/**
|
||||
* v1.41 — the refs of the tables that arrived as `interim` while the turn
|
||||
* still ran. They show as one-line previews until the answer settles, and
|
||||
* are taken down again when `done` says `withheldResults`.
|
||||
*/
|
||||
interimRefs: string[];
|
||||
/** v1.41 — the fact this turn saved or proposes (`memoryNote` frame). */
|
||||
memoryNote: CoachMemoryNote | null;
|
||||
/** v1.41 — the plan this turn proposes (`planProposal` frame). */
|
||||
planProposal: CoachPlanProposal | null;
|
||||
/** v1.41 — why the answer was forced, when it was (`done.stop`). */
|
||||
stop: CoachStop | null;
|
||||
/**
|
||||
* v1.41 — when the turn started on this device (epoch ms), for the live
|
||||
* seconds on the status line. Null when no turn has run.
|
||||
*/
|
||||
startedAt: number | null;
|
||||
/** v1.41 — when the stream closed (epoch ms); null while it runs. */
|
||||
endedAt: number | null;
|
||||
/** True until the `done` frame closes the stream. */
|
||||
inProgress: boolean;
|
||||
/** Final messageId once `done` lands; null otherwise. */
|
||||
@@ -670,12 +657,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
interimRefs: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
@@ -988,12 +972,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: params.message,
|
||||
conversationId: params.conversationId ?? null,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
setStreaming({
|
||||
...EMPTY_STREAMING,
|
||||
inProgress: true,
|
||||
startedAt,
|
||||
});
|
||||
setStreaming({ ...EMPTY_STREAMING, inProgress: true });
|
||||
|
||||
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
|
||||
// user gets the offline-specific copy immediately rather than
|
||||
@@ -1159,11 +1138,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
if (evt.interim && !interimRefs.includes(result.ref)) {
|
||||
interimRefs.push(result.ref);
|
||||
}
|
||||
const refs = [...interimRefs];
|
||||
// An interim table is kept quietly: nothing renders it
|
||||
// until the answer's own results do, and `done` may still
|
||||
// take it down again (`withheldResults`).
|
||||
setStreaming((prev) => ({
|
||||
...prev,
|
||||
results: upsertResult(prev.results, result),
|
||||
interimRefs: refs,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
@@ -1259,12 +1239,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
followUps: collectedFollowUps,
|
||||
clarification: collectedClarification,
|
||||
activity: collectedActivity,
|
||||
interimRefs: withheldResults ? [] : interimRefs,
|
||||
memoryNote: collectedMemoryNote,
|
||||
planProposal: collectedPlanProposal,
|
||||
stop: collectedStop,
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
inProgress: false,
|
||||
messageId,
|
||||
errorCode: lastError,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A trailing ellipsis, three dots or the single character. */
|
||||
const TRAILING_ELLIPSIS = /(?:\.\.\.|…)\s*$/u;
|
||||
|
||||
/** The stagger of the three dots, one class each (no inline style). */
|
||||
const DOT_DELAYS = [
|
||||
"[animation-delay:0s]",
|
||||
"[animation-delay:0.2s]",
|
||||
"[animation-delay:0.4s]",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Text that is still waiting on something: a trailing "..." or "…" becomes
|
||||
* three dots that fade in turn (`waiting-dot` in `globals.css`), so the line
|
||||
* reads as alive without a spinner beside it. Text without a trailing
|
||||
* ellipsis renders as it is. Under reduced motion the dots stand still.
|
||||
*
|
||||
* `textClassName` styles the words alone, never the dots: a text effect
|
||||
* clipped to the glyphs (`text-shimmer`) does not reliably reach a child
|
||||
* that animates its own opacity, so the dots keep the surrounding colour.
|
||||
*
|
||||
* The dots are ordinary full stops, so the text reads and copies as
|
||||
* "Thinking...". A surface that announces its status elsewhere hides this
|
||||
* text from assistive technology itself.
|
||||
*/
|
||||
export function WaitingText({
|
||||
text,
|
||||
textClassName,
|
||||
}: {
|
||||
text: string;
|
||||
textClassName?: string;
|
||||
}) {
|
||||
const match = TRAILING_ELLIPSIS.exec(text);
|
||||
const words = match ? text.slice(0, match.index) : text;
|
||||
return (
|
||||
<>
|
||||
<span className={textClassName}>{words}</span>
|
||||
{match ? (
|
||||
<span data-slot="waiting-dots">
|
||||
{DOT_DELAYS.map((delay) => (
|
||||
<span key={delay} className={cn("waiting-dot", delay)}>
|
||||
.
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,13 +26,12 @@ import {
|
||||
activityDigestDoneKey,
|
||||
activityDigestKey,
|
||||
activityMemoryKey,
|
||||
activitySummaryKey,
|
||||
activityThoughtProcessKey,
|
||||
} from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
COACH_ASSUMPTION_KEYS,
|
||||
COACH_CHART_COMPARE_KEYS,
|
||||
COACH_SUGGESTED_REPLIES_KEYS,
|
||||
interimLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
COACH_MEMORY_KEYS,
|
||||
@@ -91,8 +90,7 @@ function catalogKeys(): Set<string> {
|
||||
keys.add(activityDigestDoneKey(count, locale));
|
||||
keys.add(activityAreasKey(count, locale));
|
||||
keys.add(activityMemoryKey(count, locale));
|
||||
keys.add(activitySummaryKey(count, locale));
|
||||
keys.add(interimLabelKey(count, locale));
|
||||
keys.add(activityThoughtProcessKey(count, locale));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
@@ -104,7 +102,6 @@ const OWNED = [
|
||||
"insights.coach.plan",
|
||||
"insights.coach.assumption",
|
||||
"insights.coach.suggestedReplies",
|
||||
"insights.coach.interim",
|
||||
"insights.coach.chart",
|
||||
"insights.coach.reasoning",
|
||||
"settings.coach.memory",
|
||||
@@ -122,7 +119,7 @@ describe("v1.41 contract keys", () => {
|
||||
// Every key laid down for the release but the `change_assumption` chip
|
||||
// label, which the dialog catalog's own test covers, plus the two
|
||||
// declined-proposal replies and the admin reasoning description.
|
||||
expect(keys.size).toBe(60);
|
||||
expect(keys.size).toBe(57);
|
||||
});
|
||||
|
||||
it.each(locales)("every catalog key resolves in %s", (locale) => {
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
methodAveragesKey,
|
||||
methodReadingsKey,
|
||||
methodTotalsKey,
|
||||
stepHeaderDoneKey,
|
||||
stepMetricsKey,
|
||||
stepReadingsKey,
|
||||
stepRowsKey,
|
||||
@@ -112,7 +111,6 @@ function catalogKeys(): Set<string> {
|
||||
}
|
||||
for (const locale of locales) {
|
||||
for (const count of COUNTS) {
|
||||
keys.add(stepHeaderDoneKey(count, locale));
|
||||
keys.add(stepReadingsKey(count, locale));
|
||||
keys.add(stepRowsKey(count, locale));
|
||||
keys.add(stepMetricsKey(count, locale));
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ACTIVITY_TEXT_MAX_CHARS,
|
||||
ACTIVITY_TITLE_MAX_CHARS,
|
||||
TRAIL_MAX_BYTES,
|
||||
activitySummaryKey,
|
||||
activityThoughtProcessKey,
|
||||
createNoopActivityRecorder,
|
||||
} from "../contract";
|
||||
|
||||
@@ -38,15 +38,15 @@ describe("activity contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing about lookups on a turn that made none", () => {
|
||||
expect(activitySummaryKey(0, "en")).toBe(
|
||||
"insights.coach.activity.summaryNoLookups",
|
||||
it("counts the steps of the settled line by plural tier", () => {
|
||||
expect(activityThoughtProcessKey(1, "en")).toBe(
|
||||
"insights.coach.activity.thoughtProcessOne",
|
||||
);
|
||||
expect(activitySummaryKey(1, "en")).toBe(
|
||||
"insights.coach.activity.summaryOne",
|
||||
expect(activityThoughtProcessKey(0, "en")).toBe(
|
||||
"insights.coach.activity.thoughtProcessOther",
|
||||
);
|
||||
expect(activitySummaryKey(3, "pl")).toBe(
|
||||
"insights.coach.activity.summaryFew",
|
||||
expect(activityThoughtProcessKey(3, "pl")).toBe(
|
||||
"insights.coach.activity.thoughtProcessFew",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,8 @@ export const COACH_ACTIVITY_KEYS = {
|
||||
plan: "insights.coach.activity.plan",
|
||||
asking: "insights.coach.activity.asking",
|
||||
answer: "insights.coach.activity.answer",
|
||||
/** The settled line of a turn that waits for the person's choice. */
|
||||
awaitingAnswer: "insights.coach.activity.awaitingAnswer",
|
||||
} as const;
|
||||
|
||||
/** The `stop` entry's label, by reason. */
|
||||
@@ -132,12 +134,10 @@ export function activityMemoryKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.activity.memory", count, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary once the turn is done: "Thought it through, N lookups,
|
||||
* S s", or the seconds alone on a turn that looked nothing up.
|
||||
*/
|
||||
export function activitySummaryKey(lookups: number, locale: Locale): string {
|
||||
return lookups === 0
|
||||
? "insights.coach.activity.summaryNoLookups"
|
||||
: pluralKey("insights.coach.activity.summary", lookups, locale);
|
||||
/** The settled line once the turn is done: "Thought process · N steps". */
|
||||
export function activityThoughtProcessKey(
|
||||
count: number,
|
||||
locale: Locale,
|
||||
): string {
|
||||
return pluralKey("insights.coach.activity.thoughtProcess", count, locale);
|
||||
}
|
||||
@@ -46,11 +46,6 @@ export const COACH_STEP_UI_KEYS = {
|
||||
announceDone: "coach.step.announceDone",
|
||||
} as const;
|
||||
|
||||
/** "Looked at N sources", by plural tier. */
|
||||
export function stepHeaderDoneKey(count: number, locale: Locale): string {
|
||||
return pluralKey("coach.step.headerDone", count, locale);
|
||||
}
|
||||
|
||||
/** "N readings" on a step row, by plural tier. */
|
||||
export function stepReadingsKey(count: number, locale: Locale): string {
|
||||
return pluralKey("coach.step.readings", count, locale);
|
||||
@@ -256,7 +251,7 @@ export function clarifyWindowLabelKey(window: CoachScopeWindow): string {
|
||||
return `coach.clarify.window.${window}`;
|
||||
}
|
||||
|
||||
// ── Replies, assumptions, interim tables (v1.41) ──────────────────────────
|
||||
// ── Replies, assumptions (v1.41) ──────────────────────────────────────
|
||||
|
||||
/** The group of reply pills under the latest answer. */
|
||||
export const COACH_SUGGESTED_REPLIES_KEYS = {
|
||||
@@ -269,11 +264,6 @@ export const COACH_ASSUMPTION_KEYS = {
|
||||
change: "insights.coach.assumption.change",
|
||||
} as const;
|
||||
|
||||
/** The one-line preview of a table read while the turn still runs. */
|
||||
export function interimLabelKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.interim.label", count, locale);
|
||||
}
|
||||
|
||||
/** The legend of a chart comparing two periods ("{a} vs {b}"). */
|
||||
export const COACH_CHART_COMPARE_KEYS = {
|
||||
periods: "insights.coach.chart.compare.periods",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The refusal a demo deploy answers a write with (`DEMO_MODE=true`): the
|
||||
* proxy's mutation block and the routes that bypass the proxy send the same
|
||||
* body, a 403 whose `meta.errorCode` names it, so a client can tell "the demo
|
||||
* does not do this" from an outage and say so.
|
||||
*
|
||||
* Client-safe: no server import.
|
||||
*/
|
||||
export const DEMO_READ_ONLY_CODE = "demo.readOnly";
|
||||
|
||||
export const DEMO_REFUSAL_BODY = {
|
||||
data: null,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true, errorCode: DEMO_READ_ONLY_CODE },
|
||||
} as const;
|
||||
@@ -28,6 +28,7 @@
|
||||
* nothing else under `/api`.
|
||||
*/
|
||||
import { shouldRunWeb } from "@/lib/process-type";
|
||||
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
||||
|
||||
export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
// Streamed archive uploads, up to 1.5 GB.
|
||||
@@ -115,14 +116,7 @@ export function bypassRouteRefusal(method: string): Response | null {
|
||||
const m = method.toUpperCase();
|
||||
const isMutation = m !== "GET" && m !== "HEAD" && m !== "OPTIONS";
|
||||
if (process.env.DEMO_MODE === "true" && isMutation) {
|
||||
return Response.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true },
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
return Response.json(DEMO_REFUSAL_BODY, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -118,6 +118,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
|
||||
"disable-coach": ["disable-coach.body.invalid_json"],
|
||||
demo: ["demo.readOnly"],
|
||||
documents: [
|
||||
"documents.chat.conversationNotFound",
|
||||
"documents.chat.invalid",
|
||||
|
||||
+2
-8
@@ -6,6 +6,7 @@ import {
|
||||
RETIRED_ROUTE_STATUS,
|
||||
} from "@/lib/http/retired-routes";
|
||||
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
|
||||
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
||||
|
||||
/**
|
||||
* Paths that do NOT require a session cookie (public pages + external webhooks).
|
||||
@@ -361,14 +362,7 @@ export function proxy(request: NextRequest) {
|
||||
)
|
||||
) {
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true },
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user