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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:20:02 +02:00
1 parent 651ec3aea8
commit 97e4dd4c05
12 files changed
+490 -460

No files matched your search

+161 -144
View File
@@ -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 (
<section className="space-y-3" data-slot="ai-consent">
<div className="space-y-1">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-8 w-32" />
</section>
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
description={<Skeleton className="h-4 w-56" />}
/>
<Skeleton className="ml-auto h-8 w-32" />
</SettingsCard>
);
}
if (isError) {
return (
<section className="space-y-3" data-slot="ai-consent">
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.ai.consent.title")}
/>
<QueryErrorRow
slot="ai-consent-load-error"
onRetry={() => void refetch()}
/>
</section>
</SettingsCard>
);
}
@@ -150,100 +158,106 @@ export function AiConsentCard({
const activeReceipt = data && data.revokedAt === null ? data : null;
return (
<section className="space-y-3" data-slot="ai-consent">
<div className="space-y-1">
<p className="flex items-center gap-2 text-sm font-medium">
{activeReceipt ? (
<ShieldCheck className="size-4" aria-hidden />
) : (
<ShieldOff className="text-muted-foreground size-4" aria-hidden />
)}
{t("settings.ai.consent.title")}
</p>
<p className="text-muted-foreground text-xs">
{activeReceipt
? t("settings.ai.consent.activeSince", {
date: formatDateTime(activeReceipt.signedAt),
})
: t("settings.ai.consent.withdrawn")}
</p>
</div>
<>
<SettingsCard as="section" data-slot="ai-consent">
<SettingsCardHeader
icon={activeReceipt ? ShieldCheck : ShieldOff}
title={t("settings.ai.consent.title")}
description={
activeReceipt
? t("settings.ai.consent.activeSince", {
date: formatDateTime(activeReceipt.signedAt),
})
: t("settings.ai.consent.withdrawn")
}
/>
{activeReceipt ? (
confirming ? (
<div className="flex flex-wrap items-center gap-2">
<p className="text-muted-foreground w-full text-xs">
{t("settings.ai.consent.confirmBody")}
</p>
<Button
type="button"
variant="destructive"
size="sm"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
>
{t("settings.ai.consent.withdraw")}
</Button>
)
) : (
<div className="space-y-2">
{activeReceipt && confirming ? (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.confirmBody")}
</p>
) : null}
{!activeReceipt ? (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
<Button
type="button"
variant="outline"
size="sm"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
</Button>
</div>
)}
) : null}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.error")}
</p>
) : null}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.error")}
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
{grant.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.grantError")}
</p>
) : null}
{/* One action row, last in the card; while confirming, Cancel sits
before the destructive confirm (design standards §12). */}
<SettingsCardActions>
{activeReceipt ? (
confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
>
{t("settings.ai.consent.withdraw")}
</Button>
)
) : (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-grant"
onClick={() => grant.mutate()}
disabled={grant.isPending}
>
{grant.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.grant")}
</Button>
)}
</SettingsCardActions>
</SettingsCard>
<DocumentReadingConsentRow isAuthenticated={isAuthenticated} />
</section>
</>
);
}
@@ -298,62 +312,65 @@ function DocumentReadingConsentRow({
if (!active) return null;
return (
<div
data-slot="ai-consent-document-reading"
className="border-border space-y-2 border-t pt-3"
>
<p className="text-sm font-medium">
{t("settings.ai.consent.documentReading.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.documentReading.activeSince", {
<SettingsCard as="section" data-slot="ai-consent-document-reading">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.ai.consent.documentReading.title")}
description={t("settings.ai.consent.documentReading.activeSince", {
date: formatDateTime(active.signedAt),
})}
</p>
/>
{confirming ? (
<div className="flex flex-wrap items-center gap-2">
<p className="text-muted-foreground w-full text-xs">
{t("settings.ai.consent.documentReading.confirmBody")}
</p>
<Button
type="button"
variant="destructive"
size="sm"
data-slot="ai-consent-document-reading-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
data-slot="ai-consent-document-reading-withdraw"
onClick={() => setConfirming(true)}
>
{t("settings.ai.consent.withdraw")}
</Button>
)}
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.documentReading.confirmBody")}
</p>
) : null}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.error")}
</p>
) : null}
</div>
<SettingsCardActions>
{confirming ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-document-reading-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="ai-consent-document-reading-withdraw"
onClick={() => setConfirming(true)}
>
{t("settings.ai.consent.withdraw")}
</Button>
)}
</SettingsCardActions>
</SettingsCard>
);
}
+86 -92
View File
@@ -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 (
<SettingsCard>
{/* 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. */}
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.providerCardTitle")}
description={t("settings.kiInsightsDescription")}
status={
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
/>
}
/>
<div className="space-y-4">
<div className="space-y-6">
<SettingsCard data-slot="ai-provider-card">
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.activeProviderHeading")}
description={t("settings.ai.providerCardDescription")}
status={
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
/>
}
/>
{hasReadError ? (
<QueryErrorCard onRetry={retryReads} />
) : readsPending ? (
<Skeleton className="h-40 w-full rounded-lg" />
) : (
<>
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
{/* Use the operator's shared central Codex connection (opt-in; only
renders when the operator has connected it). */}
<CentralCodexSwitch settings={insightsSettings} />
{/* The standing consent and the one control that withdraws it. It
sits last because it governs everything above rather than
configuring any single provider. */}
<AiConsentCard isAuthenticated={isAuthenticated} />
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
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. */}
<MedicalDisclaimer variant="dataPosture" />
</>
)}
</div>
</SettingsCard>
</SettingsCard>
{hasReadError || readsPending ? null : (
<>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
{/* Use the operator's shared central Codex connection (opt-in; only
renders when the operator has connected it). */}
<CentralCodexSwitch settings={insightsSettings} />
{/* The standing consent and the one control that withdraws it. It
sits after the provider cards because it governs all of them. */}
<AiConsentCard isAuthenticated={isAuthenticated} />
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
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={<MedicalDisclaimer variant="dataPosture" />}
/>
</>
)}
</div>
);
}
@@ -295,12 +305,6 @@ function ProviderStatusBadges({
{t("settings.ai.connectionExpiredBadge")}
</Badge>
)}
{settings?.lastInsightAt && (
<Badge variant="outline" className="text-xs">
{t("settings.lastGeneratedAt")}:{" "}
{formatDateTime(settings.lastInsightAt)}
</Badge>
)}
</div>
);
}
@@ -324,13 +328,7 @@ function ActiveProviderSelect({
}) {
const { t } = useTranslations();
return (
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium">
{t("settings.ai.activeProviderHeading")}
</p>
<p className="text-muted-foreground mb-3 text-xs">
{t("settings.ai.activeProviderBody")}
</p>
<div className="space-y-1.5">
<Label htmlFor="ai-active-provider-select">
{t("settings.ai.activeProviderLabel")}
</Label>
@@ -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) => (
<option key={p} value={p}>
@@ -371,12 +369,8 @@ function ProviderConfigCard({
insightsSettings: InsightsSettings | null | undefined;
userProvider: UserAIProvider | null | undefined;
}) {
const { t } = useTranslations();
return (
<div className="bg-muted/50 space-y-4 rounded-lg p-4">
<p className="text-sm font-medium">
{t("settings.ai.providerConfigTitle")}
</p>
<div className="space-y-4" data-slot="ai-provider-config">
{provider === "codex" && (
<CodexProviderForm settings={insightsSettings} />
)}
@@ -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";
@@ -161,7 +162,13 @@ export function AnthropicProviderForm({
</div>
)}
<div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
@@ -176,13 +183,7 @@ export function AnthropicProviderForm({
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</SettingsCardActions>
</form>
);
}
+32 -35
View File
@@ -16,9 +16,12 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldAlert } from "lucide-react";
import { ScanText, 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 { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useAiCapability } from "@/hooks/use-ai-capability";
@@ -86,27 +89,21 @@ export function AutoReadCard() {
const checked = enabled || pendingEnable;
return (
<div
data-slot="documents-auto-read-card"
className="bg-muted/50 space-y-4 rounded-lg p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">
{t("settings.ai.autoRead.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.autoRead.subLabel")}
</p>
</div>
<Switch
checked={checked}
disabled={busy || (operatorOff && !enabled)}
onCheckedChange={onSwitch}
aria-label={t("settings.ai.autoRead.title")}
data-testid="documents-auto-read-enable"
/>
</div>
<SettingsCard data-slot="documents-auto-read-card">
<SettingsCardHeader
icon={ScanText}
title={t("settings.ai.autoRead.title")}
description={t("settings.ai.autoRead.subLabel")}
status={
<Switch
checked={checked}
disabled={busy || (operatorOff && !enabled)}
onCheckedChange={onSwitch}
aria-label={t("settings.ai.autoRead.title")}
data-testid="documents-auto-read-enable"
/>
}
/>
{operatorOff ? (
<p
@@ -130,7 +127,17 @@ export function AutoReadCard() {
/>
<p className="min-w-0">{t("settings.ai.autoRead.honesty")}</p>
</div>
<div className="flex flex-wrap gap-2">
<SettingsCardActions>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={save.isPending}
onClick={() => setPendingEnable(false)}
>
{t("settings.ai.autoRead.cancel")}
</Button>
<Button
type="button"
size="sm"
@@ -141,17 +148,7 @@ export function AutoReadCard() {
>
{t("settings.ai.autoRead.confirm")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="min-h-11 sm:min-h-9"
disabled={save.isPending}
onClick={() => setPendingEnable(false)}
>
{t("settings.ai.autoRead.cancel")}
</Button>
</div>
</SettingsCardActions>
</div>
) : null}
@@ -160,6 +157,6 @@ export function AutoReadCard() {
{t("settings.ai.errorGeneric")}
</p>
) : null}
</div>
</SettingsCard>
);
}
@@ -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 (
<div
data-slot="central-codex-switch-card"
className="bg-muted/50 space-y-4 rounded-lg p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">
{t("settings.ai.centralCodex.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.centralCodex.subLabel")}
</p>
</div>
<Switch
checked={checked}
disabled={busy}
onCheckedChange={onSwitch}
aria-label={t("settings.ai.centralCodex.title")}
data-testid="use-central-codex-enable"
/>
</div>
<SettingsCard data-slot="central-codex-switch-card">
<SettingsCardHeader
icon={Share2}
title={t("settings.ai.centralCodex.title")}
description={t("settings.ai.centralCodex.subLabel")}
status={
<Switch
checked={checked}
disabled={busy}
onCheckedChange={onSwitch}
aria-label={t("settings.ai.centralCodex.title")}
data-testid="use-central-codex-enable"
/>
}
/>
{pendingEnable ? (
<div
@@ -109,7 +106,17 @@ export function CentralCodexSwitch({
/>
<p className="min-w-0">{t("settings.ai.centralCodex.honesty")}</p>
</div>
<div className="flex flex-wrap gap-2">
<SettingsCardActions>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={save.isPending}
onClick={() => setPendingEnable(false)}
>
{t("settings.ai.centralCodex.cancel")}
</Button>
<Button
type="button"
size="sm"
@@ -120,17 +127,7 @@ export function CentralCodexSwitch({
>
{t("settings.ai.centralCodex.confirm")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="min-h-11 sm:min-h-9"
disabled={save.isPending}
onClick={() => setPendingEnable(false)}
>
{t("settings.ai.centralCodex.cancel")}
</Button>
</div>
</SettingsCardActions>
</div>
) : null}
@@ -139,6 +136,6 @@ export function CentralCodexSwitch({
{t("settings.ai.errorGeneric")}
</p>
) : null}
</div>
</SettingsCard>
);
}
@@ -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({
</div>
</div>
) : (
<Button
variant="outline"
onClick={handleConnect}
disabled={devicePolling}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{devicePolling ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="h-4 w-4" />
)}
{t("settings.ai.codex.connectButton")}
</Button>
<SettingsCardActions>
<Button
variant="outline"
onClick={handleConnect}
disabled={devicePolling}
className="min-h-11 sm:min-h-9"
>
{devicePolling ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="h-4 w-4" />
)}
{t("settings.ai.codex.connectButton")}
</Button>
</SettingsCardActions>
)}
{msg && (
@@ -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}
/>
<div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
@@ -159,13 +166,7 @@ export function CompatProviderForm({
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</SettingsCardActions>
</form>
);
}
@@ -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 (
<div
data-testid="ai-fallback-chain"
className="bg-muted/50 space-y-3 rounded-lg p-4"
>
<div>
<p className="text-sm font-medium">
{t("settings.ai.providerChain.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.providerChain.description")}
</p>
</div>
<SettingsCard data-testid="ai-fallback-chain">
<SettingsCardHeader
icon={ListOrdered}
title={t("settings.ai.providerChain.title")}
description={t("settings.ai.providerChain.description")}
/>
<ul className="space-y-2">
{entries.map((entry, idx) => (
<li
key={entry.providerType}
data-chain-row={entry.providerType}
className={`bg-card border-border flex flex-wrap items-center gap-2 rounded-md border p-2 ${
className={`bg-card border-border flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border p-2 ${
entry.providerType === selected
? "border-primary/40 ring-primary/30 ring-1"
: ""
@@ -175,53 +173,58 @@ export function FallbackChainCard({
<span className="text-muted-foreground w-5 text-center text-xs tabular-nums">
{idx + 1}.
</span>
{/* 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. */}
<button
type="button"
onClick={() => onSelect(entry.providerType)}
className="flex-1 text-left text-sm font-medium hover:underline"
className="min-w-0 flex-1 basis-[calc(100%-2rem)] text-left text-sm font-medium hover:underline sm:basis-0"
>
{t(`settings.ai.providerChain.types.${entry.providerType}`)}
</button>
<Switch
checked={entry.enabled}
onCheckedChange={() => toggle(idx)}
aria-label={t(
`settings.ai.providerChain.types.${entry.providerType}`,
)}
/>
<Button
type="button"
size="sm"
variant="outline"
aria-label={t("settings.ai.providerChain.moveUp")}
disabled={idx === 0}
onClick={() => move(idx, -1)}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label={t("settings.ai.providerChain.moveDown")}
disabled={idx === entries.length - 1}
onClick={() => move(idx, 1)}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label={t("settings.ai.providerChain.removeFromChain")}
className="text-destructive min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => remove(idx)}
disabled={entries.length <= 1}
>
<X className="h-4 w-4" />
</Button>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Switch
checked={entry.enabled}
onCheckedChange={() => toggle(idx)}
aria-label={t(
`settings.ai.providerChain.types.${entry.providerType}`,
)}
/>
<Button
type="button"
size="sm"
variant="outline"
aria-label={t("settings.ai.providerChain.moveUp")}
disabled={idx === 0}
onClick={() => move(idx, -1)}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label={t("settings.ai.providerChain.moveDown")}
disabled={idx === entries.length - 1}
onClick={() => move(idx, 1)}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label={t("settings.ai.providerChain.removeFromChain")}
className="text-destructive min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => remove(idx)}
disabled={entries.length <= 1}
>
<X className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
@@ -234,20 +237,14 @@ export function FallbackChainCard({
{t("settings.ai.providerChain.addNoneAvailable")}
</p>
)}
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(entries)}
disabled={saveMutation.isPending || entries.length === 0}
data-testid="ai-fallback-chain-save"
>
{saveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.ai.providerChain.saveOrder")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="min-h-11 sm:min-h-9">
@@ -272,14 +269,22 @@ export function FallbackChainCard({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</div>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(entries)}
disabled={saveMutation.isPending || entries.length === 0}
data-testid="ai-fallback-chain-save"
>
{saveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.ai.providerChain.saveOrder")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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}
/>
<div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
@@ -195,13 +202,7 @@ export function LocalProviderForm({
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</SettingsCardActions>
</form>
);
}
@@ -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({
)}
</div>
<div className="flex flex-wrap gap-2">
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
@@ -213,13 +220,7 @@ export function OpenAIProviderForm({
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</SettingsCardActions>
</form>
);
}
@@ -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 (
<form
className="bg-muted/50 space-y-3 rounded-lg p-4"
onSubmit={submit}
noValidate
>
<div>
<p className="text-sm font-medium">
{t("settings.ai.responseTimeoutHeading")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)}
</p>
</div>
<div>
<SettingsCard as="form" onSubmit={submit} noValidate>
<SettingsCardHeader
icon={Timer}
title={t("settings.ai.responseTimeoutHeading")}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ai-response-timeout">
{t("settings.ai.responseTimeoutLabel")}
</Label>
@@ -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. */}
<p
id="ai-response-timeout-hint"
className="text-muted-foreground text-xs"
>
{t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)}
</p>
</div>
<div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
@@ -147,12 +156,7 @@ export function ResponseTimeoutCard({
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</form>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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.
<div className="space-y-3">
<SettingsCard data-slot="ai-runtime-card">
<SettingsCardHeader
icon={Activity}
title={t("settings.ai.runtimeTitle")}
description={lastInsightLine ?? undefined}
/>
{showRegenerate && (
<div className="bg-muted/50 rounded-lg p-3">
<div>
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
@@ -241,9 +251,6 @@ export function RuntimeActionsRow({
</div>
)}
{lastInsightLine && (
<p className="text-muted-foreground text-xs">{lastInsightLine}</p>
)}
{testMsg && (
<p
className={`text-xs ${testOk ? "text-success" : "text-destructive"}`}
@@ -259,7 +266,9 @@ export function RuntimeActionsRow({
</p>
)}
<SettingsCardActions align="start">
{footnote}
<SettingsCardActions>
<Button
type="button"
size="sm"
@@ -293,6 +302,6 @@ export function RuntimeActionsRow({
</Button>
)}
</SettingsCardActions>
</div>
</SettingsCard>
);
}