mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge the Coach reasoning interface into release/v1.41.0
This commit is contained in:
commit
3b5fb1e900
58 files changed
+6040
-1762
No files matched your search
@@ -14,9 +14,23 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import {
|
||||
DEFAULT_REASONING_MAX_EFFORT,
|
||||
REASONING_ADMIN_KEYS,
|
||||
REASONING_LEVEL_LABEL_KEYS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
type ReasoningMaxEffort,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
|
||||
/**
|
||||
* Operator-side panel for the five assistant switches. The master stops
|
||||
* Operator-side panel for the five assistant switches, and (v1.41) the two
|
||||
* reasoning controls: whether models may think before they answer at all,
|
||||
* and the highest depth a person may choose. Background jobs follow the
|
||||
* same switch; there is no separate one.
|
||||
*
|
||||
* The five switches: The master stops
|
||||
* every AI feature; four sub-switches each stop one cost or egress profile:
|
||||
* the Coach, the daily briefing, status notes (per-reading notes, workout
|
||||
* notes, reaction lines) and reading documents (the vault, lab scans,
|
||||
@@ -48,8 +62,16 @@ interface AssistantFlagsResponse {
|
||||
insightStatus: boolean;
|
||||
documentAi: boolean;
|
||||
};
|
||||
/** v1.41 — the operator's reasoning controls; absent before v1.41. */
|
||||
reasoning?: { enabled: boolean; maxEffort: ReasoningMaxEffort };
|
||||
}
|
||||
|
||||
/** What the route accepts: any of the switches, and the reasoning pair. */
|
||||
type AssistantFlagsPatch = Partial<AssistantFlagsResponse["raw"]> & {
|
||||
aiReasoningEnabled?: boolean;
|
||||
aiReasoningMaxEffort?: ReasoningMaxEffort;
|
||||
};
|
||||
|
||||
function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
@@ -66,7 +88,7 @@ function useUpdateAssistantFlags() {
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
patch: Partial<AssistantFlagsResponse["raw"]>,
|
||||
patch: AssistantFlagsPatch,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
return apiPut<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
@@ -102,6 +124,7 @@ export function AssistantSection() {
|
||||
const raw = data?.raw;
|
||||
const masterOn = raw?.assistantEnabled ?? true;
|
||||
const disabledSubs = !masterOn || mutation.isPending;
|
||||
const reasoningOn = data?.reasoning?.enabled ?? true;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
@@ -159,6 +182,49 @@ export function AssistantSection() {
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.41 — reasoning: allowed at all, and how deep at most. */}
|
||||
<div
|
||||
data-slot="admin-assistant-reasoning"
|
||||
className="border-border space-y-4 border-t pt-4"
|
||||
>
|
||||
<SettingsToggle
|
||||
label={t(REASONING_ADMIN_KEYS.enabled)}
|
||||
description={t("admin.assistant.reasoning.description")}
|
||||
checked={reasoningOn}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ aiReasoningEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<Label
|
||||
htmlFor="admin-reasoning-max"
|
||||
noColon
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t(REASONING_ADMIN_KEYS.max)}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="admin-reasoning-max"
|
||||
data-slot="admin-reasoning-max"
|
||||
className="sm:w-48"
|
||||
value={data?.reasoning?.maxEffort ?? DEFAULT_REASONING_MAX_EFFORT}
|
||||
disabled={disabledSubs || !reasoningOn}
|
||||
onChange={(e) =>
|
||||
mutation.mutate({
|
||||
aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REASONING_MAX_EFFORTS.map((effort) => (
|
||||
<option key={effort} value={effort}>
|
||||
{t(REASONING_LEVEL_LABEL_KEYS[effort])}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.41 — "Assumed: last 30 days. Change": one quiet line, the change only
|
||||
* under the latest answer and only with alternatives.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachAssumptionLine, alternativesFor } from "../assumption-line";
|
||||
|
||||
const WINDOW: CoachAssumption = {
|
||||
kind: "window",
|
||||
value: {
|
||||
labelKey: "coach.step.window.last30days",
|
||||
label: "last 30 days",
|
||||
value: { window: "last30days" },
|
||||
},
|
||||
alternatives: [],
|
||||
};
|
||||
const CHANGE: CoachFollowUp = {
|
||||
id: "f1",
|
||||
kind: "change_assumption",
|
||||
labelKey: "coach.followUp.changeAssumption",
|
||||
label: "Last 90 days instead",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
assumption: { kind: "window", value: { window: "last90days" } },
|
||||
};
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CoachAssumptionLine>", () => {
|
||||
it("names the assumption in one meta line", () => {
|
||||
const html = render(
|
||||
<CoachAssumptionLine
|
||||
assumptions={[WINDOW]}
|
||||
changes={[]}
|
||||
messageId={null}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Assumed: last 30 days.");
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-assumption-line"[^>]*class="text-muted-foreground/,
|
||||
);
|
||||
expect(html).not.toContain("coach-assumption-change");
|
||||
});
|
||||
|
||||
it("offers Change under the latest answer, closed until tapped", () => {
|
||||
const html = render(
|
||||
<CoachAssumptionLine
|
||||
assumptions={[WINDOW]}
|
||||
changes={[CHANGE]}
|
||||
messageId="m1"
|
||||
disabled={false}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(">Change</button>");
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).not.toContain("Last 90 days instead");
|
||||
});
|
||||
|
||||
it("matches alternatives to their assumption by kind", () => {
|
||||
expect(alternativesFor(WINDOW, [CHANGE])).toEqual([CHANGE]);
|
||||
expect(alternativesFor({ ...WINDOW, kind: "metric" }, [CHANGE])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
// The card's only hook is the translator; the server one resolves the same
|
||||
// bundles and returns the key for a missing string, like the client one. With
|
||||
// it, the card can be called as a plain function and its handlers exercised
|
||||
// (the node test environment has no DOM to click through).
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string) => getServerTranslator("en").t(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
const focusComposer = vi.fn();
|
||||
vi.mock("../composer-focus", () => ({
|
||||
focusCoachComposer: () => focusComposer(),
|
||||
}));
|
||||
|
||||
import { CoachClarificationCard } from "../clarification-card";
|
||||
import type { CoachClarification } from "@/lib/ai/coach/types";
|
||||
|
||||
const METRIC: CoachClarification = {
|
||||
kind: "metric",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.pulse",
|
||||
label: "Pulse (server)",
|
||||
value: { metric: "pulse" },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "coach.not.a.key",
|
||||
label: "Server label",
|
||||
value: { metric: "resting_hr" },
|
||||
},
|
||||
],
|
||||
freeText: true,
|
||||
};
|
||||
|
||||
function card(clarification: CoachClarification, disabled = false) {
|
||||
const onChoose = vi.fn();
|
||||
const tree = CoachClarificationCard({
|
||||
clarification,
|
||||
messageId: "m1",
|
||||
disabled,
|
||||
onChoose,
|
||||
});
|
||||
return { tree, onChoose, html: renderToStaticMarkup(tree) };
|
||||
}
|
||||
|
||||
function choiceButtons(
|
||||
node: ReactNode,
|
||||
): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(choiceButtons);
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
const own = el.props["data-choice-id"] ? [el] : [];
|
||||
return [...own, ...choiceButtons(el.props.children as ReactNode)];
|
||||
}
|
||||
|
||||
describe("<CoachClarificationCard>", () => {
|
||||
it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => {
|
||||
const { html } = card(METRIC);
|
||||
// The card mounts with its content, so a live region on the card itself
|
||||
// is never heard; the conversation keeps one mounted for it.
|
||||
expect(html).not.toContain("aria-live");
|
||||
expect(html).toContain('aria-label="The Coach has a question"');
|
||||
expect(html).not.toContain("autofocus");
|
||||
expect(html).not.toContain("autoFocus");
|
||||
});
|
||||
|
||||
it("renders the catalog label, falling back to the server label", () => {
|
||||
const { html } = card(METRIC);
|
||||
expect(html).toContain('aria-label="Possible answers"');
|
||||
expect(html).toContain(">Pulse</button>");
|
||||
expect(html).toContain(">Server label</button>");
|
||||
expect(html).toContain("You can also type your own answer.");
|
||||
});
|
||||
|
||||
it("sends the tapped choice", () => {
|
||||
const { tree, onChoose } = card(METRIC);
|
||||
const buttons = choiceButtons(tree);
|
||||
expect(buttons).toHaveLength(2);
|
||||
focusComposer.mockClear();
|
||||
(buttons[1].props.onClick as () => void)();
|
||||
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
|
||||
// The choices disable while the answer runs: focus goes to the composer.
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the choices while a turn is in flight", () => {
|
||||
const { tree } = card(METRIC, true);
|
||||
for (const button of choiceButtons(tree)) {
|
||||
expect(button.props.disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for a typed answer on a context question", () => {
|
||||
const { html, tree } = card({
|
||||
kind: "context",
|
||||
choices: [],
|
||||
freeText: true,
|
||||
});
|
||||
expect(choiceButtons(tree)).toHaveLength(0);
|
||||
expect(html).not.toContain('role="group"');
|
||||
expect(html).toContain("Type your answer below.");
|
||||
});
|
||||
});
|
||||
@@ -113,16 +113,15 @@ describe("<CoachConversation> page deep-link (#67)", () => {
|
||||
expect(html).not.toContain('data-slot="coach-hero"');
|
||||
});
|
||||
|
||||
it("keeps the clarification live region mounted before any question arrives", () => {
|
||||
// A region that mounts together with its content is not announced; the
|
||||
// empty region has to be in the page first.
|
||||
it("asks no question in a card above the composer", () => {
|
||||
// v1.41 — a clarifying question is a normal Coach message with reply
|
||||
// pills under it; there is no card and no separate live region.
|
||||
const html = render(
|
||||
<CoachConversation surface="page" initialConversationId="older" />,
|
||||
makeClient(),
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<div[^>]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/,
|
||||
);
|
||||
expect(html).not.toContain("coach-clarification-card");
|
||||
expect(html).not.toContain("coach-clarification-live");
|
||||
});
|
||||
|
||||
it("auto-opens the most-recent thread when none is pinned", () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
import {
|
||||
chainWithFirst,
|
||||
modelControlFor,
|
||||
reasoningFieldFor,
|
||||
selectableProviders,
|
||||
switchDidNotTake,
|
||||
} from "../coach-model-picker";
|
||||
@@ -120,20 +119,3 @@ describe("modelControlFor", () => {
|
||||
).toEqual({ kind: "gateway", value: "qwen3:32b" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoningFieldFor", () => {
|
||||
it("writes the Local and gateway entries' own reasoning fields", () => {
|
||||
expect(
|
||||
reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })),
|
||||
).toEqual({ field: "localReasoningEffort", value: "low" });
|
||||
expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({
|
||||
field: "compatReasoningEffort",
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("has nothing for providers without a reasoning setting", () => {
|
||||
expect(reasoningFieldFor("openai", userProvider())).toBeNull();
|
||||
expect(reasoningFieldFor("codex", userProvider())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* v1.41 — the Coach's thinking depth: Off / Low / Medium / High, rendered
|
||||
* from what the server resolved, never recomputed.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
getServerTranslator(locale.current).t(key, params),
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
CoachReasoningSelect,
|
||||
reasoningLocked,
|
||||
reasoningOptions,
|
||||
reasoningStateOrDefault,
|
||||
shownReasoningLevel,
|
||||
type CoachReasoningState,
|
||||
} from "../coach-reasoning-field";
|
||||
|
||||
const OPEN: CoachReasoningState = {
|
||||
level: "medium",
|
||||
preference: "medium",
|
||||
maxLevel: "high",
|
||||
available: true,
|
||||
offIsReal: true,
|
||||
source: "user",
|
||||
};
|
||||
const ADMIN_OFF: CoachReasoningState = {
|
||||
level: "off",
|
||||
preference: "high",
|
||||
maxLevel: "off",
|
||||
available: false,
|
||||
offIsReal: true,
|
||||
source: "admin_off",
|
||||
};
|
||||
|
||||
function render(state: CoachReasoningState, value = state.level) {
|
||||
return renderToStaticMarkup(
|
||||
<CoachReasoningSelect
|
||||
id="r"
|
||||
value={value}
|
||||
state={state}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the resolved block", () => {
|
||||
it("reads an older server's missing block as allowed, uncapped, really off", () => {
|
||||
expect(reasoningStateOrDefault(null)).toEqual(OPEN);
|
||||
expect(reasoningStateOrDefault(undefined)).toEqual(OPEN);
|
||||
});
|
||||
|
||||
it("is locked only when the operator switched reasoning off", () => {
|
||||
expect(reasoningLocked(ADMIN_OFF)).toBe(true);
|
||||
expect(reasoningLocked(OPEN)).toBe(false);
|
||||
// A provider that cannot reason does not lock the person's choice.
|
||||
expect(
|
||||
reasoningLocked({ ...OPEN, available: false, source: "unsupported" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachReasoningSelect>", () => {
|
||||
it("offers Off, Low, Medium and High with the label and hint", () => {
|
||||
const html = render(OPEN);
|
||||
expect(html).toContain(">Thinking depth<");
|
||||
for (const label of ["Off", "Low", "Medium", "High"]) {
|
||||
expect(html).toContain(`>${label}</option>`);
|
||||
}
|
||||
expect(html).toContain(
|
||||
"Deeper thinking means more precise answers and a little more waiting.",
|
||||
);
|
||||
expect(html).not.toMatch(/<select[^>]*disabled/);
|
||||
});
|
||||
|
||||
it("is locked with one sentence when the admin turned reasoning off", () => {
|
||||
const html = render(ADMIN_OFF, "high");
|
||||
expect(html).toMatch(/<select[^>]*disabled/);
|
||||
expect(html).toContain('data-locked="true"');
|
||||
expect(html).toContain("The admin has turned deeper thinking off.");
|
||||
expect(html).not.toContain("a little more waiting");
|
||||
});
|
||||
|
||||
it("shows the admin's cap: levels above it are disabled and say why", () => {
|
||||
const html = render({ ...OPEN, maxLevel: "medium" });
|
||||
expect(html).toMatch(
|
||||
/<option value="high" disabled="">High \(limited by the admin\)<\/option>/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<option value="medium"( selected="")?>Medium<\/option>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says Minimal where the provider cannot switch thinking off", () => {
|
||||
const options = reasoningOptions(
|
||||
{ ...OPEN, offIsReal: false },
|
||||
getServerTranslator("en").t,
|
||||
);
|
||||
expect(options[0]).toEqual({
|
||||
value: "off",
|
||||
label: "Minimal",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a choice above the cap at the cap, which is what runs", () => {
|
||||
expect(shownReasoningLevel("high", { ...OPEN, maxLevel: "low" })).toBe(
|
||||
"low",
|
||||
);
|
||||
expect(shownReasoningLevel("low", OPEN)).toBe("low");
|
||||
expect(shownReasoningLevel("high", ADMIN_OFF)).toBe("off");
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
locale.current = "de";
|
||||
const html = render({ ...OPEN, maxLevel: "low" });
|
||||
locale.current = "en";
|
||||
expect(html).toContain(">Denktiefe<");
|
||||
expect(html).toContain("Hoch (vom Admin begrenzt)");
|
||||
});
|
||||
});
|
||||
@@ -100,4 +100,39 @@ describe("resolveCoachSendTarget", () => {
|
||||
expect(target.url).toBe("/api/insights/chat");
|
||||
},
|
||||
);
|
||||
|
||||
it("carries a memory or plan decision on the tool route, never on the fenced one", () => {
|
||||
const memory = JSON.parse(
|
||||
resolveCoachSendTarget({
|
||||
conversationId: "c1",
|
||||
message: "Yes, remember it",
|
||||
memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
|
||||
}).body,
|
||||
);
|
||||
expect(memory.memoryDecision).toEqual({
|
||||
messageId: "a1",
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
});
|
||||
const plan = JSON.parse(
|
||||
resolveCoachSendTarget({
|
||||
conversationId: "c1",
|
||||
message: "Not now",
|
||||
planDecision: { messageId: "a1", planId: "plan1", accept: false },
|
||||
}).body,
|
||||
);
|
||||
expect(plan.planDecision).toEqual({
|
||||
messageId: "a1",
|
||||
planId: "plan1",
|
||||
accept: false,
|
||||
});
|
||||
const fenced = resolveCoachSendTarget({
|
||||
conversationId: "c1",
|
||||
message: "Yes",
|
||||
fenced: true,
|
||||
memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
|
||||
});
|
||||
expect(fenced.url).toBe("/api/insights/chat/fenced");
|
||||
expect(JSON.parse(fenced.body).memoryDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -109,13 +109,13 @@ describe("<CoachSettingsBody>", () => {
|
||||
|
||||
it("locks the reasoning select while a change is saving", async () => {
|
||||
// Two writes in flight at once could land out of order and lose one;
|
||||
// the provider and model selects already lock, the reasoning one too.
|
||||
// the provider and model selects already lock, the thinking depth too.
|
||||
const source = (await import("node:fs")).readFileSync(
|
||||
new URL("../coach-model-picker.tsx", import.meta.url),
|
||||
new URL("../coach-reasoning-field.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const field = source.match(/<ReasoningEffortField[\s\S]*?\/>/)?.[0];
|
||||
expect(field).toMatch(/disabled=\{busy\}/);
|
||||
expect(source).toMatch(/disabled=\{locked \|\| busy\}/);
|
||||
expect(source).toMatch(/busy=\{save\.isPending\}/);
|
||||
});
|
||||
|
||||
it("names the data section so a deep link can land on it", () => {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.41 — the one quiet line under an answer that says the Coach kept
|
||||
* something, with Undo.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
getServerTranslator(locale.current).t(key, params),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CoachMemoryNote, CoachMemoryNoteLine } from "../memory-note";
|
||||
|
||||
function render(node: ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{node}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function find(
|
||||
node: ReactNode,
|
||||
slot: string,
|
||||
): ReactElement<Record<string, unknown>> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const hit = find(child, slot);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isValidElement(node)) return null;
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
if (el.props["data-slot"] === slot) return el;
|
||||
return find(el.props.children as ReactNode, slot);
|
||||
}
|
||||
|
||||
describe("<CoachMemoryNoteLine>", () => {
|
||||
it("says what was kept as one meta line, with Undo as a text button", () => {
|
||||
locale.current = "en";
|
||||
const html = render(
|
||||
<CoachMemoryNoteLine
|
||||
fact="You want to reach 75 kg by December."
|
||||
pending={false}
|
||||
onUndo={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-memory-note"');
|
||||
expect(html).toContain("Remembered: You want to reach 75 kg by December.");
|
||||
expect(html).toMatch(/class="text-muted-foreground[^"]*text-xs/);
|
||||
expect(html).toContain(">Undo</button>");
|
||||
expect(html).not.toMatch(/\bbg-primary\b|text-muted-foreground\/\d/);
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
locale.current = "de";
|
||||
const html = render(
|
||||
<CoachMemoryNoteLine
|
||||
fact="Ziel 75 kg"
|
||||
pending={false}
|
||||
onUndo={() => {}}
|
||||
/>,
|
||||
);
|
||||
locale.current = "en";
|
||||
expect(html).toContain("Gemerkt: Ziel 75 kg.");
|
||||
expect(html).toContain(">Rückgängig</button>");
|
||||
});
|
||||
|
||||
it("Undo forgets the fact: one tap, one call", () => {
|
||||
const onUndo = vi.fn();
|
||||
const tree = CoachMemoryNoteLine({ fact: "x", pending: false, onUndo });
|
||||
const undo = find(tree, "coach-memory-note-undo");
|
||||
expect(undo).not.toBeNull();
|
||||
(undo?.props.onClick as () => void)();
|
||||
expect(onUndo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("locks Undo while the forget is on its way", () => {
|
||||
const tree = CoachMemoryNoteLine({
|
||||
fact: "x",
|
||||
pending: true,
|
||||
onUndo: () => {},
|
||||
});
|
||||
expect(find(tree, "coach-memory-note-undo")?.props.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachMemoryNote>", () => {
|
||||
it("shows a saved fact from the live frame", () => {
|
||||
const html = render(
|
||||
<CoachMemoryNote
|
||||
note={{ proposal: false, factId: "f1", category: "goal" }}
|
||||
liveFact="Reach 75 kg by December"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Remembered: Reach 75 kg by December.");
|
||||
});
|
||||
|
||||
it("shows nothing for a proposal: the reply pills carry it", () => {
|
||||
const html = render(
|
||||
<CoachMemoryNote
|
||||
note={{ proposal: true, proposalId: "p1", category: "medication" }}
|
||||
liveFact="Takes ramipril"
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("shows nothing for a persisted note whose fact is not in the list", () => {
|
||||
const html = render(
|
||||
<CoachMemoryNote
|
||||
note={{ proposal: false, factId: "gone", category: "goal" }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,13 @@ function streaming(
|
||||
results: [],
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
interimRefs: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
|
||||
import {
|
||||
PlanProposalCards,
|
||||
PROPOSAL_POLL_MS,
|
||||
PROPOSAL_POLL_MAX_ATTEMPTS,
|
||||
nextProposalPollInterval,
|
||||
} from "../plan-proposal-card";
|
||||
|
||||
/**
|
||||
* The plan-proposal confirm cards at the thread tail. SSR string render
|
||||
* against a pre-seeded query cache (the `status:proposed` slot the component
|
||||
* reads), so the tests pin the render contract without a network layer:
|
||||
* only proposals born in the OPEN conversation render, the prose reads in
|
||||
* the card, and both actions are present as real buttons.
|
||||
*/
|
||||
|
||||
function plan(overrides: Partial<CoachPlanDTO> = {}): CoachPlanDTO {
|
||||
return {
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCue: "every morning",
|
||||
thenAction: "weigh in before breakfast",
|
||||
target: "steady trend by autumn",
|
||||
status: "proposed",
|
||||
reviewDate: null,
|
||||
sourceConversationId: "conv-1",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(conversationId: string, plans: CoachPlanDTO[]) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.coachPlans("status:proposed"), plans);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<PlanProposalCards conversationId={conversationId} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<PlanProposalCards>", () => {
|
||||
it("renders nothing when the conversation has no open proposal", () => {
|
||||
expect(render("conv-1", [])).toBe("");
|
||||
});
|
||||
|
||||
it("renders a confirm card for a proposal born in the open conversation", () => {
|
||||
const html = render("conv-1", [plan()]);
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-card"');
|
||||
// The plan prose is content — visible in the card.
|
||||
expect(html).toContain("every morning");
|
||||
expect(html).toContain("weigh in before breakfast");
|
||||
expect(html).toContain("steady trend by autumn");
|
||||
expect(html).toContain("WEIGHT");
|
||||
// Both actions render as real buttons.
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-accept"');
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-decline"');
|
||||
});
|
||||
|
||||
it("hides proposals born in a different conversation", () => {
|
||||
const html = render("conv-2", [plan()]);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("skips a proposal whose prose is undecryptable (null fields)", () => {
|
||||
const html = render("conv-1", [plan({ ifCue: null, thenAction: null })]);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders one card per open proposal", () => {
|
||||
const html = render("conv-1", [
|
||||
plan(),
|
||||
plan({ id: "p2", ifCue: "after dinner", thenAction: "short walk" }),
|
||||
]);
|
||||
expect(html.match(/coach-plan-proposal-card/g)?.length).toBe(2);
|
||||
expect(html).toContain("after dinner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextProposalPollInterval", () => {
|
||||
it("polls at the slow interval below the attempt ceiling", () => {
|
||||
expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS);
|
||||
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe(
|
||||
PROPOSAL_POLL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops polling once the attempt ceiling is reached", () => {
|
||||
// Watched red: asserting an interval at the cap fails — the ceiling is
|
||||
// live, not decorative.
|
||||
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false);
|
||||
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* v1.41 — what the latest answer waits for (a clarifying question, a fact
|
||||
* proposal, a plan proposal) becomes reply pills, and a tap answers it.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { CoachMessageDTO, CoachProvenance } from "@/lib/ai/coach/types";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: null }) }));
|
||||
|
||||
import {
|
||||
decisionReplies,
|
||||
latestDecision,
|
||||
type CoachReplyIntent,
|
||||
} from "../message-thread";
|
||||
import type { CoachStreamingMessage } from "../use-coach";
|
||||
|
||||
const { t } = getServerTranslator("en");
|
||||
|
||||
function assistant(id: string, metricSource: Partial<CoachProvenance>) {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: "…",
|
||||
createdAt: "2026-10-06T10:00:00.000Z",
|
||||
metricSource: { windows: [], metrics: [], ...metricSource },
|
||||
providerType: "mock",
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
} as unknown as CoachMessageDTO;
|
||||
}
|
||||
const user = (id: string) =>
|
||||
({
|
||||
id,
|
||||
role: "user",
|
||||
content: "Yes",
|
||||
createdAt: "2026-10-06T10:01:00.000Z",
|
||||
metricSource: null,
|
||||
}) as unknown as CoachMessageDTO;
|
||||
|
||||
const CLARIFY = {
|
||||
kind: "metric" as const,
|
||||
freeText: true,
|
||||
assumption: "c2",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.pulse",
|
||||
label: "Pulse",
|
||||
value: { metric: "pulse" as const },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "insights.coach.metric.resting_hr",
|
||||
label: "Resting HR",
|
||||
value: { metric: "resting_hr" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function collect() {
|
||||
const sent: CoachReplyIntent[] = [];
|
||||
return { sent, onReply: (intent: CoachReplyIntent) => sent.push(intent) };
|
||||
}
|
||||
|
||||
describe("latestDecision", () => {
|
||||
it("is the latest answer's open question or proposal", () => {
|
||||
const msgs = [
|
||||
assistant("a1", {
|
||||
memoryNote: {
|
||||
proposal: true,
|
||||
proposalId: "p1",
|
||||
category: "medication",
|
||||
},
|
||||
}),
|
||||
];
|
||||
expect(latestDecision(msgs, undefined)).toEqual({
|
||||
messageId: "a1",
|
||||
clarification: null,
|
||||
memoryProposalId: "p1",
|
||||
planId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is gone once the person has answered", () => {
|
||||
const msgs = [assistant("a1", { clarification: CLARIFY }), user("u2")];
|
||||
expect(latestDecision(msgs, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers nothing while a turn runs, and nothing for a saved note", () => {
|
||||
const msgs = [assistant("a1", { clarification: CLARIFY })];
|
||||
expect(
|
||||
latestDecision(msgs, { inProgress: true } as CoachStreamingMessage),
|
||||
).toBeNull();
|
||||
expect(
|
||||
latestDecision(
|
||||
[
|
||||
assistant("a1", {
|
||||
memoryNote: { proposal: false, factId: "f1", category: "goal" },
|
||||
}),
|
||||
],
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the just-streamed turn before its persisted copy lands", () => {
|
||||
const streaming = {
|
||||
inProgress: false,
|
||||
messageId: "a9",
|
||||
clarification: null,
|
||||
memoryNote: null,
|
||||
planProposal: {
|
||||
planId: "plan1",
|
||||
metric: "WEIGHT",
|
||||
reviewInDays: 14,
|
||||
ifCue: "after dinner",
|
||||
thenAction: "walk",
|
||||
},
|
||||
} as unknown as CoachStreamingMessage;
|
||||
expect(latestDecision([], streaming)?.planId).toBe("plan1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decisionReplies", () => {
|
||||
it("puts the assumed choice first and sends it as a clarification", () => {
|
||||
const { sent, onReply } = collect();
|
||||
const replies = decisionReplies(
|
||||
{
|
||||
messageId: "a1",
|
||||
clarification: CLARIFY,
|
||||
memoryProposalId: null,
|
||||
planId: null,
|
||||
},
|
||||
t,
|
||||
onReply,
|
||||
);
|
||||
expect(replies.map((r) => r.label)).toEqual(["Resting HR", "Pulse"]);
|
||||
replies[0].onSelect();
|
||||
expect(sent[0]).toMatchObject({
|
||||
kind: "clarification",
|
||||
messageId: "a1",
|
||||
choice: { id: "c2" },
|
||||
label: "Resting HR",
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms a fact proposal with Yes and declines it with No", () => {
|
||||
const { sent, onReply } = collect();
|
||||
const replies = decisionReplies(
|
||||
{
|
||||
messageId: "a1",
|
||||
clarification: null,
|
||||
memoryProposalId: "p1",
|
||||
planId: null,
|
||||
},
|
||||
t,
|
||||
onReply,
|
||||
);
|
||||
expect(replies.map((r) => r.label)).toEqual(["Yes, remember it", "No"]);
|
||||
replies[0].onSelect();
|
||||
replies[1].onSelect();
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
kind: "memory",
|
||||
messageId: "a1",
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
label: "Yes, remember it",
|
||||
},
|
||||
{
|
||||
kind: "memory",
|
||||
messageId: "a1",
|
||||
proposalId: "p1",
|
||||
accept: false,
|
||||
label: "No",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes on or postpones a plan", () => {
|
||||
const { sent, onReply } = collect();
|
||||
const replies = decisionReplies(
|
||||
{
|
||||
messageId: "a1",
|
||||
clarification: null,
|
||||
memoryProposalId: null,
|
||||
planId: "plan1",
|
||||
},
|
||||
getServerTranslator("de").t,
|
||||
onReply,
|
||||
);
|
||||
expect(replies.map((r) => r.label)).toEqual([
|
||||
"Plan übernehmen",
|
||||
"Nicht jetzt",
|
||||
]);
|
||||
replies[1].onSelect();
|
||||
expect(sent[0]).toEqual({
|
||||
kind: "plan",
|
||||
messageId: "a1",
|
||||
planId: "plan1",
|
||||
accept: false,
|
||||
label: "Nicht jetzt",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* v1.41 — the compare chart: two periods overlaid on one axis, or two
|
||||
* metrics with two axes whose grid lines meet. Recharts is replaced by
|
||||
* pass-through parts so the props the chart hands it can be read without a
|
||||
* browser layout.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
yAxis: [] as Array<Record<string, unknown>>,
|
||||
line: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("recharts", () => {
|
||||
const Pass = ({ children }: { children?: ReactNode }) => <>{children}</>;
|
||||
const Leaf = () => null;
|
||||
return {
|
||||
ResponsiveContainer: Pass,
|
||||
LineChart: Pass,
|
||||
BarChart: Pass,
|
||||
CartesianGrid: Leaf,
|
||||
Bar: Leaf,
|
||||
Tooltip: Leaf,
|
||||
XAxis: Leaf,
|
||||
Line: (props: Record<string, unknown>) => {
|
||||
calls.line.push(props);
|
||||
return null;
|
||||
},
|
||||
YAxis: (props: Record<string, unknown>) => {
|
||||
calls.yAxis.push(props);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachResultChart, alignedAxes, comparePoints } from "../result-chart";
|
||||
|
||||
function table(
|
||||
chart: Extract<CoachChartSpec, { kind: "compare" }>,
|
||||
rows: CoachResultTable["rows"],
|
||||
labels: [string, string],
|
||||
): CoachResultTable {
|
||||
return {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "compare_series" as never,
|
||||
domain: "resting_hr",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "k",
|
||||
title: "Resting heart rate and sleep",
|
||||
rowCount: rows.length,
|
||||
chartKind: "compare",
|
||||
displayed: true,
|
||||
columns: [
|
||||
{ key: "period", kind: "category", labelKey: "k", label: "Day" },
|
||||
{
|
||||
key: "a",
|
||||
kind: "number",
|
||||
labelKey: "k",
|
||||
label: labels[0],
|
||||
unit: "bpm",
|
||||
},
|
||||
{ key: "b", kind: "number", labelKey: "k", label: labels[1], unit: "h" },
|
||||
],
|
||||
rows,
|
||||
truncated: false,
|
||||
chart,
|
||||
};
|
||||
}
|
||||
|
||||
const METRICS = table(
|
||||
{ kind: "compare", mode: "metrics", x: "period", a: "a", b: "b", axes: 2 },
|
||||
[
|
||||
["1", 58, 7.5],
|
||||
["2", 61, 6.1],
|
||||
["3", null, 6.8],
|
||||
["4", 59, 8.0],
|
||||
],
|
||||
["Resting HR", "Sleep"],
|
||||
);
|
||||
const PERIODS = table(
|
||||
{ kind: "compare", mode: "periods", x: "period", a: "a", b: "b", axes: 1 },
|
||||
[
|
||||
["1", 58, 60],
|
||||
["2", 61, 62],
|
||||
],
|
||||
["This month", "Last month"],
|
||||
);
|
||||
|
||||
function render(result: CoachResultTable) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachResultChart result={result} otherLabel="Other" label="Chart" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.yAxis.length = 0;
|
||||
calls.line.length = 0;
|
||||
});
|
||||
|
||||
describe("compare chart", () => {
|
||||
it("renders, where the placeholder rendered nothing", () => {
|
||||
const html = render(METRICS);
|
||||
expect(html).toContain('data-slot="coach-result-chart-compare"');
|
||||
expect(html).toContain('role="img"');
|
||||
// A legend button per series, both shown.
|
||||
expect(html.match(/aria-pressed="true"/g)).toHaveLength(2);
|
||||
expect(html).toContain("Resting HR");
|
||||
expect(html).toContain("Sleep");
|
||||
});
|
||||
|
||||
it("draws two metrics on two axes whose grid lines meet", () => {
|
||||
render(METRICS);
|
||||
const [left, right] = calls.yAxis;
|
||||
expect(left.yAxisId).toBe("left");
|
||||
expect(right.yAxisId).toBe("right");
|
||||
expect(right.orientation).toBe("right");
|
||||
expect((left.ticks as number[]).length).toBe(
|
||||
(right.ticks as number[]).length,
|
||||
);
|
||||
expect(calls.line.map((l) => [l.dataKey, l.yAxisId, l.stroke])).toEqual([
|
||||
["a", "left", "var(--chart-1)"],
|
||||
["b", "right", "var(--chart-2)"],
|
||||
]);
|
||||
// A gap stays a gap.
|
||||
expect(calls.line.every((l) => l.connectNulls === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("overlays two periods on one axis, the earlier one dashed, named a vs b", () => {
|
||||
const html = render(PERIODS);
|
||||
expect(calls.yAxis).toHaveLength(1);
|
||||
expect(calls.line.map((l) => l.strokeDasharray)).toEqual([
|
||||
undefined,
|
||||
"5 4",
|
||||
]);
|
||||
expect(html).toContain('aria-label="This month vs Last month"');
|
||||
});
|
||||
|
||||
it("reads its rows by the spec's columns", () => {
|
||||
expect(comparePoints(METRICS, METRICS.chart as never)[2]).toEqual({
|
||||
x: "3",
|
||||
a: null,
|
||||
b: 6.8,
|
||||
});
|
||||
});
|
||||
|
||||
it("widens the second axis until its ticks line up with the first", () => {
|
||||
const axes = alignedAxes([50, 90], [6, 7]);
|
||||
expect(axes).not.toBeNull();
|
||||
expect(axes?.right.ticks.length).toBe(axes?.left.ticks.length);
|
||||
const steps = axes!.right.ticks
|
||||
.slice(1)
|
||||
.map((v, i) => v - axes!.right.ticks[i]);
|
||||
expect(new Set(steps.map((s) => s.toFixed(6))).size).toBe(1);
|
||||
expect(alignedAxes([], [1])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* v1.41 — the one quiet reply pattern: pills under the latest answer that
|
||||
* send their label as the person's own message.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
getServerTranslator("en").t(key, params),
|
||||
}),
|
||||
}));
|
||||
|
||||
const focusComposer = vi.fn();
|
||||
vi.mock("../composer-focus", () => ({
|
||||
focusCoachComposer: () => focusComposer(),
|
||||
}));
|
||||
|
||||
import { SuggestedReplies, type SuggestedReply } from "../suggested-replies";
|
||||
|
||||
function pills(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(pills);
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
const own = el.props["data-reply-id"] ? [el] : [];
|
||||
return [...own, ...pills(el.props.children as ReactNode)];
|
||||
}
|
||||
|
||||
function replies(onSelect: (id: string) => void): SuggestedReply[] {
|
||||
return [
|
||||
"Resting heart rate",
|
||||
"Walking heart rate",
|
||||
"Both",
|
||||
"Neither",
|
||||
"Fifth",
|
||||
].map((label, i) => ({
|
||||
id: `c${i + 1}`,
|
||||
label,
|
||||
kind: "clarification",
|
||||
data: { "data-choice-id": `c${i + 1}` },
|
||||
onSelect: () => onSelect(`c${i + 1}`),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("<SuggestedReplies>", () => {
|
||||
it("renders at most four quiet outline pills in a labelled group", () => {
|
||||
const tree = SuggestedReplies({
|
||||
replies: replies(() => {}),
|
||||
messageId: "m1",
|
||||
disabled: false,
|
||||
});
|
||||
const html = tree ? renderToStaticMarkup(tree) : "";
|
||||
expect(html).toContain('role="group"');
|
||||
expect(html).toContain('aria-label="Suggested replies"');
|
||||
expect(html).toContain('data-slot="coach-suggested-replies"');
|
||||
expect(html).toContain('data-message-id="m1"');
|
||||
expect(html).not.toContain("Fifth");
|
||||
const rendered = pills(tree);
|
||||
expect(rendered).toHaveLength(4);
|
||||
for (const pill of rendered) {
|
||||
expect(pill.props.variant).toBe("outline");
|
||||
const cls = String(pill.props.className);
|
||||
// The answer's text size, low contrast in the foreground, a pill.
|
||||
expect(cls).toContain("text-sm");
|
||||
expect(cls).toContain("font-normal");
|
||||
expect(cls).toContain("text-foreground/80");
|
||||
expect(cls).toContain("rounded-full");
|
||||
expect(cls).toContain("min-h-11");
|
||||
// Never an accent, never muted-with-alpha.
|
||||
expect(cls).not.toMatch(/\bprimary\b|text-muted-foreground\/\d/);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends the tapped reply and hands focus to the composer", () => {
|
||||
focusComposer.mockClear();
|
||||
const onSelect = vi.fn();
|
||||
const tree = SuggestedReplies({
|
||||
replies: replies(onSelect),
|
||||
messageId: "m1",
|
||||
disabled: false,
|
||||
});
|
||||
(pills(tree)[1].props.onClick as () => void)();
|
||||
expect(onSelect).toHaveBeenCalledWith("c2");
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is hidden while a turn runs, and when there is nothing to offer", () => {
|
||||
expect(
|
||||
SuggestedReplies({
|
||||
replies: replies(() => {}),
|
||||
messageId: "m1",
|
||||
disabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
SuggestedReplies({ replies: [], messageId: "m1", disabled: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
CoachTurnActivity,
|
||||
CoachTurnStepList,
|
||||
activityLineLabel,
|
||||
activitySeconds,
|
||||
activitySummary,
|
||||
countLookups,
|
||||
countSources,
|
||||
currentActivity,
|
||||
currentStep,
|
||||
describeStep,
|
||||
legacyAreaLabels,
|
||||
stepLabel,
|
||||
} from "../turn-activity";
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function translators(locale: Locale) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const tCount = (base: string, count: number) =>
|
||||
t(pluralKey(base, count, locale), { count });
|
||||
return { t, tCount };
|
||||
}
|
||||
|
||||
const BP: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "done",
|
||||
count: 142,
|
||||
};
|
||||
const SLEEP: CoachStep = {
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
};
|
||||
const LABS_RUNNING: CoachStep = {
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Lab results, last 12 months",
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("describeStep", () => {
|
||||
const { t, tCount } = translators("en");
|
||||
|
||||
it("reads domain · window · count on a found step", () => {
|
||||
expect(describeStep(BP, t, tCount)).toEqual({
|
||||
title: "Blood pressure",
|
||||
meta: ["last 90 days", "142 readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says why nothing was found on an empty step", () => {
|
||||
expect(describeStep(SLEEP, t, tCount)).toEqual({
|
||||
title: "Sleep",
|
||||
meta: ["last 30 days", "no readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
|
||||
const outside: CoachStep = {
|
||||
...SLEEP,
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
};
|
||||
expect(describeStep(outside, t, tCount).meta).toEqual([
|
||||
"last 30 days",
|
||||
"no readings in this window",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts metrics on the snapshot step and rows on labs", () => {
|
||||
const snapshot: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 1,
|
||||
};
|
||||
expect(describeStep(snapshot, t, tCount)).toEqual({
|
||||
title: "Record summary",
|
||||
meta: ["1 metric"],
|
||||
});
|
||||
expect(
|
||||
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
|
||||
.meta,
|
||||
).toEqual(["last 12 months", "3 rows"]);
|
||||
});
|
||||
|
||||
it("renders in the reader's language from the keys, not the server label", () => {
|
||||
const de = translators("de");
|
||||
expect(stepLabel(BP, de.t)).toBe("Prüfe: Blutdruck, letzte 90 Tage");
|
||||
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
|
||||
"letzte 90 Tage",
|
||||
"142 Messwerte",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the server label for a key the bundle lacks", () => {
|
||||
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
|
||||
BP.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const THINKING_RUNNING: CoachActivity = {
|
||||
id: "a1",
|
||||
phase: "thinking",
|
||||
status: "running",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.thinking",
|
||||
label: "Thinking…",
|
||||
};
|
||||
const FETCH_BP: CoachActivity = {
|
||||
id: "a2",
|
||||
phase: "fetch",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
label: "Fetching blood pressure, last 90 days…",
|
||||
stepRef: "s1",
|
||||
durationMs: 900,
|
||||
};
|
||||
const FETCH_SLEEP: CoachActivity = {
|
||||
id: "a3",
|
||||
phase: "fetch",
|
||||
status: "empty",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
label: "Fetching sleep, last 30 days…",
|
||||
stepRef: "s2",
|
||||
durationMs: 1_400,
|
||||
};
|
||||
const DIGEST_RUNNING: CoachActivity = {
|
||||
id: "a4",
|
||||
phase: "digest",
|
||||
status: "running",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.digestOther",
|
||||
label: "Summarising 214 readings…",
|
||||
count: 214,
|
||||
};
|
||||
const THINKING_DONE: CoachActivity = {
|
||||
...THINKING_RUNNING,
|
||||
status: "done",
|
||||
durationMs: 4_000,
|
||||
title: "Comparing the two months",
|
||||
text: "The second half of the month runs higher.",
|
||||
};
|
||||
const DIGEST_DONE: CoachActivity = {
|
||||
...DIGEST_RUNNING,
|
||||
status: "done",
|
||||
label: "214 readings from 2 areas",
|
||||
durationMs: 600,
|
||||
};
|
||||
const ANSWER_DONE: CoachActivity = {
|
||||
id: "a5",
|
||||
phase: "answer",
|
||||
status: "done",
|
||||
round: 2,
|
||||
labelKey: "insights.coach.activity.answer",
|
||||
label: "Writing the answer…",
|
||||
durationMs: 6_000,
|
||||
};
|
||||
const ASKING: CoachActivity = {
|
||||
id: "a6",
|
||||
phase: "asking",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.asking",
|
||||
label: "Asking you…",
|
||||
durationMs: 1_000,
|
||||
};
|
||||
const STOPPED: CoachActivity = {
|
||||
id: "a7",
|
||||
phase: "stop",
|
||||
status: "done",
|
||||
round: 6,
|
||||
labelKey: "insights.coach.activity.stop.time",
|
||||
label: "Time limit reached, answering with what it has",
|
||||
stop: "time",
|
||||
};
|
||||
const SETTLED = [
|
||||
THINKING_DONE,
|
||||
FETCH_BP,
|
||||
FETCH_SLEEP,
|
||||
DIGEST_DONE,
|
||||
ANSWER_DONE,
|
||||
];
|
||||
|
||||
describe("the running line", () => {
|
||||
it("names the latest running entry, else the latest one", () => {
|
||||
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
|
||||
"a4",
|
||||
);
|
||||
expect(currentActivity([THINKING_DONE, FETCH_BP])?.id).toBe("a2");
|
||||
expect(currentActivity([])).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a reasoning title once one arrives, the catalog label before", () => {
|
||||
expect(activityLineLabel(THINKING_RUNNING)).toBe("Thinking…");
|
||||
expect(
|
||||
activityLineLabel({ ...THINKING_RUNNING, title: "Checking the trend" }),
|
||||
).toBe("Checking the trend");
|
||||
// Model text never replaces a fetch's catalog label, and a finished
|
||||
// entry no longer trails off.
|
||||
expect(activityLineLabel({ ...FETCH_BP, title: "x" })).toBe(
|
||||
"Fetching blood pressure, last 90 days",
|
||||
);
|
||||
expect(
|
||||
activityLineLabel({ ...FETCH_BP, status: "running", title: "x" }),
|
||||
).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}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
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 sit on the right of the line.
|
||||
expect(html).toMatch(/data-slot="coach-turn-steps-seconds"[^>]*>1[23]s</);
|
||||
}
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
|
||||
it("falls back to the step label on a server that sends only steps", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
|
||||
);
|
||||
expect(html).toContain("Checking: Lab results, last 12 months");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nothing opens by itself", () => {
|
||||
const states: Array<[string, React.ReactNode]> = [
|
||||
[
|
||||
"before any frame",
|
||||
<CoachTurnActivity key="0" activity={[]} steps={[]} active />,
|
||||
],
|
||||
[
|
||||
"running",
|
||||
<CoachTurnActivity
|
||||
key="1"
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"asking",
|
||||
<CoachTurnActivity
|
||||
key="2"
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"stopped",
|
||||
<CoachTurnActivity
|
||||
key="3"
|
||||
activity={[...SETTLED, STOPPED]}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"settled",
|
||||
<CoachTurnActivity
|
||||
key="4"
|
||||
activity={SETTLED}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
method={{ entries: [], text: "Blood pressure, 9 readings." }}
|
||||
dataUsed={<div data-slot="coach-data-used" />}
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"reloaded (metadata only)",
|
||||
<CoachTurnActivity
|
||||
key="5"
|
||||
activity={JSON.parse(
|
||||
JSON.stringify(
|
||||
SETTLED.map(({ title: _t, text: _x, ...meta }) => meta),
|
||||
),
|
||||
)}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
conversationId="c1"
|
||||
messageId="m1"
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"legacy steps",
|
||||
<CoachTurnActivity
|
||||
key="6"
|
||||
activity={[]}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
],
|
||||
];
|
||||
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('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.");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("after the answer", () => {
|
||||
const { t } = translators("en");
|
||||
|
||||
it("collapses to one calm summary of lookups and seconds", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={SETTLED}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
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<',
|
||||
);
|
||||
// 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"');
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
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} />,
|
||||
);
|
||||
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(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentStep", () => {
|
||||
it("names the latest running step, else the latest step", () => {
|
||||
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
|
||||
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
|
||||
expect(currentStep([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
it("names the known areas and drops unknown tokens and general", () => {
|
||||
expect(
|
||||
legacyAreaLabels(
|
||||
["bp", "bloodPressure" as never, "general", "sleep", "bp"],
|
||||
t,
|
||||
),
|
||||
).toEqual(["Blood pressure", "Sleep"]);
|
||||
});
|
||||
});
|
||||
@@ -1,378 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
CoachTurnStepList,
|
||||
CoachTurnSteps,
|
||||
CoachTurnStepsPanel,
|
||||
countSources,
|
||||
legacyAreaLabels,
|
||||
currentStep,
|
||||
describeStep,
|
||||
nextAnnouncement,
|
||||
stepLabel,
|
||||
} from "../turn-steps";
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function translators(locale: Locale) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const tCount = (base: string, count: number) =>
|
||||
t(pluralKey(base, count, locale), { count });
|
||||
return { t, tCount };
|
||||
}
|
||||
|
||||
const BP: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "done",
|
||||
count: 142,
|
||||
};
|
||||
const SLEEP: CoachStep = {
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
};
|
||||
const LABS_RUNNING: CoachStep = {
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Lab results, last 12 months",
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("describeStep", () => {
|
||||
const { t, tCount } = translators("en");
|
||||
|
||||
it("reads domain · window · count on a found step", () => {
|
||||
expect(describeStep(BP, t, tCount)).toEqual({
|
||||
title: "Blood pressure",
|
||||
meta: ["last 90 days", "142 readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says why nothing was found on an empty step", () => {
|
||||
expect(describeStep(SLEEP, t, tCount)).toEqual({
|
||||
title: "Sleep",
|
||||
meta: ["last 30 days", "no readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
|
||||
const outside: CoachStep = {
|
||||
...SLEEP,
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
};
|
||||
expect(describeStep(outside, t, tCount).meta).toEqual([
|
||||
"last 30 days",
|
||||
"no readings in this window",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts metrics on the snapshot step and rows on labs", () => {
|
||||
const snapshot: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 1,
|
||||
};
|
||||
expect(describeStep(snapshot, t, tCount)).toEqual({
|
||||
title: "Record summary",
|
||||
meta: ["1 metric"],
|
||||
});
|
||||
expect(
|
||||
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
|
||||
.meta,
|
||||
).toEqual(["last 12 months", "3 rows"]);
|
||||
});
|
||||
|
||||
it("renders in the reader's language from the keys, not the server label", () => {
|
||||
const de = translators("de");
|
||||
expect(stepLabel(BP, de.t)).toBe("Prüfe: Blutdruck, letzte 90 Tage");
|
||||
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
|
||||
"letzte 90 Tage",
|
||||
"142 Messwerte",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the server label for a key the bundle lacks", () => {
|
||||
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
|
||||
BP.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentStep / nextAnnouncement", () => {
|
||||
it("names the latest running step, else the latest step", () => {
|
||||
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
|
||||
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
|
||||
expect(currentStep([])).toBeNull();
|
||||
});
|
||||
|
||||
it("announces only settled steps not yet told, the newest one", () => {
|
||||
expect(nextAnnouncement([BP, SLEEP, LABS_RUNNING], new Set())?.id).toBe(
|
||||
"s2",
|
||||
);
|
||||
expect(nextAnnouncement([BP, SLEEP], new Set(["s1", "s2"]))).toBeNull();
|
||||
expect(nextAnnouncement([LABS_RUNNING], new Set())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnSteps>", () => {
|
||||
it("renders nothing without steps", () => {
|
||||
expect(render(<CoachTurnSteps steps={[]} active />)).not.toContain(
|
||||
"coach-turn-steps",
|
||||
);
|
||||
});
|
||||
|
||||
it("while active: the running step in the header, with a stable accessible name", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-active"');
|
||||
expect(html).toContain("Checking: Lab results, last 12 months…");
|
||||
expect(html).toContain('aria-label="Show what the Coach looked at"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
// The shimmer comes from the class that stops under reduced motion.
|
||||
expect(html).toContain("skeleton-shimmer");
|
||||
// Collapsed by default.
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("when done: folds into a count of sources, closed", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, SLEEP]} active={false} />);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
expect(html).not.toContain("skeleton-shimmer");
|
||||
expect(html).not.toContain("aria-label=");
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("counts distinct sources: a second read of the same domain is not a new source", () => {
|
||||
const bpPrevious: CoachStep = {
|
||||
...BP,
|
||||
id: "s4",
|
||||
period: "previous",
|
||||
count: 120,
|
||||
};
|
||||
const snapshot: CoachStep = {
|
||||
id: "s5",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Overview",
|
||||
status: "done",
|
||||
};
|
||||
expect(countSources([BP, bpPrevious, SLEEP])).toBe(2);
|
||||
expect(countSources([BP, snapshot, snapshot])).toBe(2);
|
||||
const html = render(
|
||||
<CoachTurnSteps steps={[BP, bpPrevious, SLEEP]} active={false} />,
|
||||
);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP]} active={false} />);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses theme tokens only, and every motion stops under reduced motion", () => {
|
||||
const html =
|
||||
render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} 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}`);
|
||||
}
|
||||
}
|
||||
// The spinner stops under reduced motion (the repo-wide convention).
|
||||
expect(html).toContain("animate-spin motion-reduce:animate-none");
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
"",
|
||||
);
|
||||
// Strip tags until nothing changes, so no fragment can survive one pass.
|
||||
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");
|
||||
});
|
||||
|
||||
it("a reloaded list shows the same rows", () => {
|
||||
const live = render(<CoachTurnStepList steps={[BP, SLEEP]} active />);
|
||||
const stored = render(
|
||||
<CoachTurnStepList
|
||||
steps={JSON.parse(JSON.stringify([BP, SLEEP]))}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
const text = (h: string) => h.replace(/<svg[\s\S]*?<\/svg>/g, "");
|
||||
expect(text(stored)).toBe(text(live));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the open list: method and the tables the answer read", () => {
|
||||
const METHOD = {
|
||||
entries: [],
|
||||
text: "Blood pressure, last 30 days, 9 readings, daily averages.",
|
||||
};
|
||||
|
||||
it("ends with the method line and the data-used slot, after the steps", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepsPanel
|
||||
steps={[BP]}
|
||||
active={false}
|
||||
areaLabels={[]}
|
||||
method={METHOD}
|
||||
dataUsed={<div data-slot="coach-data-used">tables</div>}
|
||||
/>,
|
||||
);
|
||||
const steps = html.indexOf('data-slot="coach-turn-steps-list"');
|
||||
const method = html.indexOf('data-slot="coach-method-line"');
|
||||
const dataUsed = html.indexOf('data-slot="coach-data-used"');
|
||||
expect(steps).toBeGreaterThan(-1);
|
||||
expect(method).toBeGreaterThan(steps);
|
||||
expect(dataUsed).toBeGreaterThan(method);
|
||||
expect(html).toContain(METHOD.text);
|
||||
});
|
||||
|
||||
it("holds the method back while the turn runs", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepsPanel
|
||||
steps={[LABS_RUNNING]}
|
||||
active
|
||||
areaLabels={[]}
|
||||
method={METHOD}
|
||||
dataUsed={<div data-slot="coach-data-used" />}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("coach-method-line");
|
||||
expect(html).not.toContain("coach-data-used");
|
||||
});
|
||||
|
||||
it("lists an older message's areas, without counts", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepsPanel
|
||||
steps={[]}
|
||||
active={false}
|
||||
areaLabels={["Blood pressure", "Sleep"]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-turn-areas"');
|
||||
expect((html.match(/data-slot="coach-turn-area"/g) ?? []).length).toBe(2);
|
||||
expect(html).not.toMatch(/\d+ readings/);
|
||||
});
|
||||
|
||||
it("shows a header for an answer that has only a method", () => {
|
||||
const html = render(
|
||||
<CoachTurnSteps steps={[]} active={false} method={METHOD} />,
|
||||
);
|
||||
expect(html).toContain("What the Coach looked at");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacyAreaLabels", () => {
|
||||
const { t } = translators("en");
|
||||
|
||||
it("names the known areas and drops unknown tokens and general", () => {
|
||||
expect(
|
||||
legacyAreaLabels(
|
||||
["bp", "bloodPressure" as never, "general", "sleep", "bp"],
|
||||
t,
|
||||
),
|
||||
).toEqual(["Blood pressure", "Sleep"]);
|
||||
});
|
||||
|
||||
it("folds an older message into a count of areas", () => {
|
||||
const html = render(
|
||||
<CoachTurnSteps steps={[]} active={false} areas={["bp", "sleep"]} />,
|
||||
);
|
||||
expect(html).toContain("Looked at 2 areas");
|
||||
expect(
|
||||
render(<CoachTurnSteps steps={[]} active={false} areas={["bp"]} />, "de"),
|
||||
).toContain("1 Bereich angesehen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a step row copied out of the list", () => {
|
||||
it("reads 'domain · window' without the screen reader's comma", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepList
|
||||
steps={[{ ...BP, domain: "weight", count: undefined }]}
|
||||
active={false}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
// What a selection copies: every text node except the unselectable ones.
|
||||
// Reads this test's own static markup; not a sanitiser for input.
|
||||
const copied = html
|
||||
.split(/<span[^>]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/)
|
||||
.join("")
|
||||
.split(/<[^>]*>/)
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
expect(copied).toBe("Gewicht · letzte 90 Tage");
|
||||
// The comma is still there for a screen reader.
|
||||
expect(html).toMatch(/class="sr-only select-none">, <\/span>/);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,13 @@ function streaming(
|
||||
results: [],
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
interimRefs: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — what an answer assumed instead of asking, in one quiet line under
|
||||
* it: "Assumed: last 30 days. Change".
|
||||
*
|
||||
* Meta text, the value in the foreground. "Change" is a text button of the
|
||||
* same size, offered only under the latest answer and only when the server
|
||||
* sent alternatives for that assumption (`change_assumption` follow-ups).
|
||||
* A tap shows them as reply pills right below; a pill sends its label as
|
||||
* the person's message and the server answers from the alternative, without
|
||||
* asking again. Nothing opens by itself.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_ASSUMPTION_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { SuggestedReplies } from "./suggested-replies";
|
||||
|
||||
/** The alternatives the server offers for one assumption. */
|
||||
export function alternativesFor(
|
||||
assumption: CoachAssumption,
|
||||
followUps: readonly CoachFollowUp[],
|
||||
): CoachFollowUp[] {
|
||||
return followUps.filter(
|
||||
(chip) =>
|
||||
chip.kind === "change_assumption" &&
|
||||
chip.assumption?.kind === assumption.kind,
|
||||
);
|
||||
}
|
||||
|
||||
/** The catalog value in the reader's language, else the server's. */
|
||||
function optionLabel(
|
||||
t: (key: string) => string,
|
||||
option: CoachAssumption["value"],
|
||||
): string {
|
||||
const local = t(option.labelKey);
|
||||
return local === option.labelKey ? option.label : local;
|
||||
}
|
||||
|
||||
export interface CoachAssumptionLineProps {
|
||||
assumptions: CoachAssumption[];
|
||||
/** The latest answer's `change_assumption` chips; empty elsewhere. */
|
||||
changes: CoachFollowUp[];
|
||||
messageId: string | null;
|
||||
/** True while a turn is in flight. */
|
||||
disabled: boolean;
|
||||
onChange?: (followUp: CoachFollowUp, messageId: string) => void;
|
||||
}
|
||||
|
||||
export function CoachAssumptionLine({
|
||||
assumptions,
|
||||
changes,
|
||||
messageId,
|
||||
disabled,
|
||||
onChange,
|
||||
}: CoachAssumptionLineProps) {
|
||||
const { t } = useTranslations();
|
||||
const [openKind, setOpenKind] = useState<string | null>(null);
|
||||
const baseId = useId();
|
||||
if (assumptions.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-assumptions"
|
||||
className="flex w-full min-w-0 flex-col gap-2"
|
||||
>
|
||||
{assumptions.slice(0, 2).map((assumption) => {
|
||||
const alternatives =
|
||||
onChange && messageId && !disabled
|
||||
? alternativesFor(assumption, changes)
|
||||
: [];
|
||||
const open = openKind === assumption.kind && alternatives.length > 0;
|
||||
const panelId = `${baseId}-${assumption.kind}`;
|
||||
return (
|
||||
<div key={assumption.kind} className="flex flex-col gap-2">
|
||||
<p
|
||||
data-slot="coach-assumption-line"
|
||||
data-kind={assumption.kind}
|
||||
className="text-muted-foreground max-w-full text-xs leading-relaxed"
|
||||
>
|
||||
{t(COACH_ASSUMPTION_KEYS.line, {
|
||||
value: optionLabel(t, assumption.value),
|
||||
})}
|
||||
.{" "}
|
||||
{alternatives.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
data-slot="coach-assumption-change"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
onClick={() => setOpenKind(open ? null : assumption.kind)}
|
||||
// Inline in the meta line; the hit area reaches past the text (a
|
||||
// 44 px target on phones) without making the line any taller.
|
||||
className="text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5"
|
||||
>
|
||||
{t(COACH_ASSUMPTION_KEYS.change)}
|
||||
</Button>
|
||||
) : null}
|
||||
</p>
|
||||
{open && messageId && onChange ? (
|
||||
<div id={panelId}>
|
||||
{SuggestedReplies({
|
||||
slot: "coach-assumption-alternatives",
|
||||
messageId,
|
||||
disabled,
|
||||
max: 3,
|
||||
replies: alternatives.map((chip) => ({
|
||||
id: chip.id,
|
||||
label: chip.label,
|
||||
kind: "followUp",
|
||||
data: { "data-follow-up-id": chip.id },
|
||||
onSelect: () => onChange(chip, messageId),
|
||||
})),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,22 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
import { SuggestedActionCard } from "./suggested-action-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
import { CoachTurnSteps } from "./turn-steps";
|
||||
import { CoachResults, countResultsInSection } from "./coach-results";
|
||||
import { CoachTurnActivity } from "./turn-activity";
|
||||
import { CoachInterimResults } from "./interim-results";
|
||||
import {
|
||||
CoachResults,
|
||||
countResultsInSection,
|
||||
liveWithProvenance,
|
||||
} from "./coach-results";
|
||||
import { CoachFollowUpChips } from "./follow-up-chips";
|
||||
import { SuggestedReplies, type SuggestedReply } from "./suggested-replies";
|
||||
import { CoachMemoryNote } from "./memory-note";
|
||||
import { CoachAssumptionLine } from "./assumption-line";
|
||||
import { AssistantMessageActions, UserMessageActions } from "./message-actions";
|
||||
import type {
|
||||
CoachActivity,
|
||||
CoachFollowUp,
|
||||
CoachMemoryNote as CoachMemoryNoteFrame,
|
||||
CoachProvenanceMetric,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
@@ -316,6 +326,30 @@ interface ChatBubbleProps {
|
||||
/** True while another turn is in flight; the chips step aside. */
|
||||
followUpsDisabled?: boolean;
|
||||
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
|
||||
/**
|
||||
* v1.41 — the reply pills the latest answer offers instead of its chips:
|
||||
* a clarifying question's choices, or the two answers to a fact or plan
|
||||
* proposal. Built by the thread; one array per offer.
|
||||
*/
|
||||
replies?: ReplyOffer | null;
|
||||
/**
|
||||
* v1.41 — the live trail from the streaming hook. Persisted messages carry
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** v1.41 — the reply pills one assistant message offers. */
|
||||
export interface ReplyOffer {
|
||||
messageId: string;
|
||||
replies: SuggestedReply[];
|
||||
}
|
||||
|
||||
/** The chips one assistant message offers. */
|
||||
@@ -380,6 +414,12 @@ export function areChatBubblePropsEqual(
|
||||
sameFollowUpOffer(prev.followUps, next.followUps) &&
|
||||
prev.followUpsDisabled === next.followUpsDisabled &&
|
||||
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)
|
||||
);
|
||||
@@ -407,6 +447,12 @@ function ChatBubbleImpl({
|
||||
followUps,
|
||||
followUpsDisabled,
|
||||
onFollowUp,
|
||||
replies,
|
||||
activity,
|
||||
interimRefs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
memoryNote,
|
||||
}: ChatBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -543,7 +589,9 @@ function ChatBubbleImpl({
|
||||
// grounded token is present (provider-agnostic — reads the inline token).
|
||||
// v1.39.4 — the result tables this answer references and the ones it
|
||||
// only used; the metadata is enough to place and count them.
|
||||
const resultMetas = results?.length ? results : (metricSource?.results ?? []);
|
||||
const resultMetas = results?.length
|
||||
? liveWithProvenance(results, metricSource?.results ?? [])
|
||||
: (metricSource?.results ?? []);
|
||||
const shownDomains = new Set(
|
||||
resultMetas
|
||||
.filter((meta) => meta.displayed)
|
||||
@@ -558,6 +606,7 @@ function ChatBubbleImpl({
|
||||
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
|
||||
: [];
|
||||
const settled = !inProgress && !errorCode && providerType !== "refusal";
|
||||
const memoryMeta = memoryNote ?? metricSource?.memoryNote ?? null;
|
||||
|
||||
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
|
||||
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
|
||||
@@ -596,15 +645,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)]"
|
||||
>
|
||||
{/* What the Coach read on this turn, live from the step frames and
|
||||
restored from the provenance on reload; an older message lists
|
||||
the areas it drew on. Opened, it also says how the answer was
|
||||
worked out and holds the tables the answer read. */}
|
||||
<CoachTurnSteps
|
||||
{/* 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. */}
|
||||
<CoachTurnActivity
|
||||
activity={activity ?? metricSource?.activity ?? []}
|
||||
steps={steps ?? metricSource?.steps ?? []}
|
||||
active={!!inProgress}
|
||||
startedAt={startedAt}
|
||||
endedAt={endedAt}
|
||||
areas={metricSource?.metrics}
|
||||
method={inProgress || errorCode ? null : method}
|
||||
conversationId={conversationId}
|
||||
messageId={messageId}
|
||||
dataUsed={
|
||||
dataUsedCount > 0 ? (
|
||||
<CoachResults
|
||||
@@ -617,22 +673,22 @@ function ChatBubbleImpl({
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
|
||||
(three pulsing dots) while it is still thinking with no prose
|
||||
yet, restoring the writing/typing indicator the maintainer
|
||||
prefers over the "Denke nach / Nachgedacht" reasoning disclosure.
|
||||
The dots render inside the prose bubble below; the disclosure is
|
||||
retired. Persisted bubbles never stream, so history stays settled. */}
|
||||
{/* The prose bubble. While the live turn is still thinking with no
|
||||
prose the bubble carries the typing animation; once tokens land
|
||||
it swaps to the streamed prose without a layout jump. */}
|
||||
{(content || safeError || inProgress) && (
|
||||
{/* 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. */}
|
||||
{(content || safeError) && (
|
||||
<div
|
||||
data-slot="coach-answer-bubble"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground max-w-full",
|
||||
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed break-words whitespace-pre-wrap",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300",
|
||||
)}
|
||||
>
|
||||
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
|
||||
@@ -644,8 +700,6 @@ function ChatBubbleImpl({
|
||||
keeps the bubble and the error stays a caption. */}
|
||||
{content ? (
|
||||
<StreamedProse content={content} streaming={!!streaming} />
|
||||
) : inProgress ? (
|
||||
<TypingDots label={t("insights.coach.thinking")} />
|
||||
) : safeError ? (
|
||||
<span className="text-warning">{safeError}</span>
|
||||
) : (
|
||||
@@ -705,6 +759,23 @@ function ChatBubbleImpl({
|
||||
section="displayed"
|
||||
/>
|
||||
)}
|
||||
{/* v1.41 — what the answer assumed instead of asking, and what the
|
||||
Coach kept from this turn: one quiet line each. */}
|
||||
{settled && (metricSource?.assumptions?.length ?? 0) > 0 ? (
|
||||
<CoachAssumptionLine
|
||||
assumptions={metricSource?.assumptions ?? []}
|
||||
changes={followUps?.followUps ?? []}
|
||||
messageId={followUps?.messageId ?? null}
|
||||
disabled={!!followUpsDisabled}
|
||||
onChange={onFollowUp}
|
||||
/>
|
||||
) : null}
|
||||
{settled && memoryMeta ? (
|
||||
<CoachMemoryNote
|
||||
note={memoryMeta}
|
||||
liveFact={memoryNote?.fact ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{/* v1.18.6 — a "no provider configured anywhere" turn is a
|
||||
setup gap, not a transient failure: surface a direct link to
|
||||
Settings → AI so the Coach guides the user into BYOK / local
|
||||
@@ -753,10 +824,20 @@ function ChatBubbleImpl({
|
||||
model={usage?.model ?? model}
|
||||
/>
|
||||
)}
|
||||
{/* The follow-up chips of the latest answer, in its own column. */}
|
||||
{followUps && onFollowUp ? (
|
||||
{/* The replies the latest answer offers, in its own column: a
|
||||
question's choices or a proposal's two answers when it has them,
|
||||
else its follow-up chips. One pattern for all of them. */}
|
||||
{replies && replies.replies.length > 0 ? (
|
||||
<SuggestedReplies
|
||||
replies={replies.replies}
|
||||
messageId={replies.messageId}
|
||||
disabled={!!followUpsDisabled}
|
||||
/>
|
||||
) : followUps && onFollowUp ? (
|
||||
<CoachFollowUpChips
|
||||
followUps={followUps.followUps}
|
||||
followUps={followUps.followUps.filter(
|
||||
(chip) => chip.kind !== "change_assumption",
|
||||
)}
|
||||
messageId={followUps.messageId}
|
||||
disabled={!!followUpsDisabled}
|
||||
onSelect={onFollowUp}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.39.4 — the choices for a clarifying question, above the composer.
|
||||
* The question itself is the assistant reply in the thread, so the card
|
||||
* does not repeat it: a short heading, the choices, and a meta line saying a
|
||||
* typed answer works too. A tap on a choice sends its label with
|
||||
* `clarification: { messageId, choiceId }`; a typed answer goes out with
|
||||
* `clarification: { messageId }`.
|
||||
*
|
||||
* Announced politely when it appears, through the live region the
|
||||
* conversation keeps mounted beside it (a region that arrives with its
|
||||
* content is never heard), and never takes focus on its own: the person may
|
||||
* already be typing. A tapped choice hands focus to the composer.
|
||||
*/
|
||||
import { MessageCircleQuestionMark } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachClarificationChoice,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
|
||||
export interface CoachClarificationCardProps {
|
||||
clarification: CoachClarification;
|
||||
/** The assistant message that asked. */
|
||||
messageId: string;
|
||||
/** True while a turn is in flight. */
|
||||
disabled: boolean;
|
||||
onChoose: (choice: CoachClarificationChoice) => void;
|
||||
}
|
||||
|
||||
export function CoachClarificationCard({
|
||||
clarification,
|
||||
messageId,
|
||||
disabled,
|
||||
onChoose,
|
||||
}: CoachClarificationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
// The catalog string in the reader's locale when the bundle has it, the
|
||||
// server's rendering when it does not.
|
||||
const choiceLabel = (choice: CoachClarificationChoice) => {
|
||||
const local = t(choice.labelKey);
|
||||
return local === choice.labelKey ? choice.label : local;
|
||||
};
|
||||
const { choices } = clarification;
|
||||
const hint =
|
||||
choices.length === 0
|
||||
? t(COACH_CLARIFY_UI_KEYS.contextHint)
|
||||
: clarification.freeText
|
||||
? t(COACH_CLARIFY_UI_KEYS.freeTextHint)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="coach-clarification-card"
|
||||
data-message-id={messageId}
|
||||
aria-label={t(COACH_CLARIFY_UI_KEYS.cardLabel)}
|
||||
role="region"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 md:px-6">
|
||||
<MessageCircleQuestionMark
|
||||
aria-hidden="true"
|
||||
className="text-foreground size-4 shrink-0"
|
||||
/>
|
||||
<p className="text-sm font-medium">
|
||||
{t(COACH_CLARIFY_UI_KEYS.cardLabel)}
|
||||
</p>
|
||||
</div>
|
||||
{choices.length > 0 ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(COACH_CLARIFY_UI_KEYS.choicesLabel)}
|
||||
className="flex flex-wrap gap-2 px-4 md:px-6"
|
||||
>
|
||||
{choices.map((choice) => (
|
||||
<Button
|
||||
key={choice.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled}
|
||||
data-choice-id={choice.id}
|
||||
onClick={() => {
|
||||
onChoose(choice);
|
||||
focusCoachComposer();
|
||||
}}
|
||||
>
|
||||
{choiceLabel(choice)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<p className="text-muted-foreground px-4 text-xs md:px-6">{hint}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,14 +14,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
CoachClarificationChoice,
|
||||
CoachFollowUp,
|
||||
CoachScope,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
|
||||
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
@@ -51,9 +46,9 @@ import { HistoryRail } from "./history-rail";
|
||||
import {
|
||||
MessageThread,
|
||||
latestClarification,
|
||||
type CoachReplyIntent,
|
||||
type InterleavedThreadItem,
|
||||
} from "./message-thread";
|
||||
import { CoachClarificationCard } from "./clarification-card";
|
||||
import { CoachSettingsOverlay } from "./coach-settings-overlay";
|
||||
import { CoachTopBarTrail } from "./coach-top-bar-trail";
|
||||
import { ConversationsPanel } from "./conversations-panel";
|
||||
@@ -662,18 +657,50 @@ export function CoachConversation({
|
||||
});
|
||||
}
|
||||
|
||||
// v1.39.4 — a choice on the clarification card.
|
||||
function handleClarificationChoice(choice: CoachClarificationChoice) {
|
||||
if (send.isStreaming || !openClarification) return;
|
||||
if (currentConversationId === null) return;
|
||||
void send.send({
|
||||
// v1.41 — a reply pill under the latest answer: a clarifying question's
|
||||
// choice, or an answer to a fact or plan proposal. Its label goes out as
|
||||
// the person's own message; the request says which offer it answers, and
|
||||
// the server resolves the rest from what it stored on that message.
|
||||
function handleReply(intent: CoachReplyIntent) {
|
||||
if (send.isStreaming || fenced || currentConversationId === null) return;
|
||||
const sent = send.send({
|
||||
conversationId: currentConversationId,
|
||||
message: choice.label,
|
||||
clarification: {
|
||||
messageId: openClarification.messageId,
|
||||
choiceId: choice.id,
|
||||
},
|
||||
message: intent.label,
|
||||
...(intent.kind === "clarification"
|
||||
? {
|
||||
clarification: {
|
||||
messageId: intent.messageId,
|
||||
choiceId: intent.choice.id,
|
||||
},
|
||||
}
|
||||
: intent.kind === "memory"
|
||||
? {
|
||||
memoryDecision: {
|
||||
messageId: intent.messageId,
|
||||
proposalId: intent.proposalId,
|
||||
accept: intent.accept,
|
||||
},
|
||||
}
|
||||
: {
|
||||
planDecision: {
|
||||
messageId: intent.messageId,
|
||||
planId: intent.planId,
|
||||
accept: intent.accept,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// A decision changes what the Coach remembers or which plans run; the
|
||||
// lists elsewhere read it fresh once the turn is through.
|
||||
if (intent.kind !== "clarification") {
|
||||
void sent.then(() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
intent.kind === "memory"
|
||||
? queryKeys.coachFacts()
|
||||
: queryKeys.coachPlansAll(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(value: string) {
|
||||
@@ -908,25 +935,6 @@ export function CoachConversation({
|
||||
{t("insights.coach.attach.indexingHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* v1.39.4 — mounted empty before any question, so the question is
|
||||
announced when it arrives (a live region that mounts with its
|
||||
content is not heard). Visually hidden, so it takes no room in the
|
||||
stack; the card below carries the choices. */}
|
||||
<div
|
||||
data-slot="coach-clarification-live"
|
||||
aria-live="polite"
|
||||
className="sr-only"
|
||||
>
|
||||
{openClarification ? t(COACH_CLARIFY_UI_KEYS.cardLabel) : ""}
|
||||
</div>
|
||||
{openClarification ? (
|
||||
<CoachClarificationCard
|
||||
clarification={openClarification.clarification}
|
||||
messageId={openClarification.messageId}
|
||||
disabled={send.isStreaming}
|
||||
onChoose={handleClarificationChoice}
|
||||
/>
|
||||
) : null}
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
@@ -1221,6 +1229,7 @@ export function CoachConversation({
|
||||
interleaved={interleaved}
|
||||
onRegenerate={handleRegenerate}
|
||||
onFollowUp={handleFollowUp}
|
||||
onReply={handleReply}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -1329,6 +1338,7 @@ export function CoachConversation({
|
||||
interleaved={interleaved}
|
||||
onRegenerate={handleRegenerate}
|
||||
onFollowUp={handleFollowUp}
|
||||
onReply={handleReply}
|
||||
/>
|
||||
}
|
||||
composer={composerStack}
|
||||
|
||||
@@ -372,7 +372,7 @@ export function CoachInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
|
||||
// Read-only rather than disabled while a reply streams: a disabled
|
||||
// field drops focus, and a follow-up chip or a clarification choice
|
||||
// field drops focus, and a tapped reply pill
|
||||
// hands focus here right as the turn starts.
|
||||
readOnly={disabled}
|
||||
aria-disabled={disabled || undefined}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
|
||||
import {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
LOCAL_MODEL_PRESETS,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
uiToLegacyProviderEnum,
|
||||
type ProviderChainData,
|
||||
type ProviderType,
|
||||
type ReasoningEffort,
|
||||
type UserAIProvider,
|
||||
} from "@/components/settings/ai/shared";
|
||||
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
|
||||
@@ -29,7 +27,9 @@ import {
|
||||
|
||||
/**
|
||||
* The quick model choice in the Coach's settings overlay: which provider
|
||||
* answers, which model it uses, and how hard a reasoning model thinks.
|
||||
* answers and which model it uses. How hard the Coach thinks is the Coach's
|
||||
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
|
||||
* reasoning select stays in Settings → AI for everything else.
|
||||
*
|
||||
* It is a shortcut onto the same settings Settings → AI writes, never a second
|
||||
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
|
||||
* to the front and every other entry in its current order. The entries'
|
||||
* reasoning settings are not sent, so the server keeps what it stored.
|
||||
* - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
|
||||
* - Model: `PATCH /api/user/ai-provider`, only where a person
|
||||
* owns the setting (their own key's provider, or their gateway). The
|
||||
* operator's shared provider and Codex show their state read-only.
|
||||
* Every write invalidates the AI input keys, so the capability on
|
||||
@@ -127,29 +127,6 @@ export function modelControlFor(
|
||||
return { kind: "managed" };
|
||||
}
|
||||
|
||||
/** The reasoning setting the active provider carries, if any. */
|
||||
export function reasoningFieldFor(
|
||||
active: ProviderType | null,
|
||||
userProvider: UserAIProvider | null | undefined,
|
||||
): {
|
||||
field: "localReasoningEffort" | "compatReasoningEffort";
|
||||
value: ReasoningEffort | null;
|
||||
} | null {
|
||||
if (active === "local") {
|
||||
return {
|
||||
field: "localReasoningEffort",
|
||||
value: userProvider?.localReasoningEffort ?? null,
|
||||
};
|
||||
}
|
||||
if (active === "openai-compatible") {
|
||||
return {
|
||||
field: "compatReasoningEffort",
|
||||
value: userProvider?.compatReasoningEffort ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CoachModelPicker() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -212,7 +189,6 @@ export function CoachModelPicker() {
|
||||
const active = chain?.activeProvider ?? null;
|
||||
const options = chain ? selectableProviders(chain.configuredChain) : [];
|
||||
const model = modelControlFor(active, userProvider);
|
||||
const reasoning = reasoningFieldFor(active, userProvider);
|
||||
const busy = switchProvider.isPending || patchProvider.isPending;
|
||||
// The server may answer with `admin-codex`, which the UI's type omits.
|
||||
const unlisted = activeOutsideOptions(active as string | null, options);
|
||||
@@ -339,16 +315,6 @@ export function CoachModelPicker() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reasoning ? (
|
||||
<ReasoningEffortField
|
||||
id="coach-quick-reasoning"
|
||||
noColon
|
||||
value={reasoning.value}
|
||||
disabled={busy}
|
||||
onChange={(next) => patchProvider.mutate({ [reasoning.field]: next })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{busy ? (
|
||||
<p role="status" className="text-muted-foreground text-xs">
|
||||
{t("insights.coach.frame.saving")}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — how hard the Coach thinks before it answers: one select, Off / Low
|
||||
* / Medium / High, Medium by default.
|
||||
*
|
||||
* The person's own choice lives in `coachPrefsJson.reasoning`. What the
|
||||
* server resolved from it rides `/api/auth/me` as `coachReasoning`: whether
|
||||
* the operator allows reasoning at all, the highest level they allow, and
|
||||
* whether "off" is really off on the active provider. The field renders from
|
||||
* that and never recomputes it:
|
||||
* - the operator switched reasoning off: the select is locked and one
|
||||
* sentence says so;
|
||||
* - a level above the operator's cap is offered but disabled, with
|
||||
* "(limited by the admin)";
|
||||
* - where the provider cannot switch thinking off, "Off" reads "Minimal".
|
||||
* A server older than v1.41 sends no block; the field then reads as allowed,
|
||||
* uncapped and really off.
|
||||
*
|
||||
* Two homes, one field: the Coach's quick settings (saves on change) and
|
||||
* Settings → Coach (part of that card's draft and save).
|
||||
*/
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachReasoning } from "@/hooks/use-ai-capability";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
REASONING_LEVELS,
|
||||
REASONING_LEVEL_LABEL_KEYS,
|
||||
REASONING_SETTING_KEYS,
|
||||
isReasoningLevel,
|
||||
type ReasoningLevel,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
|
||||
import { coachReasoningLevel } from "@/lib/validations/coach-prefs";
|
||||
|
||||
export type { CoachReasoningState };
|
||||
|
||||
/**
|
||||
* The block as the field reads it: the server's answer, or for a server that
|
||||
* does not publish one (older than v1.41) "allowed, uncapped, really off" at
|
||||
* the default level.
|
||||
*/
|
||||
export function reasoningStateOrDefault(
|
||||
state: CoachReasoningState | null | undefined,
|
||||
): CoachReasoningState {
|
||||
return (
|
||||
state ?? {
|
||||
level: DEFAULT_REASONING_LEVEL,
|
||||
preference: DEFAULT_REASONING_LEVEL,
|
||||
maxLevel: "high",
|
||||
available: true,
|
||||
offIsReal: true,
|
||||
source: "user",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the operator switched reasoning off: the field is locked. */
|
||||
export function reasoningLocked(state: CoachReasoningState): boolean {
|
||||
return state.maxLevel === "off" || state.source === "admin_off";
|
||||
}
|
||||
|
||||
export interface ReasoningOption {
|
||||
value: ReasoningLevel;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** The four options in order, labelled and capped for this account. */
|
||||
export function reasoningOptions(
|
||||
state: CoachReasoningState,
|
||||
t: (key: string) => string,
|
||||
): ReasoningOption[] {
|
||||
const locked = reasoningLocked(state);
|
||||
const cap = REASONING_LEVELS.indexOf(state.maxLevel);
|
||||
return REASONING_LEVELS.map((value, index) => {
|
||||
const base = t(
|
||||
value === "off" && !state.offIsReal
|
||||
? REASONING_LEVEL_LABEL_KEYS.minimal
|
||||
: REASONING_LEVEL_LABEL_KEYS[value],
|
||||
);
|
||||
// Above the cap is "(limited by the admin)"; while reasoning is off the
|
||||
// one sentence under the field says it, not every option.
|
||||
const capped = !locked && index > cap;
|
||||
return {
|
||||
value,
|
||||
label: capped ? `${base} ${t(REASONING_SETTING_KEYS.capped)}` : base,
|
||||
disabled: capped,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The level the select shows: the person's choice, lowered to the cap when
|
||||
* it is above it (that is what runs), and the resolved level while the
|
||||
* operator has reasoning off.
|
||||
*/
|
||||
export function shownReasoningLevel(
|
||||
choice: ReasoningLevel,
|
||||
state: CoachReasoningState,
|
||||
): ReasoningLevel {
|
||||
if (reasoningLocked(state)) return state.level;
|
||||
return REASONING_LEVELS.indexOf(choice) >
|
||||
REASONING_LEVELS.indexOf(state.maxLevel)
|
||||
? state.maxLevel
|
||||
: choice;
|
||||
}
|
||||
|
||||
export interface CoachReasoningSelectProps {
|
||||
id: string;
|
||||
value: ReasoningLevel;
|
||||
state: CoachReasoningState;
|
||||
/** Locks the select while a write is in flight. */
|
||||
busy?: boolean;
|
||||
onChange: (next: ReasoningLevel) => void;
|
||||
}
|
||||
|
||||
/** The field itself, without data: what the tests render. */
|
||||
export function CoachReasoningSelect({
|
||||
id,
|
||||
value,
|
||||
state,
|
||||
busy = false,
|
||||
onChange,
|
||||
}: CoachReasoningSelectProps) {
|
||||
const { t } = useTranslations();
|
||||
const options = reasoningOptions(state, t);
|
||||
const locked = reasoningLocked(state);
|
||||
return (
|
||||
<div data-slot="coach-reasoning-field">
|
||||
<Label htmlFor={id} noColon>
|
||||
{t(REASONING_SETTING_KEYS.label)}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
data-slot="coach-reasoning-select"
|
||||
data-locked={locked ? "true" : undefined}
|
||||
className="mt-1"
|
||||
value={shownReasoningLevel(value, state)}
|
||||
disabled={locked || busy}
|
||||
aria-describedby={`${id}-hint`}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (isReasoningLevel(next) && next !== value) onChange(next);
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
|
||||
{t(
|
||||
locked
|
||||
? REASONING_SETTING_KEYS.disabled
|
||||
: REASONING_SETTING_KEYS.hint,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The quick-settings field: reads the saved choice and saves on change. */
|
||||
export function CoachReasoningField({ id }: { id: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const reasoning = useCoachReasoning();
|
||||
const prefs = useCoachPrefs();
|
||||
const save = useSaveCoachPrefs({
|
||||
// The resolved block on `/me` follows the saved choice.
|
||||
onSuccess: () =>
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }),
|
||||
});
|
||||
if (!prefs.data) return null;
|
||||
const current = prefs.data;
|
||||
const state = reasoningStateOrDefault(reasoning);
|
||||
return (
|
||||
<CoachReasoningSelect
|
||||
id={id}
|
||||
value={coachReasoningLevel(current)}
|
||||
state={state}
|
||||
busy={save.isPending}
|
||||
onChange={(reasoning) => save.mutate({ ...current, reasoning })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,32 @@ function useReusedLine(result: CoachResultMeta): string | null {
|
||||
: t(COACH_RESULT_UI_KEYS.reusedFromUndated);
|
||||
}
|
||||
|
||||
/** `displayed` per ref, from a message's result metadata. */
|
||||
function displayedByRef(
|
||||
metas: ReadonlyArray<Pick<CoachResultMeta, "ref" | "displayed">>,
|
||||
): Map<string, boolean> {
|
||||
return new Map(metas.map((meta) => [meta.ref, meta.displayed]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The live tables with `displayed` taken from the provenance where it names
|
||||
* them: an interim table is sent before the answer decides.
|
||||
*/
|
||||
export function liveWithProvenance<
|
||||
T extends Pick<CoachResultMeta, "ref" | "displayed">,
|
||||
>(
|
||||
live: readonly T[],
|
||||
metas: ReadonlyArray<Pick<CoachResultMeta, "ref" | "displayed">>,
|
||||
): T[] {
|
||||
const displayed = displayedByRef(metas);
|
||||
return live.map((table) => {
|
||||
const flag = displayed.get(table.ref);
|
||||
return flag === undefined || flag === table.displayed
|
||||
? table
|
||||
: { ...table, displayed: flag };
|
||||
});
|
||||
}
|
||||
|
||||
/** How many tables sit in a section, from the metadata alone. */
|
||||
export function countResultsInSection(
|
||||
metas: ReadonlyArray<Pick<CoachResultMeta, "displayed">>,
|
||||
@@ -147,8 +173,11 @@ function sectionItems(args: {
|
||||
const inSection = (displayed: boolean) =>
|
||||
args.section === "displayed" ? displayed : !displayed;
|
||||
if (args.live && args.live.length > 0) {
|
||||
// v1.41 — a table that arrived while the turn still ran learns whether
|
||||
// the answer points at it from the provenance, which comes later.
|
||||
const displayed = displayedByRef(args.metas);
|
||||
return args.live
|
||||
.filter((table) => inSection(table.displayed))
|
||||
.filter((table) => inSection(displayed.get(table.ref) ?? table.displayed))
|
||||
.map((table) => ({ ref: table.ref, table }));
|
||||
}
|
||||
if (!args.fetched) return [];
|
||||
@@ -174,7 +203,7 @@ export function CoachResults({
|
||||
const { t } = useTranslations();
|
||||
const hasLive = (live?.length ?? 0) > 0;
|
||||
const expected = hasLive
|
||||
? countResultsInSection(live ?? [], section)
|
||||
? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
|
||||
: countResultsInSection(metas, section);
|
||||
const { ref, results, isError, refetch } = useCoachMessageResults({
|
||||
conversationId,
|
||||
@@ -236,7 +265,7 @@ export function CoachResults({
|
||||
|
||||
type ResultView = "chart" | "table";
|
||||
|
||||
function CoachResultView({
|
||||
export function CoachResultView({
|
||||
result,
|
||||
chartFirst,
|
||||
}: {
|
||||
|
||||
@@ -2,27 +2,63 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { Brain, ChevronRight, Cpu } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { COACH_MEMORY_LIST_KEYS } from "@/lib/ai/coach/memory/shared";
|
||||
|
||||
import { CoachModelPicker } from "./coach-model-picker";
|
||||
import { CoachReasoningField } from "./coach-reasoning-field";
|
||||
import { useCoachFacts } from "./use-coach-facts";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
|
||||
/**
|
||||
* The content of the Coach's quick settings (see `coach-settings-overlay`):
|
||||
* the model choice, "What I can see" (the existing sources rail, wired to the
|
||||
* saved Coach preferences: data areas and how far back the Coach looks) and
|
||||
* a way on to the full AI settings.
|
||||
* the model choice and how hard the Coach thinks, a way to what it remembers,
|
||||
* "What I can see" (the existing sources rail, wired to the saved Coach
|
||||
* preferences: data areas and how far back the Coach looks) and a way on to
|
||||
* the full AI settings.
|
||||
*
|
||||
* `focusData` lands focus on the "What I can see" section when the body
|
||||
* mounts, for the `/coach?settings=data` deep link. It runs here rather than
|
||||
* in the overlay because the body loads on demand and may arrive after the
|
||||
* popover or sheet has already placed focus.
|
||||
* popover or sheet has already placed focus. The section draws no focus
|
||||
* ring: it is where the link lands, not a control, and a ring drawn on every
|
||||
* deep link reads as a highlight nobody asked for.
|
||||
*/
|
||||
export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
|
||||
|
||||
/** Where the memory list lives: Settings → Coach, its memory card. */
|
||||
export const COACH_MEMORY_HREF = "/settings/coach#coach-memory";
|
||||
|
||||
/** "Memory (8)": a link to the list, with how much it holds. */
|
||||
function CoachMemoryLink() {
|
||||
const { t } = useTranslations();
|
||||
const facts = useCoachFacts();
|
||||
const count = facts.data?.length ?? 0;
|
||||
return (
|
||||
<Link
|
||||
href={COACH_MEMORY_HREF}
|
||||
data-slot="coach-settings-memory-link"
|
||||
className="text-foreground hover:bg-muted focus-visible:ring-ring/50 -mx-2 flex min-h-11 items-center gap-2 rounded-md px-2 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
|
||||
>
|
||||
<Brain
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
{t(COACH_MEMORY_LIST_KEYS.link, { count })}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachSettingsBody({
|
||||
focusData = false,
|
||||
}: {
|
||||
@@ -53,6 +89,8 @@ export function CoachSettingsBody({
|
||||
{t("insights.coach.frame.modelSection")}
|
||||
</h3>
|
||||
<CoachModelPicker />
|
||||
<CoachReasoningField id="coach-quick-reasoning" />
|
||||
<CoachMemoryLink />
|
||||
</section>
|
||||
<section
|
||||
ref={dataRef}
|
||||
@@ -60,7 +98,7 @@ export function CoachSettingsBody({
|
||||
data-slot="coach-settings-data"
|
||||
tabIndex={-1}
|
||||
aria-label={t("insights.coach.sourcesTitle")}
|
||||
className="border-border focus-visible:ring-ring/50 border-t pt-4 outline-none focus-visible:ring-2"
|
||||
className="border-border border-t pt-4 outline-none"
|
||||
>
|
||||
<SourcesRail className="h-auto p-0" />
|
||||
</section>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* v1.39.4 — the Coach composer's id, and handing focus to it.
|
||||
*
|
||||
* A follow-up chip or a clarification choice sends a message and then goes
|
||||
* away (chips hide while the turn runs, choices disable), which would drop
|
||||
* keyboard and screen-reader focus to the page. Focus moves to the composer
|
||||
* A reply pill (a follow-up, a clarifying choice, a memory or plan
|
||||
* decision) sends a message and then goes away (pills hide while the turn
|
||||
* runs), which would drop keyboard and screen-reader focus to the page. Focus moves to the composer
|
||||
* instead: that is where the person continues. The composer stays focusable
|
||||
* while a reply streams (it is read-only then, not disabled).
|
||||
*/
|
||||
|
||||
@@ -2,24 +2,21 @@
|
||||
|
||||
/**
|
||||
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
|
||||
* They sit inside the answer's own column, on the bubble's left edge and at
|
||||
* the answer's text size, so they read as part of the reply rather than as
|
||||
* thread chrome. A tap sends the chip's label as the message with
|
||||
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
|
||||
* v1.41 — a thin adapter onto `SuggestedReplies`, the one pattern every
|
||||
* tappable reply in the thread uses (follow-ups, clarifying choices, memory
|
||||
* and plan decisions).
|
||||
*
|
||||
* A tap sends the chip's label as the message with
|
||||
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
|
||||
* The label is the server's rendering in the request locale. It is not
|
||||
* re-rendered from `labelKey` here: a related-metric chip names its metric
|
||||
* in the label, and the key alone does not carry it. Hidden while a turn is
|
||||
* in flight, so a chip can never be tapped against a reply that is about to
|
||||
* stop being the latest. The tapped chip goes away with the rest, so focus
|
||||
* moves to the composer.
|
||||
* in the label, and the key alone does not carry it.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
import { SuggestedReplies } from "./suggested-replies";
|
||||
|
||||
/** At most this many chips render, whatever the payload carries. */
|
||||
const MAX_CHIPS = 3;
|
||||
@@ -40,36 +37,21 @@ export function CoachFollowUpChips({
|
||||
onSelect,
|
||||
}: CoachFollowUpChipsProps) {
|
||||
const { t } = useTranslations();
|
||||
const chips = followUps.slice(0, MAX_CHIPS);
|
||||
if (disabled || chips.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
|
||||
data-slot="coach-follow-up-chips"
|
||||
data-message-id={messageId}
|
||||
className="flex max-w-full flex-wrap gap-2"
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// The answer's own scale (text-sm, regular weight); the 44 px tap
|
||||
// floor below `sm`, 36 px beside a pointer. A long label wraps
|
||||
// inside the pill instead of pushing past the column at 390 px.
|
||||
className="h-auto min-h-11 max-w-full rounded-full px-3 py-1.5 text-left text-sm leading-snug font-normal whitespace-normal sm:min-h-9"
|
||||
data-follow-up-id={chip.id}
|
||||
data-follow-up-kind={chip.kind}
|
||||
onClick={() => {
|
||||
onSelect(chip, messageId);
|
||||
focusCoachComposer();
|
||||
}}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return SuggestedReplies({
|
||||
slot: "coach-follow-up-chips",
|
||||
label: t(COACH_FOLLOW_UP_UI_KEYS.groupLabel),
|
||||
messageId,
|
||||
disabled,
|
||||
max: MAX_CHIPS,
|
||||
replies: followUps.map((chip) => ({
|
||||
id: chip.id,
|
||||
label: chip.label,
|
||||
kind: "followUp",
|
||||
data: {
|
||||
"data-follow-up-id": chip.id,
|
||||
"data-follow-up-kind": chip.kind,
|
||||
},
|
||||
onSelect: () => onSelect(chip, messageId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"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-ring/50 -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — the one quiet line under an answer that says the Coach kept
|
||||
* something: "Remembered: you want to reach 75 kg by December. Undo".
|
||||
*
|
||||
* Meta text at the meta size, with "Undo" as a text button of the same size.
|
||||
* Undo forgets the fact (a soft delete) and the line goes. A fact the Coach
|
||||
* only proposes (a health fact, which is never kept without a tap) has no
|
||||
* line: the answer ends with the question and the reply pills carry
|
||||
* "Yes, remember it" / "No".
|
||||
*
|
||||
* The live turn has the fact's words from the `memoryNote` frame. A
|
||||
* persisted message carries only the fact's id, so its words come from the
|
||||
* memory list; a fact that has since been forgotten or edited away leaves
|
||||
* no line behind.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_MEMORY_KEYS } from "@/lib/ai/coach/memory/shared";
|
||||
import type { CoachMemoryNoteMeta } from "@/lib/ai/coach/types";
|
||||
|
||||
import { useCoachFacts, useForgetCoachFact } from "./use-coach-facts";
|
||||
|
||||
export interface CoachMemoryNoteLineProps {
|
||||
fact: string;
|
||||
pending: boolean;
|
||||
onUndo: () => void;
|
||||
}
|
||||
|
||||
/** The line itself, without data: what the tests render. */
|
||||
export function CoachMemoryNoteLine({
|
||||
fact,
|
||||
pending,
|
||||
onUndo,
|
||||
}: CoachMemoryNoteLineProps) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<p
|
||||
data-slot="coach-memory-note"
|
||||
className="text-muted-foreground max-w-full text-xs leading-relaxed break-words"
|
||||
>
|
||||
{t(COACH_MEMORY_KEYS.saved, { fact: fact.replace(/[.\s]+$/, "") })}.{" "}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
data-slot="coach-memory-note-undo"
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
onClick={onUndo}
|
||||
// Inline in the meta line; the hit area reaches past the text (a
|
||||
// 44 px target on phones) without making the line any taller.
|
||||
className="text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5"
|
||||
>
|
||||
{t(COACH_MEMORY_KEYS.undo)}
|
||||
</Button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachMemoryNote({
|
||||
note,
|
||||
liveFact,
|
||||
}: {
|
||||
/** `metricSource.memoryNote`, or the live frame's metadata. */
|
||||
note: CoachMemoryNoteMeta;
|
||||
/** The fact's words from the live frame, when the turn just streamed. */
|
||||
liveFact?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [undone, setUndone] = useState(false);
|
||||
const saved = !note.proposal && !!note.factId;
|
||||
// The list is read only for a persisted note without its words.
|
||||
const facts = useCoachFacts({ enabled: saved && !liveFact && !undone });
|
||||
const forget = useForgetCoachFact({
|
||||
onSuccess: () => setUndone(true),
|
||||
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
|
||||
});
|
||||
if (!saved || undone || !note.factId) return null;
|
||||
const fact =
|
||||
liveFact ?? facts.data?.find((f) => f.id === note.factId)?.text ?? null;
|
||||
if (!fact) return null;
|
||||
return (
|
||||
<CoachMemoryNoteLine
|
||||
fact={fact}
|
||||
pending={forget.isPending}
|
||||
onUndo={() => {
|
||||
if (note.factId && !forget.isPending) forget.mutate(note.factId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,10 @@ import type { Formatters } from "@/lib/format-locale";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
import {
|
||||
COACH_MEMORY_KEYS,
|
||||
REMEMBER_FACT_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/memory/shared";
|
||||
|
||||
import {
|
||||
COACH_ICON_BUTTON,
|
||||
@@ -271,37 +274,38 @@ function MessageTime({ iso }: { iso: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the user's message in the self-context (Settings → AI) on one tap,
|
||||
* so it rides every future system prompt. The server picks the field from
|
||||
* the text itself. The icon confirms in place and a toast says where it
|
||||
* went; the button never unmounts, so keyboard focus stays on it.
|
||||
* Keeps the user's message in the Coach's memory on one tap
|
||||
* (`POST /api/insights/coach/facts` with the message id; the server reads the
|
||||
* text and files it). It then shows in Settings → Coach with "from you", and
|
||||
* reaches every later turn. v1.41 — it used to write the self-context; the
|
||||
* Coach's memory is now the one place for what the Coach knows. The icon
|
||||
* confirms in place and a toast says so; the button never unmounts, so
|
||||
* keyboard focus stays on it.
|
||||
*/
|
||||
function RememberMessageButton({ content }: { content: string }) {
|
||||
function RememberMessageButton({ messageId }: { messageId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
|
||||
|
||||
const remember = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
|
||||
answer: content,
|
||||
apiPost<{ created: boolean }>("/api/insights/coach/facts", {
|
||||
messageId,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data.adopted) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
});
|
||||
}
|
||||
setSettled(data.adopted ? "adopted" : "duplicate");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachFacts(),
|
||||
});
|
||||
setSettled(data.created ? "adopted" : "duplicate");
|
||||
// A new entry is a write (success); an existing one is an honest
|
||||
// non-event (neutral), the same mapping `toastWrittenOutcome` uses.
|
||||
// Called directly: importing the outcome module here regroups the
|
||||
// shared chunks and costs ~16 KB gz across the client bundle.
|
||||
(data.adopted ? toast.success : toast.info)(
|
||||
(data.created ? toast.success : toast.info)(
|
||||
t(
|
||||
data.adopted
|
||||
? "insights.coach.rememberMessage.done"
|
||||
: "insights.coach.rememberMessage.duplicate",
|
||||
data.created
|
||||
? COACH_MEMORY_KEYS.confirmed
|
||||
: "insights.coach.rememberMessage.alreadyKept",
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -400,8 +404,8 @@ export function UserMessageActions({
|
||||
messageId,
|
||||
createdAt,
|
||||
}: UserMessageActionsProps) {
|
||||
// Only a persisted message that fits the self-context field can be kept.
|
||||
const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS;
|
||||
// Only a persisted message short enough to be one fact can be kept.
|
||||
const canRemember = !!messageId && content.length <= REMEMBER_FACT_MAX_CHARS;
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-user-actions"
|
||||
@@ -412,7 +416,9 @@ export function UserMessageActions({
|
||||
)}
|
||||
>
|
||||
<CopyMessageButton content={content} strip={false} />
|
||||
{canRemember && <RememberMessageButton content={content} />}
|
||||
{canRemember && messageId && (
|
||||
<RememberMessageButton messageId={messageId} />
|
||||
)}
|
||||
{createdAt && <MessageTime iso={createdAt} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,8 @@ import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { PlanProposalCards } from "./plan-proposal-card";
|
||||
import { ChatBubble, type FollowUpOffer } from "./chat-bubble";
|
||||
import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble";
|
||||
import type { SuggestedReply } from "./suggested-replies";
|
||||
import { CoachMessageDatesProvider } from "./coach-results";
|
||||
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
@@ -32,9 +32,14 @@ import type {
|
||||
} from "./use-coach";
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachClarificationChoice,
|
||||
CoachFollowUp,
|
||||
CoachMessageDTO,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_MEMORY_KEYS,
|
||||
COACH_PLAN_KEYS,
|
||||
} from "@/lib/ai/coach/memory/shared";
|
||||
|
||||
// v1.28.26 file-size split (pure code motion): the bubble renderer +
|
||||
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
|
||||
@@ -99,6 +104,159 @@ export interface MessageThreadProps {
|
||||
* v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown.
|
||||
*/
|
||||
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
|
||||
/**
|
||||
* v1.41 — a reply pill was tapped: a clarifying question's choice, or an
|
||||
* answer to a fact or plan proposal. Omitted → those pills are not shown.
|
||||
*/
|
||||
onReply?: (intent: CoachReplyIntent) => void;
|
||||
}
|
||||
|
||||
/** v1.41 — what a tapped reply pill answers, and how. */
|
||||
export type CoachReplyIntent =
|
||||
| {
|
||||
kind: "clarification";
|
||||
messageId: string;
|
||||
choice: CoachClarificationChoice;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
kind: "memory";
|
||||
messageId: string;
|
||||
proposalId: string;
|
||||
accept: boolean;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
kind: "plan";
|
||||
messageId: string;
|
||||
planId: string;
|
||||
accept: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.41 — the decision the latest answer waits for, if any: a clarifying
|
||||
* question's choices, a fact proposal or a plan proposal. Like the chips, only
|
||||
* the latest assistant turn offers one, and none while a turn is in flight.
|
||||
*/
|
||||
export interface LatestDecision {
|
||||
messageId: string;
|
||||
clarification: CoachClarification | null;
|
||||
memoryProposalId: string | null;
|
||||
planId: string | null;
|
||||
}
|
||||
|
||||
export function latestDecision(
|
||||
messages: CoachMessageDTO[],
|
||||
streaming: CoachStreamingMessage | undefined,
|
||||
): LatestDecision | null {
|
||||
if (streaming?.inProgress) return null;
|
||||
let found: LatestDecision | null = null;
|
||||
if (streaming?.messageId) {
|
||||
const persisted = messages.find((m) => m.id === streaming.messageId);
|
||||
const note = streaming.memoryNote ?? persisted?.metricSource?.memoryNote;
|
||||
const plan =
|
||||
streaming.planProposal ?? persisted?.metricSource?.planProposal;
|
||||
found = {
|
||||
messageId: streaming.messageId,
|
||||
clarification:
|
||||
streaming.clarification ??
|
||||
persisted?.metricSource?.clarification ??
|
||||
null,
|
||||
memoryProposalId:
|
||||
note?.proposal && note.proposalId ? note.proposalId : null,
|
||||
planId: plan?.planId ?? null,
|
||||
};
|
||||
const last = messages[messages.length - 1];
|
||||
// The streamed turn is the latest only until something follows it.
|
||||
if (persisted && last && last.id !== streaming.messageId) found = null;
|
||||
} else {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
const note = last.metricSource?.memoryNote;
|
||||
found = {
|
||||
messageId: last.id,
|
||||
clarification: last.metricSource?.clarification ?? null,
|
||||
memoryProposalId:
|
||||
note?.proposal && note.proposalId ? note.proposalId : null,
|
||||
planId: last.metricSource?.planProposal?.planId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
!found ||
|
||||
(!found.clarification && !found.memoryProposalId && !found.planId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pills of a decision, in the order they are offered: a question's
|
||||
* choices with the assumed one first, then a fact proposal's two answers,
|
||||
* then a plan proposal's. Labels in the reader's language.
|
||||
*/
|
||||
export function decisionReplies(
|
||||
decision: LatestDecision,
|
||||
t: (key: string) => string,
|
||||
onReply: (intent: CoachReplyIntent) => void,
|
||||
): SuggestedReply[] {
|
||||
const { messageId } = decision;
|
||||
const out: SuggestedReply[] = [];
|
||||
const choices = decision.clarification?.choices ?? [];
|
||||
const assumed = decision.clarification?.assumption;
|
||||
const ordered = assumed
|
||||
? [
|
||||
...choices.filter((c) => c.id === assumed),
|
||||
...choices.filter((c) => c.id !== assumed),
|
||||
]
|
||||
: choices;
|
||||
for (const choice of ordered.slice(0, 4)) {
|
||||
const local = t(choice.labelKey);
|
||||
const label = local === choice.labelKey ? choice.label : local;
|
||||
out.push({
|
||||
id: `clarify-${choice.id}`,
|
||||
label,
|
||||
kind: "clarification",
|
||||
data: { "data-choice-id": choice.id },
|
||||
onSelect: () =>
|
||||
onReply({ kind: "clarification", messageId, choice, label }),
|
||||
});
|
||||
}
|
||||
const proposalId = decision.memoryProposalId;
|
||||
if (proposalId) {
|
||||
for (const accept of [true, false]) {
|
||||
const label = t(
|
||||
accept ? COACH_MEMORY_KEYS.accept : COACH_MEMORY_KEYS.decline,
|
||||
);
|
||||
out.push({
|
||||
id: `memory-${accept ? "accept" : "decline"}`,
|
||||
label,
|
||||
kind: "memory",
|
||||
data: { "data-decision": accept ? "accept" : "decline" },
|
||||
onSelect: () =>
|
||||
onReply({ kind: "memory", messageId, proposalId, accept, label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
const planId = decision.planId;
|
||||
if (planId) {
|
||||
for (const accept of [true, false]) {
|
||||
const label = t(
|
||||
accept ? COACH_PLAN_KEYS.accept : COACH_PLAN_KEYS.decline,
|
||||
);
|
||||
out.push({
|
||||
id: `plan-${accept ? "accept" : "decline"}`,
|
||||
label,
|
||||
kind: "plan",
|
||||
data: { "data-decision": accept ? "accept" : "decline" },
|
||||
onSelect: () =>
|
||||
onReply({ kind: "plan", messageId, planId, accept, label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,6 +433,7 @@ export function MessageThread({
|
||||
interleaved,
|
||||
onRegenerate,
|
||||
onFollowUp,
|
||||
onReply,
|
||||
}: MessageThreadProps) {
|
||||
const { t } = useTranslations();
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -354,14 +513,32 @@ export function MessageThread({
|
||||
: null,
|
||||
[chipsMessageId, chipsArray],
|
||||
);
|
||||
const chipsFor = (messageId: string | null | undefined) =>
|
||||
onFollowUp && chips && messageId === chips.messageId
|
||||
// v1.41 — a decision the latest answer waits for replaces its chips with
|
||||
// the decision's own pills. There is none while a turn streams, so the
|
||||
// fresh array per render never re-renders a bubble per token.
|
||||
const decision = onReply ? latestDecision(messages, streaming) : null;
|
||||
const replyOffer: ReplyOffer | null =
|
||||
decision && onReply
|
||||
? {
|
||||
messageId: decision.messageId,
|
||||
replies: decisionReplies(decision, t, onReply),
|
||||
}
|
||||
: null;
|
||||
const chipsFor = (messageId: string | null | undefined) => ({
|
||||
...(onFollowUp && chips && messageId === chips.messageId
|
||||
? {
|
||||
followUps: chips,
|
||||
followUpsDisabled: !!streaming?.inProgress,
|
||||
onFollowUp,
|
||||
}
|
||||
: {};
|
||||
: {}),
|
||||
...(replyOffer && messageId === replyOffer.messageId
|
||||
? {
|
||||
replies: replyOffer,
|
||||
followUpsDisabled: !!streaming?.inProgress,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const hasThread =
|
||||
messages.length > 0 ||
|
||||
@@ -507,7 +684,13 @@ export function MessageThread({
|
||||
// assistant reply (the nearest preceding user turn) so the surface
|
||||
// can resubmit it. Null when there is none → no regenerate action.
|
||||
let precedingUserContent: string | null = null;
|
||||
if (m.role === "assistant" && onRegenerate) {
|
||||
// v1.41 — a reply to a memory or plan tap is answered without a
|
||||
// model (`decision`); there is nothing to try again.
|
||||
if (
|
||||
m.role === "assistant" &&
|
||||
onRegenerate &&
|
||||
m.providerType !== "decision"
|
||||
) {
|
||||
for (let j = idx - 1; j >= 0; j--) {
|
||||
if (messages[j].role === "user") {
|
||||
precedingUserContent = messages[j].content;
|
||||
@@ -532,6 +715,19 @@ export function MessageThread({
|
||||
streaming.results.length > 0
|
||||
? { 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.
|
||||
{...(m.id === streaming?.messageId
|
||||
? {
|
||||
...(streaming.activity.length > 0
|
||||
? { activity: streaming.activity }
|
||||
: {}),
|
||||
startedAt: streaming.startedAt,
|
||||
endedAt: streaming.endedAt,
|
||||
memoryNote: streaming.memoryNote,
|
||||
}
|
||||
: {})}
|
||||
{...(m.role === "assistant" ? chipsFor(m.id) : {})}
|
||||
onRegenerate={
|
||||
precedingUserContent !== null && onRegenerate
|
||||
@@ -585,6 +781,11 @@ export function MessageThread({
|
||||
suggestedAction={streaming.suggestedAction}
|
||||
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}
|
||||
errorCode={streaming.errorCode}
|
||||
@@ -602,14 +803,6 @@ export function MessageThread({
|
||||
{placement.tail.map((i) => (
|
||||
<Fragment key={i.key}>{i.node}</Fragment>
|
||||
))}
|
||||
{/* Plan-proposal confirm cards for THIS conversation. The extractor
|
||||
runs after the turn (memory-refresh worker), so the block appears at
|
||||
the thread tail once the proposal lands rather than under a specific
|
||||
bubble; the component itself slow-polls and renders nothing while
|
||||
the conversation has no open proposal. */}
|
||||
{conversation ? (
|
||||
<PlanProposalCards conversationId={conversation.id} />
|
||||
) : null}
|
||||
</div>
|
||||
<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />
|
||||
</div>
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useCoachPlans,
|
||||
useCoachPlanMutations,
|
||||
type CoachPlanDTO,
|
||||
} from "@/hooks/use-coach-plans";
|
||||
|
||||
/**
|
||||
* v1.27.x — the confirm cards for Coach plan proposals, rendered at the tail
|
||||
* of the open conversation's thread.
|
||||
*
|
||||
* The extractor (memory-refresh worker, runs AFTER the turn) writes a plan as
|
||||
* `status: "proposed"`; nothing becomes a standing plan until the user
|
||||
* confirms it here (or on `/coach/plans`). Because the proposal lands
|
||||
* asynchronously, the block slow-polls the proposed list while the thread is
|
||||
* mounted (TanStack pauses the interval in background tabs) and only shows
|
||||
* proposals whose `sourceConversationId` is the OPEN conversation — a
|
||||
* proposal born in an older thread stays on the management page instead of
|
||||
* popping into an unrelated chat.
|
||||
*
|
||||
* Accept → PATCH `{status:"active"}` (the plan enters the Coach's snapshot
|
||||
* memory block from the next turn on). Decline → DELETE (soft-delete; the
|
||||
* proposal never activates and stops counting against the per-user cap).
|
||||
* A settled card collapses to a one-line confirmation for the session so the
|
||||
* refetch dropping the row from the proposed list cannot double-prompt.
|
||||
*/
|
||||
|
||||
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
|
||||
export const PROPOSAL_POLL_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Attempt ceiling for the proposal poll. The extractor runs right after the
|
||||
* turn and lands its proposal within a couple of minutes; 15 polls (~5 min
|
||||
* of coverage) catch it with margin, and every settled response counts an
|
||||
* attempt. Without the cap an open thread polled the proposed list every
|
||||
* 20 s indefinitely. Same bounded-poll shape as `nextAdvisorPollInterval`
|
||||
* (`use-insights-advisor.ts`); a new turn's invalidation bumps the query and
|
||||
* the next mount of a fresh conversation restarts the budget.
|
||||
*/
|
||||
export const PROPOSAL_POLL_MAX_ATTEMPTS = 15;
|
||||
|
||||
/**
|
||||
* Decide whether the proposal query schedules its next poll. Pure so the
|
||||
* ceiling is unit-testable: returns the slow interval until the attempt cap
|
||||
* is reached, then `false` (poll off).
|
||||
*/
|
||||
export function nextProposalPollInterval(
|
||||
dataUpdateCount: number,
|
||||
): number | false {
|
||||
if (dataUpdateCount >= PROPOSAL_POLL_MAX_ATTEMPTS) return false;
|
||||
return PROPOSAL_POLL_MS;
|
||||
}
|
||||
|
||||
type SettledOutcome = "accepted" | "declined";
|
||||
|
||||
export function PlanProposalCards({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<
|
||||
Array<{ id: string; outcome: SettledOutcome }>
|
||||
>([]);
|
||||
|
||||
const query = useCoachPlans({
|
||||
filter: { status: "proposed" },
|
||||
refetchInterval: (q) => nextProposalPollInterval(q.state.dataUpdateCount),
|
||||
});
|
||||
|
||||
// Quiet on error by design: the thread is the Coach's surface, and a failed
|
||||
// enhancement read must never interrupt the conversation with an error card.
|
||||
const proposals = (query.data ?? []).filter(
|
||||
(p) =>
|
||||
p.sourceConversationId === conversationId &&
|
||||
p.ifCue !== null &&
|
||||
p.thenAction !== null &&
|
||||
!settled.some((s) => s.id === p.id),
|
||||
);
|
||||
|
||||
if (proposals.length === 0 && settled.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="coach-plan-proposals" className="flex flex-col gap-2.5">
|
||||
{proposals.map((plan) => (
|
||||
<PlanProposalCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onSettled={(outcome) =>
|
||||
setSettled((prev) => [...prev, { id: plan.id, outcome }])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{settled.map((s) => (
|
||||
<p
|
||||
key={s.id}
|
||||
role="status"
|
||||
data-slot="coach-plan-proposal-done"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check className="text-success size-3.5" aria-hidden="true" />
|
||||
{t(
|
||||
s.outcome === "accepted"
|
||||
? "coach.plans.accepted"
|
||||
: "coach.plans.declined",
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One proposal with confirm / decline actions. */
|
||||
function PlanProposalCard({
|
||||
plan,
|
||||
onSettled,
|
||||
}: {
|
||||
plan: CoachPlanDTO;
|
||||
onSettled: (outcome: SettledOutcome) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { setStatus, remove } = useCoachPlanMutations();
|
||||
const busy = setStatus.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-plan-proposal-card"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
|
||||
"px-3.5 py-3 text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-primary/10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Target className="text-primary size-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("coach.plans.proposalTitle")}
|
||||
</span>
|
||||
{/* The plan prose is the user's own committed intention — content,
|
||||
never meta, so it reads in foreground per the text-colour rules. */}
|
||||
<span className="text-foreground leading-relaxed break-words">
|
||||
{t("coach.plans.ifThen", {
|
||||
cue: plan.ifCue ?? "",
|
||||
action: plan.thenAction ?? "",
|
||||
})}
|
||||
</span>
|
||||
{plan.target ? (
|
||||
<span className="text-foreground text-xs break-words">
|
||||
{t("coach.plans.targetPrefix", { target: plan.target })}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
{plan.metric}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-plan-proposal-accept"
|
||||
onClick={() =>
|
||||
setStatus.mutate(
|
||||
{ id: plan.id, status: "active" },
|
||||
{
|
||||
onSuccess: () => onSettled("accepted"),
|
||||
onError: () => toast.error(t("coach.plans.failed")),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"bg-primary/90 text-primary-foreground hover:bg-primary",
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
|
||||
"focus-visible:ring-2 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy && setStatus.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("coach.plans.accept")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-plan-proposal-decline"
|
||||
onClick={() =>
|
||||
remove.mutate(plan.id, {
|
||||
onSuccess: () => onSettled("declined"),
|
||||
onError: () => toast.error(t("coach.plans.failed")),
|
||||
})
|
||||
}
|
||||
disabled={busy}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.decline")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
CoachResultTable,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import { niceAxis } from "@/lib/charts/nice-axis";
|
||||
import { niceAxis, type NiceAxis } from "@/lib/charts/nice-axis";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -122,9 +122,9 @@ export function CoachResultChart({
|
||||
if (spec.kind === "histogram") {
|
||||
return <HistogramChart result={result} spec={spec} label={label} />;
|
||||
}
|
||||
// v1.41 — the contract is in place; no table carries this kind yet, and the
|
||||
// table view stays available until the chart lands.
|
||||
if (spec.kind === "compare") return null;
|
||||
if (spec.kind === "compare") {
|
||||
return <CompareChart result={result} spec={spec} label={label} />;
|
||||
}
|
||||
if (spec.kind === "bar" && spec.orientation === "horizontal") {
|
||||
return (
|
||||
<CategoryBarChart
|
||||
@@ -354,14 +354,21 @@ function SeriesLegend({
|
||||
series,
|
||||
hidden,
|
||||
onToggle,
|
||||
label,
|
||||
dashed,
|
||||
}: {
|
||||
series: Array<{ key: string; label: string }>;
|
||||
hidden: ReadonlySet<string>;
|
||||
onToggle: (key: string) => void;
|
||||
/** The legend's accessible name, when it says more than its buttons. */
|
||||
label?: string;
|
||||
/** Series drawn dashed; their swatch is a ring rather than a dot. */
|
||||
dashed?: ReadonlySet<string>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-result-chart-legend"
|
||||
{...(label ? { role: "group", "aria-label": label } : {})}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
{series.map(({ key, label }, index) => {
|
||||
@@ -385,9 +392,16 @@ function SeriesLegend({
|
||||
"inline-block size-2 rounded-full",
|
||||
!shown && "opacity-40",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
|
||||
}}
|
||||
style={
|
||||
dashed?.has(key)
|
||||
? {
|
||||
border: `1.5px solid ${SERIES_COLORS[index % SERIES_COLORS.length]}`,
|
||||
}
|
||||
: {
|
||||
backgroundColor:
|
||||
SERIES_COLORS[index % SERIES_COLORS.length],
|
||||
}
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
@@ -397,6 +411,211 @@ function SeriesLegend({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compare: two periods overlaid, or two metrics side by side ─────────────
|
||||
|
||||
/**
|
||||
* Two axes whose grid lines meet: the second axis is widened, a step at a
|
||||
* time on its own nice step, until it has as many ticks as the first. Null
|
||||
* when either side has no value.
|
||||
*/
|
||||
export function alignedAxes(
|
||||
a: ReadonlyArray<number | null>,
|
||||
b: ReadonlyArray<number | null>,
|
||||
): { left: NiceAxis; right: NiceAxis } | null {
|
||||
const left = niceAxis(a);
|
||||
if (!left) return null;
|
||||
const count = left.ticks.length;
|
||||
const right = niceAxis(b, { maxTicks: count });
|
||||
if (!right) return null;
|
||||
const ticks = [...right.ticks];
|
||||
if (ticks.length >= 2) {
|
||||
const step = ticks[1] - ticks[0];
|
||||
const decimals = Math.max(0, (String(step).split(".")[1] ?? "").length);
|
||||
while (ticks.length < count) {
|
||||
ticks.push(Number((ticks[ticks.length - 1] + step).toFixed(decimals)));
|
||||
}
|
||||
}
|
||||
return {
|
||||
left,
|
||||
right: { domain: [ticks[0], ticks[ticks.length - 1]], ticks },
|
||||
};
|
||||
}
|
||||
|
||||
/** The rows of a compare chart: the shared x, then each side's value. */
|
||||
export function comparePoints(
|
||||
result: CoachResultTable,
|
||||
spec: Extract<CoachChartSpec, { kind: "compare" }>,
|
||||
): Array<{ x: string; a: number | null; b: number | null }> {
|
||||
const index = (key: string) =>
|
||||
result.columns.findIndex((column) => column.key === key);
|
||||
const xi = index(spec.x);
|
||||
const ai = index(spec.a);
|
||||
const bi = index(spec.b);
|
||||
return result.rows.map((row) => ({
|
||||
x: String(row[xi] ?? ""),
|
||||
a: typeof row[ai] === "number" ? (row[ai] as number) : null,
|
||||
b: typeof row[bi] === "number" ? (row[bi] as number) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function CompareChart({
|
||||
result,
|
||||
spec,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "compare" }>;
|
||||
label: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const periodLabel = usePeriodLabel();
|
||||
const valueFormat = useValueFormat();
|
||||
const animate = !prefersReducedMotion();
|
||||
const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
|
||||
const byKey = useMemo(
|
||||
() => new Map(result.columns.map((column) => [column.key, column])),
|
||||
[result.columns],
|
||||
);
|
||||
const points = useMemo(() => comparePoints(result, spec), [result, spec]);
|
||||
const isPeriod = byKey.get(spec.x)?.kind === "period";
|
||||
const tickLabel = (x: string) => (isPeriod ? periodLabel(x) : x);
|
||||
const twoAxes = spec.mode === "metrics" && spec.axes === 2;
|
||||
// The earlier period is the dashed line, so the two read apart without
|
||||
// relying on colour alone.
|
||||
const dashed = new Set(spec.mode === "periods" ? ["b"] : []);
|
||||
const series = [
|
||||
{ key: "a", column: byKey.get(spec.a), color: SERIES_COLORS[0] },
|
||||
{ key: "b", column: byKey.get(spec.b), color: SERIES_COLORS[1] },
|
||||
];
|
||||
const visible = series.filter((s) => !hidden.has(s.key));
|
||||
const values = (key: "a" | "b") =>
|
||||
hidden.has(key) ? [] : points.map((point) => point[key]);
|
||||
const aligned = twoAxes ? alignedAxes(values("a"), values("b")) : null;
|
||||
const shared = twoAxes ? null : niceAxis([...values("a"), ...values("b")]);
|
||||
const left = aligned?.left ?? (twoAxes ? niceAxis(values("a")) : shared);
|
||||
const right = aligned?.right ?? (twoAxes ? niceAxis(values("b")) : null);
|
||||
const nameOf = (key: string) => {
|
||||
const column = byKey.get(key === "a" ? spec.a : spec.b);
|
||||
return column?.label ?? key;
|
||||
};
|
||||
const legendLabel =
|
||||
spec.mode === "periods"
|
||||
? // The key is spelled out rather than read from `dialog-keys.ts`: this
|
||||
// file rides the shared chart runtime, and that module would pull the
|
||||
// Coach's schemas into every chart on every page.
|
||||
t("insights.coach.chart.compare.periods", {
|
||||
a: nameOf("a"),
|
||||
b: nameOf("b"),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const tooltip = (props: {
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{
|
||||
payload?: { x: string; a: number | null; b: number | null };
|
||||
}>;
|
||||
}) => {
|
||||
const point = props.payload?.[0]?.payload;
|
||||
if (!props.active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows = visible.flatMap((s) => {
|
||||
const value = point[s.key as "a" | "b"];
|
||||
if (typeof value !== "number") return [];
|
||||
return [
|
||||
{
|
||||
name: s.column?.label ?? s.key,
|
||||
value: valueFormat(value, s.column),
|
||||
color: s.color,
|
||||
},
|
||||
];
|
||||
});
|
||||
return <RichChartTooltip active label={tickLabel(point.x)} rows={rows} />;
|
||||
};
|
||||
|
||||
const axisProps = (axis: NiceAxis | null) =>
|
||||
axis
|
||||
? { domain: axis.domain, ticks: axis.ticks, interval: 0 as const }
|
||||
: { domain: ["auto", "auto"] as [string, string] };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2" data-slot="coach-result-chart-compare">
|
||||
<SeriesLegend
|
||||
series={series.map((s) => ({ key: s.key, label: nameOf(s.key) }))}
|
||||
hidden={hidden}
|
||||
label={legendLabel}
|
||||
dashed={dashed}
|
||||
onToggle={(key) =>
|
||||
setHidden((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else if (series.length - next.size > 1) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PlotBand height={RESULT_CHART_HEIGHT_PX} label={label}>
|
||||
<LineChart
|
||||
accessibilityLayer={false}
|
||||
data={points}
|
||||
margin={{ top: 8, right: twoAxes ? 0 : 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
tickFormatter={tickLabel}
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
{...axisProps(left)}
|
||||
/>
|
||||
{twoAxes ? (
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
{...axisProps(right)}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip content={tooltip} cursor={{ stroke: "var(--border)" }} />
|
||||
{visible.map((s) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
yAxisId={twoAxes && s.key === "b" ? "right" : "left"}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={nameOf(s.key)}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={dashed.has(s.key) ? "5 4" : undefined}
|
||||
dot={{ r: 2.5, strokeWidth: 0, fill: s.color }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</PlotBand>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal category bars ───────────────────────────────────────────────
|
||||
|
||||
function CategoryBarChart({
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — the one quiet way the Coach offers a reply: pills under the latest
|
||||
* answer, in the answer's own column, at the answer's text size.
|
||||
*
|
||||
* Every reply the person can give by tap uses it: the follow-ups after an
|
||||
* answer, the choices of a clarifying question (the question itself is the
|
||||
* answer above, its assumed choice the first pill), "Yes, remember it" /
|
||||
* "No" under a fact proposal and "Take on this plan" / "Not now" under a
|
||||
* plan proposal. A tap sends the pill's label as the person's own message,
|
||||
* so it shows in the thread like anything they typed; the request carries
|
||||
* which offer it answered, and the server resolves it from what it stored.
|
||||
*
|
||||
* Outline pills in the foreground at reduced contrast, never an accent
|
||||
* colour, never a card. Hidden while a turn runs, so a pill can never answer
|
||||
* a reply that is about to stop being the latest. The tapped pill goes away
|
||||
* with the rest, so focus moves to the composer.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_SUGGESTED_REPLIES_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
|
||||
/** At most this many pills render, whatever the offer carries. */
|
||||
export const MAX_SUGGESTED_REPLIES = 4;
|
||||
|
||||
export interface SuggestedReply {
|
||||
/** Stable within the offer; the React key. */
|
||||
id: string;
|
||||
/** What the pill says, and the message a tap sends. */
|
||||
label: string;
|
||||
/** What the offer is (`followUp`, `clarification`, `memory`, `plan`). */
|
||||
kind: string;
|
||||
/** Extra `data-*` attributes, for tests and the e2e specs. */
|
||||
data?: Record<`data-${string}`, string>;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export interface SuggestedRepliesProps {
|
||||
replies: SuggestedReply[];
|
||||
/** The assistant message that offered them. */
|
||||
messageId: string;
|
||||
/** True while a turn is in flight. */
|
||||
disabled: boolean;
|
||||
/** At most this many; defaults to {@link MAX_SUGGESTED_REPLIES}. */
|
||||
max?: number;
|
||||
/** The group's accessible name; defaults to "Suggested replies". */
|
||||
label?: string;
|
||||
/** The group's `data-slot`. */
|
||||
slot?: string;
|
||||
}
|
||||
|
||||
export function SuggestedReplies({
|
||||
replies,
|
||||
messageId,
|
||||
disabled,
|
||||
max = MAX_SUGGESTED_REPLIES,
|
||||
label,
|
||||
slot = "coach-suggested-replies",
|
||||
}: SuggestedRepliesProps) {
|
||||
const { t } = useTranslations();
|
||||
const shown = replies.slice(0, max);
|
||||
if (disabled || shown.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label ?? t(COACH_SUGGESTED_REPLIES_KEYS.groupLabel)}
|
||||
data-slot={slot}
|
||||
data-message-id={messageId}
|
||||
className="flex max-w-full flex-wrap gap-2"
|
||||
>
|
||||
{shown.map((reply) => (
|
||||
<Button
|
||||
key={reply.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// The answer's own scale (text-sm, regular weight) at reduced
|
||||
// contrast; the 44 px tap floor below `sm`, 36 px beside a
|
||||
// pointer. A long label wraps inside the pill instead of pushing
|
||||
// past the column at 390 px.
|
||||
className="border-border text-foreground/80 hover:text-foreground hover:bg-muted h-auto min-h-11 max-w-full rounded-full px-3 py-1.5 text-left text-sm leading-snug font-normal whitespace-normal shadow-none sm:min-h-9"
|
||||
data-reply-id={reply.id}
|
||||
data-reply-kind={reply.kind}
|
||||
{...reply.data}
|
||||
onClick={() => {
|
||||
reply.onSelect();
|
||||
focusCoachComposer();
|
||||
}}
|
||||
>
|
||||
{reply.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — the live trail of a Coach turn, one quiet line at the top of the
|
||||
* answer.
|
||||
*
|
||||
* While the turn runs the line says what the Coach is doing right now, from
|
||||
* the `activity` frames: "Thinking…", "Fetching blood pressure, last 90
|
||||
* days…", "Summarising 214 readings…", "Writing the answer…", with the
|
||||
* seconds so far on the right. A reasoning title replaces the thinking label
|
||||
* when one arrives. Once the turn ends the same line settles into one calm
|
||||
* summary: "Thought it through, 3 lookups, 12 s".
|
||||
*
|
||||
* Nothing opens by itself. The trail starts closed in every state of a turn
|
||||
* (running, asking, stopped, failed, settled, reloaded) and only a tap on the
|
||||
* line opens it; it then stays open until the person closes it, and keeps
|
||||
* growing while the turn runs. Open, it lists one entry per phase with its
|
||||
* status, the reasoning text of a round behind its own disclosure (closed
|
||||
* too), the method line and the tables the answer used without pointing at
|
||||
* them.
|
||||
*
|
||||
* Labels are catalog text the server rendered; titles and texts are screened
|
||||
* model text the owner alone receives. A persisted message carries only the
|
||||
* metadata, so its titles and texts are read from `…/trail` the first time
|
||||
* the person opens the trail. A message saved before v1.41 has no activity
|
||||
* and falls back to the step list (`metricSource.steps`), and one saved
|
||||
* before steps existed to the areas it drew on.
|
||||
*
|
||||
* Screen readers hear the line through a polite status region, throttled to
|
||||
* one announcement per `ANNOUNCE_THROTTLE_MS` (the latest wins), and the
|
||||
* summary once when the turn ends. A reloaded conversation announces nothing.
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Minus,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
COACH_STEP_REASON_KEYS,
|
||||
COACH_STEP_UI_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachGranularityLabelKey,
|
||||
coachPeriodLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
COACH_ACTIVITY_KEYS,
|
||||
activitySummaryKey,
|
||||
} from "@/lib/ai/coach/activity/contract";
|
||||
import type {
|
||||
CoachActivity,
|
||||
CoachMethod,
|
||||
CoachProvenanceMetric,
|
||||
CoachStep,
|
||||
CoachTrail,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachMethodLine } from "./method-line";
|
||||
|
||||
/** The shortest gap between two screen-reader announcements. */
|
||||
export const ANNOUNCE_THROTTLE_MS = 1500;
|
||||
|
||||
/** "12s": the seconds on the line and beside a finished round. */
|
||||
const ELAPSED_KEY = "insights.coach.thinkingElapsed";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
type TranslateCount = (
|
||||
baseKey: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
|
||||
function tOr(
|
||||
t: Translate,
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>,
|
||||
) {
|
||||
const value = t(key, params);
|
||||
return value === key ? fallback : value;
|
||||
}
|
||||
|
||||
// ── Steps (messages saved before v1.41, and the fetch rows) ────────────────
|
||||
|
||||
/**
|
||||
* The step's label in the reader's language: the catalog key rendered with
|
||||
* the domain and window names, or the server's label when the key is not
|
||||
* in the bundle.
|
||||
*/
|
||||
export function stepLabel(step: CoachStep, t: Translate): string {
|
||||
return tOr(t, step.labelKey, step.label, {
|
||||
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
|
||||
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One row's text: the domain as the title, then the window, period,
|
||||
* granularity, and either how much was read or why nothing was.
|
||||
*/
|
||||
export function describeStep(
|
||||
step: CoachStep,
|
||||
t: Translate,
|
||||
tCount: TranslateCount,
|
||||
): { title: string; meta: string[] } {
|
||||
const title = step.domain
|
||||
? t(coachDomainLabelKey(step.domain))
|
||||
: stepLabel(step, t);
|
||||
const meta: string[] = [];
|
||||
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
|
||||
if (step.period && step.period !== "current") {
|
||||
meta.push(t(coachPeriodLabelKey(step.period)));
|
||||
}
|
||||
if (step.granularity)
|
||||
meta.push(t(coachGranularityLabelKey(step.granularity)));
|
||||
if (step.status === "done" && step.count !== undefined) {
|
||||
const base =
|
||||
step.tool === "snapshot"
|
||||
? "coach.step.metrics"
|
||||
: step.tool === "get_labs"
|
||||
? "coach.step.rows"
|
||||
: "coach.step.readings";
|
||||
meta.push(tCount(base, step.count));
|
||||
} else if (step.status !== "running" && step.reason) {
|
||||
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
|
||||
}
|
||||
return { title, meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many sources the turn looked at: one per domain (a step without one,
|
||||
* the snapshot, counts by its tool). A second read of the same domain, for
|
||||
* the period before or a retry, is not a new source.
|
||||
*/
|
||||
export function countSources(steps: readonly CoachStep[]): number {
|
||||
return new Set(steps.map((step) => step.domain ?? step.tool)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step a legacy running line names: the latest one still running, else
|
||||
* the latest one overall (between two rounds nothing is running).
|
||||
*/
|
||||
export function currentStep(steps: CoachStep[]): CoachStep | null {
|
||||
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
||||
if (steps[i].status === "running") return steps[i];
|
||||
}
|
||||
return steps.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The areas an older message drew on, in the order its provenance lists
|
||||
* them, by their display names. A token without a name in the bundle is
|
||||
* dropped, and so is `general`, which names no area of the record.
|
||||
*/
|
||||
export function legacyAreaLabels(
|
||||
areas: readonly CoachProvenanceMetric[] | undefined,
|
||||
t: Translate,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const area of areas ?? []) {
|
||||
if (area === "general") continue;
|
||||
const key = `insights.coach.metric.${area}`;
|
||||
const label = t(key);
|
||||
if (label === key || out.includes(label)) continue;
|
||||
out.push(label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Activity ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The entry the running line names: the latest one still running, else the
|
||||
* latest one overall. Null when the turn has sent no activity yet.
|
||||
*/
|
||||
export function currentActivity(
|
||||
activity: readonly CoachActivity[],
|
||||
): CoachActivity | null {
|
||||
for (let i = activity.length - 1; i >= 0; i -= 1) {
|
||||
if (activity[i].status === "running") return activity[i];
|
||||
}
|
||||
return activity.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an entry is called on the line: a screened reasoning title for a
|
||||
* thinking round once one has arrived, else the server's catalog label.
|
||||
*/
|
||||
export function activityLineLabel(entry: CoachActivity): string {
|
||||
if (
|
||||
(entry.phase === "thinking" || entry.phase === "checkpoint") &&
|
||||
entry.title
|
||||
) {
|
||||
return entry.title;
|
||||
}
|
||||
// A finished entry no longer trails off: "Writing the answer", not
|
||||
// "Writing the answer…".
|
||||
return entry.status === "running"
|
||||
? entry.label
|
||||
: entry.label.replace(/(?:…|\.\.\.)\s*$/u, "");
|
||||
}
|
||||
|
||||
/** The lookups a turn made: one per fetch. */
|
||||
export function countLookups(activity: readonly CoachActivity[]): number {
|
||||
return activity.filter((entry) => entry.phase === "fetch").length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seconds a turn took, from its trail: the phases of a round run one
|
||||
* after the other, except its fetches, which run side by side (the longest
|
||||
* counts). Null when the trail carries no durations.
|
||||
*/
|
||||
export function activitySeconds(
|
||||
activity: readonly CoachActivity[],
|
||||
): number | null {
|
||||
const fetchByRound = new Map<number, number>();
|
||||
let sequential = 0;
|
||||
let any = false;
|
||||
for (const entry of activity) {
|
||||
if (entry.durationMs === undefined) continue;
|
||||
any = true;
|
||||
if (entry.phase === "fetch") {
|
||||
fetchByRound.set(
|
||||
entry.round,
|
||||
Math.max(fetchByRound.get(entry.round) ?? 0, entry.durationMs),
|
||||
);
|
||||
} else {
|
||||
sequential += entry.durationMs;
|
||||
}
|
||||
}
|
||||
if (!any) return null;
|
||||
let total = sequential;
|
||||
for (const ms of fetchByRound.values()) total += ms;
|
||||
return Math.max(1, Math.round(total / 1000));
|
||||
}
|
||||
|
||||
/** Whole seconds between two instants, at least one. */
|
||||
export function elapsedSeconds(from: number, to: number): number {
|
||||
return Math.max(1, Math.round((to - from) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* The settled line: "Thought it through, N lookups, S s". The seconds come
|
||||
* from the trail, else from the clock of the turn that just streamed. Null
|
||||
* when neither is known (a message saved before v1.41).
|
||||
*/
|
||||
export function activitySummary(args: {
|
||||
activity: readonly CoachActivity[];
|
||||
startedAt?: number | null;
|
||||
endedAt?: number | null;
|
||||
t: Translate;
|
||||
locale: Locale;
|
||||
}): string | null {
|
||||
const { activity, startedAt, endedAt, t, locale } = args;
|
||||
if (activity.length === 0) return null;
|
||||
const seconds =
|
||||
activitySeconds(activity) ??
|
||||
(startedAt && endedAt ? elapsedSeconds(startedAt, endedAt) : null);
|
||||
if (seconds === null) return null;
|
||||
const lookups = countLookups(activity);
|
||||
return t(activitySummaryKey(lookups, locale), { lookups, seconds });
|
||||
}
|
||||
|
||||
/** A tick per second while `running`; the current time otherwise. */
|
||||
function useNow(running: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [running]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status region's text: the running line as it changes, throttled, and
|
||||
* the summary once when the turn ends. Nothing is announced for a trail that
|
||||
* was already settled when it mounted.
|
||||
*/
|
||||
function useLineAnnouncement(text: string, active: boolean): string {
|
||||
const [mountedActive] = useState(active);
|
||||
const lastAt = useRef(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const said = useRef<string | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// A reloaded, settled trail stays quiet.
|
||||
if (!mountedActive) return;
|
||||
if (!text || text === said.current) return;
|
||||
said.current = text;
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
|
||||
);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
lastAt.current = Date.now();
|
||||
setAnnouncement(text);
|
||||
}, wait);
|
||||
}, [text, mountedActive]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return announcement;
|
||||
}
|
||||
|
||||
/** The owner-only titles and texts of a persisted message's trail. */
|
||||
function useTrailText(args: {
|
||||
conversationId: string | null | undefined;
|
||||
messageId: string | null | undefined;
|
||||
enabled: boolean;
|
||||
}): CoachTrail | null {
|
||||
const { conversationId, messageId, enabled } = args;
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachMessageTrail(
|
||||
conversationId ?? "",
|
||||
messageId ?? "",
|
||||
),
|
||||
queryFn: async (): Promise<CoachTrail | null> => {
|
||||
const data = await apiGet<
|
||||
{ trail?: CoachTrail | null } | CoachTrail | null
|
||||
>(
|
||||
`/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/trail`,
|
||||
);
|
||||
if (!data) return null;
|
||||
if ("entries" in data) return data;
|
||||
return data.trail ?? null;
|
||||
},
|
||||
enabled: enabled && !!conversationId && !!messageId,
|
||||
// The trail is the stored snapshot of a finished turn; it never changes.
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
return query.data ?? null;
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
type EntryStatus = CoachActivity["status"];
|
||||
|
||||
function StatusIcon({
|
||||
status,
|
||||
active,
|
||||
}: {
|
||||
status: EntryStatus;
|
||||
active: boolean;
|
||||
}) {
|
||||
const className = "mt-0.5 size-3 shrink-0";
|
||||
if (status === "running" && active) {
|
||||
return (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
className,
|
||||
"text-muted-foreground animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (status === "done" || status === "running") {
|
||||
return (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-warning")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Minus
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rows of a legacy step list: one per step, in the order they started. */
|
||||
export function CoachTurnStepList({
|
||||
id,
|
||||
steps,
|
||||
active,
|
||||
}: {
|
||||
id?: string;
|
||||
steps: CoachStep[];
|
||||
active: boolean;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
return (
|
||||
<ol
|
||||
id={id}
|
||||
data-slot="coach-turn-steps-list"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{steps.map((step) => {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
data-slot="coach-turn-step"
|
||||
data-status={step.status}
|
||||
className="flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<StatusIcon status={step.status} active={active} />
|
||||
<span className="text-muted-foreground min-w-0">
|
||||
<span className="text-foreground">{title}</span>
|
||||
<StepMeta parts={meta} />
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function StepMeta({ parts }: { parts: string[] }) {
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
<span aria-hidden="true"> · </span>
|
||||
{/* The comma is for a screen reader only. `select-none` keeps it
|
||||
out of a copied row. */}
|
||||
<span className="sr-only select-none">, </span>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One trail entry. A fetch backed by a step reads like a step row (domain,
|
||||
* window, count); everything else shows its label and, once finished, how
|
||||
* long it took. A thinking round with reasoning text, and the memory entry
|
||||
* with the things it recalled, open their detail on their own tap.
|
||||
*/
|
||||
function ActivityEntry({
|
||||
entry,
|
||||
step,
|
||||
active,
|
||||
detail,
|
||||
}: {
|
||||
entry: CoachActivity;
|
||||
step: CoachStep | undefined;
|
||||
active: boolean;
|
||||
detail: { text?: string; recalled?: string[] } | null;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const detailId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
let title: string;
|
||||
let meta: string[] = [];
|
||||
if (entry.phase === "fetch" && step) {
|
||||
({ title, meta } = describeStep(step, t, tCount));
|
||||
} else {
|
||||
title = activityLineLabel(entry);
|
||||
if (entry.status !== "running" && entry.durationMs !== undefined) {
|
||||
meta = [
|
||||
t(ELAPSED_KEY, {
|
||||
seconds: Math.max(1, Math.round(entry.durationMs / 1000)),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
const hasDetail =
|
||||
!!detail?.text ||
|
||||
(detail?.recalled !== undefined && detail.recalled.length > 0);
|
||||
return (
|
||||
<li
|
||||
data-slot="coach-turn-step"
|
||||
data-phase={entry.phase}
|
||||
data-status={entry.status}
|
||||
className="relative flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<StatusIcon status={entry.status} active={active} />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{hasDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-turn-step-detail-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? detailId : undefined}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex w-fit max-w-full items-start gap-1 rounded text-left outline-none focus-visible:ring-2"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-foreground">{title}</span>
|
||||
<StepMeta parts={meta} />
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 size-3 shrink-0 motion-safe:transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground min-w-0">
|
||||
<span className="text-foreground">{title}</span>
|
||||
<StepMeta parts={meta} />
|
||||
</span>
|
||||
)}
|
||||
{hasDetail && open ? (
|
||||
<div
|
||||
id={detailId}
|
||||
data-slot="coach-turn-step-detail"
|
||||
className="text-foreground flex flex-col gap-1"
|
||||
>
|
||||
{detail?.text ? (
|
||||
<p className="whitespace-pre-wrap">{detail.text}</p>
|
||||
) : null}
|
||||
{detail?.recalled?.length ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{detail.recalled.map((fact) => (
|
||||
<li key={fact}>{fact}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open trail: the entries (or a legacy message's steps or areas), then
|
||||
* the method line, then the tables the answer used. One left rule holds
|
||||
* them together.
|
||||
*/
|
||||
function CoachTurnActivityPanel({
|
||||
id,
|
||||
activity,
|
||||
steps,
|
||||
active,
|
||||
areaLabels,
|
||||
trail,
|
||||
method,
|
||||
dataUsed,
|
||||
}: {
|
||||
id: string;
|
||||
activity: CoachActivity[];
|
||||
steps: CoachStep[];
|
||||
active: boolean;
|
||||
areaLabels: string[];
|
||||
trail: CoachTrail | null;
|
||||
method?: CoachMethod | null;
|
||||
dataUsed?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const stepsById = new Map(steps.map((step) => [step.id, step]));
|
||||
const textById = new Map(
|
||||
(trail?.entries ?? []).map((entry) => [entry.id, entry]),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
data-slot="coach-turn-steps-panel"
|
||||
className="border-border mt-1.5 ml-1.5 flex min-w-0 flex-col gap-2 border-l pl-2.5"
|
||||
>
|
||||
{activity.length > 0 ? (
|
||||
<ol
|
||||
data-slot="coach-turn-activity-list"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{activity.map((entry) => {
|
||||
const stored = textById.get(entry.id);
|
||||
const merged: CoachActivity = {
|
||||
...entry,
|
||||
title: entry.title ?? stored?.title,
|
||||
text: entry.text ?? stored?.text,
|
||||
};
|
||||
const detail =
|
||||
entry.phase === "memory"
|
||||
? { recalled: trail?.recalled }
|
||||
: merged.text
|
||||
? { text: merged.text }
|
||||
: null;
|
||||
return (
|
||||
<ActivityEntry
|
||||
key={entry.id}
|
||||
entry={merged}
|
||||
step={entry.stepRef ? stepsById.get(entry.stepRef) : undefined}
|
||||
active={active}
|
||||
detail={detail}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : steps.length > 0 ? (
|
||||
<CoachTurnStepList steps={steps} active={active} />
|
||||
) : areaLabels.length > 0 ? (
|
||||
<ul
|
||||
data-slot="coach-turn-areas"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{areaLabels.map((label) => (
|
||||
<li
|
||||
key={label}
|
||||
data-slot="coach-turn-area"
|
||||
className="text-foreground flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground mt-0.5 size-3 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!active && <CoachMethodLine method={method ?? null} />}
|
||||
{!active && dataUsed}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CoachTurnActivityProps {
|
||||
/** The live trail, or `metricSource.activity` of a persisted message. */
|
||||
activity: CoachActivity[];
|
||||
/** The steps the fetch entries point at; a legacy message's whole list. */
|
||||
steps: CoachStep[];
|
||||
/** True while the turn is still running. */
|
||||
active: boolean;
|
||||
/** When the live turn started and ended on this device (epoch ms). */
|
||||
startedAt?: number | null;
|
||||
endedAt?: number | null;
|
||||
/**
|
||||
* The provenance metrics, for a message saved before steps existed. Read
|
||||
* only when there is neither activity nor steps.
|
||||
*/
|
||||
areas?: readonly CoachProvenanceMetric[];
|
||||
/** How the answer was worked out; shown at the end of the open trail. */
|
||||
method?: CoachMethod | null;
|
||||
/**
|
||||
* The tables the answer read without pointing at them, shown last in the
|
||||
* open trail. Pass it only when there is something to show.
|
||||
*/
|
||||
dataUsed?: ReactNode;
|
||||
/** The persisted message, for reading its stored titles and texts. */
|
||||
conversationId?: string | null;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
export function CoachTurnActivity({
|
||||
activity,
|
||||
steps,
|
||||
active,
|
||||
startedAt,
|
||||
endedAt,
|
||||
areas,
|
||||
method,
|
||||
dataUsed,
|
||||
conversationId,
|
||||
messageId,
|
||||
}: CoachTurnActivityProps) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const panelId = useId();
|
||||
// Closed in every state; only the person's tap opens it, and it stays as
|
||||
// they left it (also across the swap to the persisted copy, which keeps
|
||||
// this component).
|
||||
const [open, setOpen] = useState(false);
|
||||
const now = useNow(active);
|
||||
|
||||
// Only a persisted message whose live entries carry no text asks the
|
||||
// server for it, and only once the trail is open.
|
||||
const needsStoredText =
|
||||
!active && activity.length > 0 && !activity.some((a) => a.title || a.text);
|
||||
const trail = useTrailText({
|
||||
conversationId,
|
||||
messageId,
|
||||
enabled: open && needsStoredText,
|
||||
});
|
||||
|
||||
const areaLabels =
|
||||
activity.length === 0 && steps.length === 0
|
||||
? legacyAreaLabels(areas, t)
|
||||
: [];
|
||||
const hasMethod = !active && !!method?.text;
|
||||
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
|
||||
|
||||
let line: string;
|
||||
if (active) {
|
||||
const entry = currentActivity(activity);
|
||||
const step = entry ? null : currentStep(steps);
|
||||
line = entry
|
||||
? activityLineLabel(entry)
|
||||
: step
|
||||
? stepLabel(step, t)
|
||||
: t(COACH_ACTIVITY_KEYS.thinking);
|
||||
} else {
|
||||
line =
|
||||
activitySummary({ activity, startedAt, endedAt, t, locale }) ??
|
||||
(steps.length > 0
|
||||
? tCount("coach.step.headerDone", countSources(steps))
|
||||
: areaLabels.length > 0
|
||||
? tCount("insights.coach.answer.areasDone", areaLabels.length)
|
||||
: t(COACH_STEP_UI_KEYS.listLabel));
|
||||
}
|
||||
const announcement = useLineAnnouncement(line, active);
|
||||
|
||||
// A settled message with nothing to show has no line at all.
|
||||
if (
|
||||
!active &&
|
||||
activity.length === 0 &&
|
||||
steps.length === 0 &&
|
||||
areaLabels.length === 0 &&
|
||||
!hasMethod &&
|
||||
!hasDataUsed
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seconds =
|
||||
active && startedAt
|
||||
? elapsedSeconds(startedAt, Math.max(now, startedAt))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-turn-steps"
|
||||
data-state={active ? "running" : "done"}
|
||||
className="flex w-full max-w-full min-w-0 flex-col self-stretch text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-turn-steps-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
// While the turn runs the visible text changes with every phase; the
|
||||
// status region announces it, so the button keeps a stable name.
|
||||
{...(active
|
||||
? {
|
||||
"aria-label": t(
|
||||
open
|
||||
? COACH_STEP_UI_KEYS.toggleHide
|
||||
: COACH_STEP_UI_KEYS.toggleShow,
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
// A 44 px target on phones whose extra height reaches into the gap
|
||||
// around it, so the line still sits close to the answer; beside a
|
||||
// pointer the line is as tall as the avatar it sits next to.
|
||||
"text-muted-foreground hover:text-foreground -my-1.5 flex min-h-11 w-full max-w-full min-w-0 items-center gap-1.5 sm:my-0 sm:min-h-8",
|
||||
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
data-slot="coach-turn-steps-spinner"
|
||||
className="size-3 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-3 shrink-0 motion-safe:transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="coach-turn-steps-active"
|
||||
className="min-w-0 truncate"
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
) : (
|
||||
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
|
||||
{line}
|
||||
</span>
|
||||
)}
|
||||
{seconds !== null ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="coach-turn-steps-seconds"
|
||||
className="ml-auto shrink-0 pl-2 tabular-nums"
|
||||
>
|
||||
{t(ELAPSED_KEY, { seconds })}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{open && (
|
||||
<CoachTurnActivityPanel
|
||||
id={panelId}
|
||||
activity={activity}
|
||||
steps={steps}
|
||||
active={active}
|
||||
areaLabels={areaLabels}
|
||||
trail={trail}
|
||||
method={method}
|
||||
dataUsed={hasDataUsed ? dataUsed : null}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.39.4 — what the Coach read on a turn: a compact list of steps above
|
||||
* the answer, live while the turn runs (from the `step` frames) and
|
||||
* restored from `metricSource.steps` on reload.
|
||||
*
|
||||
* While the turn runs the header names the step in progress ("Reading
|
||||
* Blood pressure, last 90 days…") with a soft shimmer; once it ends the
|
||||
* header folds into "Looked at 3 sources" and the list closes. The list
|
||||
* itself is one row per step: domain, window, and a count or the reason
|
||||
* nothing was found. Everything shown comes from catalog keys and the
|
||||
* server's enums and counts; a row never shows a value, a tool argument or
|
||||
* model text.
|
||||
*
|
||||
* The open list also says how the answer was worked out (the method line)
|
||||
* and holds the tables the answer read without pointing at them. An older
|
||||
* message saved before steps existed folds into "Looked at 3 areas" and
|
||||
* lists the areas from its provenance, without counts; a metric token the
|
||||
* bundle has no name for is left out rather than shown as a raw key.
|
||||
*
|
||||
* Screen readers hear each completed step once, through a polite status
|
||||
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
|
||||
* wins). A reloaded conversation announces nothing.
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Minus,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
COACH_STEP_REASON_KEYS,
|
||||
COACH_STEP_UI_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachGranularityLabelKey,
|
||||
coachPeriodLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import type {
|
||||
CoachMethod,
|
||||
CoachProvenanceMetric,
|
||||
CoachStep,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachMethodLine } from "./method-line";
|
||||
|
||||
export interface CoachTurnStepsProps {
|
||||
steps: CoachStep[];
|
||||
/** True while the turn is still running. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps {
|
||||
/**
|
||||
* The provenance metrics, for a message saved before steps existed. Read
|
||||
* only when `steps` is empty.
|
||||
*/
|
||||
areas?: readonly CoachProvenanceMetric[];
|
||||
/** How the answer was worked out; shown at the end of the open list. */
|
||||
method?: CoachMethod | null;
|
||||
/**
|
||||
* The tables the answer read without pointing at them, shown last in the
|
||||
* open list. Pass it only when there is something to show.
|
||||
*/
|
||||
dataUsed?: ReactNode;
|
||||
}
|
||||
|
||||
/** The shortest gap between two screen-reader announcements. */
|
||||
export const ANNOUNCE_THROTTLE_MS = 1500;
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
type TranslateCount = (
|
||||
baseKey: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
|
||||
function tOr(
|
||||
t: Translate,
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>,
|
||||
) {
|
||||
const value = t(key, params);
|
||||
return value === key ? fallback : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's label in the reader's language: the catalog key rendered with
|
||||
* the domain and window names, or the server's label when the key is not
|
||||
* in the bundle.
|
||||
*/
|
||||
export function stepLabel(step: CoachStep, t: Translate): string {
|
||||
return tOr(t, step.labelKey, step.label, {
|
||||
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
|
||||
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One row's text: the domain as the title, then the window, period,
|
||||
* granularity, and either how much was read or why nothing was.
|
||||
*/
|
||||
export function describeStep(
|
||||
step: CoachStep,
|
||||
t: Translate,
|
||||
tCount: TranslateCount,
|
||||
): { title: string; meta: string[] } {
|
||||
const title = step.domain
|
||||
? t(coachDomainLabelKey(step.domain))
|
||||
: stepLabel(step, t);
|
||||
const meta: string[] = [];
|
||||
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
|
||||
if (step.period && step.period !== "current") {
|
||||
meta.push(t(coachPeriodLabelKey(step.period)));
|
||||
}
|
||||
if (step.granularity)
|
||||
meta.push(t(coachGranularityLabelKey(step.granularity)));
|
||||
if (step.status === "done" && step.count !== undefined) {
|
||||
const base =
|
||||
step.tool === "snapshot"
|
||||
? "coach.step.metrics"
|
||||
: step.tool === "get_labs"
|
||||
? "coach.step.rows"
|
||||
: "coach.step.readings";
|
||||
meta.push(tCount(base, step.count));
|
||||
} else if (step.status !== "running" && step.reason) {
|
||||
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
|
||||
}
|
||||
return { title, meta };
|
||||
}
|
||||
|
||||
function rowText(step: CoachStep, t: Translate, tCount: TranslateCount) {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return [title, ...meta].join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* How many sources the turn looked at: one per domain (a step without one,
|
||||
* the snapshot, counts by its tool). A second read of the same domain, for
|
||||
* the period before or a retry, is not a new source.
|
||||
*/
|
||||
export function countSources(steps: readonly CoachStep[]): number {
|
||||
return new Set(steps.map((step) => step.domain ?? step.tool)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step the active header names: the latest one still running, else the
|
||||
* latest one overall (between two rounds nothing is running).
|
||||
*/
|
||||
export function currentStep(steps: CoachStep[]): CoachStep | null {
|
||||
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
||||
if (steps[i].status === "running") return steps[i];
|
||||
}
|
||||
return steps.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest settled step the reader has not been told about yet, given the
|
||||
* ids already announced (or present when the list mounted). Null when there
|
||||
* is nothing new.
|
||||
*/
|
||||
export function nextAnnouncement(
|
||||
steps: CoachStep[],
|
||||
told: ReadonlySet<string>,
|
||||
): CoachStep | null {
|
||||
let latest: CoachStep | null = null;
|
||||
for (const step of steps) {
|
||||
if (step.status !== "running" && !told.has(step.id)) latest = step;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status region's text: each newly settled step, throttled. Steps that
|
||||
* were already settled when the list mounted are never announced, so a
|
||||
* reloaded conversation stays quiet.
|
||||
*/
|
||||
function useStepAnnouncement(
|
||||
steps: CoachStep[],
|
||||
describe: (step: CoachStep) => string,
|
||||
): string {
|
||||
// Mutable on purpose: the ids the reader has been told about, seeded with
|
||||
// whatever was already settled at mount.
|
||||
const [told] = useState(
|
||||
() => new Set(steps.filter((s) => s.status !== "running").map((s) => s.id)),
|
||||
);
|
||||
const lastAt = useRef(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const next = nextAnnouncement(steps, told);
|
||||
if (!next) return;
|
||||
for (const step of steps) {
|
||||
if (step.status !== "running") told.add(step.id);
|
||||
}
|
||||
const nextText = describe(next);
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
|
||||
);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
lastAt.current = Date.now();
|
||||
setText(nextText);
|
||||
}, wait);
|
||||
}, [steps, describe, told]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
|
||||
const className = "mt-0.5 size-3 shrink-0";
|
||||
if (step.status === "running" && active) {
|
||||
return (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
className,
|
||||
"text-muted-foreground animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.status === "done") {
|
||||
return (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.status === "failed") {
|
||||
return (
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-warning")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Minus
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The areas an older message drew on, in the order its provenance lists
|
||||
* them, by their display names. A token without a name in the bundle is
|
||||
* dropped, and so is `general`, which names no area of the record.
|
||||
*/
|
||||
export function legacyAreaLabels(
|
||||
areas: readonly CoachProvenanceMetric[] | undefined,
|
||||
t: Translate,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const area of areas ?? []) {
|
||||
if (area === "general") continue;
|
||||
const key = `insights.coach.metric.${area}`;
|
||||
const label = t(key);
|
||||
if (label === key || out.includes(label)) continue;
|
||||
out.push(label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The rows under the header: one per step, in the order they started. */
|
||||
export function CoachTurnStepList({
|
||||
id,
|
||||
steps,
|
||||
active,
|
||||
}: CoachTurnStepsProps & { id?: string }) {
|
||||
const { t, tCount } = useTranslations();
|
||||
return (
|
||||
<ol
|
||||
id={id}
|
||||
data-slot="coach-turn-steps-list"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{steps.map((step) => {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
data-slot="coach-turn-step"
|
||||
data-status={step.status}
|
||||
className="flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<StepIcon step={step} active={active} />
|
||||
<span className="text-muted-foreground min-w-0">
|
||||
<span className="text-foreground/80">{title}</span>
|
||||
{meta.map((part, i) => (
|
||||
<span key={i}>
|
||||
<span aria-hidden="true"> · </span>
|
||||
{/* The comma is for a screen reader only. `select-none`
|
||||
keeps it out of a copied row, which otherwise read
|
||||
"Weight · ,all time". */}
|
||||
<span className="sr-only select-none">, </span>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open list: the steps (or an older message's areas), then the method
|
||||
* line, then the tables the answer read. One left rule holds them together.
|
||||
*/
|
||||
export function CoachTurnStepsPanel({
|
||||
id,
|
||||
steps,
|
||||
active,
|
||||
areaLabels,
|
||||
method,
|
||||
dataUsed,
|
||||
}: CoachTurnStepsProps & {
|
||||
id?: string;
|
||||
areaLabels: string[];
|
||||
method?: CoachMethod | null;
|
||||
dataUsed?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
data-slot="coach-turn-steps-panel"
|
||||
className="border-border/60 mt-1.5 ml-1.5 flex min-w-0 flex-col gap-2 border-l pl-2.5"
|
||||
>
|
||||
{steps.length > 0 ? (
|
||||
<CoachTurnStepList steps={steps} active={active} />
|
||||
) : areaLabels.length > 0 ? (
|
||||
<ul
|
||||
data-slot="coach-turn-areas"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{areaLabels.map((label) => (
|
||||
<li
|
||||
key={label}
|
||||
data-slot="coach-turn-area"
|
||||
className="text-foreground/80 flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground mt-0.5 size-3 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!active && <CoachMethodLine method={method ?? null} />}
|
||||
{!active && dataUsed}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachTurnSteps({
|
||||
steps,
|
||||
active,
|
||||
areas,
|
||||
method,
|
||||
dataUsed,
|
||||
}: CoachTurnStepsDetailProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const listId = useId();
|
||||
// Closed by default; the reader's toggle holds until the turn ends, and
|
||||
// the list folds away again when it does.
|
||||
const [openOverride, setOpenOverride] = useState<boolean | null>(null);
|
||||
const [wasActive, setWasActive] = useState(active);
|
||||
if (wasActive !== active) {
|
||||
setWasActive(active);
|
||||
setOpenOverride(null);
|
||||
}
|
||||
const announcement = useStepAnnouncement(steps, (step) =>
|
||||
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
|
||||
);
|
||||
|
||||
const areaLabels = steps.length === 0 ? legacyAreaLabels(areas, t) : [];
|
||||
const hasMethod = !active && !!method?.text;
|
||||
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
|
||||
if (
|
||||
steps.length === 0 &&
|
||||
areaLabels.length === 0 &&
|
||||
!hasMethod &&
|
||||
!hasDataUsed
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const open = openOverride ?? false;
|
||||
const current = active ? currentStep(steps) : null;
|
||||
const header = current
|
||||
? null
|
||||
: steps.length > 0
|
||||
? tCount("coach.step.headerDone", countSources(steps))
|
||||
: areaLabels.length > 0
|
||||
? tCount("insights.coach.answer.areasDone", areaLabels.length)
|
||||
: t(COACH_STEP_UI_KEYS.listLabel);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-turn-steps"
|
||||
className="flex w-full max-w-full min-w-0 flex-col self-stretch text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-turn-steps-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
// While the turn runs the visible text changes with every step; the
|
||||
// status region below announces completions, so the button keeps a
|
||||
// stable name instead of re-announcing each label.
|
||||
{...(current
|
||||
? {
|
||||
"aria-label": t(
|
||||
open
|
||||
? COACH_STEP_UI_KEYS.toggleHide
|
||||
: COACH_STEP_UI_KEYS.toggleShow,
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
onClick={() => setOpenOverride(!open)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-fit max-w-full items-center gap-1.5",
|
||||
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-3 shrink-0 motion-safe:transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{current ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="coach-turn-steps-active"
|
||||
className="skeleton-shimmer min-w-0 truncate rounded"
|
||||
>
|
||||
{t(COACH_STEP_UI_KEYS.headerActive, {
|
||||
label: stepLabel(current, t),
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
|
||||
{header}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<CoachTurnStepsPanel
|
||||
id={listId}
|
||||
steps={steps}
|
||||
active={active}
|
||||
areaLabels={areaLabels}
|
||||
method={method}
|
||||
dataUsed={hasDataUsed ? dataUsed : null}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — what the Coach remembers, as `GET /api/insights/coach/facts` lists
|
||||
* it: the one read behind the memory list in Settings → Coach, the memory
|
||||
* link's count in the Coach's quick settings and the memory note under an
|
||||
* answer. One query function for the one key, so the surfaces share a cache
|
||||
* entry and never poison it with two shapes.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { CoachMemoryCategory } from "@/lib/ai/coach/types";
|
||||
import type { CoachFactSource } from "@/lib/ai/coach/memory/shared";
|
||||
|
||||
export interface CoachFactDTO {
|
||||
id: string;
|
||||
/** A v1.41 category, or an older extraction's. */
|
||||
category: CoachMemoryCategory;
|
||||
text: string;
|
||||
confidence: number;
|
||||
/** Absent on a server older than v1.41. */
|
||||
source?: CoachFactSource;
|
||||
sourceConversationId?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export async function fetchCoachFacts(): Promise<CoachFactDTO[]> {
|
||||
const data = await apiGet<{ facts?: CoachFactDTO[] } | undefined>(
|
||||
"/api/insights/coach/facts",
|
||||
);
|
||||
return data?.facts ?? [];
|
||||
}
|
||||
|
||||
export function useCoachFacts(opts: { enabled?: boolean } = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.coachFacts(),
|
||||
queryFn: fetchCoachFacts,
|
||||
enabled: opts.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget one fact (soft delete); the list refetches. */
|
||||
export function useForgetCoachFact(
|
||||
opts: {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.coachFacts(),
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
opts.onSuccess?.();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
|
||||
},
|
||||
onError: () => opts.onError?.(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Rewrite one fact's wording; the category and the source stay. */
|
||||
export function useEditCoachFact(
|
||||
opts: {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.coachFacts(),
|
||||
mutationFn: async (input: { id: string; fact: string }) =>
|
||||
apiPatch(`/api/insights/coach/facts/${encodeURIComponent(input.id)}`, {
|
||||
fact: input.fact,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
opts.onSuccess?.();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
|
||||
},
|
||||
onError: () => opts.onError?.(),
|
||||
});
|
||||
}
|
||||
@@ -11,15 +11,19 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type {
|
||||
CoachActivity,
|
||||
CoachClarification,
|
||||
CoachConversationAttachmentDTO,
|
||||
CoachConversationDetailDTO,
|
||||
CoachConversationsPage,
|
||||
CoachFollowUp,
|
||||
CoachMemoryNote,
|
||||
CoachPlanProposal,
|
||||
CoachProvenance,
|
||||
CoachResultTable,
|
||||
CoachScope,
|
||||
CoachStep,
|
||||
CoachStop,
|
||||
CoachStreamEvent,
|
||||
CoachSuggestion,
|
||||
CoachUsage,
|
||||
@@ -558,6 +562,31 @@ export interface CoachStreamingMessage {
|
||||
followUps: CoachFollowUp[];
|
||||
/** v1.39.4 — the choices from the additive `clarification` frame. */
|
||||
clarification: CoachClarification | null;
|
||||
/**
|
||||
* v1.41 — the live trail from the additive `activity` frames, upserted by
|
||||
* id in first-seen order. Persisted messages carry the metadata on
|
||||
* `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. */
|
||||
@@ -640,6 +669,13 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
|
||||
results: [],
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
interimRefs: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
@@ -655,6 +691,47 @@ export function upsertStep(steps: CoachStep[], step: CoachStep): CoachStep[] {
|
||||
return next;
|
||||
}
|
||||
|
||||
/** v1.41 — insert a trail entry, or replace the one with the same id. */
|
||||
export function upsertActivity(
|
||||
activity: CoachActivity[],
|
||||
entry: CoachActivity,
|
||||
): CoachActivity[] {
|
||||
const at = activity.findIndex((a) => a.id === entry.id);
|
||||
if (at === -1) return [...activity, entry];
|
||||
const next = [...activity];
|
||||
// A later frame may leave out what an earlier one carried (the title on a
|
||||
// `done` frame that only adds the duration); keep it.
|
||||
next[at] = { ...next[at], ...entry };
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — insert a table, or replace the one with the same ref. An interim
|
||||
* table and the final frame for it share a ref.
|
||||
*/
|
||||
export function upsertResult(
|
||||
results: CoachResultTable[],
|
||||
result: CoachResultTable,
|
||||
): CoachResultTable[] {
|
||||
const at = results.findIndex((r) => r.ref === result.ref);
|
||||
if (at === -1) return [...results, result];
|
||||
const next = [...results];
|
||||
next[at] = result;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — the stream's state after `done`: a turn blocked after its interim
|
||||
* tables went out takes them down again.
|
||||
*/
|
||||
export function withoutWithheldResults(
|
||||
results: CoachResultTable[],
|
||||
interimRefs: readonly string[],
|
||||
): CoachResultTable[] {
|
||||
const withheld = new Set(interimRefs);
|
||||
return results.filter((r) => !withheld.has(r.ref));
|
||||
}
|
||||
|
||||
export interface SendCoachMessageParams {
|
||||
conversationId?: string;
|
||||
message: string;
|
||||
@@ -709,6 +786,13 @@ export interface SendCoachMessageParams {
|
||||
* tapped (absent when the person typed their own answer). Tool route only.
|
||||
*/
|
||||
clarification?: { messageId: string; choiceId?: string };
|
||||
/**
|
||||
* v1.41 — the person answered a fact proposal with a reply pill. The
|
||||
* server reads the fact from what it stored on that message.
|
||||
*/
|
||||
memoryDecision?: { messageId: string; proposalId: string; accept: boolean };
|
||||
/** v1.41 — the person answered a plan proposal with a reply pill. */
|
||||
planDecision?: { messageId: string; planId: string; accept: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,6 +842,8 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
|
||||
workoutId: params.workoutId,
|
||||
followUp: params.followUp,
|
||||
clarification: params.clarification,
|
||||
memoryDecision: params.memoryDecision,
|
||||
planDecision: params.planDecision,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -902,9 +988,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: params.message,
|
||||
conversationId: params.conversationId ?? null,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
setStreaming({
|
||||
...EMPTY_STREAMING,
|
||||
inProgress: true,
|
||||
startedAt,
|
||||
});
|
||||
|
||||
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
|
||||
@@ -1005,9 +1093,15 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
let collectedSuggestion: CoachSuggestion | null = null;
|
||||
let collectedSuggestedAction: CoachSuggestedAction | null = null;
|
||||
let collectedSteps: CoachStep[] = [];
|
||||
const collectedResults: CoachResultTable[] = [];
|
||||
let collectedResults: CoachResultTable[] = [];
|
||||
let collectedFollowUps: CoachFollowUp[] = [];
|
||||
let collectedClarification: CoachClarification | null = null;
|
||||
let collectedActivity: CoachActivity[] = [];
|
||||
const interimRefs: string[] = [];
|
||||
let collectedMemoryNote: CoachMemoryNote | null = null;
|
||||
let collectedPlanProposal: CoachPlanProposal | null = null;
|
||||
let collectedStop: CoachStop | null = null;
|
||||
let withheldResults = false;
|
||||
let lastError: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let collectedUsage: CoachUsage | null = null;
|
||||
@@ -1061,13 +1155,39 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
}
|
||||
case "result": {
|
||||
const result = evt.result;
|
||||
collectedResults.push(result);
|
||||
collectedResults = upsertResult(collectedResults, result);
|
||||
if (evt.interim && !interimRefs.includes(result.ref)) {
|
||||
interimRefs.push(result.ref);
|
||||
}
|
||||
const refs = [...interimRefs];
|
||||
setStreaming((prev) => ({
|
||||
...prev,
|
||||
results: [...prev.results, result],
|
||||
results: upsertResult(prev.results, result),
|
||||
interimRefs: refs,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "activity": {
|
||||
// v1.41 — the live trail, upserted by entry id.
|
||||
const entry = evt.activity;
|
||||
collectedActivity = upsertActivity(collectedActivity, entry);
|
||||
setStreaming((prev) => ({
|
||||
...prev,
|
||||
activity: upsertActivity(prev.activity, entry),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "memoryNote":
|
||||
collectedMemoryNote = evt.note;
|
||||
setStreaming((prev) => ({ ...prev, memoryNote: evt.note }));
|
||||
break;
|
||||
case "planProposal":
|
||||
collectedPlanProposal = evt.proposal;
|
||||
setStreaming((prev) => ({
|
||||
...prev,
|
||||
planProposal: evt.proposal,
|
||||
}));
|
||||
break;
|
||||
case "followUps":
|
||||
collectedFollowUps = evt.followUps;
|
||||
setStreaming((prev) => ({
|
||||
@@ -1093,6 +1213,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId = evt.messageId;
|
||||
// v1.18.9 — additive per-turn usage envelope.
|
||||
collectedUsage = evt.usage ?? null;
|
||||
// v1.41 — why the answer was forced, and whether the
|
||||
// interim tables must come down again.
|
||||
collectedStop = evt.stop ?? null;
|
||||
withheldResults = evt.withheldResults === true;
|
||||
break;
|
||||
case "error":
|
||||
lastError = evt.code;
|
||||
@@ -1107,6 +1231,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
resolvedConversationId = evt.conversationId;
|
||||
messageId = evt.messageId;
|
||||
collectedUsage = evt.usage ?? null;
|
||||
collectedStop = evt.stop ?? null;
|
||||
withheldResults = evt.withheldResults === true;
|
||||
} else if (evt.type === "error") {
|
||||
lastError = evt.code;
|
||||
}
|
||||
@@ -1117,6 +1243,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (withheldResults) {
|
||||
collectedResults = withoutWithheldResults(
|
||||
collectedResults,
|
||||
interimRefs,
|
||||
);
|
||||
}
|
||||
setStreaming({
|
||||
content: collectedContent,
|
||||
metricSource: collectedProvenance,
|
||||
@@ -1126,6 +1258,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
results: collectedResults,
|
||||
followUps: collectedFollowUps,
|
||||
clarification: collectedClarification,
|
||||
activity: collectedActivity,
|
||||
interimRefs: withheldResults ? [] : interimRefs,
|
||||
memoryNote: collectedMemoryNote,
|
||||
planProposal: collectedPlanProposal,
|
||||
stop: collectedStop,
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
inProgress: false,
|
||||
messageId,
|
||||
errorCode: lastError,
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
reason: coachState.available ? null : "no_provider",
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
// v1.41 — the thinking depth reads the resolved block; none here.
|
||||
useCoachReasoning: () => null,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
|
||||
@@ -25,10 +25,17 @@ import { CoachMemorySection } from "../coach-memory-section";
|
||||
|
||||
interface SeedFact {
|
||||
id: string;
|
||||
category: "preference" | "condition" | "goal" | "constraint" | "context";
|
||||
category:
|
||||
| "preference"
|
||||
| "condition"
|
||||
| "goal"
|
||||
| "constraint"
|
||||
| "context"
|
||||
| "medication";
|
||||
text: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
source?: "user" | "coach" | "extracted" | "pattern";
|
||||
}
|
||||
|
||||
const NOW = "2026-06-04T12:00:00.000Z";
|
||||
@@ -152,4 +159,45 @@ describe("Settings — CoachMemorySection", () => {
|
||||
expect(withFacts).toContain("rolling summary");
|
||||
expect(empty).toContain("rolling summary");
|
||||
});
|
||||
|
||||
it("says where each fact came from, and offers to edit it", () => {
|
||||
const html = render([
|
||||
{ ...buildFacts()[0], source: "user" },
|
||||
{ ...buildFacts()[1], source: "coach" },
|
||||
{ ...buildFacts()[2], source: "extracted" },
|
||||
{
|
||||
id: "fact-med-1",
|
||||
category: "medication",
|
||||
text: "Takes ramipril in the morning",
|
||||
confidence: 1,
|
||||
createdAt: NOW,
|
||||
source: "user",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
html.match(/data-slot="settings-coach-memory-source"/g),
|
||||
).toHaveLength(4);
|
||||
expect(html).toContain("from you");
|
||||
expect(html).toContain("from the Coach");
|
||||
expect(html.match(/data-slot="settings-coach-memory-edit"/g)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="settings-coach-memory-edit"[^>]*aria-label="Edit"/,
|
||||
);
|
||||
// Medications get their own group.
|
||||
expect(html).toContain(
|
||||
'data-testid="settings-coach-memory-group-medication"',
|
||||
);
|
||||
expect(html).toContain("Medications");
|
||||
// The quick settings land here.
|
||||
expect(html).toMatch(/<section[^>]*id="coach-memory"/);
|
||||
});
|
||||
|
||||
it("lists goals first", () => {
|
||||
const html = render(buildFacts());
|
||||
expect(html.indexOf("group-goal")).toBeLessThan(
|
||||
html.indexOf("group-preference"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
reason: null,
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
// v1.41 — the thinking depth reads the resolved block; none here.
|
||||
useCoachReasoning: () => null,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
// v1.41 — the thinking depth reads the resolved block; none here.
|
||||
useCoachReasoning: () => null,
|
||||
}));
|
||||
|
||||
const authState: {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
|
||||
* Same Label + NativeSelect + hint layout as the model field above it.
|
||||
* The empty value is Default: nothing is sent and the model decides.
|
||||
* v1.41 — the Coach reads its own thinking depth from the Coach settings;
|
||||
* a line under the hint says so.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -56,6 +58,14 @@ export function ReasoningEffortField({
|
||||
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.reasoning.hint")}
|
||||
</p>
|
||||
{/* v1.41 — the Coach has its own thinking depth; this select is the
|
||||
default for everything else the provider does. */}
|
||||
<p
|
||||
data-slot="reasoning-effort-coach-note"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t("settings.ai.reasoning.coachNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,12 @@
|
||||
* every read/write routes its key through `queryKeys.coachFacts()` so
|
||||
* a forget invalidates the list.
|
||||
*
|
||||
* v1.41 — the Coach's one memory. Each entry says where it came from
|
||||
* ("from you" for what the person asked it to keep, "from the Coach" for what
|
||||
* it kept or worked out itself), and can be edited in place
|
||||
* (`PATCH /api/insights/coach/facts/{id}`) as well as forgotten. The Coach's
|
||||
* quick settings link here (`#coach-memory`).
|
||||
*
|
||||
* v1.39 — never gated on the Coach. What the Coach stored is the person's
|
||||
* own record: it stays readable and deletable while the Coach is off for any
|
||||
* reason (the operator's switch, Hide Coach, no provider, no consent). The
|
||||
@@ -24,9 +30,9 @@
|
||||
* shows this card; while that page is not reachable, Settings → AI shows it
|
||||
* through `<StoredCoachMemory>` below, whenever rows exist.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Brain, Loader2, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Brain, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -50,42 +56,192 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
COACH_MEMORY_LIST_KEYS,
|
||||
REMEMBER_FACT_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/memory/shared";
|
||||
import {
|
||||
useCoachFacts,
|
||||
useEditCoachFact,
|
||||
useForgetCoachFact,
|
||||
type CoachFactDTO,
|
||||
} from "@/components/insights/coach-panel/use-coach-facts";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card";
|
||||
|
||||
/** Closed enum mirrored from the server `CoachFact.category` column. */
|
||||
/**
|
||||
* The categories in the order the list shows them: goals first, then how the
|
||||
* person likes things, then health, then the rest.
|
||||
*/
|
||||
const FACT_CATEGORIES = [
|
||||
"goal",
|
||||
"preference",
|
||||
"condition",
|
||||
"goal",
|
||||
"medication",
|
||||
"constraint",
|
||||
"context",
|
||||
] as const;
|
||||
type FactCategory = (typeof FACT_CATEGORIES)[number];
|
||||
|
||||
interface CoachFact {
|
||||
id: string;
|
||||
category: FactCategory;
|
||||
text: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<FactCategory, string> = {
|
||||
goal: "settings.ai.coachMemory.categoryGoal",
|
||||
preference: "settings.ai.coachMemory.categoryPreference",
|
||||
condition: "settings.ai.coachMemory.categoryCondition",
|
||||
goal: "settings.ai.coachMemory.categoryGoal",
|
||||
medication: COACH_MEMORY_LIST_KEYS.categoryMedication,
|
||||
constraint: "settings.ai.coachMemory.categoryConstraint",
|
||||
context: "settings.ai.coachMemory.categoryContext",
|
||||
};
|
||||
|
||||
async function fetchFacts(): Promise<CoachFact[]> {
|
||||
const data = await apiGet<{ facts?: CoachFact[] } | undefined>(
|
||||
"/api/insights/coach/facts",
|
||||
/** "from you" for what the person asked to keep; everything else the Coach. */
|
||||
export function factSourceKey(
|
||||
fact: Pick<CoachFactDTO, "source">,
|
||||
): string | null {
|
||||
if (!fact.source) return null;
|
||||
return fact.source === "user"
|
||||
? COACH_MEMORY_LIST_KEYS.sourceUser
|
||||
: COACH_MEMORY_LIST_KEYS.sourceCoach;
|
||||
}
|
||||
|
||||
/** One entry, read or being edited. */
|
||||
function FactRow({
|
||||
fact,
|
||||
isAuthenticated,
|
||||
forgetting,
|
||||
onForget,
|
||||
}: {
|
||||
fact: CoachFactDTO;
|
||||
isAuthenticated: boolean;
|
||||
forgetting: boolean;
|
||||
onForget: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(fact.text);
|
||||
const edit = useEditCoachFact({
|
||||
// The edited words in place are the confirmation.
|
||||
onSuccess: () => setEditing(false),
|
||||
onError: () => toast.error(t("admin.settingsSaveError")),
|
||||
});
|
||||
const sourceKey = factSourceKey(fact);
|
||||
const trimmed = draft.trim();
|
||||
const inputId = `coach-memory-edit-${fact.id}`;
|
||||
return (
|
||||
<li
|
||||
data-testid="settings-coach-memory-fact"
|
||||
data-source={fact.source}
|
||||
className="border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{editing ? (
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (trimmed && trimmed !== fact.text) {
|
||||
edit.mutate({ id: fact.id, fact: trimmed });
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
{t(COACH_MEMORY_LIST_KEYS.edit)}
|
||||
</label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
data-slot="settings-coach-memory-edit-input"
|
||||
value={draft}
|
||||
maxLength={REMEMBER_FACT_MAX_CHARS}
|
||||
rows={2}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setDraft(fact.text);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="settings-coach-memory-edit-save"
|
||||
disabled={!trimmed || edit.isPending}
|
||||
>
|
||||
{edit.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.ai.saveCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm break-words">{fact.text}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.coachMemory.learnedPrefix", {
|
||||
when: formatDateOrRelative(fact.createdAt, t),
|
||||
})}
|
||||
{sourceKey ? (
|
||||
<span data-slot="settings-coach-memory-source">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span className="sr-only">, </span>
|
||||
{t(sourceKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="settings-coach-memory-edit"
|
||||
aria-label={t(COACH_MEMORY_LIST_KEYS.edit)}
|
||||
title={t(COACH_MEMORY_LIST_KEYS.edit)}
|
||||
disabled={!isAuthenticated}
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => {
|
||||
setDraft(fact.text);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
slot="settings-coach-memory-forget"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.coachMemory.forget")}
|
||||
title={t("settings.ai.coachMemory.forgetTitle")}
|
||||
body={t("settings.ai.coachMemory.forgetBody")}
|
||||
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
|
||||
disabled={!isAuthenticated}
|
||||
pending={forgetting}
|
||||
onConfirm={onForget}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
return data?.facts ?? [];
|
||||
}
|
||||
|
||||
export function CoachMemorySection({
|
||||
@@ -99,25 +255,11 @@ export function CoachMemorySection({
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachFacts(),
|
||||
queryFn: fetchFacts,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const query = useCoachFacts({ enabled: isAuthenticated });
|
||||
|
||||
const forgetOne = useMutation({
|
||||
mutationKey: queryKeys.coachFacts(),
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("settings.ai.coachMemory.forgotToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("settings.ai.coachMemory.forgotError"));
|
||||
},
|
||||
const forgetOne = useForgetCoachFact({
|
||||
onSuccess: () => toast.success(t("settings.ai.coachMemory.forgotToast")),
|
||||
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
|
||||
});
|
||||
|
||||
const forgetAll = useMutation({
|
||||
@@ -144,9 +286,16 @@ export function CoachMemorySection({
|
||||
// Group by category in the canonical category order so the panel
|
||||
// reads the same way every render regardless of insertion order.
|
||||
const grouped = useMemo(() => {
|
||||
// A category the list does not know (an older extraction's) reads as
|
||||
// context rather than vanishing.
|
||||
const known = new Set<string>(FACT_CATEGORIES);
|
||||
return FACT_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
items: facts.filter((f) => f.category === category),
|
||||
items: facts.filter((f) =>
|
||||
known.has(f.category)
|
||||
? f.category === category
|
||||
: category === "context",
|
||||
),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}, [facts]);
|
||||
|
||||
@@ -156,6 +305,8 @@ export function CoachMemorySection({
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
id="coach-memory"
|
||||
className="scroll-mt-28"
|
||||
aria-labelledby="settings-ai-coach-memory-title"
|
||||
data-testid="settings-coach-memory-card"
|
||||
>
|
||||
@@ -193,42 +344,16 @@ export function CoachMemorySection({
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{group.items.map((fact) => (
|
||||
<li
|
||||
<FactRow
|
||||
key={fact.id}
|
||||
data-testid="settings-coach-memory-fact"
|
||||
className="border-border bg-background flex items-start justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm break-words">{fact.text}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.coachMemory.learnedPrefix", {
|
||||
when: formatDateOrRelative(fact.createdAt, t),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmButton
|
||||
slot="settings-coach-memory-forget"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.coachMemory.forget")}
|
||||
title={t("settings.ai.coachMemory.forgetTitle")}
|
||||
body={t("settings.ai.coachMemory.forgetBody")}
|
||||
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
|
||||
disabled={!isAuthenticated}
|
||||
// v1.11.2 — per-id pending: only the row being
|
||||
// deleted disables/spins. `forgetOne.variables` holds
|
||||
// the id passed to the in-flight `mutate()`, so a
|
||||
// single shared mutation no longer greys out every
|
||||
// other row's forget button during one delete.
|
||||
pending={
|
||||
forgetOne.isPending && forgetOne.variables === fact.id
|
||||
}
|
||||
onConfirm={() => forgetOne.mutate(fact.id)}
|
||||
/>
|
||||
</li>
|
||||
fact={fact}
|
||||
isAuthenticated={isAuthenticated}
|
||||
// Per-id pending: only the row being deleted spins.
|
||||
forgetting={
|
||||
forgetOne.isPending && forgetOne.variables === fact.id
|
||||
}
|
||||
onForget={() => forgetOne.mutate(fact.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -39,13 +39,18 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
CoachReasoningSelect,
|
||||
reasoningStateOrDefault,
|
||||
} from "@/components/insights/coach-panel/coach-reasoning-field";
|
||||
import { useAiCapability, useCoachReasoning } from "@/hooks/use-ai-capability";
|
||||
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { HISTORY_REACH_DAYS, lookbackText } from "@/lib/ai/coach/history-reach";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachDataClusterEnum,
|
||||
coachReasoningLevel,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
@@ -106,6 +111,7 @@ export interface CoachPrefsSectionProps {
|
||||
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const coach = useAiCapability("coach");
|
||||
const reasoningState = reasoningStateOrDefault(useCoachReasoning());
|
||||
|
||||
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
|
||||
|
||||
@@ -252,6 +258,17 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.41 — how hard the Coach thinks; the same field sits in the
|
||||
Coach's quick settings. */}
|
||||
<CoachReasoningSelect
|
||||
id="coach-prefs-reasoning"
|
||||
value={coachReasoningLevel(draft)}
|
||||
state={reasoningState}
|
||||
onChange={(reasoning) =>
|
||||
setDraft((prev) => ({ ...prev, reasoning }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Data clusters + analysis window live on ONE owner: "What I
|
||||
can see" behind the Coach's settings gear, which
|
||||
`/coach?settings=data` opens directly. This card used to render
|
||||
|
||||
Reference in new issue
Block a user