mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge the Coach interface polish into release/v1.41.0
This commit is contained in:
commit
bd7a354ddd
37 files changed
+288
-92
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -6076,7 +6076,6 @@
|
||||
"categoryGoal": "목표",
|
||||
"categoryConstraint": "제약",
|
||||
"categoryContext": "맥락",
|
||||
"forget": "잊기",
|
||||
"forgetAria": "이 정보 잊기",
|
||||
"forgetTitle": "이 정보를 잊을까요?",
|
||||
"forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.",
|
||||
|
||||
+1
-2
@@ -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*\)/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user