Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions apps/web/src/components/features/SettingsDialog.interaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ function input(label: string, index = 0) {
if (!element) throw new Error(`Missing input ${label}[${index}]`);
return element;
}
function modality(label: "Input modalities" | "Output modalities", value: "text" | "image" | "audio" | "video") {
const element = container.querySelector(`input[aria-label="${label}: ${value}"]`) as HTMLInputElement | null;
if (!element) throw new Error(`Missing modality ${label}: ${value}`);
return element;
}
function change(element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: string) {
act(() => {
const previous = element.value;
Expand Down Expand Up @@ -102,6 +107,20 @@ beforeEach(() => installDom());
afterEach(() => { act(() => root.unmount()); dom.window.close(); });

describe("SettingsDialog interactions", () => {
test("keeps the apply notice and Settings workspace inside one bounded column", () => {
const withNotice = structuredClone(snapshot);
withNotice.restartRequiredSections = ["mcp"];
act(() => root.render(<DialogRoot open><SettingsBody snapshot={withNotice} servers={{}} onReload={async () => {}} /></DialogRoot>));

const layout = container.querySelector("[data-settings-layout]") as HTMLElement;
const workspace = container.querySelector("[data-settings-workspace]") as HTMLElement;
expect(layout.className).toContain("flex-col");
expect(layout.className).toContain("h-full");
expect(workspace.className).toContain("flex-1");
expect(workspace.className).toContain("min-h-0");
expect(workspace.className).not.toContain("h-full");
});

test("opens the requested section and follows an external section change", () => {
act(() => root.render(<DialogRoot open><SettingsBody snapshot={snapshot} servers={{}} onReload={async () => {}} section="profiles" /></DialogRoot>));
expect(container.textContent).toContain("Principal, deep, and fast model bindings");
Expand Down Expand Up @@ -216,8 +235,25 @@ describe("SettingsDialog interactions", () => {
act(() => root.render(<DialogRoot open><SettingsBody snapshot={snapshot} servers={{}} onReload={async () => {}} /></DialogRoot>));
expect(input("Provider package").value).toBe("@ai-sdk/openai-compatible");
expect(input("Output limit").value).toBe("500");
expect(input("Input modalities").value).toBe("text");
expect(input("Output modalities").value).toBe("text");
expect(modality("Input modalities", "text").checked).toBe(true);
expect(modality("Input modalities", "image").checked).toBe(false);
expect(modality("Output modalities", "text").checked).toBe(true);
expect(modality("Output modalities", "image").checked).toBe(false);
expect(modality("Input modalities", "text").disabled).toBe(true);
});

test("selects model modalities without comma-delimited text editing", () => {
act(() => root.render(<DialogRoot open><SettingsBody snapshot={snapshot} servers={{}} onReload={async () => {}} /></DialogRoot>));

act(() => modality("Input modalities", "image").click());
expect(modality("Input modalities", "text").checked).toBe(true);
expect(modality("Input modalities", "text").disabled).toBe(false);
expect(modality("Input modalities", "image").checked).toBe(true);

act(() => modality("Input modalities", "text").click());
expect(modality("Input modalities", "text").checked).toBe(false);
expect(modality("Input modalities", "image").checked).toBe(true);
expect(modality("Input modalities", "image").disabled).toBe(true);
});

test("selects packages from the server catalog and preserves adapter-specific advanced options", () => {
Expand Down Expand Up @@ -263,7 +299,8 @@ describe("SettingsDialog interactions", () => {
act(() => root.render(<DialogRoot open><SettingsBody snapshot={snapshot} servers={{}} onReload={async () => {}} /></DialogRoot>));
click("Add provider");
await act(async () => { click("Save changes"); await Promise.resolve(); });
expect(container.textContent).toContain("Must be a URL");
expect(container.querySelector("footer [role=\"alert\"]")?.textContent)
.toBe("Configuration validation failed: Must be a URL");
expect(container.textContent).toContain("provider-2");
});

Expand Down Expand Up @@ -305,7 +342,7 @@ describe("SettingsDialog interactions", () => {
)) });
act(() => root.render(<DialogRoot open><SettingsBody snapshot={snapshot} servers={{}} onReload={async () => {}} /></DialogRoot>));
click("Memory");
const enabled = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
const enabled = container.querySelector('input[aria-label="Memory extraction"]') as HTMLInputElement;
act(() => enabled.click());
await act(async () => { click("Save changes"); await Promise.resolve(); });
expect(container.textContent).toContain("Restart required for: Memory");
Expand Down Expand Up @@ -396,6 +433,41 @@ describe("SettingsDialog interactions", () => {
expect([...(input("Variant") as HTMLSelectElement).querySelectorAll("option")].map((option) => option.value)).toContain("deep");
});

test("saves missing Profile variant references and marks Profiles for attention", async () => {
const referenced = structuredClone(snapshot);
referenced.config.profiles.principal.variant = "fast";
referenced.config.profiles.deep.variant = "fast";
let request: Record<string, unknown> | undefined;
Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async (_url: string, init?: RequestInit) => {
request = JSON.parse(String(init?.body));
const submitted = request?.config as typeof snapshot.config;
const response = successfulSaveResponse();
response.config.provider.local.models.demo.variants = structuredClone(submitted.provider.local.models.demo.variants);
response.config.profiles = structuredClone(submitted.profiles);
return Response.json(response);
}) });
act(() => root.render(<DialogRoot open><SettingsBody snapshot={referenced} servers={{}} onReload={async () => {}} /></DialogRoot>));

change(input("Variants JSON"), JSON.stringify({ high: { temperature: 0.2 } }));
const profilesButton = [...container.querySelectorAll("button")].find((button) => button.textContent === "Profiles");
expect(profilesButton?.getAttribute("data-invalid-count")).toBe("2");
expect(profilesButton?.getAttribute("aria-label")).toBe("Profiles, 2 variant references need attention");

click("Profiles");
expect(container.textContent).toContain('Variant "fast" no longer exists. This Profile is using the model default.');
expect(input("Variant").getAttribute("aria-invalid")).toBe("true");
expect(input("Variant").value).toBe("fast");
const principalSummary = [...container.querySelectorAll("summary")].find((summary) => summary.textContent?.includes("principal"));
expect(principalSummary?.getAttribute("aria-label")).toBe('principal, local:demo, variant "fast" is missing; using model default');

await act(async () => { click("Save changes"); await Promise.resolve(); });
const config = request?.config as typeof snapshot.config;
expect(config.profiles.principal.variant).toBe("fast");
expect(config.profiles.deep.variant).toBe("fast");
expect(profilesButton?.getAttribute("data-invalid-count")).toBe("2");
expect(input("Variant").value).toBe("fast");
});

test("locks secret-bearing identities and still renames entries without preserved secrets", () => {
act(() => root.render(<DialogRoot open><SettingsBody snapshot={snapshot} servers={{}} onReload={async () => {}} /></DialogRoot>));
expect((input("Provider ID") as HTMLInputElement).readOnly).toBe(true);
Expand Down
50 changes: 29 additions & 21 deletions apps/web/src/components/features/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ApiError } from "../../api/client";
import { getProviderAdapterCatalog, getServerConfig, saveServerConfig, toConfigDraft, type ServerConfigSnapshot } from "../../api/config";
import { useMcpStatusStore } from "../../store/mcp-status-store";
import { DialogContent, DialogDescription, DialogRoot, DialogTitle } from "../ui/Dialog";
import { cloneConfig, hasConfigChanges, toFieldErrors, type SettingsSection } from "./settings-helpers";
import { cloneConfig, hasConfigChanges, missingProfileVariants, toFieldErrors, type SettingsSection } from "./settings-helpers";
import { SettingsProfilesPanel, SettingsGithubPanel, SettingsMcpPanel, SettingsMemoryPanel, SettingsModelsPanel, SettingsNavigation } from "./settings-panels";
import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
import { SettingsUpdatesPanel } from "./SettingsUpdatesPanel";
Expand All @@ -22,7 +22,7 @@ const restartSectionLabels: Record<RestartRequiredSection, string> = {

export function SettingsApplyNotice({ modelsAppliedLive, restartRequiredSections }: { modelsAppliedLive: boolean; restartRequiredSections: readonly RestartRequiredSection[] }) {
if (!modelsAppliedLive && restartRequiredSections.length === 0) return null;
return <div role="status" className={`border-b px-5 py-2 text-sm ${restartRequiredSections.length > 0 ? "border-warning/30 bg-warning-muted text-warning" : "border-success/30 bg-success-muted text-success"}`}>
return <div role="status" className={`shrink-0 border-b px-5 py-2 text-sm ${restartRequiredSections.length > 0 ? "border-warning/30 bg-warning-muted text-warning" : "border-success/30 bg-success-muted text-success"}`}>
{modelsAppliedLive && <span>Model and Profile changes applied live.</span>}
{modelsAppliedLive && restartRequiredSections.length > 0 ? " " : null}
{restartRequiredSections.length > 0 && <span>Restart required for: {restartRequiredSections.map((section) => restartSectionLabels[section]).join(", ")}.</span>}
Expand Down Expand Up @@ -58,6 +58,7 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, sect
}, [requestedSection]);

const dirty = useMemo(() => hasConfigChanges(draft, snapshot.config), [draft, snapshot.config]);
const invalidProfileCount = useMemo(() => missingProfileVariants(draft).length, [draft]);
const onJsonValidationChange = useCallback((path: string, error?: string) => {
setJsonErrors((current) => {
if (error === undefined) {
Expand Down Expand Up @@ -89,10 +90,14 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, sect
await onReload();
setModelsAppliedLive(modelSettingsChanged);
} catch (error) {
setErrors(toFieldErrors(error));
const nextErrors = toFieldErrors(error);
setErrors(nextErrors);
const firstValidationMessage = Object.values(nextErrors)[0];
Comment thread
boh5 marked this conversation as resolved.
setSaveError(error instanceof ApiError && error.code === "CONFIG_REVISION_CONFLICT"
? "This configuration was changed elsewhere. Reload the latest version before saving."
: error instanceof Error ? error.message : "Unable to save settings");
: firstValidationMessage !== undefined
? `Configuration validation failed: ${firstValidationMessage}`
: error instanceof Error ? error.message : "Unable to save settings");
} finally {
setSaving(false);
}
Expand All @@ -103,21 +108,24 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, sect
onSectionChange?.(next);
};

return <>{section !== "updates" && <SettingsApplyNotice modelsAppliedLive={modelsAppliedLive} restartRequiredSections={restartRequiredSections} />}<div className="flex h-full min-h-0 flex-col sm:flex-row">
<SettingsSidebar section={section} onSelect={selectSection} />
{section === "updates"
? <main className="min-h-0 flex-1 overflow-y-auto bg-bg-base px-5 py-5 sm:px-6"><SettingsUpdatesPanel /></main>
: <fieldset data-settings-controls disabled={saving || reloading} className="contents">
<div className="flex min-h-0 flex-1 flex-col"><main className="min-h-0 flex-1 overflow-y-auto bg-bg-base px-5 py-5 sm:px-6">
<div hidden={section !== "models"}><SettingsModelsPanel config={draft} adapterCatalog={adapterCatalog} onChange={setDraft} errors={fieldErrors} onJsonValidationChange={onJsonValidationChange} jsonResetVersion={jsonResetVersion} /></div>
<div hidden={section !== "profiles"}><SettingsProfilesPanel config={draft} onChange={setDraft} errors={fieldErrors} onJsonValidationChange={onJsonValidationChange} jsonResetVersion={jsonResetVersion} /></div>
{section === "security" && <SettingsSecurityPanel onConfigChanged={onReload} />}
<div hidden={section !== "mcp"}><SettingsMcpPanel config={draft} servers={servers} onChange={setDraft} errors={errors} /></div>
<div hidden={section !== "memory"}><SettingsMemoryPanel config={draft} onChange={setDraft} errors={errors} /></div>
<div hidden={section !== "github"}><SettingsGithubPanel config={draft} onChange={setDraft} errors={errors} /></div>
</main><footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t border-border-subtle bg-bg-surface px-5 py-3">{saveError || reloadError ? <div role="alert" className="text-[11px] leading-4 text-error">{saveError ?? reloadError}</div> : <span className={`text-[11px] leading-4 ${hasJsonErrors ? "text-error" : dirty ? "text-warning" : "text-text-tertiary"}`}>{hasJsonErrors ? "Fix invalid JSON before saving" : dirty ? "Unsaved changes" : "All changes saved"}</span>}<div className="flex gap-2"><button type="button" onClick={() => { setModelsAppliedLive(false); void onReload(); }} className="h-8 rounded-sm bg-bg-active px-4 text-[12px] font-medium text-text-secondary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand">{reloading ? "Reloading…" : "Reload"}</button><button type="button" disabled={!dirty || saving || reloading || hasJsonErrors} onClick={() => { void save(); }} className="h-8 rounded-sm bg-brand px-4 text-[12px] font-medium text-bg-overlay transition-colors duration-[var(--motion-hover)] hover:bg-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40">{saving ? "Saving…" : "Save changes"}</button></div></footer></div>
</fieldset>}
</div></>;
return <div data-settings-layout className="flex h-full min-h-0 flex-col">
{section !== "updates" && <SettingsApplyNotice modelsAppliedLive={modelsAppliedLive} restartRequiredSections={restartRequiredSections} />}
<div data-settings-workspace className="flex min-h-0 flex-1 flex-col sm:flex-row">
<SettingsSidebar section={section} onSelect={selectSection} invalidProfileCount={invalidProfileCount} />
{section === "updates"
? <main className="min-h-0 flex-1 overflow-y-auto bg-bg-base px-5 py-5 sm:px-6"><SettingsUpdatesPanel /></main>
: <fieldset data-settings-controls disabled={saving || reloading} className="contents">
<div className="flex min-h-0 flex-1 flex-col"><main className="min-h-0 flex-1 overflow-y-auto bg-bg-base px-5 py-5 sm:px-6">
<div hidden={section !== "models"}><SettingsModelsPanel config={draft} adapterCatalog={adapterCatalog} onChange={setDraft} errors={fieldErrors} onJsonValidationChange={onJsonValidationChange} jsonResetVersion={jsonResetVersion} /></div>
<div hidden={section !== "profiles"}><SettingsProfilesPanel config={draft} onChange={setDraft} errors={fieldErrors} onJsonValidationChange={onJsonValidationChange} jsonResetVersion={jsonResetVersion} /></div>
{section === "security" && <SettingsSecurityPanel onConfigChanged={onReload} />}
<div hidden={section !== "mcp"}><SettingsMcpPanel config={draft} servers={servers} onChange={setDraft} errors={errors} /></div>
<div hidden={section !== "memory"}><SettingsMemoryPanel config={draft} onChange={setDraft} errors={errors} /></div>
<div hidden={section !== "github"}><SettingsGithubPanel config={draft} onChange={setDraft} errors={errors} /></div>
</main><footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t border-border-subtle bg-bg-surface px-5 py-3">{saveError || reloadError ? <div role="alert" className="text-[11px] leading-4 text-error">{saveError ?? reloadError}</div> : <span className={`text-[11px] leading-4 ${hasJsonErrors ? "text-error" : dirty ? "text-warning" : "text-text-tertiary"}`}>{hasJsonErrors ? "Fix invalid JSON before saving" : dirty ? "Unsaved changes" : "All changes saved"}</span>}<div className="flex gap-2"><button type="button" onClick={() => { setModelsAppliedLive(false); void onReload(); }} className="h-8 rounded-sm bg-bg-active px-4 text-[12px] font-medium text-text-secondary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand">{reloading ? "Reloading…" : "Reload"}</button><button type="button" disabled={!dirty || saving || reloading || hasJsonErrors} onClick={() => { void save(); }} className="h-8 rounded-sm bg-brand px-4 text-[12px] font-medium text-bg-overlay transition-colors duration-[var(--motion-hover)] hover:bg-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40">{saving ? "Saving…" : "Save changes"}</button></div></footer></div>
</fieldset>}
</div>
</div>;
}

export function SettingsDialog({ open, section = "models", onClose }: { open: boolean; section?: SettingsSection; onClose: () => void }) {
Expand Down Expand Up @@ -178,8 +186,8 @@ export function SettingsDialog({ open, section = "models", onClose }: { open: bo
: "Loading settings…"}</SettingsLoadState>}</DialogContent></DialogRoot>;
}

function SettingsSidebar({ section, onSelect }: { section: SettingsSection; onSelect: (section: SettingsSection) => void }) {
return <aside className="flex shrink-0 flex-col border-b border-border-subtle bg-bg-surface sm:w-52 sm:border-b-0 sm:border-r"><div className="border-b border-border-subtle px-4 py-4"><h2 className="text-[16px] font-semibold leading-[22px] text-text-primary">Settings</h2><p className="mt-1 text-[11px] leading-4 text-text-tertiary">Server and application</p></div><SettingsNavigation activeSection={section} onSelect={onSelect} /></aside>;
function SettingsSidebar({ section, onSelect, invalidProfileCount = 0 }: { section: SettingsSection; onSelect: (section: SettingsSection) => void; invalidProfileCount?: number }) {
return <aside className="flex shrink-0 flex-col border-b border-border-subtle bg-bg-surface sm:w-52 sm:border-b-0 sm:border-r"><div className="border-b border-border-subtle px-4 py-4"><h2 className="text-[16px] font-semibold leading-[22px] text-text-primary">Settings</h2><p className="mt-1 text-[11px] leading-4 text-text-tertiary">Server and application</p></div><SettingsNavigation activeSection={section} onSelect={onSelect} invalidProfileCount={invalidProfileCount} /></aside>;
}

function UpdatesOnlyWorkspace({ section, onSelect }: { section: SettingsSection; onSelect: (section: SettingsSection) => void }) {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/features/settings-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,36 @@ export const PROFILE_NAMES = [

export const BUILT_IN_MCP_NAMES = BUILTIN_MCP_SERVER_NAMES;

export interface MissingProfileVariant {
profile: typeof PROFILE_NAMES[number];
model: string;
variant: string;
}

export function missingProfileVariant(
config: ServerConfig,
profile: typeof PROFILE_NAMES[number],
): MissingProfileVariant | undefined {
const item = config.profiles[profile];
if (item.variant === undefined) return undefined;
const separator = item.model.indexOf(":");
if (separator < 0) return undefined;
const providerId = item.model.slice(0, separator);
const modelId = item.model.slice(separator + 1);
const model = config.provider[providerId]?.models[modelId];
if (!model || Object.prototype.hasOwnProperty.call(model.variants ?? {}, item.variant)) {
return undefined;
}
return { profile, model: item.model, variant: item.variant };
}

export function missingProfileVariants(config: ServerConfig): MissingProfileVariant[] {
return PROFILE_NAMES.flatMap((profile) => {
const issue = missingProfileVariant(config, profile);
return issue === undefined ? [] : [issue];
});
}

export function cloneConfig(config: ServerConfig): ServerConfig {
return structuredClone(config);
}
Expand Down
Loading
Loading