diff --git a/web-ui/app/_lib/agents.ts b/web-ui/app/_lib/agents.ts index 5309f2dba..efb6c116b 100644 --- a/web-ui/app/_lib/agents.ts +++ b/web-ui/app/_lib/agents.ts @@ -1,4 +1,5 @@ import { ApiError } from './api'; +import type { LocalizedMarkdown } from './storeTypes'; /** * Typed client for the operator multi-orchestrator REST surface @@ -821,9 +822,16 @@ export type SetupFieldType = export interface PluginSetupFieldDto { key: string; - label: string; + /** #602 (OM-17) — the manifest loader normalises `label` into a + * `{ : text }` map (`?? { en: key }`), so this is NOT a plain + * string on any current middleware. Rendering it directly threw React #31 + * ("Objects are not valid as a React child") and took the whole orchestrator + * page down via the route error boundary. Resolve with `pickLocalized`. + * The bare-string arm covers payloads from a pre-#602 middleware. */ + label: LocalizedMarkdown | string; type: SetupFieldType; - help?: string; + /** #602 (OM-17) — localized help map; same contract as `label`. */ + help?: LocalizedMarkdown | string; default?: string | string[]; enum?: Array<{ value: string; label: string }>; } diff --git a/web-ui/app/_lib/localized.ts b/web-ui/app/_lib/localized.ts index a6e907993..68230ee69 100644 --- a/web-ui/app/_lib/localized.ts +++ b/web-ui/app/_lib/localized.ts @@ -5,11 +5,18 @@ import type { LocalizedMarkdown } from './storeTypes'; * locale. Falls back to English, then German, then any remaining locale, so a * guide that ships only one language still renders. Returns undefined when the * map is empty or absent. + * + * A bare string is returned as-is. The manifest loader normalises setup text + * into a map, but a payload from a pre-#602 middleware still ships the plain + * string — without this branch `Object.values('abc')` would resolve to `'a'`. */ export function pickLocalized( - map: LocalizedMarkdown | undefined, + map: LocalizedMarkdown | string | undefined, locale: string, ): string | undefined { + if (typeof map === 'string') { + return map.trim().length > 0 ? map : undefined; + } if (!map) return undefined; const direct = map[locale]; if (direct && direct.trim().length > 0) return direct; diff --git a/web-ui/app/operator/agents/_components/PluginsDnd.tsx b/web-ui/app/operator/agents/_components/PluginsDnd.tsx index d8050137f..48c27acfd 100644 --- a/web-ui/app/operator/agents/_components/PluginsDnd.tsx +++ b/web-ui/app/operator/agents/_components/PluginsDnd.tsx @@ -21,9 +21,10 @@ import { import { CSS } from '@dnd-kit/utilities'; import { GripVertical } from 'lucide-react'; import { useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { Button } from '@/app/_components/ui/Button'; +import { pickLocalized } from '@/app/_lib/localized'; import type { OperatorAgentDto, PluginCatalogEntryDto, @@ -780,6 +781,14 @@ function PluginConfigField(props: { onChange: (value: string | boolean | number | string[]) => void; }): React.ReactElement { const { field, value, disabled, onChange } = props; + const locale = useLocale(); + // #602 (OM-17) — `label` / `help` arrive as `{ : text }` maps from + // the manifest loader. Rendering the map object straight into JSX threw + // React #31 and replaced the whole orchestrator page with the route error + // boundary the moment "Config" was clicked. `key` is the loader's own + // fallback for a label-less field, so it is the right last resort here too. + const label = pickLocalized(field.label, locale) ?? field.key; + const help = pickLocalized(field.help, locale); const isSecret = field.type === 'secret' || field.type === 'password'; const isHostList = field.type === 'host_list'; const isEnum = field.type === 'enum' && (field.enum?.length ?? 0) > 0; @@ -789,9 +798,9 @@ function PluginConfigField(props: { return (