@tai42/studio-sdk 10.0.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +8 -5
  2. package/dist/components/components.css +41 -0
  3. package/dist/components/dialog.d.ts +14 -2
  4. package/dist/components/dialog.d.ts.map +1 -1
  5. package/dist/components/dialog.js +25 -2
  6. package/dist/components/dialog.js.map +1 -1
  7. package/dist/components/dirty-guard.d.ts +23 -10
  8. package/dist/components/dirty-guard.d.ts.map +1 -1
  9. package/dist/components/dirty-guard.js +22 -12
  10. package/dist/components/dirty-guard.js.map +1 -1
  11. package/dist/components/explorer-view.d.ts.map +1 -1
  12. package/dist/components/explorer-view.js +2 -2
  13. package/dist/components/explorer-view.js.map +1 -1
  14. package/dist/components/field.d.ts.map +1 -1
  15. package/dist/components/field.js +1 -1
  16. package/dist/components/field.js.map +1 -1
  17. package/dist/components/fleet-report.d.ts +12 -3
  18. package/dist/components/fleet-report.d.ts.map +1 -1
  19. package/dist/components/fleet-report.js +28 -9
  20. package/dist/components/fleet-report.js.map +1 -1
  21. package/dist/components/icons.d.ts +4 -0
  22. package/dist/components/icons.d.ts.map +1 -1
  23. package/dist/components/icons.js +5 -0
  24. package/dist/components/icons.js.map +1 -1
  25. package/dist/components/tokens.css +18 -6
  26. package/dist/hooks/useApi.d.ts +348 -2
  27. package/dist/hooks/useApi.d.ts.map +1 -1
  28. package/dist/host.d.ts +1 -1
  29. package/dist/host.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/navigation/context.d.ts +6 -2
  35. package/dist/navigation/context.d.ts.map +1 -1
  36. package/dist/navigation/context.js +10 -2
  37. package/dist/navigation/context.js.map +1 -1
  38. package/dist/navigation/types.d.ts +1 -0
  39. package/dist/navigation/types.d.ts.map +1 -1
  40. package/dist/plugin/host-state.d.ts +16 -0
  41. package/dist/plugin/host-state.d.ts.map +1 -1
  42. package/dist/plugin/host-state.js +1 -0
  43. package/dist/plugin/host-state.js.map +1 -1
  44. package/dist/plugin/types.d.ts +17 -7
  45. package/dist/plugin/types.d.ts.map +1 -1
  46. package/dist/schema-form/SecretRefField.d.ts.map +1 -1
  47. package/dist/schema-form/SecretRefField.js +3 -2
  48. package/dist/schema-form/SecretRefField.js.map +1 -1
  49. package/dist/schema-form/default-value.d.ts +12 -0
  50. package/dist/schema-form/default-value.d.ts.map +1 -1
  51. package/dist/schema-form/default-value.js +34 -3
  52. package/dist/schema-form/default-value.js.map +1 -1
  53. package/dist/schema-form/field-node.d.ts +7 -0
  54. package/dist/schema-form/field-node.d.ts.map +1 -1
  55. package/dist/schema-form/field-node.js +3 -2
  56. package/dist/schema-form/field-node.js.map +1 -1
  57. package/dist/schema-form/object-fields.d.ts +3 -1
  58. package/dist/schema-form/object-fields.d.ts.map +1 -1
  59. package/dist/schema-form/object-fields.js +2 -2
  60. package/dist/schema-form/object-fields.js.map +1 -1
  61. package/dist/schema-form/union-field.d.ts +5 -2
  62. package/dist/schema-form/union-field.d.ts.map +1 -1
  63. package/dist/schema-form/union-field.js +68 -15
  64. package/dist/schema-form/union-field.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/components/components.css +41 -0
  67. package/src/components/dialog.tsx +36 -1
  68. package/src/components/dirty-guard.tsx +23 -13
  69. package/src/components/explorer-view.tsx +9 -2
  70. package/src/components/field.tsx +10 -5
  71. package/src/components/fleet-report.tsx +44 -13
  72. package/src/components/icons.tsx +19 -0
  73. package/src/components/tokens.css +18 -6
  74. package/src/host.ts +5 -1
  75. package/src/index.ts +3 -1
  76. package/src/navigation/context.tsx +16 -1
  77. package/src/navigation/types.ts +3 -0
  78. package/src/plugin/host-state.ts +18 -0
  79. package/src/plugin/types.ts +17 -7
  80. package/src/schema-form/SecretRefField.tsx +3 -2
  81. package/src/schema-form/default-value.ts +36 -3
  82. package/src/schema-form/field-node.tsx +21 -2
  83. package/src/schema-form/object-fields.tsx +4 -0
  84. package/src/schema-form/union-field.tsx +85 -19
@@ -8,6 +8,7 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  type AriaAttributes,
11
+ type CSSProperties,
11
12
  type MouseEvent,
12
13
  type ReactNode,
13
14
  } from 'react';
@@ -453,6 +454,10 @@ export interface AppLinkProps<T extends RouteToken> {
453
454
  className?: string;
454
455
  'aria-label'?: string;
455
456
  'aria-current'?: AriaAttributes['aria-current'];
457
+ /** Native tooltip text (also a sensible non-AT hover hint). */
458
+ title?: string;
459
+ /** Inline styles for the anchor. */
460
+ style?: CSSProperties;
456
461
  }
457
462
 
458
463
  /**
@@ -467,6 +472,8 @@ export function AppLink<T extends RouteToken>({
467
472
  className,
468
473
  'aria-label': ariaLabel,
469
474
  'aria-current': ariaCurrent,
475
+ title,
476
+ style,
470
477
  }: AppLinkProps<T>): ReactNode {
471
478
  const { navigate, resolvePath } = useNavigation();
472
479
  const href = resolvePath(to, search);
@@ -482,7 +489,15 @@ export function AppLink<T extends RouteToken>({
482
489
  );
483
490
  return createElement(
484
491
  'a',
485
- { href, className, 'aria-label': ariaLabel, 'aria-current': ariaCurrent, onClick },
492
+ {
493
+ href,
494
+ className,
495
+ 'aria-label': ariaLabel,
496
+ 'aria-current': ariaCurrent,
497
+ title,
498
+ style,
499
+ onClick,
500
+ },
486
501
  children,
487
502
  );
488
503
  }
@@ -41,6 +41,9 @@ export interface RouteSearchByToken {
41
41
  q?: string;
42
42
  };
43
43
  connectors: { connection?: string };
44
+ // The Served Endpoints surface (the promoted sub-MCP servers): a curated subset
45
+ // of tools served on a transport. No search parameters — a flat list + create form.
46
+ servedEndpoints: Record<string, never>;
44
47
  hooks: Record<string, never>;
45
48
  templates: { template?: string; q?: string };
46
49
  storage: { q?: string };
@@ -16,6 +16,17 @@ import { useSyncExternalStore } from 'react';
16
16
  import type { PluginContributions } from './types';
17
17
  import { getContributions } from './registry';
18
18
 
19
+ /**
20
+ * Identity of a successfully-loaded plugin, for host chrome that must attribute a
21
+ * contribution to its origin (e.g. the shell's provenance badge on plugin nav). The
22
+ * `name` is the same id the registry stamps on every contribution (`pluginId`); the
23
+ * `version` comes from the plugin's manifest.
24
+ */
25
+ export interface LoadedPlugin {
26
+ readonly name: string;
27
+ readonly version: string;
28
+ }
29
+
19
30
  /**
20
31
  * The plugin load pass's public state, mirrored for feature pages to read. It is
21
32
  * the shape the app shell's loader store already exposes: `status` gates when the
@@ -26,6 +37,12 @@ export interface PluginLoaderState {
26
37
  readonly status: 'idle' | 'loading' | 'ready';
27
38
  /** Names of plugins whose bundle imported and registered successfully. */
28
39
  readonly loaded: readonly string[];
40
+ /**
41
+ * Identity (name + version) of every successfully-loaded plugin, parallel to
42
+ * `loaded`. The host reads it to attribute a contribution to its plugin — e.g. the
43
+ * nav provenance badge's "Plugin: name version". Empty before any plugin loads.
44
+ */
45
+ readonly plugins: readonly LoadedPlugin[];
29
46
  /** Plugin name → loud error message (version mismatch / load failure). */
30
47
  readonly errors: Readonly<Record<string, string>>;
31
48
  /** A registry-listing failure that is NOT a 401 (surfaced loudly, never hidden). */
@@ -35,6 +52,7 @@ export interface PluginLoaderState {
35
52
  const INITIAL_HOST_STATE: PluginLoaderState = {
36
53
  status: 'idle',
37
54
  loaded: [],
55
+ plugins: [],
38
56
  errors: {},
39
57
  registryError: null,
40
58
  };
@@ -142,13 +142,15 @@ export interface RegisteredPage extends PageContribution {
142
142
 
143
143
  /**
144
144
  * The sidebar section a nav entry renders in. A named core section groups the
145
- * entry there, after that section's core rows; `'Plugins'`, an absent field, or
146
- * any unrecognised value falls to the Plugins section. The shell tolerates an
147
- * unknown value at runtime (a bundle newer than this host), so the field is a
148
- * placement hint, never a hard contract.
145
+ * entry there, after that section's core rows. An absent field or any value that
146
+ * is not a live core section renders the entry in the shared "Plugins" section that
147
+ * follows the core sections (alongside every other undeclared plugin entry),
148
+ * placed after the core sections. The shell tolerates an unknown value at runtime
149
+ * (a bundle newer than this host), falling back to that shared section, so the
150
+ * field is a placement hint, never a hard contract.
149
151
  */
150
152
  export type NavEntrySection =
151
- 'Capabilities' | 'Integrations' | 'Activity' | 'Administration' | 'Plugins';
153
+ 'Capabilities' | 'Connections' | 'Triggers' | 'Activity' | 'Administration';
152
154
 
153
155
  export interface NavEntryContribution {
154
156
  /**
@@ -160,10 +162,18 @@ export interface NavEntryContribution {
160
162
  readonly title: string;
161
163
  /**
162
164
  * Optional target sidebar section (see {@link NavEntrySection}); absent ⇒ the
163
- * Plugins section. Additive — an older bundle omits it and stays on the same
164
- * plugin API version.
165
+ * shared "Plugins" section. Additive — an older bundle omits it and stays
166
+ * on the same plugin API version.
165
167
  */
166
168
  readonly section?: NavEntrySection;
169
+ /**
170
+ * Optional sort weight WITHIN the entry's section (whether a core section or the
171
+ * shared "Plugins" one): entries render in ascending `order`, and every
172
+ * entry that omits it sorts AFTER the ordered ones, in registration order. Two
173
+ * entries with the same `order` keep registration order (a stable sort). Additive —
174
+ * an older bundle omits it and stays on the same plugin API version.
175
+ */
176
+ readonly order?: number;
167
177
  /**
168
178
  * Optional icon rendered before the title. It must be a square inline SVG that
169
179
  * fills its box and draws with `currentColor`; the host constrains the slot,
@@ -3,8 +3,9 @@
3
3
  * EITHER a reference to an existing env key OR a freshly pasted secret. It knows
4
4
  * nothing about the `!ENV ${KEY}` marker syntax or any wire format: it emits a
5
5
  * discriminated {@link SecretRef} and the host maps that to whatever it stores
6
- * (the consumer that mounts this — the McpTab editor — writes an `!ENV ${KEY}`
7
- * marker for a `key` ref and runs the combined store-then-mark op for a `paste`).
6
+ * (the consumer that mounts this — the connectors page's McpServersSection —
7
+ * writes an `!ENV ${KEY}` marker for a `key` ref and runs the combined
8
+ * store-then-mark op for a `paste`).
8
9
  *
9
10
  * WRITE-ONLY for a pasted secret: the plaintext lives only in local editor state
10
11
  * until the user commits it, at which point it leaves the DOM entirely (held only
@@ -10,6 +10,10 @@
10
10
  * `default` — an optional object/array is NOT auto-materialized, so its own
11
11
  * required children can't raise spurious errors on a field the user never
12
12
  * opted into.
13
+ *
14
+ * `skeletonValueForSchema` is the sibling entry for programmatic reseeds (a
15
+ * union variant switch): same walk, but required scalars mint their type's
16
+ * empty value so the committed seed validates against its own schema.
13
17
  */
14
18
  import { classifySchema } from './classify';
15
19
  import type { JsonSchema } from './types';
@@ -30,6 +34,30 @@ function explicitDefault(
30
34
  * whole-tool-schema call).
31
35
  */
32
36
  export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = schema): unknown {
37
+ return seedValue(schema, root, false);
38
+ }
39
+
40
+ /**
41
+ * A SELF-CONSISTENT skeleton for a schema — the seed a variant switch (or any
42
+ * other programmatic reseed) commits as the field's new value. Where
43
+ * {@link defaultValueForSchema} deliberately leaves a required scalar absent so
44
+ * a fresh form flags it, a committed skeleton must validate against its own
45
+ * schema, so here EVERY required field with a known empty value is minted:
46
+ * required strings seed `''`, numbers `0`, booleans `false` — consistently with
47
+ * the `[]` / `{}` / nested-object seeds the default path already mints. Kinds
48
+ * with no empty value to name (enum, union, free-form JSON) stay absent either
49
+ * way: inventing a choice would be a lie the user never made.
50
+ */
51
+ export function skeletonValueForSchema(schema: JsonSchema, root: JsonSchema = schema): unknown {
52
+ return seedValue(schema, root, true);
53
+ }
54
+
55
+ /**
56
+ * The shared seeding walk. `mintScalars` distinguishes the two entry points
57
+ * above: `false` leaves value-less required scalars absent; `true` mints their
58
+ * type's empty value.
59
+ */
60
+ function seedValue(schema: JsonSchema, root: JsonSchema, mintScalars: boolean): unknown {
33
61
  const classified = classifySchema(schema, root);
34
62
  const explicit = explicitDefault(schema, classified.schema);
35
63
  if (explicit.present) return explicit.value;
@@ -42,7 +70,7 @@ export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = sch
42
70
  const result: Record<string, unknown> = {};
43
71
  for (const [name, propSchema] of model.properties) {
44
72
  if (model.required.has(name)) {
45
- const seeded = defaultValueForSchema(propSchema, root);
73
+ const seeded = seedValue(propSchema, root, mintScalars);
46
74
  if (seeded !== undefined) result[name] = seeded;
47
75
  continue;
48
76
  }
@@ -56,15 +84,20 @@ export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = sch
56
84
  return [];
57
85
  case 'record':
58
86
  return {};
59
- case 'enum':
60
87
  case 'string':
88
+ return mintScalars ? '' : undefined;
61
89
  case 'number':
90
+ return mintScalars ? 0 : undefined;
62
91
  case 'boolean':
92
+ return mintScalars ? false : undefined;
93
+ case 'enum':
63
94
  case 'union':
64
95
  case 'json':
65
96
  // A free-form JSON field starts ABSENT (its editor seeds an empty buffer),
66
97
  // like the other value-less kinds: an optional one never invents a value,
67
- // and a required one is left absent so validation flags it.
98
+ // and a required one is left absent so validation flags it. These kinds
99
+ // have no honest empty value even for a minted skeleton — an enum or a
100
+ // union would need a CHOICE invented on the user's behalf.
68
101
  return undefined;
69
102
  }
70
103
  }
@@ -30,6 +30,13 @@ interface FieldNodeProps {
30
30
  /** The label to show — a property key, array-item caption, or `undefined` at root. */
31
31
  readonly label: string | undefined;
32
32
  readonly required: boolean;
33
+ /**
34
+ * Append the design system's required marker (a trailing ` *`, the same
35
+ * convention host forms use on their own labels) to the heading when the
36
+ * field is required. Opt-in per face: the union variant surface sets it so
37
+ * the ACTIVE variant's obligations read at a glance.
38
+ */
39
+ readonly markRequired?: boolean;
33
40
  readonly errors: SchemaFormErrors | undefined;
34
41
  readonly idPrefix: string;
35
42
  }
@@ -40,9 +47,21 @@ function labelText(explicit: string | undefined, label: string | undefined): str
40
47
 
41
48
  /** A single schema node, dispatched on its classified model kind. */
42
49
  export function FieldNode(props: FieldNodeProps): ReactNode {
43
- const { schema, root, value, onChange, path, label, required, errors, idPrefix } = props;
50
+ const {
51
+ schema,
52
+ root,
53
+ value,
54
+ onChange,
55
+ path,
56
+ label,
57
+ required,
58
+ markRequired = false,
59
+ errors,
60
+ idPrefix,
61
+ } = props;
44
62
  const classified = classifySchema(schema, root);
45
- const heading = labelText(classified.title, label);
63
+ const base = labelText(classified.title, label);
64
+ const heading = markRequired && required ? `${base} *` : base;
46
65
  const description = classified.description;
47
66
  const error = errors?.[path];
48
67
  const model = classified.model;
@@ -23,6 +23,7 @@ export function ObjectFields({
23
23
  errors,
24
24
  idPrefix,
25
25
  skip,
26
+ markRequired = false,
26
27
  }: {
27
28
  properties: readonly (readonly [string, JsonSchema])[];
28
29
  requiredKeys: ReadonlySet<string>;
@@ -33,6 +34,8 @@ export function ObjectFields({
33
34
  errors: SchemaFormErrors | undefined;
34
35
  idPrefix: string;
35
36
  skip: string | undefined;
37
+ /** Mark each required property's label with the DS required marker. */
38
+ markRequired?: boolean;
36
39
  }): ReactNode {
37
40
  const obj = isPlainObject(value) ? value : {};
38
41
  return (
@@ -52,6 +55,7 @@ export function ObjectFields({
52
55
  path={childPath}
53
56
  label={name}
54
57
  required={requiredKeys.has(name)}
58
+ markRequired={markRequired}
55
59
  errors={errors}
56
60
  idPrefix={idPrefix}
57
61
  />
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * The union field renderer: a variant picker plus the active variant's fields.
3
3
  * An object variant renders its properties inline (skipping the discriminator
4
- * tag, which the picker owns); a scalar/enum variant renders as a single nested
5
- * {@link FieldNode}. Selecting a variant seeds a concrete value for it.
4
+ * tag, which the picker owns), with the variant's required fields carrying the
5
+ * design system's required marker; a scalar/enum variant renders as a single
6
+ * nested {@link FieldNode}. Selecting a variant seeds a concrete value for it,
7
+ * preserving the fields the outgoing value shares with the new variant (see
8
+ * {@link switchVariantValue}).
6
9
  */
7
10
  import type { ReactNode } from 'react';
8
11
 
9
12
  import { Field } from '../components/field';
10
13
  import { Select } from '../components/select';
11
- import { classifySchema, type FieldModel } from './classify';
12
- import { defaultValueForSchema } from './default-value';
14
+ import { classifySchema, type FieldModel, type UnionVariant } from './classify';
15
+ import { skeletonValueForSchema } from './default-value';
13
16
  import { FieldGroup } from './field-group';
14
17
  import { FieldNode } from './field-node';
15
18
  import { ObjectFields } from './object-fields';
@@ -18,28 +21,87 @@ import { groupClass } from './styles';
18
21
  import { activeVariantIndex } from './union';
19
22
  import type { JsonSchema, SchemaFormErrors } from './types';
20
23
 
24
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+
21
28
  /**
22
- * A concrete seed for a chosen union variant. Object variants get their
23
- * required-field skeleton; a scalar/enum variant that has no explicit default
29
+ * A concrete seed for a chosen union variant. Object variants get their full
30
+ * skeleton (every required field minted, so the seed validates against the
31
+ * variant's own schema); a scalar/enum variant that has no explicit default
24
32
  * still needs a non-`undefined` value so the selection sticks (otherwise
25
33
  * `activeVariantIndex` reads it back as "no variant" and the picker snaps empty).
26
34
  */
27
35
  function seedVariantValue(schema: JsonSchema, root: JsonSchema): unknown {
28
- const seeded = defaultValueForSchema(schema, root);
36
+ const seeded = skeletonValueForSchema(schema, root);
29
37
  if (seeded !== undefined) return seeded;
30
38
  const resolved = resolveRef(schema, root);
31
39
  if (resolved.const !== undefined) return resolved.const;
32
40
  if (resolved.enum !== undefined && resolved.enum.length > 0) return resolved.enum[0];
33
- switch (classifySchema(schema, root).model.kind) {
34
- case 'string':
35
- return '';
36
- case 'number':
37
- return 0;
38
- case 'boolean':
39
- return false;
40
- default:
41
- return undefined;
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * The value committed when the user switches to `target`. Typed values SURVIVE
46
+ * the switch: the target's skeleton is minted first, then every top-level field
47
+ * of the current value whose NAME exists in the target variant's properties is
48
+ * carried over wholesale — the merge happens at the top level only, each carried
49
+ * field keeping its nested contents as-is — so fields both variants share are
50
+ * never silently discarded. Only fields absent from the current value keep
51
+ * their freshly minted seed. The discriminator tag is never carried: it always
52
+ * comes from the target's seed (the old tag would re-select the old variant).
53
+ * A non-object side (scalar variant, or a non-object current value) has no
54
+ * fields to carry, so it reseeds wholesale.
55
+ */
56
+ function switchVariantValue(
57
+ current: unknown,
58
+ target: UnionVariant,
59
+ discriminator: string | undefined,
60
+ root: JsonSchema,
61
+ ): unknown {
62
+ const seeded = seedVariantValue(target.schema, root);
63
+ if (!isPlainObject(current) || !isPlainObject(seeded)) return seeded;
64
+ const { model } = classifySchema(target.schema, root);
65
+ if (model.kind !== 'object') return seeded;
66
+ const targetNames = new Set(model.properties.map(([name]) => name));
67
+ const merged: Record<string, unknown> = { ...seeded };
68
+ for (const [name, held] of Object.entries(current)) {
69
+ if (name === discriminator) continue;
70
+ if (targetNames.has(name)) merged[name] = held;
71
+ }
72
+ return merged;
73
+ }
74
+
75
+ /** Title-case a property name: `kind` → "Kind", `content_type` → "Content Type". */
76
+ function titleCaseName(name: string): string {
77
+ return name
78
+ .split(/[_\-\s]+/)
79
+ .filter((part) => part.length > 0)
80
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
81
+ .join(' ');
82
+ }
83
+
84
+ /**
85
+ * The variant picker's label. A discriminated union names the picker after the
86
+ * discriminator property it sets — the property's schema `title` when a variant
87
+ * declares one, else the property name title-cased — so the picker reads as the
88
+ * field it is ("Kind", "Format"). Only a union without a discriminator falls
89
+ * back to the generic "Variant".
90
+ */
91
+ function pickerLabel(
92
+ discriminator: string | undefined,
93
+ variants: readonly UnionVariant[],
94
+ root: JsonSchema,
95
+ ): string {
96
+ if (discriminator === undefined) return 'Variant';
97
+ for (const variant of variants) {
98
+ const property = resolveRef(variant.schema, root).properties?.[discriminator];
99
+ if (property === undefined) continue;
100
+ const title = property.title ?? resolveRef(property, root).title;
101
+ if (typeof title === 'string' && title.length > 0) return title;
42
102
  }
103
+ const cased = titleCaseName(discriminator);
104
+ return cased.length > 0 ? cased : 'Variant';
43
105
  }
44
106
 
45
107
  export function UnionField({
@@ -74,15 +136,18 @@ export function UnionField({
74
136
  const position = Number(key);
75
137
  const variant = model.variants[position];
76
138
  if (variant === undefined) return;
77
- onChange(seedVariantValue(variant.schema, root));
139
+ onChange(switchVariantValue(value, variant, model.discriminator, root));
78
140
  };
79
141
 
80
142
  const active = index === -1 ? undefined : model.variants[index];
81
143
  const activeClassified = active === undefined ? undefined : classifySchema(active.schema, root);
82
144
 
83
145
  return (
84
- <FieldGroup heading={heading} description={description} error={error} atRoot={false}>
85
- <Field label="Variant">
146
+ // At the form root the union sits inside a field the HOST already labels
147
+ // and describes — a synthetic heading there is noise, so it is suppressed
148
+ // exactly as a root object's is; nested unions keep theirs.
149
+ <FieldGroup heading={heading} description={description} error={error} atRoot={path === ''}>
150
+ <Field label={pickerLabel(model.discriminator, model.variants, root)}>
86
151
  <Select
87
152
  options={options}
88
153
  value={index === -1 ? '' : String(index)}
@@ -102,6 +167,7 @@ export function UnionField({
102
167
  errors={errors}
103
168
  idPrefix={idPrefix}
104
169
  skip={model.discriminator}
170
+ markRequired
105
171
  />
106
172
  </div>
107
173
  ) : active !== undefined ? (