From 1da431f2b58d3c72a79733eff09cf6a2102d40ab Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 10:28:52 -0400 Subject: [PATCH 1/4] Widen V2 Admin Settings and rebuild the Model Catalog natively V2 Admin Settings now fills the width beside an "On this page" index that lists each visible section with its status. Every section uses the card the Agents settings introduced, with its icon taken from the navigation, and the nesting of settings under the switch they depend on is derived from the field schema. Wide cards place each label beside its control, and runs of independent switches pair up in two columns. The Model Catalog is a native React workbench in V2: one-line profile rows with vendor and connected-model count, a tabbed detail pane, and links that open the exact AI Connection with the model outlined. The admin catalog API adds connection and model ids to linked models. Classic keeps its catalog. In-page jumps scroll to their target again; they looked up an id the cards stopped rendering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/Dockerfile | 8 - application/single_app/config.py | 2 +- .../single_app/route_backend_models.py | 4 + .../static/js/admin/model_catalog_ui.d.ts | 14 - .../components/admin/ModelCatalogDetail.tsx | 621 ++++++++++++++ .../components/admin/ModelCatalogEditor.tsx | 217 +++++ .../components/admin/ModelCatalogManager.tsx | 791 +++++++++++++++++- .../admin/ModelConnectionsManager.tsx | 61 +- .../src/components/admin/SettingsIndex.tsx | 195 +++++ .../src/components/admin/SettingsSection.tsx | 369 ++++---- .../src/components/admin/adminSectionIcons.ts | 213 +++++ .../admin/agentSectionAppearance.ts | 17 +- .../v2_ui/src/components/admin/fields.tsx | 164 ++-- .../admin/sectionStatusPresentation.ts | 59 ++ .../v2_ui/src/components/ui/primitives.tsx | 17 +- application/v2_ui/src/lib/adminSections.ts | 166 ++++ application/v2_ui/src/lib/modelCatalog.ts | 471 +++++++++++ .../v2_ui/src/pages/AdminSettingsPage.tsx | 503 +++++++---- .../v2_ui/src/stores/modelConnectionsStore.ts | 23 + application/v2_ui/src/styles/theme.css | 107 +++ docs/admin/agents-actions.md | 14 +- docs/admin/index.md | 25 + docs/admin/model-catalog.md | 31 +- .../features/V2_ADMIN_AGENTS_ACTIONS.md | 5 +- .../V2_ADMIN_AGENTS_VISUAL_HIERARCHY.md | 7 + .../V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md | 147 ++++ .../features/V2_MODEL_CATALOG_WORKBENCH.md | 125 +++ .../fixes/V2_ADMIN_SECTION_JUMP_FIX.md | 55 ++ functional_tests/test_model_catalog_api.py | 31 + .../test_v2_admin_section_logic.ts | 248 +++++- .../test_v2_admin_section_shell.py | 3 +- .../test_v2_admin_settings_layout.py | 152 ++++ .../test_v2_model_catalog_logic.ts | 228 +++++ .../test_v2_model_catalog_workbench.py | 168 ++++ .../fixtures/orchestration/harness_build.py | 4 +- ui_tests/fixtures/v2_admin_settings.py | 125 ++- ui_tests/test_model_catalog_management.py | 20 +- .../test_v2_admin_agents_visual_hierarchy.py | 20 +- .../test_v2_admin_model_catalog_workbench.py | 167 ++++ .../test_v2_admin_settings_wide_layout.py | 155 ++++ 40 files changed, 5180 insertions(+), 572 deletions(-) delete mode 100644 application/single_app/static/js/admin/model_catalog_ui.d.ts create mode 100644 application/v2_ui/src/components/admin/ModelCatalogDetail.tsx create mode 100644 application/v2_ui/src/components/admin/ModelCatalogEditor.tsx create mode 100644 application/v2_ui/src/components/admin/SettingsIndex.tsx create mode 100644 application/v2_ui/src/components/admin/adminSectionIcons.ts create mode 100644 application/v2_ui/src/components/admin/sectionStatusPresentation.ts create mode 100644 application/v2_ui/src/lib/modelCatalog.ts create mode 100644 docs/explanation/features/V2_ADMIN_SETTINGS_LAYOUT_AND_HIERARCHY.md create mode 100644 docs/explanation/features/V2_MODEL_CATALOG_WORKBENCH.md create mode 100644 docs/explanation/fixes/V2_ADMIN_SECTION_JUMP_FIX.md create mode 100644 functional_tests/test_v2_admin_settings_layout.py create mode 100644 functional_tests/test_v2_model_catalog_logic.ts create mode 100644 functional_tests/test_v2_model_catalog_workbench.py create mode 100644 ui_tests/test_v2_admin_model_catalog_workbench.py create mode 100644 ui_tests/test_v2_admin_settings_wide_layout.py diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index e6dd249ca..732a4c7e5 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -30,14 +30,6 @@ WORKDIR /v2_ui COPY application/v2_ui/package.json application/v2_ui/package-lock.json* ./ RUN npm ci --no-audit --no-fund -# The model catalog UI is shared source: the V2 SPA imports the same module and stylesheet -# that the legacy admin page loads, by relative path out of application/v2_ui. Those files -# must exist at /single_app/static for `tsc -b && vite build` to resolve the import. -COPY application/single_app/static/js/admin/model_catalog_ui.js \ - application/single_app/static/js/admin/model_catalog_ui.d.ts \ - /single_app/static/js/admin/ -COPY application/single_app/static/css/model-catalog.css /single_app/static/css/ - COPY application/v2_ui/ ./ # Vite is configured to emit into ../single_app/static/v2, so the build output lands at diff --git a/application/single_app/config.py b/application/single_app/config.py index 17903e612..fe62d3837 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.252" +VERSION = "0.261.253" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index d75f9f7b6..e5eb57623 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -100,9 +100,13 @@ def catalog_response(settings, *, admin=False): ), None) if profile is not None: effective = apply_model_profile(model, endpoint, settings, profiles) + # The ids let the admin catalog open the exact connection and model; + # names alone can repeat across connections. links[profile["id"]].append({ "connection": endpoint.get("name") or endpoint.get("id"), + "connection_id": str(endpoint.get("id") or ""), "model": model.get("displayName") or model.get("deploymentName") or model.get("modelName"), + "model_id": str(model.get("id") or ""), "enabled": bool(endpoint.get("enabled", True) and model.get("enabled", True)), "capabilities": {key: value for key, value in effective.get("capabilities", {}).items() if type(value) is bool}, diff --git a/application/single_app/static/js/admin/model_catalog_ui.d.ts b/application/single_app/static/js/admin/model_catalog_ui.d.ts deleted file mode 100644 index 48efdd9ea..000000000 --- a/application/single_app/static/js/admin/model_catalog_ui.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// model_catalog_ui.d.ts -export interface CatalogProfile { - id: string; - displayName: string; - [key: string]: unknown; -} -export interface CatalogResponse { - profiles: CatalogProfile[]; - tasks: Record; - etag?: string; -} -export type CatalogRequest = (path: string, options?: { method?: string; body?: unknown; signal?: AbortSignal }) => Promise; -export function mountModelCatalog(root: HTMLElement, options?: { request?: CatalogRequest; onSaved?: () => void }): () => void; -export function mountProfilePicker(root: HTMLElement, model: { catalogProfileId?: string }, onChange: (id: string) => void, request?: CatalogRequest): () => void; diff --git a/application/v2_ui/src/components/admin/ModelCatalogDetail.tsx b/application/v2_ui/src/components/admin/ModelCatalogDetail.tsx new file mode 100644 index 000000000..5c48a3ccc --- /dev/null +++ b/application/v2_ui/src/components/admin/ModelCatalogDetail.tsx @@ -0,0 +1,621 @@ +// ModelCatalogDetail.tsx +// One catalog profile: what it is, what it is good at, and where it is connected. +// +// The classic detail panel printed every fact in one column, which made a single profile +// several screens long. Here the header keeps what an administrator acts on -- the name, +// where the profile comes from, and its routing preferences -- and the rest is split into +// tabs, each short enough to read without scrolling on a wide screen. + +import { useId, useRef, type KeyboardEvent, type ReactNode, type RefObject } from 'react'; +import { clsx } from 'clsx'; +import { + Archive, + ArchiveRestore, + ArrowUpRight, + CircleCheck, + CircleHelp, + CircleX, + Copy, + ExternalLink, + Pencil, + Server, + Star, + TriangleAlert, +} from 'lucide-react'; +import { + CAPABILITY_GROUP_LABELS, + CATALOG_CAPABILITIES, + PRIORITY_OPTIONS, + SUITABILITY_LABELS, + capabilityLabel, + evidenceDescription, + evidenceLabel, + formatTokenLimit, + isLifecycleWarning, + lifecycleLabel, + linkedModelCount, + publisherLabel, + readSuitability, + safeEvidenceUrl, + type CapabilityGroup, + type CatalogConnectionTarget, + type CatalogPriority, + type CatalogProfile, + type TaskSuitability, +} from '../../lib/modelCatalog'; +import { GlassButton } from '../ui/primitives'; +import { inputClass } from './fields'; + +export type CatalogDetailTab = 'overview' | 'capabilities' | 'connections' | 'evidence'; + +const TABS: ReadonlyArray<{ id: CatalogDetailTab; label: string }> = [ + { id: 'overview', label: 'Overview' }, + { id: 'capabilities', label: 'Capabilities' }, + { id: 'connections', label: 'Connections' }, + { id: 'evidence', label: 'Evidence' }, +]; + +/** Technical facts that only make sense as structured data, shown collapsed. */ +const ADVANCED_TECHNICAL: ReadonlyArray<[string, string]> = [ + ['tokenLimitEvidence', 'Token capacity evidence'], + ['tokenLimitProfiles', 'Hosted capacity profiles'], + ['reasoningPolicy', 'Reasoning policy'], + ['embeddingPolicy', 'Embedding policy'], + ['imageProfiles', 'Image operation profiles'], +]; + +const SUITABILITY_TONE: Readonly> = { + strong: 'bg-ok-soft text-ok', + suitable: 'bg-accent-soft text-accent', + unsuitable: 'bg-danger-soft text-danger', + unknown: 'bg-surface-sunken text-text-3', +}; + +function Chip({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'warn' }) { + return ( + + {children} + + ); +} + +function SectionTitle({ children }: { children: ReactNode }) { + return

{children}

; +} + +function TextList({ title, items }: { title: string; items: string[] }) { + return ( +
+ {title} + {items.length ? ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ) : ( +

Not documented.

+ )} +
+ ); +} + +function CapabilityRow({ label, value }: { label: string; value: unknown }) { + const state = value === true ? 'supported' : value === false ? 'unsupported' : 'unknown'; + const Icon = state === 'supported' ? CircleCheck : state === 'unsupported' ? CircleX : CircleHelp; + const stateText = state === 'supported' ? 'Supported' : state === 'unsupported' ? 'Not supported' : 'Unknown'; + // The icon's shape carries the state for sighted readers; the text is there for + // assistive technology and on hover, which keeps three columns readable. + return ( +
  • +
  • + ); +} + +function OverviewPanel({ profile, tasks }: { profile: CatalogProfile; tasks: Record }) { + // Every declared task is listed, so "Unknown" reads as a rating rather than an omission. + const taskEntries: [string, string][] = [ + ...Object.entries(tasks), + ...Object.keys(profile.tasks ?? {}) + .filter((key) => !(key in tasks)) + .map((key): [string, string] => [key, key]), + ]; + return ( +
    + {profile.chatCompletions === false ? ( +

    +

    + ) : null} + +
    + + +
    + +
    + Task suitability +
      + {taskEntries.map(([key, label]) => { + const rating = readSuitability(profile.tasks?.[key]); + return ( +
    • + {label} + + {SUITABILITY_LABELS[rating]} + +
    • + ); + })} +
    +
    + +
    + Routing +

    + Auto ranks eligible models by task suitability first, then priority, then + favorite. Preferences do not change access or chat defaults. +

    +
    +
    + ); +} + +function CapabilitiesPanel({ profile }: { profile: CatalogProfile }) { + const technical = profile.technical ?? {}; + const known = new Set(CATALOG_CAPABILITIES.map((item) => item.key)); + const others = Object.keys(profile.capabilities ?? {}).filter((key) => !known.has(key)).sort(); + const lifecycle = lifecycleLabel(technical.lifecycle); + const advanced = ADVANCED_TECHNICAL.filter(([key]) => technical[key] != null); + + return ( +
    +
    + {(['input', 'output', 'feature'] as CapabilityGroup[]).map((group) => ( +
    + {CAPABILITY_GROUP_LABELS[group]} +
      + {CATALOG_CAPABILITIES.filter((item) => item.group === group).map((item) => ( + + ))} +
    +
    + ))} +
    + + {others.length ? ( +
    + Other operations +
      + {others.map((key) => ( + + ))} +
    +
    + ) : null} + +
    + Limits and lifecycle +
    + {([ + ['Context window', formatTokenLimit(technical.contextWindow)], + ['Input token limit', formatTokenLimit(technical.inputTokenLimit)], + ['Output token limit', formatTokenLimit(technical.outputTokenLimit)], + ['Lifecycle', lifecycle ?? 'Not documented'], + ] as [string, string][]).map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    +

    + Capacity and operation support remain provider-qualified. Connection + overrides and access still apply. +

    +
    + + {advanced.length ? ( +
    + Technical detail + {advanced.map(([key, label]) => ( +
    + + {label} + +
    +                                {JSON.stringify(technical[key], null, 2)}
    +                            
    +
    + ))} +
    + ) : null} +
    + ); +} + +function ConnectionsPanel({ + profile, + onOpenConnection, +}: { + profile: CatalogProfile; + onOpenConnection?: (target?: CatalogConnectionTarget) => void; +}) { + const links = profile.linked_models ?? []; + return ( +
    + {links.length === 0 ? ( +
    +

    + No global AI Connection uses this profile yet. +

    +

    + To link one, edit a model in AI Connections and choose this profile as + its Catalog profile. One profile can describe several deployments. +

    + {onOpenConnection ? ( + onOpenConnection()} + > + Go to AI Connections + + ) : null} +
    + ) : ( +
      + {links.map((link, index) => { + const support = Object.entries(link.capabilities ?? {}) + .filter(([, value]) => value) + .map(([key]) => capabilityLabel(key)); + return ( +
    • +
    • + ); + })} +
    + )} +

    + Personal and group connections are managed in their own workspaces. A profile + never publishes a deployment. +

    +
    + ); +} + +function EvidencePanel({ profile }: { profile: CatalogProfile }) { + const sources = (profile.sources ?? []) + .map((source) => ({ source, href: safeEvidenceUrl(source) })) + .filter((entry) => entry.href); + return ( +
    +
    +
    +
    Evidence
    +
    {evidenceLabel(profile.evidence)}
    +
    + {evidenceDescription(profile.evidence)} +
    +
    +
    +
    Profile ID
    +
    {profile.id}
    + {profile.verifiedAt ? ( +
    Reviewed {profile.verifiedAt}
    + ) : null} +
    +
    + +
    + Sources + {sources.length ? ( + + ) : ( +

    No sources recorded.

    + )} +
    + +
    + Aliases + {profile.aliases?.length ? ( +
      + {profile.aliases.map((alias) => ( +
    • + {alias} +
    • + ))} +
    + ) : ( +

    None.

    + )} +
    +
    + ); +} + +export function ModelCatalogDetail({ + profile, + tasks, + tab, + onTabChange, + onToggleFavorite, + onPriorityChange, + onDuplicate, + onEdit, + onToggleArchive, + onOpenConnection, + headingRef, +}: { + profile: CatalogProfile; + tasks: Record; + tab: CatalogDetailTab; + onTabChange: (tab: CatalogDetailTab) => void; + onToggleFavorite: () => void; + onPriorityChange: (priority: CatalogPriority) => void; + onDuplicate: () => void; + onEdit: () => void; + onToggleArchive: () => void; + onOpenConnection?: (target?: CatalogConnectionTarget) => void; + headingRef?: RefObject; +}) { + const baseId = useId(); + const tabRefs = useRef>>({}); + const favorite = Boolean(profile.preferences?.favorite); + const lifecycle = profile.technical?.lifecycle; + const lifecycleText = lifecycleLabel(lifecycle); + const custom = profile.origin === 'custom'; + const connected = linkedModelCount(profile); + + // Arrow keys move between tabs and select as they go, the usual tablist behaviour. + const onTabKeyDown = (event: KeyboardEvent) => { + const index = TABS.findIndex((item) => item.id === tab); + let next = index; + if (event.key === 'ArrowRight') next = (index + 1) % TABS.length; + else if (event.key === 'ArrowLeft') next = (index - 1 + TABS.length) % TABS.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = TABS.length - 1; + else return; + event.preventDefault(); + const nextTab = TABS[next].id; + onTabChange(nextTab); + tabRefs.current[nextTab]?.focus(); + }; + + return ( +
    +
    +
    +
    +

    + {profile.displayName} +

    +
    + {publisherLabel(profile.publisher)} + {custom ? 'Custom' : 'Built-in'} + {evidenceLabel(profile.evidence)} + {lifecycleText ? ( + {lifecycleText} + ) : null} + {profile.archived ? Archived : null} +
    +
    + +
    + + +
    + + +
    +
    +
    + + {profile.summary ? ( +

    {profile.summary}

    + ) : null} + +
    + + + {custom ? ( + <> + + + + {profile.archived ? ( + + + ) : null} +
    +
    + +
    + {TABS.map((item) => { + const selected = item.id === tab; + return ( + + ); + })} +
    + +
    + {tab === 'overview' ? : null} + {tab === 'capabilities' ? : null} + {tab === 'connections' ? ( + + ) : null} + {tab === 'evidence' ? : null} +
    +
    + ); +} diff --git a/application/v2_ui/src/components/admin/ModelCatalogEditor.tsx b/application/v2_ui/src/components/admin/ModelCatalogEditor.tsx new file mode 100644 index 000000000..bc9dacc1d --- /dev/null +++ b/application/v2_ui/src/components/admin/ModelCatalogEditor.tsx @@ -0,0 +1,217 @@ +// ModelCatalogEditor.tsx +// The form for a custom catalog profile. +// +// Field labels match the classic editor so the two interfaces describe a profile in the +// same words. On a wide pane the description lists, task ratings, and capability ratings +// lay out in columns, which keeps the whole profile on roughly one screen. + +import { useId, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Save } from 'lucide-react'; +import { + CATALOG_CAPABILITIES, + type CatalogProfileForm, + type TaskSuitability, +} from '../../lib/modelCatalog'; +import { GlassButton } from '../ui/primitives'; +import { inputClass } from './fields'; + +const SUITABILITY_CHOICES: ReadonlyArray<[TaskSuitability, string]> = [ + ['unknown', 'Unknown'], + ['suitable', 'Suitable'], + ['strong', 'Strong'], + ['unsuitable', 'Unsuitable'], +]; + +const CAPABILITY_CHOICES: ReadonlyArray<[string, string]> = [ + ['unknown', 'Unknown'], + ['true', 'Supported'], + ['false', 'Not supported'], +]; + +function Labelled({ id, label, children }: { id: string; label: string; children: ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +function Group({ legend, hint, children }: { legend: string; hint?: string; children: ReactNode }) { + return ( +
    + {legend} + {hint ?

    {hint}

    : null} +
    {children}
    +
    + ); +} + +export function ModelCatalogEditor({ + form, + tasks, + isNew, + onChange, + onSave, + onCancel, +}: { + form: CatalogProfileForm; + tasks: Record; + isNew: boolean; + onChange: (next: CatalogProfileForm) => void; + onSave: () => void; + onCancel: () => void; +}) { + const baseId = useId(); + const id = (name: string) => `${baseId}-${name}`; + const set = (key: K, value: CatalogProfileForm[K]) => + onChange({ ...form, [key]: value }); + + const textArea = (key: 'strengths' | 'limitations' | 'aliases' | 'sources', label: string) => ( + +