@@ -231,8 +244,17 @@ export default function Parameters() {
/** The cell owns the span, not the control. The definitions carry a
* columnSpan written for the four-column preset dialog, which says
* nothing about a panel this narrow. */
+ /** The cell stretches its control to the row, so a pair whose labels
+ * wrap differently still lines their inputs up. Applied here rather
+ * than in the shared controls, which the preset editors reuse. */
return (
-
+
{
});
});
+describe('isModified with an operator default', () => {
+ /** A deployment can give a text or tags parameter its own default, and clearing it is
+ * a change this conversation made. */
+ it('counts clearing a nonempty default as a change', () => {
+ expect(isModified(setting('promptPrefix', { default: 'Be brief' }), { promptPrefix: '' })).toBe(
+ true,
+ );
+ expect(isModified(setting('stop', { default: ['END'] }), { stop: [] })).toBe(true);
+ });
+
+ it('leaves an empty value untouched when the default is empty too', () => {
+ expect(isModified(setting('promptPrefix'), { promptPrefix: '' })).toBe(false);
+ expect(isModified(setting('stop', { default: [] }), { stop: [] })).toBe(false);
+ expect(isModified(setting('promptPrefix', { default: 'Be brief' }), {})).toBe(false);
+ });
+});
+
describe('hasControl', () => {
it('keeps a choice with options to pick from', () => {
expect(
diff --git a/client/src/components/SidePanel/Parameters/groups.ts b/client/src/components/SidePanel/Parameters/groups.ts
index 1cfb1028903..71b8abf1016 100644
--- a/client/src/components/SidePanel/Parameters/groups.ts
+++ b/client/src/components/SidePanel/Parameters/groups.ts
@@ -169,13 +169,21 @@ export function isModified(
conversation: Partial | Partial | null,
): boolean {
const value = conversation?.[setting.key as keyof typeof conversation];
- if (value == null || value === '') {
+ if (value == null) {
return false;
}
+ const fallback = setting.default;
+ /** Clearing a field reads as untouched only when there was nothing to clear. */
+ if (value === '') {
+ return fallback != null && fallback !== '';
+ }
if (Array.isArray(value)) {
- return value.length > 0 && JSON.stringify(value) !== JSON.stringify(setting.default);
+ if (value.length === 0) {
+ return Array.isArray(fallback) && fallback.length > 0;
+ }
+ return JSON.stringify(value) !== JSON.stringify(fallback);
}
- return value !== setting.default;
+ return value !== fallback;
}
export function countModified(
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index f044120df93..38d803a40d6 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1993,6 +1993,8 @@
"com_ui_output": "Output",
"com_ui_page": "Page",
"com_ui_parameters": "Parameters",
+ "com_ui_params_changed_count": "{{count}} changed settings",
+ "com_ui_params_changed_count_one": "{{count}} changed setting",
"com_ui_params_identity": "Identity",
"com_ui_params_limits": "Limits",
"com_ui_params_reasoning": "Reasoning",
From e6d7b228d600e5a9d01310f1aee7981a3a274022 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Mon, 28 Sep 2026 00:29:52 +0200
Subject: [PATCH 6/6] test(e2e): Scenario for the preset dialog's label spacing
---
.../scenarios/grouped-model-params.spec.ts | 80 ++++++++++++++++++-
1 file changed, 78 insertions(+), 2 deletions(-)
diff --git a/e2e/specs/mock/scenarios/grouped-model-params.spec.ts b/e2e/specs/mock/scenarios/grouped-model-params.spec.ts
index 738c843ad7e..6a5e472f487 100644
--- a/e2e/specs/mock/scenarios/grouped-model-params.spec.ts
+++ b/e2e/specs/mock/scenarios/grouped-model-params.spec.ts
@@ -1,13 +1,19 @@
+import { randomUUID } from 'node:crypto';
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { MOCK_ENDPOINTS, NEW_CHAT_PATH, selectMockEndpoint } from '../helpers';
+import type { MockEndpoint } from '../helpers';
import { openPanel } from './panels';
+import { withMongo } from '../db';
/** Mock Provider A takes Anthropic's parameter set, which carries the thinking and
* prompt cache controls this panel groups. */
-async function openParameters(page: Page): Promise {
+async function openParameters(
+ page: Page,
+ endpoint: MockEndpoint = MOCK_ENDPOINTS[0],
+): Promise {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
- await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+ await selectMockEndpoint(page, endpoint);
await openPanel(page, 'parameters', 'Parameters');
await expect(page.getByRole('region', { name: 'Sampling' })).toBeVisible();
}
@@ -29,6 +35,8 @@ test.describe('grouped model parameters', () => {
.first()
.click();
await expect(reasoning.getByText(/^1$/)).toBeVisible();
+ /* The numeral is decoration; the heading says what it counts. */
+ await expect(page.getByRole('region', { name: 'Reasoning 1 changed setting' })).toBeVisible();
});
test('a parameter stays on screen when the toggle it works with is off @scenario:params-stay-visible-when-companion-off', async ({
@@ -45,6 +53,74 @@ test.describe('grouped model parameters', () => {
await expect(reasoning.getByText('Thinking Budget')).toBeVisible();
});
+ test('the preset editor keeps each label beside its control @scenario:preset-dialog-controls-keep-labels-close', async ({
+ page,
+ }) => {
+ /* The harness defines model specs, which turn the presets menu off; the editor that
+ * reuses these controls sits behind it. */
+ await page.route(
+ (url) => url.pathname === '/api/config',
+ async (route) => {
+ const response = await route.fetch();
+ const body = await response.json();
+ await route.fulfill({
+ response,
+ json: { ...body, interface: { ...body.interface, presets: true, modelSelect: true } },
+ });
+ },
+ );
+ const title = `Params preset ${randomUUID().slice(0, 8)}`;
+ try {
+ /* Mock Provider B is a plain custom endpoint, so its preset editor is the OpenAI
+ * one, whose left column holds far fewer controls than its right. */
+ await openParameters(page, MOCK_ENDPOINTS[1]);
+ await page.getByRole('button', { name: 'Save As Preset' }).click();
+ const saveDialog = page.getByRole('dialog');
+ await saveDialog.getByRole('textbox').first().fill(title);
+ await saveDialog.getByRole('button', { name: 'Save' }).click();
+ await expect(saveDialog).toBeHidden();
+
+ /* On a phone the drawer holding the panel covers the header's presets button. */
+ if ((page.viewportSize()?.width ?? 1280) < 768) {
+ await page.getByTestId('close-sidebar-button').click();
+ }
+ await page.getByTestId('presets-button').first().click();
+ const presets = page.getByRole('dialog', { name: 'Presets' });
+ const item = presets.getByRole('button', { name: new RegExp(`^${title}`) });
+ /* Each run has its own database, so this preset is the only one listed. */
+ await item.hover();
+ await presets.getByRole('button', { name: 'Edit' }).first().click();
+ const editor = page.getByRole('dialog').filter({ has: page.getByRole('slider') });
+ await expect(editor.getByRole('slider').first()).toBeVisible();
+
+ /* A control stretched to its column's height would push its field far below its
+ * label. */
+ /* Each control's label and field share one hover card trigger; the field is its
+ * last child, whatever kind of control it is. */
+ const gaps = await editor.evaluate((element) =>
+ Array.from(element.querySelectorAll('label'))
+ .map((label) => {
+ const trigger = label.closest('[data-state]');
+ const field = trigger?.lastElementChild;
+ if (trigger == null || field == null || field.contains(label)) {
+ return null;
+ }
+ return {
+ label: label.textContent,
+ gap: field.getBoundingClientRect().top - label.getBoundingClientRect().bottom,
+ };
+ })
+ .filter((entry) => entry != null),
+ );
+ expect(gaps.length, JSON.stringify(gaps)).toBeGreaterThan(3);
+ for (const entry of gaps) {
+ expect(entry?.gap, JSON.stringify(entry)).toBeLessThan(48);
+ }
+ } finally {
+ await withMongo((db) => db.collection('presets').deleteMany({ title }));
+ }
+ });
+
test('Reset and Save As Preset stay inside the panel in a language with long labels @scenario:params-actions-fit-long-labels', async ({
page,
}) => {