fix(coach): calm the answer column, its replies and its focus marks

The running status line now carries its seconds right after the text, in
the line's own muted tone, instead of at the far edge of the column. The
live count and every finished round read "4 s", the same spacing as the
settled summary, with a no-break space so a round never wraps between
number and unit.

Reply pills (follow-ups, clarifying choices, memory and plan decisions)
sit directly under the answer, and the action row is the last thing in
the column. Beside a pointer the row stays invisible until hover or
focus; above the pills it left an empty band between an answer and its
replies. Hovering or focusing the message still reveals the row without
moving anything.

Keyboard focus in the Coach panel is a solid ring in the neutral
text-field focus tone, never the purple ring: the pills, Undo and Change
merge it over the button primitive, and the hand-built controls carry it
directly. A structural test keeps the purple ring out of the panel.

A disabled native select now looks disabled, like Input and Select, so a
thinking depth locked by the operator no longer reads as a live choice.
In Settings, thinking depth takes the shape of Tone and Reply length
beside it; a memory entry's Forget is an icon like its edit; the
operator's highest-depth label sits on the switches' left edge.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 21:24:24 +02:00
1 parent c7d659eb11
commit e406b9c7ab
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>;