mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
651ec3aea8
commit
97e4dd4c05
12 files changed
+490
-460
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user