feat(ui): collapsible provider tiles + ops + move Add provider to top

Reduces vertical footprint of the System-block Transform Pipeline card so
long pipelines (cc-bridge ships 9 ops) do not dominate the Routing tab.

- New Collapsible component (src/shared/components/Collapsible.tsx) used as
  a shared primitive. Open/closed state lives in local component state; does
  NOT persist across reloads (per UX brief: always-collapsed default).
- Each provider tile is now collapsible (closed by default).
- Each pipeline op inside a provider is collapsible (closed by default) —
  click to expand the per-kind editor.
- 'Add provider' Select+Button moved from BOTTOM to TOP of the card with a
  dashed border, so it is the first thing the user sees.
- Trailing controls (Toggle, Button) render as siblings of the toggle button
  (not nested), avoiding invalid <button> inside <button> HTML.

59/59 unit tests green.
This commit is contained in:
Mourad Maatoug
2026-05-15 23:47:36 +02:00
parent 79e19b5466
commit b653cfd160
3 changed files with 196 additions and 99 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Card, Input, Select, Toggle } from "@/shared/components";
import { Button, Card, Collapsible, Input, Select, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
import FallbackChainsEditor from "./FallbackChainsEditor";
import {
@@ -973,7 +973,42 @@ export default function RoutingTab() {
</div>
</div>
<div className="flex flex-col gap-5">
{/* Add provider — moved to TOP per UX brief. */}
<div className="mb-4 flex items-end gap-2 rounded-lg border border-dashed border-border/40 bg-surface/30 p-3">
<Select
label={t("systemTransformsAddProvider")}
value={newProviderId}
disabled={loading || availableProvidersToAdd.length === 0}
onChange={(e) => setNewProviderId(e.target.value)}
className="flex-1"
>
<option value="">
{availableProvidersToAdd.length === 0
? t("systemTransformsAddProviderAllConfigured")
: t("systemTransformsAddProviderPlaceholder")}
</option>
{availableProvidersToAdd.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</Select>
<Button
variant="secondary"
size="sm"
icon="add"
disabled={loading || !newProviderId || !!systemTransforms.providers[newProviderId]}
onClick={addProvider}
>
{t("systemTransformsAddProvider")}
</Button>
</div>
{Object.keys(systemTransforms.providers).length === 0 && (
<p className="text-sm text-text-muted py-2">{t("systemTransformsNoProviders")}</p>
)}
<div className="flex flex-col gap-3">
{Object.entries(systemTransforms.providers).map(([providerId, providerCfg]) => {
const isBuiltin = BUILTIN_PROVIDERS.has(providerId);
const display = PROVIDER_TILE_DISPLAY[providerId] ?? {
@@ -989,32 +1024,28 @@ export default function RoutingTab() {
(DEFAULT_SYSTEM_TRANSFORMS_CLIENT.providers as Record<string, unknown>)[providerId]
);
const isJsonOpen = showJsonEditor[providerId] ?? false;
const enabled = providerCfg.enabled !== false;
const selectedKind =
(addOpKind[providerId] as TransformOpKind | undefined) ??
"drop_paragraph_if_contains";
return (
<div
<Collapsible
key={providerId}
className="rounded-lg border border-border/50 bg-surface/20 p-4"
>
{/* Provider header row */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
{providerId}
</code>
<span className="text-sm font-medium">{display.name}</span>
</div>
<p className="text-xs text-text-muted mt-1">{display.description}</p>
<p className="text-[11px] text-text-muted mt-1">
{opCount} op{opCount === 1 ? "" : "s"}
</p>
defaultOpen={false}
title={
<div className="flex items-center gap-2 flex-wrap">
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
{providerId}
</code>
<span className="text-sm font-medium">{display.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
}
subtitle={`${opCount} op${opCount === 1 ? "" : "s"} · ${enabled ? "enabled" : "disabled"}`}
trailing={
<>
<Toggle
checked={providerCfg.enabled !== false}
checked={enabled}
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
disabled={loading}
ariaLabel={`Enable ${display.name} transforms`}
@@ -1030,59 +1061,65 @@ export default function RoutingTab() {
onClick={() => removeProvider(providerId)}
/>
)}
</div>
</div>
</>
}
>
<p className="text-xs text-text-muted mb-3">{display.description}</p>
{/* Pipeline op list with per-op editor */}
{/* Pipeline op list — each op is itself collapsible. */}
{opCount > 0 && (
<ol className="flex flex-col gap-2 mb-3">
{(providerCfg.pipeline as any[]).map((op, index) => (
<li
key={index}
className="rounded border border-border/30 bg-background/30 p-2 text-xs"
>
<div className="flex items-center gap-2 mb-1.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-[10px] font-semibold text-purple-400">
{index + 1}
</span>
<span className="font-mono text-purple-300 flex-1">{op?.kind}</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
icon="keyboard_arrow_up"
disabled={loading || index === 0}
aria-label={t("systemTransformsOpMoveUp")}
title={t("systemTransformsOpMoveUp")}
onClick={() => moveOp(providerId, index, -1)}
/>
<Button
variant="ghost"
size="sm"
icon="keyboard_arrow_down"
disabled={loading || index === opCount - 1}
aria-label={t("systemTransformsOpMoveDown")}
title={t("systemTransformsOpMoveDown")}
onClick={() => moveOp(providerId, index, 1)}
/>
<Button
variant="ghost"
size="sm"
icon="delete"
disabled={loading}
aria-label={t("systemTransformsOpDelete")}
title={t("systemTransformsOpDelete")}
onClick={() => deleteOp(providerId, index)}
/>
</div>
</div>
<div className="ml-7">
<li key={index}>
<Collapsible
variant="inline"
defaultOpen={false}
title={
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-[10px] font-semibold text-purple-400">
{index + 1}
</span>
<span className="font-mono text-purple-300 text-xs">{op?.kind}</span>
</div>
}
trailing={
<>
<Button
variant="ghost"
size="sm"
icon="keyboard_arrow_up"
disabled={loading || index === 0}
aria-label={t("systemTransformsOpMoveUp")}
title={t("systemTransformsOpMoveUp")}
onClick={() => moveOp(providerId, index, -1)}
/>
<Button
variant="ghost"
size="sm"
icon="keyboard_arrow_down"
disabled={loading || index === opCount - 1}
aria-label={t("systemTransformsOpMoveDown")}
title={t("systemTransformsOpMoveDown")}
onClick={() => moveOp(providerId, index, 1)}
/>
<Button
variant="ghost"
size="sm"
icon="delete"
disabled={loading}
aria-label={t("systemTransformsOpDelete")}
title={t("systemTransformsOpDelete")}
onClick={() => deleteOp(providerId, index)}
/>
</>
}
>
<OpEditor
op={op}
disabled={loading}
onChange={(next) => updateOp(providerId, index, next)}
/>
</div>
</Collapsible>
</li>
))}
</ol>
@@ -1171,45 +1208,11 @@ export default function RoutingTab() {
</div>
)}
</div>
</div>
</Collapsible>
);
})}
</div>
{Object.keys(systemTransforms.providers).length === 0 && (
<p className="text-sm text-text-muted py-2">{t("systemTransformsNoProviders")}</p>
)}
<div className="mt-4 flex items-end gap-2 border-t border-border/20 pt-4">
<Select
label={t("systemTransformsAddProvider")}
value={newProviderId}
disabled={loading || availableProvidersToAdd.length === 0}
onChange={(e) => setNewProviderId(e.target.value)}
className="flex-1"
>
<option value="">
{availableProvidersToAdd.length === 0
? t("systemTransformsAddProviderAllConfigured")
: t("systemTransformsAddProviderPlaceholder")}
</option>
{availableProvidersToAdd.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</Select>
<Button
variant="secondary"
size="sm"
icon="add"
disabled={loading || !newProviderId || !!systemTransforms.providers[newProviderId]}
onClick={addProvider}
>
{t("systemTransformsAddProvider")}
</Button>
</div>
<p className="mt-3 text-[11px] text-text-muted">
All transform ops are idempotent on re-run. Changes take effect immediately on the next
request.

View File

@@ -0,0 +1,93 @@
"use client";
import { useState, type ReactNode } from "react";
import { cn } from "@/shared/utils/cn";
interface CollapsibleProps {
/** Header content (always visible, click-to-toggle). */
title: ReactNode;
/** Optional secondary line under the title. */
subtitle?: ReactNode;
/** Material symbol name shown left of the title. */
icon?: string;
/** Trailing element rendered next to the chevron (badges, op counts, etc). */
trailing?: ReactNode;
/** Whether the section is open on first render. Defaults to false. */
defaultOpen?: boolean;
/** Visual variant. `default` for top-level sections; `inline` for nested rows. */
variant?: "default" | "inline";
/** Custom class for the wrapper. */
className?: string;
/** Content rendered when expanded. */
children: ReactNode;
}
/**
* Minimal click-to-expand section. Stateless from the caller's perspective
* (open/closed lives in local state — does NOT survive page refresh, per the
* UX brief). Uses material-symbols-outlined chevrons to match the rest of
* the OmniRoute UI.
*/
export default function Collapsible({
title,
subtitle,
icon,
trailing,
defaultOpen = false,
variant = "default",
className,
children,
}: CollapsibleProps) {
const [open, setOpen] = useState(defaultOpen);
const wrapperClasses = cn(
variant === "default"
? "rounded-lg border border-black/5 dark:border-white/5 bg-surface"
: "rounded-md border border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/[0.02]",
className
);
const headerRowClasses = cn(
"flex items-center gap-3",
variant === "default" ? "p-4" : "p-3",
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors",
open && "border-b border-black/5 dark:border-white/5"
);
// The chevron + title region is the click target. Trailing interactive
// controls (Toggle, Button) live OUTSIDE the toggle button so we never nest
// <button> inside <button> (invalid HTML; breaks keyboard nav + ARIA).
return (
<div className={wrapperClasses}>
<div className={headerRowClasses}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex items-center gap-3 flex-1 min-w-0 text-left -m-1 p-1 rounded"
>
<span
className="material-symbols-outlined text-text-muted text-[20px] shrink-0"
aria-hidden="true"
>
{open ? "expand_more" : "chevron_right"}
</span>
{icon && (
<span
className="material-symbols-outlined text-text-muted text-[18px] shrink-0"
aria-hidden="true"
>
{icon}
</span>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-main truncate">{title}</div>
{subtitle && <div className="text-xs text-text-muted truncate">{subtitle}</div>}
</div>
</button>
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
</div>
{open && <div className={variant === "default" ? "p-4" : "p-3"}>{children}</div>}
</div>
);
}

View File

@@ -3,6 +3,7 @@ export { default as Button } from "./Button";
export { default as Input } from "./Input";
export { default as Select } from "./Select";
export { default as Card } from "./Card";
export { default as Collapsible } from "./Collapsible";
export { default as Modal, ConfirmModal } from "./Modal";
export { default as Loading, Spinner, PageLoading, Skeleton, CardSkeleton } from "./Loading";
export { default as Avatar } from "./Avatar";