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 ( -
-
- - -
- -
+ + } + /> + + ); } if (isError) { return ( -
+ + void refetch()} /> -
+ ); } @@ -150,100 +158,106 @@ export function AiConsentCard({ const activeReceipt = data && data.revokedAt === null ? data : null; return ( -
-
-

- {activeReceipt ? ( - - ) : ( - - )} - {t("settings.ai.consent.title")} -

-

- {activeReceipt - ? t("settings.ai.consent.activeSince", { - date: formatDateTime(activeReceipt.signedAt), - }) - : t("settings.ai.consent.withdrawn")} -

-
+ <> + + - {activeReceipt ? ( - confirming ? ( -
-

- {t("settings.ai.consent.confirmBody")} -

- - -
- ) : ( - - ) - ) : ( -
+ {activeReceipt && confirming ? ( +

+ {t("settings.ai.consent.confirmBody")} +

+ ) : null} + {!activeReceipt ? (

{t("settings.ai.consent.regrantHint")}

- -
- )} + ) : null} - {revoke.isError ? ( -

- {t("settings.ai.consent.error")} -

- ) : null} + {revoke.isError ? ( +

+ {t("settings.ai.consent.error")} +

+ ) : null} - {grant.isError ? ( -

- {t("settings.ai.consent.grantError")} -

- ) : null} + {grant.isError ? ( +

+ {t("settings.ai.consent.grantError")} +

+ ) : null} + + {/* One action row, last in the card; while confirming, Cancel sits + before the destructive confirm (design standards §12). */} + + {activeReceipt ? ( + confirming ? ( + <> + + + + ) : ( + + ) + ) : ( + + )} + +
-
+ ); } @@ -298,62 +312,65 @@ function DocumentReadingConsentRow({ if (!active) return null; return ( -
-

- {t("settings.ai.consent.documentReading.title")} -

-

- {t("settings.ai.consent.documentReading.activeSince", { + + + /> {confirming ? ( -

-

- {t("settings.ai.consent.documentReading.confirmBody")} -

- - -
- ) : ( - - )} +

+ {t("settings.ai.consent.documentReading.confirmBody")} +

+ ) : null} {revoke.isError ? (

{t("settings.ai.consent.error")}

) : null} -
+ + {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={} + /> + + )} +
); } @@ -295,12 +305,6 @@ function ProviderStatusBadges({ {t("settings.ai.connectionExpiredBadge")} )} - {settings?.lastInsightAt && ( - - {t("settings.lastGeneratedAt")}:{" "} - {formatDateTime(settings.lastInsightAt)} - - )} ); } @@ -324,13 +328,7 @@ function ActiveProviderSelect({ }) { const { t } = useTranslations(); return ( -
-

- {t("settings.ai.activeProviderHeading")} -

-

- {t("settings.ai.activeProviderBody")} -

+
@@ -342,7 +340,7 @@ function ActiveProviderSelect({ const next = e.target.value; if (isProviderType(next)) onChange(next); }} - className="mt-1 sm:max-w-md" + className="sm:max-w-md" > {PROVIDER_TYPES.map((p) => (
+ ); } diff --git a/src/components/settings/ai/central-codex-switch.tsx b/src/components/settings/ai/central-codex-switch.tsx index 010366b03..980de0bac 100644 --- a/src/components/settings/ai/central-codex-switch.tsx +++ b/src/components/settings/ai/central-codex-switch.tsx @@ -17,9 +17,12 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ShieldAlert } from "lucide-react"; +import { Share2, ShieldAlert } 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 { Switch } from "@/components/ui/switch"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; @@ -74,27 +77,21 @@ export function CentralCodexSwitch({ const checked = enabled || pendingEnable; return ( -
-
-
-

- {t("settings.ai.centralCodex.title")} -

-

- {t("settings.ai.centralCodex.subLabel")} -

-
- -
+ + + } + /> {pendingEnable ? (

{t("settings.ai.centralCodex.honesty")}

-
+ + - -
+
) : null} @@ -139,6 +136,6 @@ export function CentralCodexSwitch({ {t("settings.ai.errorGeneric")}

) : 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}`, - )} - /> - - - +
    + toggle(idx)} + aria-label={t( + `settings.ai.providerChain.types.${entry.providerType}`, + )} + /> + + + +
  • ))}
@@ -234,20 +237,14 @@ export function FallbackChainCard({ {t("settings.ai.providerChain.addNoneAvailable")}

)} - +
+ + {msg && ( +

+ {msg} +

+ )} + + + ); } diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index 58d255a2e..9a2abd63b 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -9,6 +9,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 { NativeSelect } from "@/components/ui/native-select"; @@ -180,7 +181,13 @@ export function LocalProviderForm({ onChange={setReasoning} /> -
+ {msg && ( +

+ {msg} +

+ )} + +
- - {msg && ( -

- {msg} -

- )} + ); } diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index 84c5f63de..b598f9514 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-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 { NativeSelect } from "@/components/ui/native-select"; @@ -198,7 +199,13 @@ export function OpenAIProviderForm({ )} -
+ {msg && ( +

+ {msg} +

+ )} + +
- - {msg && ( -

- {msg} -

- )} + ); } diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index 628e9c0e4..8af2bcadd 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -13,9 +13,12 @@ import { useRef, useState, type FormEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Loader2, Save } from "lucide-react"; +import { Loader2, Save, Timer } 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; @@ -100,20 +103,12 @@ export function ResponseTimeoutCard({ } return ( -
-
-

- {t("settings.ai.responseTimeoutHeading")} -

-

- {t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)} -

-
-
+ + +
@@ -129,10 +124,24 @@ export function ResponseTimeoutCard({ "settings.ai.responseTimeoutPlaceholder", RESPONSE_TIMEOUT_COPY_PARAMS, )} - className="mt-1 sm:max-w-xs" + aria-describedby="ai-response-timeout-hint" + className="sm:max-w-xs" /> + {/* The explainer is several sentences, so it rides under the field it + explains rather than in the one-sentence header slot. */} +

+ {t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)} +

-
+ {msg && ( +

+ {msg} +

+ )} +
- {msg && ( -

- {msg} -

- )} - + +
); } diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c0a1ebcce..e91f09034 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -4,13 +4,15 @@ * Runtime actions — Test active provider, regenerate insights, raw-mode toggle. * ──────────────────────────────────────────────────────────────── */ -import { useState } from "react"; -import { Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useAiCapability } from "@/hooks/use-ai-capability"; @@ -30,6 +32,7 @@ export function RuntimeActionsRow({ lastInsightAt, onRegenerated, onPrivacyChanged, + footnote, }: { provider: ProviderType; userProvider: UserAIProvider | null | undefined; @@ -38,6 +41,8 @@ export function RuntimeActionsRow({ lastInsightAt: string | null; onRegenerated: () => void; onPrivacyChanged: () => void; + /** A closing statement rendered above the action row (the data posture). */ + footnote?: ReactNode; }) { const { t } = useTranslations(); // "Regenerate" writes the daily briefing, so it is offered only while the @@ -215,9 +220,14 @@ export function RuntimeActionsRow({ // §12 — the action row is the LAST thing in the block. The raw-data // setting and every status line (last-generated, test / regenerate // result) sit ABOVE it, never under it. -
+ + {showRegenerate && ( -
+

{t("settings.rawData")}

@@ -241,9 +251,6 @@ export function RuntimeActionsRow({
)} - {lastInsightLine && ( -

{lastInsightLine}

- )} {testMsg && (

)} - + {footnote} + +

+ ); }