feat(settings): rebuild App Settings as a rail over one scrolling document

The modal had grown to 8 tabs that wrapped onto two rows on desktop and
became a horizontal scroller on phones, with a "Display" mega-tab holding
11 sections and ~35 controls. Local Echo sat 60% down it, and the model
settings were split across two tabs whose three controls fought each
other (the 1M Opus toggle's own hint said it was "ignored when a Claude
Model is selected above").

Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling
document: every section stays mounted, the rail follows the scroll, and
find-in-page works across the whole thing. Nine sections:

  Terminal & Input (Local Echo is the first row of the first section)
  Appearance, Header & Panels, Models, Agents & CLIs,
  Notifications, Voice, Shortcuts, System

Models are now one page. The picker is a card grid of BASE models with a
single "1M context window" switch; context becomes a property of the
chosen model and composes back into `claudeModel` as `base + [1m]`, which
retires the precedence trap. Thinking effort is a segmented control on
the same page, and the old Models tab (task routing) becomes a collapsed
Advanced block under it.

The 12 header-button toggles and the 8 panel toggles become chip grids,
which is most of the old Display tab reclaimed. Rows now say whether a
setting is per-device or synced, stated once per group.

Phones drop the rail for a sticky jump pill that names the current
section and opens a jump list, move Save into the header (the bottom
action bar cost 60px), and render groups as one inset rounded list with
hairline dividers instead of a stack of bordered cards.

Load and save are untouched: every control keeps its id, so
openAppSettings()/saveAppSettings() work as before. Model cards and the
effort segment are views over hidden <select>s that stay the source of
truth. test/app-settings-structure.test.ts pins that contract, plus the
rail hooks admin-ui.js injects the multi-user Users section into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:59:43 +02:00
parent c942bb5dfb
commit 45ad9de89e
10 changed files with 2397 additions and 863 deletions
+7 -6
View File
@@ -28,7 +28,7 @@ async function bootWith(me: Record<string, unknown>) {
const dom = new JSDOM(
`<!doctype html><body>
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
<div class="modal" id="appSettingsModal"><nav class="set-rail"><div class="set-rail-items"></div></nav><div class="set-doc" id="appSettingsDoc"></div></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
@@ -45,22 +45,23 @@ async function bootWith(me: Record<string, unknown>) {
}
describe('admin-ui boot', () => {
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
it('exposes the identity and injects the Users section for a multi-user admin', async () => {
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
const btn = win.document.querySelector('[data-tab="settings-users"]');
// The settings modal is a rail over one document: a rail entry, not a tab.
const btn = win.document.querySelector('[data-section="settings-users"]');
expect(btn).toBeTruthy();
expect(win.document.getElementById('settings-users')).toBeTruthy();
});
it('does NOT inject the Users tab for a regular user', async () => {
it('does NOT inject the Users section for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
});
it('does NOT inject the Users tab in single-user mode', async () => {
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
});
it('shows the change-password modal when mustChangePassword is set', async () => {
+102
View File
@@ -0,0 +1,102 @@
/**
* App Settings structural guard.
*
* The settings modal is a rail (table of contents) over ONE scrolling document.
* Its load/save path is pure `getElementById` by a fixed set of ids
* (openAppSettings / saveAppSettings in settings-ui.js), so a restructure of the
* markup that drops or renames an element does not fail loudly: the setting just
* silently stops loading, or stops being saved and falls back to its default.
*
* These tests read the REAL settings-ui.js and index.html and pin that contract.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
/** The App Settings modal markup, so assertions can't be satisfied elsewhere. */
function settingsModal(): string {
const start = html.indexOf('<div class="modal" id="appSettingsModal">');
expect(start).toBeGreaterThan(-1);
const end = html.indexOf('<!-- Shortcut Overlay Modal -->', start);
expect(end).toBeGreaterThan(start);
return html.slice(start, end);
}
/**
* Every id the load and save paths touch. Scoped to those two functions on
* purpose: settings-ui.js also drives elements that live OUTSIDE the modal
* (toasts, header chips), and those are not this file's contract.
*/
function referencedIds(): string[] {
const ids = new Set<string>();
for (const fn of ['openAppSettings()', 'async saveAppSettings()']) {
const start = settingsUi.indexOf(`\n ${fn} {`);
expect(start, `${fn} not found in settings-ui.js`).toBeGreaterThan(-1);
const body = settingsUi.slice(start, settingsUi.indexOf('\n },', start));
for (const m of body.matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
}
return [...ids];
}
describe('App Settings modal structure', () => {
it('keeps every element settings-ui.js loads or saves by id', () => {
const modal = settingsModal();
const missing = referencedIds().filter((id) => !modal.includes(`id="${id}"`));
expect(missing).toEqual([]);
});
it('carries every section the rail points at, exactly once', () => {
const modal = settingsModal();
const sections = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
expect(sections.length).toBeGreaterThanOrEqual(9);
for (const id of new Set(sections)) {
const hits = modal.split(`<section class="set-section" id="${id}"`).length - 1;
expect(hits, `section ${id} should exist exactly once`).toBe(1);
}
});
it('opens on Terminal & Input, so Local Echo is the first thing in reach', () => {
expect(settingsUi).toContain("this.switchSettingsTab('settings-terminal')");
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1;
expect(localEcho).toBeGreaterThan(-1);
expect(localEcho).toBeLessThan(cjk);
});
it('models: keeps the 1M variants as select options behind the context switch', () => {
const modal = settingsModal();
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards render the base models; the [1m] rows exist so that base + the
// context switch can compose back into a real claudeModel value.
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) {
expect(select).toContain(`value="${value}"`);
}
expect(select).toContain('data-ctx="1"');
expect(modal).toContain('id="appSettingsOpusContext1m"');
});
it('never hides sections behind .modal-tab-content (that class means display:none)', () => {
expect(settingsModal()).not.toContain('modal-tab-content');
});
it('leaves the shared modal tab classes to the other modals', () => {
// #sessionOptionsModal and #createCaseModal still use .modal-tabs; the
// settings rail must not restyle them out from under those.
expect(settingsModal()).not.toContain('class="modal-tabs"');
expect(html).toContain('<div class="modal-tabs">');
});
it('exposes the rail hooks admin-ui.js injects the Users section into', () => {
const modal = settingsModal();
expect(modal).toContain('class="set-rail-items"');
expect(modal).toContain('id="appSettingsDoc"');
const adminUi = readFileSync(resolve(publicDir, 'admin-ui.js'), 'utf8');
expect(adminUi).toContain('.set-rail-items');
expect(adminUi).toContain('.set-doc');
});
});
+25 -25
View File
@@ -274,41 +274,41 @@ describe('Run launch synchronization', () => {
});
describe('Codex quick start settings', () => {
it('renders Codex CLI settings in a dedicated app settings tab', () => {
it('renders Codex CLI settings in their own group inside Agents & CLIs', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
expect(html).toContain('data-tab="settings-codex">Codex CLI</button>');
// The settings modal is one scrolling document: Codex is a GROUP that can be
// hidden wholesale, not a tab (see _applyCodexSettingsVisibility).
const clis = html.match(/<section class="set-section" id="settings-clis"([\s\S]*?)<\/section>/);
expect(clis?.[1]).toBeTruthy();
const claudeTab = html.match(
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
);
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(claudeTab?.[1]).not.toContain('appSettingsCodexAnimations');
const codexGroup = clis![1].match(/id="appSettingsCodexGroup"([\s\S]*)$/);
expect(codexGroup?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexGroup?.[1]).toContain('appSettingsCodexAnimations');
expect(codexGroup?.[1]).not.toContain('appSettingsCodexRenderMode');
const codexTab = html.match(
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
);
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexTab?.[1]).toContain('appSettingsCodexAnimations');
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
// The Claude settings above it must not have absorbed the codex inputs.
const beforeCodex = clis![1].slice(0, clis![1].indexOf('id="appSettingsCodexGroup"'));
expect(beforeCodex).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(beforeCodex).not.toContain('appSettingsCodexAnimations');
});
describe('Codex CLI tab visibility', () => {
// Both settings on the tab are handed to `codex` at launch, so on an instance
// where the binary does not resolve the tab is a promise nothing can keep.
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
// so deleting the call in openAppSettings() is what it is meant to catch.
describe('Codex CLI group visibility', () => {
// Both settings in the group are handed to `codex` at launch, so on an
// instance where the binary does not resolve the group is a promise nothing
// can keep. renderIndexHtml injects window.__codemanCliAvailable; this pins
// the client half. Coupled test: it drives the REAL settings-ui.js against a
// stub element, so deleting the call in openAppSettings() is what it catches.
function loadSettingsUi(codexAvailable: boolean | undefined) {
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
const codexTabBtn = { id: 'appSettingsCodexGroup', style: { display: 'PRISTINE' } };
const CodemanApp = function CodemanApp(this: any) {};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: () => null,
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
getElementById: (id: string) => (id === 'appSettingsCodexGroup' ? codexTabBtn : null),
querySelector: () => null,
},
console,
});
@@ -319,19 +319,19 @@ describe('Codex quick start settings', () => {
return { app: new (CodemanApp as any)(), codexTabBtn };
}
it('hides the Codex tab when the codex binary is not available', () => {
it('hides the Codex group when the codex binary is not available', () => {
const { app, codexTabBtn } = loadSettingsUi(false);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('hides the Codex tab when the availability flag was never injected', () => {
it('hides the Codex group when the availability flag was never injected', () => {
const { app, codexTabBtn } = loadSettingsUi(undefined);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('shows the Codex tab when codex is available', () => {
it('shows the Codex group when codex is available', () => {
const { app, codexTabBtn } = loadSettingsUi(true);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('');
+4 -4
View File
@@ -32,8 +32,8 @@ describe('shortcut registry and overlay', () => {
expect(appSource).not.toContain("key: '/'");
});
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
it('exposes shortcut overrides in a dedicated App Settings shortcuts section', () => {
expect(htmlSource).toContain('data-section="settings-shortcuts"');
expect(htmlSource).toContain('id="settings-shortcuts"');
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
@@ -192,7 +192,7 @@ describe('shortcut settings persistence and capture', () => {
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
});
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
it('renders the shortcuts list when the Shortcuts settings section is reached', () => {
const { app, elements } = loadSettingsHarness();
elements.appSettingsModal = { querySelectorAll: () => [] };
app.renderShortcutSettingsList = vi.fn();
@@ -200,7 +200,7 @@ describe('shortcut settings persistence and capture', () => {
app.switchSettingsTab('settings-shortcuts');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
app.switchSettingsTab('settings-display');
app.switchSettingsTab('settings-terminal');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
});