refactor(webui): improve visual consistency (#5249)

This commit is contained in:
chengyongru
2026-08-05 13:24:45 +08:00
committed by GitHub
parent a54d5d14cb
commit 9098ffd38f
33 changed files with 578 additions and 646 deletions
+35 -423
View File
@@ -109,6 +109,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Textarea } from "@/components/ui/textarea";
import { isLoopbackHost } from "@/lib/network";
import {
@@ -144,7 +145,6 @@ import {
updateModelConfiguration,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateTranscriptionSettings,
updateWebSearchSettings,
} from "@/lib/api";
@@ -349,30 +349,6 @@ const SETTINGS_SEARCH_INPUT_CLASS = cn(
"focus-visible:ring-0 focus-visible:ring-offset-0",
);
const FALLBACK_TIMEZONES = [
"UTC",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Tokyo",
"Asia/Seoul",
"Asia/Singapore",
"Asia/Taipei",
"Asia/Dubai",
"Asia/Kolkata",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Amsterdam",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Toronto",
"America/Sao_Paulo",
"Australia/Sydney",
"Pacific/Auckland",
];
interface CustomMcpForm {
name: string;
transport: CustomMcpTransport;
@@ -426,7 +402,6 @@ interface SettingsViewProps {
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void;
onRestart?: () => void;
@@ -631,7 +606,6 @@ export function SettingsView({
onModelNameChange,
onSettingsChange,
skills = [],
onWorkspaceSettingsChange,
onSectionChange,
onLogout,
onRestart,
@@ -841,7 +815,9 @@ export function SettingsView({
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
@@ -1082,11 +1058,6 @@ export function SettingsView({
);
}, [form, settings]);
const runtimeDirty = useMemo(() => {
if (!settings) return false;
return form.timezone !== settings.agent.timezone;
}, [form, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
return (
@@ -1399,27 +1370,6 @@ export function SettingsView({
}
};
const saveRuntimeSettings = async () => {
if (!settings || !runtimeDirty || saving) return;
setSaving(true);
try {
const payload = await updateSettings(token, {
timezone: form.timezone,
});
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await onWorkspaceSettingsChange?.();
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const saveImageGenerationSettings = async () => {
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
@@ -2276,11 +2226,7 @@ export function SettingsView({
return (
<RuntimeSettings
form={form}
setForm={setForm}
settings={settings}
dirty={runtimeDirty}
saving={saving}
onSave={saveRuntimeSettings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
@@ -2902,10 +2848,7 @@ function AppearanceSettings({
<section>
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={t("settings.rows.theme")}
description={t("settings.help.theme")}
>
<SettingsRow title={t("settings.rows.theme")}>
<button
type="button"
onClick={onToggleTheme}
@@ -2932,10 +2875,7 @@ function AppearanceSettings({
</button>
</SettingsRow>
<SettingsRow
title={t("settings.rows.language")}
description={t("settings.help.language")}
>
<SettingsRow title={t("settings.rows.language")}>
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
@@ -2944,10 +2884,7 @@ function AppearanceSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.density", "Density")}
description={tx("settings.help.density", "Stored only in this browser.")}
>
<SettingsRow title={tx("settings.rows.density", "Density")}>
<SegmentedControl
value={localPrefs.density}
options={[
@@ -2959,10 +2896,7 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.activityMode", "Activity detail")}
description={tx("settings.help.activityMode", "Choose how much agent activity chrome to show by default.")}
>
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
<SegmentedControl
value={localPrefs.activityMode}
options={[
@@ -2974,10 +2908,7 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.fileEditDisplay", "File edit display")}
description={tx("settings.help.fileEditDisplay", "Choose whether file edit activity opens as line counts or a diff.")}
>
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
<SegmentedControl
value={localPrefs.fileEditDisplayMode}
options={[
@@ -2993,10 +2924,7 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.codeWrap", "Code wrapping")}
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
>
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
<ToggleButton
checked={localPrefs.codeWrap}
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
@@ -3004,10 +2932,7 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
@@ -4854,10 +4779,7 @@ function ImageGenerationSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.imageGeneration", "Image generation")}
description={tx("settings.help.imageGeneration", "Expose generate_image in chats when a configured image provider is available.")}
>
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
@@ -4865,10 +4787,7 @@ function ImageGenerationSettings({
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.imageProvider", "Image provider")}
description={tx("settings.help.imageProvider", "Choose the registry provider used by generate_image.")}
>
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
<ProviderPicker
providers={settings.image_generation.providers}
value={form.provider}
@@ -4905,10 +4824,7 @@ function ImageGenerationSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.imageModel", "Image model")}
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
>
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
<ModelIdPicker
token={token}
settings={settings}
@@ -4928,10 +4844,7 @@ function ImageGenerationSettings({
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultAspectRatio", "Default aspect")}
description={tx("settings.help.defaultAspectRatio", "Used when the prompt does not choose an aspect ratio.")}
>
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
<ProviderPicker
providers={aspectOptions}
value={form.defaultAspectRatio}
@@ -4941,10 +4854,7 @@ function ImageGenerationSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultImageSize", "Default size")}
description={tx("settings.help.defaultImageSize", "Size hint sent to providers that support it.")}
>
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
<ProviderPicker
providers={sizeOptions}
value={form.defaultImageSize}
@@ -4954,10 +4864,7 @@ function ImageGenerationSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}
description={tx("settings.help.maxImagesPerTurn", "Upper bound for one generate_image request.")}
>
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
<NumberInput
value={form.maxImagesPerTurn}
min={1}
@@ -5038,10 +4945,7 @@ function TranscriptionSettings({
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionProvider", "Provider")}
description={tx("settings.help.transcriptionProvider", "Uses the matching provider credentials from Providers.")}
>
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
<ProviderPicker
providers={transcription.providers}
value={form.provider}
@@ -5208,10 +5112,7 @@ function WebSettings({
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
) : null}
<SettingsGroup>
<SettingsRow
title={t("settings.byok.webSearch.provider")}
description={t("settings.byok.webSearch.providerHelp")}
>
<SettingsRow title={t("settings.byok.webSearch.provider")}>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
@@ -5222,10 +5123,7 @@ function WebSettings({
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow
title={t("settings.byok.webSearch.credentials")}
description={t("settings.byok.webSearch.noCredentialHelp")}
>
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
</SettingsRow>
) : null}
@@ -5310,10 +5208,7 @@ function WebSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.maxResults", "Max results")}
description={tx("settings.help.maxResults", "Results returned by each web_search call.")}
>
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
<NumberInput
value={form.maxResults ?? settings.web_search.max_results}
min={1}
@@ -5321,10 +5216,7 @@ function WebSettings({
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.timeout", "Timeout")}
description={tx("settings.help.timeout", "Seconds before a search provider request times out.")}
>
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
<NumberInput
value={form.timeout ?? settings.web_search.timeout}
min={1}
@@ -6287,7 +6179,7 @@ function NanobotFeatureInstallDialog({
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl sm:rounded-[28px]"
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
>
<DialogHeader className="items-center space-y-0 text-center">
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
@@ -7074,23 +6966,6 @@ function ChannelsSettings({
>
{!showingCompactDetail ? (
<section className="shrink-0 space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx(
"settings.channels.description",
"Connect chat apps, email, and WebUI to nanobot.",
)}
</p>
<div className="flex flex-wrap gap-2 text-[12px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted/70 px-2.5 py-1">
{t("settings.channels.caption", {
enabled: enabledCount,
total: allChannels.length,
defaultValue: "{{enabled}} enabled · {{total}} channels",
})}
</span>
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
@@ -7315,24 +7190,9 @@ function AppsCatalogSettings({
mcpError ||
(!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const readyCount = (cliApps?.installed_count ?? 0) + (mcpPresets?.installed_count ?? 0);
const caption = t("settings.apps.enabledSummary", {
count: readyCount,
defaultValue: "{{count}} ready",
});
return (
<div className="space-y-7">
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx(
"settings.apps.description",
"Add tools to nanobot, then @ them in chat.",
)}
</p>
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
@@ -8299,11 +8159,7 @@ function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos:
function RuntimeSettings({
form,
setForm,
settings,
dirty,
saving,
onSave,
onRestart,
isRestarting,
requiresRestartPending,
@@ -8319,11 +8175,7 @@ function RuntimeSettings({
onInstallCapability,
}: {
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
dirty: boolean;
saving: boolean;
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
@@ -8419,42 +8271,6 @@ function RuntimeSettings({
};
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.regional", "Regional")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.timezone", "Timezone")}
description={tx(
"settings.help.timezone",
"Used for schedules and time-aware replies.",
)}
>
<TimezonePicker
value={form.timezone}
onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
dirtyMessage={
isNativeHost
? tx("settings.status.hostRestartAfterSaving", "Save changes and nanobot will restart its engine.")
: tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
}
pendingMessage={
isNativeHost
? tx("settings.status.hostRestartPending", "Saved. Restarting engine when ready.")
: tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
{isNativeHost ? (
<section>
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
@@ -8464,9 +8280,7 @@ function RuntimeSettings({
<SettingsRow
title={tx("settings.rows.logs", "Logs")}
description={
hostActionMessage?.target === "logs"
? hostActionMessage.message
: tx("settings.help.logs", "Open the native engine log folder.")
hostActionMessage?.target === "logs" ? hostActionMessage.message : undefined
}
>
<Button
@@ -8495,9 +8309,7 @@ function RuntimeSettings({
description={
hostActionMessage?.target === "diagnostics"
? hostActionMessage.message
: diagnosticsPath
? diagnosticsPath
: tx("settings.help.diagnostics", "Export a small runtime report for support.")
: diagnosticsPath || undefined
}
>
<Button
@@ -8542,7 +8354,7 @@ function RuntimeSettings({
? apiServiceError
: apiDefaults.running
? apiDefaults.endpoint
: tx("settings.api.description", "Connect SDKs and agents through a local /v1 endpoint.")
: undefined
}
>
<div className="flex items-center justify-end gap-2">
@@ -8608,10 +8420,7 @@ function RuntimeSettings({
onChange={(value) => setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.api.port", "Port")}
description={tx("settings.api.portHelp", "The API uses this local port.")}
>
<SettingsRow title={tx("settings.api.port", "Port")}>
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
</SettingsRow>
{apiNetworkAccess ? (
@@ -8647,11 +8456,6 @@ function RuntimeSettings({
</>
) : null}
</SettingsGroup>
{!apiDefaults.installed && !apiDefaults.running ? (
<p className="mt-2 text-[11.5px] text-muted-foreground">
{tx("settings.api.autoInstall", "API support will be installed automatically when you start it.")}
</p>
) : null}
</section>
<section>
@@ -8661,7 +8465,7 @@ function RuntimeSettings({
title="Langfuse"
description={
settings.observability?.configured
? tx("settings.observability.configured", "Tracing credentials are available to nanobot.")
? undefined
: tx(
"settings.observability.environment",
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
@@ -8708,10 +8512,15 @@ function RuntimeSettings({
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? (
<ReadOnlyRow title={tx("settings.rows.timezone", "Timezone")} value={form.timezone} />
{onRestart ? (
<SettingsRow
title={t("settings.rows.restart")}
description={t("app.system.restartHint")}
description={
requiresRestartPending
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: undefined
}
>
<Button
size="sm"
@@ -8829,117 +8638,6 @@ function AdvancedSettings({
);
}
function TimezonePicker({
value,
onChange,
}: {
value: string;
onChange: (timezone: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const options = useMemo(() => timezoneOptions(value), [value]);
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
const optionValues = useMemo(
() => filteredOptions.map((option) => option.name),
[filteredOptions],
);
const chooseTimezone = (timezone: string) => {
onChange(timezone);
setOpen(false);
};
const navigation = useComboboxNavigation({
open,
values: optionValues,
selectedValue: value,
onSelect: chooseTimezone,
onClose: () => setOpen(false),
});
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-8 w-[220px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[340px] max-w-[calc(100vw-2rem)]"
>
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
<div className="flex h-9 items-center gap-2 rounded-full border border-input bg-background px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
{...navigation.inputProps}
placeholder={tx("settings.timezone.search", "Search timezone")}
aria-label={tx("settings.timezone.search", "Search timezone")}
className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
/>
</div>
</div>
{filteredOptions.length ? (
<div
{...navigation.listProps}
aria-label={tx("settings.timezone.select", "Select timezone")}
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list"
>
{filteredOptions.map((option) => {
const selected = option.name === value;
return (
<ComboboxOption
key={option.name}
{...navigation.getOptionProps(option.name)}
className={cn(
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
selected && "text-foreground",
)}
>
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-[11.5px] font-medium text-muted-foreground/80">
{option.offset}
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</span>
</ComboboxOption>
);
})}
</div>
) : (
<div
role="status"
className="px-3 py-5 text-center text-[12px] text-muted-foreground"
data-testid="timezone-picker-list"
>
{tx("settings.timezone.empty", "No matching timezones.")}
</div>
)}
</PopoverContent>
</Popover>
);
}
function ProviderPicker({
providers,
value,
@@ -9459,62 +9157,6 @@ function providerVisibilityRank(provider: SettingsPayload["providers"][number]):
return 200;
}
interface TimezoneOption {
name: string;
offset: string;
searchText: string;
}
function timezoneOptions(current: string): TimezoneOption[] {
return timezonesWithCurrent(current).map((name) => {
const offset = timezoneOffset(name);
return {
name,
offset,
searchText: `${name} ${name.replace(/_/g, " ")} ${offset}`.toLowerCase(),
};
});
}
function timezonesWithCurrent(current: string): string[] {
const intl = Intl as typeof Intl & {
supportedValuesOf?: (key: "timeZone") => string[];
};
let values: string[];
try {
values = intl.supportedValuesOf?.("timeZone") ?? [];
} catch {
values = [];
}
const deduped = new Set([...FALLBACK_TIMEZONES, ...values, current].filter(Boolean));
return Array.from(deduped).sort((left, right) => {
if (left === "UTC") return -1;
if (right === "UTC") return 1;
return left.localeCompare(right);
});
}
function filterTimezoneOptions(options: TimezoneOption[], query: string): TimezoneOption[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return options;
return options.filter((option) => option.searchText.includes(normalized));
}
function timezoneOffset(timezone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
timeZoneName: "shortOffset",
hour: "2-digit",
minute: "2-digit",
}).formatToParts(new Date());
const value = parts.find((part) => part.type === "timeZoneName")?.value;
return value ? value.replace(/^GMT$/, "UTC").replace(/^GMT/, "UTC") : "UTC";
} catch {
return "Custom timezone";
}
}
function optionRowsWithCurrent(
options: Array<{ name: string; label: string }>,
value: string,
@@ -9931,36 +9573,6 @@ function StatusPill({
);
}
function SegmentedControl({
value,
options,
onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}) {
return (
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
"rounded-full px-3 py-1 transition-colors",
value === option.value &&
"bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
>
{option.label}
</button>
))}
</div>
);
}
function NumberInput({
value,
min,
@@ -26,6 +26,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api";
@@ -36,9 +37,6 @@ import { useClient } from "@/providers/ClientProvider";
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
const { t } = useTranslation();
const availableCount = skills.filter(
(skill) => skill.enabled !== false && skill.available,
).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
const [view, setView] = useState<"installed" | "discover">("installed");
const [installingSkill, setInstallingSkill] = useState("");
@@ -80,51 +78,28 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue:
"Review installed skills or discover new capabilities from the skills.sh catalog.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<div
className="inline-flex rounded-[12px] bg-muted/65 p-1"
role="tablist"
aria-label={t("settings.skills.views", { defaultValue: "Skills views" })}
>
{(["installed", "discover"] as const).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={view === item}
onClick={() => setView(item)}
className={cn(
"inline-flex items-center rounded-[9px] px-3.5 py-1.5 text-[13px] font-medium transition-colors",
view === item
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{item === "installed"
? t("settings.skills.installedTab", { defaultValue: "Installed" })
: t("settings.skills.discoverTab", { defaultValue: "Discover" })}
</button>
))}
</div>
<SegmentedControl
value={view}
mode="tabs"
ariaLabel={t("settings.skills.views", { defaultValue: "Skills views" })}
className="w-fit text-[13px]"
itemClassName="px-3.5"
options={[
{
value: "installed",
label: t("settings.skills.installedTab", { defaultValue: "Installed" }),
},
{
value: "discover",
label: t("settings.skills.discoverTab", { defaultValue: "Discover" }),
},
]}
onChange={setView}
/>
{view === "installed" ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col gap-3 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-[320px]">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
@@ -142,13 +117,11 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
/>
</div>
<div
className={cn(
"flex max-w-full items-center gap-1 overflow-x-auto rounded-[10px] bg-muted/65 p-1",
"scrollbar-thin scrollbar-track-transparent sm:w-auto",
)}
>
{([
<SegmentedControl
value={installedFilter}
className="sm:w-auto"
itemClassName="px-2.5 text-[11px]"
options={([
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
[
"enabled",
@@ -160,28 +133,22 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
disabledCount,
],
] as const).map(([filter, label, count]) => (
<button
key={filter}
type="button"
onClick={() => setInstalledFilter(filter)}
className={cn(
"shrink-0 whitespace-nowrap rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
installedFilter === filter
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
</button>
))}
</div>
] as const).map(([value, label, count]) => ({
value,
label: (
<>
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
</>
),
}))}
onChange={setInstalledFilter}
/>
</div>
{groupedSkills.length ? (
<div className="pb-2">
<div className="space-y-5 px-3 pb-3 pt-2 sm:px-4">
{groupedSkills.map((group) => (
<section key={group.key}>
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
<section key={group.key} className="space-y-1">
<div className="flex items-center gap-2 px-2 py-1.5">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{group.label}
</h2>
@@ -189,7 +156,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
{group.skills.length}
</span>
</div>
<div className="divide-y divide-border/40 px-3 sm:px-4">
<div className="space-y-1">
{group.skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
@@ -254,8 +221,8 @@ function SkillCatalogRow({
onClick={() => onSelect(skill)}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left",
"transition-[background-color,box-shadow] duration-150",
"hover:bg-muted/70 hover:shadow-[inset_0_0_0_1px_hsl(var(--border)/0.35)]",
"transition-colors duration-150",
"hover:bg-muted/70",
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!enabled && "opacity-60",
)}
@@ -475,17 +442,9 @@ function SkillDetailSheet({
) : (
<div className="mt-6 space-y-5">
<div className="flex min-h-16 items-start justify-between gap-3 border-y border-border/45 px-1 py-3.5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.enabledDescription", {
defaultValue:
"Allow the agent to load this skill when its requirements are ready.",
})}
</p>
</div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
</p>
<button
type="button"
role="switch"
@@ -21,6 +21,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
fetchMarketplaceSkillTrends,
fetchTrendingMarketplaceSkills,
@@ -232,19 +233,12 @@ export function SkillsMarketplace({
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending by marketplace",
})}
</h2>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingDescription", {
defaultValue: "Each marketplace keeps its own ranking and install metrics.",
})}
</p>
</div>
<div className="flex flex-col items-start gap-2 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending by marketplace",
})}
</h2>
{provider !== "all" ? (
<a
href={providerUrl(provider)}
@@ -359,32 +353,27 @@ function ProviderFilter({
const { t } = useTranslation();
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
return (
<div
className="flex w-fit items-center gap-0.5 rounded-full bg-settings-surface p-1"
role="tablist"
aria-label={t("settings.skills.marketplaceProviderFilter", {
<SegmentedControl
value={value}
mode="tabs"
ariaLabel={t("settings.skills.marketplaceProviderFilter", {
defaultValue: "Skill source",
})}
>
{providers.map((provider) => (
<button
key={provider}
type="button"
role="tab"
aria-selected={value === provider}
onClick={() => onChange(provider)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium text-muted-foreground transition-colors",
value === provider && "bg-background text-foreground shadow-sm",
)}
>
{provider !== "all" ? <ProviderDot provider={provider} /> : null}
{provider === "all"
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
: providerLabel(provider)}
</button>
))}
</div>
className="w-fit bg-settings-surface"
itemClassName="inline-flex h-7 items-center gap-1.5"
options={providers.map((provider) => ({
value: provider,
label: (
<>
{provider !== "all" ? <ProviderDot provider={provider} /> : null}
{provider === "all"
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
: providerLabel(provider)}
</>
),
}))}
onChange={onChange}
/>
);
}
@@ -420,13 +409,13 @@ function MarketplaceSkillGroups({
);
}
return (
<div>
<div className="space-y-5 pb-3 pt-2">
{providers.map((provider) => {
const providerSkills = skills.filter((skill) => skill.provider === provider);
if (!providerSkills.length) return null;
return (
<section key={provider} className="border-t border-border/45 first:border-t-0">
<div className="flex items-center justify-between px-5 pb-1 pt-3.5">
<section key={provider} className="space-y-1">
<div className="flex items-center justify-between px-5 py-1.5">
<ProviderMark provider={provider} />
<a
href={providerUrl(provider)}
@@ -469,7 +458,7 @@ function MarketplaceSkillList({
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
return (
<div className="divide-y divide-border/45 px-3 sm:px-4">
<div className="space-y-1 px-3 pb-3 sm:px-4">
{skills.map((skill) => (
<MarketplaceSkillRow
key={skill.id}
@@ -679,7 +668,7 @@ function TrendSparkline({ values }: { values?: number[] }) {
function TrendingSkeleton() {
return (
<div className="divide-y divide-border/45 px-5" aria-hidden>
<div className="space-y-1 px-5 pb-3" aria-hidden>
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="flex items-center gap-3 py-4">
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
@@ -232,7 +232,7 @@ export function TokenUsageHeatmap({
<TooltipContent
side="top"
align="center"
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
className="px-2.5 py-1.5 text-[11px] font-normal"
>
<span className="block">{label}</span>
{breakdown ? (
@@ -136,7 +136,7 @@ export function ChannelLogo({
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] border border-border/45 bg-background"
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background"
>
<img
src={logoUrl}
@@ -154,7 +154,7 @@ export function ChannelLogo({
if (Icon) {
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background"
style={{ color }}
aria-hidden
>
@@ -165,7 +165,7 @@ export function ChannelLogo({
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background text-[11px] font-bold"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
style={{ color }}
aria-hidden
>
@@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
<article
key={instance.id}
className={cn(
"overflow-hidden rounded-[18px] border border-transparent transition-colors",
"overflow-hidden rounded-[18px] transition-colors",
expanded
? "bg-background"
: "bg-background/70 hover:bg-muted",
@@ -230,8 +230,8 @@ export function ChannelInstancesPanel({
</div>
{expanded ? (
<div className="border-t border-border/60">
<section className="px-4 py-4">
<div className="space-y-5 px-4 pb-4">
<section className="pt-4">
<div className="mb-3 flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
{customization.renderInstanceSummary?.(instance) ?? instance.id}
@@ -256,7 +256,7 @@ export function ChannelInstancesPanel({
}
/>
{instanceFields.length ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<details className="group text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
@@ -290,8 +290,8 @@ export function ChannelInstancesPanel({
<Button
type="submit"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-muted/70 px-3 text-[12px] font-semibold hover:bg-muted"
disabled={savingFields}
>
{savingFields ? (
@@ -397,7 +397,7 @@ function ChannelInstanceAvatar({
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold"
className="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-full bg-background text-[10px] font-bold"
style={{ color }}
aria-hidden
>
@@ -80,7 +80,7 @@ export function ChannelCatalogRow({
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] border border-transparent px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
selected ? "bg-background" : "hover:bg-muted",
)}
>
@@ -194,7 +194,7 @@ export function ChannelSetupPanel({
<Button
type="button"
size="sm"
variant="outline"
variant="secondary"
disabled={enableBusy}
onClick={() => onAction("enable", feature.name)}
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
@@ -396,13 +396,13 @@ function ChannelSetupSurface({
return (
<form
className="mt-5 overflow-hidden rounded-[16px] bg-background/55"
className="mt-5 space-y-5"
onSubmit={(event) => {
event.preventDefault();
if (mode === "credentials") void saveCredentialSettings();
}}
>
<section className="px-4 py-4">
<section>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[13px] font-semibold text-foreground">
{tx("settings.channels.requiredSetup", "Required setup")}
@@ -444,8 +444,8 @@ function ChannelSetupSurface({
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
onClick={() =>
setNotice(
tx(
@@ -461,7 +461,7 @@ function ChannelSetupSurface({
<Button
type="button"
size="sm"
variant="outline"
variant="secondary"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={copyCommand}
>
@@ -498,8 +498,8 @@ function ChannelSetupSurface({
<Button
type="submit"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
disabled={saving}
>
{saving || validating ? (
@@ -527,7 +527,7 @@ function ChannelSetupSurface({
{notice ? (
<div
role="status"
className="border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground"
className="rounded-[12px] bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
>
{notice}
</div>
@@ -540,7 +540,7 @@ function ChannelSetupSurface({
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
{hasAdvanced ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<details className="group text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
@@ -57,7 +57,7 @@ export function ChannelGuideLink({
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex max-w-full items-center gap-2 border border-border/45 bg-background/90 font-semibold text-foreground transition-colors hover:bg-muted",
"inline-flex max-w-full items-center gap-2 bg-background/80 font-semibold text-foreground transition-colors hover:bg-background",
compact
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
@@ -65,7 +65,7 @@ export function ChannelGuideLink({
>
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background font-bold",
"grid shrink-0 place-items-center overflow-hidden bg-muted/70 font-bold",
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
)}
style={{ color }}
@@ -135,10 +135,10 @@ export function ChannelOfficialLink({
href={setup.officialUrl}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full border border-border/45 bg-background/90 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-muted"
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full bg-background/80 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-background"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background"
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full bg-muted/70"
style={{ color }}
aria-hidden
>
@@ -182,8 +182,8 @@ export function ChannelSetupActions({
key={action.id}
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
onClick={() => {
if (action.copyText) {
void copyTextToClipboard(action.copyText).then((ok) =>
@@ -246,8 +246,7 @@ export function ChannelProviderPresets({
}}
className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selected === preset.id
&& "bg-background text-foreground ring-1 ring-inset ring-border/45",
selected === preset.id && "bg-background text-foreground",
)}
>
{preset.label}
@@ -307,7 +306,7 @@ export function ChannelValidationChecks({ validation }: { validation: ChannelVal
const { t } = useTranslation();
if (!validation.checks.length) return null;
return (
<div className="border-t border-border/60 px-4 py-4">
<div>
<div className="mb-2 text-[12px] font-semibold text-foreground">
{t("settings.channels.connectionChecks")}
</div>
@@ -353,7 +352,7 @@ export function ChannelSetupSteps({
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="border-t border-border/60 px-4 py-4 text-[12.5px] leading-5 text-muted-foreground">
<div className="text-[12.5px] leading-5 text-muted-foreground">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12px] font-semibold text-foreground">
{tx("settings.channels.setupSteps", "Next steps")}
@@ -371,7 +370,7 @@ export function ChannelSetupSteps({
))}
</ol>
{tryIt ? (
<div className="mt-3 rounded-[12px] border border-border/55 bg-background px-3 py-2 text-[12px] text-muted-foreground">
<div className="mt-3 rounded-[12px] bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">
{tx("settings.channels.tryIt", "Try it")}
</span>