"Trying to use a different provider on the web app glitches out completely" -
an iPhone photo of the first-run gate with iOS's saved-password sheet laid
over it ("password for proxmox...") and the provider field stuck under the
keyboard.
- The key fields (gate, Settings, provider rows) were type=password with
autocomplete=off, which iOS/Safari and 1Password/LastPass read as a login.
They now carry autoComplete="new-password", data-form-type="other",
data-lpignore="true", autoCorrect/autoCapitalize=off and inputMode=text:
the mask stays, the password overlay goes. The custom base URL gets
inputMode=url and the same hints.
- The gate's keyboard handling scrolled to the Continue button instead of the
field being edited, so with the provider rows open the keyboard covered the
input and the locked wrapper could not be scrolled to it. The wrapper is
user-scrollable again, alignment anchors on the focused element (Continue
is the fallback), and the corner controls hide while the keyboard is up
rather than landing on the card.
providers-test drives a fake visualViewport (340px, installed before mount)
and pins the three layout contracts plus the hardening attributes. All 16
suites green, contrast clean. Version 0.7.2, SW v94.
1123 lines
52 KiB
TypeScript
1123 lines
52 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { AnimatePresence, motion, useDragControls } from "framer-motion";
|
|
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
|
|
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
|
|
import { useVersion } from "@/lib/use-version";
|
|
import { haptic } from "@/lib/haptics";
|
|
import { modelErrorCopy } from "@/lib/model-errors";
|
|
import { springSheet } from "@/lib/motion";
|
|
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
|
import { DataSection } from "@/components/DataSection";
|
|
import { LearningSection } from "@/components/LearningSection";
|
|
import { ThemeSection } from "@/components/ThemeSection";
|
|
import { useReducedMotionPref } from "@/lib/motion-pref";
|
|
import { ProvidersSection } from "@/components/ProvidersSection";
|
|
import { SyncSection } from "@/components/SyncSection";
|
|
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
|
|
import { useBackClose } from "@/lib/use-back-close";
|
|
import { useIsPhone } from "@/lib/use-phone";
|
|
import { cn } from "@/lib/utils";
|
|
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
|
|
import { useHealth } from "@/lib/use-health";
|
|
import {
|
|
AlertTriangleIcon,
|
|
CheckCircleIcon,
|
|
ChevronDownIcon,
|
|
ChevronRightIcon,
|
|
DownloadIcon,
|
|
EyeIcon,
|
|
EyeOffIcon,
|
|
RefreshIcon,
|
|
XCircleIcon,
|
|
} from "@/components/icons";
|
|
import { Button, Spinner } from "@/components/ui";
|
|
import { SegmentedPill } from "@/components/SegmentedPill";
|
|
|
|
/** “Checked just now”, or the clock time once the result is older. */
|
|
function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars?: Record<string, string>) => string) {
|
|
if (Date.now() - checkedAt < 90_000) return t("health.checkedNow");
|
|
const time = new Date(checkedAt).toLocaleTimeString(locale === "cs" ? "cs-CZ" : "en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
return t("health.checkedAt", { time });
|
|
}
|
|
|
|
export function SettingsDialog({
|
|
open,
|
|
onClose,
|
|
focusField,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
/** Set by openSettings("key") — focuses the key input once the sheet opens. */
|
|
focusField?: { field: "key"; nonce: number } | null;
|
|
}) {
|
|
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
|
|
const { reduced: reducedMotion, setReduced: setReducedMotion } = useReducedMotionPref();
|
|
const installAvailable = useInstallAvailable();
|
|
const { locale, setLocale, t, plural } = useI18n();
|
|
const version = useVersion();
|
|
const keyInputRef = useRef<HTMLInputElement>(null);
|
|
const settings = useSyncExternalStore(
|
|
settingsStore.subscribe,
|
|
settingsStore.getSnapshot,
|
|
settingsStore.getServerSnapshot,
|
|
);
|
|
/* The system Back gesture closes the sheet instead of leaving the page. */
|
|
useBackClose(open, onClose);
|
|
|
|
/* "Open Settings" from a usage-limit error lands right on the key field. */
|
|
useEffect(() => {
|
|
if (!open || focusField?.field !== "key") return;
|
|
const timer = window.setTimeout(() => {
|
|
keyInputRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
keyInputRef.current?.focus({ preventScroll: true });
|
|
}, 380);
|
|
return () => window.clearTimeout(timer);
|
|
}, [open, focusField]);
|
|
const [keyDraft, setKeyDraft] = useState(apiKey);
|
|
const [reveal, setReveal] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
const [canScroll, setCanScroll] = useState(false);
|
|
// Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
|
|
const phone = useIsPhone();
|
|
const dragControls = useDragControls();
|
|
const reduceMotion = useReducedMotion();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setKeyDraft(apiKey);
|
|
setSaved(false);
|
|
setConfirmRemove(false);
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, apiKey, onClose]);
|
|
|
|
// The scroll cue: a small chevron that fades in whenever more content is
|
|
// below the fold, and out once the bottom is reached.
|
|
const updateCue = () => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
setCanScroll(element.scrollHeight - element.scrollTop - element.clientHeight > 24);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const element = scrollRef.current;
|
|
const content = contentRef.current;
|
|
if (!element) return;
|
|
updateCue();
|
|
const observer = new ResizeObserver(updateCue);
|
|
if (content) observer.observe(content);
|
|
return () => observer.disconnect();
|
|
}, [open]);
|
|
|
|
// Drag only from the handle/header, and only while the body is at the top.
|
|
const startSheetDrag = (event: React.PointerEvent) => {
|
|
if (!phone) return;
|
|
if ((event.target as HTMLElement).closest("button, a, input, summary")) return;
|
|
const element = scrollRef.current;
|
|
if (element && element.scrollTop > 2) return;
|
|
dragControls.start(event);
|
|
};
|
|
|
|
const save = () => {
|
|
saveSettings({ apiKey: keyDraft.trim() });
|
|
setSaved(true);
|
|
window.setTimeout(() => setSaved(false), 2500);
|
|
};
|
|
|
|
const forget = () => {
|
|
clearApiKey();
|
|
setKeyDraft("");
|
|
setConfirmRemove(false);
|
|
};
|
|
|
|
const needsKeyMode = health?.authMode === "user-key";
|
|
|
|
/* Phones: one status row instead of the whole health panel. */
|
|
const modelsReady = health ? [health.vision.available, health.text.available].filter((value) => value === true).length : 0;
|
|
const statusText = !health
|
|
? t("settings.checking")
|
|
: health.ok
|
|
? `${t("settings.connected")} · ${plural(modelsReady, "settings.modelsReady")}`
|
|
: health.needsKey
|
|
? t("health.needsKey")
|
|
: health.keyAccepted === false
|
|
? t("health.rejected")
|
|
: health.reachable
|
|
? t("health.modelMissing")
|
|
: t("health.unreachable");
|
|
const statusDot = !health
|
|
? "bg-[var(--text-subtle)]"
|
|
: health.ok
|
|
? "bg-emerald-500"
|
|
: health.needsKey || health.reachable
|
|
? "bg-amber-500"
|
|
: "bg-rose-500";
|
|
const modelBadge = (model: { available: boolean | null } | undefined) =>
|
|
!model || model.available === null
|
|
? { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" }
|
|
: model.available
|
|
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
|
: { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" };
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open ? (
|
|
<motion.div
|
|
className="scene-pause fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
|
|
initial={reduceMotion ? false : { opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={reduceMotion ? { duration: 0 } : { duration: 0.18 }}
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="settings-title"
|
|
drag={phone ? "y" : false}
|
|
dragControls={dragControls}
|
|
dragListener={false}
|
|
dragMomentum={false}
|
|
dragConstraints={{ top: 0, bottom: 0 }}
|
|
dragElastic={{ top: 0, bottom: 0.55 }}
|
|
onDragEnd={(_, gesture) => {
|
|
if (gesture.offset.y > 90 || gesture.delta.y > 140 || gesture.velocity.y > 700) onClose();
|
|
}}
|
|
initial={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 48, opacity: 0 }}
|
|
animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
|
|
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
|
|
transition={reduceMotion ? { duration: 0 } : phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="glass-panel glass-sheet relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
|
|
>
|
|
{/* handle + header: the drag strip. Fixed, so the body scrolls. */}
|
|
<div
|
|
onPointerDown={startSheetDrag}
|
|
className={cn("shrink-0 px-5 pt-2", phone && "cursor-grab touch-none active:cursor-grabbing")}
|
|
>
|
|
<div aria-hidden className="mx-auto flex h-6 w-full items-center justify-center">
|
|
<span className="h-1 w-9 rounded-full bg-[var(--text-subtle)] opacity-40" />
|
|
</div>
|
|
<div className="mb-3 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
|
|
{t("settings.title")}
|
|
</h2>
|
|
{!phone ? (
|
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
|
{t("settings.subtitle.before")}{" "}
|
|
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
|
|
{t("settings.subtitle.after")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="touch-target -mr-1 shrink-0"
|
|
onClick={onClose}
|
|
aria-label={t("settings.close")}
|
|
>
|
|
✕
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* scrollable body: the safe area comes from pb-safe + a spacer */}
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={updateCue}
|
|
className="sheet-scroll thin-scroll min-h-0 flex-1 overflow-y-auto px-5 pb-safe"
|
|
>
|
|
<div ref={contentRef}>
|
|
{phone ? (
|
|
<>
|
|
{/* --------------------------------------------- language */}
|
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("language.label")}
|
|
</h3>
|
|
<div className="segmented" role="group" aria-label={t("language.label")}>
|
|
{LOCALES.map((entry) => (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
aria-pressed={locale === entry.id}
|
|
onClick={() => {
|
|
haptic("toggle");
|
|
setLocale(entry.id);
|
|
}}
|
|
className={cn("relative", phone && "seg-slide")}
|
|
>
|
|
{phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null}
|
|
<span className="seg-label relative z-10">{entry.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ------------------------------------------- connection */}
|
|
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("settings.connection")}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refresh()}
|
|
aria-label={t("settings.recheck")}
|
|
title={t("settings.recheck")}
|
|
className="touch-target grid size-9 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
|
>
|
|
<RefreshIcon className={cn("size-4", loading && "animate-spin")} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-[13px] font-medium" role="status">
|
|
<span aria-hidden className={cn("size-2.5 shrink-0 rounded-full", statusDot)} />
|
|
<span className="min-w-0 flex-1 text-[var(--text)]">{statusText}</span>
|
|
</div>
|
|
|
|
{/* monthly usage used up / short-term throttle */}
|
|
{health?.kind === "usage_limit" || health?.kind === "throttled" ? (
|
|
<div className="rounded-xl border border-amber-300 bg-amber-50 p-2.5 text-[12px] leading-relaxed text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
|
|
<p className="font-bold">{modelErrorCopy(health.kind)?.title}</p>
|
|
<p className="mt-0.5">{modelErrorCopy(health.kind)?.body}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<ModelErrorActions kind={health.kind} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="relative">
|
|
<label className="sr-only" htmlFor="ollama-api-key-phone">
|
|
{t("settings.apiKey")}
|
|
</label>
|
|
<input
|
|
id="ollama-api-key-phone"
|
|
ref={keyInputRef}
|
|
type={reveal ? "text" : "password"}
|
|
value={keyDraft}
|
|
onChange={(event) => setKeyDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation();
|
|
if (event.key === "Enter") save();
|
|
}}
|
|
placeholder={t("settings.pasteKey")}
|
|
/* Not a login: keep the browser's password UI away
|
|
(see the gate for the same treatment). */
|
|
autoComplete="new-password"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
inputMode="text"
|
|
data-form-type="other"
|
|
data-lpignore="true"
|
|
className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] py-3 pl-3 pr-12 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-[var(--text-subtle)] focus:border-accent-500 dark:text-frost-50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setReveal((value) => !value)}
|
|
aria-label={reveal ? t("settings.hide") : t("settings.show")}
|
|
title={reveal ? t("settings.hide") : t("settings.show")}
|
|
className="absolute right-0.5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-lg text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
|
>
|
|
{reveal ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
|
</button>
|
|
</div>
|
|
|
|
{keyDraft.trim() !== apiKey ? (
|
|
<Button size="sm" className="touch-target w-full" onClick={save}>
|
|
{t("common.save")}
|
|
</Button>
|
|
) : null}
|
|
|
|
<details className="group">
|
|
<summary className="touch-target flex cursor-pointer list-none flex-wrap items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] [&::-webkit-details-marker]:hidden">
|
|
{t("settings.privacyLine")}
|
|
<span className="inline-flex items-center gap-0.5 font-semibold text-[var(--text)] underline underline-offset-2">
|
|
{t("common.more")}
|
|
<ChevronDownIcon className="size-3 transition-transform group-open:rotate-180" />
|
|
</span>
|
|
</summary>
|
|
<div className="space-y-1.5 pt-1.5">
|
|
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.keyNote")}</p>
|
|
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("settings.localTipTitle")} {t("settings.localTipSet")}{" "}
|
|
<code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")} <code>.env.local</code>{" "}
|
|
{t("settings.keyNoteEnd")} <code>ollama signin</code>
|
|
{t("settings.keyNoteEnd2")}
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
{/* haptics: best-effort vibration feedback */}
|
|
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
|
|
<span className="min-w-0">
|
|
<span className="block text-[13px] font-semibold text-[var(--text)]">
|
|
{t("settings.haptics")}
|
|
</span>
|
|
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">
|
|
{t("settings.hapticsNote")}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={settings.haptics}
|
|
aria-label={t("settings.haptics")}
|
|
onClick={() => {
|
|
saveSettings({ haptics: !settings.haptics });
|
|
haptic("toggle");
|
|
}}
|
|
className={cn(
|
|
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
|
|
settings.haptics
|
|
? "grad-ice border-transparent"
|
|
: "border-[var(--hairline)] bg-[var(--surface-sunken)]",
|
|
)}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
|
|
settings.haptics ? "left-6" : "left-0.5",
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* chat grounding: may chat read the notes text? */}
|
|
<ToggleRow
|
|
label={t("settings.chatNotes")}
|
|
note={t("settings.chatNotesNote")}
|
|
checked={settings.chatNotes}
|
|
onChange={() => {
|
|
saveSettings({ chatNotes: !settings.chatNotes });
|
|
haptic("toggle");
|
|
}}
|
|
/>
|
|
</section>
|
|
|
|
{/* ------------------------------------------- theme */}
|
|
<ThemeSection />
|
|
|
|
{/* --------------------------------------- motion */}
|
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<ToggleRow
|
|
label={t("settings.reduceMotion")}
|
|
note={t("settings.reduceMotionNote")}
|
|
checked={reducedMotion}
|
|
onChange={() => {
|
|
setReducedMotion(!reducedMotion);
|
|
haptic("toggle");
|
|
}}
|
|
/>
|
|
</section>
|
|
|
|
{/* --------------------------------------- providers */}
|
|
<ProvidersSection />
|
|
|
|
{/* ----------------------------------------- learning */}
|
|
<LearningSection level={3} />
|
|
|
|
{/* ------------------------------------------- data & sync */}
|
|
<DataSection />
|
|
<SyncSection />
|
|
|
|
{/* ----------------------------------------------- install */}
|
|
{installAvailable ? (
|
|
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onClose();
|
|
openInstallSheet();
|
|
}}
|
|
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
|
|
>
|
|
<DownloadIcon className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("settings.installApp")}
|
|
</span>
|
|
<span className="block text-[12px] text-[var(--text-subtle)]">
|
|
{t("settings.addToHome")}
|
|
</span>
|
|
</span>
|
|
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
|
</button>
|
|
</section>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="mb-4 rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ----------------------------------------------- details */}
|
|
<details className="group mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<summary className="touch-target flex cursor-pointer list-none items-center justify-between gap-2 text-sm font-semibold text-frost-950 dark:text-frost-50 [&::-webkit-details-marker]:hidden">
|
|
{t("settings.details")}
|
|
<ChevronDownIcon className="size-4 text-[var(--text-subtle)] transition-transform group-open:rotate-180" />
|
|
</summary>
|
|
<div className="pt-2">
|
|
{loading ? (
|
|
<p className="flex items-center gap-2 pb-2 text-[12px] text-[var(--text-muted)]">
|
|
<Spinner className="size-3.5" /> {t("settings.checking")}
|
|
</p>
|
|
) : checkedAt ? (
|
|
<p className="pb-2 text-[11px] font-medium text-[var(--text-subtle)]">
|
|
{freshnessLabel(checkedAt, locale, t)}
|
|
</p>
|
|
) : null}
|
|
<dl>
|
|
<DetailRow
|
|
label={t("health.auth")}
|
|
value={
|
|
health
|
|
? health.authMode === "user-key"
|
|
? t("health.authUserKey")
|
|
: health.authMode === "server"
|
|
? t("health.authServer")
|
|
: t("health.authLocal")
|
|
: "—"
|
|
}
|
|
/>
|
|
<DetailRow label={t("health.endpoint")} value={health?.baseUrl ?? "—"} mono />
|
|
<DetailRow
|
|
label={t("health.mode")}
|
|
value={
|
|
health
|
|
? health.mode === "direct-cloud"
|
|
? t("health.modeCloud")
|
|
: t("health.modeLocal")
|
|
: "—"
|
|
}
|
|
/>
|
|
<DetailRow
|
|
label={t("health.key")}
|
|
value={
|
|
health
|
|
? health.needsKey
|
|
? t("health.keyMissing")
|
|
: health.keyAccepted === true
|
|
? t("health.keyAccepted")
|
|
: health.keyAccepted === false
|
|
? t("health.keyRejected")
|
|
: health.authMode === "local"
|
|
? t("health.keyNotNeeded")
|
|
: t("health.keyUnknown")
|
|
: "—"
|
|
}
|
|
/>
|
|
<DetailRow
|
|
label={t("health.visionModel")}
|
|
value={health?.vision.resolved ?? "—"}
|
|
mono
|
|
badge={modelBadge(health?.vision)}
|
|
/>
|
|
<DetailRow
|
|
label={t("health.textModel")}
|
|
value={health?.text.resolved ?? "—"}
|
|
mono
|
|
badge={modelBadge(health?.text)}
|
|
/>
|
|
{version ? (
|
|
<DetailRow
|
|
label={t("settings.version")}
|
|
value={[
|
|
version.version ? `v${version.version}` : "",
|
|
version.short,
|
|
version.builtAt ? new Date(version.builtAt).toLocaleString() : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
mono
|
|
/>
|
|
) : null}
|
|
</dl>
|
|
{/* Legal: the same three documents the cookie banner
|
|
links to, always reachable from Settings. */}
|
|
<div className="pt-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
|
{t("settings.legal")}
|
|
</p>
|
|
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] font-semibold">
|
|
<Link href="/privacy" className="text-[var(--text)] underline decoration-dotted">
|
|
{t("legal.privacy")}
|
|
</Link>
|
|
<Link href="/terms" className="text-[var(--text)] underline decoration-dotted">
|
|
{t("legal.terms")}
|
|
</Link>
|
|
<Link href="/cookies" className="text-[var(--text)] underline decoration-dotted">
|
|
{t("legal.cookies")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
{health?.mode === "direct-cloud" && health.needsKey ? (
|
|
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("health.createKey.before")}{" "}
|
|
<a
|
|
className="font-semibold underline underline-offset-2"
|
|
href="https://ollama.com/settings/keys"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
ollama.com/settings/keys
|
|
</a>{" "}
|
|
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
|
|
<code>OLLAMA_AUTH=local</code>.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</details>
|
|
|
|
{/* ------------------------------- remove key (very bottom) */}
|
|
{apiKey ? (
|
|
confirmRemove ? (
|
|
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
|
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
|
{t("settings.removeKeyConfirm")}
|
|
</p>
|
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
|
{t("settings.removeKeyYes")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="touch-target"
|
|
onClick={() => setConfirmRemove(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmRemove(true)}
|
|
className="touch-target mx-auto block px-3 text-center text-[12.5px] font-medium text-rose-700 underline-offset-2 hover:underline dark:text-rose-300"
|
|
>
|
|
{t("settings.removeKey")}
|
|
</button>
|
|
)
|
|
) : null}
|
|
|
|
<div className="h-9" aria-hidden />
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* --------------------------------------------- language */}
|
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("language.label")}
|
|
</h3>
|
|
<div className="segmented" role="group" aria-label={t("language.label")}>
|
|
{LOCALES.map((entry) => (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
aria-pressed={locale === entry.id}
|
|
onClick={() => setLocale(entry.id)}
|
|
>
|
|
{entry.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("language.note")}
|
|
</p>
|
|
</section>
|
|
|
|
{/* ------------------------------------------------ API key */}
|
|
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("settings.apiKey")}
|
|
</h3>
|
|
{apiKey ? (
|
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
|
|
{t("settings.savedInBrowser")}
|
|
</span>
|
|
) : needsKeyMode ? (
|
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
|
{t("settings.required")}
|
|
</span>
|
|
) : (
|
|
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-[var(--text-muted)]">
|
|
{t("settings.notNeeded")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phones: the field gets its own row, the two actions stack
|
|
underneath at full touch-target size. */}
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<label className="sr-only" htmlFor="ollama-api-key">
|
|
{t("settings.apiKey")}
|
|
</label>
|
|
<input
|
|
id="ollama-api-key"
|
|
ref={keyInputRef}
|
|
type={reveal ? "text" : "password"}
|
|
value={keyDraft}
|
|
onChange={(event) => setKeyDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation();
|
|
if (event.key === "Enter") save();
|
|
}}
|
|
placeholder={t("settings.pasteKey")}
|
|
/* Not a login: keep the browser's password UI away
|
|
(see the gate for the same treatment). */
|
|
autoComplete="new-password"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
inputMode="text"
|
|
data-form-type="other"
|
|
data-lpignore="true"
|
|
className="w-full min-w-0 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-[var(--text-subtle)] focus:border-accent-500 dark:text-frost-50 sm:min-w-40 sm:flex-1"
|
|
/>
|
|
<div className="flex items-center gap-2 sm:contents">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="touch-target flex-1 sm:flex-none"
|
|
onClick={() => setReveal((value) => !value)}
|
|
>
|
|
{reveal ? t("settings.hide") : t("settings.show")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="touch-target flex-1 sm:flex-none"
|
|
onClick={save}
|
|
disabled={keyDraft.trim() === apiKey}
|
|
>
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("settings.keyNote")}
|
|
</p>
|
|
|
|
<details className="group">
|
|
<summary className="touch-target -mt-1 flex cursor-pointer list-none items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
|
|
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
|
{t("settings.localTipTitle")}
|
|
</summary>
|
|
<p className="pt-0.5 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("settings.localTipSet")} <code>OLLAMA_AUTH=local</code>{" "}
|
|
{t("settings.keyNoteAfter")} <code>.env.local</code> {t("settings.keyNoteEnd")}{" "}
|
|
<code>ollama signin</code>
|
|
{t("settings.keyNoteEnd2")}
|
|
</p>
|
|
</details>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="touch-target"
|
|
onClick={() => void refresh()}
|
|
loading={loading}
|
|
>
|
|
{t("settings.testConnection")}
|
|
</Button>
|
|
{saved ? (
|
|
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
|
|
{t("settings.savedTesting")}
|
|
</span>
|
|
) : null}
|
|
{apiKey && !confirmRemove ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="touch-target -ml-2.5 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
|
|
onClick={() => setConfirmRemove(true)}
|
|
>
|
|
{t("settings.removeKey")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{apiKey && confirmRemove ? (
|
|
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
|
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
|
{t("settings.removeKeyConfirm")}
|
|
</p>
|
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
|
{t("settings.removeKeyYes")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="touch-target"
|
|
onClick={() => setConfirmRemove(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{/* ------------------------------------------- chat grounding */}
|
|
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<ToggleRow
|
|
label={t("settings.chatNotes")}
|
|
note={t("settings.chatNotesNote")}
|
|
checked={settings.chatNotes}
|
|
onChange={() => saveSettings({ chatNotes: !settings.chatNotes })}
|
|
/>
|
|
</section>
|
|
|
|
{/* ------------------------------------------- theme */}
|
|
<ThemeSection />
|
|
|
|
{/* --------------------------------------- motion */}
|
|
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<ToggleRow
|
|
label={t("settings.reduceMotion")}
|
|
note={t("settings.reduceMotionNote")}
|
|
checked={reducedMotion}
|
|
onChange={() => {
|
|
setReducedMotion(!reducedMotion);
|
|
haptic("toggle");
|
|
}}
|
|
/>
|
|
</section>
|
|
|
|
{/* --------------------------------------- providers */}
|
|
<ProvidersSection />
|
|
|
|
{/* ----------------------------------------- learning */}
|
|
<LearningSection level={3} />
|
|
|
|
{/* ------------------------------------------- data & sync */}
|
|
<DataSection />
|
|
<SyncSection />
|
|
|
|
{/* ------------------------------------------------ install */}
|
|
{installAvailable ? (
|
|
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onClose();
|
|
openInstallSheet();
|
|
}}
|
|
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
|
|
>
|
|
<DownloadIcon className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
|
|
{t("settings.installApp")}
|
|
</span>
|
|
<span className="block text-[12px] text-[var(--text-subtle)]">
|
|
{t("settings.addToHome")}
|
|
</span>
|
|
</span>
|
|
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
|
</button>
|
|
</section>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center gap-3 py-2 text-sm text-[var(--text-muted)]">
|
|
<Spinner className="size-4" /> {t("settings.checking")}
|
|
</div>
|
|
) : checkedAt ? (
|
|
<p className="py-1 text-[11px] font-medium text-[var(--text-subtle)]">
|
|
{freshnessLabel(checkedAt, locale, t)}
|
|
</p>
|
|
) : null}
|
|
|
|
{health ? <HealthPanel health={health} /> : null}
|
|
{version ? (
|
|
<p className="mt-2 text-[11px] text-[var(--text-subtle)]">
|
|
{t("settings.version")}:{" "}
|
|
<span className="font-mono">
|
|
{version.version ? `v${version.version} · ` : ""}
|
|
{version.short}
|
|
</span>
|
|
{version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""}
|
|
</p>
|
|
) : null}
|
|
|
|
{/* Legal: the same three documents the cookie banner links to. */}
|
|
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] font-semibold">
|
|
<Link href="/privacy" className="text-[var(--text-muted)] underline decoration-dotted">
|
|
{t("legal.privacy")}
|
|
</Link>
|
|
<Link href="/terms" className="text-[var(--text-muted)] underline decoration-dotted">
|
|
{t("legal.terms")}
|
|
</Link>
|
|
<Link href="/cookies" className="text-[var(--text-muted)] underline decoration-dotted">
|
|
{t("legal.cookies")}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* keeps real content out of the 36px fade strip at the bottom */}
|
|
<div className="h-9" aria-hidden />
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* scroll cue: a bare chevron inside the fade strip, so it never
|
|
covers text (the .sheet-scroll mask fades what is underneath) */}
|
|
<div
|
|
aria-hidden
|
|
className={cn(
|
|
"pointer-events-none absolute inset-x-0 bottom-0 flex h-9 items-end justify-center pb-1.5 transition-opacity duration-200",
|
|
canScroll ? "opacity-100" : "opacity-0",
|
|
)}
|
|
>
|
|
<ChevronDownIcon className="size-4 text-[var(--text-subtle)]" />
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
|
|
const { t } = useI18n();
|
|
if (!health) return null;
|
|
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
|
|
const toneClasses: Record<string, string> = {
|
|
emerald: "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200",
|
|
amber: "bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
|
|
rose: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200",
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className={cn("flex items-start gap-2 rounded-2xl p-3 text-sm font-medium", toneClasses[tone])}>
|
|
<span aria-hidden className="mt-0.5 shrink-0">
|
|
{tone === "emerald" ? (
|
|
<CheckCircleIcon className="size-5" />
|
|
) : tone === "amber" ? (
|
|
<AlertTriangleIcon className="size-5" />
|
|
) : (
|
|
<XCircleIcon className="size-5" />
|
|
)}
|
|
</span>
|
|
<span>
|
|
{health.ok
|
|
? t("health.ok")
|
|
: health.needsKey
|
|
? t("health.needsKey")
|
|
: health.keyAccepted === false
|
|
? t("health.rejected")
|
|
: health.reachable
|
|
? t("health.modelMissing")
|
|
: t("health.unreachable")}
|
|
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
|
|
{health.kind === "usage_limit" || health.kind === "throttled" ? (
|
|
<span className="mt-2 flex flex-wrap gap-2">
|
|
<ModelErrorActions kind={health.kind} />
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
|
<Info
|
|
label={t("health.auth")}
|
|
value={
|
|
health.authMode === "user-key"
|
|
? t("health.authUserKey")
|
|
: health.authMode === "server"
|
|
? t("health.authServer")
|
|
: t("health.authLocal")
|
|
}
|
|
/>
|
|
<Info label={t("health.endpoint")} value={health.baseUrl} mono />
|
|
<Info
|
|
label={t("health.mode")}
|
|
value={health.mode === "direct-cloud" ? t("health.modeCloud") : t("health.modeLocal")}
|
|
/>
|
|
<Info
|
|
label={t("health.key")}
|
|
value={
|
|
health.needsKey
|
|
? t("health.keyMissing")
|
|
: health.keyAccepted === true
|
|
? t("health.keyAccepted")
|
|
: health.keyAccepted === false
|
|
? t("health.keyRejected")
|
|
: health.authMode === "local"
|
|
? t("health.keyNotNeeded")
|
|
: t("health.keyUnknown")
|
|
}
|
|
/>
|
|
</dl>
|
|
|
|
<div className="space-y-2">
|
|
<ModelRow title={t("health.visionModel")} model={health.vision} />
|
|
<ModelRow title={t("health.textModel")} model={health.text} />
|
|
</div>
|
|
|
|
{health.mode === "direct-cloud" && health.needsKey ? (
|
|
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-[var(--text-muted)]">
|
|
{t("health.createKey.before")}{" "}
|
|
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
|
|
ollama.com/settings/keys
|
|
</a>{" "}
|
|
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
|
|
<code>OLLAMA_AUTH=local</code>.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailRow({
|
|
label,
|
|
value,
|
|
mono,
|
|
badge,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
mono?: boolean;
|
|
badge?: { text: string; className: string };
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3 border-b border-[var(--hairline-soft)] py-1.5 last:border-0">
|
|
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{label}</dt>
|
|
<dd className={cn("flex min-w-0 items-center justify-end gap-1.5 text-right font-medium text-[var(--text)]", mono && "font-mono text-[11px]")}>
|
|
<span className="truncate">{value}</span>
|
|
{badge ? (
|
|
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold", badge.className)}>
|
|
{badge.text}
|
|
</span>
|
|
) : null}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<dt className="text-xs uppercase tracking-wide text-[var(--text-subtle)]">{label}</dt>
|
|
<dd className={cn("mt-0.5 break-words font-semibold", mono && "font-mono text-xs")}>{value}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModelRow({
|
|
title,
|
|
model,
|
|
}: {
|
|
title: string;
|
|
model: { configured: string; resolved: string; available: boolean | null; note?: string };
|
|
}) {
|
|
const { t } = useI18n();
|
|
const badge =
|
|
model.available === true
|
|
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
|
|
: model.available === false
|
|
? { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
|
|
: { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" };
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-subtle)]">{title}</p>
|
|
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", badge.className)}>
|
|
{badge.text}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
|
|
{model.configured !== model.resolved ? (
|
|
<p className="text-xs text-[var(--text-subtle)]">
|
|
{t("health.configuredAs", { model: model.configured })}
|
|
</p>
|
|
) : null}
|
|
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Boolean toggle row used by the chat-grounding setting. */
|
|
function ToggleRow({
|
|
label,
|
|
note,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
note: string;
|
|
checked: boolean;
|
|
onChange: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
|
|
<span className="min-w-0">
|
|
<span className="block text-[13px] font-semibold text-[var(--text)]">{label}</span>
|
|
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={label}
|
|
onClick={onChange}
|
|
className={cn(
|
|
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
|
|
checked ? "grad-ice border-transparent" : "border-[var(--hairline)] bg-[var(--surface-sunken)]",
|
|
)}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
|
|
checked ? "left-6" : "left-0.5",
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|