From e406b9c7ab0ed88abe0f41556ad8f70dccc7f280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 21:24:16 +0200 Subject: [PATCH] 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. --- e2e/coach-answer-layout.spec.ts | 17 ++++-- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 1 - messages/pl.json | 3 +- src/__tests__/coach-focus-ring-guard.test.ts | 56 +++++++++++++++++++ src/components/admin/assistant-section.tsx | 4 +- .../__tests__/coach-reasoning-field.test.tsx | 41 +++++++++++++- .../__tests__/message-actions.test.tsx | 11 +++- .../__tests__/suggested-replies.test.tsx | 23 ++++++++ .../__tests__/turn-activity.test.tsx | 13 ++++- .../insights/coach-panel/assumption-line.tsx | 7 ++- .../coach-panel/attachment-picker.tsx | 2 +- .../insights/coach-panel/chat-bubble.tsx | 33 ++++++----- .../coach-panel/coach-reasoning-field.tsx | 27 +++++++-- .../coach-panel/coach-settings-body.tsx | 2 +- .../insights/coach-panel/focus-ring.ts | 15 +++++ .../coach-panel/guided-dialog-bubbles.tsx | 4 +- .../coach-panel/guided-questions-card.tsx | 6 +- .../insights/coach-panel/history-rail.tsx | 2 +- .../insights/coach-panel/interim-results.tsx | 2 +- .../insights/coach-panel/memory-note.tsx | 7 ++- .../insights/coach-panel/read-aloud.tsx | 4 +- .../coach-panel/reminder-suggestion-card.tsx | 6 +- .../insights/coach-panel/result-chart.tsx | 2 +- .../insights/coach-panel/result-table.tsx | 2 +- .../insights/coach-panel/scope-hint-badge.tsx | 4 +- .../coach-panel/self-context-adopt-offer.tsx | 4 +- .../coach-panel/suggested-action-card.tsx | 4 +- .../coach-panel/suggested-replies.tsx | 12 +++- .../insights/coach-panel/turn-activity.tsx | 15 +++-- .../settings/coach-memory-section.tsx | 9 ++- .../settings/coach-prefs-section.tsx | 23 ++++---- src/components/ui/native-select.tsx | 4 +- 37 files changed, 288 insertions(+), 92 deletions(-) create mode 100644 src/__tests__/coach-focus-ring-guard.test.ts create mode 100644 src/components/insights/coach-panel/focus-ring.ts diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts index fe13504f9..060ab06b2 100644 --- a/e2e/coach-answer-layout.spec.ts +++ b/e2e/coach-answer-layout.spec.ts @@ -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, ); diff --git a/messages/de.json b/messages/de.json index 18ab0b8cd..90baf90f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index b762e5b47..cb3e738ea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 6a25701af..531bcc740 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 855305c28..e13156a46 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é.", diff --git a/messages/it.json b/messages/it.json index b56cee77e..bda0a89a9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index 44689fd9c..42368be2b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6076,7 +6076,6 @@ "categoryGoal": "목표", "categoryConstraint": "제약", "categoryContext": "맥락", - "forget": "잊기", "forgetAria": "이 정보 잊기", "forgetTitle": "이 정보를 잊을까요?", "forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.", diff --git a/messages/pl.json b/messages/pl.json index ffdf2715a..c26366b41 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ć.", diff --git a/src/__tests__/coach-focus-ring-guard.test.ts b/src/__tests__/coach-focus-ring-guard.test.ts new file mode 100644 index 000000000..e51241121 --- /dev/null +++ b/src/__tests__/coach-focus-ring-guard.test.ts @@ -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*\)/); + }, + ); +}); diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 6d3994a24..466be2503 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -201,7 +201,9 @@ export function AssistantSection() { diff --git a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx index 25ec4c284..7b3489779 100644 --- a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx @@ -78,12 +78,12 @@ describe("", () => { expect(html).toContain( "Deeper thinking means more precise answers and a little more waiting.", ); - expect(html).not.toMatch(/]*disabled/); + expect(html).not.toMatch(/]*\sdisabled=""/); }); it("is locked with one sentence when the admin turned reasoning off", () => { const html = render(ADMIN_OFF, "high"); - expect(html).toMatch(/]*disabled/); + expect(html).toMatch(/]*\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("", () => { expect(html).toContain("Hoch (vom Admin begrenzt)"); }); }); + +describe("the two homes of the field", () => { + const selectClass = (html: string) => + html.match(/]*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( + {}} + />, + ); + // 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(/]*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(/]*\sdisabled=""/); + expect(selectClass(html)).toContain("disabled:opacity-50"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx index 928dee229..5100f9dd9 100644 --- a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx @@ -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( {}} />, ); 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/, diff --git a/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx b/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx index de20a63ed..3ecd1b1b9 100644 --- a/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx +++ b/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx @@ -75,6 +75,29 @@ describe("", () => { } }); + 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(/]*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(); diff --git a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx index 9a56084c8..73fb2fa6d 100644 --- a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx +++ b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx @@ -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]*>[^<]*<\/span>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s]*data-slot="coach-turn-steps-seconds"[^>]*>/, + )?.[0]; + expect(seconds).toContain("tabular-nums"); + expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/); } }); diff --git a/src/components/insights/coach-panel/assumption-line.tsx b/src/components/insights/coach-panel/assumption-line.tsx index 89284f10b..ab84da991 100644 --- a/src/components/insights/coach-panel/assumption-line.tsx +++ b/src/components/insights/coach-panel/assumption-line.tsx @@ -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)} diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index 286b9a037..732754dda 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -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", diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index e35376530..435d324ad 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -810,23 +810,13 @@ function ChatBubbleImpl({ suggestedAction ?? metricSource?.suggestedAction ?? null; return 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 && ( - - )} {/* 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 ? ( ) : 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 && ( + + )} ); diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx index 8ea3a5038..8c97d8600 100644 --- a/src/components/insights/coach-panel/coach-reasoning-field.tsx +++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx @@ -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 ( -
-
- {/* v1.41 — how hard the Coach thinks; the same field sits in the - Coach's quick settings. */} - - 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. */} + + setDraft((prev) => ({ ...prev, reasoning })) + } + /> + {/* Data clusters + analysis window live on ONE owner: "What I can see" behind the Coach's settings gear, which diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx index b344ba353..dc5bc33fc 100644 --- a/src/components/ui/native-select.tsx +++ b/src/components/ui/native-select.tsx @@ -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 `` and the shadcn +// ``, 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;