diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8dbae33e7..701639aea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4096,6 +4096,24 @@ paths: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -4423,6 +4441,24 @@ paths: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP deviceTypePriority: type: object properties: @@ -9412,6 +9448,8 @@ components: enum: - weekly - biweekly + ambientSuggestions: + type: boolean measurementReminder: type: object properties: @@ -13773,6 +13811,24 @@ components: minimum: 0 maximum: 9007199254740991 description: Nights still needed before the debt is asserted (0 when ready). + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. + `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would + be a device-native debt. Clients explain the figure when it is `COMPUTED`. required: - state - debtMinutes @@ -13780,6 +13836,7 @@ components: - nightsCounted - windowNights - nightsUntilReady + - source additionalProperties: false chronotype: type: object @@ -16655,6 +16712,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -16982,6 +17057,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object @@ -17355,6 +17448,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -17682,6 +17793,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object diff --git a/messages/de.json b/messages/de.json index ffe344e28..d1b80c95b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2697,7 +2697,8 @@ "caption": "Kumuliertes Defizit der letzten {nights} Nächte", "clearValue": "Ausgeglichen", "clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte", - "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz." + "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.", + "computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt." }, "average": { "title": "Durchschnitt pro Nacht", @@ -4049,7 +4050,8 @@ "skinTemperature": "Hauttemperatur", "respiratoryRate": "Atemfrequenz", "recovery": "Erholung", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Schlafdefizit" }, "sourceLabels": { "WITHINGS": "Withings", @@ -6118,7 +6120,10 @@ "frequencyAria": "Häufigkeit der Coach-Hinweise wählen", "enabledToast": "Proaktive Coach-Hinweise aktiviert.", "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", - "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", + "suggestionsLabel": "Beispielvorschläge", + "suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.", + "suggestionsAria": "Beispiel-Coach-Vorschläge umschalten" }, "lowStock": { "title": "Vorratswarnung", diff --git a/messages/en.json b/messages/en.json index 1f20e7493..6b637b7f9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2697,7 +2697,8 @@ "caption": "Cumulative shortfall over the last {nights} nights", "clearValue": "All caught up", "clearCaption": "No accumulated shortfall over the last {nights} nights", - "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance." + "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.", + "computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model." }, "average": { "title": "Average per night", @@ -4049,7 +4050,8 @@ "skinTemperature": "Skin temperature", "respiratoryRate": "Respiratory rate", "recovery": "Recovery", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Sleep debt" }, "sourceLabels": { "WITHINGS": "Withings", @@ -6118,7 +6120,10 @@ "frequencyAria": "Select nudge frequency", "enabledToast": "Proactive Coach nudges enabled.", "disabledToast": "Proactive Coach nudges disabled.", - "saveError": "Could not save. Please try again later." + "saveError": "Could not save. Please try again later.", + "suggestionsLabel": "Example suggestions", + "suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.", + "suggestionsAria": "Toggle example Coach suggestions" }, "lowStock": { "title": "Low-stock alert", diff --git a/messages/es.json b/messages/es.json index 8d38fbd75..511245644 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2697,7 +2697,8 @@ "caption": "Déficit acumulado de las últimas {nights} noches", "clearValue": "Al día", "clearCaption": "Sin déficit acumulado en las últimas {nights} noches", - "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable." + "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.", + "computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad." }, "average": { "title": "Media por noche", @@ -4049,7 +4050,8 @@ "skinTemperature": "Temperatura de la piel", "respiratoryRate": "Frecuencia respiratoria", "recovery": "Recuperación", - "stress": "Estrés" + "stress": "Estrés", + "sleepDebt": "Deuda de sueño" }, "sourceLabels": { "WITHINGS": "Withings", @@ -6118,7 +6120,10 @@ "statusOff": "Desactivado", "enabledToast": "Avisos proactivos del Coach activados.", "disabledToast": "Avisos proactivos del Coach desactivados.", - "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." + "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.", + "suggestionsLabel": "Sugerencias de ejemplo", + "suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.", + "suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach" }, "lowStock": { "title": "Aviso de existencias bajas", diff --git a/messages/fr.json b/messages/fr.json index 611a88dd7..722bb3c26 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2697,7 +2697,8 @@ "caption": "Déficit cumulé sur les {nights} dernières nuits", "clearValue": "À jour", "clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits", - "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable." + "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.", + "computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin." }, "average": { "title": "Moyenne par nuit", @@ -4049,7 +4050,8 @@ "skinTemperature": "Température cutanée", "respiratoryRate": "Fréquence respiratoire", "recovery": "Récupération", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Dette de sommeil" }, "sourceLabels": { "WITHINGS": "Withings", @@ -6118,7 +6120,10 @@ "statusOff": "Désactivé", "enabledToast": "Rappels proactifs du Coach activés.", "disabledToast": "Rappels proactifs du Coach désactivés.", - "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard." + "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.", + "suggestionsLabel": "Suggestions d'exemples", + "suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.", + "suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach" }, "lowStock": { "title": "Alerte de stock faible", diff --git a/messages/it.json b/messages/it.json index b33713758..8ae1aef9a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2697,7 +2697,8 @@ "caption": "Deficit cumulato nelle ultime {nights} notti", "clearValue": "In pari", "clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti", - "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile." + "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.", + "computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno." }, "average": { "title": "Media per notte", @@ -4049,7 +4050,8 @@ "skinTemperature": "Temperatura cutanea", "respiratoryRate": "Frequenza respiratoria", "recovery": "Recupero", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Debito di sonno" }, "sourceLabels": { "WITHINGS": "Withings", @@ -6118,7 +6120,10 @@ "statusOff": "Disattivato", "enabledToast": "Avvisi proattivi del Coach attivati.", "disabledToast": "Avvisi proattivi del Coach disattivati.", - "saveError": "Impossibile salvare. Riprova più tardi." + "saveError": "Impossibile salvare. Riprova più tardi.", + "suggestionsLabel": "Suggerimenti di esempio", + "suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.", + "suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach" }, "lowStock": { "title": "Avviso scorte basse", diff --git a/messages/pl.json b/messages/pl.json index aac1023a3..382247918 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2697,7 +2697,8 @@ "caption": "Skumulowany niedobór z ostatnich {nights} nocy", "clearValue": "Na bieżąco", "clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach", - "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans." + "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.", + "computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania." }, "average": { "title": "Średnia na noc", @@ -4049,7 +4050,8 @@ "skinTemperature": "Temperatura skóry", "respiratoryRate": "Częstość oddechów", "recovery": "Regeneracja", - "stress": "Stres" + "stress": "Stres", + "sleepDebt": "Dług snu" }, "sourceLabels": { "WITHINGS": "Withings", @@ -6118,7 +6120,10 @@ "statusOff": "Wyłączone", "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", - "saveError": "Nie udało się zapisać. Spróbuj ponownie później." + "saveError": "Nie udało się zapisać. Spróbuj ponownie później.", + "suggestionsLabel": "Przykładowe sugestie", + "suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.", + "suggestionsAria": "Przełącz przykładowe sugestie Coacha" }, "lowStock": { "title": "Alert niskiego zapasu", diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 46dbd045d..77824fcd8 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -102,6 +102,7 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -137,6 +138,7 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -174,6 +176,7 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -222,6 +225,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -246,6 +250,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -274,6 +279,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -294,6 +300,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -367,6 +374,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -405,6 +413,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -429,6 +438,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, @@ -472,6 +482,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, }, measurementReminder: { clientManaged: false, diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b621a2600..453a2e3e4 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -18,7 +18,6 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card"; import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; /** @@ -115,14 +114,6 @@ export default function InsightsGewichtPage() { - - {/* v1.12.0 — Weight owns the weight × weekday correlation (relocated off the overview onto its metric page). */} diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 088e7d0f5..bdcb5fc89 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,21 +24,16 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - it("renders the mic disabled with an unsupported tooltip in SSR markup", () => { - // v1.18.10 (W4) — the mic always renders so the affordance stays - // discoverable. SSR (and any browser without the Web Speech API) shows - // it DISABLED with an explanatory tooltip rather than vanishing or - // sitting as a dead control. A post-hydration effect re-enables it once - // `SpeechRecognition` is confirmed present. + it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => { + // v1.25.0 — the mic only mounts once the client confirms the Web Speech + // API exists AND the page runs in a secure context. SSR (and any + // unsupported / non-secure environment) renders NO mic at all, so the + // composer never ships an advertised-but-erroring control. The earlier + // "render it disabled" affordance is gone. const html = render( {}} onSubmit={() => {}} />, ); - const micTag = html.match(/]*data-slot="coach-input-mic"[^>]*>/); - expect(micTag).not.toBeNull(); - expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); - expect(micTag?.[0]).toContain('data-unsupported="true"'); - // Tooltip explains why it is inert, sourced from i18n. - expect(html).toContain("Voice input is not supported in this browser"); + expect(html).not.toContain('data-slot="coach-input-mic"'); }); it("renders the localised placeholder without the retired shortcut hint", () => { @@ -232,8 +227,9 @@ describe("", () => { expect(html).not.toContain('data-slot="coach-input-hub"'); expect(html).not.toContain('data-slot="coach-input-actions"'); expect(html).not.toContain('data-slot="coach-input-settings"'); - // Mic + send still render in the single-row layout. - expect(html).toContain('data-slot="coach-input-mic"'); + // Send renders in the single-row layout. The mic is client-and-secure- + // context only, so it never appears in SSR markup. + expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); }); @@ -256,8 +252,9 @@ describe("", () => { expect(html).toContain('data-slot="coach-input-actions"'); // The settings gear is gone from the composer entirely. expect(html).not.toContain('data-slot="coach-input-settings"'); - // Mic + send remain present on the same row. - expect(html).toContain('data-slot="coach-input-mic"'); + // Send remains in the trailing cluster. The mic is client-and-secure- + // context only, so it never appears in SSR markup. + expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); // The leading `+` precedes the textarea; the textarea precedes the send. const plusIdx = html.indexOf('data-slot="coach-input-actions"'); diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx index 268bce7a0..86003e2b3 100644 --- a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -112,7 +112,7 @@ describe("", () => { expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"'); }); - it("lets the seeded opener fill its column (no max-w-md cap)", () => { + it("sizes the seeded opener to its text and keeps it single-line", () => { const html = render( ", () => { onDismiss={() => {}} />, ); - // The opener grows to the composer column width; the old narrow cap is gone. + const seedTag = html.match( + /]*data-slot="coach-scope-hint-seed"[^>]*>/, + ); + expect(seedTag).not.toBeNull(); + // v1.25.0 — intrinsic width, not stretched to the composer column: the + // opener grows to its text and caps at the full width. The old narrow cap + // and the full-width stretch are both gone. expect(html).not.toContain("max-w-md"); + // Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not + // the old `flex-1` / `w-full` full-width stretch. + expect(seedTag?.[0]).toContain("inline-flex"); + expect(seedTag?.[0]).toContain("max-w-full"); + expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/); + // The question span truncates with an ellipsis rather than wrapping. + expect(html).toContain("truncate"); }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 0432e6a77..1457ed0fc 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -40,6 +40,7 @@ import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; import { useResettableValue } from "./use-resettable-value"; +import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions"; import { useCoachConversation, useCoachConversations, @@ -516,8 +517,15 @@ export function CoachConversation({ return false; } }); + // v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the + // seeded opener (and skips its fetch when off). The A2 launch scope below is + // NOT ambient — it reflects how the chat was opened — so it stays unaffected. + const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled(); const seededEnabled = - heroActive && a2Metric === null && !seededDismissedToday; + heroActive && + a2Metric === null && + !seededDismissedToday && + ambientSuggestionsEnabled; const { data: seeded } = useQuery({ queryKey: queryKeys.coachSeededQuestion(), queryFn: async () => @@ -574,7 +582,11 @@ export function CoachConversation({ onSeed={seedComposer} /> ); - } else if (seeded?.signal && !seededDismissedToday) { + } else if ( + seeded?.signal && + !seededDismissedToday && + ambientSuggestionsEnabled + ) { // A3 — the notable derived signal. The label + opener are keyed on the // signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel // (future detector additions) skips the opener rather than guessing. diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 31f01e4d6..e830fe0c8 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -55,10 +55,13 @@ import { useTranslations } from "@/lib/i18n/context"; * transcripts append into the controlled `value`; the button toggles * listening on/off and is fully keyboard- and screen-reader-accessible. * - * v1.18.10 (W4): the mic always renders so the affordance is discoverable. - * On a browser without the Web Speech API (or during SSR) it renders - * DISABLED with an explanatory tooltip rather than vanishing or sitting as - * a dead control that silently does nothing on tap. + * v1.25.0: the mic only renders when dictation is genuinely usable — + * the Web Speech API exists AND the page runs in a secure context (the + * recogniser refuses to start over plain HTTP). On SSR, an unsupported + * browser (Firefox), a non-secure self-host, or after the user denies the + * mic permission, the control HIDES rather than sitting as an + * advertised-but-erroring affordance. A real failure mid-use surfaces a + * single quiet toast and then retires the button for the session. * * v1.18.11 (W11): the composer is the conversation's control hub on the * full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a @@ -193,15 +196,34 @@ function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null { // mic at SSR) and the client snapshot reflects whether the constructor // exists. The store never changes after hydration, so the subscribe // callback is a no-op. +// +// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition` +// throws / fires a `not-allowed` error the instant it starts over plain HTTP, +// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic +// that can never work. Gating support on `window.isSecureContext` hides it +// there cleanly instead. const noopSubscribe = () => () => {}; function useVoiceSupported(): boolean { return useSyncExternalStore( noopSubscribe, - () => getSpeechRecognitionCtor() !== null, + () => getSpeechRecognitionCtor() !== null && window.isSecureContext, () => false, ); } +// v1.25.0 — map the app locale onto a BCP-47 tag with a region the +// recogniser accepts. The bare language subtag works in most engines, but +// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy +// on Chromium / Safari. Unknown locales fall back to the bare subtag. +const SPEECH_RECOGNITION_LANG: Record = { + en: "en-US", + de: "de-DE", + es: "es-ES", + fr: "fr-FR", + it: "it-IT", + pl: "pl-PL", +}; + export function CoachInput({ value, onChange, @@ -274,6 +296,12 @@ export function CoachInput({ // always read the freshest composer state without re-subscribing. const voiceSupported = useVoiceSupported(); const [listening, setListening] = useState(false); + // v1.25.0 — once the user denies the mic permission (or the engine reports + // it cannot serve), retire the control for the session rather than leaving a + // button that errors on every tap. Combined with `voiceSupported` this is + // the single gate the mic renders behind. + const [voiceBlocked, setVoiceBlocked] = useState(false); + const voiceAvailable = voiceSupported && !voiceBlocked; const recognitionRef = useRef(null); const onChangeRef = useRef(onChange); const valueRef = useRef(value); @@ -299,7 +327,7 @@ export function CoachInput({ const Ctor = getSpeechRecognitionCtor(); if (!Ctor) return; const recognition = new Ctor(); - recognition.lang = locale === "en" ? "en-US" : locale; + recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale; recognition.continuous = true; recognition.interimResults = true; // Anchor every transcript onto the text present when dictation @@ -321,15 +349,17 @@ export function CoachInput({ }; recognition.onerror = (event) => { setListening(false); - // v1.18.10 (W4) — surface a quiet toast on a real failure so a tap - // that "does nothing" (denied mic permission, an insecure-context - // self-host, or no network for the recogniser) gives feedback rather - // than silently resetting. `aborted` / `no-speech` are the benign - // codes that fire on a normal stop or a quiet pause — stay silent. + // `aborted` / `no-speech` are the benign codes that fire on a normal + // stop or a quiet pause — stay silent. const code = event?.error; - if (code && code !== "aborted" && code !== "no-speech") { - toast.error(t("insights.coach.dictateError")); + if (!code || code === "aborted" || code === "no-speech") return; + // v1.25.0 — a permission refusal (or an engine that reports it cannot + // serve) is terminal: surface one quiet toast and HIDE the mic for the + // session so the user is not left tapping a control that always errors. + if (code === "not-allowed" || code === "service-not-allowed") { + setVoiceBlocked(true); } + toast.error(t("insights.coach.dictateError")); }; recognition.onend = () => { setListening(false); @@ -346,18 +376,16 @@ export function CoachInput({ }, [locale, t]); const toggleDictation = useCallback(() => { - if (!voiceSupported) return; + if (!voiceAvailable) return; if (listening) stopDictation(); else startDictation(); - }, [voiceSupported, listening, startDictation, stopDictation]); + }, [voiceAvailable, listening, startDictation, stopDictation]); - // The mic label tracks three states: unsupported (disabled + tooltip - // explaining why), listening (tap to stop), and idle (tap to dictate). - const micLabel = !voiceSupported - ? t("insights.coach.voiceUnsupported") - : listening - ? t("insights.coach.dictateStop") - : t("insights.coach.dictate"); + // The mic only renders when dictation is available, so the label tracks the + // two live states: listening (tap to stop) and idle (tap to dictate). + const micLabel = listening + ? t("insights.coach.dictateStop") + : t("insights.coach.dictate"); const canSubmit = !disabled && value.trim().length > 0; @@ -381,19 +409,20 @@ export function CoachInput({ // v1.18.11 — the dictation mic. Lifted to a const so it can sit in the // single-row composer (drawer) OR in the hub action row (page) without - // duplicating the wiring. - const micButton = ( + // duplicating the wiring. v1.25.0 — `null` when dictation is unavailable + // (no API, non-secure context, or a denied permission) so the surface hides + // the control rather than advertising a button that errors on tap. + const micButton = voiceAvailable ? ( - ); + ) : null; // v1.18.11 — the send / stop control. Same const-lift rationale as the // mic: one definition, two mount points. diff --git a/src/components/insights/coach-panel/scope-hint-badge.tsx b/src/components/insights/coach-panel/scope-hint-badge.tsx index 370231e9a..8fbc980ec 100644 --- a/src/components/insights/coach-panel/scope-hint-badge.tsx +++ b/src/components/insights/coach-panel/scope-hint-badge.tsx @@ -91,28 +91,31 @@ export function ScopeHintBadge({ {/* The tappable seed question — the data-aware opener. A real button so - keyboard + screen-reader users get the same affordance. The opener - fills the column (= the composer's max-width) so it reads as one line - with the field below; the seeded variant adds a dismiss control at the - trailing edge, mirroring the composer's send affordance. */} -
+ keyboard + screen-reader users get the same affordance. v1.25.0 — the + opener sizes to its TEXT (intrinsic width, centred) instead of being + stretched to the full composer width, which left a large empty tail + that read as broken. It is guaranteed single-line: the question + truncates with an ellipsis on a narrow phone rather than wrapping or + forcing a horizontal scroll. The seeded variant keeps the dismiss + control at the trailing edge. */} +