Merge the Coach interface polish into release/v1.41.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 21:37:29 +02:00
commit bd7a354ddd
37 files changed
+288 -92

No files matched your search

+13 -4
View File
@@ -18,9 +18,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* are not text on the row; the details icon shows them in a tooltip on
* keyboard focus (desktop) and on a tap (phone); Enter and Space keep it
* open, and a second tap or Escape closes it.
* 2. The follow-up chips sit in the answer's column: their left edge is
* the bubble's, their text is the answer's size, and a chip is at most
* 36 px tall beside a pointer and at least 44 px on a phone.
* 2. The follow-up chips sit in the answer's column, right under the
* answer and above the action row (the row is the last thing in the
* column): their left edge is the bubble's, their text is the
* answer's size, and a chip is at most 36 px tall beside a pointer and
* at least 44 px on a phone.
* 3. Under a question, copy and remember share one row; nothing stacks
* below it.
* 4. Scrolled away from the end, a round button appears; it brings the
@@ -311,7 +313,14 @@ test.describe("Coach answer layout", () => {
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
const chipsBox = await box(chips);
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
// No empty band between the answer and its replies: the column's own
// gap, whatever the hover state of the row below.
const chipsGap = chipsBox.y - (bubbleBox.y + bubbleBox.height);
expect(chipsGap, "the chips follow the answer").toBeGreaterThanOrEqual(0);
expect(chipsGap, "the chips follow the answer").toBeLessThanOrEqual(12);
expect(rowBox.y, "the action row comes last").toBeGreaterThanOrEqual(
chipsBox.y + chipsBox.height - 1,
);
const answerSize = await bubble.evaluate(
(el) => getComputedStyle(el).fontSize,
);
+1 -2
View File
@@ -3018,7 +3018,7 @@
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
"thinkingElapsed": "{seconds}s",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Nachgedacht für {seconds}s",
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Ziele",
"categoryConstraint": "Einschränkungen",
"categoryContext": "Kontext",
"forget": "Vergessen",
"forgetAria": "Diese Angabe vergessen",
"forgetTitle": "Diese Notiz vergessen?",
"forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.",
+1 -2
View File
@@ -3018,7 +3018,7 @@
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
"heroSubline": "Ask about your trends, medications, or readings.",
"thinkingElapsed": "{seconds}s",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Thought for {seconds}s",
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Goals",
"categoryConstraint": "Constraints",
"categoryContext": "Context",
"forget": "Forget",
"forgetAria": "Forget this fact",
"forgetTitle": "Forget this fact?",
"forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.",
+1 -2
View File
@@ -3018,7 +3018,7 @@
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
"thinkingElapsed": "{seconds}s",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Pensó durante {seconds}s",
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Objetivos",
"categoryConstraint": "Restricciones",
"categoryContext": "Contexto",
"forget": "Olvidar",
"forgetAria": "Olvidar este dato",
"forgetTitle": "¿Olvidar este dato?",
"forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.",
+1 -2
View File
@@ -3018,7 +3018,7 @@
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
"thinkingElapsed": "{seconds}s",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Objectifs",
"categoryConstraint": "Contraintes",
"categoryContext": "Contexte",
"forget": "Oublier",
"forgetAria": "Oublier cette information",
"forgetTitle": "Oublier ce fait ?",
"forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.",
+1 -2
View File
@@ -3018,7 +3018,7 @@
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
"thinkingElapsed": "{seconds}s",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Obiettivi",
"categoryConstraint": "Vincoli",
"categoryContext": "Contesto",
"forget": "Dimentica",
"forgetAria": "Dimentica questa informazione",
"forgetTitle": "Dimenticare questa informazione?",
"forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.",
-1
View File
@@ -6076,7 +6076,6 @@
"categoryGoal": "목표",
"categoryConstraint": "제약",
"categoryContext": "맥락",
"forget": "잊기",
"forgetAria": "이 정보 잊기",
"forgetTitle": "이 정보를 잊을까요?",
"forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.",
+1 -2
View File
@@ -3018,7 +3018,7 @@
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
"thinkingElapsed": "{seconds}s",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Myślał przez {seconds}s",
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
@@ -6076,7 +6076,6 @@
"categoryGoal": "Cele",
"categoryConstraint": "Ograniczenia",
"categoryContext": "Kontekst",
"forget": "Zapomnij",
"forgetAria": "Zapomnij tę informację",
"forgetTitle": "Zapomnieć tę informację?",
"forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.",
@@ -0,0 +1,56 @@
/**
* Structural guard on keyboard focus in the Coach panel.
*
* The purple `--ring` is gone from every Coach control: a tap draws nothing,
* keyboard focus draws a solid ring in the neutral text-field focus tone
* (`--input-focus`). Two checks:
*
* 1. No class string under `coach-panel/` names the purple ring, border or
* outline on focus. The sweep must find a floor of neutral focus rings,
* so a matcher that stops matching fails instead of passing on nothing.
* 2. The controls built on the `Button` primitive inside an answer (the
* reply pills, Undo, Change) pass `COACH_FOCUS_RING`, which replaces the
* primitive's purple ring when the classes merge.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const DIR = join(__dirname, "..", "components", "insights", "coach-panel");
const SOURCES = readdirSync(DIR)
.filter((name) => /\.(tsx|ts)$/.test(name))
.map((name) => ({ name, text: readFileSync(join(DIR, name), "utf8") }));
const PURPLE_FOCUS =
/(?:focus|focus-visible|focus-within):(?:ring-ring|border-ring|outline-ring|ring-primary|border-primary)\b/g;
describe("Coach focus ring", () => {
it("never names the purple ring on focus", () => {
const offenders = SOURCES.flatMap(({ name, text }) =>
[...text.matchAll(PURPLE_FOCUS)].map((m) => `${name}: ${m[0]}`),
);
expect(offenders).toEqual([]);
});
it("finds the neutral ring where it expects it", () => {
const neutral = SOURCES.reduce(
(n, { text }) =>
n + (text.match(/focus-visible:ring-input-focus\b/g)?.length ?? 0),
0,
);
expect(neutral).toBeGreaterThanOrEqual(15);
});
it.each(["suggested-replies.tsx", "memory-note.tsx", "assumption-line.tsx"])(
"%s merges the neutral ring over the Button primitive",
(file) => {
const text = SOURCES.find((s) => s.name === file)?.text ?? "";
expect(text).toMatch(
/import \{ COACH_FOCUS_RING \} from "\.\/focus-ring"/,
);
expect(text).toMatch(/cn\([\s\S]*?COACH_FOCUS_RING,?\s*\)/);
},
);
});
+3 -1
View File
@@ -201,7 +201,9 @@ export function AssistantSection() {
<Label
htmlFor="admin-reasoning-max"
noColon
className="text-sm font-medium"
// On the toggles' left edge: the primitive's `pl-1` would
// inset this one label beside the switch rows above it.
className="pl-0 text-sm font-medium"
>
{t(REASONING_ADMIN_KEYS.max)}
</Label>
@@ -78,12 +78,12 @@ describe("<CoachReasoningSelect>", () => {
expect(html).toContain(
"Deeper thinking means more precise answers and a little more waiting.",
);
expect(html).not.toMatch(/<select[^>]*disabled/);
expect(html).not.toMatch(/<select[^>]*\sdisabled=""/);
});
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).toMatch(/<select[^>]*\sdisabled=""/);
expect(html).toContain('data-locked="true"');
expect(html).toContain("The admin has turned deeper thinking off.");
expect(html).not.toContain("a little more waiting");
@@ -127,3 +127,40 @@ describe("<CoachReasoningSelect>", () => {
expect(html).toContain("Hoch (vom Admin begrenzt)");
});
});
describe("the two homes of the field", () => {
const selectClass = (html: string) =>
html.match(/<select[^>]*class="([^"]*)"/)?.[1] ?? "";
it("stacks as wide as the quick settings sheet", () => {
const html = render(OPEN);
expect(html).toContain('data-no-colon="true"');
expect(selectClass(html)).toMatch(/\bw-full\b/);
expect(selectClass(html)).toMatch(/\bmt-1\b/);
});
it("takes the shape of Tone and Reply length in Settings → Coach", () => {
const html = renderToStaticMarkup(
<CoachReasoningSelect
id="r"
variant="card"
value="medium"
state={OPEN}
onChange={() => {}}
/>,
);
// Their small label with its colon, a content-wide select, the card's
// own gap instead of margins.
expect(html).toMatch(/data-slot="coach-reasoning-field" class="[^"]*gap-2/);
expect(html).toContain('data-no-colon="false"');
expect(html).toMatch(/<label[^>]*class="[^"]*\btext-xs\b/);
expect(selectClass(html)).toMatch(/\bw-fit\b/);
expect(selectClass(html)).not.toMatch(/\bw-full\b|\bmt-1\b/);
});
it("looks locked when the operator switched reasoning off", () => {
const html = render(ADMIN_OFF);
expect(html).toMatch(/<select[^>]*\sdisabled=""/);
expect(selectClass(html)).toContain("disabled:opacity-50");
});
});
@@ -192,15 +192,20 @@ describe("user action row", () => {
describe("follow-up chips in the answer column", () => {
const offer = { messageId: "m2", followUps: CHIPS };
it("render inside the column, after the action row", () => {
it("render inside the column, right after the answer, above the action row", () => {
const html = render(
<ChatBubble {...ANSWER} followUps={offer} onFollowUp={() => {}} />,
);
const column = slot(html, "coach-answer-column");
const answer = column.indexOf('data-slot="coach-answer-bubble"');
const actions = column.indexOf('data-slot="coach-answer-actions"');
const chips = column.indexOf('data-slot="coach-follow-up-chips"');
expect(actions).toBeGreaterThan(-1);
expect(chips).toBeGreaterThan(actions);
expect(answer).toBeGreaterThan(-1);
expect(chips).toBeGreaterThan(answer);
// The row is the last thing in the column: beside a pointer it is
// invisible until hover, and above the chips it left an empty band
// between the answer and its replies.
expect(actions).toBeGreaterThan(chips);
// The answer's own text size, not the button's default.
expect(slot(column, "coach-follow-up-chips")).toMatch(
/class="[^"]*\btext-sm\b[^"]*font-normal/,
@@ -75,6 +75,29 @@ describe("<SuggestedReplies>", () => {
}
});
it("marks keyboard focus with the neutral ring, never the purple one", () => {
const tree = SuggestedReplies({
replies: replies(() => {}),
messageId: "m1",
disabled: false,
});
const html = tree ? renderToStaticMarkup(tree) : "";
// The class the browser gets, after the primitive's variant classes and
// ours are merged.
const classes = [...html.matchAll(/<button[^>]*class="([^"]*)"/g)].map(
(m) => m[1],
);
expect(classes).toHaveLength(4);
for (const cls of classes) {
expect(cls).toContain("focus-visible:ring-input-focus");
expect(cls).toContain("focus-visible:border-border");
expect(cls).toContain("focus-visible:ring-2");
expect(cls).not.toMatch(
/(?:^|\s)focus-visible:(?:ring-ring|border-ring|ring-\[3px\])/,
);
}
});
it("sends the tapped reply and hands focus to the composer", () => {
focusComposer.mockClear();
const onSelect = vi.fn();
@@ -270,8 +270,17 @@ describe("the running line", () => {
);
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</);
// The seconds so far follow the text directly, in the line's own
// muted tone, with the summary's spacing ("12 s"), never pushed to
// the far edge of the column.
expect(html).toMatch(
/data-slot="coach-turn-steps-active"[^>]*>[^<]*<\/span><span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s</,
);
const seconds = html.match(
/<span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>/,
)?.[0];
expect(seconds).toContain("tabular-nums");
expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/);
}
});
@@ -14,11 +14,13 @@
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
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";
import { COACH_FOCUS_RING } from "./focus-ring";
/** The alternatives the server offers for one assumption. */
export function alternativesFor(
@@ -96,7 +98,10 @@ export function CoachAssumptionLine({
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"
className={cn(
"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",
COACH_FOCUS_RING,
)}
>
{t(COACH_ASSUMPTION_KEYS.change)}
</Button>
@@ -208,7 +208,7 @@ export function AttachmentPicker({
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
@@ -810,23 +810,13 @@ function ChatBubbleImpl({
suggestedAction ?? metricSource?.suggestedAction ?? null;
return action ? <SuggestedActionCard action={action} /> : null;
})()}
{/* One row under the answer: copy, read aloud, try again and the
time on the left; the tokens and model it cost on the right.
Only on a settled reply (not while it streams, not on an error
or a refusal). */}
{settled && content && (
<AssistantMessageActions
content={content}
streaming={!!streaming}
createdAt={createdAt}
onRegenerate={onRegenerate}
tokens={usage?.totalTokens ?? tokensUsed}
model={usage?.model ?? model}
/>
)}
{/* 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. */}
else its follow-up chips. One pattern for all of them. They sit
right under the answer, above the action row: beside a pointer
the row stays invisible until the message is hovered or holds
focus, and above the pills it left an empty band between the
answer and its replies. */}
{replies && replies.replies.length > 0 ? (
<SuggestedReplies
replies={replies.replies}
@@ -843,6 +833,19 @@ function ChatBubbleImpl({
onSelect={onFollowUp}
/>
) : null}
{/* One row under the answer, the last thing in its column: copy,
read aloud, try again, details and the time. Only on a settled
reply (not while it streams, not on an error or a refusal). */}
{settled && content && (
<AssistantMessageActions
content={content}
streaming={!!streaming}
createdAt={createdAt}
onRegenerate={onRegenerate}
tokens={usage?.totalTokens ?? tokensUsed}
model={usage?.model ?? model}
/>
)}
</div>
</div>
);
@@ -22,6 +22,7 @@
*/
import { useQueryClient } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
@@ -118,6 +119,12 @@ export interface CoachReasoningSelectProps {
state: CoachReasoningState;
/** Locks the select while a write is in flight. */
busy?: boolean;
/**
* `sheet` (default): the quick settings' stacked field, as wide as the
* sheet. `card`: Settings → Coach, where it sits in the grid beside Tone
* and Reply length and takes their small label and content-wide select.
*/
variant?: "sheet" | "card";
onChange: (next: ReasoningLevel) => void;
}
@@ -127,21 +134,30 @@ export function CoachReasoningSelect({
value,
state,
busy = false,
variant = "sheet",
onChange,
}: CoachReasoningSelectProps) {
const { t } = useTranslations();
const options = reasoningOptions(state, t);
const locked = reasoningLocked(state);
const card = variant === "card";
return (
<div data-slot="coach-reasoning-field">
<Label htmlFor={id} noColon>
<div
data-slot="coach-reasoning-field"
className={cn(card && "flex flex-col gap-2")}
>
<Label
htmlFor={id}
noColon={!card}
className={cn(card && "text-xs font-medium")}
>
{t(REASONING_SETTING_KEYS.label)}
</Label>
<NativeSelect
id={id}
data-slot="coach-reasoning-select"
data-locked={locked ? "true" : undefined}
className="mt-1"
className={card ? "w-fit" : "mt-1"}
value={shownReasoningLevel(value, state)}
disabled={locked || busy}
aria-describedby={`${id}-hint`}
@@ -160,7 +176,10 @@ export function CoachReasoningSelect({
</option>
))}
</NativeSelect>
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
<p
id={`${id}-hint`}
className={cn("text-muted-foreground text-xs", !card && "mt-1")}
>
{t(
locked
? REASONING_SETTING_KEYS.disabled
@@ -42,7 +42,7 @@ function CoachMemoryLink() {
<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"
className="text-foreground hover:bg-muted focus-visible:ring-input-focus -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"
@@ -0,0 +1,15 @@
/**
* The Coach's keyboard focus mark: a solid ring in the neutral text-field
* focus tone (`--input-focus`, at least 3:1 on the background in both
* themes), never the purple `--ring`. It shows on keyboard focus only
* (`:focus-visible`), so a tap or a click draws nothing.
*
* On a `Button` it also replaces the primitive's ring colour and width and
* keeps its border the resting one instead of the purple focus border (`cn`
* merges the later classes over the variant's), so a pill or an inline link
* in the thread marks focus the same way as the hand-built controls beside
* it. `coach-focus-ring-guard.test.ts` keeps the purple ring out of the
* Coach panel.
*/
export const COACH_FOCUS_RING =
"outline-none focus-visible:ring-2 focus-visible:ring-input-focus focus-visible:border-border";
@@ -37,7 +37,7 @@ import { TypingDots } from "./message-thread";
/** Shared quiet text-button styling for the per-question actions. */
const guidedActionClass = cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
);
@@ -226,7 +226,7 @@ export function GuidedSummaryBubble({
data-slot="coach-guided-summary-link"
className={cn(
"text-primary mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"focus-visible:ring-primary/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-input-focus rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
@@ -72,7 +72,7 @@ export function GuidedQuestionsCard({
onClick={onStart}
className={cn(
"bg-primary/15 text-primary inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-primary/25 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/25 focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -85,7 +85,7 @@ export function GuidedQuestionsCard({
onClick={onLater}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -98,7 +98,7 @@ export function GuidedQuestionsCard({
onClick={onDismissAll}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -271,7 +271,7 @@ function HistoryRow({
data-slot="coach-history-select"
className={cn(
"flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-md py-2 pr-11 pl-2 text-left text-sm transition-colors pointer-fine:min-h-9",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
// The open conversation reads like the open page in the left
// navigation: the same primary wash, so the two rails agree.
active
@@ -146,7 +146,7 @@ function InterimPreview({ result }: { result: CoachResultTable }) {
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"
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus -my-1 flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded text-left text-xs outline-none focus-visible:ring-2 sm:my-0 sm:min-h-6"
>
<ChevronRight
aria-hidden="true"
@@ -19,11 +19,13 @@ import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
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";
import { COACH_FOCUS_RING } from "./focus-ring";
export interface CoachMemoryNoteLineProps {
fact: string;
@@ -54,7 +56,10 @@ export function CoachMemoryNoteLine({
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"
className={cn(
"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",
COACH_FOCUS_RING,
)}
>
{t(COACH_MEMORY_KEYS.undo)}
</Button>
@@ -229,10 +229,10 @@ function useReadAloud(): {
* v1.22 — shared styling for the icon-only per-message action buttons. One
* tap convention across Copy / Read-aloud / feedback / Try-again: a 44px
* mobile tap floor (WCAG 2.5.5) collapsing to a compact 32px desktop target,
* muted until hover/focus, with the standard focus ring.
* muted until hover/focus, with the Coach's neutral focus ring.
*/
export const COACH_ICON_BUTTON = cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"text-muted-foreground hover:text-foreground focus-visible:ring-input-focus",
"inline-flex size-11 min-w-11 shrink-0 items-center justify-center rounded",
"outline-none focus-visible:ring-2 disabled:opacity-50 sm:size-8 sm:min-w-8",
);
@@ -120,7 +120,7 @@ export function ReminderSuggestionCard({
disabled={busy}
className={cn(
"bg-primary/90 text-background hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"focus-visible:ring-input-focus 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",
)}
@@ -140,7 +140,7 @@ export function ReminderSuggestionCard({
data-slot="coach-reminder-suggestion-dismiss"
onClick={() => act.mutate("dismiss")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
{t("coach.reminderSuggestion.dismiss")}
</button>
@@ -149,7 +149,7 @@ export function ReminderSuggestionCard({
data-slot="coach-reminder-suggestion-stop"
onClick={() => act.mutate("stop")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
{t("coach.reminderSuggestion.stop")}
</button>
@@ -380,7 +380,7 @@ function SeriesLegend({
aria-pressed={shown}
onClick={() => onToggle(key)}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 text-xs transition-colors outline-none focus-visible:ring-2 motion-reduce:transition-none sm:min-h-8",
"focus-visible:ring-input-focus inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 text-xs transition-colors outline-none focus-visible:ring-2 motion-reduce:transition-none sm:min-h-8",
shown
? "border-border text-foreground"
: "border-border/60 text-muted-foreground",
@@ -179,7 +179,7 @@ export function CoachResultTable({
tabIndex={0}
aria-labelledby={titleId}
className={cn(
"focus-visible:ring-ring/50 overflow-x-auto rounded-b-lg outline-none focus-visible:ring-2",
"focus-visible:ring-input-focus overflow-x-auto rounded-b-lg outline-none focus-visible:ring-2",
expanded && "max-h-96 overflow-y-auto overscroll-contain",
)}
>
@@ -104,7 +104,7 @@ export function ScopeHintBadge({
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 truncate text-sm leading-snug">
@@ -127,7 +127,7 @@ export function ScopeHintBadge({
className={cn(
"text-muted-foreground hover:bg-muted/60 hover:text-foreground shrink-0",
"flex size-9 items-center justify-center rounded-xl transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-4" aria-hidden="true" />
@@ -119,7 +119,7 @@ export function SelfContextAdoptOffer({
onClick={() => adopt.mutate()}
className={cn(
"text-primary inline-flex min-h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-xs font-medium",
"hover:bg-primary/15 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/15 focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -141,7 +141,7 @@ export function SelfContextAdoptOffer({
}}
className={cn(
"text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5",
"hover:bg-primary/15 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/15 focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-3" aria-hidden="true" />
@@ -116,7 +116,7 @@ export function SuggestedActionCard({
disabled={busy}
className={cn(
"bg-primary/90 text-background hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"focus-visible:ring-input-focus inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
)}
@@ -136,7 +136,7 @@ export function SuggestedActionCard({
data-slot="coach-suggested-action-dismiss"
onClick={() => setSettled("dismissed")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50 sm:min-h-9"
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus inline-flex min-h-11 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50 sm:min-h-9"
>
{t("coach.suggestedAction.dismiss")}
</button>
@@ -13,15 +13,17 @@
* 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
* colour (not even the focus ring), 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 { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_SUGGESTED_REPLIES_KEYS } from "@/lib/ai/coach/dialog-keys";
import { focusCoachComposer } from "./composer-focus";
import { COACH_FOCUS_RING } from "./focus-ring";
/** At most this many pills render, whatever the offer carries. */
export const MAX_SUGGESTED_REPLIES = 4;
@@ -80,8 +82,12 @@ export function SuggestedReplies({
// 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"
// past the column at 390 px. Keyboard focus is the Coach's
// neutral ring, not the primitive's purple one.
className={cn(
"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",
COACH_FOCUS_RING,
)}
data-reply-id={reply.id}
data-reply-kind={reply.kind}
{...reply.data}
@@ -7,9 +7,9 @@
* 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".
* seconds so far right after the text. 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
@@ -510,7 +510,7 @@ function ActivityEntry({
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"
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus 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>
@@ -776,7 +776,7 @@ export function CoachTurnActivity({
// 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",
"focus-visible:ring-input-focus rounded text-left leading-relaxed outline-none focus-visible:ring-2",
)}
>
{active ? (
@@ -807,11 +807,14 @@ export function CoachTurnActivity({
{line}
</span>
)}
{/* The seconds follow the text they time, in the same muted tone,
so the line reads as one phrase rather than a label and a
counter at the far edge of the column. */}
{seconds !== null ? (
<span
aria-hidden="true"
data-slot="coach-turn-steps-seconds"
className="ml-auto shrink-0 pl-2 tabular-nums"
className="shrink-0 tabular-nums"
>
{t(ELAPSED_KEY, { seconds })}
</span>
@@ -222,14 +222,17 @@ function FactRow({
>
<Pencil className="size-4" aria-hidden />
</Button>
{/* Two icons of one size: a labelled Forget beside the icon-only
edit squeezed the entry to three lines at 390 px. The
accessible name and the confirmation carry the words. */}
<ConfirmButton
slot="settings-coach-memory-forget"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
size="icon"
className="size-11 sm:size-9"
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
icon={<Trash2 className="size-4" aria-hidden />}
label={t("settings.ai.coachMemory.forget")}
label=""
title={t("settings.ai.coachMemory.forgetTitle")}
body={t("settings.ai.coachMemory.forgetBody")}
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
+12 -11
View File
@@ -256,18 +256,19 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</p>
)}
</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 }))
}
/>
{/* v1.41 — how hard the Coach thinks, a third field of the same
shape; the same field sits in the Coach's quick settings. */}
<CoachReasoningSelect
id="coach-prefs-reasoning"
variant="card"
value={coachReasoningLevel(draft)}
state={reasoningState}
onChange={(reasoning) =>
setDraft((prev) => ({ ...prev, reasoning }))
}
/>
</div>
{/* Data clusters + analysis window live on ONE owner: "What I
can see" behind the Coach's settings gear, which
+3 -1
View File
@@ -47,6 +47,8 @@ import { cn } from "@/lib/utils";
// single element, so every call site's width / margin className still composes
// straight onto it. `pe-9` reserves room so a long value never runs under the
// chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws.
// Disabled: dimmed and not-allowed, the same as `<Input>` and the shadcn
// `<SelectTrigger>`, so a locked field never reads as one that takes a choice.
// Colour: `currentColor` inside a background-image data URI resolves to black
// (no CSS cascade reaches it), which read as a near-black arrow on the light
// theme. The stroke is therefore baked per theme into the
@@ -55,7 +57,7 @@ import { cn } from "@/lib/utils";
// chevron uses.
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;