diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx
index d1e5ff6e3..aa5c50158 100644
--- a/src/components/documents/document-link-picker.tsx
+++ b/src/components/documents/document-link-picker.tsx
@@ -20,6 +20,7 @@ import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -132,9 +133,10 @@ export function DocumentLinkPicker({
))}
) : list.isError ? (
-
list.refetch()}
+ />
) : documents.length === 0 ? (
{t("documents.linkPicker.empty")}
diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx
new file mode 100644
index 000000000..4a3bffd11
--- /dev/null
+++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * The bare-destructive read-failure family: several settings cards rendered a
+ * failed read as a bare `text-destructive` line with no way to recover. Each is
+ * forced into its query error state and must now render the shared
+ * `query-error-row` primitive with a retry control — an alert is foreground
+ * content, and a read failure has to be retryable.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ refetch: vi.fn(),
+ }),
+ useMutation: () => ({
+ mutate: vi.fn(),
+ mutateAsync: vi.fn(),
+ isPending: false,
+ isError: false,
+ reset: vi.fn(),
+ }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ usePathname: () => "/settings",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { SecuritySessionsCard } from "../security-sessions-card";
+import { SecurityActivityCard } from "../security-activity-card";
+import { TrustedDevicesCard } from "../trusted-devices-card";
+import { CoachMemorySection } from "../coach-memory-section";
+import { CoachRemindersSection } from "../coach-reminders-section";
+import { AboutMeSection } from "../about-me-section";
+
+function renderCard(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+const cases: Array<[string, React.ReactNode]> = [
+ ["SecuritySessionsCard", ],
+ ["SecurityActivityCard", ],
+ ["TrustedDevicesCard", ],
+ ["CoachMemorySection", ],
+ ["CoachRemindersSection", ],
+ ["AboutMeSection", ],
+];
+
+describe("settings read-error family — a read failure is foreground and recoverable", () => {
+ it.each(cases)("%s renders a query-error row with retry", (_name, node) => {
+ const html = renderCard(node);
+ expect(html).toContain('data-slot="query-error-row"');
+ expect(html).toContain('data-slot="query-error-row-retry"');
+ // No bare red line: the alert is the primitive now.
+ expect(html).not.toContain('
- {t("settings.ai.aboutMe.loadError")}
-
+ query.refetch()}
+ />
)}
diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx
index bf382459a..73826576a 100644
--- a/src/components/settings/coach-memory-section.tsx
+++ b/src/components/settings/coach-memory-section.tsx
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -152,13 +153,10 @@ export function CoachMemorySection({
{t("settings.ai.coachMemory.detail")}
{query.isError && (
-
- {t("settings.ai.coachMemory.loadError")}
-
+ query.refetch()}
+ />
)}
{!query.isError && facts.length === 0 ? (
diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx
index c0e47c204..c4cde2c14 100644
--- a/src/components/settings/coach-reminders-section.tsx
+++ b/src/components/settings/coach-reminders-section.tsx
@@ -22,6 +22,7 @@ import { BellRing, Check, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -165,13 +166,10 @@ export function CoachRemindersSection({
{t("settings.ai.coachReminders.detail")}
{query.isError && (
-
- {t("settings.ai.coachReminders.loadError")}
-
+ query.refetch()}
+ />
)}
{!query.isError && reminders.length === 0 ? (
diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx
index 6d4806d4c..1091e9fc7 100644
--- a/src/components/settings/security-activity-card.tsx
+++ b/src/components/settings/security-activity-card.tsx
@@ -15,6 +15,7 @@ import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -62,7 +63,7 @@ export function SecurityActivityCard({
const [open, setOpen] = useState(false);
const regionId = useId();
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
@@ -102,9 +103,10 @@ export function SecurityActivityCard({
)}
{isError && (
-
- {t("settings.security.activityLoadError")}
-
+ refetch()}
+ />
)}
{!isLoading && !isError && events.length === 0 && (
diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx
index d9b2a6821..206a743f1 100644
--- a/src/components/settings/security-sessions-card.tsx
+++ b/src/components/settings/security-sessions-card.tsx
@@ -18,6 +18,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
@@ -48,7 +49,7 @@ export function SecuritySessionsCard({
const [open, setOpen] = useState(false);
const regionId = useId();
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.sessions(),
queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"),
enabled: isAuthenticated,
@@ -117,9 +118,10 @@ export function SecuritySessionsCard({
)}
{isError && (
-
- {t("settings.security.sessionsLoadError")}
-
+ refetch()}
+ />
)}
{!isLoading && !isError && sessions.length === 0 && (
diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx
index 8db9feb08..4320f448f 100644
--- a/src/components/settings/share-document-picker.tsx
+++ b/src/components/settings/share-document-picker.tsx
@@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
@@ -152,9 +153,10 @@ export function ShareDocumentPicker({
))}
) : list.isError ? (
-
- {t("documents.list.loadError")}
-
+ list.refetch()}
+ />
) : documents.length === 0 ? (
{t("settings.sharing.pickerEmpty")}
diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx
index 5213faffe..7fa19272f 100644
--- a/src/components/settings/trusted-devices-card.tsx
+++ b/src/components/settings/trusted-devices-card.tsx
@@ -15,6 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -39,7 +40,7 @@ export function TrustedDevicesCard({
const queryClient = useQueryClient();
const [status, setStatus] = useState(null);
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.trustedDevices(),
queryFn: () =>
apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"),
@@ -102,9 +103,10 @@ export function TrustedDevicesCard({
)}
{isError && (
-
- {t("settings.security.trustedDevices.loadError")}
-
+ refetch()}
+ />
)}
{devices.length > 0 && (