diff --git a/messages/de.json b/messages/de.json index f1a372d90..ae1e581a3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2947,7 +2947,7 @@ "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.", "retry": "Erneut fragen" }, - "historyReadOnlyNote": "Der Coach ist gerade nicht verfügbar. Deine Gespräche kannst du trotzdem lesen und löschen.", + "historyReadOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Gespräche kannst du aber weiter lesen und löschen.", "transcriptYou": "Du", "transcriptCoach": "Coach", "errorConsent": "Der Coach braucht deine KI-Einwilligung, bevor er antworten kann. Du kannst sie unter Einstellungen, KI geben.", diff --git a/messages/en.json b/messages/en.json index d2442a434..aef4fcb86 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2947,7 +2947,7 @@ "note": "This answer was interrupted before it finished.", "retry": "Ask again" }, - "historyReadOnlyNote": "The Coach is not available right now. You can still read and delete your conversations.", + "historyReadOnlyNote": "The Coach is not available right now, but you can still read and delete your conversations.", "transcriptYou": "You", "transcriptCoach": "Coach", "errorConsent": "The Coach needs your AI consent before it can answer. You can give it in Settings, AI.", diff --git a/messages/es.json b/messages/es.json index 122a1c6d2..d0c5cb37e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2947,7 +2947,7 @@ "note": "Esta respuesta se interrumpió antes de terminar.", "retry": "Preguntar de nuevo" }, - "historyReadOnlyNote": "El Coach no está disponible ahora mismo. Aun así puedes leer y eliminar tus conversaciones.", + "historyReadOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus conversaciones.", "transcriptYou": "Tú", "transcriptCoach": "Coach", "errorConsent": "El Coach necesita tu consentimiento de IA antes de poder responder. Puedes darlo en Ajustes, IA.", diff --git a/messages/fr.json b/messages/fr.json index 7100c0fa0..e006f315a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2947,7 +2947,7 @@ "note": "Cette réponse a été interrompue avant la fin.", "retry": "Reposer la question" }, - "historyReadOnlyNote": "Le Coach n'est pas disponible pour le moment. Vous pouvez toujours lire et supprimer vos conversations.", + "historyReadOnlyNote": "Le Coach n'est pas disponible pour le moment, mais vous pouvez toujours lire et supprimer vos conversations.", "transcriptYou": "Vous", "transcriptCoach": "Coach", "errorConsent": "Le Coach a besoin de votre consentement à l'IA avant de pouvoir répondre. Vous pouvez le donner dans Réglages, IA.", diff --git a/messages/it.json b/messages/it.json index 6e7b0141f..51dc640a9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2947,7 +2947,7 @@ "note": "Questa risposta è stata interrotta prima di essere completata.", "retry": "Chiedi di nuovo" }, - "historyReadOnlyNote": "Il Coach non è disponibile al momento. Puoi comunque leggere ed eliminare le tue conversazioni.", + "historyReadOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare le tue conversazioni.", "transcriptYou": "Tu", "transcriptCoach": "Coach", "errorConsent": "Il Coach ha bisogno del tuo consenso all'IA prima di poter rispondere. Puoi darlo in Impostazioni, IA.", diff --git a/messages/ko.json b/messages/ko.json index c9341f621..7fc71b76d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2947,7 +2947,7 @@ "note": "이 답변은 끝나기 전에 중단됐어요.", "retry": "다시 물어보기" }, - "historyReadOnlyNote": "지금은 코치를 사용할 수 없습니다. 대화는 계속 읽고 삭제할 수 있습니다.", + "historyReadOnlyNote": "지금은 코치를 사용할 수 없지만 대화는 계속 읽고 삭제할 수 있습니다.", "transcriptYou": "나", "transcriptCoach": "코치", "errorConsent": "코치가 답변하려면 AI 동의가 필요합니다. 설정의 AI에서 동의할 수 있습니다.", diff --git a/messages/pl.json b/messages/pl.json index 2bb11f3ad..8fc0ddc6e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2947,7 +2947,7 @@ "note": "Ta odpowiedź została przerwana przed ukończeniem.", "retry": "Zapytaj ponownie" }, - "historyReadOnlyNote": "Coach jest teraz niedostępny. Nadal możesz czytać i usuwać swoje rozmowy.", + "historyReadOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje rozmowy.", "transcriptYou": "Ty", "transcriptCoach": "Coach", "errorConsent": "Coach potrzebuje Twojej zgody na AI, zanim odpowie. Możesz ją wyrazić w Ustawieniach, AI.", diff --git a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx index 444d2d2f2..2eb08a56d 100644 --- a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx +++ b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx @@ -75,6 +75,35 @@ describe("AiConsentCard", () => { expect(html).toContain('data-slot="ai-consent-grant"'); }); + it("never reports a failed read as a withdrawal", () => { + // A refused or failed read (the consent bucket's rate limit, a network + // drop) says nothing about the decision. Reporting it as "Withdrawn" and + // offering a grant would misstate the person's own choice. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, retryOnMount: false } }, + }); + client + .getQueryCache() + .build(client, { queryKey: receiptKey }) + .setState({ + status: "error", + error: new Error("read failed"), + data: undefined, + fetchStatus: "idle", + }); + const html = renderToStaticMarkup( + + + + + , + ); + expect(html).not.toContain("Withdrawn."); + expect(html).not.toContain('data-slot="ai-consent-grant"'); + expect(html).not.toContain('data-slot="ai-consent-withdraw"'); + expect(html).toContain('data-slot="ai-consent-load-error"'); + }); + it("never shows the grant control while consent stands", () => { const html = renderWith({ id: "rcpt-1", diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx index 05fa88dd0..7f3050ce1 100644 --- a/src/components/settings/ai/ai-consent-card.tsx +++ b/src/components/settings/ai/ai-consent-card.tsx @@ -35,6 +35,7 @@ import { Loader2, ShieldCheck, ShieldOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; @@ -56,11 +57,13 @@ export function AiConsentCard({ const queryClient = useQueryClient(); const [confirming, setConfirming] = useState(false); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.aiConsentReceipt("ai_full"), queryFn: async () => { const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full"); - if (!res.ok) return null; + // A failed read is not a withdrawal: throw so the card says it could + // not load instead of reporting the person's decision wrongly. + if (!res.ok) throw new Error("consent read failed"); const json = await res.json(); return (json.data?.receipt ?? null) as ConsentReceiptWire; }, @@ -133,6 +136,17 @@ export function AiConsentCard({ ); } + if (isError) { + return ( +
+ void refetch()} + /> +
+ ); + } + // Narrow to the receipt itself rather than a boolean, so the branches // below can read its fields without an assertion. const activeReceipt = data && data.revokedAt === null ? data : null;