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} + +

+ ); }