From 97e4dd4c05bdfb9d945a937e7548a5d0e3bf6c22 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 8 Oct 2026 20:20:02 +0200
Subject: [PATCH] Split the AI provider settings into one card per concern
The page was one card titled like the page, holding seven grey panels
with three primary Saves, each left-aligned mid-card. Provider, fallback
chain, response timeout, document reading, consents and the check row
are separate Settings cards now, each with one action row at its foot.
The chain rows keep the name on its own line on a phone, and the cards
below the provider card wait for its reads so they do not jump.
---
.../settings/ai/ai-consent-card.tsx | 305 +++++++++---------
.../settings/ai/ai-insights-card.tsx | 178 +++++-----
.../settings/ai/anthropic-provider-form.tsx | 17 +-
src/components/settings/ai/auto-read-card.tsx | 67 ++--
.../settings/ai/central-codex-switch.tsx | 67 ++--
.../settings/ai/codex-provider-form.tsx | 29 +-
.../settings/ai/compat-provider-form.tsx | 17 +-
.../settings/ai/fallback-chain-card.tsx | 157 ++++-----
.../settings/ai/local-provider-form.tsx | 17 +-
.../settings/ai/openai-provider-form.tsx | 17 +-
.../settings/ai/response-timeout-card.tsx | 52 +--
.../settings/ai/runtime-actions-row.tsx | 27 +-
12 files changed, 490 insertions(+), 460 deletions(-)
diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx
index 0741df1cc..2dab317fc 100644
--- a/src/components/settings/ai/ai-consent-card.tsx
+++ b/src/components/settings/ai/ai-consent-card.tsx
@@ -34,6 +34,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -124,24 +127,29 @@ export function AiConsentCard({
// below it once the receipt arrived.
if (isLoading) {
return (
-
-
+
+ {confirming ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
);
}
diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx
index 9daea5ea4..f032d5f27 100644
--- a/src/components/settings/ai/ai-insights-card.tsx
+++ b/src/components/settings/ai/ai-insights-card.tsx
@@ -19,6 +19,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
@@ -26,7 +27,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
-import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
@@ -72,6 +72,7 @@ export function AiInsightsCard({
// card paints an honest error instead of a misleading form.
const {
data: insightsSettings,
+ isPending: settingsPending,
isError: settingsError,
refetch: refetchSettings,
} = useQuery({
@@ -87,6 +88,7 @@ export function AiInsightsCard({
const {
data: userProvider,
+ isPending: providerPending,
isError: providerError,
refetch: refetchProvider,
} = useQuery({
@@ -102,6 +104,7 @@ export function AiInsightsCard({
const {
data: chainData,
+ isPending: chainPending,
isError: chainError,
refetch: refetchChain,
} = useQuery({
@@ -116,6 +119,12 @@ export function AiInsightsCard({
});
const hasReadError = settingsError || providerError || chainError;
+ // The cards below the provider card size themselves from these reads (the
+ // chain rows, the provider form). Painting them before the reads land
+ // moved every card under the cursor once they did, so the page holds one
+ // placeholder in the provider card until all three have settled.
+ const readsPending =
+ isAuthenticated && (settingsPending || providerPending || chainPending);
function retryReads() {
void refetchSettings();
void refetchProvider();
@@ -177,94 +186,95 @@ export function AiInsightsCard({
router.replace(`${pathname}?${params.toString()}`);
}
+ // v1.42 — one card per concern. The page used to be a single "AI provider"
+ // card (repeating the page title) holding seven grey panels, three of them
+ // with their own primary Save, each left-aligned mid-card. Every concern is
+ // its own Settings card now, each with one action row at its foot
+ // (design standards §12/§13).
return (
-
- {/* The card used to render an icon-only header row (Sparkles +
- status badges, no title), which left the tile unanchored next
- to its titled siblings. It now follows the shared
- `SettingsCardHeader` contract: icon column, title + description
- in the content column, status badges top-right — and the body
- starts on the card edge like every other Settings card. */}
-
- }
- />
-
-
+
+
+
+ }
+ />
{hasReadError ? (
+ ) : readsPending ? (
+
) : (
<>
-
-
-
-
- {/* v1.22 (#89) — per-user response timeout (mainly for slow
- local/self-hosted backends). */}
-
-
- {/* Read uploaded vault documents automatically with AI (opt-in). */}
-
-
- {/* Use the operator's shared central Codex connection (opt-in; only
- renders when the operator has connected it). */}
-
-
- {/* The standing consent and the one control that withdraws it. It
- sits last because it governs everything above rather than
- configuring any single provider. */}
-
-
-
- queryClient.invalidateQueries({
- queryKey: queryKeys.insightsRoot(),
- })
- }
- onPrivacyChanged={() =>
- queryClient.invalidateQueries({
- queryKey: queryKeys.insightsRoot(),
- })
- }
- />
-
- {/* v1.25 — the data-posture statement lives where the provider key is
- configured: self-hosted, data stays on this instance, BYOK / local
- model, no third-party analytics. One calm line, not a banner. */}
-
>
)}
-
-
+
+
+ {hasReadError || readsPending ? null : (
+ <>
+
+
+ {/* v1.22 (#89) — per-user response timeout (mainly for slow
+ local/self-hosted backends). */}
+
+
+ {/* Read uploaded vault documents automatically with AI (opt-in). */}
+
+
+ {/* Use the operator's shared central Codex connection (opt-in; only
+ renders when the operator has connected it). */}
+
+
+ {/* The standing consent and the one control that withdraws it. It
+ sits after the provider cards because it governs all of them. */}
+
+
+
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsRoot(),
+ })
+ }
+ onPrivacyChanged={() =>
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsRoot(),
+ })
+ }
+ // v1.25 — the data-posture statement lives where the provider
+ // key is configured: self-hosted, data stays on this instance,
+ // BYOK / local model, no third-party analytics.
+ footnote={}
+ />
+ >
+ )}
+
) : null}
-
+
);
}
diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx
index 2aafecff3..eaf4098d2 100644
--- a/src/components/settings/ai/codex-provider-form.tsx
+++ b/src/components/settings/ai/codex-provider-form.tsx
@@ -10,6 +10,7 @@ import { Loader2, Sparkles, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
@@ -270,19 +271,21 @@ export function CodexProviderForm({
) : (
-
+
+
+
)}
{msg && (
diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx
index 3e621cf2f..8717457f8 100644
--- a/src/components/settings/ai/compat-provider-form.tsx
+++ b/src/components/settings/ai/compat-provider-form.tsx
@@ -12,6 +12,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -144,7 +145,13 @@ export function CompatProviderForm({
onChange={setReasoning}
/>
-
+ {msg && (
+
+ {msg}
+
+ )}
+
+
-
- {msg && (
-
- {msg}
-
- )}
+
);
}
diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx
index 80977ecfb..0b4d64e41 100644
--- a/src/components/settings/ai/fallback-chain-card.tsx
+++ b/src/components/settings/ai/fallback-chain-card.tsx
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
+ ListOrdered,
Loader2,
PlusCircle,
RotateCcw,
@@ -29,6 +30,9 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { apiPut } from "@/lib/api/api-fetch";
@@ -148,25 +152,19 @@ export function FallbackChainCard({
}
return (
-
-
-
- {t("settings.ai.providerChain.title")}
-
-
- {t("settings.ai.providerChain.description")}
-
-
+
+
{entries.map((entry, idx) => (
{idx + 1}.
+ {/* The name keeps its own line on a phone and the controls wrap
+ under it, right-aligned; squeezed beside five controls it
+ broke a three-word name over three lines. */}
- toggle(idx)}
- aria-label={t(
- `settings.ai.providerChain.types.${entry.providerType}`,
- )}
- />
-
-
-
+