- Toggle (owner bug report): the ON disc sat flush against the pill's right edge — translate-x-4.5 left only a 1px sliver; now translate-x-4 for a 2px inner inset both states (off: 3px left inset; on: 3px right inset — measured live, symmetric). - Toggle colors (owner ask): ON is a gray track (muted-foreground) with a WHITE disc (foreground) — the full-white track was too loud; OFF stays the muted track with the gray disc. - Nav spacing (owner ask): gap-1 between the section nav buttons so the hover highlights no longer touch. - Role dropdowns (owner ask): the menus were clipped by the dialog's overflow frame — they now portal to document.body with fixed positioning against their trigger (AnchoredMenu), right-aligned, repositioned on scroll/resize, so they flow over the whole screen outside the settings dialog. Verified: a bottom-row menu extends 137px past the dialog's bottom edge; search + pick + Default-clear work through the portal; Esc and inside-dialog clicks close the menu but not the dialog. - Thinking trigger (owner ask): fixed w-20 — the width the Default state needs — so picking levels no longer shifts the row; the model trigger takes the same width as a minimum (its values vary in length). - Role hints (owner ask): ALWAYS visible and naming the actual default — 'Default: <model>'. The resolved per-role pins are not client-side yet: master routed rolePins to the Platform stream (GET /api/models extension, standalone commit); until that merge the rows read the 'config pin' fallback and light up on rebase. rolePins also lets the thinking list use the pinned model's real supportedLevels when it's in the catalog. Also: staging repair commit 270db53 — the round-1 dialog rewiring (sidebar row, chord, redirect, keyframes) was listed in 27d4f33's message but never staged; committed as catch-up. Live-verified on the worktree dev server. Gates: typecheck, lint, 701/701.
519 lines
19 KiB
TypeScript
519 lines
19 KiB
TypeScript
"use client";
|
|
/**
|
|
* The Agent section (settings-app wave): background-role model overrides,
|
|
* the tool toggles, and the prompt override switches — three GROUPS
|
|
* (owner, 2026-09-20: clear separation — Models / Tools / System prompts),
|
|
* ONE section (PUT /api/settings/agent replaces the whole agent object,
|
|
* so every change composes models+tools+prompts together from the record).
|
|
*
|
|
* The role selects (owner, 2026-09-20) are the EXACT same dropdown as the
|
|
* chat model selector — the shared ModelMenuList/ThinkingMenuList from
|
|
* ./model-menu.tsx (fuzzy search, arrow-walk, Enter pick). The collapsed
|
|
* trigger keeps the thin select look; the opened menu is the model
|
|
* picker's. Menus open DOWNWARD here (the dialog sits mid-screen); the
|
|
* chat control opens upward — the shell belongs to each mount, the body
|
|
* is shared.
|
|
*
|
|
* Tools — the owner's final list (2026-09-19): research, demo, visual, pdf.
|
|
* Absent key = enabled in the schema; the UI writes explicit booleans for
|
|
* all four (simpler to reason about, sanitize-equivalent).
|
|
*
|
|
* Models: each of the six background roles defaults to config.ts's pin
|
|
* (no override). The Default row clears the model override; the stored id
|
|
* stays VISIBLE even when the catalog no longer contains it — a trigger
|
|
* showing "Default" over a live override would lie about the record.
|
|
*
|
|
* Prompts: GET /api/settings/prompts segments; the switch requires an
|
|
* override FILE (route-enforced), so a file-less row dims whole-row
|
|
* (graph-view graying) until one is written. Switch state lives in this
|
|
* section's PUT; text writes through the segment route.
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import type React from "react";
|
|
import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, type ModelCatalogEntry } from "@/src/core";
|
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
import { prettyModel } from "@/components/usage-format";
|
|
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
|
|
|
/** The role's display label — schema ids are camelCase, the label is human. */
|
|
const ROLE_LABEL: Record<BackgroundModelRole, string> = {
|
|
researcher: "Researcher",
|
|
demoBuilder: "Demo builder",
|
|
visualMaker: "Visual maker",
|
|
extractor: "Extractor",
|
|
auditor: "Auditor",
|
|
labeler: "Labeler",
|
|
};
|
|
|
|
/** The owner's final toggleable-tool list (confirm with the user before
|
|
* changing — the Teacher stream settled this 2026-09-19). */
|
|
export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
|
|
|
const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
|
|
/** SegmentInfo as GET /api/settings/prompts returns it. */
|
|
interface SegmentInfo {
|
|
path: string;
|
|
sourceText: string;
|
|
overrideText: string | null;
|
|
enabled: boolean;
|
|
shared: boolean;
|
|
}
|
|
|
|
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
|
* Pure — exported for tests. */
|
|
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
|
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
|
}
|
|
|
|
/** Compose: one role's model/thinking override. Empty model = absent
|
|
* override (Default); empty thinking = thinking falls back. Pure — tests. */
|
|
export function composeRoleModel(
|
|
agent: AgentSettings | undefined,
|
|
role: BackgroundModelRole,
|
|
modelId: string,
|
|
thinking: string,
|
|
): AgentSettings {
|
|
const prev = agent?.models?.[role];
|
|
const models = { ...agent?.models };
|
|
if (!modelId && !thinking) delete models[role];
|
|
else {
|
|
models[role] = {
|
|
...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}),
|
|
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
|
};
|
|
}
|
|
return { ...agent, models };
|
|
}
|
|
|
|
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
|
|
* (absent = off is the schema's resting state; explicit false is the
|
|
* same thing but noisier). Pure — tests. */
|
|
export function composePromptSwitch(agent: AgentSettings | undefined, path: string, on: boolean): AgentSettings {
|
|
const prompts = { ...agent?.prompts };
|
|
if (on) prompts[path] = true;
|
|
else delete prompts[path];
|
|
return { ...agent, prompts };
|
|
}
|
|
|
|
/** The collapsed select look (owner: "the look of the collapsed menu is
|
|
* good") — now a button that opens the shared model-picker dropdown. */
|
|
function MenuTrigger({
|
|
value,
|
|
emptyLabel,
|
|
ariaLabel,
|
|
testid,
|
|
open,
|
|
onClick,
|
|
widthClass,
|
|
}: {
|
|
value: string;
|
|
/** What the trigger shows when there is no override ("Default"). */
|
|
emptyLabel: string;
|
|
ariaLabel: string;
|
|
testid: string;
|
|
open: boolean;
|
|
onClick: () => void;
|
|
/** Owner (2026-09-20): the thinking trigger is FIXED at the width the
|
|
* Default state needs (values shifting it mid-edit shifted the row);
|
|
* the model trigger takes the same width as a MINIMUM — its values
|
|
* genuinely vary in length. */
|
|
widthClass: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
aria-expanded={open}
|
|
data-testid={testid}
|
|
onClick={onClick}
|
|
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${widthClass}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
|
|
<span aria-hidden className="text-muted-foreground">
|
|
▾
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** The opened dropdown, PORTALED to document.body with fixed positioning:
|
|
* the dialog's overflow frame must not clip it — the owner wants the
|
|
* menus flowing over the whole screen, outside the settings surface
|
|
* (2026-09-20). Right-aligned to its trigger, repositioned while the
|
|
* dialog content scrolls under it. */
|
|
function AnchoredMenu({
|
|
anchorTestid,
|
|
menuClass,
|
|
testid,
|
|
children,
|
|
}: {
|
|
/** The trigger to hang from — its data-testid. */
|
|
anchorTestid: string;
|
|
/** The positioned menu's classes (width, padding, frame). */
|
|
menuClass: string;
|
|
testid: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [style, setStyle] = useState<{ right: number; top: number } | null>(null);
|
|
useEffect(() => {
|
|
const place = () => {
|
|
const anchor = document.querySelector(`[data-testid="${anchorTestid}"]`);
|
|
if (!anchor) return;
|
|
const r = anchor.getBoundingClientRect();
|
|
setStyle({ right: window.innerWidth - r.right, top: r.bottom + 4 });
|
|
};
|
|
place();
|
|
// Capture-phase: catches the dialog's inner content scroll.
|
|
window.addEventListener("scroll", place, true);
|
|
window.addEventListener("resize", place);
|
|
return () => {
|
|
window.removeEventListener("scroll", place, true);
|
|
window.removeEventListener("resize", place);
|
|
};
|
|
}, [anchorTestid]);
|
|
if (style === null) return null;
|
|
return createPortal(
|
|
<div
|
|
data-testid={testid}
|
|
data-lh-menu=""
|
|
style={{ position: "fixed", right: style.right, top: style.top, zIndex: 60 }}
|
|
className={menuClass}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function AgentSection({
|
|
agent,
|
|
onSectionChange,
|
|
}: {
|
|
agent: AgentSettings | undefined;
|
|
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
|
onSectionChange: (next: AgentSettings) => void;
|
|
}) {
|
|
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
|
/** The config-pinned model per background role — GET /api/models'
|
|
* rolePins (Platform stream, landing as a standalone commit; master
|
|
* routing 2026-09-20). Optional here: until the rebase the rows read
|
|
* "Default: config pin" and light up the moment the field arrives. */
|
|
const [rolePins, setRolePins] = useState<Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> | null>(null);
|
|
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
|
|
const [openSegment, setOpenSegment] = useState<string | null>(null);
|
|
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
|
|
const [segmentStatus, setSegmentStatus] = useState<string | null>(null);
|
|
/** One open dropdown for the whole section: which role, which kind. */
|
|
const [openMenu, setOpenMenu] = useState<{ role: BackgroundModelRole; kind: "model" | "thinking" } | null>(null);
|
|
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
|
|
|
// The catalog + the prompt segments — one fetch each per section mount.
|
|
useEffect(() => {
|
|
fetch("/api/models")
|
|
.then((r) => r.json())
|
|
.then((body: { models: ModelCatalogEntry[]; rolePins?: Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> }) => {
|
|
setCatalog(body.models);
|
|
if (body.rolePins) setRolePins(body.rolePins);
|
|
})
|
|
.catch(() => setCatalog(null));
|
|
fetch("/api/settings/prompts")
|
|
.then((r) => r.json())
|
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
|
.catch(() => setPrompts(null));
|
|
}, []);
|
|
|
|
// The dropdown contract (the chat model selector's, mirrored): any
|
|
// click outside the open menu — or an Esc — closes it. Esc is CLAIMED
|
|
// with stopPropagation so the settings dialog behind does NOT also
|
|
// close. A pointerdown on the backdrop is left for the dialog (its own
|
|
// click closes everything); a click inside the dialog just closes us.
|
|
useEffect(() => {
|
|
if (openMenu === null) return;
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
const target = e.target as Node;
|
|
if (menuAnchorRef.current?.contains(target)) return;
|
|
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
|
|
if (target instanceof Element && target.closest('[role="dialog"]')) {
|
|
setOpenMenu(null);
|
|
}
|
|
// else: outside the dialog — the dialog's own close handles it.
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
setOpenMenu(null);
|
|
}
|
|
};
|
|
document.addEventListener("pointerdown", onPointerDown);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [openMenu]);
|
|
|
|
async function saveSegment(path: string, text: string) {
|
|
setSegmentStatus(null);
|
|
const res = await fetch(`/api/settings/prompts/${path}`, {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ text }),
|
|
});
|
|
if (!res.ok) {
|
|
setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
|
return;
|
|
}
|
|
// Refetch: file presence gates the switch, so the view must track it.
|
|
fetch("/api/settings/prompts")
|
|
.then((r) => r.json())
|
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
|
|
setSegmentStatus(`Saved ${path}`);
|
|
}
|
|
|
|
async function deleteSegment(path: string) {
|
|
setSegmentStatus(null);
|
|
const res = await fetch(`/api/settings/prompts/${path}`, { method: "DELETE" });
|
|
if (!res.ok) {
|
|
setSegmentStatus(`Delete failed: ${res.status}`);
|
|
return;
|
|
}
|
|
fetch("/api/settings/prompts")
|
|
.then((r) => r.json())
|
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
|
|
}
|
|
|
|
/** The pinned Default row — clears this control's override (absent key
|
|
* = config pin). Same row language as the shared menu's list rows. */
|
|
const defaultRow = (active: boolean, onPick: () => void) => (
|
|
<button
|
|
type="button"
|
|
data-testid="menu-default"
|
|
onClick={onPick}
|
|
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
|
active ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
|
}`}
|
|
>
|
|
<span>Default</span>
|
|
{active && <span aria-hidden>✓</span>}
|
|
</button>
|
|
);
|
|
|
|
const segmentRow = (info: SegmentInfo) => {
|
|
const fileExists = info.overrideText !== null;
|
|
const open = openSegment === info.path;
|
|
return (
|
|
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
|
<SettingsRow
|
|
label={info.path}
|
|
hint={fileExists ? undefined : "no override file yet — write one below"}
|
|
testid={`prompt-row-${info.path}`}
|
|
dimmed={!fileExists}
|
|
>
|
|
<Toggle
|
|
checked={info.enabled && fileExists}
|
|
disabled={!fileExists}
|
|
label={`prompt ${info.path}`}
|
|
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
|
/>
|
|
</SettingsRow>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setOpenSegment(open ? null : info.path);
|
|
setSegmentStatus(null);
|
|
}}
|
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
|
>
|
|
{open ? "Hide text" : "Show text"}
|
|
</button>
|
|
{open && (
|
|
<div className="mt-1 flex flex-col gap-2">
|
|
{info.shared && (
|
|
<p className="text-xs text-muted-foreground/70">Shared base — appears in both agents.</p>
|
|
)}
|
|
<div>
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
|
|
<textarea
|
|
readOnly
|
|
value={info.sourceText}
|
|
aria-label={`source ${info.path}`}
|
|
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
|
|
<textarea
|
|
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
|
|
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
|
|
aria-label={`override ${info.path}`}
|
|
placeholder="Write the override text…"
|
|
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
|
|
className="text-xs text-foreground underline-offset-2 hover:underline"
|
|
>
|
|
Save override
|
|
</button>
|
|
{fileExists && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void deleteSegment(info.path)}
|
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
|
>
|
|
Delete override
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<SettingsGroup label="Models" testid="group-models">
|
|
{BACKGROUND_MODEL_ROLES.map((role) => {
|
|
const override = agent?.models?.[role];
|
|
const modelId = override?.model?.id ?? "";
|
|
const thinking = override?.thinkingLevel ?? "";
|
|
// The model whose levels govern the thinking list: the
|
|
// override, else the config pin (rolePins makes the
|
|
// default's levels resolvable when it's in the catalog);
|
|
// the standard ladder as the last fallback.
|
|
const effectiveId = modelId || rolePins?.[role]?.model.id || "";
|
|
const chosen = catalog?.find((m) => m.id === effectiveId);
|
|
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
|
// The row ALWAYS names the default (owner, 2026-09-20: the
|
|
// old "config pin applies" hint said nothing). rolePins
|
|
// carries the resolved default; the fallback reads until
|
|
// Platform's rolePins lands on main.
|
|
const defaultName = rolePins?.[role]?.model.id
|
|
? prettyModel(rolePins[role].model.id)
|
|
: "config pin";
|
|
const thisMenuOpen =
|
|
openMenu?.role === role ? openMenu.kind : null;
|
|
return (
|
|
<SettingsRow
|
|
key={role}
|
|
label={ROLE_LABEL[role]}
|
|
hint={`Default: ${defaultName}`}
|
|
testid={`role-row-${role}`}
|
|
>
|
|
{/* The anchor for the outside-click contract is the
|
|
span holding BOTH triggers of the open menu. */}
|
|
<span
|
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
|
className="relative flex items-center gap-2"
|
|
>
|
|
<MenuTrigger
|
|
value={modelId ? prettyModel(modelId) : ""}
|
|
emptyLabel="Default"
|
|
ariaLabel={`${ROLE_LABEL[role]} model`}
|
|
testid={`role-model-${role}`}
|
|
open={thisMenuOpen === "model"}
|
|
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
|
|
widthClass="min-w-20"
|
|
/>
|
|
<MenuTrigger
|
|
value={thinking}
|
|
emptyLabel="Default"
|
|
ariaLabel={`${ROLE_LABEL[role]} thinking`}
|
|
testid={`role-thinking-${role}`}
|
|
open={thisMenuOpen === "thinking"}
|
|
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
|
|
widthClass="w-20"
|
|
/>
|
|
{thisMenuOpen === "model" && (
|
|
<AnchoredMenu
|
|
anchorTestid={`role-model-${role}`}
|
|
testid={`role-model-menu-${role}`}
|
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
|
>
|
|
{defaultRow(
|
|
modelId === "",
|
|
() => {
|
|
onSectionChange(composeRoleModel(agent, role, "", thinking));
|
|
setOpenMenu(null);
|
|
},
|
|
)}
|
|
<ModelMenuList
|
|
models={catalog ?? undefined}
|
|
effectiveId={modelId || undefined}
|
|
onPick={(entry) => {
|
|
onSectionChange(composeRoleModel(agent, role, entry.id, thinking));
|
|
setOpenMenu(null);
|
|
}}
|
|
footer="Applied to every run of this role"
|
|
/>
|
|
</AnchoredMenu>
|
|
)}
|
|
{thisMenuOpen === "thinking" && (
|
|
<AnchoredMenu
|
|
anchorTestid={`role-thinking-${role}`}
|
|
testid={`role-thinking-menu-${role}`}
|
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
|
>
|
|
{defaultRow(
|
|
thinking === "",
|
|
() => {
|
|
onSectionChange(composeRoleModel(agent, role, modelId, ""));
|
|
setOpenMenu(null);
|
|
},
|
|
)}
|
|
<ThinkingMenuList
|
|
levels={levels.map(String)}
|
|
effectiveLevel={thinking || undefined}
|
|
onPick={(level) => {
|
|
onSectionChange(composeRoleModel(agent, role, modelId, level));
|
|
setOpenMenu(null);
|
|
}}
|
|
/>
|
|
</AnchoredMenu>
|
|
)}
|
|
</span>
|
|
</SettingsRow>
|
|
);
|
|
})}
|
|
</SettingsGroup>
|
|
|
|
<SettingsGroup label="Tools" testid="group-tools">
|
|
{TOGGLE_TOOLS.map((id) => (
|
|
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}>
|
|
<Toggle
|
|
checked={agent?.tools?.[id] ?? true}
|
|
label={`tool ${id}`}
|
|
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
|
/>
|
|
</SettingsRow>
|
|
))}
|
|
</SettingsGroup>
|
|
|
|
<SettingsGroup label="System prompts" testid="group-prompts">
|
|
{prompts === null ? (
|
|
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
{(["prober", "teacher"] as const).map((who) => (
|
|
<div key={who}>
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
|
|
<div className="flex flex-col gap-2">
|
|
{prompts[who].map((info) => segmentRow(info))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
|
|
</div>
|
|
)}
|
|
</SettingsGroup>
|
|
</>
|
|
);
|
|
} |